Skip to content

Cut a release

A release is one tag, vX.Y.Z, that releases every binary at the repository’s one version. You choose the version in a release pull request and merge it. release-plz then creates the tag and starts the release workflow on it, which builds and publishes everything. The versions page and docs/versioning.md explain what a version means and how bumps are proposed.

Set up once: let workflows open the release PR

Section titled “Set up once: let workflows open the release PR”

There is no token or secret to create: release-plz runs on the workflow’s own GITHUB_TOKEN, and starts the release and the release PR’s CI by dispatching them (docs/versioning.md says why that works). The one switch it needs is a repository setting, off by default:

Settings > Actions > General > Workflow permissions > Allow GitHub Actions to create and approve pull requests. The same thing from a terminal (an admin’s gh):

Terminal window
gh api -X PUT repos/scoot-sh/scoot/actions/permissions/workflow \
-f default_workflow_permissions=read -F can_approve_pull_request_reviews=true

While it is off, the “Open or update the release PR” job fails with GitHub’s “not permitted to create or approve pull requests” error. Tagging a merged release PR does not need it.

Cut the first release (v0.2.0) by hand (historical)

Section titled “Cut the first release (v0.2.0) by hand (historical)”

The first tag was v0.2.0, never a 0.1.x: FlakeHub already published rolling 0.1.* versions, and a 0.1 tag would sort below them. The workspace read 0.2.0 at the time. release-plz waited for this first tag, so it was cut by hand, once. Both v0.2.0 and v0.2.1 exist now; this section is the record for the next project that bootstraps release-plz.

First, a small PR that adds ## [0.2.0] - YYYY-MM-DD to CHANGELOG.md, dated the day you tag, directly under the Unreleased heading, so everything written so far becomes v0.2.0’s notes. Merge it, then tag that merge commit on an up-to-date main and push the tag:

Terminal window
git switch main && git pull --ff-only
scripts/workspace-version # must print 0.2.0
git tag -a v0.2.0 -m "scoot v0.2.0"
scripts/release-resolve v0.2.0 --no-github # the workflow's own gate, locally
git push origin v0.2.0

release-resolve refuses a tag whose version is not the workspace version, or that is not on main. The push starts the release workflow; watch its first run. The next sections say what it does and how to recover.

On every push to main, release-plz keeps one PR open titled chore: release vX.Y.Z. It bumps [workspace.package] version and writes the version’s section in CHANGELOG.md: a list generated from the commits since the last tag, grouped by component, above the hand notes written under Unreleased.

  1. Read the PR. The proposed version follows the commits (below 1.0: a breaking change bumps the minor, anything else the patch). To release a different version, edit version under [workspace.package] in Cargo.toml and the heading in CHANGELOG.md on the PR branch, then re-run scripts/sync-packaging-versions there so the distro packaging follows your edit.
  2. Check the two sync commits release-plz pushed to the PR (the fuzz lockfiles, then the distro packaging: pkgver in the five Arch release PKGBUILDs with their .SRCINFO, Version plus a %changelog entry in the five RPM specs). The debs need no bump: they read the workspace version at build time. If a sync commit is missing or wrong, run the script by hand on the PR branch.
  3. Tidy the changelog section if it needs it, and let CI go green: ci.yml, packaging.yml, release.yml’s lint job and the distro packaging gate (deb.yml, rpm.yml, arch.yml) were all dispatched on the PR branch, and the three packaging runs must be green before merging. If a dispatch failed, run it yourself on the PR’s release-plz-… branch (gh workflow run ci.yml --ref <branch>, likewise packaging.yml, deb.yml, rpm.yml, arch.yml, and release.yml with no tag for its lint job), or approve the held runs.
  4. Merge. release-plz pushes vX.Y.Z, and the release workflow builds and publishes it, then its verify job downloads the published assets and runs every binary.

The run resolves the tag (scripts/release-resolve: the vX.Y.Z shape, the workspace version at the tagged commit, every crate inheriting it, on main; a release already complete is a green no-op), stages the vendored tarball and the SBOM once (the union of every shipped binary’s locked dependencies), builds scoot, scootbg, scootbar, scootlaunch and scootnotify as portable glibc binaries for x86_64-linux and aarch64-linux (cargo --locked --offline from that staged tree in a Debian 12 container with Rust 1.97.0: glibc floor 2.36), checks that every binary starts on the bare base, assembles SHA256SUMS, attests every file with keyless build provenance (verify with the --signer-workflow pin on the releases page), takes the version’s CHANGELOG.md section as the release notes (scripts/release-notes), and publishes the GitHub Release. scootctl is the client library inside scoot msg and ships no binary of its own. Then the verify job downloads the release’s own assets and proves them: every file against SHA256SUMS and its attestation, and each binary on bare Ubuntu and Debian containers (--version names the release, the bar’s, the launcher’s and the notification daemon’s --check, the compositor’s default-config render).

Dispatch the workflow by hand with dry-run on (the default) against a tag that already exists on main. It runs resolve, vendor, both arch builds, the SBOM, the checksums and the notes, then prints what it would publish and stops, with no release and no attestation. It does upload the run’s own build artifacts, kept 7 days, which is how the build and publish jobs hand files over:

Terminal window
gh workflow run release --ref main -f tag=v0.2.0 -f dry_run=true

dry_run=false publishes for real, and only on the tag itself (--ref v0.2.0); from a branch it is refused. That is the escape hatch for re-cutting after a deleted release, and the call release-plz makes after it creates a tag:

Terminal window
gh workflow run release --ref v0.2.0 -f tag=v0.2.0 -f dry_run=false

A dry run exercises the whole artifact round trip (vendor, build, download, assemble, checksums) but not the attestation or the release creation. If a real run fails after the build, use the recovery steps below before anyone downloads anything.

Verify an existing release without publishing

Section titled “Verify an existing release without publishing”

Dispatch the workflow with verify_only against a tag that already has a release. It runs only resolve plus the verify job, from any ref: it downloads the tag’s assets, checks every file against SHA256SUMS and its attestation, and runs each binary on bare Ubuntu and Debian containers for both architectures. It builds and publishes nothing:

Terminal window
gh workflow run release --ref main -f tag=v0.4.0 -f verify_only=true

Use it to test the verify job itself, and to re-prove an old release after a workflow change.

Symptom: the workflow went red on a tag push.

Re-run the failed run (the run’s page, “Re-run jobs”). The gates resume an incomplete release (a draft counts as incomplete), re-uploading only what is missing, and a run that finds the release complete stops green with “Already released”. So a retry converges instead of duplicating, and a tag that both you and release-plz started releases once.

Symptom: the published release is wrong (bad notes, wrong files) and a re-run only says “Already released”.

Delete the release and its tag, then re-cut from the same commit:

Terminal window
TAG=v0.2.0
gh release delete "$TAG" --yes
git push origin ":refs/tags/$TAG"

Local tags are untouched by either command; drop yours too if you made one (git tag -d "$TAG").

Symptom: a release PR merged but no release appeared.

Look at the release-plz workflow’s “Tag a merged release PR” job. If it created the tag but the dispatch step failed, run the dispatch yourself (the gh workflow run release --ref vX.Y.Z ... line above). If it made no tag, re-run the job, or push the tag by hand as for the first release: either way the release runs once.

Symptom: the release PR exists but CI never ran on it.

Its pull_request runs wait for “Approve workflows to run”, because GITHUB_TOKEN opened it. The dispatched runs (ci.yml, packaging.yml, release.yml’s lint job, and the deb.yml/rpm.yml/arch.yml packaging gate) are the real checks. If the “Dispatch the release PR’s checks” step failed, run them yourself on the PR’s release-plz-… branch (gh workflow run ci.yml --ref <branch>, likewise packaging.yml, deb.yml, rpm.yml, arch.yml, and release.yml with no tag), or approve the held runs.

Downstream bumps (try-scoot and scoot-iso)

Section titled “Downstream bumps (try-scoot and scoot-iso)”

Two repos pin scoot by release tag: try-scoot and scoot-iso. After a release is published, bump each by running its own workflow:

Terminal window
gh workflow run bump-scoot.yml -R scoot-sh/scoot-iso -f tag=vX.Y.Z
gh workflow run bump-scoot.yml -R scoot-sh/try-scoot -f tag=vX.Y.Z

Each run takes scripts/bump-downstream from that tag of this repo, moves the pin (the flake input and lock, and for the ISO the installer target flake and the patch-consistency constants), pushes ci/bump-scoot-<tag> and opens or updates one PR. It never merges. It needs no secret: it uses the repo’s own GITHUB_TOKEN.

A PR opened with GITHUB_TOKEN starts no pull_request run, so the workflow dispatches ci.yml on the branch itself. Those checks attach to the PR’s head commit but gh pr checks does not list them. Read them with gh api repos/scoot-sh/<repo>/commits/<head-sha>/check-runs. In try-scoot that run skips the push jobs, which only publish PR-tagged images. Review the lock diff, then merge once the checks are green.

Release creation and provenance run on GITHUB_TOKEN: there is no signing key in secrets and no environment to approve. The runs need only what this repo already uses elsewhere: GitHub-hosted runners with Docker (the arch builders), and third-party actions allowed (every action is pinned by commit SHA). A human bumps two pins over time: the distro base (debian:bookworm-slim, the glibc floor) and the rustup toolchain (1.97.0 in .github/workflows/release.yml; the portable binaries are built with Rust 1.97.0). The toolchain must stay new enough to compile the tree (the workspace floor predates the let-chains in it), and the workflow asserts the toolchain and the glibc it produces on every run. Verify downloads with gh attestation verify <file> --owner scoot-sh --signer-workflow scoot-sh/scoot/.github/workflows/release.yml (see the releases page for why the signer pin matters).