GitHub Actions

The workflows that automate this repo, what merging to develop triggers, and what each one posts to Slack.

2026-11-07

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 matching type X reported one hand-made type as four separate additions. Only the base type implements Entry.
  • The CI hook is SCOPED to its step (steps.model_drift.outcome). A bare if: 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 an n/8 shards reporting count 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:

  • develop going 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 on develop: 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/web from 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 reached develop. 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, not failure, and it also emits in_progress and inactive on 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 inactive is stamped on the previous deployment whenever a new one succeeds, so its latest status is not its outcome.
  • A deployment_status workflow runs the file as it is in the DEPLOYED commit, not on the default branch. A Staging deploy of release/X.Y.Z runs that branch's copy, so a change here reaches a release only when the branch is cut after it lands on develop, or when it is brought into the branch. release/0.1.0 was cut before the staging-tickets job existed, and its first Staging deploy moved nothing.
  • A successful Staging deploy also moves tickets. The staging-tickets job runs release-jira.mjs staging for 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):

  1. Lints and applies pending migrations, then syncs the Contentful apps — lint-migrations.mjs, lint-apps.mjs, then migrate.mjs --env=develop, which runs sync-apps after migrating (app definitions, installs, field bindings). The runner writes the migration ledger; nobody edits it by hand.
  2. Opens a PR for the apps lock — if the sync wrote packages/model/apps/apps.lock.json, the job pushes it on a chore/apps-lock-<run id> branch and opens a PR into develop, because develop only takes pull requests. Normally a no-op.
  3. 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.
  4. Announces what reached develop in 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=1 makes 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.