Branching and releases¶
The short version: main is the only long-lived branch, every PR targets it,
and every published tag points at a commit on main.
Everything below exists to keep that last sentence true, because it is the one property that makes "did this actually ship?" answerable by a machine.
Why this shape¶
This repo has lost work twice. Both times the work was merged by GitHub's
account and absent from main:
- PR #23 — commits were pushed onto the branch after its PR merged. The branch still existed, so the push succeeded and went nowhere.
- PR #30 — based on
fix/justify-full(PR #29's branch). #29 squash-merged tomain; eighteen seconds later #30 merged intofix/justify-full, a branch nothing pulls from. GitHub reported "merged". The terminal-theme presets were silently lost and had to be recovered later.
Both share a shape: a second long-lived head that isn't main. So the model
refuses to create one. There are no release lines to back-merge, no develop, and
release branches live for minutes.
The usual Git Flow move — tag the release branch, then merge it back — is
deliberately inverted here. Release branches merge into main first, and the
tag goes on the resulting commit on main. That makes this a true invariant:
If that ever fails, something was published that isn't on main.
Branches¶
| pattern | cut from | merges to | lifetime |
|---|---|---|---|
main |
— | — | permanent, protected |
feat/<slug> · fix/<slug> · chore/<slug> · docs/<slug> |
fresh origin/main |
main via PR |
one PR |
port/<module> |
fresh origin/main |
main via PR |
one PR — the port-module skill |
sync/rich-<version> |
fresh origin/main |
main via PR |
one PR — the sync-upstream skill |
rc/<X.Y.Z> |
fresh origin/main |
main via PR, then tag on main |
a release cycle |
release/<X.Y.Z> or releases/<X.Y.Z> |
fresh origin/main |
main via PR, then tag vX.Y.Z on main |
minutes |
hotfix/<X.Y.Z> |
the tag vX.Y.(Z-1) |
nothing — tagged in place, forward-ported to main by PR |
until forward-ported |
A merged branch is dead. It is auto-deleted, and pushing to it is a bug, not
a shortcut. If you need to add to merged work, cut a new branch from origin/main.
Enable the local guard once per clone — server-side deletion only helps for branches merged after it was switched on, and only once you've fetched:
It refuses any push to a branch whose PR has already merged. This is not hypothetical: it was written immediately after two commits were pushed onto PR #31's branch minutes after that PR merged, stranding them exactly as #23 and
30 were stranded.¶
Release-candidate branches¶
rc/<X.Y.Z> is an integration branch: ordinary work targets it during a
release cycle, and it merges to main when the cycle closes. Tags are still only
ever placed on main, so the ancestor invariant above still holds.
This is a deliberate exception to "everything targets main", and it reintroduces
the one thing the rest of this document exists to avoid — a second long-lived
head. It is safe only while the rc stays a fast-forward of main. The moment
main moves ahead, merging the rc either conflicts or silently reverts, and a
release cut from it no longer contains what is on main.
CI enforces that: the rc not behind main check applies to rc/*, release/*,
and releases/* (including releases/v0.0.3-rc). It tests the proposed merge
result, so a PR bringing in current main can pass. Keep it current with:
git fetch origin
git switch -c fix/refresh-release origin/releases/v0.0.3-rc
git merge origin/main
# Open a PR into releases/v0.0.3-rc.
Protection¶
The ruleset described below covers rc/*. Accepting release/* and releases/*
in workflow checks does not extend that ruleset; verify server-side protections
before creating another integration branch.
rc/* is covered by its own ruleset ("rc branches"), matching main's
protection rather than being the soft underbelly of the release process:
| rule | effect |
|---|---|
creation |
only repository admins may create an rc/* branch |
deletion |
it cannot be deleted |
non_fast_forward |
it cannot be force-pushed |
pull_request |
changes land by PR (0 approvals — solo maintainer) |
required_status_checks |
ci-ok, pr body, base branch, rc not behind main, strict |
The required checks are deliberately not the same list as main's. main
additionally requires the CodeQL Analyze (…) contexts, and those do not run on
PRs into rc/* — requiring them would leave every rc PR blocked forever on a
check that can never report. Only require a context you have watched report on a
PR against that specific base.
An rc branch must be cut from main, and GitHub cannot enforce it. No
protection rule or ruleset constrains the commit a branch is created from. The
rc branch guard workflow fires on branch creation and fails if the new rc/*
branch does not contain origin/main — a detector, not a gate, since the branch
already exists by then. It pairs with rc not behind main, which gates drift
afterwards.
Stacked branches¶
Opening a PR based on another PR's branch is fine. Merging it while stacked is not — that is incident 2 exactly. Retarget before merge:
CI enforces this: the base branch check fails any PR not targeting main or
rc/*, release/*, or releases/*.
Releases¶
There are two separate decisions here:
- Each crate owns its SemVer. A number describes that crate's Rust API and contents; it is never copied from either Python upstream. A change in one crate does not, by policy alone, require an unrelated crate's version to change.
- The tag explicitly selects what ships. A
vX.Y.Ztag retains the coordinated workspace meaning: all four manifests and their internal requirements must agree atX.Y.Z. A<crate>-vX.Y.Ztag selects only that crate, whose manifest must match the tag. Unselected crates keep their own versions and are neither published nor verified as if they had changed.
| Tag | Packages published and verified |
|---|---|
v0.0.3 |
All four crates at 0.0.3 |
rs-rich-v0.0.3 |
Only rs-rich at 0.0.3 |
rs-rich-ext-v0.0.3 |
Only rs-rich-ext at 0.0.3 |
rs-rich-cli-v0.0.3 |
Only rs-rich-cli at 0.0.3 |
rs-rich-art-v0.0.3 |
Only rs-rich-art at 0.0.3 |
The same forms accept SemVer prereleases, for example
rs-rich-cli-v0.0.3-rc.1. Manual dispatch accepts an existing tag in one of
these forms, never a branch name. Every job checks out the validated tag's
commit SHA, including the reusable CI gate. Tags still belong on main after
merging the release changes; this does not relax the ancestry rule.
For a CLI-only 0.0.3 release with core still at 0.0.2, use
rs-rich-cli-v0.0.3, not v0.0.3. For an art-only release, use
rs-rich-art-v0.0.3 and update the root rich-art dependency requirement to
match. All three workspace requirements are checked against their respective
crate versions, not against the selected crate's tag version. Refresh
Cargo.lock whenever manifests or dependency requirements change.
Dependencies outside the selected set must already be available on crates.io;
cargo publish -p <crate> --locked --dry-run verifies the packaged crate against
those registry dependencies before any upload. If both art and CLI advance and
CLI requires the new art version, publish and verify art first, then CLI.
What Cargo means by 0.0.x¶
The workspace dependency spelling version = "0.0.2" is Cargo shorthand for
the caret requirement ^0.0.2. Cargo permits versions >=0.0.2,<0.0.3: for a
0.0.x requirement, changing the patch component is incompatible. A path
dependency can use the local package while developing, but its version must
still satisfy that requirement, and the requirement is what consumers see in
the published package. Thus bumping rs-rich from 0.0.2 to 0.0.3 requires
updating the requirements used by its direct dependents (rs-rich-ext,
rs-rich-art, and rs-rich-cli); bumping rs-rich-ext or rs-rich-art
requires updating rs-rich-cli. Cargo does not force unrelated crates to
share a version. Our coordinated-tag policy does.
Prepared 0.0.4 snapshot¶
All four manifests select 0.0.4 because each package changes or consumes the new
core version. Core adds an ownership extension point and performance changes;
ext adds explicit text decoding; art adds GIF half-block rendering; the CLI wires
these options and reduces CSV copies. Root internal requirements and the lockfile
must agree with those independently selected versions. The coordinated v0.0.4
scope is appropriate after final integration and verification; preparation does
not create a tag or publish. See release preparation.
Prepared 0.0.3 snapshot¶
This preparation includes the Markdown fix from main, requiring core 0.0.3.
The ext package also changes its core dependency and must publish a new version.
Together with the prepared CLI and art versions, all four manifests in that
snapshot said 0.0.3. This is the dependency closure for this release, not a lockstep policy.
The selected coordinated tag v0.0.3 selects all four:
| package | decision | manifest change | internal requirement change |
|---|---|---|---|
rs-rich |
publish 0.0.3 |
crates/rich/Cargo.toml: 0.0.2 → 0.0.3 |
root rich requirement: 0.0.2 → 0.0.3 |
rs-rich-ext |
publish 0.0.3 |
crates/rich-ext/Cargo.toml: 0.0.2 → 0.0.3 |
root rich-ext requirement: 0.0.2 → 0.0.3; it consumes the updated root rich requirement |
rs-rich-art |
publish 0.0.3 |
crates/rich-art/Cargo.toml: 0.0.2 → 0.0.3 |
root rich-art requirement: 0.0.2 → 0.0.3; it consumes the updated root rich requirement |
rs-rich-cli |
publish 0.0.3 |
crates/rich-cli/Cargo.toml: 0.0.2 → 0.0.3 |
it consumes all three updated root requirements |
The manifest and lockfile updates are finalized, and selected changes are under
the 0.0.3 changelog heading. Unreleased is reserved for subsequent work. Regenerate
version tables with python3 scripts/gen_versions.py and CLI help with
python3 scripts/gen_cli_reference.py after building the release binary. CI
checks both generated documents. No tag or registry upload is created by preparation.
This coordinated option is separate from the independent CLI/art tags above.
Do not publish a crate at 0.0.3 independently and then expect a coordinated
v0.0.3 to skip it: the already-published-version guard deliberately rejects
that mixed attempt. Choose the release scope before tagging.
Tags are annotated: vX.Y.Z or <crate>-vX.Y.Z for releases, with -rc.N
appended for candidates.
The full procedure lives in the release skill (.claude/skills/release/).
In outline:
mainis green andCHANGELOG.mdhas content under## [Unreleased].- Cut
rc/X.Y.Z-rc.1, make the single version+changelog commit, PR it, merge. - Tag
vX.Y.Z-rc.Non the merge commit onmain; the release workflow publishes it. - Soak. Fixes land on
mainas ordinary PRs, then cut another rc. - Cut
release/X.Y.Z, same shape, merge, tagvX.Y.Z.
Publish order¶
cargo publish --workspace --locked retains the coordinated path's topological
order and sibling-tarball verification. Independent tags use
cargo publish -p <crate> --locked. Both paths run a dry run with the identical
selection first. The workflow resolves that selection once, checks the
manifests and internal requirements, and queries crates.io for each selected
package/version. A hit or an unexpected registry response aborts the job;
unchanged, unselected versions are not queried. Uploads are serialized across
all release tags and manual dispatches.
The gate requires an annotated tag pointing at the checked-out commit on main;
lightweight tags are rejected before CI or publication.
After publishing, verification reuses the protected crates-io job's validated
checkout: fresh registry consumers can execute build scripts too. The registry
token is scoped only to the upload step.
Verification waits for each selected version on crates.io
and fails if it does not appear. In fresh temporary directories outside the
checkout, it installs the CLI with an exact version and --locked, or compiles
a consumer with an exact registry dependency for each selected library. An
art-only release never installs the CLI or waits for a new core version.
To retry verification after all selected packages have been uploaded, manually
dispatch the release workflow with the same existing tag and verify_only: true.
This keeps the annotated-tag, ancestry and full-CI gates, skips preflight and both
upload commands, and runs exact-version verification. Normal tag pushes and
manual dispatches default to publication and still reject any existing version.
Verification-only mode cannot complete a partial upload.
Publishing is irreversible — versions are immutable and can only be yanked.
A partial upload still requires manual recovery, not a blind rerun. Planning and documentation updates
do not create tags or publish packages. Create the annotated tag on main;
the protected release workflow publishes the selected packages.
What is enforced, and what is merely written down¶
Documented discipline decays; these are the mechanisms.
| Risk | Mechanism | Prevents or detects |
|---|---|---|
| Pushing to a branch whose PR merged | delete_branch_on_merge — the branch ceases to exist |
prevents |
| …the same, on a clone that still has the branch | .githooks/pre-push — refuses the push |
prevents |
| Merging a stacked PR into a dead base | base branch CI check |
prevents |
An rc branch drifting behind main |
rc not behind main CI check |
detects, before release |
Anyone creating an rc/* branch |
creation rule in the "rc branches" ruleset — admins only |
prevents |
| An rc branch deleted or force-pushed | deletion + non_fast_forward rules on rc/* |
prevents |
An rc branch cut from something other than main |
rc branch guard workflow on branch creation |
detects (GitHub cannot prevent it) |
| A required check that can never report | one aggregate ci-ok context, not per-matrix-job names |
prevents |
Merging a branch cut from a stale main |
strict: true on required checks |
prevents |
| A PR that says nothing useful | pr body CI check against the template |
prevents |
Publishing content that isn't on main |
tags only ever go on main; the ancestor assertion above |
detects |
| "Is it actually merged?" | git merge-base --is-ancestor "$(gh pr view N --json mergeCommit -q .mergeCommit.oid)" origin/main |
detects |
That last one matters because the obvious tools lie: after a squash or rebase
merge, git branch --contains finds nothing and git cherry reports false
negatives. Testing the merge commit works regardless, because the merge commit
lives on the base branch and its SHA is never rewritten.