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.
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:
- Validate the vendored Token Studio export —
turbo check --filter=@v4/tokens. - Build —
pnpm turbo build --filter=!@v4/web, Turbo-cached.@v4/webis 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. - Generated-tokens drift gate — regenerate
@v4/tokensand fail on any diff inpackages/tokens/src/__generated__. - Model schema + generated types drift gate — first asks
migrate.mjs --env=develop --dry --skip-appswhether the PR carries migrationsdevelophas not run. If it does, the live schema cannot match yet, so the step skipssync-schema(proving that case is the model PR gate's job) and checks the committed artefacts offline. Otherwise it runssync-schema. Either way it then runsgenerateand fails on any diff inschema.graphql/src/__generated__, EXCLUDINGsrc/__generated__/dictionary(editorial copy Contentful owns, so an editor's label edit is not a model change). UsesCONTENTFUL_CMA_TOKENand theCONTENTFUL_SPACE_IDvariable. - 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.
- 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 needsANTHROPIC_API_KEY, which is not provisioned in this repo, so the step always skips with a notice. - Composition drift gate —
compose-club -- --checkregenerates the shims and model composition and fails on any diff. UsesCONTENTFUL_ACCESS_TOKEN. - App codegen drift gate — regenerate
@v4/web,@v4/content-appand@v4/video-appcodegen and fail on any diff in their__generated__trees. - Contentful app registry lint —
node packages/model/scripts/lint-apps.mjs. - Type-check —
pnpm check-types. - Test (package unit suites) —
turbo test, excluding@v4/e2eand@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 addwould sweep those unstaged hunks into the commit. That is the classiclint-stagedsurprise 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
- Clone an ephemeral environment from
develop. A CMAPUTcreates the environment withX-Contentful-Source-Environment: develop, then the job polls (up to 60 × 5s) until its status isready. - 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-keywas renamedpre-production-env-key(14 Oct 2026), a lookup by name failed every model PR's gate. - Lint and apply the pending migrations —
lint-migrations.mjs(the expand/contract and ownership checks),lint-apps.mjs(the app registry), thenmigrate.mjs --env=$EPHEMERAL_ENV --skip-apps. - Check the Contentful apps —
sync-apps.mjs --env=$EPHEMERAL_ENV --checkprints 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). - Regenerate against the migrated environment and fail on drift —
sync-schema+generateagainst the ephemeral env, thengit diff --exit-codeonschema.graphqlandsrc/__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 andpnpm --filter @v4/model drift. - 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:
node --test scripts/ci/release-lib.test.mjs— the release tracking helpers' own tests.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 Prettierformat: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 touchedpackages/model/migrations/**,packages/model/apps/**orpackages/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 thedevelopenvironment (see Develop automation). - Development redeploys. Every push to
developdeploys 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.