Alef Release Procedure
Cut a new alef version with a verifiable, reproducible procedure. Skip no step. Every release step has a concrete verification — never assume; always check.
When to apply
- User asks to cut/release/publish a new version
- User asks to bump alef version
- After a stack of fix/feat commits that need a version cycle
- After a breaking change is committed (must bump MINOR pre-1.0, MAJOR otherwise)
- When consumer repos need a new pin
Hard rules
- Always update
CHANGELOG.md— every release has a dated heading and one bullet per user-visible change. Move entries from[Unreleased]into the new version section. Group under### Added,### Changed (BREAKING),### Fixed,### Removed. Never tag a version with an empty section. - Always run
poly fmt --fix .thenpoly lint .to fix lint/format issues before publishing. Re-stage anything the formatter rewrites. Only commit with--no-verifyif a hook is genuinely broken in a way unrelated to the change — and then file an issue. - No AI signatures in commit messages, tag messages, or release notes.
No
Co-Authored-By: Claude, noGenerated by .... Never. - Atomic commits —
chore(release): X.Y.Zcarries only the version bump and changelog roll. Code fixes live in their own commits, merged before the release commit. - Add tests for any fix that changed behavior. A release that includes a fix without a regression test is a release that will regress.
- Use the Taskfile for version setting — never hand-edit
Cargo.toml,alef.toml,src/core/template_versions.rs, orschemas/alef.schema.json.task set-versionrewrites all four in lockstep. - Publish with
gh release create— a baregit tagis not a release. ThePublishworkflow triggers onrelease: types: [published], so the GitHub release is literally what runscargo publish; a tag push alone runs nothing at all. This is not theoretical: v0.55.2 and v0.55.3 were tagged and pushed with no release created, and neither ever reached crates.io. Alef ships as a single crate, so one release means exactly onecargo publish— no multi-crate sequencing, no index propagation race.
Procedure
0. Pre-flight
git status # working tree clean (or only release-prep diffs)
git fetch origin # inspect freshness without rewriting local commits
If the release branch has diverged from its upstream, stop and ask how to reconcile it. Do not rebase or merge after committing unless the user explicitly asks for it.
1. Update CHANGELOG.md
- Open
CHANGELOG.md. - Move every bullet under
## [Unreleased]into a new section## [X.Y.Z] - YYYY-MM-DD(today's date, ISO format). - Re-create an empty
## [Unreleased]heading at the top. - If the release contains anything tagged
!(breaking) in commit messages, surface it under### Changed (BREAKING)with explicit migration guidance. - Verify no entries are lost:
git diff CHANGELOG.mdshould show only adds in the new section + the moved bullets. - When folding scratchpad or agent-drafted bullets into a section, strip every
heading line from the source first — write plain bullets only, never nested
#/##lines. A stray heading from a pasted source reparents everything below it under the wrong version. Before and after any CHANGELOG edit,grep -c '^## \['must be unchanged andgrep -c '^# 'must be exactly1(the file's single top-level title). poly fmt --fix CHANGELOG.mdhas previously demoted every heading below the first (rumdl's autofix for a second level-1 heading treats it as a title and reparents everything that follows).poly.toml's[fmt.markdown.rumdl] disablealready includes"MD025"as the guard — do not remove it. Re-run thegrep -c '^## \['/grep -c '^# 'check after anypoly fmtpass over this file regardless.
2. Set the version via Taskfile
task set-version -- X.Y.Z # bumps Cargo.toml, alef.toml, ALEF_REV; regenerates
# schemas/alef.schema.json; runs cargo update
The set-version task is the only sanctioned way to bump versions in this
repo. It rewrites Cargo.toml (package.version), alef.toml
(alef_version), and src/core/template_versions.rs::ALEF_REV, regenerates
schemas/alef.schema.json (cargo run -- schema --schema-version), then runs
cargo update — all in one shot. Never hand-edit any of these — they must stay
in lockstep. The regenerated schema shows up in the release diff (its $id and
version both carry the new version); that is expected output, not drift.
After the task finishes, verify:
grep -E '^version' Cargo.toml # package version
grep -E '^alef_version' alef.toml # alef.toml mirror
grep ALEF_REV src/core/template_versions.rs # template version pin
grep '"version"' schemas/alef.schema.json # regenerated schema
All four must match X.Y.Z (the ALEF_REV line and the schema $id both
include a leading v).
3. Lint pass
poly fmt --fix .
poly lint .
Re-stage any files the formatter rewrote. If a lint fails for a real reason, fix
that reason — never bypass with --no-verify to push past a lint failure.
4. Tests for changed behavior
For every fix: or feat: rolled into this release, confirm there is a test
that would have caught the bug or covers the new surface. Add the test now if
missing — release commit goes on top.
5. Commit
git add -A
git commit -m "chore(release): X.Y.Z"
The commit subject is exactly chore(release): X.Y.Z. No body unless the
release is large enough to warrant a summary; never add AI attribution.
If gitfluff or another commit-msg hook rewrites the subject in an unhelpful
way, prefer fixing the hook config over --no-verify. When the user has
explicitly authorized --no-verify for this run, document which hooks were
skipped in the release notes.
6. Tag and publish
Push main before tagging. Tagging first and pushing second means a
rebase or a rejected push after the tag exists leaves the tag pointing at a
commit origin/main never contains — --force-with-lease does not work on
tags, so recovering means deleting and recreating the tag. Push main, confirm
it landed, then tag against the now-confirmed commit:
git push origin main
git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z
Then create the GitHub release — this is the part most likely to be skipped and the most important:
gh release create vX.Y.Z \
--title "vX.Y.Z" \
--notes-from-tag \
--verify-tag
If the changelog entry is rich enough to use as release notes, replace
--notes-from-tag with --notes-file <(awk '/^## \[X.Y.Z\]/,/^## \[/' CHANGELOG.md | head -n -1)
or build a small notes file from the new CHANGELOG section.
For pre-releases (RC, beta), add --prerelease.
7. Verify
The release object existing is not proof the crate shipped — verify the registry, not just the GitHub release:
gh release view vX.Y.Z # release exists with notes
git ls-remote --tags origin vX.Y.Z # tag pushed
gh run list --workflow=publish.yaml --json databaseId,status,conclusion -L 5
gh run view <run-id> --json jobs \
--jq '.jobs[] | select(.name | test("crates")) | {name, conclusion}'
curl -sI -H 'User-Agent: alef-release (contact: <maintainer email>)' \
https://index.crates.io/al/ef/alef | head -1 # 200 if the version is on the index
Notes:
- A failed publish job may still have published (e.g. it failed on a later
step after
cargo publishalready succeeded) — check the crates.io index before assuming a red run means nothing shipped, and before re-running. - A skipped job in the run is not the same as a passing one — a publish run that reports overall success while a job inside it was skipped can still mean the crate never moved. Read individual job conclusions, not just the run's headline status.
- If a run must be retried,
gh run rerun --failed <run-id>re-runs only the failed jobs; confirm which ones actually need it first. - If none of the above resolve, redo the failed step — do not move on.
8. Downstream pins
For any consumer repo that pins this version, open a follow-up PR that bumps the pin. Don't bundle that into the release commit.
9. Local install and cleanup (optional)
To pick up the release locally, cargo install --path . --force from the repo
root (see the local-alef-install rule — never cargo install alef from
crates.io for testing a pre-release change). Then task clean (cargo clean +
rm -rf .alef/) to reclaim space once the release artifacts are no longer
needed.
Anti-patterns
- Tagging without a
gh release create— the crate is never published at all.Publishfires onrelease: published, not on the tag. This silently lost v0.55.2 and v0.55.3; both are tagged on origin and absent from crates.io. - Empty
## [Unreleased]rolled forward to a new version section. - Hand-editing
version = "..."inCargo.toml,alef_versioninalef.toml, orALEF_REVinsrc/core/template_versions.rsinstead of usingtask set-version. - Fix commits with no test added.
--no-verifyto skip a real lint failure.- AI attribution in commit/tag/release text.
- Squashing release prep with code fixes — keep
chore(release): X.Y.Zatomic. - Treating a green publish run as proof of a shipped crate without checking for a skipped job inside it.
- Re-running a failed publish job without first checking whether it already
published — a second
cargo publishfor the same version fails loudly, but the confusion it causes is avoidable. - Tagging before pushing
main(see step 6) — recovering from a stranded tag means delete-and-recreate, not--force-with-lease. - Chasing a
Publishrun stuck inqueuedas a code bug — this is commonly account-wide runner capacity, not this repo. Report it once with the run link; if it is genuinely wedged rather than merely slow,gh run cancel --force-cancel <run-id>before retrying (cancelalone can leave a queued run deadlocked).
Quick reference
| Step | Command | What it verifies |
|---|---|---|
| Pre-flight | git status && git fetch origin |
Clean tree + remote state |
| Changelog | manual edit of CHANGELOG.md |
Every change is documented |
| Version | task set-version -- X.Y.Z then grep -E '^version' Cargo.toml |
Crate version updated |
| Lint | poly fmt --fix . && poly lint . |
Lint clean |
| Commit | git commit -m "chore(release): X.Y.Z" |
Atomic release commit |
| Push main | git push origin main |
Tag will land on a commit origin/main actually has |
| Tag | git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z |
Tag exists remotely |
| Publish | gh release create vX.Y.Z --notes-from-tag --verify-tag |
GitHub release exists |
| Verify | gh run view <run-id> --json jobs --jq '...test("crates")...' + crates.io index curl |
Crate actually shipped, not just the release object |