A release is one workflow, triggered by hand, with one input. Nothing about it lives only in a
maintainer’s head, and nothing is published from a tree that is not ready.
You give it a version. It does the rest — raising the version, closing the changelog section,
tagging, building the binary and both packages, installing them in clean containers to prove they
work, publishing the release with its notes, verifying every asset against its checksum, and
regenerating the Arch recipes from what it published — which it does in a second workflow it calls
for that last step, so the one part that depends on the AUR can be paused and re-run on its own.
Before you trigger it
Three things are worth checking, because they are the three that will stop it:
- the
[Unreleased]section ofCHANGELOG.mdis written and non-empty — it becomes the release
notes, so it needs to read as something a user would want; - the gating checks are green on the commit you are releasing;
- the release checklist has been walked. That is where the requirements no automated check can
judge get judged: the supported-versions list inSECURITY.md, the distribution matrix, the
packager block, the previous-release configuration fixture.
What it needs configured, once
Three things live in the repository’s settings rather than in the tree, and each fails the run
rather than being worked around:
AUR_SSH_KEY, the key the AUR push authenticates with. When the push runs without it, it
fails loudly and says so. Keeping the recipe in step with the release is not conditional, and a
push that silently skips itself is exactly how a recipe falls behind.AUR_PUBLISH, a repository variable rather than a secret, and the one thing here that is
currently off. The AUR has paused new account registration, so neither package can be created
there yet; untilAUR_PUBLISHistrue, a release regenerates and commits both recipes as
usual and stops short of pushing them, saying so in the run. Setting it totrueis the whole
of turning the push back on — and releases published while it was off are caught up by running
the aur workflow by hand, once per version, with its push box ticked.RELEASE_SSH_KEY, the way the release commit reaches the default branch. The branch ruleset
requiresci-required, and required checks are evaluated on push, so the release commit — which
no check has seen yet, because it did not exist until the run created it — is refused unless the
pusher is a bypass actor. This repository is owned by a user rather than an organisation, and
GitHub will not make the Actions app a bypass actor on a personal repository’s ruleset; deploy
keys are the one bypass actor such a repository can name. SoRELEASE_SSH_KEYholds a
write-enabled deploy key, the ruleset listsDeployKeyas a bypass actor, and both the release
commit and the Arch recipe commit are pushed over SSH. Like the built-in token and unlike a
personal token, a deploy-key push starts no workflow, which is what keeps thegatejob the
only thing that runs CI against a release. ARELEASE_TOKENsecret holding a token that belongs
to a bypass actor is still honoured if the key is absent.
The procedure
What follows is not a description of the workflow — it is the workflow’s contract, included at
build time from
specs/003-oss-release-readiness/contracts/release.md,
so this page and the specification the workflow is built to cannot drift apart.
One workflow, workflow_dispatch, one input. Nothing about releasing lives only in the
maintainer’s head (FR-105), and nothing is published from a tree that is not ready (FR-110).
Input
| Input | Form | Validation |
|---|---|---|
version |
MAJOR.MINOR.PATCH |
semver; strictly greater than the current Cargo.toml version; the first release must be exactly 1.0.0 |
Preconditions — all checked before anything is written (FR-110)
- The workflow was triggered on the default branch, and the working tree is clean.
- The tag
v<version>does not exist, unless a draft release already exists for it — the
resume case below. - The gating checks of ci.md are green on the commit being released. Steps 1–4 then
create a new commit, so the gate is re-run against the tag before step 6 builds anything: no
artefact is ever built from a commit no check has seen. CHANGELOG.mdhas a non-empty[Unreleased]section.
Any failure stops the workflow before the first commit, so a refused release leaves no trace.
Steps
| # | Step | Requirement |
|---|---|---|
| 1 | Raise version in Cargo.toml; refresh Cargo.lock (cargo update -w) |
FR-105 |
| 2 | Rename [Unreleased] to ## [<version>] - <date>; open a fresh empty [Unreleased] |
FR-102a |
| 3 | Assert the runtime version, the new tag and the changelog heading agree | FR-103 |
| 4 | Commit (release: <version>) and tag v<version> |
FR-103 |
| 5 | Re-run the gating checks of ci.md against the new tag; stop if they are not green | FR-110 |
| 6 | Build the x86_64 binary (release profile) |
FR-106 |
| 7 | Build the .deb in the oldest supported Ubuntu LTS container; build the .rpm in the oldest supported Fedora container |
FR-106, FR-109a |
| 8 | Install each package in a clean container of that family’s oldest and current release; run --version and --environment |
FR-109, SC-039 |
| 9 | Compute SHA256SUMS over every artefact |
FR-108 |
| 10 | Publish the release with its notes: the changelog entry, plus the packager block of FR-111 | FR-106, FR-111 |
| 11 | Verify every published asset against SHA256SUMS by re-downloading |
FR-108 |
| 12 | Regenerate both Arch recipes (pkgver, pkgrel, sha256sums) from the published artefacts — the archive for hypr-swap, the binary and the archive for hypr-swap-bin; commit; push each to its own AUR repository |
FR-107, FR-107a |
Step 12 is its own workflow, called by the release workflow as its last job. It is the same step in
the same order; what it gains by being separate is that the one step whose failures come from
outside the repository can be disabled, re-enabled and re-run alone, against a release published
long before. It takes only the version: the digests it rewrites the recipes with are read out of
the release’s own published SHA256SUMS.
Artefacts (FR-106)
| Artefact | Name |
|---|---|
| Source archive | hypr-swap-<version>.tar.gz (GitHub’s tag archive) |
| Binary | hypr-swap-<version>-x86_64 |
| Debian family | hypr-swap_<version>_amd64.deb |
| RPM family | hypr-swap-<version>-1.x86_64.rpm |
| Integrity | SHA256SUMS |
Release notes (FR-111)
Beyond the changelog entry, every release carries what a distribution packager needs without
contacting the maintainer:
- build dependencies with minimum versions — Rust
rust-version, cairo, pango, pangocairo; - runtime dependencies — cairo, pango, pangocairo; optional: an icon set,
notify-send; - the build steps (
cargo build --release); - the install map from packaging.md;
- the verified distribution matrix.
Re-running after a partial failure (FR-110)
The release is created as a draft and published only by step 11. Re-running the workflow for a
version whose tag exists:
- fails if a published (non-draft) release exists for that tag — a published version is
immutable; - resumes if the release is still a draft: it checks out the existing tag rather than creating
one, rebuilds the artefacts from that exact commit, and replaces the draft’s assets.
Because every artefact is built from the tag rather than from the branch head, a resumed run
cannot produce a different file for the same version.
The AUR push is step 12, after the release is published and verified, so a run that fails late
never leaves a recipe pointing at a release that does not exist. A failure on either of the two
recipes fails the job — they describe the same release, and one of them quietly lagging is the
same defect.
The AUR push, while the AUR is closed
The AUR has paused new account registration, so neither package can be created there yet. Step 12
therefore separates the two things it was doing:
- regenerating both recipes and committing them happens on every release, unconditionally.
This is what FR-107’s “in step with the released version” asks for, and it is satisfied in this
repository whatever the AUR is doing; - pushing them to the AUR is gated on the repository variable
AUR_PUBLISHbeingtrue, and
it is currently unset. When it does run and the key is absent it still fails loudly rather than
skipping: a push that silently skips itself is how a recipe falls behind, and the gate exists so
that a deliberate pause is visible in the workflow rather than disguised as one.
Turning it back on is setting that variable. Recipes for releases published while it was off are
caught up by running the AUR workflow by hand, once per version, with the push box ticked.
Supported versions
SECURITY.md states which released versions receive fixes (FR-120); the release workflow does not
change it, and updating it is a checklist item in quickstart.md.
Two properties worth understanding
A refused release leaves no trace. Every precondition is checked before the first commit is
written, so a run that is going to fail fails having changed nothing — no orphan tag, no bumped
version to revert, no half-written changelog.
Every artefact is built from the tag, never from the branch head. This is what makes a resumed
run safe: re-running for a version whose tag exists checks out that tag rather than creating one,
rebuilds from that exact commit, and replaces the draft’s assets. The same version cannot produce
two different files.
What counts as a breaking change
Versioning is semantic, from 1.0.0. What makes a change breaking is defined over the whole
contract surface — the shortcut names, the configuration schema, the command line, the
diagnostic conditions and the install map, not merely the Rust API — and that definition is in
contracts/versioning.md.
Read it before deciding whether the number you are about to release is a minor or a major one.
Between releases
Builds that are not from a tag identify themselves as such: --version reports the commit it was
built from, so a bug report from a development build can be traced to the exact source it came out
of. Nothing about that path is manual.