Releasing
The release train: develop integrates, a release branch builds rc tags for Staging, Jira tracks each build, and a tag cuts the release.
The release train
A release moves through three branches and two hosted environments. As practised in this repo:
developintegrates. 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.- A release branch is cut. When a release is ready for staging,
release/X.Y.Zis cut fromdevelop. Before the cut, achore/staging-release-X.Y.ZPR intodeveloppoints Flightcontrol Staging at the new branch inflightcontrol.json— and sets any--untilcap or data scripts that release needs — so the branch carries its own hosting config. See Staging. - Every push to the release branch is a build.
release-build.ymltags itvX.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. - Every push deploys Staging. The Staging build migrates the
stagingContentful 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. - Fixes go into the release branch as PRs (CI and the ticket check run on
them), producing the next
rc, and the same fixes reachdevelop. release.ymlreleases the branch. A manual dispatch checks that CI passed on the branch's head, fast-forwardsmainto it, tagsvX.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:
- Guards that the ref is green. It reads the
check-runsfor the chosen commit, keeps those namedlint,verifyortest(CI has notestjob, so in practicelintandverify), and refuses to proceed unless there is at least one and all concludedsuccess. A ref with red or missing CI is rejected outright — CI runs on every push torelease/**so that a fix merged into the release branch has a result to check. - Fast-forwards
mainand tags. It movesmainto the guarded ref (git push origin HEAD:main --force-with-lease=main— an old pre-trainmainor a hotfix history may not be a clean ancestor, so the guarded ref wins) and pushes thev<version>tag. - 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. - 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 toSLACK_WEBHOOK_URL). Each push to the release branch has already tagged anrcbuild 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.Zif 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.Nand 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.