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

GitHub

Release 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.toml version = "X.Y.Z" is the source of truth.
  • Git tag is always vX.Y.Z (leading v).
  • release.yml rejects tag/version mismatch.
  • ROADMAP_ACTIVE and STATUS/CURRENT first Released Version line must equal X.Y.Z before 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 — ship stops at Phase 0.
  • The ⚠️ parse-changelog not installed line is a silent fallback, not a skipped check: install it (cargo install --locked parse-changelog — the crate is parse-changelog; parse-changelog-cli does not exist) so release.yml fills the GitHub Release body from CHANGELOG.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):

  1. Unique ## [X.Y.Z] - YYYY-MM-DD headings — duplicate historical versions make parse-changelog fail and produce an empty release body.
  2. Ship notes under standard Keep a Changelog sections (### Added, ### Fixed, …).
  3. After wait-release, confirm: gh release view vX.Y.Z shows ## Release Notes content (not blank).
  4. 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

  1. verify-release-state.sh --check-unreleased
  2. Local: fmt, clippy, build check, nextest, doctest, quality-gates
    (note: quality-gates defaults QUALITY_GATE_SKIP_OPTIONAL=true locally; optional tool gates like coverage/audit run when tools are installed or explicit QUALITY_GATE_SKIP_OPTIONAL=false is set. Skip local checks with --skip-local-tests only in documented emergencies)
  3. ci-check on origin/main HEAD (all runs completed success/skipped)
  4. git tag -a vX.Y.Z -m "Release vX.Y.Z" on that HEAD
  5. git push origin refs/tags/vX.Y.Z only (does not push commits)
  6. Prints how to monitor release.yml

After ship

  • Confirm: gh release view vX.Y.Z

  • The 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_TOKEN creates no run for on: release: published, so release.yml's dispatch-publish job runs gh workflow run publish-crates.yml --ref vX.Y.Z -f dry-run=false itself. The publish run's event is therefore workflow_dispatch on the tag SHA — the absence of a release-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 no CARGO_REGISTRY_TOKEN secret to rotate. One-time prerequisite per crate: register a trusted publisher (crate → Settings → Trusted Publishing) with repository d-o-hub/rust-self-learning-memory, workflow filename publish-crates.yml, environment crates.io — crates.io matches the calling workflow and environment only, so restrict that environment to v* 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 only
    
  • Drift 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.yml is 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). A do-memory-cli 404 is by design (it ships as a GitHub Release asset) and do-memory-test-utils is publish = false (test-utils/Cargo.toml) — neither is a publish failure, so never widen the loop or "fix" the 404. Send a User-Agent (crates.io rejects the default curl UA with FETCH_FAILED-shaped noise) and test version membership, not .versions[0].num equality.
    • Release is not done until index("X.Y.Z") != null for all 4 and Verify publish landed is green. A green job whose publish step conclusion was skipped is not a publish — that is how v0.1.45 shipped while publishing nothing (conclusion=skipped;duration_ms=0). Read step conclusions, never the job verdict.

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