diff options
| author | Mehmet Samet Duman <yongdohyun@projecttick.org> | 2026-04-05 17:37:54 +0300 |
|---|---|---|
| committer | Mehmet Samet Duman <yongdohyun@projecttick.org> | 2026-04-05 17:37:54 +0300 |
| commit | 32f5f761bc8e960293b4f4feaf973dd0da26d0f8 (patch) | |
| tree | 8d0436fdd093d5255c3b75e45f9741882b22e2e4 /docs/handbook/ci | |
| parent | 64f4ddfa97c19f371fe1847b20bd26803f0a25d5 (diff) | |
| download | Project-Tick-32f5f761bc8e960293b4f4feaf973dd0da26d0f8.tar.gz Project-Tick-32f5f761bc8e960293b4f4feaf973dd0da26d0f8.zip | |
NOISSUE Project Tick Handbook is Released!
Assisted-by: Claude:Opus-4.6-High
Signed-off-by: Mehmet Samet Duman <yongdohyun@projecttick.org>
Diffstat (limited to 'docs/handbook/ci')
| -rw-r--r-- | docs/handbook/ci/branch-strategy.md | 388 | ||||
| -rw-r--r-- | docs/handbook/ci/codeowners.md | 370 | ||||
| -rw-r--r-- | docs/handbook/ci/commit-linting.md | 418 | ||||
| -rw-r--r-- | docs/handbook/ci/formatting.md | 298 | ||||
| -rw-r--r-- | docs/handbook/ci/nix-infrastructure.md | 611 | ||||
| -rw-r--r-- | docs/handbook/ci/overview.md | 494 | ||||
| -rw-r--r-- | docs/handbook/ci/pr-validation.md | 378 | ||||
| -rw-r--r-- | docs/handbook/ci/rate-limiting.md | 321 |
8 files changed, 3278 insertions, 0 deletions
diff --git a/docs/handbook/ci/branch-strategy.md b/docs/handbook/ci/branch-strategy.md new file mode 100644 index 0000000000..89535c9f54 --- /dev/null +++ b/docs/handbook/ci/branch-strategy.md @@ -0,0 +1,388 @@ +# Branch Strategy + +## Overview + +The Project Tick monorepo uses a structured branch naming convention that enables +CI scripts to automatically classify branches, determine valid base branches for PRs, +and decide which checks to run. The classification logic lives in +`ci/supportedBranches.js`. + +--- + +## Branch Naming Convention + +### Format + +``` +prefix[-version[-suffix]] +``` + +Where: +- `prefix` — The branch type (e.g., `master`, `release`, `feature`) +- `version` — Optional semantic version (e.g., `1.0`, `2.5.1`) +- `suffix` — Optional additional descriptor (e.g., `pre`, `hotfix`) + +### Parsing Regex + +```javascript +/(?<prefix>[a-zA-Z-]+?)(-(?<version>\d+\.\d+(?:\.\d+)?)(?:-(?<suffix>.*))?)?$/ +``` + +This regex extracts three named groups: + +| Group | Description | Example: `release-2.5.1-hotfix` | +|-----------|----------------------------------|---------------------------------| +| `prefix` | Branch type identifier | `release` | +| `version` | Semantic version number | `2.5.1` | +| `suffix` | Additional descriptor | `hotfix` | + +### Parse Examples + +```javascript +split('master') +// { prefix: 'master', version: undefined, suffix: undefined } + +split('release-1.0') +// { prefix: 'release', version: '1.0', suffix: undefined } + +split('release-2.5.1') +// { prefix: 'release', version: '2.5.1', suffix: undefined } + +split('staging-1.0') +// { prefix: 'staging', version: '1.0', suffix: undefined } + +split('staging-next-1.0') +// { prefix: 'staging-next', version: '1.0', suffix: undefined } + +split('feature-new-ui') +// { prefix: 'feature', version: undefined, suffix: undefined } +// Note: "new-ui" doesn't match version pattern, so prefix consumes it + +split('fix-crash-on-start') +// { prefix: 'fix', version: undefined, suffix: undefined } + +split('backport-123-to-release-1.0') +// { prefix: 'backport', version: undefined, suffix: undefined } +// Note: "123-to-release-1.0" doesn't start with a version, so no match + +split('dependabot-npm') +// { prefix: 'dependabot', version: undefined, suffix: undefined } +``` + +--- + +## Branch Classification + +### Type Configuration + +```javascript +const typeConfig = { + master: ['development', 'primary'], + release: ['development', 'primary'], + staging: ['development', 'secondary'], + 'staging-next': ['development', 'secondary'], + feature: ['wip'], + fix: ['wip'], + backport: ['wip'], + revert: ['wip'], + wip: ['wip'], + dependabot: ['wip'], +} +``` + +### Branch Types + +| Prefix | Type Tags | Description | +|----------------|------------------------------|-------------------------------------| +| `master` | `development`, `primary` | Main development branch | +| `release` | `development`, `primary` | Release branches (e.g., `release-1.0`) | +| `staging` | `development`, `secondary` | Pre-release staging | +| `staging-next` | `development`, `secondary` | Next staging cycle | +| `feature` | `wip` | Feature development branches | +| `fix` | `wip` | Bug fix branches | +| `backport` | `wip` | Backport branches | +| `revert` | `wip` | Revert branches | +| `wip` | `wip` | Work-in-progress branches | +| `dependabot` | `wip` | Automated dependency updates | + +Any branch with an unrecognized prefix defaults to type `['wip']`. + +### Type Tag Meanings + +| Tag | Purpose | +|--------------|-------------------------------------------------------------| +| `development` | A long-lived branch that receives PRs | +| `primary` | The main target for new work (master or release branches) | +| `secondary` | A staging area — receives from primary, not from WIP directly | +| `wip` | A short-lived branch created for a specific task | + +--- + +## Order Configuration + +Branch ordering determines which branch is preferred when multiple branches are +equally good candidates as PR base branches: + +```javascript +const orderConfig = { + master: 0, + release: 1, + staging: 2, + 'staging-next': 3, +} +``` + +| Branch Prefix | Order | Preference | +|----------------|-------|------------------------------------------| +| `master` | 0 | Highest — default target for new work | +| `release` | 1 | Second — for release-specific changes | +| `staging` | 2 | Third — staging area | +| `staging-next` | 3 | Fourth — next staging cycle | +| All others | `Infinity` | Lowest — not considered as base branches | + +If two branches have the same number of commits ahead of a PR head, the one with +the lower order is preferred. This means `master` is preferred over `release-1.0` +when both are equally close. + +--- + +## Classification Function + +```javascript +function classify(branch) { + const { prefix, version } = split(branch) + return { + branch, + order: orderConfig[prefix] ?? Infinity, + stable: version != null, + type: typeConfig[prefix] ?? ['wip'], + version: version ?? 'dev', + } +} +``` + +### Output Fields + +| Field | Type | Description | +|----------|----------|------------------------------------------------------| +| `branch` | String | The original branch name | +| `order` | Number | Sort priority (lower = preferred as base) | +| `stable` | Boolean | `true` if the branch has a version suffix | +| `type` | Array | Type tags from `typeConfig` | +| `version` | String | Extracted version number, or `'dev'` if none | + +### Classification Examples + +```javascript +classify('master') +// { branch: 'master', order: 0, stable: false, type: ['development', 'primary'], version: 'dev' } + +classify('release-1.0') +// { branch: 'release-1.0', order: 1, stable: true, type: ['development', 'primary'], version: '1.0' } + +classify('release-2.5.1') +// { branch: 'release-2.5.1', order: 1, stable: true, type: ['development', 'primary'], version: '2.5.1' } + +classify('staging-1.0') +// { branch: 'staging-1.0', order: 2, stable: true, type: ['development', 'secondary'], version: '1.0' } + +classify('staging-next-1.0') +// { branch: 'staging-next-1.0', order: 3, stable: true, type: ['development', 'secondary'], version: '1.0' } + +classify('feature-new-ui') +// { branch: 'feature-new-ui', order: Infinity, stable: false, type: ['wip'], version: 'dev' } + +classify('fix-crash-on-start') +// { branch: 'fix-crash-on-start', order: Infinity, stable: false, type: ['wip'], version: 'dev' } + +classify('dependabot-npm') +// { branch: 'dependabot-npm', order: Infinity, stable: false, type: ['wip'], version: 'dev' } + +classify('wip-experiment') +// { branch: 'wip-experiment', order: Infinity, stable: false, type: ['wip'], version: 'dev' } + +classify('random-unknown-branch') +// { branch: 'random-unknown-branch', order: Infinity, stable: false, type: ['wip'], version: 'dev' } +``` + +--- + +## Branch Flow Model + +### Development Flow + +``` +┌─────────────────────────────────────────────┐ +│ master │ +│ (primary development, receives all work) │ +└──────────┬──────────────────────┬───────────┘ + │ fork │ fork + ▼ ▼ +┌──────────────────┐ ┌──────────────────────┐ +│ staging-X.Y │ │ release-X.Y │ +│ (secondary, │ │ (primary, │ +│ pre-release) │ │ stable release) │ +└──────────────────┘ └──────────────────────┘ +``` + +### WIP Branch Flow + +``` + master (or release-X.Y) + │ + ┌────┴────┐ + │ fork │ + ▼ │ + feature-* │ + fix-* │ + backport-* │ + wip-* │ + │ │ + └──── PR ─┘ + (merged back) +``` + +### Typical Branch Lifecycle + +1. **Create** — Developer creates `feature-my-change` from `master` +2. **Develop** — Commits follow Conventional Commits format +3. **PR** — Pull request targets `master` (or the appropriate release branch) +4. **CI Validation** — `prepare.js` classifies branches, `lint-commits.js` checks messages +5. **Review** — Code review by owners defined in `ci/OWNERS` +6. **Merge** — PR is merged into the target branch +7. **Cleanup** — The WIP branch is deleted + +--- + +## How CI Uses Branch Classification + +### Commit Linting Exemptions + +PRs between development branches skip commit linting: + +```javascript +if ( + baseBranchType.includes('development') && + headBranchType.includes('development') && + pr.base.repo.id === pr.head.repo?.id +) { + core.info('This PR is from one development branch to another. Skipping checks.') + return +} +``` + +Exempted transitions: +- `staging` → `master` +- `staging-next` → `staging` +- `release-X.Y` → `master` + +### Base Branch Suggestion + +For WIP branches, `prepare.js` finds the optimal base: + +1. Start with `master` as a candidate +2. Compare commit distances to all `release-*` branches (sorted newest first) +3. The branch with fewest commits ahead is the best candidate +4. On ties, lower `order` wins (master > release > staging) + +### Release Branch Targeting Warning + +When a non-backport/fix/revert branch targets a release branch: + +``` +Warning: This PR targets release branch `release-1.0`. +New features should typically target `master`. +``` + +--- + +## Version Extraction + +The `stable` flag and `version` field enable version-aware CI decisions: + +| Branch | `stable` | `version` | Interpretation | +|-------------------|----------|-----------|--------------------------------| +| `master` | `false` | `'dev'` | Development, no specific version | +| `release-1.0` | `true` | `'1.0'` | Release 1.0 | +| `release-2.5.1` | `true` | `'2.5.1'` | Release 2.5.1 | +| `staging-1.0` | `true` | `'1.0'` | Staging for release 1.0 | +| `feature-foo` | `false` | `'dev'` | WIP, no version association | + +Release branches are sorted by version (descending) when computing base branch +suggestions, so `release-2.0` is checked before `release-1.0`. + +--- + +## Module Exports + +The `supportedBranches.js` module exports two functions: + +```javascript +module.exports = { classify, split } +``` + +| Function | Purpose | +|-----------|----------------------------------------------------------| +| `classify` | Full classification: type tags, order, stability, version| +| `split` | Parse branch name into prefix, version, suffix | + +These are imported by: +- `ci/github-script/lint-commits.js` — For commit linting exemptions +- `ci/github-script/prepare.js` — For branch targeting validation + +--- + +## Self-Testing + +When `supportedBranches.js` is run directly (not imported as a module), it executes +built-in tests: + +```bash +cd ci/ +node supportedBranches.js +``` + +Output: + +``` +split(branch) +master { prefix: 'master', version: undefined, suffix: undefined } +release-1.0 { prefix: 'release', version: '1.0', suffix: undefined } +release-2.5.1 { prefix: 'release', version: '2.5.1', suffix: undefined } +staging-1.0 { prefix: 'staging', version: '1.0', suffix: undefined } +staging-next-1.0 { prefix: 'staging-next', version: '1.0', suffix: undefined } +feature-new-ui { prefix: 'feature', version: undefined, suffix: undefined } +fix-crash-on-start { prefix: 'fix', version: undefined, suffix: undefined } +... + +classify(branch) +master { branch: 'master', order: 0, stable: false, type: ['development', 'primary'], version: 'dev' } +release-1.0 { branch: 'release-1.0', order: 1, stable: true, type: ['development', 'primary'], version: '1.0' } +... +``` + +--- + +## Adding New Branch Types + +To add a new branch type: + +1. Add the prefix and type tags to `typeConfig`: + +```javascript +const typeConfig = { + // ... existing entries ... + 'hotfix': ['wip'], // or ['development', 'primary'] if it's a long-lived branch +} +``` + +2. If it should be a base branch candidate, add it to `orderConfig`: + +```javascript +const orderConfig = { + // ... existing entries ... + hotfix: 4, // lower number = higher preference +} +``` + +3. Update the self-tests at the bottom of the file. diff --git a/docs/handbook/ci/codeowners.md b/docs/handbook/ci/codeowners.md new file mode 100644 index 0000000000..0054a168f1 --- /dev/null +++ b/docs/handbook/ci/codeowners.md @@ -0,0 +1,370 @@ +# CODEOWNERS + +## Overview + +Project Tick uses a code ownership system based on the `ci/OWNERS` file. This file +follows the same syntax as GitHub's native `CODEOWNERS` file but is stored in a +custom location and validated by a patched version of the +[codeowners-validator](https://github.com/mszostok/codeowners-validator) tool. + +The OWNERS file serves two purposes: +1. **Automated review routing** — PR authors know who to request reviews from +2. **Structural validation** — CI checks that referenced paths and users exist + +--- + +## File Location and Format + +### Location + +``` +ci/OWNERS +``` + +Unlike GitHub's native CODEOWNERS (which must be in `.github/CODEOWNERS`, +`CODEOWNERS`, or `docs/CODEOWNERS`), Project Tick stores ownership data in +`ci/OWNERS` to keep CI infrastructure colocated. + +### Syntax + +The file uses CODEOWNERS syntax: + +``` +# Comments start with # +# Pattern followed by one or more @owner references +/path/pattern/ @owner1 @owner2 +``` + +### Header + +``` +# This file describes who owns what in the Project Tick CI infrastructure. +# Users/teams will get review requests for PRs that change their files. +# +# This file uses the same syntax as the natively supported CODEOWNERS file, +# see https://help.github.com/articles/about-codeowners/ for documentation. +# +# Validated by ci/codeowners-validator. +``` + +--- + +## Ownership Map + +The OWNERS file maps every major directory and subdirectory in the monorepo to +code owners. Below is the complete ownership mapping: + +### GitHub Infrastructure + +``` +/.github/actions/change-analysis/ @YongDo-Hyun +/.github/actions/meshmc/package/ @YongDo-Hyun +/.github/actions/meshmc/setup-dependencies/ @YongDo-Hyun +/.github/actions/mnv/test_artefacts/ @YongDo-Hyun +/.github/codeql/ @YongDo-Hyun +/.github/ISSUE_TEMPLATE/ @YongDo-Hyun +/.github/workflows/ @YongDo-Hyun +``` + +### Archived Projects + +``` +/archived/projt-launcher/ @YongDo-Hyun +/archived/projt-minicraft-modpack/ @YongDo-Hyun +/archived/projt-modpack/ @YongDo-Hyun +/archived/ptlibzippy/ @YongDo-Hyun +``` + +### Core Projects + +``` +/cgit/* @YongDo-Hyun +/cgit/contrib/* @YongDo-Hyun +/cgit/contrib/hooks/ @YongDo-Hyun +/cgit/filters/ @YongDo-Hyun +/cgit/tests/ @YongDo-Hyun + +/cmark/* @YongDo-Hyun +/cmark/api_test/ @YongDo-Hyun +/cmark/bench/ @YongDo-Hyun +/cmark/cmake/ @YongDo-Hyun +/cmark/data/ @YongDo-Hyun +/cmark/fuzz/ @YongDo-Hyun +/cmark/man/ @YongDo-Hyun +/cmark/src/ @YongDo-Hyun +/cmark/test/ @YongDo-Hyun +/cmark/tools/ @YongDo-Hyun +/cmark/wrappers/ @YongDo-Hyun +``` + +### Corebinutils (every utility individually owned) + +``` +/corebinutils/* @YongDo-Hyun +/corebinutils/cat/ @YongDo-Hyun +/corebinutils/chflags/ @YongDo-Hyun +/corebinutils/chmod/ @YongDo-Hyun +/corebinutils/contrib/* @YongDo-Hyun +/corebinutils/contrib/libc-vis/ @YongDo-Hyun +/corebinutils/contrib/libedit/ @YongDo-Hyun +/corebinutils/contrib/printf/ @YongDo-Hyun +/corebinutils/cp/ @YongDo-Hyun +... +/corebinutils/uuidgen/ @YongDo-Hyun +``` + +### Other Projects + +``` +/forgewrapper/* @YongDo-Hyun +/forgewrapper/gradle/ @YongDo-Hyun +/forgewrapper/jigsaw/ @YongDo-Hyun +/forgewrapper/src/ @YongDo-Hyun + +/genqrcode/* @YongDo-Hyun +/genqrcode/cmake/ @YongDo-Hyun +/genqrcode/tests/ @YongDo-Hyun +/genqrcode/use/ @YongDo-Hyun + +/hooks/ @YongDo-Hyun +/images4docker/ @YongDo-Hyun + +/json4cpp/* @YongDo-Hyun +/json4cpp/.reuse/ @YongDo-Hyun +/json4cpp/cmake/ @YongDo-Hyun +/json4cpp/docs/ @YongDo-Hyun +/json4cpp/include/* @YongDo-Hyun +... + +/libnbtplusplus/* @YongDo-Hyun +/libnbtplusplus/include/* @YongDo-Hyun +... + +/LICENSES/ @YongDo-Hyun + +/meshmc/* @YongDo-Hyun +/meshmc/branding/ @YongDo-Hyun +/meshmc/buildconfig/ @YongDo-Hyun +/meshmc/cmake/* @YongDo-Hyun +/meshmc/launcher/* @YongDo-Hyun +... +``` + +--- + +## Pattern Syntax + +### Glob Rules + +| Pattern | Matches | +|---------------|------------------------------------------------------| +| `/path/` | All files directly under `path/` | +| `/path/*` | All files directly under `path/` (explicit) | +| `/path/**` | All files recursively under `path/` | +| `*.js` | All `.js` files everywhere | +| `/path/*.md` | All `.md` files directly under `path/` | + +### Ownership Resolution + +When multiple patterns match a file, the **last matching rule** wins (just like +Git's `.gitignore` and GitHub's native CODEOWNERS): + +``` +/meshmc/* @teamA # Matches all direct files +/meshmc/launcher/* @teamB # More specific — wins for launcher files +``` + +A PR modifying `meshmc/launcher/main.cpp` would require review from `@teamB`. + +### Explicit Directory Listing + +The OWNERS file explicitly lists individual subdirectories rather than using `**` +recursive globs. This is intentional: + +1. **Precision** — Each directory has explicit ownership +2. **Validation** — The codeowners-validator checks that each listed path exists +3. **Documentation** — The file serves as a directory map of the monorepo + +--- + +## Validation + +### codeowners-validator + +The CI runs a patched version of `codeowners-validator` against the OWNERS file. +The tool is built from source with Project Tick–specific patches. + +#### What It Validates + +| Check | Description | +|-------------------------|------------------------------------------------| +| **Path existence** | All paths in OWNERS exist in the repository | +| **User/team existence** | All `@` references are valid GitHub users/teams| +| **Syntax** | Pattern syntax is valid CODEOWNERS format | +| **No orphaned patterns** | Patterns match at least one file | + +#### Custom Patches + +Two patches are applied to the upstream validator: + +**1. Custom OWNERS file path** (`owners-file-name.patch`) + +```go +func openCodeownersFile(dir string) (io.Reader, error) { + if file, ok := os.LookupEnv("OWNERS_FILE"); ok { + return fs.Open(file) + } + // ... default CODEOWNERS paths +} +``` + +Set `OWNERS_FILE=ci/OWNERS` to validate the custom location. + +**2. Removed write-access requirement** (`permissions.patch`) + +GitHub's native CODEOWNERS requires that listed users have write access to the +repository. Project Tick's OWNERS file is used for review routing, not branch +protection, so this check is removed: + +```go +// Before: required push permission +if t.Permissions["push"] { return nil } +return newValidateError("Team cannot review PRs...") + +// After: any team membership is sufficient +return nil +``` + +Also removes the `github.ScopeReadOrg` requirement from required OAuth scopes, +allowing the validator to work with tokens generated for GitHub Apps. + +### Running Validation Locally + +```bash +cd ci/ +nix-shell # enters the CI dev shell with codeowners-validator available + +# Set the custom OWNERS file path: +export OWNERS_FILE=ci/OWNERS + +# Run validation: +codeowners-validator +``` + +Or build and run directly: + +```bash +nix-build ci/ -A codeownersValidator +OWNERS_FILE=ci/OWNERS ./result/bin/codeowners-validator +``` + +--- + +## MAINTAINERS File Relationship + +In addition to `ci/OWNERS`, individual projects may have a `MAINTAINERS` file +(e.g., `archived/projt-launcher/MAINTAINERS`): + +``` +# MAINTAINERS +# +# Fields: +# - Name: Display name +# - GitHub: GitHub handle (with @) +# - Email: Primary contact email +# - Paths: Comma-separated glob patterns (repo-relative) + +[Mehmet Samet Duman] +GitHub: @YongDo-Hyun +Email: yongdohyun@mail.projecttick.org +Paths: ** +``` + +The `MAINTAINERS` file provides additional metadata (email, display name) that +`OWNERS` doesn't support. The two files serve complementary purposes: + +| File | Purpose | Format | +|--------------|--------------------------------------|-------------------| +| `ci/OWNERS` | Automated review routing via CI | CODEOWNERS syntax | +| `MAINTAINERS`| Human-readable contact information | INI-style blocks | + +--- + +## Review Requirements + +### How Reviews Are Triggered + +When a PR modifies files matching an OWNERS pattern: + +1. The workflow identifies which owners are responsible for the changed paths +2. Review requests are sent to the matching owners +3. At least one approving review from a code owner is typically required before merge + +### Bot-Managed Reviews + +The CI bot (`github-actions[bot]`) manages automated reviews via `ci/github-script/reviews.js`: +- Reviews are tagged with a `reviewKey` comment for identification +- When issues are resolved, bot reviews are automatically dismissed or minimized +- The `CHANGES_REQUESTED` state blocks merge until the review is dismissed + +--- + +## Adding Ownership Entries + +### For a New Project Directory + +1. Add ownership patterns to `ci/OWNERS`: + +``` +/newproject/* @owner-handle +/newproject/src/ @owner-handle +/newproject/tests/ @owner-handle +``` + +2. List every subdirectory explicitly (not just the top-level with `**`) + +3. Run the validator locally: + +```bash +cd ci/ +nix-shell +OWNERS_FILE=ci/OWNERS codeowners-validator +``` + +4. Commit with a CI scope: + +``` +ci(repo): add ownership for newproject +``` + +### For a New Team or User + +Simply reference the new `@handle` in the ownership patterns: + +``` +/some/path/ @existing-owner @new-owner +``` + +The validator will check that `@new-owner` exists in the GitHub organization. + +--- + +## Limitations + +### No Recursive Globs in Current File + +The current OWNERS file uses explicit directory listings rather than `/**` recursive +globs. This means: +- New subdirectories must be manually added to OWNERS +- Deeply nested directories need their own entries +- The file can grow large for projects with many subdirectories + +### Single Organization Scope + +All `@` references must be members of the repository's GitHub organization, +or GitHub users with access to the repository. + +### No Per-File Patterns + +The file doesn't currently use file-level patterns (e.g., `*.nix @nix-team`). +Ownership is assigned at the directory level. diff --git a/docs/handbook/ci/commit-linting.md b/docs/handbook/ci/commit-linting.md new file mode 100644 index 0000000000..9b8e9cc97d --- /dev/null +++ b/docs/handbook/ci/commit-linting.md @@ -0,0 +1,418 @@ +# Commit Linting + +## Overview + +Project Tick enforces the [Conventional Commits](https://www.conventionalcommits.org/) +specification for all commit messages. The commit linter (`ci/github-script/lint-commits.js`) +runs automatically on every pull request to validate that every commit follows the required +format. + +This ensures: +- Consistent, machine-readable commit history +- Automated changelog generation potential +- Clear communication of change intent (feature, fix, refactor, etc.) +- Monorepo-aware scoping that maps commits to project directories + +--- + +## Commit Message Format + +### Structure + +``` +type(scope): subject +``` + +### Examples + +``` +feat(mnv): add new keybinding support +fix(meshmc): resolve crash on startup +ci(neozip): update build matrix +docs(cmark): fix API reference +refactor(corebinutils): simplify ls output logic +chore(deps): bump tomlplusplus to v4.0.0 +revert(forgewrapper): undo jigsaw module changes +``` + +### Rules + +| Rule | Requirement | +|-------------------------------|----------------------------------------------------------| +| **Type** | Must be one of the supported types (see below) | +| **Scope** | Optional, but should match a known project directory | +| **Subject** | Must follow the type/scope with `: ` (colon + space) | +| **Trailing period** | Subject must NOT end with a period | +| **Subject case** | Subject should start with a lowercase letter (warning) | +| **No fixup/squash commits** | `fixup!`, `squash!`, `amend!` prefixes are rejected | +| **Breaking changes** | Use `!` after type/scope: `feat(mnv)!: remove API` | + +--- + +## Supported Types + +The following Conventional Commit types are recognized: + +```javascript +const CONVENTIONAL_TYPES = [ + 'build', + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'style', + 'test', +] +``` + +| Type | Use When | +|-----------|-------------------------------------------------------------| +| `build` | Changes to the build system or external dependencies | +| `chore` | Routine tasks, no production code change | +| `ci` | CI configuration files and scripts | +| `docs` | Documentation only changes | +| `feat` | A new feature | +| `fix` | A bug fix | +| `perf` | A performance improvement | +| `refactor`| Code change that neither fixes a bug nor adds a feature | +| `revert` | Reverts a previous commit | +| `style` | Formatting, semicolons, whitespace (no code change) | +| `test` | Adding or correcting tests | + +--- + +## Known Scopes + +Scopes correspond to directories in the Project Tick monorepo: + +```javascript +const KNOWN_SCOPES = [ + 'archived', + 'cgit', + 'ci', + 'cmark', + 'corebinutils', + 'forgewrapper', + 'genqrcode', + 'hooks', + 'images4docker', + 'json4cpp', + 'libnbtplusplus', + 'meshmc', + 'meta', + 'mnv', + 'neozip', + 'tomlplusplus', + 'repo', + 'deps', +] +``` + +### Special Scopes + +| Scope | Meaning | +|----------|----------------------------------------------------| +| `repo` | Changes affecting the repository as a whole | +| `deps` | Dependency updates not scoped to a single project | + +### Unknown Scope Handling + +Using an unknown scope generates a **warning** (not an error): + +``` +Commit abc123456789: scope "myproject" is not a known project. +Known scopes: archived, cgit, ci, cmark, ... +``` + +This allows new scopes to be introduced before updating the linter. + +--- + +## Validation Logic + +### Regex Pattern + +The commit message is validated against this regex: + +```javascript +const conventionalRegex = new RegExp( + `^(${CONVENTIONAL_TYPES.join('|')})(\\(([^)]+)\\))?(!)?: .+$`, +) +``` + +Expanded, this matches: + +``` +^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test) # type +(\(([^)]+)\))? # optional (scope) +(!)? # optional breaking change marker +: .+$ # colon, space, and subject +``` + +### Validation Order + +For each commit in the PR: + +1. **Check for fixup/squash/amend** — If the message starts with `amend!`, `fixup!`, or + `squash!`, the commit fails immediately. These commits should be rebased before merging: + + ```javascript + const fixups = ['amend!', 'fixup!', 'squash!'] + if (fixups.some((s) => msg.startsWith(s))) { + core.error( + `${logPrefix}: starts with "${fixups.find((s) => msg.startsWith(s))}". ` + + 'Did you forget to run `git rebase -i --autosquash`?', + ) + failures.add(commit.sha) + continue + } + ``` + +2. **Check Conventional Commits format** — If the regex doesn't match, the commit fails: + + ```javascript + if (!conventionalRegex.test(msg)) { + core.error( + `${logPrefix}: "${msg}" does not follow Conventional Commits format. ` + + 'Expected: type(scope): subject (e.g. "feat(mnv): add keybinding")', + ) + failures.add(commit.sha) + continue + } + ``` + +3. **Check trailing period** — Subjects ending with `.` fail: + + ```javascript + if (msg.endsWith('.')) { + core.error(`${logPrefix}: subject should not end with a period.`) + failures.add(commit.sha) + } + ``` + +4. **Warn on unknown scope** — Non-standard scopes produce a warning: + + ```javascript + if (scope && !KNOWN_SCOPES.includes(scope)) { + core.warning( + `${logPrefix}: scope "${scope}" is not a known project. ` + + `Known scopes: ${KNOWN_SCOPES.join(', ')}`, + ) + warnings.add(commit.sha) + } + ``` + +5. **Warn on uppercase subject** — If the first character after `: ` is uppercase, warn: + + ```javascript + const subjectStart = msg.indexOf(': ') + 2 + if (subjectStart < msg.length) { + const firstChar = msg[subjectStart] + if (firstChar === firstChar.toUpperCase() && firstChar !== firstChar.toLowerCase()) { + core.warning(`${logPrefix}: subject should start with lowercase letter.`) + warnings.add(commit.sha) + } + } + ``` + +--- + +## Branch-Based Exemptions + +The linter skips validation for PRs between development branches: + +```javascript +const baseBranchType = classify(pr.base.ref.replace(/^refs\/heads\//, '')).type +const headBranchType = classify(pr.head.ref.replace(/^refs\/heads\//, '')).type + +if ( + baseBranchType.includes('development') && + headBranchType.includes('development') && + pr.base.repo.id === pr.head.repo?.id +) { + core.info('This PR is from one development branch to another. Skipping checks.') + return +} +``` + +This exempts: +- `staging` → `master` merges +- `staging-next` → `staging` merges +- `release-X.Y` → `master` merges + +These are infrastructure merges where commits were already validated in their original PRs. + +The `classify()` function from `supportedBranches.js` determines branch types: + +| Branch Prefix | Type | Exempt as PR source? | +|----------------|-------------------------|---------------------| +| `master` | `development`, `primary` | Yes | +| `release-*` | `development`, `primary` | Yes | +| `staging-*` | `development`, `secondary` | Yes | +| `staging-next-*`| `development`, `secondary` | Yes | +| `feature-*` | `wip` | No | +| `fix-*` | `wip` | No | +| `backport-*` | `wip` | No | + +--- + +## Commit Detail Extraction + +The linter uses `get-pr-commit-details.js` to extract commit information. Notably, +this uses **git directly** rather than the GitHub API: + +```javascript +async function getCommitDetailsForPR({ core, pr, repoPath }) { + await runGit({ + args: ['fetch', `--depth=1`, 'origin', pr.base.sha], + repoPath, core, + }) + await runGit({ + args: ['fetch', `--depth=${pr.commits + 1}`, 'origin', pr.head.sha], + repoPath, core, + }) + + const shas = ( + await runGit({ + args: [ + 'rev-list', + `--max-count=${pr.commits}`, + `${pr.base.sha}..${pr.head.sha}`, + ], + repoPath, core, + }) + ).stdout.split('\n').map((s) => s.trim()).filter(Boolean) +``` + +### Why Not Use the GitHub API? + +The GitHub REST API's "list commits on a PR" endpoint has a hard limit of **250 commits**. +For large PRs or release-branch merges, this is insufficient. Using git directly: +- Has no commit count limit +- Also returns changed file paths per commit (used for scope validation) +- Is faster for bulk operations + +For each commit, the script extracts: + +| Field | Source | Purpose | +|----------------------|-----------------------------|---------------------------------| +| `sha` | `git rev-list` | Commit identifier | +| `subject` | `git log --format=%s` | First line of commit message | +| `changedPaths` | `git log --name-only` | Files changed in that commit | +| `changedPathSegments` | Path splitting | Directory segments for scope matching | + +--- + +## Error Output + +### Failures (block merge) + +``` +Error: Commit abc123456789: "Add new feature" does not follow Conventional Commits format. +Expected: type(scope): subject (e.g. "feat(mnv): add keybinding") + +Error: Commit def456789012: starts with "fixup!". +Did you forget to run `git rebase -i --autosquash`? + +Error: Commit ghi789012345: subject should not end with a period. + +Error: Please review the Conventional Commits guidelines at +<https://www.conventionalcommits.org/> and the project CONTRIBUTING.md. + +Error: 3 commit(s) do not follow commit conventions. +``` + +### Warnings (informational) + +``` +Warning: Commit jkl012345678: scope "myproject" is not a known project. +Known scopes: archived, cgit, ci, cmark, ... + +Warning: Commit mno345678901: subject should start with lowercase letter. + +Warning: 2 commit(s) have minor issues (see warnings above). +``` + +--- + +## Local Testing + +Test the commit linter locally using the CLI runner: + +```bash +cd ci/github-script +nix-shell # enter Nix dev shell +gh auth login # authenticate with GitHub +./run lint-commits YongDo-Hyun Project-Tick 123 # lint PR #123 +``` + +The `./run` CLI uses the `commander` package and authenticates via `gh auth token`: + +```javascript +program + .command('lint-commits') + .description('Lint commit messages for Conventional Commits compliance.') + .argument('<owner>', 'Repository owner (e.g. YongDo-Hyun)') + .argument('<repo>', 'Repository name (e.g. Project-Tick)') + .argument('<pr>', 'Pull Request number') + .action(async (owner, repo, pr) => { + const lint = (await import('./lint-commits.js')).default + await run(lint, owner, repo, pr) + }) +``` + +--- + +## Best Practices + +### Writing Good Commit Messages + +1. **Use the correct type** — `feat` for features, `fix` for bugs, `docs` for documentation +2. **Include a scope** — Helps identify which project is affected: `feat(meshmc): ...` +3. **Use imperative mood** — "add feature" not "added feature" or "adds feature" +4. **Keep subject under 72 characters** — For readability in `git log` +5. **Start with lowercase** — `add feature` not `Add feature` +6. **No trailing period** — `fix(cgit): resolve parse error` not `fix(cgit): resolve parse error.` + +### Handling Fixup Commits During Development + +During development, you can use `git commit --fixup=<sha>` freely. Before opening +the PR (or before requesting review), squash them: + +```bash +git rebase -i --autosquash origin/master +``` + +### Multiple Scopes + +If a commit touches multiple projects, either: +- Use `repo` as the scope: `refactor(repo): update shared build config` +- Use the primary affected project as the scope +- Split the commit into separate per-project commits + +--- + +## Adding New Types or Scopes + +### New Scope + +Add the scope to the `KNOWN_SCOPES` array in `ci/github-script/lint-commits.js`: + +```javascript +const KNOWN_SCOPES = [ + 'archived', + 'cgit', + // ... + 'newproject', // ← add here (keep sorted) + // ... +] +``` + +### New Type + +Adding new types requires updating `CONVENTIONAL_TYPES` — but this should be done +rarely, as the standard Conventional Commits types cover most use cases. diff --git a/docs/handbook/ci/formatting.md b/docs/handbook/ci/formatting.md new file mode 100644 index 0000000000..9d2ddb35a4 --- /dev/null +++ b/docs/handbook/ci/formatting.md @@ -0,0 +1,298 @@ +# Code Formatting + +## Overview + +Project Tick uses [treefmt](https://github.com/numtide/treefmt) orchestrated through +[treefmt-nix](https://github.com/numtide/treefmt-nix) to enforce consistent code formatting +across the entire monorepo. The formatting configuration lives in `ci/default.nix` and +covers JavaScript, Nix, YAML, GitHub Actions workflows, and sorted-list enforcement. + +--- + +## Configured Formatters + +### Summary Table + +| Formatter | Language/Files | Key Settings | +|-------------|-------------------------------|-------------------------------------------| +| `actionlint` | GitHub Actions YAML | Default (syntax + best practices) | +| `biome` | JavaScript / TypeScript | Single quotes, optional semicolons | +| `keep-sorted`| Any (marked sections) | Default | +| `nixfmt` | Nix expressions | nixfmt-rfc-style | +| `yamlfmt` | YAML files | Retain line breaks | +| `zizmor` | GitHub Actions YAML | Security scanning | + +--- + +### actionlint + +**Purpose**: Validates GitHub Actions workflow files for syntax errors, type mismatches, +and best practices. + +**Scope**: `.github/workflows/*.yml` + +**Configuration**: Default — no custom settings. + +```nix +programs.actionlint.enable = true; +``` + +**What it catches**: +- Invalid workflow syntax +- Missing or incorrect `runs-on` values +- Type mismatches in expressions +- Unknown action references + +--- + +### biome + +**Purpose**: Formats JavaScript and TypeScript source files with consistent style. + +**Scope**: All `.js` and `.ts` files except `*.min.js` + +**Configuration**: + +```nix +programs.biome = { + enable = true; + validate.enable = false; + settings.formatter = { + useEditorconfig = true; + }; + settings.javascript.formatter = { + quoteStyle = "single"; + semicolons = "asNeeded"; + }; + settings.json.formatter.enabled = false; +}; +settings.formatter.biome.excludes = [ + "*.min.js" +]; +``` + +**Style rules**: + +| Setting | Value | Effect | +|---------------------|----------------|-------------------------------------------| +| `useEditorconfig` | `true` | Respects `.editorconfig` (indent, etc.) | +| `quoteStyle` | `"single"` | Uses `'string'` instead of `"string"` | +| `semicolons` | `"asNeeded"` | Only inserts `;` where ASI requires it | +| `validate.enable` | `false` | No lint-level validation, only formatting | +| `json.formatter` | `disabled` | JSON files are not formatted by biome | + +**Exclusions**: `*.min.js` — Minified JavaScript files are never reformatted. + +--- + +### keep-sorted + +**Purpose**: Enforces alphabetical ordering in marked sections of any file type. + +**Scope**: Files containing `keep-sorted` markers. + +```nix +programs.keep-sorted.enable = true; +``` + +**Usage**: Add markers around sections that should stay sorted: + +``` +# keep-sorted start +apple +banana +cherry +# keep-sorted end +``` + +--- + +### nixfmt + +**Purpose**: Formats Nix expressions according to the RFC-style convention. + +**Scope**: All `.nix` files. + +```nix +programs.nixfmt = { + enable = true; + package = pkgs.nixfmt; +}; +``` + +The `pkgs.nixfmt` package from the pinned Nixpkgs provides the formatter. This +is `nixfmt-rfc-style`, the official Nix formatting standard. + +--- + +### yamlfmt + +**Purpose**: Formats YAML files with consistent indentation and structure. + +**Scope**: All `.yml` and `.yaml` files. + +```nix +programs.yamlfmt = { + enable = true; + settings.formatter = { + retain_line_breaks = true; + }; +}; +``` + +**Key setting**: `retain_line_breaks = true` — Preserves intentional blank lines between +YAML sections, preventing the formatter from collapsing the file into a dense block. + +--- + +### zizmor + +**Purpose**: Security scanner for GitHub Actions workflows. Detects injection +vulnerabilities, insecure defaults, and untrusted input handling. + +**Scope**: `.github/workflows/*.yml` + +```nix +programs.zizmor.enable = true; +``` + +**What it detects**: +- Script injection via `${{ github.event.* }}` in `run:` steps +- Insecure use of `pull_request_target` +- Unquoted expressions that could be exploited +- Dangerous permission configurations + +--- + +## treefmt Global Settings + +```nix +projectRootFile = ".git/config"; +settings.verbose = 1; +settings.on-unmatched = "debug"; +``` + +| Setting | Value | Purpose | +|--------------------|---------------|----------------------------------------------| +| `projectRootFile` | `.git/config` | Identifies repository root for treefmt | +| `settings.verbose` | `1` | Logs which files each formatter processes | +| `settings.on-unmatched` | `"debug"` | Files with no matching formatter are logged at debug level | + +--- + +## Running Formatters + +### In CI + +The formatting check runs as a Nix derivation: + +```bash +nix-build ci/ -A fmt.check +``` + +This: +1. Copies the full source tree (excluding `.git`) into the Nix store +2. Runs all configured formatters +3. Fails with a diff if any file would be reformatted + +### Locally (Nix Shell) + +```bash +cd ci/ +nix-shell # enter CI dev shell +treefmt # format all files +treefmt --check # check without modifying (dry run) +``` + +### Locally (Nix Build) + +```bash +# Just check (no modification): +nix-build ci/ -A fmt.check + +# Get the formatter binary: +nix-build ci/ -A fmt.pkg +./result/bin/treefmt +``` + +--- + +## Source Tree Construction + +The treefmt check operates on a clean copy of the source tree: + +```nix +fs = pkgs.lib.fileset; +src = fs.toSource { + root = ../.; + fileset = fs.difference ../. (fs.maybeMissing ../.git); +}; +``` + +This: +- Takes the entire repository directory (`../.` from `ci/`) +- Excludes the `.git` directory (which is large and irrelevant for formatting) +- `fs.maybeMissing` handles the case where `.git` doesn't exist (e.g., in tarballs) + +The resulting source is passed to`fmt.check`: + +```nix +check = treefmtEval.config.build.check src; +``` + +--- + +## Formatter Outputs + +The formatting system exposes three Nix attributes: + +```nix +{ + shell = treefmtEval.config.build.devShell; # Interactive shell + pkg = treefmtEval.config.build.wrapper; # treefmt binary + check = treefmtEval.config.build.check src; # CI check derivation +} +``` + +| Attribute | Use Case | +|------------|--------------------------------------------------------| +| `fmt.shell` | `nix develop .#fmt.shell` — interactive formatting | +| `fmt.pkg` | The treefmt wrapper with all formatters bundled | +| `fmt.check` | `nix build .#fmt.check` — CI formatting check | + +--- + +## Troubleshooting + +### "File would be reformatted" + +If CI fails with formatting issues: + +```bash +# Enter the CI shell to get the exact same formatter versions: +cd ci/ +nix-shell + +# Format all files: +treefmt + +# Stage and commit the changes: +git add -u +git commit -m "style(repo): apply treefmt formatting" +``` + +### Editor Integration + +For real-time formatting in VS Code: + +1. Use the biome extension for JavaScript/TypeScript +2. Configure single quotes and optional semicolons to match CI settings +3. Use nixpkgs-fmt or nixfmt for Nix files + +### Formatter Conflicts + +Each file type has exactly one formatter assigned by treefmt. If a file matches +multiple formatters, treefmt reports a conflict. The current configuration avoids +this by: +- Disabling biome's JSON formatter +- Having non-overlapping file type coverage diff --git a/docs/handbook/ci/nix-infrastructure.md b/docs/handbook/ci/nix-infrastructure.md new file mode 100644 index 0000000000..27481ed46a --- /dev/null +++ b/docs/handbook/ci/nix-infrastructure.md @@ -0,0 +1,611 @@ +# Nix Infrastructure + +## Overview + +The CI system for the Project Tick monorepo is built on Nix, using pinned dependency +sources to guarantee reproducible builds and formatting checks. The primary entry point +is `ci/default.nix`, which bootstraps the complete CI toolchain from `ci/pinned.json`. + +This document covers the Nix expressions in detail: how they work, what they produce, +and how they integrate with the broader Project Tick build infrastructure. + +--- + +## ci/default.nix — The CI Entry Point + +The `default.nix` file is the sole entry point for all Nix-based CI operations. It: + +1. Reads pinned source revisions from `pinned.json` +2. Fetches the exact Nixpkgs tarball +3. Configures the treefmt multi-formatter +4. Builds the codeowners-validator +5. Exposes a development shell with all CI tools + +### Top-level Structure + +```nix +let + pinned = (builtins.fromJSON (builtins.readFile ./pinned.json)).pins; +in +{ + system ? builtins.currentSystem, + nixpkgs ? null, +}: +let + nixpkgs' = + if nixpkgs == null then + fetchTarball { + inherit (pinned.nixpkgs) url; + sha256 = pinned.nixpkgs.hash; + } + else + nixpkgs; + + pkgs = import nixpkgs' { + inherit system; + config = { }; + overlays = [ ]; + }; +``` + +### Function Parameters + +| Parameter | Default | Purpose | +|-----------|------------------------------|-------------------------------------------------| +| `system` | `builtins.currentSystem` | Target system (e.g., `x86_64-linux`) | +| `nixpkgs` | `null` (uses pinned) | Override Nixpkgs source for development/testing | + +When `nixpkgs` is `null` (the default), the pinned revision is fetched. When provided +explicitly, the override is used instead — useful for testing against newer Nixpkgs. + +### Importing Nixpkgs + +The Nixpkgs tarball is imported with empty config and no overlays: + +```nix +pkgs = import nixpkgs' { + inherit system; + config = { }; + overlays = [ ]; +}; +``` + +This ensures a "pure" package set with no user-specific customizations that could +break CI reproducibility. + +--- + +## Pinned Dependencies (pinned.json) + +### Format + +The `pinned.json` file uses the [npins](https://github.com/andir/npins) v5 format. It +stores Git-based pins with full provenance information: + +```json +{ + "pins": { + "nixpkgs": { + "type": "Git", + "repository": { + "type": "GitHub", + "owner": "NixOS", + "repo": "nixpkgs" + }, + "branch": "nixpkgs-unstable", + "submodules": false, + "revision": "bde09022887110deb780067364a0818e89258968", + "url": "https://github.com/NixOS/nixpkgs/archive/bde09022887110deb780067364a0818e89258968.tar.gz", + "hash": "13mi187zpa4rw680qbwp7pmykjia8cra3nwvjqmsjba3qhlzif5l" + }, + "treefmt-nix": { + "type": "Git", + "repository": { + "type": "GitHub", + "owner": "numtide", + "repo": "treefmt-nix" + }, + "branch": "main", + "submodules": false, + "revision": "e96d59dff5c0d7fddb9d113ba108f03c3ef99eca", + "url": "https://github.com/numtide/treefmt-nix/archive/e96d59dff5c0d7fddb9d113ba108f03c3ef99eca.tar.gz", + "hash": "02gqyxila3ghw8gifq3mns639x86jcq079kvfvjm42mibx7z5fzb" + } + }, + "version": 5 +} +``` + +### Pin Fields + +| Field | Description | +|--------------|------------------------------------------------------------| +| `type` | Source type (`Git`) | +| `repository` | Source location (`GitHub` with owner + repo) | +| `branch` | Upstream branch being tracked | +| `submodules` | Whether to fetch Git submodules (`false`) | +| `revision` | Full commit SHA of the pinned revision | +| `url` | Direct tarball download URL for the pinned revision | +| `hash` | SRI hash (base32) for integrity verification | + +### Why Two Pins? + +| Pin | Tracked Branch | Purpose | +|---------------|----------------------|--------------------------------------------| +| `nixpkgs` | `nixpkgs-unstable` | Base package set: compilers, tools, libraries | +| `treefmt-nix` | `main` | Code formatter orchestrator and its modules | + +The `nixpkgs-unstable` branch is used rather than a release branch to get recent +tool versions while still being reasonably stable. + +--- + +## Updating Pinned Dependencies + +### update-pinned.sh + +The update script is minimal: + +```bash +#!/usr/bin/env nix-shell +#!nix-shell -i bash -p npins + +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" + +npins --lock-file pinned.json update +``` + +This: + +1. Enters a `nix-shell` with `npins` available +2. Changes to the `ci/` directory (where `pinned.json` lives) +3. Runs `npins update` to fetch the latest commit from each tracked branch +4. Updates `pinned.json` with new revisions and hashes + +### When to Update + +- **Regularly**: To pick up security patches and tool updates +- **When a formatter change is needed**: New treefmt-nix releases may add formatters +- **When CI breaks on upstream**: Pin to a known-good revision + +### Manual Update Procedure + +```bash +# From the repository root: +cd ci/ +./update-pinned.sh + +# Review the diff: +git diff pinned.json + +# Test locally: +nix-build -A fmt.check + +# Commit: +git add pinned.json +git commit -m "ci: update pinned nixpkgs and treefmt-nix" +``` + +--- + +## treefmt Integration + +### What is treefmt? + +[treefmt](https://github.com/numtide/treefmt) is a multi-language formatter orchestrator. +It runs multiple formatters in parallel and ensures every file type has exactly one formatter. +The `treefmt-nix` module provides a Nix-native way to configure it. + +### Configuration in default.nix + +```nix +fmt = + let + treefmtNixSrc = fetchTarball { + inherit (pinned.treefmt-nix) url; + sha256 = pinned.treefmt-nix.hash; + }; + treefmtEval = (import treefmtNixSrc).evalModule pkgs { + projectRootFile = ".git/config"; + + settings.verbose = 1; + settings.on-unmatched = "debug"; + + programs.actionlint.enable = true; + + programs.biome = { + enable = true; + validate.enable = false; + settings.formatter = { + useEditorconfig = true; + }; + settings.javascript.formatter = { + quoteStyle = "single"; + semicolons = "asNeeded"; + }; + settings.json.formatter.enabled = false; + }; + settings.formatter.biome.excludes = [ + "*.min.js" + ]; + + programs.keep-sorted.enable = true; + + programs.nixfmt = { + enable = true; + package = pkgs.nixfmt; + }; + + programs.yamlfmt = { + enable = true; + settings.formatter = { + retain_line_breaks = true; + }; + }; + + programs.zizmor.enable = true; + }; +``` + +### treefmt Settings + +| Setting | Value | Purpose | +|----------------------------|---------------|---------------------------------------------| +| `projectRootFile` | `.git/config` | Marker file to detect the repository root | +| `settings.verbose` | `1` | Show which formatter processes each file | +| `settings.on-unmatched` | `"debug"` | Log unmatched files at debug level | + +### Configured Formatters + +#### actionlint +- **Purpose**: Lint GitHub Actions workflow YAML files +- **Scope**: `.github/workflows/*.yml` +- **Configuration**: Default settings + +#### biome +- **Purpose**: Format JavaScript and TypeScript files +- **Configuration**: + - `useEditorconfig = true` — Respects `.editorconfig` settings + - `quoteStyle = "single"` — Uses single quotes + - `semicolons = "asNeeded"` — Only adds semicolons where required by ASI + - `validate.enable = false` — No lint-level validation, only formatting + - `json.formatter.enabled = false` — Does not format JSON files +- **Exclusions**: `*.min.js` — Minified JavaScript files are skipped + +#### keep-sorted +- **Purpose**: Enforces sorted order in marked sections (e.g., dependency lists) +- **Configuration**: Default settings + +#### nixfmt +- **Purpose**: Format Nix expressions +- **Package**: Uses `pkgs.nixfmt` from the pinned Nixpkgs +- **Configuration**: Default nixfmt-rfc-style formatting + +#### yamlfmt +- **Purpose**: Format YAML files +- **Configuration**: + - `retain_line_breaks = true` — Preserves intentional blank lines + +#### zizmor +- **Purpose**: Security scanning for GitHub Actions workflows +- **Configuration**: Default settings +- **Detects**: Injection vulnerabilities, insecure defaults, untrusted inputs + +### Formatter Source Tree + +The treefmt evaluation creates a source tree from the repository, excluding `.git`: + +```nix +fs = pkgs.lib.fileset; +src = fs.toSource { + root = ../.; + fileset = fs.difference ../. (fs.maybeMissing ../.git); +}; +``` + +This ensures the formatting check operates on the full repository contents while +avoiding Git internals. + +### Outputs + +The `fmt` attribute set exposes three derivations: + +```nix +{ + shell = treefmtEval.config.build.devShell; # nix develop .#fmt.shell + pkg = treefmtEval.config.build.wrapper; # treefmt binary + check = treefmtEval.config.build.check src; # nix build .#fmt.check +} +``` + +| Output | Type | Purpose | +|------------|-------------|--------------------------------------------------| +| `fmt.shell` | Dev shell | Interactive shell with treefmt available | +| `fmt.pkg` | Binary | The treefmt wrapper with all formatters configured| +| `fmt.check` | Check | A Nix derivation that fails if any file needs reformatting | + +--- + +## codeowners-validator Derivation + +### Purpose + +The codeowners-validator checks that the `ci/OWNERS` file is structurally valid: +- All referenced paths exist in the repository +- All referenced GitHub users/teams exist in the organization +- Glob patterns are syntactically correct + +### Build Definition + +```nix +{ + buildGoModule, + fetchFromGitHub, + fetchpatch, +}: +buildGoModule { + name = "codeowners-validator"; + src = fetchFromGitHub { + owner = "mszostok"; + repo = "codeowners-validator"; + rev = "f3651e3810802a37bd965e6a9a7210728179d076"; + hash = "sha256-5aSmmRTsOuPcVLWfDF6EBz+6+/Qpbj66udAmi1CLmWQ="; + }; + patches = [ + (fetchpatch { + name = "user-write-access-check"; + url = "https://github.com/mszostok/codeowners-validator/compare/f3651e3...840eeb8.patch"; + hash = "sha256-t3Dtt8SP9nbO3gBrM0nRE7+G6N/ZIaczDyVHYAG/6mU="; + }) + ./permissions.patch + ./owners-file-name.patch + ]; + postPatch = "rm -r docs/investigation"; + vendorHash = "sha256-R+pW3xcfpkTRqfS2ETVOwG8PZr0iH5ewroiF7u8hcYI="; +} +``` + +### Patches Applied + +#### 1. user-write-access-check (upstream PR #222) +Fetched from the upstream repository. Modifies the write-access validation logic. + +#### 2. permissions.patch +Undoes part of the upstream PR's write-access requirement: + +```diff + var reqScopes = map[github.Scope]struct{}{ +- github.ScopeReadOrg: {}, + } +``` + +And removes the push permission checks for teams and users: + +```diff + for _, t := range v.repoTeams { + if strings.EqualFold(t.GetSlug(), team) { +- if t.Permissions["push"] { +- return nil +- } +- return newValidateError(...) ++ return nil + } + } +``` + +This is necessary because Project Tick's OWNERS file is used for code review routing, +not for GitHub's native branch protection rules. Contributors listed in OWNERS don't +need write access to the repository. + +#### 3. owners-file-name.patch +Adds support for a custom CODEOWNERS file path via the `OWNERS_FILE` environment variable: + +```diff + func openCodeownersFile(dir string) (io.Reader, error) { ++ if file, ok := os.LookupEnv("OWNERS_FILE"); ok { ++ return fs.Open(file) ++ } ++ + var detectedFiles []string +``` + +This allows the validator to check `ci/OWNERS` instead of the default `.github/CODEOWNERS` +or `CODEOWNERS` paths. + +--- + +## CI Dev Shell + +The top-level `shell` attribute combines all CI tools: + +```nix +shell = pkgs.mkShell { + packages = [ + fmt.pkg + codeownersValidator + ]; +}; +``` + +This provides: +- `treefmt` — The configured multi-formatter +- `codeowners-validator` — The patched OWNERS validator + +Enter the shell: + +```bash +cd ci/ +nix-shell # or: nix develop +treefmt # format all files +codeowners-validator # validate OWNERS +``` + +--- + +## github-script Nix Shell + +The `ci/github-script/shell.nix` provides a separate dev shell for JavaScript CI scripts: + +```nix +{ + system ? builtins.currentSystem, + pkgs ? (import ../../ci { inherit system; }).pkgs, +}: + +pkgs.callPackage ( + { + gh, + importNpmLock, + mkShell, + nodejs, + }: + mkShell { + packages = [ + gh + importNpmLock.hooks.linkNodeModulesHook + nodejs + ]; + + npmDeps = importNpmLock.buildNodeModules { + npmRoot = ./.; + inherit nodejs; + }; + } +) { } +``` + +### Key Features + +1. **Shared Nixpkgs**: Imports the pinned `pkgs` from `../../ci` (the parent `default.nix`) +2. **Node.js**: Full Node.js runtime for running CI scripts +3. **GitHub CLI**: `gh` for authentication (`gh auth token` is used by the `run` CLI) +4. **npm Lockfile Integration**: `importNpmLock` builds `node_modules` from `package-lock.json` + in the Nix store, then `linkNodeModulesHook` symlinks it into the working directory + +--- + +## Relationship to Root flake.nix + +The root `flake.nix` defines the overall development environment: + +```nix +{ + description = "Project Tick is a project dedicated to providing developers + with ease of use and users with long-lasting software."; + + inputs = { + nixpkgs.url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"; + }; + + outputs = { self, nixpkgs }: + let + systems = lib.systems.flakeExposed; + forAllSystems = lib.genAttrs systems; + nixpkgsFor = forAllSystems (system: nixpkgs.legacyPackages.${system}); + in + { + devShells = forAllSystems (system: ...); + formatter = forAllSystems (system: nixpkgsFor.${system}.nixfmt-rfc-style); + }; +} +``` + +The flake's `inputs.nixpkgs` uses `nixos-unstable` via Nix channels, while the CI +`pinned.json` uses a specific commit from `nixpkgs-unstable`. These are related but +independently pinned — the flake updates when `flake.lock` is refreshed, while CI +pins update only when `update-pinned.sh` is explicitly run. + +### When Each Is Used + +| Context | Nix Source | +|-------------------|-----------------------------------------------| +| `nix develop` | Root `flake.nix` → `flake.lock` → nixpkgs | +| CI formatting check| `ci/default.nix` → `ci/pinned.json` → nixpkgs| +| CI script dev shell| `ci/github-script/shell.nix` → `ci/default.nix` | + +--- + +## Evaluation and Build Commands + +### Building the Format Check + +```bash +# From repository root: +nix-build ci/ -A fmt.check + +# Or with flakes: +nix build .#fmt.check +``` + +This produces a derivation that: +1. Copies the entire source tree (minus `.git`) into the Nix store +2. Runs all configured formatters +3. Fails with a diff if any file would be reformatted + +### Entering the CI Shell + +```bash +# Nix classic: +nix-shell ci/ + +# Nix flakes: +nix develop ci/ +``` + +### Building codeowners-validator + +```bash +nix-build ci/ -A codeownersValidator +./result/bin/codeowners-validator +``` + +--- + +## Troubleshooting + +### "hash mismatch" on pinned.json update + +If `update-pinned.sh` produces a hash mismatch, the upstream source has changed +at the same branch tip. Re-run the update: + +```bash +cd ci/ +./update-pinned.sh +``` + +### Formatter version mismatch + +If local formatting produces different results than CI: + +1. Ensure you're using the same Nixpkgs pin: `nix-shell ci/` +2. Run `treefmt` from within the CI shell +3. If the issue persists, update pins: `./update-pinned.sh` + +### codeowners-validator fails to build + +The Go module build requires network access for vendored dependencies. Ensure: +- The `vendorHash` in `codeowners-validator/default.nix` matches the actual Go module checksum +- If upstream dependencies change, update `vendorHash` + +--- + +## Security Considerations + +- **Hash verification**: All fetched tarballs are verified against their SRI hashes +- **No overlays**: Nixpkgs is imported with empty overlays to prevent supply-chain attacks +- **Pinned revisions**: Exact commit SHAs prevent upstream branch tampering +- **zizmor**: GitHub Actions workflows are scanned for injection vulnerabilities +- **actionlint**: Workflow syntax is validated to catch misconfigurations + +--- + +## Summary + +The Nix infrastructure provides: + +1. **Reproducibility** — Identical tools and versions across all CI runs and developer machines +2. **Composability** — Each component (treefmt, codeowners-validator) is independently buildable +3. **Security** — Hash-verified dependencies, security scanning, no arbitrary overlays +4. **Developer experience** — `nix-shell` provides a ready-to-use environment with zero manual setup diff --git a/docs/handbook/ci/overview.md b/docs/handbook/ci/overview.md new file mode 100644 index 0000000000..19d42cfe2a --- /dev/null +++ b/docs/handbook/ci/overview.md @@ -0,0 +1,494 @@ +# CI Infrastructure — Overview + +## Purpose + +The `ci/` directory contains the Continuous Integration infrastructure for the Project Tick monorepo. +It provides reproducible builds, automated code quality checks, commit message validation, +pull request lifecycle management, and code ownership enforcement — all orchestrated through +Nix expressions and JavaScript-based GitHub Actions scripts. + +The CI system is designed around three core principles: + +1. **Reproducibility** — Pinned Nix dependencies ensure identical builds across environments +2. **Conventional Commits** — Enforced commit message format for automated changelog generation +3. **Ownership-driven review** — CODEOWNERS-style file ownership with automated review routing + +--- + +## Directory Structure + +``` +ci/ +├── OWNERS # Code ownership file (CODEOWNERS format) +├── README.md # CI README with local testing instructions +├── default.nix # Nix CI entry point — treefmt, codeowners-validator, shell +├── pinned.json # Pinned Nixpkgs + treefmt-nix revisions (npins format) +├── update-pinned.sh # Script to update pinned.json via npins +├── supportedBranches.js # Branch classification logic for CI decisions +├── codeowners-validator/ # Builds codeowners-validator from source (Go) +│ ├── default.nix # Nix derivation for codeowners-validator +│ ├── owners-file-name.patch # Patch: custom OWNERS file path via OWNERS_FILE env var +│ └── permissions.patch # Patch: remove write-access check (not needed for non-native CODEOWNERS) +└── github-script/ # JavaScript CI scripts for GitHub Actions + ├── run # CLI entry point for local testing (commander-based) + ├── lint-commits.js # Commit message linter (Conventional Commits) + ├── prepare.js # PR preparation: mergeability, branch targeting, touched files + ├── reviews.js # Review lifecycle: post, dismiss, minimize bot reviews + ├── get-pr-commit-details.js # Extract commit SHAs, subjects, changed paths via git + ├── withRateLimit.js # GitHub API rate limiting with Bottleneck + ├── package.json # Node.js dependencies (@actions/core, @actions/github, bottleneck, commander) + ├── package-lock.json # Lockfile for reproducible npm installs + ├── shell.nix # Nix dev shell for github-script (Node.js + gh CLI) + ├── README.md # Local testing documentation + ├── .editorconfig # Editor configuration + ├── .gitignore # Git ignore rules + └── .npmrc # npm configuration +``` + +--- + +## How CI Works End-to-End + +### 1. Triggering + +CI runs are triggered by GitHub Actions workflows (defined in `.github/workflows/`) when +pull requests are opened, updated, or merged against supported branches. The `supportedBranches.js` +module classifies branches to determine which checks to run. + +### 2. Environment Setup + +The CI environment is bootstrapped via `ci/default.nix`, which: + +- Reads pinned dependency revisions from `ci/pinned.json` +- Fetches the exact Nixpkgs tarball at the pinned commit +- Imports `treefmt-nix` for code formatting +- Builds the `codeowners-validator` tool with Project Tick–specific patches +- Exposes a development shell with all CI tools available + +```nix +# ci/default.nix — entry point +let + pinned = (builtins.fromJSON (builtins.readFile ./pinned.json)).pins; +in +{ + system ? builtins.currentSystem, + nixpkgs ? null, +}: +let + nixpkgs' = + if nixpkgs == null then + fetchTarball { + inherit (pinned.nixpkgs) url; + sha256 = pinned.nixpkgs.hash; + } + else + nixpkgs; + + pkgs = import nixpkgs' { + inherit system; + config = { }; + overlays = [ ]; + }; +``` + +### 3. Code Formatting (treefmt) + +The `default.nix` configures `treefmt-nix` with multiple formatters: + +| Formatter | Purpose | Configuration | +|-------------|--------------------------------------|----------------------------------------------| +| `actionlint` | GitHub Actions workflow linting | Enabled, no custom config | +| `biome` | JavaScript/TypeScript formatting | Single quotes, no semicolons, no JSON format | +| `keep-sorted`| Sorted list enforcement | Enabled, no custom config | +| `nixfmt` | Nix expression formatting | Uses `pkgs.nixfmt` | +| `yamlfmt` | YAML formatting | Retains line breaks | +| `zizmor` | GitHub Actions security scanning | Enabled, no custom config | + +Biome is configured with specific style rules: + +```nix +programs.biome = { + enable = true; + validate.enable = false; + settings.formatter = { + useEditorconfig = true; + }; + settings.javascript.formatter = { + quoteStyle = "single"; + semicolons = "asNeeded"; + }; + settings.json.formatter.enabled = false; +}; +settings.formatter.biome.excludes = [ + "*.min.js" +]; +``` + +### 4. Commit Linting + +When a PR is opened or updated, `ci/github-script/lint-commits.js` validates every commit +message against the Conventional Commits specification. It checks: + +- Format: `type(scope): subject` +- No `fixup!`, `squash!`, or `amend!` prefixes (must be rebased before merge) +- No trailing period on subject line +- Lowercase first letter in subject +- Known scopes matching monorepo project directories + +The supported types are: + +```javascript +const CONVENTIONAL_TYPES = [ + 'build', 'chore', 'ci', 'docs', 'feat', 'fix', + 'perf', 'refactor', 'revert', 'style', 'test', +] +``` + +And the known scopes correspond to monorepo directories: + +```javascript +const KNOWN_SCOPES = [ + 'archived', 'cgit', 'ci', 'cmark', 'corebinutils', + 'forgewrapper', 'genqrcode', 'hooks', 'images4docker', + 'json4cpp', 'libnbtplusplus', 'meshmc', 'meta', 'mnv', + 'neozip', 'tomlplusplus', 'repo', 'deps', +] +``` + +### 5. PR Preparation and Validation + +The `ci/github-script/prepare.js` script handles PR lifecycle: + +1. **Mergeability check** — Polls GitHub's mergeability computation with exponential backoff + (5s, 10s, 20s, 40s, 80s retries) +2. **Branch classification** — Classifies base and head branches using `supportedBranches.js` +3. **Base branch suggestion** — For WIP branches, computes the optimal base branch by comparing + merge-base commit distances across `master` and all release branches +4. **Merge conflict detection** — If the PR has conflicts, uses the head SHA directly; otherwise + uses the merge commit SHA +5. **Touched file detection** — Identifies which CI-relevant paths were modified: + - `ci` — any file under `ci/` + - `pinned` — `ci/pinned.json` specifically + - `github` — any file under `.github/` + +### 6. Review Lifecycle Management + +The `ci/github-script/reviews.js` module manages bot reviews: + +- **`postReview()`** — Posts or updates a review with a tracking comment tag + (`<!-- projt review key: <key>; resolved: false -->`) +- **`dismissReviews()`** — Dismisses, minimizes (marks as outdated), or resolves bot reviews + when the underlying issue is fixed +- Reviews are tagged with a `reviewKey` to allow multiple independent review concerns + on the same PR + +### 7. Rate Limiting + +All GitHub API calls go through `ci/github-script/withRateLimit.js`, which uses the +Bottleneck library for request throttling: + +- Read requests: controlled by a reservoir updated from the GitHub rate limit API +- Write requests (`POST`, `PUT`, `PATCH`, `DELETE`): minimum 1 second between calls +- The reservoir keeps 1000 spare requests for other concurrent jobs +- Reservoir is refreshed every 60 seconds +- Requests to `github.com` (not the API), `/rate_limit`, and `/search/` endpoints bypass throttling + +### 8. Code Ownership Validation + +The `ci/codeowners-validator/` builds a patched version of the +[codeowners-validator](https://github.com/mszostok/codeowners-validator) tool: + +- Fetched from GitHub at a specific pinned commit +- Two patches applied: + - `owners-file-name.patch` — Adds support for custom CODEOWNERS file path via `OWNERS_FILE` env var + - `permissions.patch` — Removes the write-access permission check (not needed since Project Tick + uses an `OWNERS` file rather than GitHub's native `CODEOWNERS`) + +This validates the `ci/OWNERS` file against the actual repository structure and GitHub +organization membership. + +--- + +## Component Interaction Flow + +``` +┌─────────────────────────────────────────┐ +│ GitHub Actions Workflow │ +│ (.github/workflows/*.yml) │ +└──────────────┬──────────────────────────┘ + │ triggers + ▼ +┌──────────────────────────────────────────┐ +│ ci/default.nix │ +│ ┌─────────┐ ┌──────────────────────┐ │ +│ │pinned. │ │ treefmt-nix │ │ +│ │json │──│ (formatting checks) │ │ +│ └─────────┘ └──────────────────────┘ │ +│ ┌──────────────────────┐ │ +│ │ codeowners-validator │ │ +│ │ (OWNERS validation) │ │ +│ └──────────────────────┘ │ +└──────────────┬───────────────────────────┘ + │ also triggers + ▼ +┌──────────────────────────────────────────┐ +│ ci/github-script/ │ +│ ┌────────────────┐ ┌───────────────┐ │ +│ │ prepare.js │ │ lint-commits │ │ +│ │ (PR validation) │ │ (commit msg) │ │ +│ └───────┬────────┘ └──────┬────────┘ │ +│ │ │ │ +│ ┌───────▼────────┐ ┌──────▼────────┐ │ +│ │ reviews.js │ │ supported │ │ +│ │ (bot reviews) │ │ Branches.js │ │ +│ └───────┬────────┘ └───────────────┘ │ +│ │ │ +│ ┌───────▼────────┐ │ +│ │ withRateLimit │ │ +│ │ (API throttle) │ │ +│ └────────────────┘ │ +└──────────────────────────────────────────┘ +``` + +--- + +## Key Design Decisions + +### Why Nix for CI? + +Nix ensures that every CI run uses the exact same versions of tools, compilers, and +libraries. The `pinned.json` file locks specific commits of Nixpkgs and treefmt-nix, +eliminating "works on my machine" problems. + +### Why a custom OWNERS file? + +GitHub's native CODEOWNERS has limitations: +- Must be in `.github/CODEOWNERS`, `CODEOWNERS`, or `docs/CODEOWNERS` +- Requires repository write access for all listed owners +- Cannot be extended with custom validation + +Project Tick uses `ci/OWNERS` with the same glob pattern syntax but adds: +- Custom file path support (via the `OWNERS_FILE` environment variable) +- No write-access requirement (via the permissions patch) +- Integration with the codeowners-validator for structural validation + +### Why Bottleneck for rate limiting? + +GitHub Actions can run many jobs in parallel, and each job makes API calls. Without +throttling, a large CI run could exhaust the GitHub API rate limit (5000 requests/hour +for authenticated requests). Bottleneck provides: +- Concurrency control (1 concurrent request by default) +- Reservoir-based rate limiting (dynamically updated from the API) +- Separate throttling for mutative requests (1 second minimum between writes) + +### Why local testing support? + +The `ci/github-script/run` CLI allows developers to test CI scripts locally before +pushing. This accelerates development and reduces CI feedback loops: + +```bash +cd ci/github-script +nix-shell # sets up Node.js + dependencies +gh auth login # authenticate with GitHub +./run lint-commits YongDo-Hyun Project-Tick 123 +./run prepare YongDo-Hyun Project-Tick 123 +``` + +--- + +## Pinned Dependencies + +The CI system pins two external Nix sources: + +| Dependency | Source | Branch | Purpose | +|-------------|----------------------------------------------|--------------------|--------------------------------| +| `nixpkgs` | `github:NixOS/nixpkgs` | `nixpkgs-unstable` | Base package set for CI tools | +| `treefmt-nix`| `github:numtide/treefmt-nix` | `main` | Multi-formatter orchestrator | + +Pins are stored in `ci/pinned.json` in npins v5 format: + +```json +{ + "pins": { + "nixpkgs": { + "type": "Git", + "repository": { + "type": "GitHub", + "owner": "NixOS", + "repo": "nixpkgs" + }, + "branch": "nixpkgs-unstable", + "revision": "bde09022887110deb780067364a0818e89258968", + "url": "https://github.com/NixOS/nixpkgs/archive/bde09022887110deb780067364a0818e89258968.tar.gz", + "hash": "13mi187zpa4rw680qbwp7pmykjia8cra3nwvjqmsjba3qhlzif5l" + }, + "treefmt-nix": { + "type": "Git", + "repository": { + "type": "GitHub", + "owner": "numtide", + "repo": "treefmt-nix" + }, + "branch": "main", + "revision": "e96d59dff5c0d7fddb9d113ba108f03c3ef99eca", + "url": "https://github.com/numtide/treefmt-nix/archive/e96d59dff5c0d7fddb9d113ba108f03c3ef99eca.tar.gz", + "hash": "02gqyxila3ghw8gifq3mns639x86jcq079kvfvjm42mibx7z5fzb" + } + }, + "version": 5 +} +``` + +To update pins: + +```bash +cd ci/ +./update-pinned.sh +``` + +This runs `npins --lock-file pinned.json update` to fetch the latest revisions. + +--- + +## Node.js Dependencies (github-script) + +The `ci/github-script/package.json` declares: + +```json +{ + "private": true, + "dependencies": { + "@actions/core": "1.11.1", + "@actions/github": "6.0.1", + "bottleneck": "2.19.5", + "commander": "14.0.3" + } +} +``` + +| Package | Version | Purpose | +|-------------------|----------|-----------------------------------------------| +| `@actions/core` | `1.11.1` | GitHub Actions core utilities (logging, outputs) | +| `@actions/github` | `6.0.1` | GitHub API client (Octokit wrapper) | +| `bottleneck` | `2.19.5` | Rate limiting / request throttling | +| `commander` | `14.0.3` | CLI argument parsing for local `./run` tool | + +These versions are kept in sync with the +[actions/github-script](https://github.com/actions/github-script) action. + +--- + +## Nix Dev Shell + +The `ci/github-script/shell.nix` provides a development environment for working on +the CI scripts locally: + +```nix +{ + system ? builtins.currentSystem, + pkgs ? (import ../../ci { inherit system; }).pkgs, +}: + +pkgs.callPackage ( + { + gh, + importNpmLock, + mkShell, + nodejs, + }: + mkShell { + packages = [ + gh + importNpmLock.hooks.linkNodeModulesHook + nodejs + ]; + + npmDeps = importNpmLock.buildNodeModules { + npmRoot = ./.; + inherit nodejs; + }; + } +) { } +``` + +This gives you: +- `nodejs` — Node.js runtime +- `gh` — GitHub CLI for authentication +- `importNpmLock.hooks.linkNodeModulesHook` — Automatically links `node_modules` from the Nix store + +--- + +## Outputs Exposed by default.nix + +The `ci/default.nix` exposes the following attributes: + +| Attribute | Type | Description | +|----------------------|-----------|--------------------------------------------------| +| `pkgs` | Nixpkgs | The pinned Nixpkgs package set | +| `fmt.shell` | Derivation| Dev shell with treefmt formatter available | +| `fmt.pkg` | Derivation| The treefmt wrapper binary | +| `fmt.check` | Derivation| A check derivation that fails if formatting drifts| +| `codeownersValidator`| Derivation| Patched codeowners-validator binary | +| `shell` | Derivation| Combined CI dev shell (fmt + codeowners-validator)| + +```nix +rec { + inherit pkgs fmt; + codeownersValidator = pkgs.callPackage ./codeowners-validator { }; + + shell = pkgs.mkShell { + packages = [ + fmt.pkg + codeownersValidator + ]; + }; +} +``` + +--- + +## Integration with Root Flake + +The root `flake.nix` provides: + +- Dev shells for all supported systems (`aarch64-linux`, `x86_64-linux`, etc.) +- A formatter (`nixfmt-rfc-style`) +- The CI `default.nix` is imported indirectly via the flake for Nix-based CI runs + +```nix +{ + description = "Project Tick is a project dedicated to providing developers + with ease of use and users with long-lasting software."; + + inputs = { + nixpkgs.url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"; + }; + ... +} +``` + +--- + +## Summary of CI Checks + +| Check | Tool / Script | Scope | +|--------------------------|---------------------------|------------------------------------| +| Code formatting | treefmt (biome, nixfmt, yamlfmt, actionlint, zizmor) | All source files | +| Commit message format | `lint-commits.js` | All commits in a PR | +| PR mergeability | `prepare.js` | Every PR | +| Base branch targeting | `prepare.js` + `supportedBranches.js` | WIP → development PRs | +| Code ownership validity | `codeowners-validator` | `ci/OWNERS` file | +| GitHub Actions security | `zizmor` (via treefmt) | `.github/workflows/*.yml` | +| Sorted list enforcement | `keep-sorted` (via treefmt)| Files with keep-sorted markers | + +--- + +## Related Documentation + +- [Nix Infrastructure](nix-infrastructure.md) — Deep dive into the Nix expressions +- [Commit Linting](commit-linting.md) — Commit message conventions and validation rules +- [PR Validation](pr-validation.md) — Pull request checks and lifecycle management +- [Branch Strategy](branch-strategy.md) — Branch naming, classification, and release branches +- [CODEOWNERS](codeowners.md) — Ownership file format and validation +- [Formatting](formatting.md) — Code formatting configuration and tools +- [Rate Limiting](rate-limiting.md) — GitHub API rate limiting strategy diff --git a/docs/handbook/ci/pr-validation.md b/docs/handbook/ci/pr-validation.md new file mode 100644 index 0000000000..f7933d3e75 --- /dev/null +++ b/docs/handbook/ci/pr-validation.md @@ -0,0 +1,378 @@ +# PR Validation + +## Overview + +The `ci/github-script/prepare.js` script runs on every pull request to validate +mergeability, classify branches, suggest optimal base branches, detect merge conflicts, +and identify which CI-relevant paths were touched. It also manages bot review comments +to guide contributors toward correct PR targeting. + +--- + +## What prepare.js Does + +1. **Checks PR state** — Ensures the PR is still open +2. **Waits for mergeability** — Polls GitHub until mergeability is computed +3. **Classifies branches** — Categorizes base and head branches using `supportedBranches.js` +4. **Validates branch targeting** — Warns if a feature branch targets a release branch +5. **Suggests better base branches** — For WIP branches, finds the optimal base by comparing + commit distances +6. **Computes merge SHAs** — Determines the merge commit SHA and target comparison SHA +7. **Detects touched CI paths** — Identifies changes to `ci/`, `ci/pinned.json`, `.github/` + +--- + +## Mergeability Check + +GitHub computes merge status asynchronously. The script polls with exponential backoff: + +```javascript +for (const retryInterval of [5, 10, 20, 40, 80]) { + core.info('Checking whether the pull request can be merged...') + const prInfo = ( + await github.rest.pulls.get({ + ...context.repo, + pull_number, + }) + ).data + + if (prInfo.state !== 'open') throw new Error('PR is not open anymore.') + + if (prInfo.mergeable == null) { + core.info( + `GitHub is still computing mergeability, waiting ${retryInterval}s...`, + ) + await new Promise((resolve) => setTimeout(resolve, retryInterval * 1000)) + continue + } + // ... process PR +} +throw new Error( + 'Timed out waiting for GitHub to compute mergeability. Check https://www.githubstatus.com.', +) +``` + +### Retry Schedule + +| Attempt | Wait Time | Cumulative Wait | +|---------|-----------|-----------------| +| 1 | 5 seconds | 5 seconds | +| 2 | 10 seconds| 15 seconds | +| 3 | 20 seconds| 35 seconds | +| 4 | 40 seconds| 75 seconds | +| 5 | 80 seconds| 155 seconds | + +If mergeability is still not computed after ~2.5 minutes, the script throws an error +with a link to [githubstatus.com](https://www.githubstatus.com) for checking GitHub's +system status. + +--- + +## Branch Classification + +Both the base and head branches are classified using `supportedBranches.js`: + +```javascript +const baseClassification = classify(base.ref) +core.setOutput('base', baseClassification) + +const headClassification = + base.repo.full_name === head.repo.full_name + ? classify(head.ref) + : { type: ['wip'] } +core.setOutput('head', headClassification) +``` + +### Fork Handling + +For cross-fork PRs (where the head repo differs from the base repo), the head branch +is always classified as `{ type: ['wip'] }` regardless of its name. This prevents +fork branches from being treated as development branches. + +### Classification Output + +Each classification produces: + +```javascript +{ + branch: 'release-1.0', + order: 1, + stable: true, + type: ['development', 'primary'], + version: '1.0', +} +``` + +| Field | Description | +|-----------|------------------------------------------------------| +| `branch` | The full branch name | +| `order` | Ranking for base-branch preference (lower = better) | +| `stable` | Whether the branch has a version suffix | +| `type` | Array of type tags | +| `version` | Extracted version number, or `'dev'` | + +--- + +## Release Branch Targeting Warning + +If a WIP branch (feature, fix, etc.) targets a stable release branch, the script +checks whether it's a backport: + +```javascript +if ( + baseClassification.stable && + baseClassification.type.includes('primary') +) { + const headPrefix = head.ref.split('-')[0] + if (!['backport', 'fix', 'revert'].includes(headPrefix)) { + core.warning( + `This PR targets release branch \`${base.ref}\`. ` + + 'New features should typically target \`master\`.', + ) + } +} +``` + +| Head Branch Prefix | Allowed to target release? | Reason | +|-------------------|---------------------------|---------------------| +| `backport-*` | Yes | Explicit backport | +| `fix-*` | Yes | Bug fix for release | +| `revert-*` | Yes | Reverting a change | +| `feature-*` | Warning issued | Should target master| +| `wip-*` | Warning issued | Should target master| + +--- + +## Base Branch Suggestion + +For WIP branches, the script computes the optimal base branch by analyzing commit +distances from the head to all candidate base branches: + +### Algorithm + +1. **List all branches** — Fetch all branches in the repository via pagination +2. **Filter candidates** — Keep `master` and all stable primary branches (release-*) +3. **Compute merge bases** — For each candidate, find the merge-base commit with the + PR head and count commits between them + +```javascript +async function mergeBase({ branch, order, version }) { + const { data } = await github.rest.repos.compareCommitsWithBasehead({ + ...context.repo, + basehead: `${branch}...${head.sha}`, + per_page: 1, + page: 2, + }) + return { + branch, + order, + version, + commits: data.total_commits, + sha: data.merge_base_commit.sha, + } +} +``` + +4. **Select the best** — The branch with the fewest commits ahead wins. If there's a tie, + the branch with the lowest `order` wins (i.e., `master` over `release-*`). + +```javascript +let candidates = [await mergeBase(classify('master'))] +for (const release of releases) { + const nextCandidate = await mergeBase(release) + if (candidates[0].commits === nextCandidate.commits) + candidates.push(nextCandidate) + if (candidates[0].commits > nextCandidate.commits) + candidates = [nextCandidate] + if (candidates[0].commits < 10000) break +} + +const best = candidates.sort((a, b) => a.order - b.order).at(0) +``` + +5. **Post review if mismatch** — If the suggested base differs from the current base, + a bot review is posted: + +```javascript +if (best.branch !== base.ref) { + const current = await mergeBase(classify(base.ref)) + const body = [ + `This PR targets \`${current.branch}\`, but based on the commit history ` + + `\`${best.branch}\` appears to be a better fit ` + + `(${current.commits - best.commits} fewer commits ahead).`, + '', + `If this is intentional, you can ignore this message. Otherwise:`, + `- [Change the base branch](...) to \`${best.branch}\`.`, + ].join('\n') + + await postReview({ github, context, core, dry, body, reviewKey }) +} +``` + +6. **Dismiss reviews if correct** — If the base branch matches the suggestion, any + previous bot reviews are dismissed. + +### Early Termination + +The algorithm stops evaluating release branches once the candidate count drops below +10,000 commits. This prevents unnecessary API calls for branches that are clearly +not good candidates. + +--- + +## Merge SHA Computation + +The script computes two key SHAs for downstream CI jobs: + +### Mergeable PR + +```javascript +if (prInfo.mergeable) { + core.info('The PR can be merged.') + mergedSha = prInfo.merge_commit_sha + targetSha = ( + await github.rest.repos.getCommit({ + ...context.repo, + ref: prInfo.merge_commit_sha, + }) + ).data.parents[0].sha +} +``` + +- `mergedSha` — GitHub's trial merge commit SHA +- `targetSha` — The first parent of the merge commit (base branch tip) + +### Conflicting PR + +```javascript +else { + core.warning('The PR has a merge conflict.') + mergedSha = head.sha + targetSha = ( + await github.rest.repos.compareCommitsWithBasehead({ + ...context.repo, + basehead: `${base.sha}...${head.sha}`, + }) + ).data.merge_base_commit.sha +} +``` + +- `mergedSha` — Falls back to the head SHA (no merge commit exists) +- `targetSha` — The merge-base between base and head + +--- + +## Touched Path Detection + +The script identifies which CI-relevant paths were modified in the PR: + +```javascript +const files = ( + await github.paginate(github.rest.pulls.listFiles, { + ...context.repo, + pull_number, + per_page: 100, + }) +).map((file) => file.filename) + +const touched = [] +if (files.some((f) => f.startsWith('ci/'))) touched.push('ci') +if (files.includes('ci/pinned.json')) touched.push('pinned') +if (files.some((f) => f.startsWith('.github/'))) touched.push('github') +core.setOutput('touched', touched) +``` + +| Touched Tag | Condition | Use Case | +|------------|------------------------------------------|---------------------------------| +| `ci` | Any file under `ci/` was changed | Re-run CI infrastructure checks | +| `pinned` | `ci/pinned.json` specifically changed | Validate pin integrity | +| `github` | Any file under `.github/` was changed | Re-run workflow lint checks | + +--- + +## Outputs + +The script sets the following outputs for downstream workflow jobs: + +| Output | Type | Description | +|-------------|--------|---------------------------------------------------| +| `base` | Object | Base branch classification (branch, type, version) | +| `head` | Object | Head branch classification | +| `mergedSha` | String | Merge commit SHA (or head SHA if conflicting) | +| `targetSha` | String | Base comparison SHA | +| `touched` | Array | Which CI-relevant paths were modified | + +--- + +## Review Lifecycle + +The `prepare.js` script integrates with `reviews.js` for bot review management: + +### Posting a Review + +When the script detects a branch targeting issue, it posts a `REQUEST_CHANGES` review: + +```javascript +await postReview({ github, context, core, dry, body, reviewKey: 'prepare' }) +``` + +The review body includes: +- A description of the issue +- A comparison of commit distances +- A link to GitHub's "change base branch" documentation + +### Dismissing Reviews + +When the issue is resolved (correct base branch), previous reviews are dismissed: + +```javascript +await dismissReviews({ github, context, core, dry, reviewKey: 'prepare' }) +``` + +The `reviewKey` (`'prepare'`) ensures only reviews posted by this script are affected. + +--- + +## Dry Run Mode + +When the `--no-dry` flag is NOT passed (default in local testing), all mutative +operations (posting/dismissing reviews) are skipped: + +```javascript +module.exports = async ({ github, context, core, dry }) => { + // ... + if (!dry) { + await github.rest.pulls.createReview({ ... }) + } +} +``` + +This allows safe local testing without modifying real PRs. + +--- + +## Local Testing + +```bash +cd ci/github-script +nix-shell +gh auth login + +# Dry run (default — no changes to the PR): +./run prepare YongDo-Hyun Project-Tick 123 + +# Live run (actually posts/dismisses reviews): +./run prepare YongDo-Hyun Project-Tick 123 --no-dry +``` + +--- + +## Error Conditions + +| Condition | Behavior | +|-------------------------------------|----------------------------------------------| +| PR is closed | Throws: `"PR is not open anymore."` | +| Mergeability timeout | Throws: `"Timed out waiting for GitHub..."` | +| API rate limit exceeded | Handled by `withRateLimit.js` | +| Merge conflict | Warning issued; head SHA used as mergedSha | +| Wrong base branch | REQUEST_CHANGES review posted | diff --git a/docs/handbook/ci/rate-limiting.md b/docs/handbook/ci/rate-limiting.md new file mode 100644 index 0000000000..4b349ee2b4 --- /dev/null +++ b/docs/handbook/ci/rate-limiting.md @@ -0,0 +1,321 @@ +# Rate Limiting + +## Overview + +The CI system interacts heavily with the GitHub REST API for PR validation, commit +analysis, review management, and branch comparison. To prevent exhausting the +GitHub API rate limit (5,000 requests/hour for authenticated tokens), all API calls +are routed through `ci/github-script/withRateLimit.js`, which uses the +[Bottleneck](https://github.com/SGrondin/bottleneck) library for request throttling. + +--- + +## Architecture + +### Request Flow + +``` +┌──────────────────────────┐ +│ CI Script │ +│ (lint-commits.js, │ +│ prepare.js, etc.) │ +└────────────┬─────────────┘ + │ github.rest.* + ▼ +┌──────────────────────────┐ +│ withRateLimit wrapper │ +│ ┌──────────────────┐ │ +│ │ allLimits │ │ ← Bottleneck (maxConcurrent: 1, reservoir: dynamic) +│ │ (all requests) │ │ +│ └──────────────────┘ │ +│ ┌──────────────────┐ │ +│ │ writeLimits │ │ ← Bottleneck (minTime: 1000ms) chained to allLimits +│ │ (POST/PUT/PATCH/ │ │ +│ │ DELETE only) │ │ +│ └──────────────────┘ │ +└────────────┬─────────────┘ + │ + ▼ +┌──────────────────────────┐ +│ GitHub REST API │ +│ api.github.com │ +└──────────────────────────┘ +``` + +--- + +## Implementation + +### Module Signature + +```javascript +module.exports = async ({ github, core, maxConcurrent = 1 }, callback) => { +``` + +| Parameter | Type | Default | Description | +|----------------|----------|---------|--------------------------------------| +| `github` | Object | — | Octokit instance from `@actions/github` | +| `core` | Object | — | `@actions/core` for logging | +| `maxConcurrent` | Number | `1` | Maximum concurrent API requests | +| `callback` | Function| — | The script logic to execute | + +### Bottleneck Configuration + +Two Bottleneck limiters are configured: + +#### allLimits — Controls all requests + +```javascript +const allLimits = new Bottleneck({ + maxConcurrent, + reservoir: 0, // Updated dynamically +}) +``` + +- `maxConcurrent: 1` — Only one API request at a time (prevents burst usage) +- `reservoir: 0` — Starts empty; updated by `updateReservoir()` before first use + +#### writeLimits — Additional throttle for mutative requests + +```javascript +const writeLimits = new Bottleneck({ minTime: 1000 }).chain(allLimits) +``` + +- `minTime: 1000` — At least 1 second between write requests +- `.chain(allLimits)` — Write requests also go through the global limiter + +--- + +## Request Classification + +The Octokit `request` hook intercepts every API call and routes it through +the appropriate limiter: + +```javascript +github.hook.wrap('request', async (request, options) => { + // Bypass: different host (e.g., github.com for raw downloads) + if (options.url.startsWith('https://github.com')) return request(options) + + // Bypass: rate limit endpoint (doesn't count against quota) + if (options.url === '/rate_limit') return request(options) + + // Bypass: search endpoints (separate rate limit pool) + if (options.url.startsWith('/search/')) return request(options) + + stats.requests++ + + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method)) + return writeLimits.schedule(request.bind(null, options)) + else + return allLimits.schedule(request.bind(null, options)) +}) +``` + +### Bypass Rules + +| URL Pattern | Reason | +|-------------------------------|---------------------------------------------| +| `https://github.com/*` | Raw file downloads, not API calls | +| `/rate_limit` | Meta-endpoint, doesn't count against quota | +| `/search/*` | Separate rate limit pool (30 requests/min) | + +### Request Routing + +| HTTP Method | Limiter | Throttle Rule | +|----------------------|------------------|----------------------------------| +| `GET` | `allLimits` | Concurrency-limited + reservoir | +| `POST` | `writeLimits` | 1 second minimum + concurrency | +| `PUT` | `writeLimits` | 1 second minimum + concurrency | +| `PATCH` | `writeLimits` | 1 second minimum + concurrency | +| `DELETE` | `writeLimits` | 1 second minimum + concurrency | + +--- + +## Reservoir Management + +### Dynamic Reservoir Updates + +The reservoir tracks how many API requests the script is allowed to make: + +```javascript +async function updateReservoir() { + let response + try { + response = await github.rest.rateLimit.get() + } catch (err) { + core.error(`Failed updating reservoir:\n${err}`) + return + } + const reservoir = Math.max(0, response.data.resources.core.remaining - 1000) + core.info(`Updating reservoir to: ${reservoir}`) + allLimits.updateSettings({ reservoir }) +} +``` + +### Reserve Buffer + +The script always keeps **1,000 spare requests** for other concurrent jobs: + +```javascript +const reservoir = Math.max(0, response.data.resources.core.remaining - 1000) +``` + +If the rate limit shows 3,500 remaining requests, the reservoir is set to 2,500. +If remaining is below 1,000, the reservoir is set to 0 (all requests will queue). + +### Why 1,000? + +Other GitHub Actions jobs running in parallel (status checks, deployment workflows, +external integrations) typically use fewer than 100 requests each. A 1,000-request +buffer provides ample headroom: + +- Normal job: ~50–100 API calls +- 10 concurrent jobs: ~500–1,000 API calls +- Buffer: 1,000 requests — covers typical parallel workload + +### Update Schedule + +```javascript +await updateReservoir() // Initial update before any work +const reservoirUpdater = setInterval(updateReservoir, 60 * 1000) // Every 60 seconds +``` + +The reservoir is refreshed every minute to account for: +- Other jobs consuming requests in parallel +- Rate limit window resets (GitHub resets the limit every hour) + +### Cleanup + +```javascript +try { + await callback(stats) +} finally { + clearInterval(reservoirUpdater) + core.notice( + `Processed ${stats.prs} PRs, ${stats.issues} Issues, ` + + `made ${stats.requests + stats.artifacts} API requests ` + + `and downloaded ${stats.artifacts} artifacts.`, + ) +} +``` + +The interval is cleared in a `finally` block to prevent resource leaks even if +the callback throws an error. + +--- + +## Statistics Tracking + +The wrapper tracks four metrics: + +```javascript +const stats = { + issues: 0, + prs: 0, + requests: 0, + artifacts: 0, +} +``` + +| Metric | Incremented By | Purpose | +|-------------|---------------------------------------|----------------------------------| +| `requests` | Every throttled API call | Total API usage | +| `prs` | Callback logic (PR processing) | PRs analyzed | +| `issues` | Callback logic (issue processing) | Issues analyzed | +| `artifacts` | Callback logic (artifact downloads) | Artifacts downloaded | + +At the end of execution, a summary is logged: + +``` +Notice: Processed 1 PRs, 0 Issues, made 15 API requests and downloaded 0 artifacts. +``` + +--- + +## Error Handling + +### Rate Limit API Failure + +If the rate limit endpoint itself fails (network error, GitHub outage): + +```javascript +try { + response = await github.rest.rateLimit.get() +} catch (err) { + core.error(`Failed updating reservoir:\n${err}`) + return // Keep retrying on next interval +} +``` + +The error is logged but does not crash the script. The reservoir retains its +previous value, and the next 60-second interval will try again. + +### Exhausted Reservoir + +When the reservoir reaches 0: +- All new requests queue in Bottleneck +- Requests wait until the next `updateReservoir()` call adds capacity +- If GitHub's rate limit has not reset, requests continue to queue +- The script may time out if the rate limit window hasn't reset + +--- + +## GitHub API Rate Limits Reference + +| Resource | Limit | Reset Period | +|-------------|--------------------------|--------------| +| Core REST API| 5,000 requests/hour | Rolling hour | +| Search API | 30 requests/minute | Rolling minute| +| GraphQL API | 5,000 points/hour | Rolling hour | + +The `withRateLimit.js` module only manages the **Core REST API** limit. Search +requests bypass the throttle because they have a separate, lower limit that is +rarely a concern for CI scripts. + +--- + +## Usage in CI Scripts + +### Wrapping a Script + +```javascript +const withRateLimit = require('./withRateLimit.js') + +module.exports = async ({ github, core }) => { + await withRateLimit({ github, core }, async (stats) => { + // All github.rest.* calls here are automatically throttled + + const pr = await github.rest.pulls.get({ + owner: 'YongDo-Hyun', + repo: 'Project-Tick', + pull_number: 123, + }) + stats.prs++ + + // ... more API calls + }) +} +``` + +### Adjusting Concurrency + +For scripts that can safely parallelize reads: + +```javascript +await withRateLimit({ github, core, maxConcurrent: 5 }, async (stats) => { + // Up to 5 concurrent GET requests + // Write requests still have 1-second minimum spacing +}) +``` + +--- + +## Best Practices + +1. **Minimize API calls** — Use pagination efficiently, avoid redundant requests +2. **Prefer git over API** — For commit data, `get-pr-commit-details.js` uses git directly + to bypass the 250-commit API limit and reduce API usage +3. **Use the `stats` object** — Track what the script does for observability +4. **Don't bypass the wrapper** — All API calls should go through the throttled Octokit instance +5. **Handle network errors** — The wrapper handles rate limit API failures, but callback + scripts should handle their own API errors gracefully |
