Releasing
A TRUSCA release is driven entirely by pushing a vX.Y.Z git tag. The
.github/workflows/release.yml
workflow does the rest. The design goal is that no release is ever announced
before it has been proven installable from the exact images users will pull.
The gate at a glance
The workflow runs four stages in order, each depending on the previous one:
build— builds each image (trusca-backend,trusca-backend-worker,trusca-frontend) on native amd64 + arm64 runners and pushes them to GitHub Container Registry by digest.merge— assembles a multi-arch manifest list per image and applies the version tags (X.Y.Zimmutable,X.Ymovable — never:latest).release— creates the GitHub Release as a draft. Notes come fromdocs-site/docs/release-notes/X.Y.Z.mdwhen present, otherwise from GitHub's auto-generated notes. The job also generates a CycloneDX SBOM of the release's own source tree (syft) and attaches it as a Release asset (trusca-X.Y.Z.cdx.json) — an SCA product ships its own SBOM.release-gate— pulls the freshly publishedX.Y.Zimages, boots the productiondocker-compose.yml(with the smalldocker-compose.smoke.ymloverlay that publishes the backend + frontend ports so the smoke can run without Traefik/DNS/TLS), and runs the documented Quickstart first-scan smoke: health poll →create_super_admin→ login → projects API. On success it runsgh release edit <tag> --draft=false --latestto reveal the Release, then asksdeploy-hetzner.ymlto roll the public demo forward to that tag. That deploy waits on thedemoEnvironment's required reviewer, so a release never reaches the demo host unattended.
build ──▶ merge ──▶ release (draft) ──▶ release-gate ──▶ reveal (draft=false)
push tag GitHub Release pull + boot public + latest
by version stays hidden published images only if smoke
digest tags + first-scan smoke passed
latest tag left in the registryReleases up to 0.21.0 pushed a latest image tag as well: docker/metadata-action
adds one unless you opt out (flavor: latest=auto), and the workflow did not.
It does now, so no new one appears. The tags already in GHCR cannot simply be
removed — GHCR deletes package versions, not tags, and latest shares its
version with 0.21.0 and 0.21. Untag it before the next release: push a
throwaway manifest to latest, then delete that version. Left alone, latest
keeps pointing at 0.21.0 forever while claiming to be current.
Why images publish before the Release is revealed
The container images are published in build + merge, before the Release
exists. This is deliberate: the gate can only prove a release is installable by
pulling and running the actual published images the way an operator would. The
Release is the human-facing announcement, so it is held back — as a draft —
until that proof succeeds.
Failure semantics
If any release-gate step fails, the reveal step is skipped (it has no
if: always() guard, so it only runs on the success path). The result is:
- The image tags stay published and pullable.
X.Y.ZandX.Ywere pushed in themergestage and are not rolled back. An operator can still pull them, and a re-run of the workflow reuses them. - The GitHub Release stays a draft. It is not visible on the Releases page,
is not marked
latest, and does not notify watchers. Nothing announces a release whose images failed to boot.
To recover, fix the underlying problem and re-run the workflow for the same tag
(or dispatch it manually with the tag input). The release job is idempotent:
it leaves an existing draft untouched, and release-gate re-pulls the same
published images and re-runs the smoke. Only when the smoke passes does the
draft flip to public.
If you have independently verified a release whose gate is failing for an
unrelated (e.g. infrastructure) reason, a maintainer can reveal it by hand with
gh release edit vX.Y.Z --draft=false --latest. Prefer fixing the gate.
Cutting a release
- Refresh the vendored endoflife.date snapshot so the release ships current
lifecycle data (EOL verdicts are stamped offline from this file):
python3 scripts/refresh_eol_snapshot.pyfromapps/backend, and commit the updated snapshot with the release-prep changes. - Documentation sweep — the release ships its docs, so before tagging:
- Write the release notes at
docs-site/docs/release-notes/X.Y.Z.md(EN + KO mirror, wired intosidebars.ts), sourced from the[Unreleased]section ofCHANGELOG.md— then move those entries under a new[X.Y.Z]heading. - Walk the
[Unreleased]entries once more and confirm every user-facing feature also landed in the relevant guide page (user-guide / admin-guide / ci-integration), not only in the release notes. A feature without a guide section is a release blocker — this is the "docs accompany features" rule enforced at the moment it is cheapest to fix. - If a new UI surface shipped, capture its screenshot via
make screenshots-captureand reference it from the guide section.
- Write the release notes at
- Bump
IMAGE_TAGin.env.exampletoX.Y.Z. - Bump the chart with it:
versionandappVersionincharts/trustedoss/Chart.yaml, andimage.tagincharts/trustedoss/values.yaml. All three, in the same commit. The chart claims to release in lock-step with the portal and drifted nine minor versions once, which left a defaulthelm installrunning an old portal unless the operator overrodeimage.tag. - Tag and push:
git tag -s vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z.-ssigns it and implies-a. A plaingit tagmakes a lightweight tag, which is a ref pointing straight at a commit with no tag object, so it cannot carry a signature at all. See Signing release tags. - Watch the
release-gatejob. When it goes green the Release is public and markedlatestautomatically — no manual step is needed. - Approve the demo deploy. Revealing the Release queues a
deploy-hetzner.ymlrun against thedemoEnvironment; it waits for a reviewer. Leave it unapproved if the demo should stay on the previous release. The queued run never reseeds — when the release changesscripts/seed_demo.py, dispatch the workflow by hand withreseed: trueinstead.
Signing release tags
Release tags are signed with SSH by the release manager. The tag-signature
job checks every release and is the first thing that runs, so once it is
enforced an unsigned tag stops the release before anything is published.
REQUIRE_SIGNED_TAGS is "false" in release.yml, so today the job reports
an unsigned tag and lets the release continue. That is deliberate: until the
signing key is registered every tag is unsigned, and a hard failure would
block releasing rather than improve it. Flip it to "true" once the setup
below is done and .github/allowed_signers lists the key. That flip is the
only remaining step.
One-time setup, release manager
-
Create a signing key, separate from any key used for server access:
ssh-keygen -t ed25519 -C "release@trusca" -f ~/.ssh/trusca_release -
Tell git to sign tags with it:
git config --global gpg.format sshgit config --global user.signingkey ~/.ssh/trusca_release.pub -
Add the public key to GitHub twice, under Settings → SSH and GPG keys. Once as an Authentication key if you want it to work for git, and once as a Signing key, which is what makes GitHub show tags as
Verified. They are separate entries for the same key and it is easy to add only the first. -
Add it to
.github/allowed_signers, which is what tells a verifier which key counts, and open a PR for that file:release@trusca ssh-ed25519 AAAA... release@trusca -
Flip
REQUIRE_SIGNED_TAGSto"true"inrelease.yml.
The private key never leaves the release manager's machine. CI does not sign and needs no secret; it only verifies.
Why the allowed-signers file matters
A signature that verifies cryptographically only says the tag was signed by
somebody. git verify-tag reports Good "git" signature for any valid key,
including one an attacker generated. What makes it mean anything is
.github/allowed_signers: without a matching entry git adds No principal matched and the check fails. Verifying without that file is theatre.
See also
- Getting started — dev stack, first PR.
- Install with Docker Compose — the operator path the gate exercises.
- Quickstart — the first-scan scenario the gate smoke mirrors.