Pull requests and gates

What CI checks on every pull request, the ticket check, the model gate for content-model changes, and how PRs merge.

2026-11-07

The CI gate

ci.yml is the gate every PR crosses. It runs on every pull_request and on pushes to main, develop and release/** — the last so that a fix merged into a release branch has a CI result on the exact commit release.yml will check. Concurrency is cancel-in-progress per ref, so a new push supersedes the previous run.

It has four jobs: verify and lint (both required), report (required, PRs only) and announce-trunk (pushes to develop only).

verify — the main job

One job carries the build, the drift gates, type-check and the unit suites. On a fully cold run (a Turborepo globalEnv change invalidates every hash) this is around 20 minutes of real work, but the timeout is 75 minutes to survive the genuinely cold case. Warm runs replay from the remote cache in minutes.

Its steps, in order:

  1. Validate the vendored Token Studio export — turbo check --filter=@v4/tokens.
  2. Build — pnpm turbo build --filter=!@v4/web, Turbo-cached. @v4/web is excluded: its production build prerenders from Contentful and needs a delivery token at build time, so it is validated by the Flightcontrol deploy instead (and announced by the deploy watch when it fails). It keeps its type-check and lint coverage here.
  3. Generated-tokens drift gate — regenerate @v4/tokens and fail on any diff in packages/tokens/src/__generated__.
  4. Model schema + generated types drift gate — first asks migrate.mjs --env=develop --dry --skip-apps whether the PR carries migrations develop has not run. If it does, the live schema cannot match yet, so the step skips sync-schema (proving that case is the model PR gate's job) and checks the committed artefacts offline. Otherwise it runs sync-schema. Either way it then runs generate and fails on any diff in schema.graphql / src/__generated__, EXCLUDING src/__generated__/dictionary (editorial copy Contentful owns, so an editor's label edit is not a model change). Uses CONTENTFUL_CMA_TOKEN and the CONTENTFUL_SPACE_ID variable.
  5. Announce a model-drift failure — only when step 4 itself failed, a Slack message naming the drifted content type. Scoped to that step so a broken build is never announced as model drift.
  6. Docs-drift check (AI, mapped paths only) — a deterministic pre-filter maps changed code globs to the docs that describe them (docs-map.json); only when a mapped path changed does headless Claude answer one question — do those docs still describe reality? It needs ANTHROPIC_API_KEY, which is not provisioned in this repo, so the step always skips with a notice.
  7. Composition drift gate — compose-club -- --check regenerates the shims and model composition and fails on any diff. Uses CONTENTFUL_ACCESS_TOKEN.
  8. App codegen drift gate — regenerate @v4/web, @v4/content-app and @v4/video-app codegen and fail on any diff in their __generated__ trees.
  9. Contentful app registry lint — node packages/model/scripts/lint-apps.mjs.
  10. Type-check — pnpm check-types.
  11. Test (package unit suites) — turbo test, excluding @v4/e2e and @v4/storybook, with a JSON reporter feeding the PR test-report comment.

The Figma bridge check (check-bridges.mjs) is in pnpm verify but not in CI; nor does CI run pnpm verify itself.

The memory ceiling

The job sets NODE_OPTIONS=--max-old-space-size=6144. It was added when the @v4/web Next build OOMed at Node's ~2 GB default, at a time when @v4/ghost-content re-emitted the whole ghost-core theme layer. Both reasons have gone — theme emission has been owner-keyed since 9 September 2026, and @v4/web is no longer built in CI — but the ceiling stays as headroom. The comment in ci.yml still describes the old cause.

lint — its own job

Lint and Prettier run as a separate required check so a style failure reports on its own instead of being buried inside the long verify job. It runs pnpm lint and pnpm format:check. The timeout is 30 minutes: a passing run takes 9–14 minutes, and the earlier 15-minute limit cut runs off mid-lint, which GitHub reports as "cancelled" rather than as a timeout.

format:check is repo-wide, which makes a formatting slip everyone's problem. On 28 September 112 ticket files reached develop with a design.figma URL wrapped onto its own line — the design-link script writes TypeScript and does not run Prettier over it. develop went red, and so did every open PR, for a reason none of them had anything to do with.

The pre-commit hook (below) is what stops that recurring. The durable fix is for the generator to format its own output; until it does, nothing unformatted can reach a commit.

The pre-commit hook

git config core.hooksPath scripts/hooks, set by the root prepare script, so it arrives with pnpm install and needs no separate tool. Two steps, both ADVISORY — CI is the gate:

Step What it does
format-staged.mjs Prettier over the staged files, re-staging what it changed
docs-drift.mjs --staged asks whether the mapped docs still describe reality

Three decisions in the formatter worth keeping:

  • It fixes rather than complains. A hook that only nags is one more thing to bypass at 6pm, and there is no judgement in "join this string back onto one line".
  • A PARTIALLY staged file is reported, never rewritten. A file can be staged and have further unstaged edits — you staged one hunk and kept working. Formatting it and running git add would sweep those unstaged hunks into the commit. That is the classic lint-staged surprise and is far worse than an unformatted line, so those files are named and left alone.
  • ESLint is deliberately not here. It is type-aware in this repo and takes minutes; that belongs in CI, not in front of every commit.

It stays silent when everything is already formatted, exits 0 when Prettier is not installed yet (a fresh clone mid-bootstrap), and ignores deleted paths.

report — the sticky comment

On pull requests only, report aggregates the unit-suite JSON results from verify into one sticky PR comment and enforces a pass-rate floor (PASS_RATE_FLOOR=90). It runs if: always() — a suite failing is exactly when the numbers matter most — and it is a required check.

announce-trunk — a red develop

On a push to develop only, when verify or lint failed, this job names the failed steps (from the jobs API) in a Slack message. It stands down when the model drift gate was the failure, because that step has already posted a more specific message. On a PR the red check is already in front of the author, so it does not run there.

What does NOT run on PRs

The full e2e suite and the Storybook browser-mode story suite (axe gate + play functions across every component) are out of PR CI by an explicit call: even sharded and diff-selected, their cost and latency were not worth it per PR on 2-core runners — the story suite alone was still running at 28 minutes. Both are owned by the nightly full matrix. The per-PR e2e selection machinery is retained in the repo (scripts/ci/select-e2e.mjs) so the sharded PR job can be restored from history if that trade changes.

The model PR gate

model-pr-gate.yml is an additional gate that arms only when a PR touches the content model — its trigger paths are packages/model/migrations/**, packages/model/apps/** (the Contentful app registry and lock) and packages/model/baseline/**. It proves a migration against a real, throwaway Contentful environment before it can merge, so a model change and its generated artefacts land in one reviewed PR.

It is not one of develop's required checks (those are verify, lint and report), so branch protection will not stop a merge while it is red. Do not merge a model PR until it is green: one merged red leaves develop's schema and the committed artefacts out of step, and every later PR's drift gate inherits the failure.

One serialised slot

The plan allows for a single spare-environment slot, so the workflow's concurrency group is model-pr-gate with cancel-in-progress: false: all PRs that touch migrations are serialised through the one ephemeral slot rather than racing for it.

The gate job has a 30-minute timeout and names its environment after the PR: model-gate-pr<number>.

What the gate does

  1. Clone an ephemeral environment from develop. A CMA PUT creates the environment with X-Contentful-Source-Environment: develop, then the job polls (up to 60 × 5s) until its status is ready.
  2. Scope the delivery key to it. A freshly created environment is in no API key's scope, and an unscoped key introspects an empty schema (0 types) rather than erroring — a real field lesson. The step adds the ephemeral env to the pre-production delivery key so the later introspection can see it. The key is found by its SCOPE (develop, not master), never by name: when develop-env-key was renamed pre-production-env-key (14 Oct 2026), a lookup by name failed every model PR's gate.
  3. Lint and apply the pending migrations — lint-migrations.mjs (the expand/contract and ownership checks), lint-apps.mjs (the app registry), then migrate.mjs --env=$EPHEMERAL_ENV --skip-apps.
  4. Check the Contentful apps — sync-apps.mjs --env=$EPHEMERAL_ENV --check prints the plan the develop apply will carry out after merge, and fails only on an inconsistency no apply could repair (a lock naming an app definition the organisation no longer has).
  5. Regenerate against the migrated environment and fail on drift — sync-schema + generate against the ephemeral env, then git diff --exit-code on schema.graphql and src/__generated__. If the migrations change the schema but the committed artefacts don't match, the gate fails and tells you to run the migration against a sandbox locally and pnpm --filter @v4/model drift.
  6. Tear down (if: always()). Delete the ephemeral environment and unscope the key, whether the gate passed or failed.

Why an ephemeral clone

This is the same drift-gate pattern the rest of CI uses (regenerate, diff, fail), but applied against a live environment rather than a local sandbox — the only way to prove that the migration produces exactly the schema the committed types describe. It also keeps to the standing model rule: Actions validate, Flightcontrol mutates. The gate mutates only a disposable clone; it never touches develop, and certainly never a production-facing environment.

The ticket check

pr-ticket.yml (the pr ticket check) runs on every pull request into develop or a release/** branch — when it is opened, edited, reopened, pushed to, or labelled. It answers one question: does this PR name its Jira ticket?

Why it exists

Release tracking (scripts/ci/release-jira.mjs) works out which tickets a release build contains from the branch names and titles of the PRs merged into it — nowhere else. A PR that names its ticket only in the description or a commit body is invisible to it, so the ticket reaches Staging with no Fix Version and the PM's view of the release is missing it. See Jira release tracking.

What passes

The PR… Result
has an ELBWEB-<n> key in its branch name or title passes
has a housekeeping branch: chore/, ci/, docs/, build/, deps/ or release/ passes
has a housekeeping title type: chore:, ci:, docs:, build: or deps: (any scope) passes
carries the no-ticket label passes
anything else — including a fix/ or feat/ branch with no key fails

The project key is read from packages/tickets/tickets.config.json (jira.defaultProject, ELBWEB here). The failure message tells you to put the key in the branch (feat/ELBWEB-123-…) or the title, or to add the label.

Changing the title or adding the label re-runs the check, so there is no need to push again.

What it runs

Two steps, five-minute timeout, no secrets:

  1. node --test scripts/ci/release-lib.test.mjs — the release tracking helpers' own tests.
  2. node scripts/ci/pr-ticket-check.mjs — the check itself, reading the branch, title and labels from the event.

It is not one of develop's required checks, but treat it as one: a merged PR without a key is a ticket missing from the release.

Merging

PRs merge into develop (or, for a fix to a release, into its release/X.Y.Z branch). The PR title is a conventional commit naming its Jira key, because it becomes a release-note line and is how Jira learns the ticket is in a build.

What must be green

Branch protection on develop requires:

  • verify — the build (minus @v4/web), the drift gates, the app registry lint, type-check and the package unit suites.
  • lint — ESLint at zero warnings, plus Prettier format:check.
  • report — the test-report comment, which also enforces the 90% pass-rate floor, so a suite that completes but drops below the floor still fails.
  • One approving review.

Two more checks run but are not required by branch protection. Wait for them anyway:

  • pr ticket — the PR names its Jira ticket (see The ticket check).
  • model PR gate — only when the PR touched packages/model/migrations/**, packages/model/apps/** or packages/model/baseline/**.

The full e2e and Storybook story suites are not part of the PR gate — they run nightly. A PR does not wait on them.

The merge method

Use a merge commit ("Create a merge commit"). It is what the trunk is made of — 140 of the 180 commits to land on develop since 15 October — and it is the only safe method for stacked PRs: squashing a PR that others are based on rewrites the commits they were built on, and every PR above it re-conflicts. To check that a stack merges cleanly in order, simulate it with git merge-tree first.

A squash merge is allowed for a single, self-contained PR that nothing is based on. Either way the release notes come from the PR title, not the commits, so the method does not change the changelog — the title does.

After merging

  • Delete the branch.
  • A model change triggers model apply (develop) — migrations, the app registry or new dictionary keys are applied to the develop environment (see Develop automation).
  • Development redeploys. Every push to develop deploys the Flightcontrol Development environment; a broken app build is announced by the deploy watch.
  • The merge primes the Turbo cache on develop, so the next PRs replay from it instead of rebuilding cold.

Fixes to a release

A fix for something already on Staging is a PR into the release branch. It goes through the same CI and ticket check, deploys Staging on merge and builds the next rc. Bring the same fix into develop so the next release has it too.