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 of CHANGELOG.md is 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 in SECURITY.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; until AUR_PUBLISH is true, a release regenerates and commits both recipes as
    usual and stops short of pushing them, saying so in the run. Setting it to true is 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
    requires ci-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. So RELEASE_SSH_KEY holds a
    write-enabled deploy key, the ruleset lists DeployKey as 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 the gate job the
    only thing that runs CI against a release. A RELEASE_TOKEN secret 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)

  1. The workflow was triggered on the default branch, and the working tree is clean.
  2. The tag v<version> does not exist, unless a draft release already exists for it — the
    resume case below.
  3. 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.
  4. CHANGELOG.md has 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_PUBLISH being true, 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.