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.01.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 --> tag[tag: push the tag, create the prerelease]
    tag --> approve{{approval: stable environment}}
    approve --> image[image: build, smoke-test, push to GHCR]
    approve --> docs[docs: publish the version, move stable]
    approve --> 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 guards the publish, not the tag. Everything before it is reversible and invisible — a tag can be deleted, and the release is created as a prerelease, which /releases/latest excludes. By the time you are asked to approve, the gates are green, the version is resolved and the notes are rendered, so you are approving evidence rather than a version string.

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

Because nothing gates the tag, abandoning a release part-way leaves a tag and a prerelease behind. Either delete both, or — usually cleaner — fix forward with the next patch version. A prerelease that was never promoted is not serving anyone.

Hand-pushing a tag publishes nothing

This is a change from earlier behaviour, and it is deliberate. docs.yml and native.yml 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