The exact commands, in order, to cut a release anywhere in the fleet. This is the
hands-on companion to RELEASE-STRATEGY.md β that doc is the policy (what is
versioned, when, why); this is the recipe (what to type). When they disagree,
RELEASE-STRATEGY.md wins; fix this.
Four flows live here β three are independently versioned (Core, dotfiles-Windows, htpx); the OS-repo rollout is the consumer side of the Core line, not its own version:
| Flow | Versioned thing | Trigger | Fans out to | Section |
|---|---|---|---|---|
| Core | dotfiles-core (core.version) |
make release + push tag |
the 8 OS repos' core/ |
1 |
| OS-repo rollout | not versioned (stamped core.lock) |
merging the fan-out PRs | the live hosts (on bootstrap) | 2 |
| dotfiles-Windows | dotfiles-Windows (own vX.Y.Z) |
mirror-sync nvim/+starship/ (auto-patch) or a manual CHANGELOG promotion + tag (minor/major) |
the Windows host (on bootstrap) | 3 |
| htpx | htpx (CHANGELOG.md) |
push a CHANGELOG bump to main |
dotfiles-Kali (companion.lock) |
4 |
These lines are independent and update different files, so they never collide: a Core
release bumps each OS repo's core.lock; an htpx release bumps Kali's companion.lock;
and dotfiles-Windows carries its own version, advanced two ways β an automatic patch when
the nvim//starship/ assets it mirrors from Core move, and a deliberate minor/major a human
cuts for host work (both flows in Β§3). It vendors no core/ subtree.
Run these in a clean dotfiles-core checkout. vX.Y.Z is the new version; pick the
bump first with Β§1.0, then run the cut in Β§1.1 (see core.version for the current
number β the current major is what the @vN alias points at, e.g. v3 while on 3.x).
Choose by blast radius on a host, not by how big the diff looks. The canonical
policy is RELEASE-STRATEGY.md Β§2 ("SemVer, mapped to dotfiles"); this is the
operator-facing summary so you don't have to leave the runbook:
| Bump | X.Y.Z moves |
Cut it when⦠| Concrete triggers |
|---|---|---|---|
| PATCH | Z β Z+1 |
a fix or doc change with no interface change | a bug fix; a zsh-plugin / nvim pin bump with no observable behavior change; a doc correction |
| MINOR | Y β Y+1, Z β 0 |
additive and backward-compatible | a new zsh module, alias, function, or keybinding that displaces nothing a host already relies on |
| MAJOR | X β X+1, Y,Z β 0 |
a host must adapt to keep working | reordering the load chain; removing/renaming a public alias, binding, or function; changing the bootstrap.sh symlink contract; dropping a core.manifest path |
When it's ambiguous, decide with these three:
- If a host that blindly re-bootstraps could break, or lose a command it relied on, it's MAJOR β even if the diff is a single line.
- If nothing a host already uses changes meaning, it's at most MINOR β even if the diff is large (a whole new module is still just additive).
- "Is this fix visible to a user?" is not the MAJOR test β a visible fix is still a PATCH as long as no interface is added, removed, or renamed.
Two consequences to remember before you type anything:
- Flag a MAJOR loudly in the CHANGELOG. It forces action on every OS repo at rollout
(they must adapt), so the
[Unreleased]β[vX.Y.Z]section should call the break out explicitly, not bury it under "changed". - The cut commands are identical for all three bumps except one step β the moving
@vNmajor-alias handling in Β§1.1 step 4. Read that callout; it's the only place major and minor/patch diverge mechanically.
# 0. Start from a clean, green, up-to-date main.
git checkout main && git fetch origin && git pull --ff-only origin main
make audit # the one gate β must be green before tagging
# 1. Stage the release (bumps core.version + promotes CHANGELOG [Unreleased], re-audits).
make release VERSION=X.Y.Z
git diff # sanity-check: only core.version + CHANGELOG heading moved
# 2. Commit + annotated tag locally (re-audits; does NOT push).
make tag
# 3. main is protected, so land the release COMMIT via a PR (merge commit, not squash).
git push origin HEAD:release/vX.Y.Z
gh pr create --base main --head release/vX.Y.Z --title "release vX.Y.Z"
# ... review, let CI go green, then MERGE with a merge commit ...
# 4. After the PR merges, tag the MERGED tip and push the tags (keeps git describe clean).
# The `vN` line is the ONE step that differs by bump type β see the callout below.
git fetch origin
git tag -fa vX.Y.Z origin/main -m vX.Y.Z
git tag -f vN origin/main # vN = the major alias, e.g. v3 (v4 on a MAJOR)
git push origin vX.Y.Z ; git push -f origin vN # ';' not '&&' β independent pushesEvery reusable workflow in the fleet pins its caller to @vN (currently @v3), and step 4
is where that alias moves. What you do with it depends on the bump you chose in Β§1.0
(policy: RELEASE-STRATEGY.md Β§"Pinning reusable workflows"):
- PATCH or MINOR β the major number is unchanged, so
vNstays the same alias (e.g.v3). Step 4 as written force-advances it to the new tip, and every caller pinned@v3picks the change up automatically on its next run. No caller edits. This auto-fan-out of guard/bootstrap fixes is the whole reason the alias moves. - MAJOR β you are minting a new major. In step 4,
vNis the new alias (e.g.v4), created fresh at the merged tip (git tag -f v4 origin/main && git push -f origin v4). Leave the previous alias frozen: do not run thevNline againstv3β advancing it would push the breaking change onto every caller still pinned@v3. Then bump the callers that should adopt the new major from@v3to@v4by hand β that fleet-wideuses:edit is the single intentional, reviewed change a MAJOR is meant to be, and it's tracked as part of rollout (Β§2), not this step.
The
make tagshortcut path handles the alias itself, safely for either case:tag-release.shderives the alias from the version (MAJOR="v${VERSION%%.*}"), so av4.0.0cut force-movesv4(creating it) and never touchesv3. It does not, however, bump the fleet's callers from@v3to@v4β that hand edit is still yours on a MAJOR.
release.ymlpublishes the GitHub Release β the body is the curatedCHANGELOG.mdsection for the tag (extracted with one awk pass), not git-cliff. (make release-notesis a separate git-cliff helper for manually drafting a body; it is not what CI publishes.)sync-fanout.ymlopens acore.lock-bump PR in every repo inscripts/os-repos.txt, each vendoringvX.Y.Zviagit subtree pull --squash. It opens PRs, never merges. (Requires theFLEET_SYNC_TOKENsecret ondotfiles-core.)
Then continue to section 2 to roll it out.
Shortcut:
make tag PUSH=1tags and pushes in one step, but it tags the pre-merge commit and then needs a re-point (it prints the recipe). The steps above avoid that β prefer them.
The fan-out from section 1 leaves one open PR per OS repo. Roll them out canary-first,
never all at once (per RELEASE-STRATEGY.md).
- Merge
dotfiles-MacBookfirst (the canary). Let it bake. - Then merge the remaining Linux/Role repos'
core.lockPRs. - On each host, the change lands when you re-run
./bootstrap.sh(or pull).
If this was a MAJOR release, the fan-out core.lock PRs are not the whole rollout: any
repo whose CI pins a reusable workflow at the old @vN also needs its uses: ref bumped to
the new major (@v3 β @v4) β the deliberate caller edit from Β§1.1 step 4. make fleet-drift
won't surface this: it compares each repo's recorded core.lock / nvim/.core-ref provenance
against Core, not its workflow uses: pins β so finding the stragglers is a manual sweep
(e.g. grep -rl 'uses:.*@v3' .github/workflows across the repos in scripts/os-repos.txt).
PATCH/MINOR releases skip this entirely: the moving alias already carried them.
If an OS-repo PR's links-only job fails on a mirror timeout (e.g. openSUSE's OSS
CDN), just re-run the job β the prep step retries automatically. A genuinely broken
prep still fails loud. dotfiles-Windows is not in the subtree fan-out β it has its
own release line (section 3).
dotfiles-Windows is standalone (no core/ subtree, and it isn't vendored), so a release
fans out to nothing β but it versions itself two ways:
- 3a β automatic PATCH, when a mirror-sync lands new
nvim//starship/content onmain. - 3b β a deliberate MINOR/MAJOR, cut by a human for accumulated host work (the PowerShell
profile & modules, Windows Terminal, the scoop/winget packages,
psmux, the WSL bridge).
Because auto-tag's patch tags advance mechanically on every mirror-sync, the tag line runs
ahead of the CHANGELOG.md headings (e.g. tags at v1.1.6 while the last ## [vX.Y.Z]
heading is still v1.1.0). Host work only ever earns a patch from auto-tag until a human
promotes it β recognizing that moment, and reconciling the two lines, is what 3b is for.
Run in a clean dotfiles-Windows checkout (PowerShell):
# 1. Mirror the shared Core assets. Pin an exact Core release for a reproducible sync
# (recommended when rolling a Core release onto the host); omit -Ref to track main.
.\nvim-sync.ps1 -Ref vX.Y.Z # nvim/ (includes lazy-lock.json)
.\starship-sync.ps1 -Ref vX.Y.Z # starship/starship.toml
# 2. Review, then commit only if content actually moved.
git diff nvim/ starship/
git add nvim/ starship/ ; git commit -m "sync nvim/starship from Core vX.Y.Z"
# 3. Land on main (PR -> merge if protected) β the push triggers auto-tag.yml.After the push, auto-tag.yml sees the new nvim//starship/ content and PATCH-bumps
Windows' own tag + Release (delegating to Core's reusable auto-tag-call.yml@v3). It is
idempotent (a no-op if HEAD is already tagged) and deliberately skips a
.core-ref-marker-only change, so a timestamp-only re-sync never cuts a spurious tag.
You usually don't run the sync by hand: the nvim-sync and starship-sync bots
(weekly, Tuesdays 08:00 UTC, plus workflow_dispatch) open a PR when Core's nvim/ or
starship/ actually changed β merging that PR is the whole release. Run the scripts
manually only to pull a specific Core release immediately (e.g. right after cutting one).
auto-tag.yml only ever produces a patch, and it fires on pushes to main that touch
nvim//starship/ β not on a CHANGELOG commit or a tag push. There is no release.yml
on this repo, so a minor/major is a fully manual flow that nothing auto-publishes. Run in
a clean checkout (PowerShell):
# 1. Decide the version. The repo's own routines REPORT (they never tag):
# /release-readiness -> go/no-go + recommended SemVer (meaningful host work vs mechanical churn)
# /release-notes -> drafts the CHANGELOG entry from Conventional Commits
# Breaking host change -> major; a feat/perf -> minor; only fix/chore/sync -> patch (let 3a handle it).
# 2. Refresh the package lock so the release ships current pins (also clears the open
# package-freshness issue). The generator REBUILDS the lock from scratch:
.\packages\Update-PackageLock.ps1 -DryRun # preview, writes nothing
.\packages\Update-PackageLock.ps1 # regenerate packages.lock.json
# Gotcha: versions are resolved via `winget export`, which OMITS an installed app it can't
# map to a winget source (even though `winget install`/`upgrade` still see it via ARP) β so
# that app is DROPPED from the new lock, not carried over at its old pin. Confirm the real
# version with `winget list --id <id>` and re-add its one pin line, or `winget uninstall`
# then `winget install --id <id> -e` to register the source so future re-pins capture it.
git diff packages/packages.lock.json # review only β it lands with the CHANGELOG in step 4
# 3. Promote the CHANGELOG: move [Unreleased] under `## [vX.Y.Z] - <today>`, open a fresh
# empty [Unreleased] above it, and curate the prose (surface behavior changes loudly, e.g.
# the psmux warm/destroy-unattached flip). Do NOT fold a real nvim/starship sync into this
# release: merging a sync fires auto-tag (3a) and publishes an intervening PATCH tag/Release
# that races the manual tag below. If you need the latest Core assets, land them as a
# separate 3a PR FIRST, let that auto-patch settle, then cut this release touching only
# CHANGELOG.md + packages.lock.json.
# 4. Land the CHANGELOG + lock on main via PR (main is protected). Base the branch on the
# LATEST origin/main so a stale checkout can't drop current commits or pull in unrelated
# ones β `git switch -c` carries the working-tree edits from steps 2-3 forward:
git fetch origin
git switch -c release/vX.Y.Z origin/main
git add CHANGELOG.md packages/packages.lock.json
git commit -m "release vX.Y.Z"
git push -u origin release/vX.Y.Z
gh pr create --base main --head release/vX.Y.Z --title "release vX.Y.Z"
# ... review, let CI go green, then MERGE ...
# 5. Cut the tag + Release BY HAND β auto-tag won't. The version must also clear the drifted
# tag floor (if tags are at v1.1.6, the next deliberate tag is >= v1.1.7; a minor like
# v1.2.0 clears it):
git fetch origin
git tag -a vX.Y.Z origin/main -m vX.Y.Z
git push origin vX.Y.Z
# save the new CHANGELOG section to notes.md, then pass it via a FILE β the CHANGELOG's
# backticks/quotes would be reinterpreted inside a double-quoted inline `--notes "..."` arg:
gh release create vX.Y.Z --title vX.Y.Z --notes-file notes.mdThen close the release-readiness / release-notes tracking issues for the cut version.
Run in a clean htpx checkout. htpx versions itself from its CHANGELOG.md.
Which bump? htpx is a content corpus, so read SemVer as impact on its consumers β
dotfiles-Kali (which regenerates the marked hacktheplanet / PURPLE-TEAM.md blocks from
the entries via gen-views.sh, drift-gated by companion.yml) and anyone browsing with the
htpx picker:
- PATCH β an existing entry is corrected in place: a fixed or retargeted detection, a
metadata/typo fix, a tightened query. No new entries, no format change. (The v2.4.0
### Changed"asrep-probing-4771 retargeted" line is patch-shaped work.) - MINOR β the corpus grows backward-compatibly: new redβblue pairs, a new unpaired entry, a new tactic/technique covered. (v2.4.0's "+2 pairs, +1 recon entry" = minor.)
- MAJOR β a change Kali's regeneration or the
htpxpicker must adapt to: the entry frontmatter /{{slot}}format, thegen-views.shmarker contract, or theentries/red|blue/layout. These break thecompanion.ymldrift-gate until Kali re-syncs, so flag them loudly.
git checkout main && git fetch origin && git pull --ff-only origin main-
Edit
CHANGELOG.md: move the[Unreleased]entries under a new dated heading and open a fresh empty[Unreleased]above it:## [Unreleased] ## [vX.Y.Z] - YYYY-MM-DD ### Fixed - ... (the entries that were under [Unreleased]) -
Land the bump on
main(PR -> merge, like any change):git checkout -b release/vX.Y.Z git commit -am "docs(changelog): release vX.Y.Z" git push -u origin release/vX.Y.Z gh pr create --base main --head release/vX.Y.Z --title "release vX.Y.Z" # ... merge ...
-
The push to
maintriggersauto-tag.yml, which tagsvX.Y.Zand publishes the Release.sync-fanout.ymlthen opens achore(companion): sync htpx -> vX.Y.ZPR againstdotfiles-Kali(bumpscompanion.lockonly β nevercore.lock). -
Review and merge that dotfiles-Kali PR.
Requires the FLEET_SYNC_TOKEN secret on the htpx repo (write to dotfiles-Kali).
htpx is read with the built-in token, so FLEET_SYNC_TOKEN needs no htpx access.
The automatic fan-out resolves the tag from the commit that triggered it. To fan out a tag whose release happened earlier (or whose fan-out failed at the time), use the manual path instead of a re-run:
Actions -> sync-fanout -> Run workflow -> tag: vX.Y.Z
That path takes the tag from the input rather than from HEAD, so it works regardless of
where main is now.
Cross-repo workflows (sync-fanout, anything that clones/pushes/PRs another repo) only
take effect once on the default branch β GitHub reads workflow_run / workflow_dispatch
triggers from main, never from a feature branch. So they cannot be exercised by PR CI,
and bugs in them surface only at a real release.
After merging a new or changed cross-repo workflow, dry-run it before depending on it:
- Trigger it manually against a past tag: Actions -> the workflow -> Run workflow ->
tag: <an-already-released-version>. - Confirm it does the right thing (opens the expected PR, touches only the expected files). Close the dry-run PR if one was opened.
- Only then cut the next real release that relies on it.
This catches the auth-scope, argument, and resolve-path bugs that PR CI cannot see.
| Symptom | Cause | Fix |
|---|---|---|
tag-release.sh: tag vX.Y.Z already exists |
re-running a finished release | bump core.version, or delete the tag to re-cut |
fan-out PR's links-only fails on curl error 28 / No provider of 'zsh' |
distro mirror timeout | re-run the job (prep retries 5x); not a code defect |
sync-fanout skips with '' is not a clean vX.Y.Z release tag |
triggered from a commit with no tag on it | use the manual backfill (section 4) with tag: vX.Y.Z |
fan-out fails could not read Username for 'https://github.com' |
a git op reading a private repo without auth | the read must be authenticated (built-in token for own repo, FLEET_SYNC_TOKEN for cross-repo) |
fan-out aborts core.lock differs ... |
an htpx sync touched Core | by design β htpx fan-out must never change core.lock; investigate the sync |
make tag refuses: no '## [vX.Y.Z]' heading |
make release wasn't run |
run make release VERSION=X.Y.Z first |
staged a release with make release but want to hold off (add more commits first) |
changed your mind before committing | make release only edits two files (no commit, no tag), so git checkout -- core.version CHANGELOG.md fully undoes it β restoring the single [Unreleased] so later commits append to it. If you also ran make tag, first git tag -d vX.Y.Z then git reset --hard HEAD~1 (confirm git show --stat HEAD is the release commit). If you already pushed the tag, also git push origin :refs/tags/vX.Y.Z |
For the policy behind all of this β cadence, canary order, why only Core is versioned β
see RELEASE-STRATEGY.md.