From 54fcbed910a7059b2f7fa2306713ab7f733bc9cc Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:59:24 +0200 Subject: [PATCH 1/2] feat(release): milestone-gated release issue + release driver + rollout companion (issue #176) Adds the release-cycle machinery: a `Release vX.Y.Z` issue template that reuses issue #97's "Blocked by #N" blocking-graph gate against every other issue in the milestone (no new state machine), a `.claude/scripts/release.sh` driver that bumps plugin.json/marketplace.json, generates a dated CHANGELOG entry from merged PR titles since the last tag (or full history on a first release), tags/commits/pushes, closes the milestone, and auto-files a census-invisible `Rollout & feedback: vX.Y.Z` companion issue with a consumer-sync checklist for reDeploy/reDeFi (sync v2, issue #141). `--dry-run` performs the real file transforms but only prints every git/gh side effect, covered by an offline release.test.sh fixture suite (with-tag, no-prior-tag, and Unreleased-scaffold cases). Documents the whole convention, including the hotfix path, in docs/USAGE.md -> "Release cycle", superseding the ad-hoc pattern from #136. Co-Authored-By: Claude Sonnet 5 --- .claude/.claude-plugin/CHANGELOG.md | 5 + .claude/scripts/release.sh | 312 ++++++++++++++++++++++++++++ .claude/scripts/release.test.sh | 293 ++++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/release.md | 56 +++++ docs/USAGE.md | 52 +++++ 5 files changed, 718 insertions(+) create mode 100755 .claude/scripts/release.sh create mode 100755 .claude/scripts/release.test.sh create mode 100644 .github/ISSUE_TEMPLATE/release.md diff --git a/.claude/.claude-plugin/CHANGELOG.md b/.claude/.claude-plugin/CHANGELOG.md index 03d1691..4362c5e 100644 --- a/.claude/.claude-plugin/CHANGELOG.md +++ b/.claude/.claude-plugin/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to the `orchestrator` plugin are documented in this file. Fo [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); versions track `plugin.json` / `marketplace.json`. +## [Unreleased] +Entries land here as work merges; `.claude/scripts/release.sh` (issue #176) turns this into a dated +`## [X.Y.Z] - YYYY-MM-DD` section — ahead of the prior release, below this scaffold — at cut time. See +`docs/USAGE.md` → "Release cycle". + ## [0.2.2] - 2026-07-17 ### Changed diff --git a/.claude/scripts/release.sh b/.claude/scripts/release.sh new file mode 100755 index 0000000..be5c7e4 --- /dev/null +++ b/.claude/scripts/release.sh @@ -0,0 +1,312 @@ +#!/usr/bin/env bash +# release.sh — the release driver for a milestone-gated "Release vX.Y.Z" issue +# (issue #176). Reuses the #97 "Blocked by #N" blocking-graph gate as the +# whole release gate: the release issue's body declares "Blocked by" every +# OTHER open issue in its milestone, so the loop's census will not advance it +# until every sibling has merged (see .github/ISSUE_TEMPLATE/release.md and +# docs/USAGE.md -> "Release cycle"). Once advanced, the implementer runs this +# script to perform the actual cut. +# +# Usage: +# bash .claude/scripts/release.sh vX.Y.Z [--issue N] [--repo OWNER/NAME] [--dry-run] +# bash .claude/scripts/release.sh --milestone-title "Release v0.3.0" [--dry-run] +# +# Version resolution (first match wins): +# 1. an explicit "vX.Y.Z" positional argument +# 2. --milestone-title "TITLE" — a vX.Y.Z substring is extracted locally, +# no network call +# 3. --issue N — fetches the issue's milestone title via bot-gh.sh (NOT +# permitted under --dry-run: dry-run must make zero network/gh calls) +# +# Steps performed (see the CRITICAL header below for the dry-run boundary): +# 1. Bump the `version` field in .claude/.claude-plugin/plugin.json AND +# .claude/.claude-plugin/marketplace.json (top-level + the matching +# plugins[] entry) via node -e (never a brittle sed on JSON, matching +# checks.sh's own JSON-editing style). +# 2. Generate a dated Keep-a-Changelog section in +# .claude/.claude-plugin/CHANGELOG.md from commits since the last git +# tag — or, if there is no prior tag yet (this repo's current state: +# zero tags), from the full history (first-release path). Prefers merge +# commit subjects ("Merge pull request #N from owner/branch" -> one +# bullet per merged PR) when any exist in range; falls back to every +# non-merge commit subject in range otherwise (the no-merges case a +# fresh/test repo hits). +# 3. Tag vX.Y.Z, commit the bumps + changelog, push both. +# 4. "Publish" per the existing "Updating the plugin" runbook +# (docs/USAGE.md): this script CANNOT run the interactive +# `/plugin marketplace update` slash command, and `npm/pnpm/yarn +# publish` are policy-denied, so "publish" here means step 3 above, +# followed by printing the manual owner follow-up. +# 5. Close the milestone (requires --issue N to resolve which milestone; +# without it this step is skipped with a warning — close it by hand). +# 6. On success, auto-file a "Rollout & feedback: vX.Y.Z" issue: NOT in any +# milestone, NO `planned` label (a census-invisible inbox — see +# docs/USAGE.md), labelled `feedback`. Body carries (a) a consumer-sync +# checklist for reDeploy/reDeFi via `/orchestrator:sync` v2 (issue +# #141), (b) test-focus areas derived from the milestone's issue titles +# (requires --issue N; a generic note otherwise), (c) a section to +# accumulate feedback back-references. The `feedback` / +# `from:redeploy` / `from:redefi` labels are created idempotently at +# file time via REST (`gh api`, never `gh label create` / +# `gh issue edit --add-label` — issue #169 found both silently no-op +# with this repo's bot token/gh version; see needs-human.sh). +# +# --- CRITICAL: --dry-run boundary ------------------------------------------- +# --dry-run performs the PURE FILE TRANSFORMS (steps 1-2 above) for real, +# against the working tree/fixtures it's pointed at (release.test.sh's temp +# git fixture) — so a caller can inspect the resulting files — but PRINTS +# every git/gh side effect (tag, push, milestone close, issue file) instead +# of executing it. No `git commit`/`git tag`/`git push`/any `bot-gh.sh`/`gh` +# call happens under --dry-run, ever. +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=resolve-roots.sh +. "$script_dir/resolve-roots.sh" +# Route EVERY gh call through the bot identity (see bot-gh.sh) — never bare gh. +gh() { bash "$script_dir/bot-gh.sh" "$@"; } + +DRY_RUN=0 +VERSION="" +ISSUE_NUM="" +MILESTONE_TITLE="" +REPO="" + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + --issue) ISSUE_NUM="${2:?--issue requires a value}"; shift 2 ;; + --milestone-title) MILESTONE_TITLE="${2:?--milestone-title requires a value}"; shift 2 ;; + --repo) REPO="${2:?--repo requires a value}"; shift 2 ;; + -*) + echo "release.sh: unknown flag '$1'" >&2 + exit 2 + ;; + *) + if [ -n "$VERSION" ]; then + echo "release.sh: unexpected extra argument '$1'" >&2 + exit 2 + fi + VERSION="$1" + shift + ;; + esac +done + +# --- version resolution ------------------------------------------------------ +if [ -z "$VERSION" ] && [ -n "$MILESTONE_TITLE" ]; then + VERSION="$(printf '%s' "$MILESTONE_TITLE" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1)" +fi +if [ -z "$VERSION" ] && [ -n "$ISSUE_NUM" ]; then + if [ "$DRY_RUN" -eq 1 ]; then + echo "release.sh: --issue resolves the version via a network call, which --dry-run must never make. Pass vX.Y.Z or --milestone-title explicitly under --dry-run." >&2 + exit 2 + fi + repo_flag=(); [ -n "$REPO" ] && repo_flag=(-R "$REPO") + milestone_title_probe="$(gh issue view "$ISSUE_NUM" "${repo_flag[@]}" --json milestone --jq '.milestone.title // empty' 2>/dev/null)" || true + VERSION="$(printf '%s' "$milestone_title_probe" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1)" +fi +if ! printf '%s' "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "release.sh: could not resolve a vX.Y.Z version — pass it as an argument, via --milestone-title, or --issue N (non-dry-run only)" >&2 + exit 2 +fi +num_version="${VERSION#v}" + +# --- fixed file locations ---------------------------------------------------- +plugin_json="$root/.claude/.claude-plugin/plugin.json" +marketplace_json="$root/.claude/.claude-plugin/marketplace.json" +changelog_md="$root/.claude/.claude-plugin/CHANGELOG.md" +for f in "$plugin_json" "$marketplace_json" "$changelog_md"; do + if [ ! -f "$f" ]; then + echo "release.sh: missing $f" >&2 + exit 1 + fi +done + +echo "release.sh: releasing $VERSION" + +# --- step 1: bump plugin.json + marketplace.json ----------------------------- +node -e ' + const fs = require("fs"); + const f = process.argv[1], v = process.argv[2]; + const j = JSON.parse(fs.readFileSync(f, "utf8")); + j.version = v; + fs.writeFileSync(f, JSON.stringify(j, null, 2) + "\n"); +' "$plugin_json" "$num_version" || { echo "release.sh: failed to bump $plugin_json" >&2; exit 1; } +echo "release.sh: bumped plugin.json -> $num_version" + +node -e ' + const fs = require("fs"); + const f = process.argv[1], v = process.argv[2]; + const j = JSON.parse(fs.readFileSync(f, "utf8")); + j.version = v; + if (Array.isArray(j.plugins)) { + for (const p of j.plugins) p.version = v; + } + fs.writeFileSync(f, JSON.stringify(j, null, 2) + "\n"); +' "$marketplace_json" "$num_version" || { echo "release.sh: failed to bump $marketplace_json" >&2; exit 1; } +echo "release.sh: bumped marketplace.json (+ plugins[].version) -> $num_version" + +# --- step 2: generate the CHANGELOG section ---------------------------------- +last_tag="$(git -C "$root" describe --tags --abbrev=0 2>/dev/null || true)" +if [ -n "$last_tag" ]; then + range="$last_tag..HEAD" + echo "release.sh: generating changelog from commits in $range (since last tag $last_tag)" +else + range="HEAD" + echo "release.sh: no prior git tag found — first-release path, using full history" +fi + +merge_subjects="$(git -C "$root" log $range --merges --pretty=%s 2>/dev/null || true)" +if [ -n "$merge_subjects" ]; then + changelog_items="$(printf '%s\n' "$merge_subjects" | sed -E 's/^Merge pull request (#[0-9]+) from [^\/]+\/(.+)$/- \1: \2/')" +else + changelog_items="$(git -C "$root" log $range --no-merges --pretty=%s 2>/dev/null | sed 's/^/- /')" +fi +[ -n "$changelog_items" ] || changelog_items="- (no changes recorded)" + +release_date="$(date -u +%Y-%m-%d)" +section="## [$num_version] - $release_date + +### Changed +$changelog_items" + +# Insert ahead of the first DATED entry, skipping past an optional leading +# "## [Unreleased]" scaffold section (Keep-a-Changelog convention: Unreleased +# always stays on top, above every dated release) so a repo that scaffolds +# one doesn't get it pushed below the new dated section. +tmp_changelog="$(mktemp "${TMPDIR:-/tmp}/release-changelog.XXXXXX")" +if grep -qEi '^## \[[0-9]' "$changelog_md"; then + awk -v section="$section" ' + !inserted && /^## \[/ && !/^## \[[Uu]nreleased\]/ { print section; print ""; inserted=1 } + { print } + ' "$changelog_md" > "$tmp_changelog" +else + cat "$changelog_md" > "$tmp_changelog" + printf '\n%s\n' "$section" >> "$tmp_changelog" +fi +mv "$tmp_changelog" "$changelog_md" +echo "release.sh: prepended CHANGELOG section for $num_version" + +# --- dry-run boundary: pure file transforms are done; print every remaining +# git/gh side effect instead of performing it. ------------------------------- +if [ "$DRY_RUN" -eq 1 ]; then + echo "release.sh: [dry-run] would run: git add -- '$plugin_json' '$marketplace_json' '$changelog_md'" + echo "release.sh: [dry-run] would run: git commit -m 'release: $VERSION'" + echo "release.sh: [dry-run] would run: git tag $VERSION" + echo "release.sh: [dry-run] would run: git push origin HEAD" + echo "release.sh: [dry-run] would run: git push origin $VERSION" + if [ -n "$ISSUE_NUM" ]; then + echo "release.sh: [dry-run] would resolve the milestone from issue #$ISSUE_NUM and close it via bot-gh.sh" + else + echo "release.sh: [dry-run] would close the milestone via bot-gh.sh (pass --issue N to resolve which one)" + fi + echo "release.sh: [dry-run] would create labels (feedback, from:redeploy, from:redefi) idempotently via bot-gh.sh api, then file a 'Rollout & feedback: $VERSION' issue (no milestone, no planned label, labelled feedback)" + echo "release.sh: [dry-run] complete — file transforms applied, no git/gh mutation performed" + exit 0 +fi + +# ============================================================================= +# Everything below this line performs REAL git/gh side effects and is never +# reached under --dry-run. +# ============================================================================= + +# --- step 3: commit, tag, push ----------------------------------------------- +git -C "$root" add -- "$plugin_json" "$marketplace_json" "$changelog_md" \ + || { echo "release.sh: git add failed" >&2; exit 1; } +git -C "$root" commit -q -m "release: $VERSION" \ + || { echo "release.sh: git commit failed" >&2; exit 1; } +# -c tag.gpgSign=false: override an operator's global gpgsign default for +# THIS invocation only (never writes to any gitconfig) — a lightweight +# release tag doesn't need GPG signing, and forcing it here means this +# script works unattended even when signing isn't set up / has no agent. +git -C "$root" -c tag.gpgSign=false tag "$VERSION" \ + || { echo "release.sh: git tag failed" >&2; exit 1; } +git -C "$root" push origin HEAD \ + || { echo "release.sh: git push (branch) failed" >&2; exit 1; } +git -C "$root" push origin "$VERSION" \ + || { echo "release.sh: git push (tag) failed" >&2; exit 1; } + +echo "" +echo "release.sh: MANUAL OWNER FOLLOW-UP REQUIRED (cannot be automated — see docs/USAGE.md 'Updating the plugin'):" +echo " /plugin marketplace update recode" +echo "" + +# --- step 5: close the milestone (requires --issue N) ------------------------ +repo_flag=(); [ -n "$REPO" ] && repo_flag=(-R "$REPO") +repo_full="$REPO" +[ -n "$repo_full" ] || repo_full="$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null)" || true + +milestone_number="" +milestone_title_for_focus="" +if [ -n "$ISSUE_NUM" ]; then + milestone_number="$(gh issue view "$ISSUE_NUM" "${repo_flag[@]}" --json milestone --jq '.milestone.number // empty' 2>/dev/null)" || true + milestone_title_for_focus="$(gh issue view "$ISSUE_NUM" "${repo_flag[@]}" --json milestone --jq '.milestone.title // empty' 2>/dev/null)" || true +fi + +if [ -n "$milestone_number" ] && [ -n "$repo_full" ]; then + if gh api -X PATCH "repos/$repo_full/milestones/$milestone_number" -f state=closed >/dev/null 2>&1; then + echo "release.sh: closed milestone #$milestone_number" + else + echo "release.sh: warning — could not close milestone #$milestone_number via the API; close it by hand" >&2 + fi +else + echo "release.sh: warning — no milestone resolved (pass --issue N); NOT closed automatically — close it by hand" >&2 +fi + +# --- step 6: test-focus areas from the milestone's issue titles -------------- +test_focus="" +if [ -n "$milestone_title_for_focus" ]; then + test_focus="$(gh issue list "${repo_flag[@]}" --state all --milestone "$milestone_title_for_focus" --json title --jq '.[].title' 2>/dev/null)" || true +fi +if [ -n "$test_focus" ]; then + test_focus_list="$(printf '%s\n' "$test_focus" | sed 's/^/- /')" +else + test_focus_list="- (milestone issue titles unavailable — pass --issue N to derive them)" +fi + +# --- step 6: idempotently ensure the rollout labels exist (REST, matching the +# proven-working pattern from needs-human.sh; `gh label create`/ +# `gh issue edit --add-label` silently no-op with this repo's bot token, see +# issue #169) ----------------------------------------------------------------- +if [ -n "$repo_full" ]; then + gh api -X POST "repos/$repo_full/labels" -f name=feedback -f color=0e8a16 \ + -f description="Post-release consumer feedback inbox (census-invisible -- no milestone, no planned label)" \ + >/dev/null 2>&1 || true + gh api -X POST "repos/$repo_full/labels" -f name=from:redeploy -f color=1d76db \ + -f description="Feedback originating from the reDeploy consumer sync" \ + >/dev/null 2>&1 || true + gh api -X POST "repos/$repo_full/labels" -f name=from:redefi -f color=5319e7 \ + -f description="Feedback originating from the reDeFi consumer sync" \ + >/dev/null 2>&1 || true +fi + +rollout_body="## Rollout & feedback: $VERSION + +Version **$VERSION** has been released. This issue is the post-release feedback inbox: it +carries NO milestone and NO \`planned\` label by design (a census-invisible inbox — see +docs/USAGE.md \"Release cycle\") until the owner triages incoming feedback. + +## Consumer-sync checklist +Run \`/orchestrator:sync\` (sync v2, issue #141) against each downstream consumer and record +the result here: +- [ ] reDeploy — \`/orchestrator:sync\` (deploy-lag, env, labels, observability checks) +- [ ] reDeFi — \`/orchestrator:sync\` (deploy-lag, env, labels, observability checks) + +## Test-focus areas (from milestone issues) +$test_focus_list + +## Feedback +Accumulate feedback references here as they come in. Link the originating issue/PR from +reDeploy/reDeFi and tag it with \`from:redeploy\`/\`from:redefi\`: +- (none yet) +" + +create_out="$(gh issue create "${repo_flag[@]}" --title "Rollout & feedback: $VERSION" --body "$rollout_body" --label feedback 2>&1)" +if [ $? -eq 0 ]; then + echo "release.sh: filed rollout issue: $create_out" +else + echo "release.sh: warning — failed to file the rollout issue: $create_out" >&2 +fi + +echo "release.sh: done — $VERSION released" diff --git a/.claude/scripts/release.test.sh b/.claude/scripts/release.test.sh new file mode 100755 index 0000000..95daa9f --- /dev/null +++ b/.claude/scripts/release.test.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# release.test.sh — offline smoke test for release.sh (issue #176). +# +# Builds TWO throwaway temp git repo fixtures (mktemp -d, git init, seeded +# plugin.json/marketplace.json/CHANGELOG.md fixtures + fake commits — the +# REAL release.sh + resolve-roots.sh copied into each fixture's +# .claude/scripts/, mirroring worktree-cleanup.test.sh's fixture pattern) and +# exercises `release.sh --dry-run` against each, asserting: +# 1. WITH-A-PRIOR-TAG case: a couple of fake merge commits ("Merge pull +# request #N from owner/branch", the real GitHub merge-commit shape) +# land after the tag; release.sh --dry-run derives the changelog from +# THOSE, not the pre-tag history. +# 2. version bump is applied to BOTH plugin.json and marketplace.json +# (top-level `version` + marketplace's nested plugins[].version). +# 3. a correctly-formatted, DATED changelog section +# ("## [X.Y.Z] - YYYY-MM-DD") is prepended ahead of the prior version's +# section, containing one bullet per fake merged-PR commit. +# 4. NO-PRIOR-TAG (first-release) case: a fresh fixture with zero tags and +# only plain (non-merge) commits standing in for PR titles — the +# first-release path still bumps + generates a changelog, using the +# full history's non-merge commit subjects since there is nothing else +# to derive them from. +# 5. --dry-run makes NO real git tag, NO git push, and NO gh/network call +# of any kind (no .env / GH_BOT_TOKEN is ever provided to either +# fixture, so a stray real `gh`/`bot-gh.sh` invocation would surface as +# a hard failure — dry-run must never reach that code path). +# 6. --milestone-title resolves the version with no --issue/network call; +# --issue combined with --dry-run is rejected (dry-run must never +# resolve a version via a live gh call). +# 7. an optional "## [Unreleased]" scaffold section at the top of the +# CHANGELOG (issue #176's optional CHANGELOG addition) stays ABOVE the +# newly-generated dated section rather than getting pushed below it. +# +# Exit 0 on success, non-zero if any assertion fails. Runnable bare: +# bash .claude/scripts/release.test.sh +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +release_src="$script_dir/release.sh" +resolve_roots_src="$script_dir/resolve-roots.sh" + +work="$(mktemp -d "${TMPDIR:-/tmp}/release-test.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +fail=0 +ok=0 +check() { + local desc="$1"; shift + if "$@"; then + ok=$((ok + 1)) + echo "ok - $desc" + else + fail=1 + echo "FAIL - $desc" + fi +} + +json_version() { + # $1 = file, prints .version + node -e ' + const fs = require("fs"); + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + process.stdout.write(String(j.version || "")); + ' "$1" 2>/dev/null +} +json_plugin_entry_version() { + # $1 = marketplace.json, prints plugins[0].version + node -e ' + const fs = require("fs"); + const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + process.stdout.write(String((j.plugins && j.plugins[0] && j.plugins[0].version) || "")); + ' "$1" 2>/dev/null +} + +seed_fixture() { + # $1 = repo dir. Seeds plugin.json/marketplace.json/CHANGELOG.md at 1.0.0 + # plus the real release.sh + resolve-roots.sh under .claude/scripts/, and + # an initial commit. Deliberately NO .env / GH_BOT_TOKEN anywhere near this + # fixture, and NO origin remote — a stray real git-push or gh call would + # hard-fail loudly rather than silently succeeding. + local repo="$1" + mkdir -p "$repo/.claude/.claude-plugin" "$repo/.claude/scripts" + git init -q -b main "$repo" + git -C "$repo" config user.email "test@example.com" + git -C "$repo" config user.name "Test" + + cat > "$repo/.claude/.claude-plugin/plugin.json" <<'EOF' +{ + "name": "orchestrator", + "version": "1.0.0", + "description": "test fixture" +} +EOF + cat > "$repo/.claude/.claude-plugin/marketplace.json" <<'EOF' +{ + "name": "recode", + "version": "1.0.0", + "plugins": [ + { "name": "orchestrator", "source": "./", "version": "1.0.0" } + ] +} +EOF + cat > "$repo/.claude/.claude-plugin/CHANGELOG.md" <<'EOF' +# Changelog + +All notable changes are documented here. + +## [1.0.0] - 2026-01-01 + +### Added +- Initial fixture release. +EOF + + cp "$release_src" "$repo/.claude/scripts/release.sh" + cp "$resolve_roots_src" "$repo/.claude/scripts/resolve-roots.sh" + chmod +x "$repo/.claude/scripts/release.sh" + + git -C "$repo" add -A + git -C "$repo" commit -q -m "seed fixture at 1.0.0" +} + +run_release() { + # $1 = repo dir, rest = args to release.sh + local repo="$1"; shift + ( cd "$repo" && bash "$repo/.claude/scripts/release.sh" "$@" ) +} + +# ============================================================================= +# Scenario A: WITH a prior tag — fake merge commits after the tag. +# ============================================================================= +repo_a="$work/repo-a" +seed_fixture "$repo_a" +# Tag the seed commit itself as v1.0.0 — everything committed after this point +# is "since the last tag" and must be the ONLY thing the changelog picks up. +git -C "$repo_a" -c tag.gpgSign=false tag v1.0.0 + +# A commit landing AFTER the tag but BEFORE the merge commits below — it is +# in-range but not a merge commit, so with merge commits present it must be +# excluded from the changelog (merge subjects win over plain commits when any +# exist in range — see release.sh's changelog-generation comment). +echo "pre-merge" > "$repo_a/premerge.txt" +git -C "$repo_a" add premerge.txt +git -C "$repo_a" commit -q -m "premerge: should not show up once merge commits exist in range" + +# Two fake merged-PR commits, real GitHub merge-commit shape. +git -C "$repo_a" checkout -q -b feat/thing-one +echo "one" > "$repo_a/one.txt" +git -C "$repo_a" add one.txt +git -C "$repo_a" commit -q -m "feat: thing one" +git -C "$repo_a" checkout -q main +git -C "$repo_a" merge -q --no-ff -m "Merge pull request #42 from robercano/feat/thing-one" feat/thing-one + +git -C "$repo_a" checkout -q -b feat/thing-two +echo "two" > "$repo_a/two.txt" +git -C "$repo_a" add two.txt +git -C "$repo_a" commit -q -m "feat: thing two" +git -C "$repo_a" checkout -q main +git -C "$repo_a" merge -q --no-ff -m "Merge pull request #43 from robercano/feat/thing-two" feat/thing-two + +out_a="$(run_release "$repo_a" v1.1.0 --dry-run)" +rc_a=$? + +check "scenario A: exits 0" [ "$rc_a" -eq 0 ] +check "scenario A: plugin.json version bumped to 1.1.0" bash -c '[ "$(cat "$1")" = "1.1.0" ]' _ <(json_version "$repo_a/.claude/.claude-plugin/plugin.json") +check "scenario A: marketplace.json top-level version bumped to 1.1.0" bash -c '[ "$(cat "$1")" = "1.1.0" ]' _ <(json_version "$repo_a/.claude/.claude-plugin/marketplace.json") +check "scenario A: marketplace.json plugins[0].version bumped to 1.1.0" bash -c '[ "$(cat "$1")" = "1.1.0" ]' _ <(json_plugin_entry_version "$repo_a/.claude/.claude-plugin/marketplace.json") + +changelog_a="$(cat "$repo_a/.claude/.claude-plugin/CHANGELOG.md")" +check "scenario A: changelog has a dated 1.1.0 section" bash -c 'printf "%s" "$1" | grep -qE "^## \[1\.1\.0\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$"' _ "$changelog_a" +check "scenario A: new 1.1.0 section is ABOVE the prior 1.0.0 section" bash -c ' + printf "%s\n" "$1" > "$2/order.txt" + n1=$(grep -n "^## \[1.1.0\]" "$2/order.txt" | head -1 | cut -d: -f1) + n0=$(grep -n "^## \[1.0.0\]" "$2/order.txt" | head -1 | cut -d: -f1) + [ -n "$n1" ] && [ -n "$n0" ] && [ "$n1" -lt "$n0" ] +' _ "$changelog_a" "$work" +check "scenario A: changelog references merged PR #42" bash -c 'printf "%s" "$1" | grep -q "#42: feat/thing-one"' _ "$changelog_a" +check "scenario A: changelog references merged PR #43" bash -c 'printf "%s" "$1" | grep -q "#43: feat/thing-two"' _ "$changelog_a" +check "scenario A: in-range non-merge commit is excluded once merge commits exist" bash -c '! printf "%s" "$1" | grep -q "premerge:"' _ "$changelog_a" +check "scenario A: no real tag v1.1.0 was created" bash -c '! git -C "$1" tag --list | grep -qx v1.1.0' _ "$repo_a" +check "scenario A: original tag v1.0.0 untouched" bash -c 'git -C "$1" tag --list | grep -qx v1.0.0' _ "$repo_a" +check "scenario A: HEAD has no new commit (dry-run never commits)" bash -c ' + msg=$(git -C "$1" log -1 --pretty=%s) + [ "$msg" != "release: v1.1.0" ] +' _ "$repo_a" +check "scenario A: dry-run output announces every side effect instead of performing it" bash -c ' + printf "%s" "$1" | grep -q "\[dry-run\] would run: git tag v1.1.0" && + printf "%s" "$1" | grep -q "\[dry-run\] would run: git push origin v1.1.0" && + printf "%s" "$1" | grep -qi "\[dry-run\].*rollout" +' _ "$out_a" + +# ============================================================================= +# Scenario B: NO prior tag (first-release path) — plain, non-merge commits +# standing in for PR titles, exactly the case a brand-new repo (this repo +# today: zero git tags) will hit on its very first release. +# ============================================================================= +repo_b="$work/repo-b" +seed_fixture "$repo_b" + +echo "alpha" > "$repo_b/alpha.txt" +git -C "$repo_b" add alpha.txt +git -C "$repo_b" commit -q -m "feat: alpha capability" +echo "beta" > "$repo_b/beta.txt" +git -C "$repo_b" add beta.txt +git -C "$repo_b" commit -q -m "fix: beta bug" + +out_b="$(run_release "$repo_b" v0.1.0 --dry-run)" +rc_b=$? + +check "scenario B: exits 0" [ "$rc_b" -eq 0 ] +check "scenario B: plugin.json version bumped to 0.1.0" bash -c '[ "$(cat "$1")" = "0.1.0" ]' _ <(json_version "$repo_b/.claude/.claude-plugin/plugin.json") +check "scenario B: marketplace.json version bumped to 0.1.0" bash -c '[ "$(cat "$1")" = "0.1.0" ]' _ <(json_version "$repo_b/.claude/.claude-plugin/marketplace.json") +check "scenario B: announces the no-prior-tag / full-history path" bash -c 'printf "%s" "$1" | grep -qi "no prior git tag found"' _ "$out_b" + +changelog_b="$(cat "$repo_b/.claude/.claude-plugin/CHANGELOG.md")" +check "scenario B: changelog has a dated 0.1.0 section" bash -c 'printf "%s" "$1" | grep -qE "^## \[0\.1\.0\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$"' _ "$changelog_b" +check "scenario B: changelog derived from the plain (non-merge) commit subjects" bash -c ' + printf "%s" "$1" | grep -q -- "- feat: alpha capability" && + printf "%s" "$1" | grep -q -- "- fix: beta bug" +' _ "$changelog_b" +check "scenario B: no tag was created at all" bash -c '[ -z "$(git -C "$1" tag --list)" ]' _ "$repo_b" + +# ============================================================================= +# Scenario C: version resolution — --milestone-title (no network) works; +# --issue combined with --dry-run is rejected (never allowed to resolve the +# version via a live gh call under dry-run). +# ============================================================================= +repo_c="$work/repo-c" +seed_fixture "$repo_c" +echo "gamma" > "$repo_c/gamma.txt" +git -C "$repo_c" add gamma.txt +git -C "$repo_c" commit -q -m "feat: gamma" + +out_c="$(run_release "$repo_c" --milestone-title "Release v2.3.4" --dry-run)" +rc_c=$? +check "scenario C: --milestone-title resolves the version offline" [ "$rc_c" -eq 0 ] +check "scenario C: plugin.json bumped to the milestone-derived version 2.3.4" bash -c '[ "$(cat "$1")" = "2.3.4" ]' _ <(json_version "$repo_c/.claude/.claude-plugin/plugin.json") + +repo_d="$work/repo-d" +seed_fixture "$repo_d" +out_d="$(run_release "$repo_d" --issue 176 --dry-run 2>&1)" +rc_d=$? +check "scenario D: --issue combined with --dry-run is rejected (non-zero exit)" [ "$rc_d" -ne 0 ] +check "scenario D: rejection message explains --dry-run can't resolve the version via network" bash -c 'printf "%s" "$1" | grep -qi "dry-run must never make"' _ "$out_d" +check "scenario D: plugin.json left untouched (still 1.0.0)" bash -c '[ "$(cat "$1")" = "1.0.0" ]' _ <(json_version "$repo_d/.claude/.claude-plugin/plugin.json") + +# ============================================================================= +# Scenario E: an optional "## [Unreleased]" scaffold section (issue #176) at +# the top of the CHANGELOG must stay ABOVE the newly-generated dated section, +# not get pushed below it — Keep-a-Changelog convention. +# ============================================================================= +repo_e="$work/repo-e" +seed_fixture "$repo_e" +cat > "$repo_e/.claude/.claude-plugin/CHANGELOG.md" <<'EOF' +# Changelog + +## [Unreleased] +- scaffold section, no dated entry yet + +## [1.0.0] - 2026-01-01 + +### Added +- Initial fixture release. +EOF +git -C "$repo_e" add .claude/.claude-plugin/CHANGELOG.md +git -C "$repo_e" commit -q -m "seed: add Unreleased scaffold" +git -C "$repo_e" -c tag.gpgSign=false tag v1.0.0 +echo "delta" > "$repo_e/delta.txt" +git -C "$repo_e" add delta.txt +git -C "$repo_e" commit -q -m "feat: delta capability" + +run_release "$repo_e" v1.1.0 --dry-run >/dev/null +changelog_e="$(cat "$repo_e/.claude/.claude-plugin/CHANGELOG.md")" +check "scenario E: Unreleased scaffold stays above the new dated section" bash -c ' + printf "%s\n" "$1" > "$2/order-e.txt" + nu=$(grep -n "^## \[Unreleased\]" "$2/order-e.txt" | head -1 | cut -d: -f1) + n1=$(grep -n "^## \[1.1.0\]" "$2/order-e.txt" | head -1 | cut -d: -f1) + [ -n "$nu" ] && [ -n "$n1" ] && [ "$nu" -lt "$n1" ] +' _ "$changelog_e" "$work" +check "scenario E: new dated section still lands above the prior 1.0.0 section" bash -c ' + printf "%s\n" "$1" > "$2/order-e2.txt" + n1=$(grep -n "^## \[1.1.0\]" "$2/order-e2.txt" | head -1 | cut -d: -f1) + n0=$(grep -n "^## \[1.0.0\]" "$2/order-e2.txt" | head -1 | cut -d: -f1) + [ -n "$n1" ] && [ -n "$n0" ] && [ "$n1" -lt "$n0" ] +' _ "$changelog_e" "$work" + +echo "" +if [ "$fail" -eq 0 ]; then + echo "release.test.sh: PASS ($ok checks)" + exit 0 +else + echo "release.test.sh: FAIL (see FAIL lines above)" + exit 1 +fi diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md new file mode 100644 index 0000000..70c2234 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/release.md @@ -0,0 +1,56 @@ +--- +name: Release +about: Cut a release for a milestone once every other issue in it has merged. +title: 'Release vX.Y.Z' +labels: module:harness +--- + +## What +This issue is the release gate for milestone **">**. It reuses +the "Blocked by #N" blocking-graph convention (issue #97) as the ENTIRE release gate — no new state +machine: the loop's census will not advance this issue until every other issue in the milestone is +closed. + +**Blocked by #…, #…, #…** + +## Keeping the Blocked-by list current +Whenever an issue is added to (or removed from) this milestone, refresh the line above so it still lists +every OTHER open issue in the milestone (never this issue itself). A quick helper query: + +```bash +bash .claude/scripts/bot-gh.sh issue list --milestone "" --state open --json number,title +``` + +Paste the resulting issue numbers into the "Blocked by" line, comma-separated (e.g. `Blocked by #101, #102, #103`). +An empty/absent "Blocked by" line (all siblings closed) makes this issue immediately eligible once it +also carries `planned`. + +## What happens when this issue is advanced +Once every "Blocked by" issue above is closed, this issue becomes a normal `planned` + `module:*` +candidate and the loop's ADVANCE step picks it up like any other issue. Its implementer runs the release +driver: + +```bash +bash .claude/scripts/release.sh vX.Y.Z --issue +``` + +which: +1. bumps the `version` field in `.claude/.claude-plugin/plugin.json` and + `.claude/.claude-plugin/marketplace.json`, +2. generates a dated CHANGELOG entry in `.claude/.claude-plugin/CHANGELOG.md` from merged PR titles since + the last git tag (or the full history, on a first release), +3. tags `vX.Y.Z`, commits, and pushes, +4. prints the manual owner follow-up (`/plugin marketplace update recode` — this cannot be automated), +5. closes this milestone, +6. auto-files a `Rollout & feedback: vX.Y.Z` issue — deliberately outside any milestone and without the + `planned` label (a census-invisible feedback inbox) — with a consumer-sync checklist for reDeploy and + reDeFi (`/orchestrator:sync` v2, issue #141) and test-focus areas derived from this milestone's issue + titles. + +See `docs/USAGE.md` → "Release cycle" for the full convention, including the hotfix path. This +supersedes the ad-hoc release pattern from issue #136. + +## Labels +- `module:harness` — routes this issue to the loop like any other harness issue. +- The owner still adds `planned` to move it into the work queue — same owner-only two-label workflow as + every other issue (see docs/USAGE.md → "Autonomous loop & the issue queue"). diff --git a/docs/USAGE.md b/docs/USAGE.md index 5ca1906..af5bdf0 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -485,6 +485,58 @@ reCode deploy-lag incident, issue #131). > after `/plugin marketplace update`. Caught 2026-07-06: a `hooks/hooks.json` schema fix merged to `main` but > didn't reach an already-installed consumer until the version string was bumped too. +## Release cycle +The maintainer note above says to bump `plugin.json`'s version "on every real change" — issue #176 +formalizes WHEN and HOW that bump actually happens for a planned batch of work, replacing the ad-hoc +process from issue #136 (closed; treat this section as its successor). + +**The milestone IS the release unit.** Each release milestone (e.g. "v0.3.0 — consumer rollout") contains +one special issue, titled `Release vX.Y.Z` (template: `.github/ISSUE_TEMPLATE/release.md`), whose body +declares **"Blocked by #…, #…, #…"** listing every OTHER open issue currently in that milestone. This +reuses issue #97's blocking-graph gate — already load-bearing for the loop's ADVANCE step (see +"Autonomous loop & the issue queue" above) — as the release gate itself: **no new state machine.** The +census will not advance the release issue until every sibling in the milestone has merged, so as long as +that "Blocked by" line is kept current (a one-line `bot-gh.sh issue list --milestone ... --state open` +query — see the template), the release issue self-schedules for whenever the milestone is actually done. + +**The cut.** Once unblocked and labelled `planned`, the release issue is implemented like any other issue +— its implementer runs `bash .claude/scripts/release.sh vX.Y.Z --issue `, which: +1. bumps the `version` field in both `.claude/.claude-plugin/plugin.json` and + `.claude/.claude-plugin/marketplace.json`, +2. generates a dated, Keep-a-Changelog-style entry in `.claude/.claude-plugin/CHANGELOG.md` from merged PR + titles since the last git tag (falling back to the full commit history when there is no prior tag — + e.g. this repo's very first release), +3. tags `vX.Y.Z`, commits the bumps + changelog, and pushes both, +4. "publishes" per the runbook above — the driver cannot run the interactive `/plugin marketplace update` + slash command, and package-publish commands are policy-denied, so it prints that command as the + **manual owner follow-up** instead of attempting it, +5. closes the milestone, +6. auto-files a `Rollout & feedback: vX.Y.Z` companion issue (below). + +Supports `--dry-run` for testability: it performs the real file transforms (version bump + changelog +generation) so they can be inspected, but only PRINTS every git/gh side effect (tag, push, milestone +close, issue file) instead of executing it — see `.claude/scripts/release.test.sh`. + +**The rollout & feedback companion.** The auto-filed `Rollout & feedback: vX.Y.Z` issue is deliberately +**census-invisible**: no milestone, and no `planned` label, until the owner triages it — the loop will +never pick it up on its own. It carries: +- a consumer-sync checklist for reDeploy and reDeFi via `/orchestrator:sync` (sync v2's offline checks — + deploy-lag, environment, labels, observability — issue #141), +- test-focus areas derived from the released milestone's issue titles, +- a running section to accumulate feedback back-references as consumers report issues. + +**Label conventions.** The companion issue is labelled `feedback`; incoming feedback that traces back to a +specific consumer is additionally tagged `from:redeploy` or `from:redefi` (both labels, plus `feedback` +itself, are created idempotently by `release.sh` at file time if they don't already exist). The version +being rolled out is always noted in the issue body. Owner triage — adding `planned` and, if warranted, a +milestone — is what turns a piece of feedback into loop-eligible work; until then it just sits in the +inbox. + +**Hotfix path.** A bug found after release doesn't reopen the old milestone — file a `vX.Y.Z+1` +micro-milestone containing just that one bug issue, label it `planned`, let the loop fix it normally, then +cut the patch release through the exact same `Release vX.Y.Z+1` → `release.sh` machinery above (its +"Blocked by" list will just be the one bug issue). + ## Merge discipline - **`pr-per-agent`** (default): each worker → branch → PR. You (or a merge step) integrate; conflicts surface at PR time. Cleanest/auditable. From 2ae1eac79051c5bad515f2cadd2c4ad3d74caf2c Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:13:57 +0200 Subject: [PATCH 2/2] fix(release): remove phantom blocking edges, cover the real mutating path, fix commit gpgsign (issue #176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix round addressing three review-blocking defects: 1. release.md rewritten so no "blocked by" phrase is ever adjacent to a real digit anywhere except the one editable Blocked-by field — prose issue references drop the leading `#` (issue 97/136/141) and the worked example uses non-numeric #AAA/#BBB/#CCC placeholders. Verified empty template yields blockedBy=[] via `cockpit.sh --parse-blocking`, and a filled-in field still extracts exactly the real numbers. 2. release.test.sh gains real (non-dry-run) coverage: a local `git init --bare` origin stands in for the network and a fake bot-gh.sh stub (installed at the fixture's own .claude/scripts/bot-gh.sh, since release.sh's gh() always shells out to its sibling, never $PATH) stands in for gh. Asserts the version-bump commit landed, the tag was created, the push reached the bare origin (branch + tag), and the milestone-close / label-create / rollout-issue-create gh calls fired with the right args (including the rollout body's derived test-focus titles) — plus the no-`--issue` warn-and-skip variant. 44 checks total (was 27). 3. The real commit step now disables gpgsign the same way the tag step already did (`git -c commit.gpgSign=false commit ...`), so an unattended run with `commit.gpgsign=true` and no agent configured can't hang. Co-Authored-By: Claude Sonnet 5 --- .claude/scripts/release.sh | 12 +- .claude/scripts/release.test.sh | 177 ++++++++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/release.md | 25 +++-- 3 files changed, 197 insertions(+), 17 deletions(-) diff --git a/.claude/scripts/release.sh b/.claude/scripts/release.sh index be5c7e4..1012893 100755 --- a/.claude/scripts/release.sh +++ b/.claude/scripts/release.sh @@ -214,12 +214,14 @@ fi # --- step 3: commit, tag, push ----------------------------------------------- git -C "$root" add -- "$plugin_json" "$marketplace_json" "$changelog_md" \ || { echo "release.sh: git add failed" >&2; exit 1; } -git -C "$root" commit -q -m "release: $VERSION" \ +# -c commit.gpgSign=false / tag.gpgSign=false: override an operator's global +# gpgsign defaults for THESE invocations only (never writes to any gitconfig) +# — an unattended release commit/tag doesn't need GPG signing, and an +# operator with `commit.gpgsign=true`/`tag.gpgsign=true` and no agent +# configured would otherwise hang here waiting on a signature. Applied +# symmetrically to both the commit and the tag. +git -C "$root" -c commit.gpgSign=false commit -q -m "release: $VERSION" \ || { echo "release.sh: git commit failed" >&2; exit 1; } -# -c tag.gpgSign=false: override an operator's global gpgsign default for -# THIS invocation only (never writes to any gitconfig) — a lightweight -# release tag doesn't need GPG signing, and forcing it here means this -# script works unattended even when signing isn't set up / has no agent. git -C "$root" -c tag.gpgSign=false tag "$VERSION" \ || { echo "release.sh: git tag failed" >&2; exit 1; } git -C "$root" push origin HEAD \ diff --git a/.claude/scripts/release.test.sh b/.claude/scripts/release.test.sh index 95daa9f..6f3ea90 100755 --- a/.claude/scripts/release.test.sh +++ b/.claude/scripts/release.test.sh @@ -30,6 +30,20 @@ # 7. an optional "## [Unreleased]" scaffold section at the top of the # CHANGELOG (issue #176's optional CHANGELOG addition) stays ABOVE the # newly-generated dated section rather than getting pushed below it. +# 8. the REAL (non-dry-run) mutating path: a local `git init --bare` origin +# stands in for the network (mirrors worktree-cleanup.test.sh) and a +# fake bot-gh.sh stub installed at the fixture's own +# .claude/scripts/bot-gh.sh stands in for gh entirely (mirrors +# pr-rebase.test.sh's convention — release.sh's `gh()` always shells out +# to its own sibling bot-gh.sh, never $PATH). Asserts the version-bump +# commit landed, the tag was created, the push actually reached the bare +# origin (branch + tag), and the milestone-close / label-create / +# rollout-issue-create gh calls fired with the right args (including the +# rollout body's milestone-derived test-focus titles) — by inspecting +# the stub's own call log. +# 9. the no-`--issue` real-path variant: milestone-close warns and skips +# (never crashes, never guesses), while the rollout companion issue is +# still filed with a graceful "titles unavailable" test-focus fallback. # # Exit 0 on success, non-zero if any assertion fails. Runnable bare: # bash .claude/scripts/release.test.sh @@ -125,6 +139,67 @@ run_release() { ( cd "$repo" && bash "$repo/.claude/scripts/release.sh" "$@" ) } +install_fake_bot_gh() { + # $1 = repo dir. Installs a FAKE bot-gh.sh at the fixture's + # .claude/scripts/bot-gh.sh — release.sh's own `gh() { bash + # "$script_dir/bot-gh.sh" "$@"; }` always shells out to its OWN sibling + # bot-gh.sh (resolved from release.sh's own location), never $PATH, so this + # is the one place a stub must live to intercept every gh call (mirrors + # pr-rebase.test.sh's / pr-comment-fix.test.sh's fake-bot-gh.sh convention). + # Logs every invocation, verbatim, to $RELEASE_TEST_GH_LOG (an env var the + # caller sets — never hardcoded, so parallel scenarios use separate logs) + # so the test can assert milestone-close / label-create / issue-create + # fired with the expected args. No real network/gh call, ever. + local repo="$1" + cat > "$repo/.claude/scripts/bot-gh.sh" <<'STUB' +#!/usr/bin/env bash +log_file="${RELEASE_TEST_GH_LOG:?RELEASE_TEST_GH_LOG must be set by the test}" +printf '%s\n' "$*" >> "$log_file" +case "$1" in + repo) + echo "acme/repo" + ;; + issue) + case "$2" in + view) + if printf '%s\n' "$*" | grep -q '\.milestone\.number'; then + echo "77" + elif printf '%s\n' "$*" | grep -q '\.milestone\.title'; then + echo "Release v1.2.0" + fi + ;; + list) + printf '%s\n' "Fix widget alignment" "Add gizmo support" + ;; + create) + echo "https://github.com/acme/repo/issues/999" + ;; + *) + echo "fake-bot-gh.sh: unexpected issue subcommand: $*" >&2 + exit 1 + ;; + esac + ;; + api) + exit 0 + ;; + *) + echo "fake-bot-gh.sh: unexpected command: $*" >&2 + exit 1 + ;; +esac +STUB + chmod +x "$repo/.claude/scripts/bot-gh.sh" +} + +run_release_real() { + # $1 = repo dir, $2 = gh-call log file (absolute path), rest = args to + # release.sh. Exports RELEASE_TEST_GH_LOG for the fake bot-gh.sh stub above + # — install_fake_bot_gh must have already been run against $1. + local repo="$1" log="$2"; shift 2 + ( cd "$repo" && RELEASE_TEST_GH_LOG="$log" bash "$repo/.claude/scripts/release.sh" "$@" ) +} + # ============================================================================= # Scenario A: WITH a prior tag — fake merge commits after the tag. # ============================================================================= @@ -283,6 +358,108 @@ check "scenario E: new dated section still lands above the prior 1.0.0 section" [ -n "$n1" ] && [ -n "$n0" ] && [ "$n1" -lt "$n0" ] ' _ "$changelog_e" "$work" +# ============================================================================= +# Scenario F: the REAL (non-dry-run) mutating path — a local `git init --bare` +# origin stands in for the network (mirrors worktree-cleanup.test.sh's own +# bare-repo-as-origin pattern) and a fake bot-gh.sh stub (installed above) +# stands in for gh entirely (mirrors pr-rebase.test.sh's convention). Asserts +# every one of release.sh's real side effects actually happened: (a) the +# version-bump commit landed, (b) the vX.Y.Z tag was created, (c) the push +# actually reached the bare origin (branch AND tag), (d) the milestone-close, +# label-create, and rollout-issue-create gh calls fired with the correct +# args — including the rollout issue body carrying the milestone-derived +# test-focus titles. +# ============================================================================= +origin_bare="$work/origin.git" +git init -q --bare "$origin_bare" + +repo_f="$work/repo-f" +seed_fixture "$repo_f" +git -C "$repo_f" remote add origin "$origin_bare" +git -C "$repo_f" push -q origin main + +install_fake_bot_gh "$repo_f" +gh_log_f="$work/gh-calls-f.log" +: > "$gh_log_f" + +out_f="$(run_release_real "$repo_f" "$gh_log_f" v1.2.0 --issue 501 --repo acme/repo 2>&1)" +rc_f=$? + +check "scenario F: exits 0" [ "$rc_f" -eq 0 ] + +check "scenario F: (a) version-bump commit landed (HEAD subject)" bash -c ' + [ "$(git -C "$1" log -1 --pretty=%s)" = "release: v1.2.0" ] +' _ "$repo_f" +check "scenario F: (a) plugin.json version bump is IN the commit, not just on disk" bash -c ' + git -C "$1" show HEAD:.claude/.claude-plugin/plugin.json | grep -q "\"version\": \"1.2.0\"" +' _ "$repo_f" +check "scenario F: (a) marketplace.json version bump is IN the commit" bash -c ' + git -C "$1" show HEAD:.claude/.claude-plugin/marketplace.json | grep -q "\"version\": \"1.2.0\"" +' _ "$repo_f" + +check "scenario F: (b) tag v1.2.0 was actually created locally" bash -c 'git -C "$1" tag --list | grep -qx v1.2.0' _ "$repo_f" + +check "scenario F: (c) push actually reached the bare origin (main branch commit)" bash -c ' + [ "$(git -C "$1" log -1 --pretty=%s refs/heads/main)" = "release: v1.2.0" ] +' _ "$origin_bare" +check "scenario F: (c) push actually reached the bare origin (tag)" bash -c 'git -C "$1" tag --list | grep -qx v1.2.0' _ "$origin_bare" + +check "scenario F: (d) milestone-close API call fired against the resolved milestone number" bash -c ' + grep -qF "api -X PATCH repos/acme/repo/milestones/77 -f state=closed" "$1" +' _ "$gh_log_f" +check "scenario F: (d) all three rollout labels created idempotently via gh api" bash -c ' + grep -qF "labels -f name=feedback" "$1" && + grep -qF "labels -f name=from:redeploy" "$1" && + grep -qF "labels -f name=from:redefi" "$1" +' _ "$gh_log_f" +check "scenario F: (d) rollout issue created with the right title and feedback label" bash -c ' + grep -qF "issue create" "$1" && + grep -qF "Rollout & feedback: v1.2.0" "$1" && + grep -qF -- "--label feedback" "$1" +' _ "$gh_log_f" +check "scenario F: rollout issue body carries the milestone-derived test-focus titles" bash -c ' + grep -qF "Fix widget alignment" "$1" && grep -qF "Add gizmo support" "$1" +' _ "$gh_log_f" + +# ============================================================================= +# Scenario G: REAL path with NO --issue given — the milestone-close step must +# warn-and-skip (never crash, never guess a milestone), while the rollout +# companion issue is STILL filed (it does not depend on milestone +# resolution), falling back to the "titles unavailable" test-focus note. +# ============================================================================= +origin_bare_g="$work/origin-g.git" +git init -q --bare "$origin_bare_g" + +repo_g="$work/repo-g" +seed_fixture "$repo_g" +git -C "$repo_g" remote add origin "$origin_bare_g" +git -C "$repo_g" push -q origin main + +install_fake_bot_gh "$repo_g" +gh_log_g="$work/gh-calls-g.log" +: > "$gh_log_g" + +out_g="$(run_release_real "$repo_g" "$gh_log_g" v1.3.0 --repo acme/repo 2>&1)" +rc_g=$? + +check "scenario G: exits 0 even though no --issue was given" [ "$rc_g" -eq 0 ] +check "scenario G: warns that no milestone was resolved / not closed automatically" bash -c ' + printf "%s" "$1" | grep -qi "no milestone resolved" +' _ "$out_g" +check "scenario G: milestone-close API call was NEVER made (nothing to resolve it from)" bash -c ' + ! grep -q "milestones/" "$1" +' _ "$gh_log_g" +check "scenario G: rollout issue is STILL filed regardless of milestone resolution" bash -c ' + grep -qF "issue create" "$1" && grep -qF "Rollout & feedback: v1.3.0" "$1" +' _ "$gh_log_g" +check "scenario G: test-focus list falls back to the titles-unavailable note" bash -c ' + grep -qF "milestone issue titles unavailable" "$1" +' _ "$gh_log_g" +check "scenario G: version was still bumped and pushed for real (only milestone-close is skipped)" bash -c ' + [ "$(git -C "$1" log -1 --pretty=%s refs/heads/main)" = "release: v1.3.0" ] && + git -C "$1" tag --list | grep -qx v1.3.0 +' _ "$origin_bare_g" + echo "" if [ "$fail" -eq 0 ]; then echo "release.test.sh: PASS ($ok checks)" diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md index 70c2234..38a94c2 100644 --- a/.github/ISSUE_TEMPLATE/release.md +++ b/.github/ISSUE_TEMPLATE/release.md @@ -7,26 +7,27 @@ labels: module:harness ## What This issue is the release gate for milestone **">**. It reuses -the "Blocked by #N" blocking-graph convention (issue #97) as the ENTIRE release gate — no new state -machine: the loop's census will not advance this issue until every other issue in the milestone is +issue 97's blocking-graph convention as the ENTIRE release gate — no new state machine: the field below, +once filled in, tells the loop's census this issue must wait until every other issue in the milestone is closed. -**Blocked by #…, #…, #…** +**Blocked by #…, #…, #…** -## Keeping the Blocked-by list current -Whenever an issue is added to (or removed from) this milestone, refresh the line above so it still lists +## Keeping that field current +Whenever an issue is added to (or removed from) this milestone, refresh the field above so it still lists every OTHER open issue in the milestone (never this issue itself). A quick helper query: ```bash bash .claude/scripts/bot-gh.sh issue list --milestone "" --state open --json number,title ``` -Paste the resulting issue numbers into the "Blocked by" line, comma-separated (e.g. `Blocked by #101, #102, #103`). -An empty/absent "Blocked by" line (all siblings closed) makes this issue immediately eligible once it -also carries `planned`. +Paste the resulting issue numbers into that field, comma-separated, prefixed with a literal `#` each — for +example, if the query returns issues AAA, BBB, and CCC, the field would read: "#AAA, #BBB, #CCC" (with +`#`, followed directly by each real number). An empty/absent field (all siblings closed) makes this issue +immediately eligible once it also carries `planned`. -## What happens when this issue is advanced -Once every "Blocked by" issue above is closed, this issue becomes a normal `planned` + `module:*` +## What happens once this issue is unblocked and advanced +Once every issue named in the field above is closed, this issue becomes a normal `planned` + `module:*` candidate and the loop's ADVANCE step picks it up like any other issue. Its implementer runs the release driver: @@ -44,11 +45,11 @@ which: 5. closes this milestone, 6. auto-files a `Rollout & feedback: vX.Y.Z` issue — deliberately outside any milestone and without the `planned` label (a census-invisible feedback inbox) — with a consumer-sync checklist for reDeploy and - reDeFi (`/orchestrator:sync` v2, issue #141) and test-focus areas derived from this milestone's issue + reDeFi (`/orchestrator:sync` v2, issue 141) and test-focus areas derived from this milestone's issue titles. See `docs/USAGE.md` → "Release cycle" for the full convention, including the hotfix path. This -supersedes the ad-hoc release pattern from issue #136. +supersedes the ad-hoc release pattern from issue 136. ## Labels - `module:harness` — routes this issue to the loop like any other harness issue.