release-kit 0.2.11

A canonical release workflow: a technology-agnostic method, per-technology bindings, and the rk CLI that lands and serves them.
Documentation
# Release runbook

The six steps of [operate](../method/03-operate.md) as commands: the chapter owns each step's why, this page owns its how. This is what a person follows with a release request open. `<repo>` is the project path, filled in by `rk guide release` where detection resolves it. `<release pr>` and `<release mr>` exist only once a bot has opened them, change every release, and are never substituted: a stale number merges someone else's work, where a visible placeholder fails loudly. The commands are the operator's to run: an agent serves a runbook and states the command, and runs one only where the operator's request named that step.

## At a glance

On github:

```bash
# 1. land the work through squash-merged pull requests
just check                                    # or the binding's check command
rk setup step package-check --target .
# 2. read the release request the bot keeps open
gh pr list --repo <repo> --state open
# 3. hold it, or correct the changelog on an unarmed request
gh pr view <release pr> --repo <repo> --json autoMergeRequest
# 4. let it merge; on an unarmed request, merge it
gh pr checks <release pr> --repo <repo> --watch
gh pr merge <release pr> --repo <repo> --squash --delete-branch   # unarmed only
# 5. wait for the publish workflow on the merge commit, then the artifact workflow on its tag
# 6. verify
```

On gitlab:

```bash
# 1. land the work through squash-merged merge requests
just check                                    # or the binding's check command
rk setup step package-check --target .
# 2. read the release request the bot keeps open
glab mr list
# 3. hold it, or correct the changelog on an unarmed request
glab mr view <release mr>
# 4. let it merge; on an unarmed request, merge it
glab ci status --wait
glab mr merge <release mr> --squash --remove-source-branch   # unarmed only
# 5. wait for the release pipeline
# 6. verify
```

Four traps, and the chapter's [own warnings](../method/03-operate.md) explain the first three. An armed request has no correction window, and a stop is a disarm run before the last check turns green, never a close raced after it. Step 3 is the last point a changelog correction reaches an unarmed release, and a correction does not survive a later refresh. Step 5 is why a check run straight after the merge reports the release as missing under a dedicated artifact builder: the release page arrives only when the slowest platform build finishes. And where the artifact workflow also runs on pull requests, its newest run is usually not this release's: select runs by commit, never by recency.

## 1. Land the work

Release intent captured in the squash titles, the local check suite green, and the package still publishable — a failure found here costs seconds where the same failure after the merge costs the recovery chapter. Where the check runs a pre-commit sweep from the trunk's checkout, name the commit-time branch guard out of it: `SKIP=no-commit-to-branch`, the same form the landed hook block's comment gives a CI sweep.

```bash
just check                                    # or the binding's check command
rk setup step package-check --target .
# check: both exit 0; the trunk is releasable
```

## 2. Read the release request

The bot keeps one request open against `master`: the version bump and the changelog entry, publishing nothing. Every merged pull request refreshes it, so the proposed version and the entry describe the trunk's tip.

On github:

```bash
gh pr list --repo <repo> --state open         # check: the release request is open
```

On gitlab:

```bash
glab mr list                                  # check: the release request is open
```

## 3. Hold, or correct the changelog

The style decides what this step is: on an armed request it is the hold window, on an unarmed one the correction window. The chapter owns why, and the forge document owns how the bot refreshes the request.

### 3a. Read whether the request stands armed

On github:

```bash
gh pr view <release pr> --repo <repo> --json autoMergeRequest \
  -q '.autoMergeRequest.enabledBy.login // "not armed"'
# check: prints the arming login on an armed request, or "not armed"
```

On gitlab:

```bash
glab mr view <release mr>
# check: the view reports auto-merge as enabled, or does not
```

Armed and this release ships: go to 4. Armed and it must not ship yet: 3b. Not armed: 3c.

### 3b. Hold the release

Run it before the last check turns green; the chapter owns why a later stop is a withdrawal instead.

On github:

```bash
gh pr merge <release pr> --repo <repo> --disable-auto
# check: 3a now prints "not armed"; the next bot refresh may re-arm, so say where the team reads that the release is held
# already merged: nothing is held; rk method recovery owns the withdrawal
```

On gitlab:

```bash
glab api -X POST "projects/:id/merge_requests/<release mr>/cancel_merge_when_pipeline_succeeds"
# check: 3a no longer reports auto-merge; the next bot refresh may re-arm, so say where the team reads that the release is held
# already merged: nothing is held; rk method recovery owns the withdrawal
```

### 3c. Compare the entry against the range

```bash
git fetch origin --tags --force
git log --oneline "v<previous version>^{commit}..origin/master"
# check: every commit that should appear in the entry is listed
```

Nothing missing: skip to step 4. Something missing: continue — and on a request still armed, run 3b first, because a correction pushed to an armed request races the checks it restarts.

### 3d. Correct it on the request's branch

On branches:

```bash
gh pr checkout <release pr> --repo <repo>        # or glab mr checkout <release mr>
```

On worktree:

```bash
rk worktree add "<bot branch>" --apply && cd "../<project>@<bot branch flattened>"
# check: the source line reports remote — the bot's branch fits the grammar's release arm and is seated from origin's tip, never recreated from the trunk; step 4's merge retires the worktree through step 4 of rk guide worktree
```

Then edit the changelog and push the correction:

```bash
git commit -am "docs(changelog): Complete the entry for v<version>"
git push
```

### 3e. Confirm the request survived

On github:

```bash
gh pr view <release pr> --repo <repo> --json state,number -q '.state, .number'
# check: still OPEN and the same number
```

On gitlab:

```bash
glab mr view <release mr>
# check: still open under the same number
```

A new number means the bot reopened the request and took the fix: redo the correction on the new request and merge without waiting.

## 4. Let it merge, or merge it

This is the release. The named check gates the merge either way, and squash is the only allowed method, so `master` stays linear.

On trunk:

The request stands armed: the forge merges it the moment the last required check passes, and 4a is a watch, not an action. A request disarmed in 3b takes the unarmed form until the bot re-arms it.

On lines:

The merge is yours: watch the checks, then merge, because a line's request is never armed.

### 4a. Watch the checks, and merge where the merge is yours

On github:

```bash
gh pr checks <release pr> --repo <repo> --watch
# check: on an armed request the forge merges when the last check passes; there is nothing to run
# not armed, or disarmed: merge it yourself once the checks pass
gh pr merge <release pr> --repo <repo> --squash --delete-branch
```

On gitlab:

```bash
glab ci status --wait
# check: on an armed request the forge merges when the pipeline passes; there is nothing to run
# not armed, or disarmed: merge it yourself once the pipeline passes
glab mr merge <release mr> --squash --remove-source-branch
```

### 4b. Bind the merge commit

Steps 5 and 6 correlate against it.

```bash
git fetch origin && git rev-parse origin/master
# check: prints the SHA the release runs on
```

The push that lands the bump runs the binding's release path: automation tags `v<version>` and publishes, and the tag starts the artifact build where the binding has one.

## 5. Wait for the artifact build

Where the binding runs a dedicated artifact builder, the release page arrives only when its final job finishes; each wait selects its run by what it ran on, never by recency.

On github:

```bash
SHA="$(git rev-parse origin/master)"
gh run watch --repo <repo> --exit-status \
  "$(gh run list --repo <repo> --workflow <publish workflow> \
     --commit "$SHA" --limit 1 --json databaseId -q '.[0].databaseId')"
gh run watch --repo <repo> --exit-status \
  "$(gh run list --repo <repo> --workflow <artifact workflow> \
     --event push --commit "$SHA" --limit 1 --json databaseId -q '.[0].databaseId')"
# check: each completes with 'success'; the binding names both workflow files
# empty second id: the tag has not landed yet; rerun after a few seconds, and if it stays empty go to 6a, which names the failure
```

On gitlab:

```bash
glab ci status --wait
```

The `--event push` filter keeps the second watch off the runs the artifact workflow also produces on pull requests. The `(rust, gitlab)` pair has no artifact builder, so there is no dedicated build to wait for and no installers to expect on the release page; [the Rust binding](../bindings/rust.md) states it.

## 6. Verify

### 6a. The tag and the trunk name the same commit

Universal, and the release itself: the bot writes an annotated tag, so `v<version>` names a tag object, and `^{commit}` is what makes the two values comparable.

```bash
git fetch origin --tags --force
git cat-file -t "v<version>"
# check: prints tag
git rev-parse "v<version>^{commit}" origin/master
# check: two identical SHAs
```

### 6b. The registry serves exactly this version

On rust:

```bash
cargo info <crate> | grep -m1 -i '^version'
# check: prints <version>
```

### 6c. The release page carries its artifacts

On github:

```bash
gh release view v<version> --repo <repo> --json assets \
  -q '[.assets[].name] | join(", ")'
# check: the artifact list is not empty, where the binding declares that surface
```

### 6d. An installed binary reports the new version

On github:

```bash
curl -LsSf "https://github.com/<repo>/releases/download/v<version>/<crate>-installer.sh" | sh
# check: the installed binary reports <version>; a bare version call only reads whatever is already on PATH
```

### 6e. The provenance verifies

The run that built each artifact in the release payload signed it, and the pair's own verifier proves it — this is the check that turns the provenance invariant from a comment into a rule.

On rust/github:

```bash
tmp="$(mktemp -d)"
gh release download "v<version>" --repo <repo> --dir "$tmp"
( for artifact in "$tmp"/*; do
    gh attestation verify "$artifact" --repo <repo> \
      --source-digest "$(git rev-parse "v<version>^{commit}")" \
      --signer-workflow "<repo>/.github/workflows/<artifact workflow>" \
      || exit 1
  done )
# check: exits 0 — every downloaded file verifies, the curled installers included, and one failure fails the whole loop; the release payload is whatever a consumer downloads, not just the archives
# the two flags bind the evidence to this release: a repo-only verify would also accept a valid attestation some other run of some other workflow minted over identical bytes
```

On bash/github:

```bash
tmp="$(mktemp -d)"
gh release download "v<version>" --repo <repo> --dir "$tmp" --pattern '*.tar.gz'
gh attestation verify "$tmp"/*.tar.gz --repo <repo> \
  --source-digest "$(git rev-parse "v<version>^{commit}")" \
  --signer-workflow "<repo>/.github/workflows/<artifact workflow>"
# check: the tarball verifies against the release commit and the signing workflow; the .sha256 beside it is verification evidence, not payload
```

On bash/gitlab:

```bash
curl -fsSL -o "<name>.tar.gz" "<package file url>"
curl -fsSL -o "<name>.tar.gz.sigstore.json" "<bundle file url>"
cosign verify-blob-attestation --type slsaprovenance1 \
  --bundle "<name>.tar.gz.sigstore.json" \
  --certificate-oidc-issuer https://gitlab.com \
  --certificate-identity "https://gitlab.com/<repo>//.gitlab-ci.yml@<built ref>" \
  "<name>.tar.gz"
# check: prints Verified OK; the release page links both files
# <built ref>: the certificate names the ref the release was built from — refs/heads/master for a trunk release, refs/heads/release/<line> for a line release
# self-managed instance: the pipeline stated the keyless-signing boundary and released without provenance; there is no bundle, and this check does not apply
```

On python/github:

```bash
pypi-attestations verify pypi --repository "https://github.com/<repo>" "pypi:<distribution filename>"
# check: prints OK, once per sdist and wheel the release published
```

On rust/gitlab:

The pair declares no provenance surface — crates.io stores none, and the pair builds and attaches no binaries; [the Rust binding](../bindings/rust.md) states it, and this check does not apply.

### 6f. Repair a failed tag push

Anything but 6a's two identical SHAs means the tag push failed: rerun the publish workflow on the merge commit — never the artifact workflow, which only builds from a tag that already exists.

On github:

```bash
gh run rerun --repo <repo> --failed \
  "$(gh run list --repo <repo> --workflow <publish workflow> \
     --commit "$(git rev-parse origin/master)" --limit 1 --json databaseId -q '.[0].databaseId')"
```

## An older line

A patch-only release for users on an older version is not this sequence: a fix crosses by `rk guide backport`, the line's own life — candidate cycle and retirement included — is `rk guide release-lines`, and [branch for release](../method/07-branch-for-release.md) owns the path's why; [recovery](../method/04-recovery.md) carries the entry point for a line whose branch does not exist yet.