release
SkillDev toolsThe single permitted path to a version tag, for any target declared in the project's .codearbiter/release-targets.md. Routed to when the user invokes /release on a non-default branch with a green suite. Takes the declared target as its one argument, derives the SemVer bump from Conventional-Commits history since that target's last tag, rolls the commits into that target's CHANGELOG, writes an annotated tag in that target's namespace, and on authorization publishes it as a GitHub Release with the changelog section as its notes. A release commit, if needed, routes through commit-gate; the tag and Release are never published without explicit authorization.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the release skill
What this skill tells your AI
The instructions your AI receives, as published by arbiterforge/codearbiter in plugins/ca/skills/release/SKILL.md and read by ahel’s review.
The single permitted path to a version tag. Routed to when the user invokes /release [target]. Derive the bump from the commit log, update the changelog, tag — nothing more.
One command, any number of declared targets. A project declares one or more release targets in {{PROJECT_DIR}}/.codearbiter/release-targets.md (grammar and parser contract: {{PLUGIN_ROOT}}/hooks/_releaselib.py's module docstring). /release takes the target's name as its only argument. When $TARGET is omitted and the declared file names exactly one target, that target is used — a single-target project's bare /release behaves exactly as it always has. When more than one target is declared, $TARGET is required; STOP and ask rather than guessing which one a bare invocation meant. Resolve the omitted-single-target case mechanically, never by assumption (MEDIUM, adversarial review 2026-07-31: tag-prefix itself takes $TARGET as a REQUIRED positional argument and has no way to express "the implicit one", so naming it here was not itself enough — the mechanical step that turns an omitted target into a concrete name before tag-prefix is ever called has to be spelled out too): run "$PY" "{{PLUGIN_ROOT}}/hooks/_releaselib.py" list-targets first — the sanctioned enumeration, through the same tested grammar tag-prefix already reads, rather than a by-eye scan of the delimiter block. Exactly one printed line confirms which name $TARGET is; more than one is the multi-target STOP above, restated by the tool rather than assumed. There is deliberately no second command per target: N commands would be N public surfaces to govern, catalog, and carry, for one operation whose only difference is which declared row it reads.
Every phase below is written once, against that row. Nothing in this skill is per-target prose.
Interpreter convention, stated once and applying to every helper invocation in this file (A-3.6). python3 is not universally present — a Windows consumer commonly has python on PATH and no python3 at all, and a literal python3 spelling fails on every invocation at once there.
Resolve the interpreter ONCE, by presence, before the first invocation:
PY=python3; { command -v python3 >/dev/null 2>&1 && python3 --version >/dev/null 2>&1; } || PY=python
command -v alone is not enough (LOW, #584): a Windows host commonly ships a python3 App Execution Alias stub at %LOCALAPPDATA%\Microsoft\WindowsApps\python3 that satisfies command -v python3 with no Python actually installed — running it opens the Microsoft Store and exits non-zero. Only actually RUNNING it (python3 --version) tells the truth; command -v merely tells you a name resolves on PATH. python3 wins whenever both it and python are genuinely present — the resolve-once order above tries it first and only falls back to python when it is absent or the stub — so a host with both interpreters gets the one this convention exists to prefer, not an arbitrary pick.
Every helper invocation below is then spelled "$PY" "{{PLUGIN_ROOT}}/hooks/<script>" <args> literally, one spelling throughout — including the inline "$PY" -c "…" snippets. Two spellings for one thing invites reading the difference as meaningful (blind exercise run 14 flagged exactly that when only three steps used "$PY" and fifteen still said python3).
Both quotes are load-bearing, and the second one is the easier to lose (HIGH-1, blind exercise run 16). Quoting only the interpreter — "$PY" {{PLUGIN_ROOT}}/hooks/<script> — leaves the script path exposed to word splitting, and a plugin root containing a space is an ordinary Windows install (C:\Users\First Last\.claude\plugins\…, since an account name with a space is unremarkable). On such a host the path splits at the space, Python is handed a truncated filename, and EVERY step of this lane fails at once: target resolution, last-tag, classify-window, check-manifests, classify, notes-match. The operator's only diagnostic is can't open file '…\First', which names nothing recognisable. The same applies to any {{PROJECT_DIR}}-rooted path passed as an argument. The one deliberate exception is $PAYLOAD, which is a git pathspec that MUST word-split — see its own note under "Targets".
MUST NOT spell them python3 "<script>" … || python "<script>" …. || branches on the EXIT CODE, and it cannot distinguish "no such interpreter" from "the helper ran and told you something". This lane's helpers answer in exit codes by design — run-pre-tag returns 5 for drift and 6 for a mutating check, semver-greater and check-manifests each separate "no" from "could not compare" — so the || form re-runs the whole command on every one of those answers and then reports the SECOND run's code. For run-pre-tag that means executing the project's declared pre-tag commands twice and losing the verdict of the first. The fallback must key on whether the interpreter EXISTS, which is what command -v tests, not on what it said.
Targets
Resolve $TARGET's row from the declared file FIRST and use it throughout — never a hardcoded table. An unparseable declared file (any parser-contract violation on a file that DOES exist — including one that exists but carries no delimiter block at all, FileExistsNoBlockError) → STOP and surface the parse error; never guess a row's shape, and never treat an existing-but-broken file as an opportunity to back-fill it (see "Back-fill" below for why that distinction is mechanical, not a judgment call). A genuinely ABSENT declared file — nothing on disk at all, the one state AbsentBlockError alone names — enters the "Back-fill" lane below instead of stopping outright; that lane never runs against a file that already exists, in any state. Resolve $TAG_PREFIX through the shared mechanism, never typed from memory: TAG_PREFIX=$("$PY" "{{PLUGIN_ROOT}}/hooks/_releaselib.py" tag-prefix $TARGET). Where a hosted publish lane's own namespace resolution is ALSO wired to read this declared file — rather than carrying a separate, hardcoded copy of the same facts — the command and the lane cannot disagree; where it is not (yet) wired that way, the two can drift, and reconciling them is a workflow-authoring task this skill cannot enforce from the command side alone.
Read the row through the helper, never by eye (HIGH, blind exercise run 14). The same rule that governs the target list governs its fields: "$PY" "{{PLUGIN_ROOT}}/hooks/_releaselib.py" show-row $TARGET prints one shell-quoted NAME='value' line per declared field, through the same tested grammar list-targets and tag-prefix use, and named for the variables this skill spells. A field the row does not declare prints with an empty value rather than being omitted, so "not declared" and "I did not look" stay distinguishable.
Read one field at a time with --field, into a normal command substitution, spelled in full each time:
TAG_PREFIX=$("$PY" "{{PLUGIN_ROOT}}/hooks/_releaselib.py" show-row $TARGET --field prefix)
CHANGELOG=$("$PY" "{{PLUGIN_ROOT}}/hooks/_releaselib.py" show-row $TARGET --field changelog)
MANIFEST=$("$PY" "{{PLUGIN_ROOT}}/hooks/_releaselib.py" show-row $TARGET --field manifest)
…and so on for generated-manifest, generate, artifacts, rebuild, pre-tag, provenance-manifest, latest-eligible, display-name. Multi-valued fields print comma-separated; an undeclared one prints empty.
Split a multi-valued field this one sanctioned way (LOW, #585) — a subshell so a temporary IFS change never leaks into the rest of the lane:
(IFS=,; for a in $ARTIFACTS; do git diff --quiet -- "$a" || …; done)
Every consumer of $ARTIFACTS/$PRE_TAG/$MANIFEST splits this way, not a hand-rolled cut/awk/read -a of its own — a second splitting rule is a second place for an edge case (a value containing a literal comma, an empty field) to disagree with this one.
MUST NOT collapse the repetition into a command held in a variable — ROW="$PY {{PLUGIN_ROOT}}/hooks/_releaselib.py show-row $TARGET" followed by $($ROW --field prefix) reads as the obvious tidy-up and reintroduces, in the one block that reads EVERY field, the exact defect the quoting above removes (HIGH-1, blind exercise run 16). An unquoted $ROW is subject to word splitting, which is what makes it run as a command at all — so the interpreter path inside it cannot be protected, and a plugin root containing a space (C:\Users\First Last\.claude\plugins\… is an ordinary Windows install) splits mid-path and fails every field read at once. Quoting "$ROW" does not rescue it either; that spelling looks for a single executable whose filename is the entire string. The verbosity is the price of the property.
MUST NOT read the row with eval. A bare eval "$(… show-row …)" executes the declared values: rebuild: cd x && npm run build parses as the assignment REBUILD=cd followed by the command x, with && npm run build waiting behind it — and eval still exits 0, because plain assignments follow. Blind exercise run 15 hit exactly that. These values are operator-authored shell that this lane runs only AFTER step 6c confirms a human has read them; executing a fragment of them while merely READING the row runs them before the gate that exists for them. show-row's bare form is shell-quoted so the mistake is now inert, but --field needs no eval at all and is the sanctioned spelling.
$PAYLOAD is a git PATHSPEC, not a path. Assign it from the dedicated subcommand, NOT from show-row's payload field, and pass it unquoted after -- so its parts stay separate words:
PAYLOAD=$("$PY" "{{PLUGIN_ROOT}}/hooks/_releaselib.py" payload-pathspec $TARGET)
payload minus payload-exclude cannot be spelled as a plain path: git log -- <path> has no subtraction, and the :(exclude) form that does appears nowhere an operator would infer it. A row that declares an exclude otherwise silently counts the excluded commits in its own bump and changelog — show-row --field payload returns the raw field and is the WRONG source for this one variable.
From the resolved row:
| field | meaning |
|---|---|
$TAG_PREFIX (prefix) | the tag namespace this target publishes under |
$DISPLAY_NAME (display-name) | optional; the human-readable name used in the Phase-3 Release title. Defaults to $TARGET itself when a row declares none |
$MANIFEST (manifest) | one or more version-carrying files; every one is asserted equal to the derived version |
$GENERATED_MANIFEST (generated-manifest) | optional subset of $MANIFEST; never hand-edited — regenerated by $GENERATE instead |
$GENERATE (generate) | optional command that regenerates every path in $GENERATED_MANIFEST; run before the Phase-1 manifest-equality assertion |
$CHANGELOG (changelog) | the file the Phase-1 section is rolled into |
$PAYLOAD (payload, minus payload-exclude) | the commit-window and rebuild-freshness scope |
$ARTIFACTS (artifacts) | committed built bundles asserted clean after $REBUILD runs |
$REBUILD (rebuild) | optional command that regenerates every path in $ARTIFACTS; Pre-flight runs it unconditionally, once, in a subshell (( eval "$REBUILD" )) so it cannot move this lane's working directory; previously missing from this table entirely |
$PRE_TAG (pre-tag) | check-only commands run in declared order before tagging (DECISION-0034) |
$PROVENANCE_MANIFEST (provenance-manifest) | optional; Phase 3 step 5 skips (and says so) when absent |
--latest eligibility (latest-eligible) | at most one declared target may claim it |
An unrecognised $TARGET — no row of that name — STOPs; do not guess which project was meant. With no declared file at all, this skill's own "Back-fill" lane below handles it at release time; context-creation (full onboarding) is the sanctioned way to create one ahead of a release. Neither ever invents a row from a guess.
The interpreter convention extends to DECLARED row commands, not only to this skill's own invocations (#583 MEDIUM-2 / #584 MEDIUM-3). $PRE_TAG, $REBUILD, and $GENERATE are operator shell this lane EXECUTES — via run-pre-tag for pre-tag, and directly for rebuild/generate — exactly the same as any command spelled directly in this file, so a row hardcoding python3 fails on exactly the host the interpreter paragraph above exists for, and that failure was previously mis-diagnosed as drift rather than a missing interpreter. run-pre-tag exports PY (its own resolved interpreter) into every declared pre-tag command's environment, and this lane's own shell defines $PY before ( eval "$REBUILD" ) runs — so a row SHOULD spell "$PY" in place of a hardcoded interpreter, the same way this file does.
This now extends to a Windows-hosted pre-tag row too (#602, closing the gap measured when the paragraph above was first written). run-pre-tag resolves a POSIX-compatible shell (Git for Windows' own bash.exe, found deterministically relative to git --exec-path — never WSL's same-named bash.exe stub under system32/WindowsApps, which runs inside a separate Linux filesystem) and dispatches $PRE_TAG through it directly, rather than falling through to subprocess.run(shell=True)'s default cmd.exe, which cannot expand $VAR. A row spelled "$PY" now expands the same way on every platform this skill runs on. When no POSIX shell can be resolved on a Windows host at all — no Git for Windows install, no bash reachable — run-pre-tag reports a distinct "could not run" diagnosis (exit 9, never 5 or 7) rather than misreading the absence as drift; the remedy is installing Git for Windows (which ships bash.exe) or putting an existing Git-for-Windows bash.exe on PATH.
Traps worth stating rather than discovering, general to any row rather than specific to one target:
- A row MAY declare more than one
manifest. Assert every one of them equals the derived version in Phase 1 — a target whose secondary manifest lags its primary one ships a tag that installs a version string the tag does not name. - A manifest path also listed in
$GENERATED_MANIFESTis never hand-edited. It is regenerated output — some other build or packaging step produces it from a primary manifest or source of truth — so "update the manifest to the derived version" means running the row's declaredgeneratecommand for that one path, then letting the SAME equality assertion every other manifest path gets confirm it landed on the derived version. Hand-writing a generated manifest defeats its own generator and can leave it silently inconsistent with whatever it is supposed to mirror. - A row's
payload-excludeentries are excluded from the commit window and the rebuild-freshness scope, not merely cosmetic — a payload that ships no policy or build artifact under an excluded directory must not gate the release on changes there. - At most one declared target may set
latest-eligible: true, and every other target's Phase-3 publish MUST pass--latest=falseEXPLICITLY. Omitting the flag is not declining it: GitHub defaultsmake_latestto true for any non-prerelease, so a target that simply does not ask for the badge still takes it — measured in this repository's own history, where a sibling's release displaced the primary target's badge for exactly this reason. A hosting service has one repo-wide "Latest"; a declared file may name several series.
Back-fill (no declared file yet)
load_targets raises AbsentBlockError when {{PROJECT_DIR}}/.codearbiter/release-targets.md does not exist on disk at all — the ONE gap this skill does not merely STOP on. This is mechanically distinct from an EXISTING file that merely carries no delimiter block, which raises the sibling FileExistsNoBlockError instead (HIGH-1, adversarial review 2026-07-31) — parse_release_targets sees text only and cannot itself tell "no file" from "a file with no block" apart, so load_targets, the one function that knows whether open() actually succeeded, makes the distinction and raises the two as siblings under ReleaseTargetsError rather than one subclassing the other. Every OTHER ReleaseTargetsError — FileExistsNoBlockError (exists, no block), or a malformed, empty, duplicate, or otherwise unparseable EXISTING file — still STOPs outright per "Targets" above; this lane triggers ONLY on AbsentBlockError and never runs against a file that already exists, in any state, however broken — a broken declaration is a different failure from a missing one, and detecting a shape to paper over it would silently discard the operator's own (bad) declaration. From the CLI this same distinction is an exit code, not free text to parse: tag-prefix and list-targets both exit 3 for the genuinely-absent case (the lane's ONE trigger) and 4 for every other declared-file error.
-
Detect. From the project root, run
"$PY" "{{PLUGIN_ROOT}}/hooks/_releaselib.py" backfill-detect. It scans the repo root for exactly one candidate manifest (package.json,pyproject.toml,Cargo.toml,composer.json) and exactly one candidate changelog (CHANGELOG.md,CHANGES.md,HISTORY.md).- Zero, or more than one, candidate of either kind (non-zero exit): the repo is genuinely ambiguous — several plausible manifests or none, several changelogs or none. STOP here; this lane never guesses among candidates and never invents one from nothing. Route the user to
context-creationinstead, which resolves the same ambiguity through full elicitation rather than a bare top-level scan. - Exactly one candidate of each kind (exit 0): the command prints the exact
release-targets.mdblock it would write, already in the grammarload_targetsaccepts, and it declareslatest-eligible: true(HIGH-2, adversarial review 2026-07-31): this lane can only ever propose ONE row — that is what "exactly one candidate of each" means — so the project it is proposing a row for is, at this moment, single-target. The "at most one declared target may claim it" hard rule in "Targets" above exists to stop SIBLING series stealing the badge from one another; applied blindly to a project's own first, only release it would instead publish that release demoted out of the Latest position by default, with nothing in this lane prompting anyone to notice. Declaring the key explicitly, rather than leaving the rule to somehow infer "solo project" later, is also the more honest choice for a project that adds a SECOND target down the line: the very next step shows this line to the operator VERBATIM before anything is written, so it is something they read and can strike, not a behavior that silently changes the day a second[target]block is hand-added.
- Zero, or more than one, candidate of either kind (non-zero exit): the repo is genuinely ambiguous — several plausible manifests or none, several changelogs or none. STOP here; this lane never guesses among candidates and never invents one from nothing. Route the user to
-
Present, and require explicit confirmation before doing anything else. Show the printed block to the user VERBATIM. Do NOT write it, and do NOT proceed to Pre-flight or any phase below, until the user explicitly confirms the detected shape is correct — including the
latest-eligible: trueline above, which the operator may strike before confirming if this project's badge should live elsewhere. A refusal STOPs the lane — nothing is written, and nothing is proposed a second time without a fresh detection pass. -
Persist, only on confirmation — and re-check existence immediately before writing, regardless of how this lane was entered. Before minting any marker or writing anything, confirm no file exists yet at
{{PROJECT_DIR}}/.codearbiter/release-targets.md. If one now exists — a race since Detect ran, or this lane reached from anywhere other than the documented AbsentBlockError trigger — STOP without writing and surface it; never overwrite an existing file at this path under any circumstance, belt-and-braces on top of the trigger distinction above rather than trusting it alone. Only once that is confirmed,release-targets.mdis a marker-gated protected-state file: immediately before writing, mint the authoring marker at the path the write-guard hooks check (project root = git top level):mkdir -p "$(git rev-parse --show-toplevel)/.codearbiter/.markers" touch "$(git rev-parse --show-toplevel)/.codearbiter/.markers/release-targets-authoring"Write the confirmed block verbatim to
{{PROJECT_DIR}}/.codearbiter/release-targets.md, then remove the marker — it is honored for 30 minutes and exists for this one authoring pass only:rm -f "$(git rev-parse --show-toplevel)/.codearbiter/.markers/release-targets-authoring"This write itself dirties the tree, inside
$PAYLOAD's own window (HIGH-3, adversarial review 2026-07-31: a single-artifact detection emitspayload: ., so the new file sits inside the window Pre-flight is about to scope), and Pre-flight below STOPs on a dirty tree. Commit it throughcommit-gateon the current branch, aschore: declare release targets(or an equivalent non-bumping type), BEFORE re-entering Pre-flight. This is expected, not a defect: achorecommit contributes no bump and rolls into no changelog section (Phase 1 step 2), so this one extra commit changes neither the derived version nor what ships in$CHANGELOG— it is accounted for here, not discovered later. Then re-enter Pre-flight, which now finds the file and resolves$TARGETexactly as "Targets" above describes. -
A second invocation reads; it does not re-detect. Once the file exists on disk,
load_targetssucceeds and this back-fill lane never runs again for this project — detection above fires ONLY when the file is genuinely absent, never once a row has been confirmed and persisted.
Pre-flight
A project may declare more than one independently-versioned release target in {{PROJECT_DIR}}/.codearbiter/release-targets.md, each with its own tag series, payload path, manifest(s), and changelog. A sibling target's tag or commit MUST NOT influence $TARGET's version, window, or changelog — that isolation is what per-row scoping buys, and it is the single most common way a release goes wrong.
Read these, or STOP and surface the gap — never guess:
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 144
- Forks
- 7
- Last commit
- Sep 2026
- Hacker News mentions
- 20
Advanced
- Catalog kind
- skill
- Gateway key
release-arbiterforge- Source
- github.com/arbiterforge/codearbiter