summaryrefslogtreecommitdiff
path: root/docs/handbook/ci
diff options
context:
space:
mode:
Diffstat (limited to 'docs/handbook/ci')
-rw-r--r--docs/handbook/ci/branch-strategy.md388
-rw-r--r--docs/handbook/ci/codeowners.md370
-rw-r--r--docs/handbook/ci/commit-linting.md418
-rw-r--r--docs/handbook/ci/formatting.md298
-rw-r--r--docs/handbook/ci/nix-infrastructure.md611
-rw-r--r--docs/handbook/ci/overview.md494
-rw-r--r--docs/handbook/ci/pr-validation.md378
-rw-r--r--docs/handbook/ci/rate-limiting.md321
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