GitHub Actions
The workflows that automate this repo, what merging to develop triggers, and what each one posts to Slack.
The workflows
Nine workflows live in .github/workflows/. Together they gate PRs, keep the
develop environment in step with the model, watch the live model and the
deploys, build release candidates for Staging and cut releases.
| Workflow | File | Trigger | What it does |
|---|---|---|---|
| CI | ci.yml |
pull_request; push to main, develop, release/** |
Build (minus @v4/web), the drift gates (tokens, model schema, docs, composition, app codegen), the Contentful app registry lint, type-check, unit suites, a sticky test-report comment, a red-trunk alert. |
| pr ticket | pr-ticket.yml |
pull_request into develop or release/** |
Fails a PR that names no Jira ticket in its branch or title (housekeeping and the no-ticket label pass). |
| model PR gate | model-pr-gate.yml |
pull_request touching packages/model/migrations/**, packages/model/apps/** or packages/model/baseline/** |
Clones an ephemeral environment from develop, applies migrations, checks the Contentful apps, regenerates, and fails on schema/type drift. One serialised slot. |
| model apply (develop) | model-apply-develop.yml |
push to develop touching packages/model/migrations/**, packages/model/apps/** or packages/model/src/dictionary/keys.ts; workflow_dispatch |
Applies pending migrations to the develop environment, syncs the Contentful apps, seeds new dictionary keys, and announces what changed. |
| model drift watch | model-drift-watch.yml |
schedule every 3h (plus 08:05 UTC); workflow_dispatch |
Introspects develop and reports to Slack when the live content model stops matching packages/model. |
| e2e nightly | e2e-nightly.yml |
schedule 02:30 UTC; workflow_dispatch |
Full cross-browser Playwright e2e (8 shards) plus the full Storybook story suite, on develop, and one Slack message a night with the result. |
| release build | release-build.yml |
push to release/**; workflow_dispatch (release_branch, apply) |
Tags the build vX.Y.Z-rc.N, publishes a GitHub pre-release, gives the release's Fix Version to its ready tickets in Jira, and reports to Slack. |
| release | release.yml |
workflow_dispatch (version, ref defaulting to release/<version>) |
Guards the ref is green, fast-forwards main, tags vX.Y.Z, publishes a GitHub Release, marks the Jira version released, and reports to Slack. Never run in this repo yet. |
| deploy watch | deploy-watch.yml |
deployment_status |
Reports a failed (or recovered) Flightcontrol deploy to Slack; after a successful Staging deploy, moves the release's tickets to Deployed to Staging. |
The design in one line each
- CI is the per-PR gate. It is deliberately trimmed — e2e and the story suite are not here — so PR latency stays reasonable on 2-core runners. See The CI gate.
- pr ticket keeps release tracking whole: Jira learns which tickets are in a build only from PR branch names and titles. See Jira release tracking.
- model PR gate proves a migration against a real throwaway environment before it can merge. See The model PR gate.
- model apply (develop) is the one automation that mutates a persistent
Contentful environment on merge — and only
develop. Covered on the next page. - release build and release are the release train: the first runs on every push to a release branch, the second is a guarded manual dispatch. See Releasing.
- e2e nightly owns the heavy coverage that PR CI drops, so engine-specific regressions surface within a day. It reports to Slack every night, pass or fail — see below. Covered on the next page.
- deploy watch listens to the deployment statuses Flightcontrol already
reports to GitHub. It exists because CI deliberately does not build
@v4/web, so a broken app build fails only at deploy — see below.
The Contentful apps installed in the space are built and released from the template repository, not from here, so there is no apps release workflow in this repo.
The model announces itself — four ways
On 28 September a player content type was created in the Contentful web app
at 09:56 with no migration behind it. Nothing said so. It surfaced nine minutes
later only because an unrelated PR merged and went red on CI's drift gate, and
it took three red PRs before anyone read the reason.
The model is the one thing in this repo a person can change from OUTSIDE it, so
it is the one thing that has to say so out loud. Four events post to the same
Slack channel as the nightly, via scripts/ci/model-slack.mjs:
| Event | Fires from | Says |
|---|---|---|
| Model updated | model-apply-develop.yml, after a merge |
Which migrations, which environment, who merged them — plus dictionary keys seeded and Contentful app changes |
| Update failed | model-apply-develop.yml, on a failure |
Which migration it stopped at, and whether anything reached Contentful before it did |
| Drift detected | model-drift-watch.yml, every 3 hours |
Which content type is in the space and not in the repo (or the reverse) |
| Drift gate failed | ci.yml, scoped to that step |
The same, for the PR or push that hit it |
The apply job makes three mutations, and the message counts all three:
migrations, dictionary keys, and syncApps({ mode: "apply" }) — which creates
app definitions, installs them and rewrites field bindings in the live space. A
merge that touched only the app registry applied no migration and seeded no key,
so until the apps count was added it posted nothing at all.
Four decisions worth keeping:
- Silence is the normal case. The reporter writes no payload at all when a merge applied nothing, or when the model is in sync — and the workflows only post when the file exists. The apply message would otherwise fire on every merge and stop being read.
- Except once a day. The 08:05 drift-watch run posts an all-clear even when clean, because a watch that has silently stopped firing is indistinguishable from a model that never drifts.
- It names the CONTENT TYPE, not the schema types. Contentful's GraphQL
schema generates six definitions per content type (
Player,PlayerCollection,PlayerCursorCollection,PlayerLinkingCollections,PlayerFilter,PlayerOrder), so matchingtype Xreported one hand-made type as four separate additions. Only the base type implementsEntry. - The CI hook is SCOPED to its step (
steps.model_drift.outcome). A bareif: failure()fires when any earlier step failed, so a broken build would have announced itself as model drift — a false alarm about the one signal that has to stay trustworthy. - A FAILED apply is the case silence gets wrong. The announce step always
ran (
if: always()), but with nothing applied there was nothing to report — so a migration that broke half way posted exactly the same as an ordinary merge: nothing. It is now passed the outcome of the two steps that mutate the model, and distinguishes three states, because a migration is not atomic: N applied then the next failed (half done), the first one failed part way (some fields may exist), and nothing reached Contentful at all. Collapsing them claimed "part of it went through" on a run where none of it had.
How a message is built
Every message comes from scripts/ci/slack-blocks.mjs — one module, because two
scripts building Block Kit by hand drift apart, and what drifts is always the
part nobody re-reads: the fallback text (what a phone notification and a screen
reader show), Slack's <url|label> link form, and the silent caps (header 150
chars, section 3000, ten fields of 2000, fifty blocks). The builders truncate
rather than let Slack drop a block.
Every alert has the same shape, built by message() rather than by hand:
<headline> what happened, in words
<lead> the consequence, one sentence
▸ Details · <shape> collapsed; everything else lives here
[ buttons ] the action, always visible
<footer> how it was found
Block Kit has no accordion element, but the container block takes
is_collapsible. Give that container's title the SHAPE of what is inside
(Details · 1 migration · 4 keys), because collapsed is the state nearly
everyone sees; a bare "Details" makes a reader click to find out whether clicking
was worth it. A message with no depth — the daily all-clear, a recovered deploy —
gets no empty disclosure.
It is a HELPER and not a convention, because "summary visible, depth one click" is a rule about all of them, and five hand-assembled block lists drift apart.
No emoji in a headline. Slack renders an emoji in a header block at roughly
double the text size, so a status dot dominates the message it is meant to
annotate. The headline already says "failed"; the button's danger style carries
the colour, quietly and where it is useful.
Lists are table blocks, not fenced code. mrkdwn gives you one tool for a
list — a code fence — so everything became monospace whether it was code or not.
A real table has aligned columns, a header row, right-aligned counts and wrapped
paths, and it is an allowed child of a collapsible container, which is what makes
the two work together.
The lead is never empty. The nightly used to open straight into a field grid, so the message said nothing at a glance — the counts ARE the news there, and they belong in the sentence, not only behind the click.
One block type to avoid: alert looks perfect for a failure (it takes
level: error) and is modals-only. Adding it to a message is the silent kind
of mistake — container and table say "Messages" in their availability
metadata and alert does not.
A rejected payload is worse than an ugly one
container and table are recent, and this repo cannot prove they render
through an incoming webhook without posting one. For an alerting system the
unacceptable failure is not an ugly message — it is a rejected payload and
therefore NO message, which is indistinguishable from nothing being wrong.
So every workflow posts through scripts/ci/slack-post.mjs, which replaced four
copies of the same curl --fail and adds what a copied curl could not: on a 4xx
it retries with degrade(), which flattens a container to its title plus its
children and a table back to the fenced block it replaced. Same content, blocks
Slack has had for years. It then emits a ::warning:: naming Slack's reason —
loud on purpose, because a silent permanent fallback would leave the flattening
in place for months. A 5xx is Slack being unwell and is not retried; both
payloads failing is a red step.
Proven against a local server that rejects container/table with Slack's own
400 invalid_blocks: the rich payload goes first, the flattened one lands, and
the alert still arrives.
The drift watch is READ-ONLY: it introspects, regenerates and diffs, and commits nothing. Resolving drift by regenerating the schema is precisely the move that makes a bypassed migration look official, which is what the gate exists to stop.
The nightly reports to Slack — on green too
A report job (if: always(), needs: [full-matrix, story-tests]) downloads
every shard's Playwright JSON and the story suite's vitest JSON, and
scripts/ci/nightly-slack.mjs turns them into one Block Kit message posted to
the SLACK_WEBHOOK_URL repo secret.
Three decisions worth keeping:
- It posts on GREEN as well as red. This exists because the nightly was red every night from 17 September to 28 September and nobody saw it: e2e is out of PR CI, so a failing nightly told no one. A workflow that silently stops firing looks exactly like a passing one, and the daily tick is the only thing that tells those two apart.
- A shard that leaves no report is named as UNMEASURED, never counted as
zero failures. Each shard uploads its JSON with
if: always(), and the message carries ann/8 shards reportingcount plus an explicit warning when any are missing. "Nothing was measured" and "nothing failed" must not look alike. - Failures are DEDUPED by test. The full matrix runs every spec on four engines, so one broken assertion reports four times: the run that prompted this had 84 raw failures that were 40 distinct tests. The message counts the distinct ones and lists the specs they sit in.
The Slack step is skipped cleanly when SLACK_WEBHOOK_URL is unset, so a fork
or a not-yet-wired clone does not fail its nightly over a missing webhook.
When the trunk or the deploy breaks
Two more alerts, both closing a gap that was found by asking what was still silent rather than by anything going wrong:
developgoing red (ci.yml→announce-trunk,scripts/ci/trunk-slack.mjs). CI announced exactly one failure — the model drift gate — so every other way the trunk can break told nobody, and the shared baseline could sit red while whoever cut a branch from it spent the morning debugging someone else's failure. It names the failed STEPS, read from the jobs API, because "CI failed" sends you hunting. It is scoped to a push ondevelop: on a pull request the red check is already in front of the author, and announcing those would bury this one. It stands down when the drift gate was the failure, since that message is more specific and has already gone out.- A failed deploy (
deploy-watch.yml,scripts/ci/deploy-slack.mjs). CI excludes@v4/webfrom the build on purpose — its production build prerenders from Contentful and needs a delivery token at build time, so building it in CI only ever produced a false red. The consequence is that a broken web build cannot fail CI; it fails at deploy, which is how the 8KB GraphQL query bug reacheddevelop. Flightcontrol already reports its deploys to GitHub as deployment statuses, so this needed no dashboard setup and no second webhook.
Three things to know about the deploy watch specifically:
- Flightcontrol's word for a broken deploy is
error, notfailure, and it also emitsin_progressandinactiveon every deploy. Both failure words are handled and the noise is dropped. - A successful deploy is announced only when it ends an outage. Nine
successful deploys in a day is normal; announcing them all trains everyone to
mute the channel, and then the failure goes unread too — which is the whole
point of having it. Working out whether it is a recovery means walking back
through the environment's earlier deployments, because
inactiveis stamped on the previous deployment whenever a new one succeeds, so its latest status is not its outcome. - A
deployment_statusworkflow runs the file as it is in the DEPLOYED commit, not on the default branch. A Staging deploy ofrelease/X.Y.Zruns that branch's copy, so a change here reaches a release only when the branch is cut after it lands ondevelop, or when it is brought into the branch.release/0.1.0was cut before thestaging-ticketsjob existed, and its first Staging deploy moved nothing. - A successful Staging deploy also moves tickets. The
staging-ticketsjob runsrelease-jira.mjs stagingfor the deployed commit and posts what moved to the release channel.
What lives where
Branch protection on develop requires three checks — verify, lint and
report — plus one approving review. pr ticket and (when it arms) the
model PR gate run on PRs too, and must be green by convention, but are not
required checks.
The automations that react to a merge into develop are
model-apply-develop.yml (when the model changed), CI itself (re-run on the push
to hold the green baseline and announce a red trunk), and the Flightcontrol
Development deploy. A push to a release branch runs CI and release-build.yml
and deploys Staging.
Five workflows accept workflow_dispatch: release.yml (dispatch only),
release-build.yml, model-apply-develop.yml, e2e-nightly.yml and
model-drift-watch.yml. Two run on a schedule — e2e-nightly.yml (02:30 UTC)
and model-drift-watch.yml (every three hours). deploy-watch.yml is the only
one triggered by something outside GitHub Actions entirely: Flightcontrol, via
the deployment statuses it already reports.
Develop automation
Three things key off develop rather than only off a PR: the model-apply job
that runs when a model change merges, the nightly full test matrix, and the
Flightcontrol Development deploy.
model apply (develop)
model-apply-develop.yml runs on a push to develop whose diff touches
packages/model/migrations/**, packages/model/apps/** or
packages/model/src/dictionary/keys.ts, and on demand via workflow_dispatch.
It is the one Action that mutates a persistent Contentful environment — and, per
the model contract, that environment is only ever develop. Staging is
migrated by its own Flightcontrol build (see
Staging).
Its concurrency group is model-apply-develop with cancel-in-progress: false,
so migrations never race each other onto the environment. The apply job
(20-minute timeout):
- Lints and applies pending migrations, then syncs the Contentful apps —
lint-migrations.mjs,lint-apps.mjs, thenmigrate.mjs --env=develop, which runssync-appsafter migrating (app definitions, installs, field bindings). The runner writes the migration ledger; nobody edits it by hand. - Opens a PR for the apps lock — if the sync wrote
packages/model/apps/apps.lock.json, the job pushes it on achore/apps-lock-<run id>branch and opens a PR intodevelop, becausedeveloponly takes pull requests. Normally a no-op. - Seeds new dictionary keys —
sync-dictionary.mjs push. This only ever creates and publishes missing keys; it never updates or deletes an existing label (labels belong to editors), so the step is idempotent on every run. - Announces what reached
developin Slack (see The workflows) — or, if a step failed, how far it got.
It reads CONTENTFUL_CMA_TOKEN and the CONTENTFUL_SPACE_ID repo variable
(12gd2cvfgb6b), with no fallback space.
Because this runs after the model PR gate has already proven the migration
against an ephemeral clone, the apply to develop is applying a change that has
been verified against a real environment.
e2e nightly (the full matrix)
e2e-nightly.yml runs on a schedule (cron: 30 2 * * * — 02:30 UTC) and on
demand via workflow_dispatch, always against develop. It owns the coverage
that PR CI deliberately drops, so engine-specific regressions surface within a
day instead of at release time.
It has two jobs:
full-matrix— the full Playwright e2e suite across every engine (E2E_FULL_MATRIX=1makes WebKit and Firefox run every spec, not just the cross-browser risk subset the per-PR job would have run). It fans out across 8 shards (--shard=n/8),fail-fast: false, with a 150-minute ceiling, and uploads the Playwright report only on failure.story-tests— the full Storybook browser-mode story suite: the axe gate and play functions across every component, which needs 30+ minutes on a 2-core runner. A first step frees ~30 GB of unused preinstalled toolchains (dotnet, Android, GHC, CodeQL) because the suite had grown past the hosted runner's free disk and was dying with "No space left on device".
Both jobs carry the same NODE_OPTIONS=--max-old-space-size=6144 memory ceiling
as CI. The workflow's comment still gives the old reason — a duplicated theme
layer — which no longer holds: theme emission has been owner-keyed since
9 September 2026. The ceiling is kept as headroom.
Flightcontrol Development deploy
Every push to develop also deploys the Flightcontrol Development
environment: the web app (Docker on Fargate, with its Redis cache), these docs
(apps/docs/out, static) and Storybook (apps/storybook/storybook-static,
static). It is not a GitHub workflow, but it is the first place @v4/web is
built for real, so a broken app build shows up here — announced by the deploy
watch. The web build runs pnpm turbo run build --filter=@v4/web, then syncs the
Contentful webhooks and preview URLs; it does not migrate anything.