l-make-release
Release @takazudo/zudo-image-tweaker end-to-end — bump the version, prepend a changelog section, commit, push, wait for CI, validate the package, push the v* tag (which triggers the npm publish), then...
/l-make-release
End-to-end release orchestrator for the @takazudo/zudo-image-tweaker npm package. It bumps the version, prepends a changelog section, commits + pushes, waits for CI, validates the package, pushes the v* tag (which triggers . → npm publish --provenance from packages/zit), then creates the GitHub Release. The Step 3 proposal is the one place a human can intervene — but for a routine, unambiguous bump it auto-proceeds (prints the plan, then runs straight through publish); it only blocks for confirmation when a signal makes the version strategy genuinely uncertain.
The single published package is @takazudo/zudo-image-tweaker (packages/) — the version source-of-truth. There are no lockstep packages, no platform/binary packages, no Rust binary, and no Homebrew formula. The workspace root package.json is private and stays as-is — do NOT bump or touch it.
Invocation & confirmation
This skill is model-invocable: a rough natural-language request like "bump version", "cut a release", or "make npm release" may trigger it. Steps 1–3 are always read-only (preconditions, version computation, change analysis); the first mutation is Step 4.
The Step 3 proposal — auto-proceed by default, block only when a human is needed
Always print the Step 3 proposal (current → new version + categorized changelog). Whether the skill then waits for confirmation depends on how routine the bump is. The gate exists to catch a wrong version strategy before anything is written — so when the strategy is obviously right, there is nothing to confirm and the skill must not pause.
Auto-proceed — print the proposal, then run straight through to publish and report — when ALL of these hold (the common case):
the skill was invoked deliberately: the explicit
/slash command, or an explicit bump arg (l- make- release patch/minor/stable/next/major);it is a cold-start bump — the current version is already tagged/released and the tree is clean (not a moved-HEAD resume);
the computed bump has no strategy conflict (none of the block conditions below).
Example (this is the canonical auto-proceed case): an explicit
/with no arg on a clean stable version, resolving to the default clean patch, with no breaking-change commits. Do not pause — just release.l- make- release
Block and wait for explicit confirmation when any signal makes the version strategy genuinely uncertain:
a breaking change (
feat!:orBREAKING CHANGEin the commit range) is detected but the computed bump is only a patch — the user should decide patch vs. minor (on 0.x a breaking change is a minor bump);the current version is a
-next.Nprerelease and no explicitstable/nextarg was given — finalizing the prerelease vs. continuing the run-up is ambiguous;the trigger was a loose, model-inferred phrase rather than an explicit slash-command/arg invocation — a vague "let's ship it" must never publish unattended;
a moved-HEAD resume (Step 1) — the bump commit is not
HEAD.
Either path — auto-proceed, or a confirmed block — authorizes the entire flow: bump, push, tag, publish, and GitHub Release. Do not add a second "push the tag now?" prompt. After Step 3 the only thing that halts the flow is a validation failure (Step 7) or a CI/Release-workflow failure — see Boundaries.
Autonomy after the gate. Once Step 3 is confirmed, run the whole flow to completion without pausing to ask the user to confirm any routine step — never prompt "push the tag now?", "commit looks right?", "proceed to publish?", or the like. Surface-and-proceed for anything advisory or cosmetic: an attw complaint, a node_modules-only dependency reconciliation, a dist-tag warning (Step 10), or a pnpm-lock.yaml that ends up unchanged. The flow stops and hands control back to the user only for a genuine careful-handling problem — one that is hard to reverse or truly ambiguous:
a publint failure (Step 7) — blocks before the tag is pushed;
a CI failure on the bump commit (Step 6), or a Release-workflow failure after the tag push (Step 8);
structural (non-version)
pnpm-lock.yamldrift (Step 4c);a moved-HEAD resume ambiguity (Step 1) — the bump commit is not
HEAD.
For those, stop and surface the specifics so the user can decide. For everything else, keep going.
If the trigger was a loose phrase, restate the proposed bump plainly at Step 3 so the user can catch a wrong version strategy before anything is written.
Boundaries
The skill does push the
v*tag and does create the GitHub Release — but only after the Step 3 confirmation. It never bypasses that confirmation.A publint validation failure (Step 7) aborts the flow before any tag is pushed — npm cannot re-publish a version, so a broken package must never reach the registry. attw output is advisory and does not block.
The skill touches only
packages/for the changelog. It does not touch the doc-site underzit/ CHANGELOG. md doc/(a standalonecreate-zudo-docproject, kept out of this pnpm workspace).The skill never runs
npm publishdirectly — publishing isrelease.yml's job, triggered by the tag push.
How release.yml publishes (context, do NOT modify it)
. triggers on push of any v* tag. It runs cd packages/zit && npm publish --provenance --access public --tag "$DIST_TAG" on Node 24 — not pnpm -r publish — because pnpm 11's OIDC token-exchange path 404s against npmjs and skips provenance (see pnpm/pnpm#11513); npm's classic-token --provenance on Node 24 (npm CLI ≥ 11.5.1) attaches a Sigstore provenance attestation via the GitHub Actions OIDC token instead. $DIST_TAG is derived from the tag name:
Tag matching
-next.*,-beta.*, or-rc.*→ npm dist-tagnextAll other
v*tags → npm dist-taglatest
Prerelease consumers opt in with pnpm add @takazudo/zudo-image-tweaker@next.
Publish auth comes from the NPM_TOKEN GitHub Actions repo secret — a granular access token with "Bypass 2FA for noninteractive automation" enabled (npm classic tokens, including the old "Automation" type, were revoked registry-wide in Dec 2025 and can no longer be created or recreated). Granular write tokens are capped at a 90-day expiration by npm, so this secret needs periodic rotation. If a Release run fails at the publish step with an auth/ENEEDAUTH/OTP error, that secret is missing, expired, or lacks the 2FA-bypass flag — check gh secret list and re-add it (gh secret set NPM_TOKEN) with a fresh token from https://www.npmjs.com/settings/<user>/tokens/granular-access-tokens/new.
Step 1: Preconditions
Before doing anything else, verify the following. If a check fails, stop with a clear message.
Current branch is
main(git branch --show-current).ghCLI is authenticated (gh auth status).
Resume detection (run this before requiring a clean tree)
A previous run — or a manual edit — may have already committed the version bump without pushing the tag. Detect that state before assuming a cold start:
git fetch --tags origin
CUR=$(node -p "require('./packages/zit/package.json').version")
git tag -l "v$CUR" # empty output = no tag yet for the current versionIf NO
v*tag exists at all (git tag -l 'v*'is entirely empty): this repo has never released, so the pickaxe search below cannot be trusted — it would match whatever commit first introduced$CURinpackage.json, which for a fresh scaffold is very likely a pre-release.ymlcommit, not a release-capable one. Do NOT run the pickaxe search or treat this as a resume. Instead, with a clean working tree, confirmpackages/'s current version (zit/ package. json $CUR) is what should ship as the first release, then tagHEADdirectly asv$CUR— do not bump the version for this one case (a fresh bump always proposes the next version, which would waste$CURand never actually ship it). Skip straight to Step 6 (CI wait) withBUMP_SHA=$(git rev-parse HEAD), through the same validate → push tag → publish → GitHub Release path. This confirmation stands in for Step 3.If
v$CURdoes NOT exist but otherv*tags do and the working tree is clean: the current version is un-tagged. Locate the actual commit that set this version — do NOT assume it isHEAD:# The commit that introduced the current version string (the bump commit) BUMP_SHA=$(git log -1 --format=%H -S"\"version\": \"$CUR\"" -- packages/zit/package.json)Tell the user the bump for
v$CURis already committed ($BUMP_SHA) and offer to RESUME. Resuming skips Steps 2–5 (bump / changelog / commit) and continues from Step 6 (CI wait) onward — tagging$BUMP_SHA(notHEAD) — through the same validate → push tag → publish → GitHub Release path as a cold start. The resume confirmation is the gate (it stands in for Step 3).Guard against a moved HEAD: if
$BUMP_SHAis not the currentHEAD(git rev-parse HEAD), commits landed after the bump. Surface this and let the user choose: (a) tag$BUMP_SHAas-is so the release matches the bumped version exactly, or (b) abort and start a fresh bump (Steps 2–5) so the newer commits are included and the changelog reflects them. Never silently tagHEADunder the old version.If
v$CURalready exists: the current version is released. Proceed with a normal cold-start bump (Steps 2–5).
Require a clean working tree (git status --porcelain empty) only on the cold-start (bump) path. On the resume path the tree is already clean — do not re-bump.
Step 2: Determine Next Version
Read the current version from packages/ (the source-of-truth).
Versioning rule — the pre-1.0 (0.x) clean-mainline ruling
While the package is 0.x, the everyday mainline ships clean 0.MINOR.PATCH versions straight to the latest dist-tag — never a -next mainline. This is the npm dist-tag rule for 0.x packages (see the / skill's "dist-tags: latest, next, and the pre-1.0 (0.x) strategy" section): a bare pnpm add @takazudo/zudo-image-tweaker is a direct latest-pointer dereference, so the only way a tagless install always resolves the newest build is to keep latest on the newest clean release. release.yml derives the dist-tag from the version string (a -next. / -beta. / -rc. suffix → next, anything clean → latest), so a clean bump lands on latest automatically — nothing to wire up.
Therefore:
Breaking change → minor bump (
0.2.0→0.3.0). On 0.x the major-zero is itself SemVer's "anything may change" signal, so breaking changes are minor bumps.Everything else → patch bump (
0.2.0→0.2.1).A
-nextprerelease is an opt-in side channel only (thenextargument, plus the1.0.0run-up viamajor) — reserved for previewing a specific upcoming version ahead oflatest. Never run the mainline on-next.Nand never mirrornextontolatest: both strandlateston a stale version, the exact footgun this rule prevents (tagless installs silently downgrade). At1.0.0the normal stable/preview split resumes automatically under the same version-derived dist-tag — no special-casing.
Apply these rules based on the optional argument:
No argument (default — clean mainline → latest)
If current is stable
X.Y.Z: propose a patch bumpX.Y.{Z+1}(clean).Example:
0.2.0→0.2.1
If current is a prerelease
X.Y.Z-next.N: finalize it — strip the suffix to cleanX.Y.Z, promoting the in-progress line to stable onlatest(identical to thestableargument). To instead continue a deliberate prerelease run-up, passnext.Example:
0.2.0-next.2→0.2.0
patch argument (→ latest)
Bump the patch of the clean base of the current version (drop any
-next.Nsuffix, then+1patch):X.Y.{Z+1}(clean).Example:
0.2.0→0.2.1,0.2.0-next.2→0.2.1
minor argument (breaking change on 0.x → latest)
Bump the minor of the clean base, reset patch, clean (no suffix):
X.{Y+1}.0.Example:
0.2.1→0.3.0,0.2.0-next.2→0.3.0
stable argument (finalize a prerelease → latest)
Strip the
-next.Nsuffix from the current prerelease → cleanX.Y.Z.Requires the current version to be a
-next.Nprerelease. If it is already stable, stop with an error.Example:
0.2.0-next.2→0.2.0
next argument (opt-in prerelease side channel → next)
Open or continue a prerelease line published to the
nextdist-tag, ahead oflatest— for previewing a specific upcoming version:From stable
X.Y.Z: start a new minor prereleaseX.{Y+1}.0-next.1.Example:
0.2.0→0.3.0-next.1
From prerelease
X.Y.Z-next.N: continue the run-upX.Y.Z-next.{N+1}.Example:
0.3.0-next.1→0.3.0-next.2
major argument (1.0.0 run-up → next, finalize later → latest)
Start the major prerelease run-up:
{X+1}.0.0-next.1(published tonext).1.0.0is the one milestone that earns a deliberate-next/-rcrun-up; finalize it later withstable(or no-arg), which routes the clean1.0.0tolatest.Example:
0.3.0→1.0.0-next.1,0.2.0-next.2→1.0.0-next.1
Step 3: Analyze Changes and Propose — the conditional gate
Find the latest version tag. Fetch remote tags first — a prior release may have created its v* tag only on the remote, so the most recent tag can be absent from this local checkout. Without the fetch, git tag -l picks a stale older tag and the changelog base re-includes already-released commits.
Sort by creation date, not version string: --sort=-v:refname mis-ranks a -next.N suffix above the clean version it was finalized into (e.g. after v0.3.0-next.2 is finalized as v0.3.0, version-sort still puts v0.3.0-next.2 first), which would silently drop the finalized-stable's own changes from consideration and re-include already-released commits in the next changelog. --sort=-creatordate picks whichever tag was actually created last, sidestepping the prerelease-suffix ordering quirk entirely:
git fetch --tags origin
LAST_TAG=$(git tag -l 'v*' --sort=-creatordate | head -1)
git log "$LAST_TAG"..HEAD --onelineCategorize each commit by its conventional-commit prefix:
Breaking Changes: commits with
!suffix (e.g.feat!:) orBREAKING CHANGEin the bodyFeatures:
feat:prefixBug Fixes:
fix:prefixOther Changes: everything else (
docs:,chore:,refactor:,ci:,test:,style:,perf:, etc.)
Present the proposal to the user:
Proposed bump: {current} → {new} ({type})
Breaking Changes:
- description (hash)
Features:
- description (hash)
Bug Fixes:
- description (hash)
Other Changes:
- description (hash)Only show sections that have entries. Then apply the auto-proceed vs. block decision from the "Invocation & confirmation" section above:
Routine, unambiguous bump (deliberate invocation, cold start, no strategy conflict) → do NOT wait. Continue straight to Step 4 and run the flow through publish + GitHub Release.
A block condition holds (breaking-vs-patch mismatch,
-next.Nwith no explicitstable/nextarg, a loose model-inferred trigger, or a moved-HEAD resume) → wait for user confirmation before proceeding.
Either way — auto-proceeding, or a confirmed block — authorizes the full flow through npm publish and the GitHub Release; the only thing that can stop it afterward is a validation failure (Step 7).
Step 4: Bump + Changelog
4a. Update packages/zit/package.json
Update the version field in packages/ to the confirmed new version (without the v prefix). Do NOT touch the workspace root package.json.
4b. Prepend a changelog section
packages/ is version-sectioned markdown: a # Changelog header, then ## <version> sections newest-first, each with ### Fixed / ### Features / etc. subsections. Insert the new ## <version> section immediately after the # Changelog header, above the previous newest version.
Format:
## <version>
### Breaking Changes
- description (hash)
### Features
- description (hash)
### Fixed
- description (hash)
### Other Changes
- description (hash)Rules:
Only include subsections that have entries.
Each entry: commit subject followed by the short hash in parentheses.
Link issues/PRs where the reference is obvious from the commit subject (e.g.
([#).310](https: / / github. com/ zudolab/ zudo- image- tweaker/ pull/ 310))
4c. Refresh the lockfile
pnpm installThis regenerates pnpm-lock.yaml so CI's pnpm install --frozen-lockfile succeeds.
Lockfile drift heuristic — before staging, surface added/removed lines that are NOT simple two-space-indented entries (those are the expected version-line churn):
# PCRE (GNU grep -P / ripgrep): show +/- lines that are NOT two-space-indented
git diff pnpm-lock.yaml | grep -P '^[+-](?! )' | head -20
# Portable fallback (BSD/macOS grep lacks -P):
# git diff pnpm-lock.yaml | awk '/^[+-]/ && !/^[+-] / { print }' | head -20If you see non-version-related structural changes, stop and surface the diff to the user before proceeding.
Step 5: Atomic Commit + Push
Stage and commit the bumped files in a single commit:
git add packages/zit/package.json packages/zit/CHANGELOG.md pnpm-lock.yaml
git commit -m "chore(release): bump to v<version>"
git push origin mainRecord the resulting commit SHA:
BUMP_SHA=$(git rev-parse HEAD)Step 6: Wait for CI on the Bump Commit
Delegate CI polling to the / skill — do NOT reimplement polling:
Skill(skill="watch-ci", args="--branch main --commit <BUMP_SHA>")/ is a user-global skill, not a repo-local one. If it is unavailable in the running session, fall back to a direct poll:
gh run watch "$(gh run list --branch main --commit <BUMP_SHA> --limit 1 --json databaseId -q '.[0].databaseId')" --exit-statusIf CI fails, fix the issue, re-push, then re-watch before proceeding.
Step 7: Pre-publish Package Validation (publint + check-pack block, attw advises)
npm cannot re-publish a version, so catch packaging mistakes before the tag is pushed. Build locally first so validation inspects what release.yml will actually publish (the CI publish runs its own build via prepublishOnly — this local build is only to produce dist/ for validation here):
pnpm --filter @takazudo/zudo-image-tweaker build
cd packages/zitRun publint — this blocks. publint findings on this ESM-only, subpath-export package are almost always real bugs (a path in exports that doesn't exist, a file referenced but missing from the files whitelist):
pnpm dlx publintIf publint reports errors, stop and surface them. Do NOT push the tag — fix the packaging issue, commit + push, re-run CI (Step 6), and re-validate.
Run attw and pnpm pack for human review only — do not block on them. attw is built around dual ESM/CJS packages and emits noise on an ESM-only package with multiple subpath exports; read its output, but a clean publint is the gate:
pnpm dlx @arethetypeswrong/cli --pack . # advisory — review, do not block
pnpm pack --pack-destination /tmp # then inspect the tarball contents:
tar -tzf /tmp/takazudo-zudo-image-tweaker-<version>.tgz | sort
cd ../.. # return to repo rootConfirm the tarball contains dist/, README.md, CHANGELOG.md, LICENSE, and package.json — and nothing unexpected (no src/, no tests).
Run scripts/ — this also blocks. It is self-locating (safe to run from the repo root regardless of cwd): it packs the tree, installs the tarball into a scratch project like a real consumer, then resolves the root import plus every subpath export (., ., ., ., ., ., ., ., ., ., .) and asserts the installed version matches package.json. The same script also runs in ci.yml (every PR) and again in release.yml immediately before npm publish — running it here too catches a files[]/exports regression before the tag is even pushed, instead of waiting for the Release workflow to fail:
bash scripts/check-pack.shIf it fails, stop and surface the output. Do NOT push the tag — fix the issue, commit + push, re-run CI (Step 6), and re-validate.
Step 8: Push the Tag (triggers the publish)
Mint the tag on the bump commit and push it — the push is what fires release.yml:
git tag "v<version>" <BUMP_SHA>
git push origin "v<version>"Then watch the Release workflow to success. GitHub can take a few seconds to register the tag-triggered run after the push, so poll for it instead of querying once — an immediate single query can return an empty list and leave RELEASE_RUN empty (which makes gh run watch fail even though the publish is proceeding fine in the background):
RELEASE_RUN=""
for _ in $(seq 1 15); do
RELEASE_RUN=$(gh run list --workflow release.yml --branch "v<version>" --limit 1 --json databaseId -q '.[0].databaseId')
[ -n "$RELEASE_RUN" ] && break
sleep 2
done
if [ -z "$RELEASE_RUN" ]; then
echo "Release run did not register within 30s — check the Actions tab manually."
else
gh run watch "$RELEASE_RUN" --exit-status
fiIf the Release workflow fails, surface the failing logs (gh run view "$RELEASE_RUN" --log-failed) and stop — the npm publish did not complete. Do not create the GitHub Release until the publish succeeds.
Step 9: Create the GitHub Release
Extract the just-released changelog section as notes (this awk pattern works whether the section is first or in the middle of the file), then create the Release. The tag already exists on the remote, so use --verify-tag. Add --prerelease for a -next. / -beta. / -rc. version:
awk '/^## <version>/{f=1;next} f&&/^## /{exit} f' packages/zit/CHANGELOG.md > /tmp/zit-release-notes.md
PRERELEASE_FLAG=$([[ "<version>" =~ -next\.|-beta\.|-rc\. ]] && echo "--prerelease" || echo "")
gh release create "v<version>" --verify-tag --title "v<version>" $PRERELEASE_FLAG \
--notes-file /tmp/zit-release-notes.mdStep 10: Verify dist-tag + Report, then STOP
Confirm the publish landed under the expected dist-tag:
npm dist-tag ls @takazudo/zudo-image-tweaker
npm view "@takazudo/zudo-image-tweaker@<version>" versionThe version should appear under next for a prerelease, latest for a stable release.
Warn-only dist-tag check: if a prerelease version is showing under latest (or latest points at an older prerelease — a known artifact of the very first publish), surface a warning. Do NOT auto-fix — moving a dist-tag is a registry-level mutation that deserves a human. The manual fix is:
npm dist-tag rm @takazudo/zudo-image-tweaker latest # remove a stray prerelease from latest
# (or repoint it once a real stable ships: npm dist-tag add @takazudo/zudo-image-tweaker <stable> latest)Print a final report: published version + dist-tag, the npm tarball URL, the Release workflow run, and the GitHub Release URL. Then STOP.
Failure Recovery
pnpm-lock.yaml drift (Step 4c)
Run the drift heuristic before staging. If non-version structural changes appear, stop and surface the diff. Resolve the lockfile manually before re-running.
CI fails on the bump commit (Step 6)
Fix the issue, commit the fix, push, then re-watch CI. Do not validate or tag until CI is green.
publint or check-pack.sh validation fails (Step 7)
Stop before tagging. Fix the packaging issue (exports path, missing file in files, a subpath that fails to resolve from the installed tarball, etc.), commit + push, re-run CI (Step 6), and re-validate. Nothing is published yet, so this is fully recoverable.
Release workflow fails after the tag was pushed (Step 8)
The tag exists on the remote but the npm publish did not complete. Inspect gh run view "$RELEASE_RUN" --log-failed. If the failure is transient (registry hiccup), re-run the workflow: gh run rerun "$RELEASE_RUN". If the fix needs a code change, the tag must move to a new commit — delete and re-cut: git push origin
: (delete remote tag), git tag -d v<version>, fix, then re-run / (which will resume or cut a new version). A version that already published successfully cannot be re-published — cut a new version instead.
Rolling back a bad bump (before the tag was pushed)
If the bump commit needs to be undone:
git revert <BUMP_SHA>
git push origin mainThen remove the prepended ## <version> section from packages/ (the revert restores package.json and pnpm-lock.yaml, but verify the changelog section is gone), delete the local tag if minted (git tag -d v<version>), and re-run / from the start.