Skip to content

Cutting a release

A release is a manual, gated act. The Release workflow owns the whole sequence — resolve the version, run the gates, tag, publish, verify, promote — and nothing about it happens as a side effect of a push.

Before the first release

Two repository settings must be in place. Neither lives in the repository, and one of them fails silently if it is missing.

The stable environment must exist with a required reviewer

The approval step is bound to a GitHub environment named stable. GitHub creates a missing environment on demand with no protection rules, so if it has not been configured by hand the approval job runs straight through and the gate does nothing at all — no error, no warning.

Create it under Settings → Environments, add a required reviewer, and confirm the workflow pauses on a real run. Environment protection rules are free on public repositories.

Pages must be served from GitHub Actions

Documentation deployment runs in the Docs workflow, which needs Settings → Pages → Source set to GitHub Actions. While it is still on the legacy gh-pages branch source, the deploy job fails.

mike still owns versioning on the gh-pages branch; only the serving mechanism changed. Doing this also retires the built-in pages-build-deployment run.

Versions and tags

The git tag is the only version. Nothing in the repository holds a version string that a human edits — Maven derives it from git state through Nisse, and the Helm chart's version is a placeholder that the release stamps from the tag.

Tags are bare three-part semantic versions: 1.2.0, never v1.2.0. The changelog configuration, the published image tag and the version validator all expect the bare form.

Releases may only be cut from main, hotfix/* or release/*, and only from a commit that is reachable from that branch and does not already carry a release tag.

The version between tags

On any commit that is not itself tagged, Nisse derives a -SNAPSHOT version by increasing the last release tag, and it reads the size of that increase from the Conventional Commits in tag..HEAD — the same source the release workflow derives from. .mvn/maven.config selects it:

-Dnisse.source.jgit.versionIncrement=conventionalCommits
-Dnisse.source.jgit.versionIncrement.zeroMajorDemotion=true

The highest increase in the range wins. Against a 0.1.0 tag:

Highest commit in tag..HEAD Version on main
fix:, chore:, or anything not a Conventional Commit 0.1.1-N-SNAPSHOT
feat: 0.2.0-N-SNAPSHOT
feat!:, or a BREAKING CHANGE: footer 0.2.0-N-SNAPSHOT

N is the number of commits since the tag. A breaking change lands on the minor rather than the major because zeroMajorDemotion holds the 0.x line — which is the same rule the release workflow applies, and the reason reaching 1.0.0 needs version and force together.

The consequence worth stating plainly: every commit message is a vote on the next version. A feat: in a pull request moves the whole line's minor, and a ! moves it as far as the pre-1.0 rule allows.

Until the first release tag exists there is nothing to increase, so the version stays at the 0.1.0-N-SNAPSHOT fallback whatever the commits say.

Running it

Actions → Release → Run workflow. The branch you pick in the dropdown is the branch that gets released; there is deliberately no ref input, so what the gates verify is always what gets tagged.

Input Meaning
version Leave empty to derive it from the Conventional Commits since the last tag.
prerelease none for a release; rc, b or a for a candidate. The number is chosen for you (1.2.0 → 1.2.0-rc1, then the next).
force Required before a hand-entered version that disagrees with the derived one is accepted.
dry-run On by default. Resolves and reports; tags nothing, publishes nothing.

Preview first

A dry run is the prepare job and nothing else, so it costs one cheap job. It writes the resolved version, the version it derived, the previous tag and the rendered release notes to the run summary. Read that before turning dry-run off.

If nothing in the range earns a version bump — only ci:/build: commits, or nothing conventional — the run fails saying exactly that, rather than reporting a tag collision.

Overriding the version

Passing a version that differs from the derived one fails unless force is also set. That pairing is the point: a hand-entered version is accepted only when someone has deliberately said so. Reaching 1.0.0 from a 0.x line needs both, because a breaking change before 1.0 bumps the minor rather than declaring the API stable by accident.

What happens, in order

flowchart TD
    prepare[prepare: resolve version and notes] --> checks[checks: the gates that guard main]
    checks --> approve{{approval: stable environment}}
    approve --> tag[tag: push the tag, create the prerelease]
    tag --> image[image: build, smoke-test, push to GHCR]
    tag --> docs[docs: publish the version, move stable]
    tag --> package[package: build the jar and chart from the tag]
    package --> assets[assets: assert the version, attach]
    image --> verify[verify: re-check the published bytes]
    assets --> verify
    verify --> promote[promote: mark it latest]

Two properties of that order are deliberate:

The approval decides whether the version exists. Nothing before it is durable: prepare resolves and reports, checks runs the gates, and neither writes anything to the repository or the world. The tag is the first irreversible step — "the git tag is the only version" — so it is behind the gate along with everything it feeds.

What you approve is the run itself, which is where the evidence is: prepare has written the resolved version, the version it derived, the previous tag and the rendered release notes into the run summary, and checks is green above it. The stable environment's link points there rather than at a release page, because at that moment there deliberately is no release to point at.

A release is not "latest" until its bytes have been checked. verify downloads the published assets over the same unauthenticated path a consumer takes and re-derives everything from what actually landed — a truncated or clobbered upload is invisible to the build that produced it. Only then does promote flip the release to latest.

A prerelease rather than a draft, because a draft's assets are not readable without a token, so there would be nothing for verify to download.

Release candidates

Choosing rc, b or a produces a candidate that is built, published and verified like any release but never promoted to latest, and never moves the documentation's stable alias. Candidates are also invisible to versioning: the 1.2.0 that ends a candidate cycle is still computed from the last stable tag, and its notes still span everything since that tag.

If a release goes wrong

Where a run stops decides what it leaves behind.

Stopped at prepare, at checks, or at the approval: nothing survives it. Neither job writes anything to the repository or to the world. Cancel it, or reject the approval, and there is nothing to clean up.

Stopped after tag: the version exists, and every publishing job that finished has added one more durable thing. Those three run in parallel, so a stopped run can have left any subset of them.

Job What it leaves behind if it finished
tag The tag <version>, and a GitHub prerelease that /releases/latest excludes
package and assets The jar, the Helm chart and the SBOM, attached to that prerelease
image ghcr.io/skillsgateway/skillsgateway:<version> in GHCR
docs That documentation version published, and the stable alias moved to it
promote The release marked latest — at which point the release is complete and there is nothing to recover

A run that gets past tag without reaching promote ends with a Partial release table in its run summary naming which of those steps succeeded, so the state does not have to be reconstructed from the job list. verify cannot report it: a failed publishing job skips verify along with everything after it.

Two decisions follow, in this order.

Can this version be re-run?

No — not while its tag exists. The tag job checks for the tag before it creates anything and fails with Tag '<version>' already exists, so re-dispatching the workflow for a version that is already tagged stops there. Re-running is only possible after the tag is deleted.

Deleting a tag and re-running has not been exercised

The refusal above is what the workflow does; what a successful second run over a deleted tag and a deleted prerelease does has never been tried on this repository. Treat it as unverified. Fixing forward has been, and is the recommendation below.

Fix forward, or clean up?

Fix forward unless the tag is genuinely private. Cut the next version once the cause is fixed; the abandoned prerelease is excluded from /releases/latest, so nothing that resolves "the latest release" ever sees it. The cost is a version number and a prerelease that stays visible in the releases list — which is also an honest record of what happened.

The 0.1.0 release of this repository is exactly this case: tagged and pre-released with nothing behind it, deliberately left standing.

A skipped version is visible in the version arithmetic

Nisse derives the version on main by incrementing the last tag, so an abandoned 0.1.0 still moves main to 0.2.0-N-SNAPSHOT. The snapshot describes itself relative to a release that was never published. Harmless, and worth knowing before it reads as a bug.

Clean up only if you are certain nobody has fetched the tag — a tag someone already has does not stop existing when the remote's copy is deleted, and their copy may now point at a commit nobody else has. In practice that means within minutes, on a repository nobody is watching. Undo in the reverse of the order above:

# Documentation, if the docs job ran: remove the version and put `stable` back.
mike delete --push <version>
mike alias --push --update-aliases <previous-version> stable

# The container image, if the image job ran: delete that package version in
# Settings → Packages, or with the API.
gh api --method DELETE \
  /orgs/skillsgateway/packages/container/skillsgateway/versions/<id>

# The release and its assets, then the tag.
gh release delete <version> --repo skillsgateway/skillsgateway --yes
git push origin :refs/tags/<version>

With the tag gone the workflow will accept the version again — which, as above, is the part nobody has yet done.

Hand-pushing a tag publishes nothing

This is a change from earlier behaviour, and it is deliberate. docs.yml and the container-image workflow used to watch for tags, so pushing one published the documentation and the container image immediately — unordered, and with no approval between the tag and the world. Both are now invoked by the release workflow instead. A tag pushed by hand is inert.

What a release produces

Artifact Where
Release notes The GitHub release, generated from Conventional Commits
skills-gateway-<version>.tgz Attached to the release — the Helm chart, stamped with the version
skills-gateway-<version>-cyclonedx.json Attached to the release — the CycloneDX SBOM
The jar Attached to the release
ghcr.io/skillsgateway/skillsgateway:<version> GHCR, with the SBOM attested against the pushed digest — see Container image
Versioned documentation This site, with stable pointing at it