# Release Procedure

> Cut, tag, and publish an alef release end-to-end. Use this skill any time the user asks for a release, a version bump, a hotfix tag, or a CHANGELOG roll-up in this repo. Covers the full pipeline: changelog, version sync via Taskfile, Cargo.toml verification, poly lint pass, atomic commit (no AI signatures, no --no-verify when avoidable), git tag, and `gh release create` (not just a tag).

- Skill: `kreuzberg-dev/release-procedure` (Agent Skill)
- Install (CLI): `npx skillmds@latest add kreuzberg-dev/release-procedure`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kreuzberg-dev/release-procedure/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- License: MIT
- Author: kreuzberg-dev (https://skillmd.com/u/kreuzberg-dev)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/kreuzberg-dev/release-procedure

---


# 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

1. **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.
2. **Always run `poly fmt --fix .` then `poly lint .`** to fix lint/format issues before
   publishing. Re-stage anything the formatter rewrites. Only commit with
   `--no-verify` if a hook is genuinely broken in a way unrelated to the change
   — and then file an issue.
3. **No AI signatures** in commit messages, tag messages, or release notes.
   No `Co-Authored-By: Claude`, no `Generated by ...`. Never.
4. **Atomic commits** — `chore(release): X.Y.Z` carries only the version bump
   and changelog roll. Code fixes live in their own commits, merged before the
   release commit.
5. **Add tests for any fix that changed behavior.** A release that includes a
   fix without a regression test is a release that will regress.
6. **Use the Taskfile** for version setting — never hand-edit `Cargo.toml`,
   `alef.toml`, `src/core/template_versions.rs`, or `schemas/alef.schema.json`.
   `task set-version` rewrites all four in lockstep.
7. **Publish with `gh release create`** — a bare `git tag` is not a release.
   The `Publish` workflow triggers on `release: types: [published]`, so the
   GitHub release is literally what runs `cargo 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 one
   `cargo publish` — no multi-crate sequencing, no index propagation race.

## Procedure

### 0. Pre-flight

```bash
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.md` should 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 and `grep -c '^# '` must be exactly `1`
  (the file's single top-level title).
- `poly fmt --fix CHANGELOG.md` has 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] disable` already includes `"MD025"` as the guard — do
  not remove it. Re-run the `grep -c '^## \['` / `grep -c '^# '` check after
  any `poly fmt` pass over this file regardless.

### 2. Set the version via Taskfile

```bash
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**:

```bash
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

```bash
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

```bash
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:

```bash
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:

```bash
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:

```bash
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 publish` already 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.
  `Publish` fires on `release: 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 = "..."` in `Cargo.toml`, `alef_version` in
  `alef.toml`, or `ALEF_REV` in `src/core/template_versions.rs` instead of
  using `task set-version`.
- Fix commits with no test added.
- `--no-verify` to skip a real lint failure.
- AI attribution in commit/tag/release text.
- Squashing release prep with code fixes — keep `chore(release): X.Y.Z` atomic.
- 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 publish` for 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 `Publish` run stuck in `queued` as 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 (`cancel` alone 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 |

