release-guard
STRICT GitHub release gatekeeper. Blocks premature releases (from develop, incomplete CI). Verifies PR merged to main + ALL CI passed before allowing tag/release. Triggers on "release", "tag", "publis
SKILL.md.claude/skills/release-guard/SKILL.md
GitHubRelease Guard — Canonical Release Skill
One workflow. Same for humans and agents. No alternate paths.
Golden path (memorize this)
1. Version + CHANGELOG already on main (via PR)
2. origin/main CI fully green
3. ./scripts/release-manager.sh ship --execute
4. release.yml builds artifacts + creates GitHub Release
5. Optional: ./scripts/release-manager.sh wait-release
| Step | Who | Tool |
|---|---|---|
Bump Cargo.toml version + CHANGELOG |
Human/agent via PR | PR to main |
Docs: Released Version = workspace version |
Same PR | ROADMAP + STATUS |
| Wait for main CI | Agent | gh run list --branch main --commit $(git rev-parse origin/main) |
| Tag + push only | Agent/human | ./scripts/release-manager.sh ship --execute |
| Build + GitHub Release | GitHub Actions | .github/workflows/release.yml (on tag push) |
| crates.io (if needed) | GitHub Actions | publish-crates.yml (separate) |
NEVER
| Forbidden | Why |
|---|---|
gh release create by hand |
Bypasses cargo-dist / preflight / artifacts |
Tag from non-main |
Policy + dist target |
Tag when Cargo.toml ≠ tag (v0.1.35 ↔ 0.1.35) |
release.yml preflight fails |
--admin / force merge |
main is guarded by ruleset main-protection (not branch protection) for a reason; required_status_checks there are the only checks that unblock a merge |
| Ship while main CI pending/failed | Broken release |
Weakening do_ci_check at ship time |
The gate is the release's only CI proof; weakening it at ship time is forbidden. Per ADR-083, do_ci_check machine-filters schedule/workflow_dispatch as advisory in code |
--skip-local-tests without ci-check parity |
Skips the local validate/prepare gates; only acceptable when a ci-check on the exact release commit is green (full ship already runs it) |
| Multiple competing “release procedures” | This skill + release-manager.sh only |
Agent checklist (every release)
# 0) Status
./scripts/release-manager.sh status
# 1) On clean main, synced
git checkout main && git pull --ff-only origin main
test -z "$(git status --porcelain)"
# 2) Version consistency (docs + crates)
./scripts/verify-release-state.sh --check-unreleased # exit 0 (warnings OK)
# 3) Main CI green (also enforced by ship)
./scripts/release-manager.sh ci-check
# 4) Ship (local quality + CI re-check + tag + push tag)
./scripts/release-manager.sh ship --execute
# 5) Watch release.yml
./scripts/release-manager.sh wait-release
# or: gh run list --workflow=release.yml --limit 3
Dry-run first if unsure:
./scripts/release-manager.sh ship # no --execute → dry-run
Version rules
- Workspace
Cargo.tomlversion = "X.Y.Z"is the source of truth. - Git tag is always
vX.Y.Z(leadingv). - release.yml rejects tag/version mismatch.
- ROADMAP_ACTIVE and STATUS/CURRENT first
Released Versionline must equalX.Y.Zbefore ship (verify-release-state). - CHANGELOG must have exactly one
## [X.Y.Z]section (unique version headers).
Semver for this 0.x line: prefer patch for fixes, minor for features (team convention).
Ship preflight — a version bump alone does NOT make the repo shippable
ship Phase 0 is ./scripts/verify-release-state.sh --check-unreleased, which validates CHANGELOG.md +
ROADMAP_ACTIVE.md + STATUS/CURRENT.md against workspace_version. A bump-only branch therefore still
fails, and no tag date may be promised until all three agree:
$ ./scripts/verify-release-state.sh --check-unreleased
❌ ROADMAP_ACTIVE.md says v0.1.45 (expected v0.1.46)
❌ STATUS/CURRENT.md references v0.1.45
❌ CHANGELOG.md missing entry for v0.1.46
⚠️ parse-changelog not installed (release notes will fall back)
- Shippable set = version + the
## [X.Y.Z]CHANGELOG heading + the version-doc flip, landed together as one release-prep PR (chore(release): prepare vX.Y.Z changelog and version docs; precedent #1169) and green on main before tagging. Queueing the bump PR alone buys nothing —shipstops at Phase 0. - The
⚠️ parse-changelog not installedline is a silent fallback, not a skipped check: install it (cargo install --locked parse-changelog— the crate isparse-changelog;parse-changelog-clidoes not exist) sorelease.ymlfills the GitHub Release body fromCHANGELOG.md.
CHANGELOG / GitHub Release notes (mandatory)
release.yml (cargo-dist) uses parse-changelog to fill the GitHub Release body from CHANGELOG.md. Format must stay consistent with published releases (e.g. v0.1.34 / v0.1.35):
- Unique
## [X.Y.Z] - YYYY-MM-DDheadings — duplicate historical versions make parse-changelog fail and produce an empty release body. - Ship notes under standard Keep a Changelog sections (
### Added,### Fixed, …). - After
wait-release, confirm:gh release view vX.Y.Zshows## Release Notescontent (not blank). - If notes were empty: fix CHANGELOG uniqueness →
gh release edit vX.Y.Z --notes-file …(or re-run notes step); land verify gate (PR #858 pattern) so it cannot regress.
# Pre-ship: changelog must parse for the version being tagged
parse-changelog CHANGELOG.md "$VERSION" >/dev/null
./scripts/verify-release-state.sh --check-unreleased
What ship does
verify-release-state.sh --check-unreleased- Local: fmt, clippy, build check, nextest, doctest, quality-gates
(note: quality-gates defaultsQUALITY_GATE_SKIP_OPTIONAL=truelocally; optional tool gates like coverage/audit run when tools are installed or explicitQUALITY_GATE_SKIP_OPTIONAL=falseis set. Skip local checks with--skip-local-testsonly in documented emergencies) ci-checkonorigin/mainHEAD (all runs completed success/skipped)git tag -a vX.Y.Z -m "Release vX.Y.Z"on that HEADgit push origin refs/tags/vX.Y.Zonly (does not push commits)- Prints how to monitor
release.yml
After ship
Confirm:
gh release view vX.Y.ZThe tag workflow publishes draft-first: it creates a draft release, attaches the dist artifacts plus the CycloneDX SBOMs, generates build-provenance and SBOM attestations (
actions/attest), then publishes. Publishing is what, once repository immutability is enabled, locks the tag and assets.The crates.io chain is dispatched, not release-triggered (LESSON-030): publishing with the repository
GITHUB_TOKENcreates no run foron: release: published, sorelease.yml'sdispatch-publishjob runsgh workflow run publish-crates.yml --ref vX.Y.Z -f dry-run=falseitself. The publish run'seventis thereforeworkflow_dispatchon the tag SHA — the absence of arelease-event run is not a failed publish.crates.io publish authentication is OIDC-only (ADR-078 amendment): each publish job uses the pinned
rust-lang/crates-io-auth-action; there is noCARGO_REGISTRY_TOKENsecret to rotate. One-time prerequisite per crate: register a trusted publisher (crate → Settings → Trusted Publishing) with repositoryd-o-hub/rust-self-learning-memory, workflow filenamepublish-crates.yml, environmentcrates.io— crates.io matches the calling workflow and environment only, so restrict that environment tov*tag refs and rely on the workflow's own tag guard (real publishes run only from tag refs; branch dispatches can only dry-run).Verify attestations on a downloaded asset (the digest binding is only meaningful for the file you actually hold):
for app in do-memory-cli do-memory-mcp do-memory-examples; do gh attestation verify ${app}-x86_64-unknown-linux-gnu.tar.xz -R d-o-hub/rust-self-learning-memory \ --signer-workflow d-o-hub/rust-self-learning-memory/.github/workflows/release.yml gh attestation verify ${app}-x86_64-unknown-linux-gnu.tar.xz -R d-o-hub/rust-self-learning-memory \ --signer-workflow d-o-hub/rust-self-learning-memory/.github/workflows/release.yml \ --predicate-type https://cyclonedx.org/bom done gh release verify vX.Y.Z -R d-o-hub/rust-self-learning-memory # immutable releases onlyDrift issue (#849-style) should close when tag matches workspace version
Bump workspace to next patch for development in a follow-up PR (optional)
A green
publish-crates.ymlis not proof the crates published. Verify each crate externally after the dispatch finishes, or the workspace ships binaries while crates.io silently stalls (LESSON-053):TAG=vX.Y.Z for c in do-memory-core do-memory-storage-redb do-memory-storage-turso do-memory-mcp; do echo "$c: $(curl -sS -H "User-Agent: rust-self-learning-memory-ci" \ "https://crates.io/api/v1/crates/$c/versions" | jq -r --arg v "${TAG#v}" '[(.versions // [])[].num] | index($v) != null')" done # every line must be true; "false" means the publish chain was green but skipped- The chain owns exactly 4 crates (
publish-crates.yml: core → redb → turso → mcp). Ado-memory-cli404 is by design (it ships as a GitHub Release asset) anddo-memory-test-utilsispublish = false(test-utils/Cargo.toml) — neither is a publish failure, so never widen the loop or "fix" the 404. Send aUser-Agent(crates.io rejects the default curl UA withFETCH_FAILED-shaped noise) and test version membership, not.versions[0].numequality. - Release is not done until
index("X.Y.Z") != nullfor all 4 andVerify publish landedis green. A green job whose publish step conclusion wasskippedis not a publish — that is howv0.1.45shipped while publishing nothing (conclusion=skipped;duration_ms=0). Read step conclusions, never the job verdict.
- The chain owns exactly 4 crates (
Failure playbook
| Symptom | Fix |
|---|---|
| verify-release-state fails docs | PR: set Released Version: vX.Y.Z in ROADMAP + STATUS |
| ci-check pending | Wait; re-run ci-check |
| ci-check failed | Fix main CI first |
ci-check failed and no push triggered it (a schedule or workflow_dispatch run on HEAD) |
Per ADR-083, do_ci_check filters by .event: schedule and workflow_dispatch runs are advisory and non-blocking, while PR/push runs remain mandatory gating checks. If ci-check still fails, read the failing run's event with gh run list --commit $(git rev-parse origin/main) --json event,name,conclusion — a tag-triggered nightly is a push event and does gate, so fix that one on its own merits. |
| Nightly (schedule) run is red and you want it fixed, not just ignored | Advisory runs never block ship (ADR-083), but the debt is still real: gh run list --workflow nightly-tests.yml --limit 10 --json number,event,conclusion and fix the failure on its own merits. Never re-narrow the gate at ship time to hide it (LESSON-051, LESSON-052). |
| Advisory-driven main red (Cargo Deny / Dependency Audit) | Fix main with a lockfile-only PR first; scope from both cargo deny check advisories and cargo audit --json — one tool green does not mean main green (LESSON-046/047) |
Check for unreleased changes red on the version PR |
Cadence is critical; the PR needs release-preparation. If the bot already applied it, gh run rerun replays the label-less payload — toggle the label as a human: gh pr edit <n> --remove-label release-preparation && gh pr edit <n> --add-label release-preparation (LESSON-049). release-preparation is the release PR's own label: applying it to an unrelated PR to force a clean re-run is a deadlock breaker, not a fix — strip it again |
Everything green but mergeStateStatus stays BLOCKED / the release PR will not merge |
strict_required_status_checks_policy makes merges serial: one merge puts every other branch BEHIND and re-queues its required runs, so head SHAs are ephemeral. Merge one PR at a time, re-read gh pr view <n> --json headRefOid,mergeStateStatus immediately before any merge/readiness claim, and batch docs/skills edits into one push (each push restarts the suite; edited does not) |
| Tag already on remote | Do not retag; inspect gh release view |
| release.yml failed preflight | Version/tag mismatch — delete bad tag only after review |
| Release left unpublished draft (workflow died between draft creation and publish) | Re-run the failed tag workflow run (gh run rerun <id>) — the draft-create step is idempotent and publishing stays inside release.yml; never create a release by hand. This recovery only applies while the release is a draft; a published immutable release cannot be amended (delete it — and the tag, after review — before shipping a corrected one) |
| Attestation missing for an asset | Check the Attest … steps of the tag workflow run; gh attestation verify locally; re-run the run if the step failed |
| GitHub Release body empty / no notes | Duplicate ## [X.Y.Z] in CHANGELOG (parse-changelog fails). Make versions unique; gh release edit to restore notes; keep gate in verify-release-state |
chore(changelog): update CHANGELOG.md for vX.Y.Z opened by changelog.yml on the tag push |
Close, never merge — git-cliff regenerates the file from history, so it replaces the curated entries back to ## [0.1.10] (precedent: #1119/#1105/#1054/#1036 all closed). The changelog is authored by the release-prep PR (LESSON-054) |
| Want crates.io | Use publish workflow / team process after GitHub Release |
Relationship to other skills
| Skill | Use for |
|---|---|
| release-guard (this) | All release/tag/publish/deploy requests |
| github-release-best-practices | Background only; defers to this skill |
| pr-readiness | Merging the version-bump PR before ship |
| code-quality | The advisory-bump procedure when main CI is red on Cargo Deny / Dependency Audit |
| release-drift workflow | Alerts when main outruns tags — does not ship |
Commands reference
./scripts/release-manager.sh status
./scripts/release-manager.sh validate
./scripts/release-manager.sh ci-check
./scripts/release-manager.sh ship # dry-run
./scripts/release-manager.sh ship --execute # production
./scripts/release-manager.sh wait-release
./scripts/release-manager.sh rollback --tag vX.Y.Z --execute # local tag only
Progressive disclosure
- CI wait patterns: ci-reference.md
- Workflow definition:
.github/workflows/release.yml - Version consistency:
./scripts/verify-release-state.sh