Releasing

The release train: develop integrates, a release branch builds rc tags for Staging, Jira tracks each build, and a tag cuts the release.

2026-11-07

The release train

A release moves through three branches and two hosted environments. As practised in this repo:

  1. develop integrates. PRs merge here behind the required checks, and every push deploys Flightcontrol Development. Tickets are QA'd on Development and moved to Ready to Deploy (Stg) when they pass.
  2. A release branch is cut. When a release is ready for staging, release/X.Y.Z is cut from develop. Before the cut, a chore/staging-release-X.Y.Z PR into develop points Flightcontrol Staging at the new branch in flightcontrol.json — and sets any --until cap or data scripts that release needs — so the branch carries its own hosting config. See Staging.
  3. Every push to the release branch is a build. release-build.yml tags it vX.Y.Z-rc.N, publishes a GitHub pre-release whose notes are the build's ticket report, gives the release's Jira Fix Version to the tickets in it that are Ready to Deploy (Stg), and posts the build to the release channel.
  4. Every push deploys Staging. The Staging build migrates the staging Contentful environment, runs the release's data scripts and builds the app. On success, the deploy watch moves that release's tickets to Deployed to Staging, ready for QA there.
  5. Fixes go into the release branch as PRs (CI and the ticket check run on them), producing the next rc, and the same fixes reach develop.
  6. release.yml releases the branch. A manual dispatch checks that CI passed on the branch's head, fast-forwards main to it, tags vX.Y.Z, publishes the GitHub Release and marks the Jira version released.

The Jira half of this is on Jira release tracking.

Where the train stops today

Steps 1–5 are running: release branches release/0.1.0 to release/0.4.0 exist, with pre-releases v0.1.0-rc.1 to v0.4.0-rc.2, and Staging deploys release/0.4.0.

Step 6 has never been run. No final vX.Y.Z tag exists, main has not moved since 21 September 2026, and there is no production environment in flightcontrol.json for a release to deploy to. Until production is provisioned, "released" means "on Staging and signed off", and the release workflow is untested in this repo.

What a release will mean for production

The intended contract is that production builds from main / the vX.Y.Z tag, and that its Contentful environment is migrated inside its own deploy, the way Staging's is today — never by GitHub Actions, which only ever migrate develop and their own ephemeral clones. When production is provisioned, this page and Environments need updating with how it is actually wired.

The mechanics of the dispatch are on the next page, Cutting a release.

Cutting a release

A release is cut by hand from the Actions tab — it is never a push. release.yml is a workflow_dispatch with a guard, so main cannot move to a ref whose CI is not green.

It has not been run in this repo yet. Only rc pre-releases exist, main is still where it was on 21 September 2026, and there is no production environment for a release to reach. Treat the first run as a test of the workflow as well as a release.

A release branch carries its own release tooling

Every workflow a release branch triggers runs as that branch has it, not as develop has it: a push to release/X.Y.Z runs the branch's release-build.yml, and a Staging deploy of it runs the branch's deploy-watch.yml (a deploy runs the workflow from the deployed commit). So a change to the release tooling reaches a release only when the branch is cut after it merges to develop, or when the changed files are brought into the branch by a PR. release/0.1.0 was cut before the tooling existed, and its first Staging deploy moved no tickets until the tooling was brought in.

Running it

Actions → release → Run workflow, then supply:

Input Required Default Meaning
version yes — The release version, e.g. 1.4.0 (tagged v1.4.0).
ref no release/<version> The ref to release — normally left empty, so the release branch.

Leave ref empty. The release branch is what QA tested on staging; the job fails if release/<version> does not exist. (It used to default to develop's HEAD, which released everything merged since the cut, none of it tested on staging.)

The job (15-minute timeout, concurrency: release so two releases can't race) does four things:

  1. Guards that the ref is green. It reads the check-runs for the chosen commit, keeps those named lint, verify or test (CI has no test job, so in practice lint and verify), and refuses to proceed unless there is at least one and all concluded success. A ref with red or missing CI is rejected outright — CI runs on every push to release/** so that a fix merged into the release branch has a result to check.
  2. Fast-forwards main and tags. It moves main to the guarded ref (git push origin HEAD:main --force-with-lease=main — an old pre-train main or a hotfix history may not be a clean ancestor, so the guarded ref wins) and pushes the v<version> tag.
  3. Publishes the GitHub Release. gh release create v<version> --generate-notes --target main — the notes are generated from the titles of the PRs merged since the previous release, which is why those titles must be conventional commits.
  4. Marks the Jira version released, dated today (scripts/ci/release-jira.mjs released), and posts the release to the release channel (SLACK_RELEASE_WEBHOOK_URL, falling back to SLACK_WEBHOOK_URL). Each push to the release branch has already tagged an rc build and given the Fix Version to its tickets in Ready to Deploy (Stg), the ones QA passed on develop (release-build.yml); a ticket in any other status gets none until a later build. Each Staging deploy moved the tickets in Ready to Deploy (Stg) that carry the Fix Version to Deployed to Staging (deploy-watch.yml).

After the tag

The tag is meant to be the hand-off to a production deploy that builds from main. That environment does not exist yet — flightcontrol.json declares only Development and Staging — so today nothing deploys from main or a vX.Y.Z tag. See The release train.

Fixes and patch releases

A fix to a release in progress goes into its release branch as a PR, producing the next rc. For a patch to a release that has already been cut, dispatch release with the patch version (e.g. 0.4.1) and its ref; the green guard still applies, so the ref must have passed CI. Neither patch flow has been exercised here yet.

Versioning & changelog

The scheme

Releases are numbered X.Y.Z and named after their release branch: release/0.4.0 builds the pre-release tags v0.4.0-rc.1, v0.4.0-rc.2, … and is eventually released as v0.4.0 on main. The same number is the Jira Fix Version the release's tickets carry, which release-jira.mjs creates on the first build of the branch — so the version is how the PM, QA and the code talk about the same release.

Releases so far are 0.1.0 to 0.4.0, all pre-1.0 and all pre-releases only. Each release branch has taken the next minor number. Pick the number when cutting the branch: it is fixed from then on, because the branch name, the tags and the Jira version all carry it.

The site's version is its own

The Euro League site has its own release history and tags. The code it inherits is tracked separately, as provenance: the templateCommit in .claude/manifest.json, which /sync-upstream advances each time a template update is merged (see Take template updates). The two never couple — taking a template update does not change the site's version.

The changelog

Each rc pre-release's notes are its build report from release-jira.mjs: the pull requests and tickets in the build, and what needs the PM's attention.

The final GitHub Release's notes are generated by gh release create --generate-notes from the titles of the PRs merged since the previous release — which is why those titles must be Conventional Commits naming their ticket. The title is the changelog line a reader sees. Write it for them.

There is no committed CHANGELOG.md; the GitHub Releases are the changelog.

Jira release tracking

Jira's view of a release — which tickets are in it, which are on Staging, when it was released — is kept in step by scripts/ci/release-jira.mjs, run by three workflows. It is release tooling, separate from the ticket-authoring tools in packages/tickets; the only thing it shares with them is the project key (jira.defaultProject in packages/tickets/tickets.config.json, ELBWEB).

How a build knows its tickets

From the pull requests merged into the build since the previous release — their branch names and titles, and nothing else. A key mentioned only in a PR description or a commit body is not seen (the report flags a key it found in a commit message whose PR named none). That is why every PR must name its ticket, and why the ticket check fails one that does not.

An epic named in a branch (feat/ELBWEB-356-…) is treated as the work's parent, not work in the build: it gets no Fix Version and is listed separately.

The statuses

Status Meaning here Moved by
Ready to Deploy (Stg) QA passed on Development; waiting for a release build QA, by hand
Deployed to Staging In a build that has deployed to Staging; ready to test there deploy-watch.yml, automatically

Only a ticket in Ready to Deploy (Stg) is given the Fix Version or moved (PM ruling, 15 October 2026). Code reaching Staging is not the same as a ticket being ready to test there, so nothing else moves.

The three runs

On every push to release/X.Y.Z — release-build.yml runs release-jira.mjs build --tag --apply:

  • creates the Jira version X.Y.Z if it does not exist yet;
  • adds that Fix Version to every ticket in the build that is in Ready to Deploy (Stg) — a ticket in any other status gets none, until a later build finds it ready;
  • tags the commit vX.Y.Z-rc.N and publishes a GitHub pre-release whose notes are the build report;
  • reports what needs the PM's attention: tickets in the build that are not ready, tickets only partly in, tickets given the Fix Version before QA passed, and tickets carrying the Fix Version that are not in the build. It never removes a Fix Version — that is the PM's call.

A re-run on a commit already tagged reuses the tag and posts nothing new. Dispatching it from the Actions tab (from develop, naming release_branch) reports on a branch, or backfills one cut before the workflow existed; leave apply unticked for a report only.

After a successful Staging deploy — deploy-watch.yml (staging-tickets) runs release-jira.mjs staging for the deployed commit and branch, moving the release's Ready to Deploy (Stg) tickets that carry its Fix Version to Deployed to Staging.

When the release is cut — release.yml runs release-jira.mjs released, marking the Jira version released with today's date. Not yet run in this repo.

Slack and secrets

Each run posts to the release channel through scripts/ci/slack-post.mjs: SLACK_RELEASE_WEBHOOK_URL, falling back to SLACK_WEBHOOK_URL. Jira is reached with JIRA_EMAIL and JIRA_API_TOKEN; without them the Jira half is skipped with a warning (posted to Slack too) and the tagging still happens.

Running it locally

The script is dry by default — without --apply it writes nothing to Jira, GitHub or Slack, and prints the report:

node scripts/ci/release-jira.mjs build --ref=origin/release/0.4.0

It needs JIRA_EMAIL and JIRA_API_TOKEN in the environment for the Jira half, and an authenticated GitHub CLI (gh, or GH_TOKEN) to read the merged PRs. Its pure helpers are tested with node --test scripts/ci/release-lib.test.mjs.

A release branch runs its own copy

A push to release/X.Y.Z runs that branch's release-build.yml, and a Staging deploy runs that branch's deploy-watch.yml. A change to the release tooling reaches a release only when the branch is cut after it merges, or when it is brought into the branch by a PR — release/0.1.0 needed exactly that before its Staging deploys moved any tickets.