Companion to RELEASES.md. RELEASES.md is the runbook (commands, paths, decision tables). This file
holds the WHY behind those rules: branching model, PR conventions, release pipeline, CHANGELOG generation, prose-check
pipeline, spec-vendor pipeline, branch-protection pitfalls.
Read this when:
- A rule in RELEASES.md doesn't make sense and you're tempted to change it.
- A new contributor asks "why do we do X this way".
- You're adding a new release-flow rule and need to know where it fits the existing model.
dev is never deleted, even after a release. The next release cycle reuses the same dev. The repo's
deleteBranchOnMerge: true setting doesn't touch dev as long as dev is never the head of a PR. Using a short-lived
release/* head is what keeps the setting compatible with a forever integration branch.
Engineering docs (docs/plans/, docs/solutions/, docs/brainstorms/, docs/reviews/) live on dev only. They never
reach main. guard-main-docs.yml blocks them from PRs targeting main, and guard-release-branch.yml rejects any PR
to main whose head isn't release/*.
Every release squash-merges into main, so dev and main diverge in history even as their content converges: after
the first release they share only an ancient merge-base. Cutting the release branch from dev (or merging dev into
main) forces a 3-way merge across that divergence: add/add collisions on files both sides changed, plus
rename/delete pairs git cannot auto-resolve. The conflict pile is an artifact of the lineage, not of the content
shipping.
Always cut the release branch from origin/main and bring dev's content onto it as a forward diff, never by
reconciling histories. The default is the whole-tree overlay (git checkout origin/dev -- ., then strip the guarded
set): main ships dev's tree minus a small, known exclusion set, so asserting that end-state directly is simpler and
safer than hand-resolving a merge. The overlay commit carries no per-PR history, so the changelog is built from the
PRs merged into dev since the previous release (generate-changelog.py --from-dev-prs) rather than from the
branch's commits; the result is the same per-PR section a cherry-picked branch would yield. Cherry-picking the dev
squash-commits is kept only as an exception for a repo with a stated reason it cannot overlay, at the cost of
guarded-path conflict handling.
Either way, the release must start from a main that dev fully contains. Security PRs, hotfixes, and config edits
land on main first, and both constructions take dev's content for the files they touch, so anything main holds
that dev never received is reverted by the release. scripts/release/drift.sh lists that set and the cut waits
until it is empty.
Branch naming release/v<version> or release/v<version>-<slug> (e.g. release/v0.1.0,
release/v0.2.0-python-checks) makes release branches sortable and unambiguous when multiple cuts are in flight. The
v<version> prefix is required: scripts/generate-changelog.py extracts the version from the branch name. Slug is
kebab-case, short, descriptive.
Every section of a PR body is user-facing substance only: the net diff, what is changing for the consumer that was
not already there, not the commit history or intermediate state that produced it. Workflow mechanics (cherry-pick,
regenerate, pre-push gate, CI behavior) is documented in RELEASES.md and .github/, NOT in the PR body. Triple-diff
output ("A: 12 files, B: none, C: clean"), leak-check narration ("guard-main-docs runs clean", "no guarded paths
leaked"), patch-id cherry-check counts, pre-push gate results, CI check status, exclusion rationale, and other
verification artifacts stay local; anomalies get fixed before push, not audit-trailed in the body.
The PR body is read by humans reviewing what shipped. Workflow mechanics and tool-fix provenance are noise from that perspective; they belong in this file, the script outputs, and the commit history respectively.
cliff.toml skips ^chore (and ^style / ^test / ^ci / ^build) regardless of body content. Mistyping a
user-facing change as chore silently strips it from release notes. Prefer feat / fix when the change has any
user-observable effect (config defaults, env vars, default behaviors).
Security advisory bumps in particular use fix(deps):, never chore(deps):, so they appear in the changelog. A bumped
dependency that closes a CVE is user-visible value, not internal tooling.
Related Issues/Stories has four labels (Story: / Issue: / Architecture: / Related PRs:). Files Modified has
four sub-headers (Modified / Created / Renamed / Deleted). All four must appear in every PR, even when empty:
write - None. or n/a rather than deleting the label. Reason: scanners and humans both rely on a known section shape.
Conditionally-absent sections force every reader to mentally check "did the author skip this or does it not apply?"
Co-Authored-By: Claude …, 🤖 Generated with [Claude Code], or any similar AI-attribution trailer is banned from
commit messages and PR bodies. Commits and PRs stand on their own technical content. Attribution trailers are noise and
they age poorly as tools shift.
Author each paragraph and each bullet as one logical line, however long. GitHub soft-wraps for display. Hard wraps
within prose produce visible mid-sentence breaks in some renderers and interfere with the prose-check pipeline: Vale's
line-anchored output reports findings against split lines, LanguageTool's input handling can choke on certain
control-char interactions. The auto-format hook skips /tmp/ paths so the body keeps its authored shape, don't undo
that with manual wrapping during composition. Same rule applies to commit messages composed via heredoc.
The release PR carries the same ### Added / ### Changed / ### Fixed / ### Documentation bullets as the feature
PRs it cherry-picks. The repetition is intentional and harmless: cliff.toml's ^release skip prevents the release-PR
squash commit from being double-counted in any future regeneration.
chore(cliff): ..., chore(prose-check): ..., and similar internal tooling commits don't appear in the PR body's ## Changelog. They are not user-facing. They belong in commit history and in the Files Modified / Key Details sections of
the PR body, not in the source-of-truth release notes.
The release-PR procedure runs three diffs (A: main→release, B: release→dev for non-doc paths, C: dev→main) plus a
patch-id cherry check. This is belt-and-suspenders because missed cherry-picks have shipped to main on this and
sibling repos before, and the file-level diff in B alone doesn't catch the patch-id false-negative class.
guard-main-docs is what CI enforces on a PR to main: the reusable workflow's hardcoded base list plus this repo's
extra_paths (styles/, .vale.ini, scripts/prose-check.sh). Every hand-kept copy of that union (runbook,
checklist, preflight script) drifted from it, and a copy that omits a guarded path reports a real leak as clean while
CI turns red after the push. scripts/release/guarded-paths.sh reads extra_paths out of the caller workflow and adds
the base list, so registering a path in the workflow is the only edit a new guarded path needs. The base list is the
one copy that still needs a manual edit when the reusable changes, because it lives in another repo. Entries are globs
with one rule set shared by the reusable and the script (**/ any depth, * and ? within a segment, trailing slash
guards the subtree), so **/.agent/ guards that directory wherever it appears and the two never disagree about what is
guarded.
The leak check screens the diff against the registered set, so it says nothing about a category nobody registered. A
new engineering directory or a stray note under docs/ passes the local check and guard-main-docs alike. Step D
lists every docs/ file and every markdown file the release adds to main outside the guarded set and puts them in
front of a human; each one needs a reason to ship, or it gets registered in extra_paths and dropped from the branch.
Root-level markdown is in scope because an agent-facing glossary at the repo root is exactly the kind of addition a
docs/-only listing misses.
In a squash-merge workflow, git cherry HEAD origin/dev produces many + lines that need human triage. They do NOT
auto-block the release. Expected sources of false positives:
- Historical commits squash-merged in prior releases. The squash commit on main has a different patch-id than the
dev commits it consolidates, so old commits show as
+forever. Anything older than the previous release tag is almost always this. - Cherry-picks where conflict resolution stripped guarded paths (
docs/plans/,docs/brainstorms/, etc.) or otherwise altered the tree. Same source-code intent, different patch-id. - Intentionally skipped commits (docs-only commits, release-prep backports, revert-and-redo prep steps).
A real miss looks like: a recent feat/fix/chore commit on dev whose file content is not yet on main. To triage a +
line:
git show <sha> --stat # what did it touch?
git diff origin/main..HEAD -- <those-files> # already on release?If every touched file is guarded (docs/plans/, docs/brainstorms/, etc.) OR the content is already on main via a
prior squash, it's a false positive (no action). Otherwise cherry-pick the commit and re-run the triple-diff.
scripts/generate-changelog.py (vendored from the github-repo-setup skill, with the repo-local cliff.toml) is the
only sanctioned way to update CHANGELOG.md. On an overlay-built release branch it runs as --from-dev-prs: the PRs
merged into dev since the previous release are the entries, and each PR's body supplies its ## Changelog → ### Breaking changes / Added / Changed / Fixed / Documentation subsections (with author and PR-link attribution). On a
cherry-picked branch it runs git-cliff first to prepend a versioned entry from the branch's commits, then expands
the same way.
If a PR's body has no ## Changelog section at all, its title becomes a Changed bullet, except for chore, ci,
build, style, and test PRs, which stay out unless they carry a ## Changelog of their own. A PR whose
## Changelog heading stands over nothing (the template's form for a change that is not user-facing) adds nothing,
whatever its type. To fix a wrong CHANGELOG entry, fix the input: edit the squash-merged PR body, then re-run the
script. Do not edit CHANGELOG.md directly.
CI enforces that CHANGELOG.md is modified in every PR to main (ci / Changelog required status check) and that it
contains a versioned section, not [Unreleased]. The release workflow extracts the tag's section for the GitHub
Release body.
These commit types do not produce user-facing content. If a cherry-picked PR has user-facing ## Changelog content but
its commit subject starts with one of those types, its bullets get silently dropped. After running the script,
cross-check the generated section against gh pr view <num> --json body for each cherry-picked PR; correct mistyped PR
titles (e.g. chore → feat) and re-amend the cherry-pick subject before re-running. See "Prefer feat/fix over
chore" in global CLAUDE.md for prevention.
Always use annotated tags (-a -m). Bare git tag <name> silently fails with fatal: no tag message? on machines
where tag.gpgsign=true is set globally (a brettdavies dotfile default). See
solutions: git tag fails with tag.gpgsign, use annotated tags.
Subsequent releases use the OIDC Trusted Publishing flow built into release.yml: no static token in CI. The initial
publish (v0.1.0) requires a regular crates.io API token because Trusted Publishing needs the crate to exist first.
The GitHub Release is created visible-but-not-latest (make_latest: false) so cargo-binstall and /releases/latest
don't 404 during the bottle-build window, but the release isn't yet promoted to "Latest" while bottles upload. After the
homebrew-tap workflow uploads bottles to this repo's release assets, it dispatches finalize-release back to this repo,
which idempotently flips make_latest: true. End result: crate on crates.io, GitHub Release marked latest, Homebrew
formula updated with bottles, all atomically advertised.
Once finalize-release.yml has flipped the GitHub Release to published, the release-bookkeeping files on main
(Cargo.toml version, Cargo.lock, CHANGELOG.md) need to reach dev so future builds from dev report the
released version (and so anc audit's embedded badge URL points at the right slug, not a stale one) and so the next
dev work starts from the released baseline.
The backport is a PR opened by scripts/sync-dev-after-release.sh, never a merge of main into dev and never a
direct push. The squash-merged branches share no recent history, so a merge conflicts on every file both sides
touched, and a direct push to dev bypasses its required status checks. The script writes the released version into
Cargo.toml with an in-place line edit (other Cargo.toml lines on dev, such as post-release deps or a
rust-version bump, are preserved), refreshes the workspace entries in Cargo.lock from the synced manifests rather
than copying main's lock (which would revert dependency updates dev merged after the release), copies
CHANGELOG.md verbatim from origin/main, and opens the PR; the postflight backport gate treats that merged PR as the
durable signal that the backport ran.
Other release-only edits are discovered rather than listed. A release branch is edited for reasons nobody predicts (a
doc fix, a reverted payload, a deleted config), each such edit is made against main's base, and a fixed list misses
it silently; the next release's overlay then restores dev's copy over main's and undoes the edit. Discovery is
bounded by the previous release tag, the last point the two branches agreed, so it cannot revert dev's unreleased
work: a path whose dev copy still matches that tag is release-prep and adopted, and a path both branches changed is
contested, reported, and adopted only when the operator names it (--only PATH) or takes every contested path
(--include-contested).
The backport is idempotent: re-running on a dev already in sync exits 0 without creating a branch or PR.
Rollback happens at the surface users consume (crates.io, the GitHub Release, the Homebrew formula), not in git.
Yanking a crate version, re-pointing releases/latest, and reverting the formula bump are fast and reversible;
rewriting main is neither, and the release flow exists so that main only ever moves forward through a PR. After
the rollback, the fix or revert lands through dev, a release branch, and main like any other change, so the branch
reconverges with what is live. Recording the last-good identifier before the release is what makes the rollback a
single command under incident pressure.
The cross-compile matrix builds 7 targets; the two musl rows (x86_64-unknown-linux-musl, aarch64-unknown-linux-musl)
are statically linked. linux_musl_required: true in the reusable workflow input makes their failures hard-block the
release, and linux_musl_verify_alpine: true runs the x86_64-musl binary inside alpine:latest after build as an
exec-compat sanity check. Alpine and other musl-libc hosts can run them without glibc, so a green musl row matters: the
dynamic targets fail loudly on missing-symbol mismatch, but a statically-linked binary that happens to compile could
still segfault on first exec without the verify step.
The agentnative-cli repo vendors the spec via scripts/sync-spec.sh (preferred latest-tag fetch) and exposes the
vendored content through codegen-derived structures (e.g., principle metadata in crates/anc-core/src/principles). The
spec-fixture-drift CI workflow runs --check on every PR; pulling the latest content during the release flow catches
any spec changes since dev was branched and avoids tagging a release with the codegen-derived host map one revision
behind upstream.
The skill-fixture refresh (scripts/sync-skill-fixture.sh) is the analogous pipeline for skill metadata: the Rust map
(SkillHost / KNOWN_HOSTS / resolve_host) regenerates from the JSON automatically on the next cargo build. No
manual src edits needed.
Three release-flow artifacts live outside any automated prose check and need a manual scrub before they ship:
- PR bodies.
gh pr createandgh pr editsend body text directly to GitHub; no automated prose check has reach there. CHANGELOG.md. A generated artifact built from upstream PR bodies; it inherits whatever prose those PR bodies carry, so scrubbing happens at generation time on the release branch.- Release-PR bodies. The
release/v<version>PR tomaincarries contributor-authored wrap-up text composed afterCHANGELOG.mdhas been generated, and the same out-of-repo gap applies.
The canonical Vale + LanguageTool rule packs and orchestrator behavior live in the spec repo at
~/dev/agentnative-spec/docs/architecture/voice-enforcement.md.
Until those packs are vendored into this repo (a deferred follow-up tracked in the spec plan; expected to extend
scripts/sync-spec.sh), point Vale at the spec checkout via --config.
Scrub-before-submit (author in /tmp/, scrub there, submit via --body-file) avoids the round-trip of "submit, scrub,
edit, scrub again". Every fix lands locally and the public PR sees only clean text. The auto-format hook skips /tmp/
paths so the body keeps its authored shape and no soft-wrapping is injected.
For a CHANGELOG.md finding, fix the upstream PR body (which generate-changelog.py re-fetches every run) and
regenerate. Hand-editing CHANGELOG.md directly produces drift the next regeneration overwrites.
The required_status_checks[].context strings in protect-main.json MUST match exactly what GitHub publishes for each
check:
- Inline job (with
name:field): published as just<job-name>(no workflow-name prefix). - Reusable-workflow caller (
uses: .../foo.yml@ref): published as<caller-job-id> / <reusable-job-id-or-name>.
Mixing these produces a stuck-but-green PR: all actual checks report green, but the ruleset waits forever on a context that will never appear. Confirm the real contexts after a first CI run with:
gh api repos/brettdavies/agentnative-cli/commits/<sha>/check-runs --jq '.check_runs[].name'Committing the JSON alongside code means ruleset changes land via the same review process as workflow changes. A
chore(ci): tighten protect-main change goes through dev → release/* → main like anything else.
RELEASES.md(operational runbook: commands, paths, decision tables)RELEASES-PREFLIGHT.md(pre-cut checklist gating the release-branch cut)RELEASES-POSTFLIGHT.md(post-tag verification of the publish pipeline)AGENTS.md(runninganc, project structure, adding new audits)README.md(install channels, principles, CLI reference).github/pull_request_template.md(PR body structure with changelog sections)