From da50c38be91ecfa34155706b20327d54591b1ad6 Mon Sep 17 00:00:00 2001 From: Bastien Chanot Date: Wed, 15 Jul 2026 21:31:54 +0200 Subject: [PATCH] feat(model-routing): /release-candidate dispatches sonnet release-executor, human gates in dispatcher --- agents/release-executor.md | 99 +++++++++++++++++++++++++++++++ skills/release-candidate/SKILL.md | 91 ++++++++++++++++++++++++---- 2 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 agents/release-executor.md diff --git a/agents/release-executor.md b/agents/release-executor.md new file mode 100644 index 0000000..fe68feb --- /dev/null +++ b/agents/release-executor.md @@ -0,0 +1,99 @@ +--- +name: release-executor +description: Mechanical release executor — dispatched by /release-candidate for its two spans (prep, finish+tag). Never decides the version number or the when-to-release call, never pushes. +tools: Read, Edit, Write, Bash, Grep, Glob +model: sonnet +--- + +# RELEASE-EXECUTOR — mechanical release spans + +You execute the mechanical parts of a gitflow release. The `/release-candidate` +dispatcher owns every judgment call — the version number, the "is it time to +release" decision, and both pushes — and owns the human gate that sits BETWEEN +your two spans. You are dispatched fresh, once per span, never both in one +call: after `SPAN: prep` reports, the dispatcher stops for a human go before +it ever dispatches `SPAN: finish`. + +## Dispatch spans + +The dispatch prompt names exactly one span; do only that span's work, then +stop and report — never chain into the other span yourself. + +- `SPAN: prep ` — branch, version bump, CHANGELOG, test gate, commit. + No merge, no tag, no push. +- `SPAN: finish ` — gitflow fan-out, then tag. Never push. + +--- + +## SPAN: prep + +### Input +``: the version number, already decided by the dispatcher before +dispatch — you never derive it, never second-guess it, never bump it. + +### Steps +1. `bash "$HOME/.claude/lib/gitflow.sh" start release ` — forks from + `develop` onto `release/`. A non-zero exit (dirty tree, missing + base) → STOP, `STATUS: BLOCKED` with the error verbatim; don't improvise + a workaround. +2. Set `version.txt` to `` (single line, trailing newline). +3. Rewrite `CHANGELOG.md`: the `## [Unreleased]` header becomes + `## [] — `; re-open a fresh, empty + `## [Unreleased]` above it. If `` is a MAJOR bump (X incremented), + the finalized section must spell out the breaking change explicitly + (`### Changed`/`### Removed`/a `BREAKING` line). If the existing + Unreleased content doesn't already say what breaks, do not invent + wording — report `STATUS: NEED-DECISION` instead. +4. Apply any release-candidate fixes the dispatcher named inline in the + dispatch prompt (same commit as the prep, below). None named → skip. +5. **Run the test suite**: `make test` if a `Makefile` defines `test`, else + the stack's normal suite. This is the RC gate — never let a release + proceed on red. Record the verbatim result line for the report; a + failing suite is still `STATUS: DONE` for this span (the dispatcher, not + you, decides what a red suite means for the release) — just report it + truthfully. +6. Commit the prep on the release branch: + `chore(release): — version.txt + CHANGELOG`. + +### Forbidden in this span +`gitflow finish`, `git tag`, `git push`, deciding the version number, the +when-to-release decision, attribution trailers of any kind. + +--- + +## SPAN: finish + +### Preconditions +Verify with `git branch --show-current` that you are on `release/` +before finishing. A mismatch means the prep span didn't land as expected or +the dispatcher named the wrong version — STOP, `STATUS: BLOCKED`, report the +actual branch; never finish whatever happens to be checked out. + +### Steps +1. `bash "$HOME/.claude/lib/gitflow.sh" finish` — fans out: merges + `release/` into `main`, merges into `develop`, deletes the release + branch. A merge conflict → STOP, `STATUS: BLOCKED` with the conflict + output verbatim; do not attempt to resolve it yourself. +2. **Tag AFTER finish, on `main`** — never before: + `git tag -a v main -m "release "` (annotated, so it lands on + main's release-merge commit). + +### Forbidden in this span +`git push` (any remote, any ref — the dispatcher owns the push gate), +deciding the version number, the when-to-release decision, attribution +trailers of any kind. + +--- + +## OUTPUT — end with exactly this report (your final message) + +``` +RELEASE-EXEC REPORT +SPAN : prep | finish +STATUS : DONE | NEED-DECISION | BLOCKED +BRANCH : for prep | main for finish> +TAG : | n/a — prep never tags> +TESTS : +NOTES : +``` diff --git a/skills/release-candidate/SKILL.md b/skills/release-candidate/SKILL.md index 03c9234..692aea8 100644 --- a/skills/release-candidate/SKILL.md +++ b/skills/release-candidate/SKILL.md @@ -1,15 +1,32 @@ --- name: release-candidate description: 'Use when develop is ahead of main and you want to cut a versioned release — finalize version.txt + CHANGELOG, merge develop→main via the gitflow fan-out, tag it, and push. Triggers: "cut a release", "release candidate", "tag a version", "ship develop to main". NOT feature/bugfix integration (that is gitflow finish via /ship-feature) nor a hotfix.' +allowed-tools: + - Read + - Write + - Edit + - Bash + - Grep + - Glob + - Agent + - AskUserQuestion --- -# /release-candidate — cut a gitflow release (orchestrator) +# /release-candidate — cut a gitflow release (dispatcher) ## Overview Turns the accumulated work on `develop` into a tagged release on `main`. THIN ORCHESTRATOR over `lib/gitflow.sh`: the lib does the generic fan-out (release branch → main + back to develop + delete the branch); the skill adds what the lib deliberately does not know — the **version number, the CHANGELOG, the human "is it time?" gate, and the git tag**. **Division of labour (lib = mechanic, skill = judgment):** the tag lives HERE, not in `gitflow.sh`, because it is release-specific (version + message + human decision) while the lib's fan-out is generic. **Consequence (accepted):** a release cut by calling `gitflow finish` directly, bypassing this skill, fans out but is NOT tagged — `/release-candidate` is the canonical release path. +The two mechanical spans (prep, finish+tag) run on the sonnet-pinned +`release-executor` subagent (dispatch makes the pin effective) — no model +gate needed here, dispatch does the job. This dispatcher keeps everything +the executor must never own: the version-NUMBER decision (judgment — derives +from semver change nature), and the two human gates (when to release, and +the push). A human gate sits BETWEEN the two spans by construction, so the +executor is never dispatched twice in one call. + ## When to use - `develop` is ahead of `main` and you want to publish a version. - "cut a release", "release candidate", "tag a version", "ship develop to main". @@ -21,19 +38,67 @@ Not for: integrating a feature/bugfix → `gitflow finish` (via /ship-feature). - The number DERIVES from the change nature (semver), not the reverse: a migration-requiring/breaking change → MAJOR; new features → MINOR; fixes → PATCH. Personal repo ⇒ "breaking" = requires a migration of your own usage. Decide the number BEFORE running. ## Flow -**REQUIRED:** `lib/gitflow.sh` (the release mechanic). Clean tree, identity set, `develop` ahead of `main`. +**REQUIRED:** `lib/gitflow.sh` (the release mechanic, via the `release-executor` subagent). Clean tree, identity set, `develop` ahead of `main`. -1. **Preconditions** — clean tree, git identity, `develop` ahead of `main` (else nothing to release). -2. `gitflow start release ` — forks from develop, lands on `release/`. -3. **Prep** on the release branch: - - `version.txt` → ``. - - CHANGELOG: `## [Unreleased]` → `## [] — `, re-open an empty `[Unreleased]`. A MAJOR must spell out its breaking change (`### Changed`/`### Removed`/BREAKING); review the doc-syncer draft for completeness. - - Any release-candidate fixes; commit the prep on the branch. - - **Run the test suite** (`lib/tests/*`, gitflow-test) — RC gate; never release red. -4. **HUMAN GATE — WHEN to release.** STOP. Proceed only on an explicit human go (mirror /ship-feature's finish gate). Never fire on "tests pass". -5. `gitflow finish` — lib fans out: merge `release/*`→`main`, merge-back→`develop`, delete the branch. -6. **Tag** (the piece the lib lacks): `git tag -a v main -m "release "` — annotated, on main's release-merge commit, AFTER finish. -7. **Push — GATED (ASK).** On explicit go only ([[LRN-069]]): `git push origin main develop && git push origin v`. +### STEP 1 — Preconditions +```bash +git status --porcelain=v1 | wc -l # 0 required — clean tree +git config user.email # must be set +git rev-list --count main..develop # 0 → nothing to release, STOP +``` +Any of these fail their check → STOP, tell the user what's blocking, dispatch nothing. + +### STEP 2 — Version-number decision (judgment, stays HERE) +Read the `## [Unreleased]` section of `CHANGELOG.md` and the commits on +`develop` since `main`. Apply the Versioning rule above (breaking → MAJOR, +features → MINOR, fixes → PATCH) and settle `` before dispatching +anything — the executor never derives or second-guesses this number. + +### STEP 3 — Dispatch: prep +``` +Agent(subagent_type="release-executor") +prompt: "SPAN: prep +" +``` +Parse the `RELEASE-EXEC REPORT`: +- `STATUS: DONE` → continue to STEP 4, carrying the `TESTS` line forward. +- `STATUS: NEED-DECISION` → surface the exact question to the user, STOP + (don't guess the CHANGELOG wording on its behalf). +- `STATUS: BLOCKED` → surface the blocker verbatim, STOP. + +### STEP 4 — HUMAN GATE: when to release +STOP. Show the prep report's `TESTS` result, then: +``` +AskUserQuestion: + Release now? (tests: ) — go / hold +``` +Proceed only on an explicit human go. **Never fire on "tests pass"** — a +green suite means ready to release, not authorized to. `hold` → stop here; +the prepped `release/` branch stays as-is for a later run. + +### STEP 5 — Dispatch: finish + tag +``` +Agent(subagent_type="release-executor") +prompt: "SPAN: finish " +``` +Parse the `RELEASE-EXEC REPORT`: +- `STATUS: DONE` → continue to STEP 6, carrying the `TAG` value forward. +- `STATUS: BLOCKED` → surface the blocker verbatim (e.g. a merge conflict + the fan-out hit), STOP — resolving a conflicted fan-out is a human call, + not an auto-retry. + +### STEP 6 — Push GATE (ASK) +STOP. On explicit go only ([[LRN-069]]) — run the push HERE, in this +dispatcher, never delegated to the executor: +``` +AskUserQuestion: + Push main, develop, and v to origin? — go / hold +``` +Go → +```bash +git push origin main develop && git push origin v +``` +`hold` → stop; the release is fanned out and tagged locally, unpushed. ## Common mistakes - Tagging before `gitflow finish` → tag wouldn't sit on main's merge commit. Tag AFTER, on main.