ADR-0015: GitHub Actions CI as the quality gate
ADR-0015: GitHub Actions CI as the quality gate
Section titled “ADR-0015: GitHub Actions CI as the quality gate”Status: Accepted Date: 2026-04-21
Context
Section titled “Context”ADR-0013 item 13 requires: “CI enforces the quality gate on every PR. pnpm check must be green before merge. Branch protection on main.” That bar is aspirational without a mechanical enforcement path.
The repository already has pnpm check as a local script (ADR-0003, ADR-0004). Contributors run it before pushing, but nothing prevents a push or a merge of code that fails it. This is the gap this ADR closes.
Two questions are in scope here, and one is deliberately out:
- In scope: continuous integration on pull requests — what gets run automatically when a PR is opened or updated, how failure blocks merge. This is about correctness of code landing in
main. - In scope: dependency hygiene — what keeps
pnpm-lock.yamlcurrent and known-vulnerabilities out of the graph. - Out of scope: continuous deployment — how code in
mainreaches production. That is the distinct question tracked at open-questions.md#cicd and graduates when the first deploy happens.
The platform decision from ADR-0014 commits us to Cloudflare Workers, which makes Cloudflare Workers Builds a candidate CI for deploy — but as noted, that is a separate ADR. For pre-merge quality gating, GitHub Actions is the incumbent for a repo already on GitHub and needs no additional vendor relationship.
Decision
Section titled “Decision”GitHub Actions is the pre-merge CI for the repository. One workflow (check) runs on every pull request into main and on every push to main, executing the same pnpm check that contributors run locally. Branch protection on main requires this workflow to pass before merge is allowed. Dependabot is enabled for the npm and GitHub Actions ecosystems to keep dependencies current.
Specifically:
- Workflow file:
.github/workflows/check.yml. - Runs on:
pull_requesttargetingmain, andpushtomain. - Concurrency: one run per ref; superseded runs cancelled so force-pushes don’t pile up.
- Steps: checkout, install Node 22 (matching
.nvmrc), enable corepack, runpnpm install --frozen-lockfile, runpnpm check. - Pnpm is sourced via corepack honoring the
packageManagerfield inpackage.jsonso the version is a single source of truth. - Dependency caching is via
actions/setup-nodewithcache: 'pnpm'pointed at the lockfile. - Dependabot config:
.github/dependabot.yml, weekly cadence, ecosystemsnpmandgithub-actions, grouped minor / patch updates to keep PR noise manageable. - Branch protection (configured in GitHub, documented below):
mainprotected, PR required,checkrequired to pass, linear history required, force-pushes disallowed, deletion disallowed.
All CI actions are pinned to major-version tags with inline version comments in the initial commit (e.g. actions/checkout@v5 # pin: awaiting first Dependabot SHA-pin PR). This is a deliberate choice over guessing SHAs by hand at ADR-acceptance time: Dependabot’s first run on dependabot.yml will open a PR converting every action reference to an immutable SHA with a version comment, and from that point forward SHA pinning is the norm. Tag-based pinning in the intermediate window is a known, narrow gap — tracked as a threat-model entry rather than hidden.
The alternative (hand-picking SHAs for the initial commit) was considered and rejected: it either requires the author to be authoritative about SHAs they cannot easily verify from this environment, or it requires waiting for SHA lookup before shipping. Dependabot performs the lookup correctly and automatically; the right abstraction is to delegate to it.
Permissions for the workflow are set to contents: read at the workflow level — the least privilege that allows the job to run. No write scopes are granted.
Consequences
Section titled “Consequences”- ADR-0013 item 13 is mechanically enforced. PRs failing lint, typecheck, or tests cannot be merged through the normal path. The bar is no longer aspirational.
- Local
pnpm checkand CIpnpm checkare the same command. Contributors cannot surprise themselves by running different checks. Debugging “green locally, red in CI” reduces to environment mismatch only. - First-PR setup burden for new contributors is minimal.
.nvmrc+ corepack +pnpm install+pnpm check. No separate CI-specific scripts to maintain. - CI runtime is the project’s feedback loop. At the current workspace size, a full check is under 15 seconds locally; CI adds overhead for container spin-up and dependency install (~60-90 seconds total with a warm cache). Keeping this fast is a recurring concern — if it slips past 5 minutes, we revisit (e.g., split typecheck and test into parallel jobs, add tighter caching).
- Dependabot creates PRs; a human reviews them. No auto-merge. Dependency updates are code changes and go through the same gate. Grouped updates reduce PR volume for minor / patch bumps.
- SHA pinning requires periodic refresh. Dependabot handles this for GitHub Actions: it opens PRs to bump pinned action SHAs when a new version ships. The alternative — floating on
@v4tags — is strictly less safe. - Branch protection is configured outside the repository. GitHub branch-protection rules live in repo settings, not in
.github/. This ADR documents the required configuration; the actual enforcement is an operator action on the repo. A follow-up: GitHub now supports Rulesets in.github/rulesets/*.json(optional); adopting that moves branch protection back into version control. Noted as a future improvement, not a blocker. - Secrets required by CI are zero today.
pnpm checkreads no secrets. This deliberately keeps the CI workflow itself off the attack surface that matters: a compromised CI workflow with no secrets can only affect itself. When later work requires secrets (LLM API keys for integration tests, deploy credentials) those will be added per-workflow, not globally.
Consequences for the repo
Section titled “Consequences for the repo”- New file:
.github/workflows/check.yml. - New file:
.github/dependabot.yml. - New file:
.github/BRANCH_PROTECTION.md— short human-readable document capturing the protection rules so that if the settings are ever accidentally changed, the intended configuration is recoverable from the repo itself. README.mdupdated to reference the CI badge and the branch-protection rules.
Alternatives considered
Section titled “Alternatives considered”- No CI; rely on local
pnpm check+ honor system. The status quo. Rejected: one forgottenpnpm checkcan breakmainfor everyone, and “remember to run it” is not a control that scales past one contributor. - Cloudflare Workers Builds as the CI backend. Workers Builds is primarily a deployment trigger — it builds and deploys Workers from a repo on push. Using it for pre-merge gating works but adds a vendor relationship that isn’t needed yet, and it is weaker than GitHub Actions for the non-deploy parts of CI (running tests against multiple Node versions, running custom matrix jobs, producing artifacts). Reasonable to revisit when deploy-to-Workers is the bottleneck; not now.
- CircleCI / Buildkite / Jenkins. All solve the same problem. GitHub Actions wins for a repo already on GitHub with no additional signup, no second credential surface, and a generous free tier for public and small private repos. No strong reason to introduce a second vendor.
- Lefthook / Husky / pre-commit hooks as the gate. Hooks run on developer machines. They are defeatable with
git commit --no-verify, skipped on CI-generated commits, and add local setup overhead. They make sense as a convenience (fail fast before push) but not as the quality gate. Tracked separately at open-questions.md#commit-hooks. - Auto-merge on green CI. Convenient but removes a human from the loop for every change, including dependency updates. Rejected: the human review is part of the bar. A future ADR can revisit this if PR volume makes review a bottleneck; dependency-update auto-merge with specific constraints (only patch bumps on dev dependencies, only after a dwell time) is the most likely first step if we go there.
- Hand-picked SHA pins in the initial CI commit. Strictly more secure than
@v5. Rejected for the initial commit only because the author would be asserting SHAs they cannot verify from this environment and the decision is reversible within a day of CI running (first Dependabot PR). Post-Dependabot, the SHA-pinning stance is the norm and any drift is the thing to fix. Not rejected as a policy — rejected as a one-shot bootstrap step.