Environments and secrets

Development and Staging, the rule that Actions validate while deploys mutate, how Staging is migrated, and the secrets CI reads.

2026-11-07

Environments

There are three axes to keep straight: git branches, Flightcontrol (hosting) environments and Contentful environments. They line up, but the rule about who is allowed to mutate what is the important part.

The mapping

Git branch Flightcontrol environment Contentful environment Who migrates it
develop Development — web app (with Redis), these docs, Storybook develop GitHub Actions (model-apply-develop.yml)
release/X.Y.Z Staging — web app (with Redis) staging The Staging build itself (migrate.mjs)
main + vX.Y.Z Production — not yet provisioned — —

Both Flightcontrol environments are declared in flightcontrol.json (region eu-west-1) and deploy on a push to their branch. GitHub records them as the Development and Staging environments, which is what the deploy watch listens to.

There is no production environment yet. flightcontrol.json declares only Development and Staging, no vX.Y.Z tag has been cut, and main has not moved since 21 September 2026. Everything below that talks about production describes the contract the deploy pipeline is built for, not something running today.

Actions validate, deploys mutate

This is the governing rule for the content model:

  • GitHub Actions mutate only two things: the develop environment (via model-apply-develop.yml) and the ephemeral model-gate-pr<number> clones (via model-pr-gate.yml, which deletes them again). Everything else Actions do to the model is validation — introspect, regenerate, diff, fail.
  • Any other environment is migrated by its deploy. Today that means Staging, whose Flightcontrol build runs the migrations before it builds the app — see Staging. No workflow migrates it.

Protected environments

The migration runner refuses the protected environment names — master, production, staging, staging-release — unless both CI=true and ALLOW_PROTECTED_ENV=1 are set. Only the Staging build command sets them; a laptop never should. --env is always explicit, so there is no default that could point a local run at a protected environment by accident. The data scripts the Staging build runs carry the same guard.

Expand / contract

Because deployed code and the model move on separate timelines, every migration must be backwards-compatible with the currently deployed code: add optional first, and ship any destructive operation (deleteField, changeFieldId, required(true) on an existing field, deleteContentType) one release after the code that stopped depending on it, carrying a // contract: marker. lint-migrations.mjs enforces this in the model PR gate and again before the develop apply.

When a destructive migration has to wait for a data step on Staging, the Staging build caps the run with --until (next page).

This repo's space

Every environment above lives in the Euro League Contentful space 12gd2cvfgb6b, set once as the CONTENTFUL_SPACE_ID repo variable for the workflows (see Secrets & variables).

Staging

Staging is the Flightcontrol environment QA tests a release on. It deploys a release branch, not develop, and it migrates its own Contentful environment as part of its build.

What it runs

flightcontrol.json declares two services under the staging environment:

  • euro-league-web-staging — the web app, built from Dockerfile.web and run on Fargate, health-checked at /api/health;
  • euro-league-cache-staging — its Redis (ElastiCache) cache.

There is no Staging deploy of the docs or Storybook; those deploy from develop only, in Development.

One release branch at a time

The environment's source.branch names the release branch it deploys — release/0.4.0 today — and every push to that branch deploys it. Moving Staging to the next release is a commit to flightcontrol.json, made on a chore/staging-release-0.N.0 branch and merged into develop before the new release branch is cut, so the branch carries its own hosting config (for example 2cf3ce153, "Staging deploys release/0.4.0").

The same pushes build the release candidates: release-build.yml tags each push vX.Y.Z-rc.N and updates Jira, and a successful Staging deploy moves that release's tickets to Deployed to Staging. See Jira release tracking.

The build migrates the environment

The web service's BUILD_COMMAND is where Staging's model changes happen. In order, it:

  1. Migrates the staging Contentful environment — node packages/model/scripts/migrate.mjs --env=staging --until=<migration>, which also syncs the Contentful apps after migrating.
  2. Runs any one-off data scripts the release needs — for 0.4.0, backfill-article-competition.mjs --env=staging --apply and merge-players-into-profile.mjs --env=staging --apply. Each is safe to re-run, because every deploy runs them again.
  3. Builds the web app — pnpm turbo run build --filter=@v4/web.
  4. Syncs the webhooks and preview URLs — sync-webhooks.mjs and sync-preview-urls.mjs, whose failure does not fail the build.

Every Contentful step is prefixed with CI=true ALLOW_PROTECTED_ENV=1, which is what lets it past the runner's protected-environment guard. Nothing else sets those two variables.

The Development web service runs steps 3 and 4 only: its Contentful environment, develop, is migrated by GitHub Actions on merge instead.

--until: holding migrations back

migrate.mjs --until=<migration> applies the pending migrations up to and including the one named, and holds every later one back. A name that matches no migration is refused rather than read as "everything".

It exists for the case where a data step has to run between two migrations. In 0.4.0 the player copy (merge-players-into-profile.mjs) reads Associated Players, and club/0060-remove-associated-players deletes that field. So Staging migrates --until=club/0059-game-team-sides, runs the copy, and leaves 0060 onwards for a later deploy.

When a release changes what Staging needs, update the --until value and the data scripts in the same flightcontrol.json change, and remove them once they have run.

What a release branch inherits

A release branch is cut from develop, so its flightcontrol.json, workflows and scripts are develop's at the cut. A change to any of them reaches a release only when it is cut after the change merged, or when the change is brought into the branch by a PR — Flightcontrol and GitHub both run what is in the deployed commit.

Secrets & variables

The workflows read a small set of GitHub secrets (credentials) and one repo variable (non-sensitive configuration). Nothing a different space or board would need to change is hard-coded in the workflow files.

Secrets

The repository holds seven secrets:

Secret Used by For
NPM_TOKEN every job that installs Read-only granular token for the private @stadionhq-v3/platform-video-sdk-* packages on npm, and @dicetechnology/vesper-sdk-web (the ELTV player): it must read both scopes.
CONTENTFUL_CMA_TOKEN ci.yml, model-pr-gate.yml, model-apply-develop.yml, model-drift-watch.yml Contentful Management API — the model drift gate, the ephemeral-clone gate, the develop apply and the scheduled drift watch.
CONTENTFUL_ACCESS_TOKEN ci.yml (composition drift gate only) Contentful delivery token the club.config.ts parse needs when compose-club -- --check runs. CI's build step excludes @v4/web and takes no token.
SLACK_WEBHOOK_URL ci.yml, model-apply-develop.yml, model-drift-watch.yml, e2e-nightly.yml, deploy-watch.yml The house Slack channel: model updates and drift, a red develop, the nightly result, deploy failures and recoveries.
SLACK_RELEASE_WEBHOOK_URL release-build.yml, release.yml, deploy-watch.yml (staging-tickets) The release channel: each rc build, what reached Staging, the release. Each of these falls back to SLACK_WEBHOOK_URL when it is unset.
JIRA_EMAIL, JIRA_API_TOKEN release-build.yml, release.yml, deploy-watch.yml (staging-tickets) Release tracking in Jira (scripts/ci/release-jira.mjs): Fix Versions, the move to Deployed to Staging, marking a version released. Without them the Jira half is skipped with a warning and the tagging still happens.

ANTHROPIC_API_KEY is read by CI's docs-drift step but is not provisioned in this repository, so that step always skips with a notice. Docs drift is checked only by the advisory pre-commit hook, and only on a machine with the claude CLI.

GITHUB_TOKEN is the automatic per-run token, not something you provision. It posts the sticky test-report comment (report), reads the jobs API for the red trunk alert (announce-trunk), opens the apps-lock PR (model-apply-develop), reads deployments and pull requests (deploy-watch), and tags, reads check-runs and publishes releases (release-build, release).

Repo variables

Variable Value Used by
CONTENTFUL_SPACE_ID 12gd2cvfgb6b ci.yml, model-pr-gate.yml, model-apply-develop.yml, model-drift-watch.yml

Every workflow reads it as ${{ vars.CONTENTFUL_SPACE_ID }} with no fallback. If the variable were missing the jobs would fail naming it, rather than quietly gate against another space — the model scripts refuse to run without a space id for the same reason.

Non-secret values inlined in CI

CI's composition drift gate sets two NEXT_PUBLIC_* values inline, because they are public by definition (they ship in the client bundle) and the club.config.ts parse expects them:

  • NEXT_PUBLIC_VIDEO_API_BASE_URL — https://mufc-stg.stadion.io/video-public, a placeholder inherited from the template;
  • NEXT_PUBLIC_BRIGHTCOVE_ACCOUNT_ID — likewise an inherited placeholder.

They only feed the drift check, not a deployed app, so they are not this site's values and do not need to be.

Hosting secrets live in Flightcontrol

The deployed apps' environment variables — the Contentful delivery tokens, the video platform URL, the SSO and analytics settings and the rest — are set in Flightcontrol per environment, not in GitHub. flightcontrol.json holds only the non-secret ones (NODE_ENV, NODE_OPTIONS, the Redis connection reference and the build command). The full list is in the environment variables reference.

Configuration, never edited workflows

The workflows read their space, tokens and webhooks from secrets and variables, never from values written into the YAML. Keep it that way: pointing a workflow at a different space or board is a settings change, and the workflow files stay the same files /sync-upstream offers updates to.