Skip to content

Continuous integration ​

How CI is wired and enforced, established by spec 003 and verified rather than assumed. Graduated from specs/003-ci-wiring/research.md so the next spec does not re-derive it.

Verified 2026-07-24 against pnpm 11.15.1, Node floor ≥22.18 (CI pins 22), and the GitHub-hosted ubuntu-latest runner. The workflow pins the current action majors, verified against the registries: actions/checkout@v7, pnpm/action-setup@v6, actions/setup-node@v7.

One workflow, four checks, fail-fast ​

.github/workflows/ci.yml runs on every pull_request and every push to main, and on nothing else. push is scoped to main so an in-repo feature branch fires only the pull-request run, never a duplicate. A single job on ubuntu-latest installs once and runs the four checks in order — lint → typecheck → test → docs:build — stopping at the first failure. The checks are the existing root pnpm scripts (specs 001/002 and VitePress); CI calls them verbatim rather than reimplementing them, so "passes locally" and "passes in CI" cannot diverge in behaviour.

A concurrency group keyed on the workflow and ref, with cancel-in-progress: true, stops a superseded run when a PR is pushed again. Splitting the checks into parallel jobs is a deliberate non-goal at this size — a well-understood later refactor once checks are slow enough to earn it.

The toolchain pins cannot silently drift ​

  • pnpm is not pinned in the workflow. pnpm/action-setup runs with no version input, so it reads the version from package.json's packageManager field (pnpm@11.15.1). That field is the single source of truth; a bump there is picked up by CI automatically.
  • Node is pinned to the engines.node floor line (22). This verifies the project's published floor: code that accidentally uses a newer-than-supported Node API fails CI even though it passes on a developer's newer local Node. Unified Node pinning across local and CI arrives with mise in a later spec, at which point CI reads that pin instead of 22.
  • tests/ci/workflow.test.ts guards both: it parses the workflow and asserts pnpm is not hardcoded to a conflicting version and the Node pin's major satisfies engines.node.

Standing guards ​

  • strictDepBuilds defaults to true. A dependency whose install needs a build script must be listed under allowBuilds in pnpm-workspace.yaml, or pnpm install --frozen-lockfile fails on the runner (ERR_PNPM_IGNORED_BUILDS). Today only esbuild is listed. If a future red CI install names an ignored build, add the dependency to allowBuilds — the gate is working, not flaking.
  • pnpm caching is via actions/setup-node. pnpm/action-setup must run before setup-node, which does the store caching through cache: 'pnpm', keyed on pnpm-lock.yaml. Reordering them breaks the cache.

Branch protection — set by hand, not by a committed file ​

"CI green before merge" is enforced by a branch protection rule (or repository ruleset) on main that requires the CI status check to pass before merging. This is a GitHub-side setting, so it is not a committed artifact and no repo test can assert it — the one place the "all artifacts are committed" rule cannot reach. It is set once, by the human, and recorded here.

Plan constraint (discovered 2026-07-25). On this repository as it stands — private, free plan — both the branch-protection API and the rulesets API return 403 "Upgrade to GitHub Pro or make this repository public". So the rule below cannot currently be applied, and SC6 is not machine-enforced (see the Verification log). GitHub offers branch protection for free only on public repositories. To enforce it, either make the repo public or upgrade the plan; until then "green before merge" is a discipline (CI still runs and reports on every PR), not a platform guarantee. The rule below is what to apply once the plan allows it.

The rule: on main, require the status check from the workflow's check job to pass before a pull request can be merged. Configure it via Settings → Branches → Add branch ruleset (target main, enable "Require status checks to pass", select the check check), or via the API:

bash
gh api -X PUT repos/:owner/:repo/branches/main/protection \
  -F required_status_checks.strict=true \
  -f 'required_status_checks.checks[][context]=check' \
  -F enforce_admins=true \
  -F required_pull_request_reviews= \
  -F restrictions=

The check name GitHub knows is the job id (check); it appears in the settings list only after the workflow has run at least once, so run a PR first, then add the rule.

Docs previews — publishing the built site ​

Established by spec 013. CI does not only build the docs, it publishes them: every branch gets its own URL, and main publishes to a fixed one. The check job uploads .vitepress/dist as an artifact; a second job, docs, downloads it and uploads it to Cloudflare Pages. The published bytes are therefore the bytes that passed the checks — the deploy job never rebuilds.

The project is direct upload, not a connected repository. gw2-priory-docs was created with wrangler pages project create gw2-priory-docs --production-branch main and has no repository connected. That is deliberate: a git-connected project would build the site a second time, on Cloudflare's build image, giving the repo a second toolchain to keep in step with the one CI pins. It would also put the build configuration in a dashboard, which is the problem this file already documents for branch protection. Direct upload keeps one toolchain and one source of truth.

Cloudflare decides preview versus production by comparing the deploy's --branch against the project's production branch. The workflow passes the pull request's head branch or the pushed branch, so one step serves both cases with no conditional.

What lives outside the repository ​

SettingWhereValue
Pages projectCloudflaregw2-priory-docs, direct upload, production branch main
CLOUDFLARE_API_TOKENGitHub repository secretAPI token scoped to Account · Cloudflare Pages · Edit, nothing else
CLOUDFLARE_ACCOUNT_IDGitHub repository secretthe account id; retrievable any time with wrangler whoami

The account id is an identifier, not a credential — it appears in every dashboard URL. It is stored as a secret for tidiness, not secrecy. The token is the real credential; to rotate it, create a new one with the same single permission and re-run gh secret set CLOUDFLARE_API_TOKEN. Nothing in the repository changes, because only the name is committed.

deployments: write is not granted. The deployment record is created by the Actions service as a consequence of the job declaring an environment, not by a call the workflow makes — verified on run 31727381528, which published successfully under the workflow's contents: read.

Two facts that will otherwise cost someone an afternoon ​

  • A new project's first branch URL fails TLS for about a minute. *.pages.dev is a single-label wildcard: it covers gw2-priory-docs.pages.dev but not 013-docs-previews.gw2-priory-docs.pages.dev. Cloudflare issues the *.gw2-priory-docs.pages.dev certificate on the project's first deployment. Measured 2026-08-13: SSL handshake failure at 0 s and 30 s, 200 at 60 s. One-time per project, not per preview — and it presents as an SSL error, which reads like a broken workflow rather than a wait.
  • The environment URL is the immutable deployment URL, never the branch alias. A deploy yields both: deployment-url (a per-deployment hash, always present) and pages-deployment-alias-url (the branch alias, documented as present "if it exists"). Production deploys have no alias, so wiring the alias into environment.url would look correct on a pull request and produce an empty URL on merge. A deployment entry also refers to one run, so the immutable URL is the honest target: an old entry shows what that commit built.

Verification log — docs previews (spec 013) ​

Observed on the real runs, since none of these can be proved by a repo test. Spec 013's traceability table cites this log for SC1–SC7.

CriterionObservationDate
SC1 (a PR publishes a browsable preview, reachable from the PR)Run 31727381528: check then docs, both green. PR #13's timeline carries a deployed event; deployment 5893154142, env=Docs preview, ref=013-docs-previews, status success, environment_url=https://d96c0bff.gw2-priory-docs.pages.dev. The published spec page served 200 and rendered, Mermaid diagrams included.2026-08-13
SC2 (a second push publishes the newer build)Run 31727821558 published the newly added docs/gaps/docs-build-render-failures page; the branch alias served it 200 immediately after. Entries accumulate: two deployment records (5893154142, 5893233968) and two deployed events on PR #13, with the older one not auto-inactivated. P1 #2 originally claimed one updating entry and was amended to match reality.2026-08-13
SC3 (a red PR publishes nothing)Run 31728487847, on a deliberate lint violation: check: failure, docs: **skipped**, deployment count unchanged at 2. The needs: gate holds. Probe commit reverted.2026-08-13
SC4 (two PRs resolve to two URLs)Not observed. Follows from the branch-keyed alias proven in research.md V6 — each deploy passes its own branch and Cloudflare mints a hostname per branch — but no two pull requests have been open at once. Recorded as inferred, not verified; the next feature branch observes it for free.—
SC5 (a merge updates production, nothing to tear down)PR #13 squash-merged as d43ac5a; the push: main run 31736199703 published to gw2-priory-docs.pages.dev, which went from 404 to 200 serving the site. No teardown action was taken or needed — the merged branch's preview simply remains, costing nothing.2026-08-13
SC6 (a deploy failure leaves the PR mergeable)Degenerate on this repository. Branch protection is unavailable on a free private plan (see the SC6 row in spec 003's log above), so nothing is a required check and every PR is mergeable regardless. The design intent — check alone gates the merge, docs never does — is structurally true (the docs job is not referenced by any protection rule because no rule exists), but it has not been tested against an enforcing rule and will not be until the plan allows one.2026-08-13
SC7 (the entry reads Docs preview on a PR, Docs on main)Both halves observed: PR deployments carry env=Docs preview, and run 31736199703 created env=Docs, ref=main. The github-context expression resolves correctly on each side (research.md V1).2026-08-13

Two of these rows are deliberately not marked pass. SC4 is inferred rather than seen, and SC6 passes for a reason unrelated to this spec's design. Recording them as green would make the table read as stronger evidence than it is.

Nothing is torn down ​

A merged pull request's preview needs no cleanup. The site is static files on a CDN — no process runs per preview and there is no idle cost, so there is nothing to kill. Deleting superseded deployments is deliberately not automated.

Preview URLs are readable by anyone holding them, while the repository stays private. That is a decision, not an oversight: the docs read like an open-source project's docs, the URLs are unindexed, and gating them behind an identity policy would cost an email round-trip on every view. Adding Cloudflare Access later is a settings change no repository code depends on.

Verification log ​

The spec's observational criteria are only provable on the real run and in GitHub's settings. Each is recorded here with the date it was observed — this log is what the spec 003 traceability table cites for these rows.

CriterionObservationDate
SC1 (a broken check → red run, later steps skipped)PR #3, run 30129726127: a deliberate lint break failed pnpm lint; typecheck, test, docs:build reported skipped.2026-07-25
SC2 (all checks pass → green run)PR #3, run 30129611590: all four checks green on ubuntu-latest in 48s, including docs:build (esbuild's Linux binary built under the frozen install).2026-07-25
SC3 (push to main reports a status on the commit)PR #3 squash-merged as 696804f ("003 — CI wiring (#3)"); the push:main run 30130359708 reported success on that commit.2026-07-25
SC6 (a red/pending PR cannot be merged; a green one can)Not machine-enforced — blocked by plan. Branch protection and rulesets both return GitHub 403 "Upgrade to Pro or make this repository public" on this free private repo. "Green before merge" holds by discipline (CI runs and reports on every PR; only green PRs are merged), not by the platform. See "Branch protection" above for how to enable it.2026-07-25

Reference ​

  • specs/003-ci-wiring/ — the spec, discovery, plan, and tasks that established all of the above.
  • docs/architecture/stack.md — the higher-level "CI: GitHub Actions" decision this refines.
  • docs/architecture/monorepo.md — the pnpm/Node/Biome toolchain CI runs.