From 6174ed6ee8a2a17ef8e2a9953f936805fdeec09f Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 27 Aug 2026 06:50:01 +0000 Subject: [PATCH 1/7] fix: verify remote release promotion before success (#211) --- commands/do/release.md | 164 +++++++++++++++++++++++++++++++--- test/release-contract.test.js | 65 ++++++++++++++ 2 files changed, 216 insertions(+), 13 deletions(-) create mode 100644 test/release-contract.test.js diff --git a/commands/do/release.md b/commands/do/release.md index 235a439..b85c08b 100644 --- a/commands/do/release.md +++ b/commands/do/release.md @@ -160,10 +160,57 @@ Verification — self-check before proceeding (no user prompt needed): ## Open the Release PR -- Push the source branch to remote (it should already be up to date with the release commit) -- Create a PR from `{source}` → `{target}` (e.g., `main` → `release`) +- **Checkpoint 1 — source push.** Push the prepared source commit and verify the + forge reports the exact same commit before creating or reusing a PR. A successful + `git push` by itself is not proof that the remote ref was updated; empty, + malformed, or mismatched output is an incomplete release and must name + `Source push` as the first unverified checkpoint: ```bash - gh pr create --title "Release v{version}" --base {target} --head {source} --body "..." + git push -u origin "HEAD:refs/heads/{source}" + SOURCE_SHA="$(git rev-parse HEAD)" + REMOTE_SOURCE_SHA="$(git ls-remote --heads origin "refs/heads/{source}" | awk 'NF { print $1; exit }')" + if ! printf '%s\n' "$REMOTE_SOURCE_SHA" | grep -Eq '^[0-9a-f]{40}$' || [ "$REMOTE_SOURCE_SHA" != "$SOURCE_SHA" ]; then + echo "INCOMPLETE — Source push is unverified; expected $SOURCE_SHA, got ${REMOTE_SOURCE_SHA:-empty}. Preserve the prepared release state and retry." + exit 1 + fi + ``` +- **Checkpoint 2 — release PR.** Query all matching PRs for the current source SHA + before creating one. Reuse an open PR, or a merged PR whose head is still this + source SHA when an interrupted rerun already completed it; never create a + duplicate. Missing, empty, malformed, or ambiguous forge output is incomplete + and must name `Release PR` as the first unverified checkpoint. A closed, + unmerged PR is not reusable, so a later run may create a new PR for the newly + pushed source SHA: + ```bash + RELEASE_PRS_JSON="$(gh pr list --state all --base "{target}" --head "{source}" --limit 100 \ + --json number,state,headRefOid,baseRefName,headRefName,url,createdAt)" || { + echo "INCOMPLETE — Release PR is unverified; the forge query failed. Preserve the prepared release state and retry." + exit 1 + } + if ! printf '%s\n' "$RELEASE_PRS_JSON" | jq -e 'type == "array"' >/dev/null; then + echo "INCOMPLETE — Release PR is unverified; the forge returned empty or malformed data. Preserve the prepared release state and retry." + exit 1 + fi + MATCHING_RELEASE_PRS="$(printf '%s\n' "$RELEASE_PRS_JSON" | jq -c --arg sha "$SOURCE_SHA" \ + '[.[] | select(.headRefOid == $sha and (.state == "OPEN" or .state == "MERGED"))]')" + MATCHING_COUNT="$(printf '%s\n' "$MATCHING_RELEASE_PRS" | jq 'length')" + if [ "$MATCHING_COUNT" -gt 1 ]; then + echo "INCOMPLETE — Release PR is ambiguous; more than one open or merged PR matches $SOURCE_SHA. Preserve the prepared release state and investigate." + exit 1 + elif [ "$MATCHING_COUNT" -eq 1 ]; then + PR_NUMBER="$(printf '%s\n' "$MATCHING_RELEASE_PRS" | jq -r '.[0].number')" + PR_URL="$(printf '%s\n' "$MATCHING_RELEASE_PRS" | jq -r '.[0].url')" + else + PR_URL="$(gh pr create --title "Release v{version}" --base "{target}" --head "{source}" --body "...")" || { + echo "INCOMPLETE — Release PR is unverified; creation failed. Preserve the prepared release state and retry without creating another PR." + exit 1 + } + PR_NUMBER="${PR_URL##*/}" + if ! printf '%s\n' "$PR_NUMBER" | grep -Eq '^[0-9]+$'; then + echo "INCOMPLETE — Release PR is unverified; creation returned empty or malformed data. Preserve the prepared release state and retry." + exit 1 + fi + fi ``` - Title: `Release v{version}` (read version from package.json or equivalent) - Body: include the changelog content for this version if available, otherwise summarize commits since last release @@ -248,20 +295,111 @@ For `dirty` or `inconclusive`: ```bash gh pr merge --merge ``` -- Verify the merge succeeded: `gh pr view --json state,mergedAt` +- **Checkpoint 3 — merged release PR.** Do not infer completion from the merge + command's exit status. Read back all three remote fields and require a merged + state, a non-empty merge timestamp, and a non-empty merge commit. Empty, + malformed, timed-out, queued, or otherwise inconclusive output is incomplete; + name `Merged release PR` as the first unverified checkpoint and preserve the + prepared state: + ```bash + MERGE_JSON="$(gh pr view "$PR_NUMBER" --json state,mergedAt,mergeCommit)" || { + echo "INCOMPLETE — Merged release PR is unverified; the forge query failed. Preserve the prepared release state and retry." + exit 1 + } + if ! printf '%s\n' "$MERGE_JSON" | jq -e \ + 'type == "object" and .state == "MERGED" and (.mergedAt | type == "string") and (.mergedAt | length > 0) and (.mergeCommit.oid | type == "string") and (.mergeCommit.oid | length > 0)' >/dev/null; then + echo "INCOMPLETE — Merged release PR is unverified; state, mergedAt, or mergeCommit is missing or not MERGED. Preserve the prepared release state and retry." + exit 1 + fi + MERGE_COMMIT="$(printf '%s\n' "$MERGE_JSON" | jq -r '.mergeCommit.oid')" + ``` ## Post-Merge -1. **Tag the release** on the target branch to trigger the publish workflow. Refuse to overwrite an existing tag — a colliding `v{version}` usually means the version bump heuristic picked an already-released value or a prior partial release left state behind, both of which need human attention before force-tagging would be safe: +1. **Checkpoint 4 — target-branch tree.** Fetch the target and verify its remote + ref is a real commit with a tree that contains the merged release commit. This + proves the source-to-target promotion landed; checking only PR state would miss + a queued or otherwise incomplete target update. If any command is empty, + malformed, timed out, or fails, report `Target branch tree` as the first + unverified checkpoint and do not create or reuse a tag: + ```bash + git fetch origin "{target}" || { + echo "INCOMPLETE — Target branch tree is unverified; fetching {target} failed. Preserve the prepared release state and retry." + exit 1 + } + TARGET_SHA="$(git ls-remote --heads origin "refs/heads/{target}" | awk 'NF { print $1; exit }')" + if ! printf '%s\n' "$TARGET_SHA" | grep -Eq '^[0-9a-f]{40}$' \ + || ! git cat-file -e "$TARGET_SHA^{tree}" 2>/dev/null \ + || ! git merge-base --is-ancestor "$MERGE_COMMIT" "$TARGET_SHA"; then + echo "INCOMPLETE — Target branch tree is unverified; expected {target} to contain $MERGE_COMMIT, got ${TARGET_SHA:-empty}. Preserve the prepared release state and retry." + exit 1 + fi + ``` +2. **Checkpoint 5 — version tag.** Publish `v{version}` only when it is absent; + on a rerun, reuse it only if its remotely resolved commit is exactly + `TARGET_SHA`. Never force-push or overwrite a conflicting tag. A failed push + may have raced with another successful publisher, so re-read the tag before + reporting failure; empty, malformed, mismatched, or inconclusive output names + `Version tag` as the first unverified checkpoint: + ```bash + TAG_SHA="$(git ls-remote origin "refs/tags/v{version}^{}" | awk 'NF { print $1; exit }')" + if ! printf '%s\n' "$TAG_SHA" | grep -Eq '^[0-9a-f]{40}$'; then + TAG_SHA="$(git ls-remote origin "refs/tags/v{version}" | awk 'NF { print $1; exit }')" + fi + if printf '%s\n' "$TAG_SHA" | grep -Eq '^[0-9a-f]{40}$'; then + if [ "$TAG_SHA" != "$TARGET_SHA" ]; then + echo "INCOMPLETE — Version tag v{version} points to $TAG_SHA, not target commit $TARGET_SHA; refusing to overwrite it." + exit 1 + fi + else + if git rev-parse -q --verify "refs/tags/v{version}" >/dev/null 2>&1; then + LOCAL_TAG_SHA="$(git rev-parse "v{version}^{commit}")" + if [ "$LOCAL_TAG_SHA" != "$TARGET_SHA" ]; then + echo "INCOMPLETE — Local version tag v{version} points to $LOCAL_TAG_SHA, not target commit $TARGET_SHA; refusing to overwrite it." + exit 1 + fi + else + git tag "v{version}" "$TARGET_SHA" || { + echo "INCOMPLETE — Version tag is unverified; local tag creation failed. Preserve the prepared release state and retry." + exit 1 + } + fi + git push origin "refs/tags/v{version}" || true + TAG_SHA="$(git ls-remote origin "refs/tags/v{version}^{}" | awk 'NF { print $1; exit }')" + if ! printf '%s\n' "$TAG_SHA" | grep -Eq '^[0-9a-f]{40}$'; then + TAG_SHA="$(git ls-remote origin "refs/tags/v{version}" | awk 'NF { print $1; exit }')" + fi + if [ "$TAG_SHA" != "$TARGET_SHA" ]; then + echo "INCOMPLETE — Version tag is unverified; expected $TARGET_SHA, got ${TAG_SHA:-empty}. Preserve the prepared release state and retry." + exit 1 + fi + fi + ``` +3. **Checkpoint 6 — GitHub Release.** The release workflow may need time to + publish after the tag. Poll for a published, non-draft, non-prerelease release + whose tag is `v{version}` for a bounded period. Missing, empty, malformed, or + timed-out output is incomplete, not success; name `GitHub Release` as the first + unverified checkpoint and preserve the prepared release state: ```bash - git fetch origin {target} 'refs/tags/*:refs/tags/*' - if git rev-parse -q --verify "refs/tags/v{version}" >/dev/null; then - echo "Tag v{version} already exists. Aborting tag step. Investigate (rerun version bump? force-tag manually?) before retrying." + RELEASE_JSON="" + for ATTEMPT in $(seq 1 30); do + RELEASE_JSON="$(gh release view "v{version}" --json tagName,isDraft,isPrerelease,publishedAt 2>/dev/null || true)" + if printf '%s\n' "$RELEASE_JSON" | jq -e \ + 'type == "object" and .tagName == "v{version}" and .isDraft == false and .isPrerelease == false and (.publishedAt | type == "string") and (.publishedAt | length > 0)' >/dev/null 2>&1; then + break + fi + RELEASE_JSON="" + sleep 10 + done + if ! printf '%s\n' "$RELEASE_JSON" | jq -e \ + 'type == "object" and .tagName == "v{version}" and .isDraft == false and .isPrerelease == false and (.publishedAt | type == "string") and (.publishedAt | length > 0)' >/dev/null 2>&1; then + echo "INCOMPLETE — GitHub Release is unverified after the bounded wait; preserve the prepared release state and retry." exit 1 fi - git tag v{version} origin/{target} - git push origin v{version} ``` -2. **Switch back to the source branch** locally: `git checkout {source} && git pull --rebase --autostash` -3. **Report the final status** including version, PR URL, tag, and merge state -4. Remind the user to check for the GitHub release once CI completes (if the project uses automated releases) +4. **Only after all six checkpoints pass** report the release as complete, including + the source SHA, PR URL and merged state, target SHA, tag SHA, and published + GitHub Release. A local prepared commit, a successful PR merge command, or a + pushed tag is never sufficient on its own. Switch back to the source branch + locally only after the remote verification succeeds: + `git checkout {source} && git pull --rebase --autostash`. diff --git a/test/release-contract.test.js b/test/release-contract.test.js new file mode 100644 index 0000000..520278d --- /dev/null +++ b/test/release-contract.test.js @@ -0,0 +1,65 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const body = fs.readFileSync(path.join(__dirname, '..', 'commands', 'do', 'release.md'), 'utf8'); + +describe('/do:release remote promotion contracts', () => { + it('requires ordered remote checkpoints before reporting completion', () => { + const checkpoints = [ + 'Checkpoint 1 — source push', + 'Checkpoint 2 — release PR', + 'Checkpoint 3 — merged release PR', + 'Checkpoint 4 — target-branch tree', + 'Checkpoint 5 — version tag', + 'Checkpoint 6 — GitHub Release', + ]; + let previous = -1; + for (const checkpoint of checkpoints) { + const index = body.indexOf(checkpoint); + assert.ok(index > previous, `${checkpoint} must follow the previous checkpoint`); + previous = index; + } + assert.match(body, /Only after all six checkpoints pass.*report the release as complete/s); + assert.match(body, /[Ee]mpty,\s+malformed, timed-out, queued, or otherwise inconclusive output is incomplete/); + }); + + it('verifies the source push and avoids duplicate PRs on reruns', () => { + assert.match(body, /git push -u origin "HEAD:refs\/heads\/\{source\}"/); + assert.match(body, /REMOTE_SOURCE_SHA="\$\(git ls-remote --heads origin "refs\/heads\/\{source\}"/); + assert.match(body, /REMOTE_SOURCE_SHA.*\[ "\$REMOTE_SOURCE_SHA" != "\$SOURCE_SHA" \]/s); + assert.match(body, /gh pr list --state all --base "\{target\}" --head "\{source\}"/); + assert.match(body, /select\(\.headRefOid == \$sha and \(\.state == "OPEN" or \.state == "MERGED"\)\)/); + assert.match(body, /never create a\s+duplicate/); + }); + + it('requires mergedAt and mergeCommit instead of trusting merge exit status', () => { + assert.match(body, /gh pr view "\$PR_NUMBER" --json state,mergedAt,mergeCommit/); + assert.match(body, /\.state == "MERGED"/); + assert.match(body, /\.mergedAt \| type == "string"/); + assert.match(body, /\.mergeCommit\.oid \| type == "string"/); + assert.match(body, /MERGE_COMMIT="\$\(.*\.mergeCommit\.oid/s); + }); + + it('verifies target ancestry and makes tag publication idempotent', () => { + assert.match(body, /git ls-remote --heads origin "refs\/heads\/\{target\}"/); + assert.match(body, /git cat-file -e "\$TARGET_SHA\^\{tree\}"/); + assert.match(body, /git merge-base --is-ancestor "\$MERGE_COMMIT" "\$TARGET_SHA"/); + assert.match(body, /refs\/tags\/v\{version\}\^\{/); + assert.match(body, /refusing to overwrite it/); + assert.match(body, /git push origin "refs\/tags\/v\{version\}" \|\| true/); + assert.match(body, /expected \$TARGET_SHA, got \$\{TAG_SHA:-empty\}/); + }); + + it('polls for a published GitHub Release and fails closed on timeout', () => { + assert.match(body, /for ATTEMPT in \$\(seq 1 30\)/); + assert.match(body, /gh release view "v\{version\}" --json tagName,isDraft,isPrerelease,publishedAt/); + assert.match(body, /\.tagName == "v\{version\}"/); + assert.match(body, /\.isDraft == false/); + assert.match(body, /\.isPrerelease == false/); + assert.match(body, /GitHub Release is unverified after the bounded wait/); + }); +}); From 2e3c84d6827d519b72f771953ae084801b86d614 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 27 Aug 2026 07:05:09 +0000 Subject: [PATCH 2/7] fix: make release recovery idempotent (#211) --- commands/do/release.md | 67 +++++++++++++++++++++++++++++------ test/release-contract.test.js | 15 +++++++- 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/commands/do/release.md b/commands/do/release.md index b85c08b..9922ff6 100644 --- a/commands/do/release.md +++ b/commands/do/release.md @@ -91,8 +91,39 @@ Print the detected workflow: `Detected release flow: {source} → {target}` 4. **Run tests** — execute the project's test suite (per project conventions already in context, or check package.json) 5. **Run build** — execute the project's build command if one exists +## Recover Prepared Release State + +Before determining a new version, look for an existing release-preparation commit +on the current source history. An interrupted run must resume the prepared version, +not bump it again: + +```bash +PREVIOUS_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)" +if [ -n "$PREVIOUS_TAG" ]; then + PREPARED_RELEASE="$(git log --format='%H%x09%s' "$PREVIOUS_TAG..HEAD" | awk -F '\t' '$2 ~ /^chore: release v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')" +else + PREPARED_RELEASE="$(git log --format='%H%x09%s' | awk -F '\t' '$2 ~ /^chore: release v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')" +fi +if [ -n "$PREPARED_RELEASE" ]; then + PREPARED_RELEASE_SHA="$(printf '%s\n' "$PREPARED_RELEASE" | cut -f1)" + VERSION="$(printf '%s\n' "$PREPARED_RELEASE" | sed -E 's/.*release v//')" + echo "Resuming prepared release v${VERSION} at ${PREPARED_RELEASE_SHA}; skipping version bump and changelog generation." +else + echo "No prepared release commit found; determine a new version and finalize its changelog below." +fi +``` + +When `PREPARED_RELEASE` is non-empty, verify that the checked-out package version +is `{version}` and continue directly to **Local Code Review**. Do not determine a +new bump, rewrite release notes, or create another `chore: release` commit. If the +package version does not match the prepared commit's version, fail closed and +preserve the prepared state for investigation. + ## Determine Version and Finalize Changelog +Skip this entire section when `PREPARED_RELEASE` is non-empty; it is only for a +release with no prepared release commit. + 1. **Determine version bump** from commits since the last git tag: - Scan commit messages for conventional commit prefixes (also check each commit's body/footer for `BREAKING CHANGE:` — a recognized way to signal a breaking change without the prefix): - `breaking:`, any prefix with a `!` (e.g. `feat!:`, `fix!:`, `refactor!:`), or a `BREAKING CHANGE:` footer → **major** bump @@ -200,6 +231,7 @@ Verification — self-check before proceeding (no user prompt needed): elif [ "$MATCHING_COUNT" -eq 1 ]; then PR_NUMBER="$(printf '%s\n' "$MATCHING_RELEASE_PRS" | jq -r '.[0].number')" PR_URL="$(printf '%s\n' "$MATCHING_RELEASE_PRS" | jq -r '.[0].url')" + PR_STATE="$(printf '%s\n' "$MATCHING_RELEASE_PRS" | jq -r '.[0].state')" else PR_URL="$(gh pr create --title "Release v{version}" --base "{target}" --head "{source}" --body "...")" || { echo "INCOMPLETE — Release PR is unverified; creation failed. Preserve the prepared release state and retry without creating another PR." @@ -210,6 +242,7 @@ Verification — self-check before proceeding (no user prompt needed): echo "INCOMPLETE — Release PR is unverified; creation returned empty or malformed data. Preserve the prepared release state and retry." exit 1 fi + PR_STATE="OPEN" fi ``` - Title: `Release v{version}` (read version from package.json or equivalent) @@ -220,6 +253,10 @@ Verification — self-check before proceeding (no user prompt needed): ## Run the Review Loop +If the selected PR already has `PR_STATE=MERGED`, skip this section entirely. +Do not request another review or treat an already-merged PR as an open merge +candidate; set `OVERALL_STATUS=clean` for the post-merge verification path. + **If `REVIEW_AGENTS` is empty** (no `--review-with` was passed), skip this entire section — no external review loop runs. The Local Code Review gate above plus the passing build/tests are the merge gate; set `OVERALL_STATUS=clean` (no-review path) and proceed to the merge section. The Copilot-specific and local-agent-specific merge checks below do not apply when no reviewer ran. Otherwise, hand off to the **multi-reviewer loop** with the parsed inputs: @@ -289,6 +326,11 @@ For `dirty` or `inconclusive`: ### Merging (after all checks above pass) +If `PR_STATE=MERGED`, skip the CI gate and merge command below and continue +directly to **Checkpoint 3**. Otherwise, run the gate and merge command. This +conditional is required for an interrupted rerun to recover from a merge that +already succeeded remotely. + - **Gate on required CI first.** If the repo has required checks on the target branch, watch them in-session before merging: `gh pr checks --required --watch --fail-fast`. (If `gh` reports no required checks, this gate is vacuously satisfied — merge directly.) - On a required-check **failure**, apply the **CI flake handling** routine — one conservative re-run on the same commit (see `~/.claude/lib/ci-flake-handling.md` and the inlined copy above). If the same SHA passes on the single re-run, treat it as a flake and proceed (logging which check flaked); if it fails again, **abort the release merge** and report which check failed. A release must never merge over a real red. - Once confirmed clean, merge: @@ -312,6 +354,7 @@ For `dirty` or `inconclusive`: exit 1 fi MERGE_COMMIT="$(printf '%s\n' "$MERGE_JSON" | jq -r '.mergeCommit.oid')" + RELEASE_TREE="$(git rev-parse "$MERGE_COMMIT^{tree}")" ``` ## Post-Merge @@ -336,8 +379,10 @@ For `dirty` or `inconclusive`: fi ``` 2. **Checkpoint 5 — version tag.** Publish `v{version}` only when it is absent; - on a rerun, reuse it only if its remotely resolved commit is exactly - `TARGET_SHA`. Never force-push or overwrite a conflicting tag. A failed push + on a rerun, reuse it only if its remotely resolved commit has the exact + `RELEASE_TREE` from the merged release commit. The target branch may advance + with workflow housekeeping after the merge, so do not use its moving tip as + the tag identity. Never force-push or overwrite a conflicting tag. A failed push may have raced with another successful publisher, so re-read the tag before reporting failure; empty, malformed, mismatched, or inconclusive output names `Version tag` as the first unverified checkpoint: @@ -347,19 +392,20 @@ For `dirty` or `inconclusive`: TAG_SHA="$(git ls-remote origin "refs/tags/v{version}" | awk 'NF { print $1; exit }')" fi if printf '%s\n' "$TAG_SHA" | grep -Eq '^[0-9a-f]{40}$'; then - if [ "$TAG_SHA" != "$TARGET_SHA" ]; then - echo "INCOMPLETE — Version tag v{version} points to $TAG_SHA, not target commit $TARGET_SHA; refusing to overwrite it." + TAG_TREE="$(git rev-parse "$TAG_SHA^{tree}" 2>/dev/null || true)" + if [ "$TAG_TREE" != "$RELEASE_TREE" ]; then + echo "INCOMPLETE — Version tag v{version} points to tree ${TAG_TREE:-empty}, not merged release tree $RELEASE_TREE; refusing to overwrite it." exit 1 fi else if git rev-parse -q --verify "refs/tags/v{version}" >/dev/null 2>&1; then - LOCAL_TAG_SHA="$(git rev-parse "v{version}^{commit}")" - if [ "$LOCAL_TAG_SHA" != "$TARGET_SHA" ]; then - echo "INCOMPLETE — Local version tag v{version} points to $LOCAL_TAG_SHA, not target commit $TARGET_SHA; refusing to overwrite it." + LOCAL_TAG_TREE="$(git rev-parse "v{version}^{tree}" 2>/dev/null || true)" + if [ "$LOCAL_TAG_TREE" != "$RELEASE_TREE" ]; then + echo "INCOMPLETE — Local version tag v{version} points to tree ${LOCAL_TAG_TREE:-empty}, not merged release tree $RELEASE_TREE; refusing to overwrite it." exit 1 fi else - git tag "v{version}" "$TARGET_SHA" || { + git tag "v{version}" "$MERGE_COMMIT" || { echo "INCOMPLETE — Version tag is unverified; local tag creation failed. Preserve the prepared release state and retry." exit 1 } @@ -369,8 +415,9 @@ For `dirty` or `inconclusive`: if ! printf '%s\n' "$TAG_SHA" | grep -Eq '^[0-9a-f]{40}$'; then TAG_SHA="$(git ls-remote origin "refs/tags/v{version}" | awk 'NF { print $1; exit }')" fi - if [ "$TAG_SHA" != "$TARGET_SHA" ]; then - echo "INCOMPLETE — Version tag is unverified; expected $TARGET_SHA, got ${TAG_SHA:-empty}. Preserve the prepared release state and retry." + TAG_TREE="$(git rev-parse "$TAG_SHA^{tree}" 2>/dev/null || true)" + if [ "$TAG_TREE" != "$RELEASE_TREE" ]; then + echo "INCOMPLETE — Version tag is unverified; expected merged release tree $RELEASE_TREE, got ${TAG_TREE:-empty}. Preserve the prepared release state and retry." exit 1 fi fi diff --git a/test/release-contract.test.js b/test/release-contract.test.js index 520278d..9285872 100644 --- a/test/release-contract.test.js +++ b/test/release-contract.test.js @@ -36,6 +36,16 @@ describe('/do:release remote promotion contracts', () => { assert.match(body, /never create a\s+duplicate/); }); + it('resumes prepared releases before version bumping and merged PRs after review', () => { + const recovery = body.indexOf('## Recover Prepared Release State'); + const determineVersion = body.indexOf('## Determine Version and Finalize Changelog'); + assert.ok(recovery >= 0 && recovery < determineVersion, 'prepared recovery must precede version determination'); + assert.match(body, /Skip this entire section when `PREPARED_RELEASE` is non-empty/); + assert.match(body, /PR_STATE="\$\(printf '[^\n]+' \"\$MATCHING_RELEASE_PRS\" \| jq -r '\.\[0\]\.state'/); + assert.match(body, /If the selected PR already has `PR_STATE=MERGED`, skip this section entirely[\s\S]*?Do not request another review/); + assert.match(body, /If `PR_STATE=MERGED`, skip the CI gate and merge command below/); + }); + it('requires mergedAt and mergeCommit instead of trusting merge exit status', () => { assert.match(body, /gh pr view "\$PR_NUMBER" --json state,mergedAt,mergeCommit/); assert.match(body, /\.state == "MERGED"/); @@ -51,7 +61,10 @@ describe('/do:release remote promotion contracts', () => { assert.match(body, /refs\/tags\/v\{version\}\^\{/); assert.match(body, /refusing to overwrite it/); assert.match(body, /git push origin "refs\/tags\/v\{version\}" \|\| true/); - assert.match(body, /expected \$TARGET_SHA, got \$\{TAG_SHA:-empty\}/); + assert.match(body, /RELEASE_TREE="\$\(git rev-parse "\$MERGE_COMMIT\^\{tree\}"\)"/); + assert.match(body, /TAG_TREE.*RELEASE_TREE/); + assert.match(body, /git tag "v\{version\}" "\$MERGE_COMMIT"/); + assert.match(body, /expected merged release tree \$RELEASE_TREE, got \$\{TAG_TREE:-empty\}/); }); it('polls for a published GitHub Release and fails closed on timeout', () => { From f66516084671ca9a1fd4634a83a29129d4487b48 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 27 Aug 2026 07:14:35 +0000 Subject: [PATCH 3/7] fix: harden release checkpoint handoff (#211) --- commands/do/release.md | 109 +++++++++++++++++++++++----------- test/release-contract.test.js | 13 ++-- 2 files changed, 82 insertions(+), 40 deletions(-) diff --git a/commands/do/release.md b/commands/do/release.md index 9922ff6..3cc8de6 100644 --- a/commands/do/release.md +++ b/commands/do/release.md @@ -73,6 +73,13 @@ Before doing anything, determine the project's source and target branches for re ``` This ensures the PR diff shows ALL changes since the last release, not just the version bump. +4. **Detect GitHub Release publication** — set `{publishes_github_release}` to true + only when the documented workflow or release instructions publish a GitHub + Release (for example, they use `gh release`, `softprops/action-gh-release`, or + an equivalent release action). Projects that publish only packages or tags do + not have a GitHub Release checkpoint; their successful completion ends after + the version-tag checkpoint. + Print the detected workflow: `Detected release flow: {source} → {target}` **Default mode**: If ambiguous, use the most likely branch (prefer `release` if it exists). If the target branch does not exist, create it from the last release tag (see step 3 above). If detection still yields `target == source`, abort with an error — a release PR cannot merge a branch into itself. **Interactive mode (`--interactive`)**: Ask the user to confirm before proceeding. @@ -98,9 +105,9 @@ on the current source history. An interrupted run must resume the prepared versi not bump it again: ```bash -PREVIOUS_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)" -if [ -n "$PREVIOUS_TAG" ]; then - PREPARED_RELEASE="$(git log --format='%H%x09%s' "$PREVIOUS_TAG..HEAD" | awk -F '\t' '$2 ~ /^chore: release v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')" +git fetch origin "refs/heads/{target}:refs/remotes/origin/{target}" >/dev/null 2>&1 || true +if git show-ref --verify --quiet "refs/remotes/origin/{target}"; then + PREPARED_RELEASE="$(git log --format='%H%x09%s' "origin/{target}..HEAD" | awk -F '\t' '$2 ~ /^chore: release v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')" else PREPARED_RELEASE="$(git log --format='%H%x09%s' | awk -F '\t' '$2 ~ /^chore: release v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')" fi @@ -213,6 +220,12 @@ Verification — self-check before proceeding (no user prompt needed): unmerged PR is not reusable, so a later run may create a new PR for the newly pushed source SHA: ```bash + SOURCE_SHA="$(git rev-parse HEAD)" + REMOTE_SOURCE_SHA="$(git ls-remote --heads origin "refs/heads/{source}" | awk 'NF { print $1; exit }')" + if ! printf '%s\n' "$REMOTE_SOURCE_SHA" | grep -Eq '^[0-9a-f]{40}$' || [ "$REMOTE_SOURCE_SHA" != "$SOURCE_SHA" ]; then + echo "INCOMPLETE — Source push is unverified; expected $SOURCE_SHA, got ${REMOTE_SOURCE_SHA:-empty}. Preserve the prepared release state and retry." + exit 1 + fi RELEASE_PRS_JSON="$(gh pr list --state all --base "{target}" --head "{source}" --limit 100 \ --json number,state,headRefOid,baseRefName,headRefName,url,createdAt)" || { echo "INCOMPLETE — Release PR is unverified; the forge query failed. Preserve the prepared release state and retry." @@ -335,7 +348,14 @@ already succeeded remotely. - On a required-check **failure**, apply the **CI flake handling** routine — one conservative re-run on the same commit (see `~/.claude/lib/ci-flake-handling.md` and the inlined copy above). If the same SHA passes on the single re-run, treat it as a flake and proceed (logging which check flaked); if it fails again, **abort the release merge** and report which check failed. A release must never merge over a real red. - Once confirmed clean, merge: ```bash - gh pr merge --merge + PR_NUMBER="" + CURRENT_PR_STATE="$(gh pr view "$PR_NUMBER" --json state -q .state)" || { + echo "INCOMPLETE — Merged release PR is unverified; the forge state query failed. Preserve the prepared release state and retry." + exit 1 + } + if [ "$CURRENT_PR_STATE" != "MERGED" ]; then + gh pr merge "$PR_NUMBER" --merge + fi ``` - **Checkpoint 3 — merged release PR.** Do not infer completion from the merge command's exit status. Read back all three remote fields and require a merged @@ -343,7 +363,12 @@ already succeeded remotely. malformed, timed-out, queued, or otherwise inconclusive output is incomplete; name `Merged release PR` as the first unverified checkpoint and preserve the prepared state: + Run the Checkpoint 3 through Checkpoint 6 blocks below as one shell invocation; + this keeps their verified values together. Substitute the selected PR number + for `` in the invocation rather than relying on a variable from an + earlier shell call. ```bash + PR_NUMBER="" MERGE_JSON="$(gh pr view "$PR_NUMBER" --json state,mergedAt,mergeCommit)" || { echo "INCOMPLETE — Merged release PR is unverified; the forge query failed. Preserve the prepared release state and retry." exit 1 @@ -354,7 +379,10 @@ already succeeded remotely. exit 1 fi MERGE_COMMIT="$(printf '%s\n' "$MERGE_JSON" | jq -r '.mergeCommit.oid')" - RELEASE_TREE="$(git rev-parse "$MERGE_COMMIT^{tree}")" + RELEASE_TREE="$(git rev-parse --verify --quiet "$MERGE_COMMIT^{tree}")" || { + echo "INCOMPLETE — Merged release PR is unverified; the merge commit tree could not be read locally. Preserve the prepared release state and retry." + exit 1 + } ``` ## Post-Merge @@ -364,44 +392,48 @@ already succeeded remotely. proves the source-to-target promotion landed; checking only PR state would miss a queued or otherwise incomplete target update. If any command is empty, malformed, timed out, or fails, report `Target branch tree` as the first - unverified checkpoint and do not create or reuse a tag: + unverified checkpoint and do not create or reuse a tag. The Checkpoint 3 through + Checkpoint 6 commands below must run as one shell invocation so verified values + survive between checkpoints: ```bash - git fetch origin "{target}" || { + # Checkpoint 4 — FETCH_HEAD pins the exact target ref fetched; do not resolve + # a second moving tip with ls-remote. + git fetch origin "refs/heads/{target}" || { echo "INCOMPLETE — Target branch tree is unverified; fetching {target} failed. Preserve the prepared release state and retry." exit 1 } - TARGET_SHA="$(git ls-remote --heads origin "refs/heads/{target}" | awk 'NF { print $1; exit }')" + TARGET_SHA="$(git rev-parse --verify --quiet FETCH_HEAD^{commit} || true)" if ! printf '%s\n' "$TARGET_SHA" | grep -Eq '^[0-9a-f]{40}$' \ || ! git cat-file -e "$TARGET_SHA^{tree}" 2>/dev/null \ || ! git merge-base --is-ancestor "$MERGE_COMMIT" "$TARGET_SHA"; then echo "INCOMPLETE — Target branch tree is unverified; expected {target} to contain $MERGE_COMMIT, got ${TARGET_SHA:-empty}. Preserve the prepared release state and retry." exit 1 fi - ``` -2. **Checkpoint 5 — version tag.** Publish `v{version}` only when it is absent; - on a rerun, reuse it only if its remotely resolved commit has the exact - `RELEASE_TREE` from the merged release commit. The target branch may advance - with workflow housekeeping after the merge, so do not use its moving tip as - the tag identity. Never force-push or overwrite a conflicting tag. A failed push - may have raced with another successful publisher, so re-read the tag before - reporting failure; empty, malformed, mismatched, or inconclusive output names - `Version tag` as the first unverified checkpoint: - ```bash + # Checkpoint 5 — version tag. A workflow may add housekeeping commits after + # the merge, so accept only a tag on the merged-release lineage, never an + # unrelated or stale tag, and never overwrite an existing tag. A failed push + # may have raced with another successful publisher, so re-read the tag before + # reporting failure. TAG_SHA="$(git ls-remote origin "refs/tags/v{version}^{}" | awk 'NF { print $1; exit }')" if ! printf '%s\n' "$TAG_SHA" | grep -Eq '^[0-9a-f]{40}$'; then TAG_SHA="$(git ls-remote origin "refs/tags/v{version}" | awk 'NF { print $1; exit }')" fi if printf '%s\n' "$TAG_SHA" | grep -Eq '^[0-9a-f]{40}$'; then - TAG_TREE="$(git rev-parse "$TAG_SHA^{tree}" 2>/dev/null || true)" - if [ "$TAG_TREE" != "$RELEASE_TREE" ]; then - echo "INCOMPLETE — Version tag v{version} points to tree ${TAG_TREE:-empty}, not merged release tree $RELEASE_TREE; refusing to overwrite it." + TAG_COMMIT="$TAG_SHA" + if ! git merge-base --is-ancestor "$MERGE_COMMIT" "$TAG_COMMIT" \ + || ! git merge-base --is-ancestor "$TAG_COMMIT" "$TARGET_SHA"; then + echo "INCOMPLETE — Version tag v{version} is not on the merged release lineage; refusing to overwrite it." exit 1 fi else - if git rev-parse -q --verify "refs/tags/v{version}" >/dev/null 2>&1; then - LOCAL_TAG_TREE="$(git rev-parse "v{version}^{tree}" 2>/dev/null || true)" - if [ "$LOCAL_TAG_TREE" != "$RELEASE_TREE" ]; then - echo "INCOMPLETE — Local version tag v{version} points to tree ${LOCAL_TAG_TREE:-empty}, not merged release tree $RELEASE_TREE; refusing to overwrite it." + if git rev-parse --verify --quiet "refs/tags/v{version}^{commit}" >/dev/null; then + LOCAL_TAG_COMMIT="$(git rev-parse --verify --quiet "refs/tags/v{version}^{commit}")" || { + echo "INCOMPLETE — Version tag is unverified; the local tag could not be read. Preserve the prepared release state and retry." + exit 1 + } + if ! git merge-base --is-ancestor "$MERGE_COMMIT" "$LOCAL_TAG_COMMIT" \ + || ! git merge-base --is-ancestor "$LOCAL_TAG_COMMIT" "$TARGET_SHA"; then + echo "INCOMPLETE — Local version tag v{version} is not on the merged release lineage; refusing to overwrite it." exit 1 fi else @@ -415,19 +447,19 @@ already succeeded remotely. if ! printf '%s\n' "$TAG_SHA" | grep -Eq '^[0-9a-f]{40}$'; then TAG_SHA="$(git ls-remote origin "refs/tags/v{version}" | awk 'NF { print $1; exit }')" fi - TAG_TREE="$(git rev-parse "$TAG_SHA^{tree}" 2>/dev/null || true)" - if [ "$TAG_TREE" != "$RELEASE_TREE" ]; then - echo "INCOMPLETE — Version tag is unverified; expected merged release tree $RELEASE_TREE, got ${TAG_TREE:-empty}. Preserve the prepared release state and retry." + TAG_COMMIT="$TAG_SHA" + if ! printf '%s\n' "$TAG_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ + || ! git merge-base --is-ancestor "$MERGE_COMMIT" "$TAG_COMMIT" \ + || ! git merge-base --is-ancestor "$TAG_COMMIT" "$TARGET_SHA"; then + echo "INCOMPLETE — Version tag is unverified; expected a tag on the merged release lineage, got ${TAG_COMMIT:-empty}. Preserve the prepared release state and retry." exit 1 fi fi - ``` -3. **Checkpoint 6 — GitHub Release.** The release workflow may need time to - publish after the tag. Poll for a published, non-draft, non-prerelease release - whose tag is `v{version}` for a bounded period. Missing, empty, malformed, or - timed-out output is incomplete, not success; name `GitHub Release` as the first - unverified checkpoint and preserve the prepared release state: - ```bash + + # Checkpoint 6 — GitHub Release. The release workflow may need time to publish + # after the tag. Missing, empty, malformed, or timed-out output is incomplete + # when GitHub Release publication is part of the documented workflow. + if [ "{publishes_github_release}" = "true" ]; then RELEASE_JSON="" for ATTEMPT in $(seq 1 30); do RELEASE_JSON="$(gh release view "v{version}" --json tagName,isDraft,isPrerelease,publishedAt 2>/dev/null || true)" @@ -436,13 +468,18 @@ already succeeded remotely. break fi RELEASE_JSON="" - sleep 10 + [ "$ATTEMPT" -lt 30 ] && sleep 10 done if ! printf '%s\n' "$RELEASE_JSON" | jq -e \ 'type == "object" and .tagName == "v{version}" and .isDraft == false and .isPrerelease == false and (.publishedAt | type == "string") and (.publishedAt | length > 0)' >/dev/null 2>&1; then echo "INCOMPLETE — GitHub Release is unverified after the bounded wait; preserve the prepared release state and retry." exit 1 fi + else + echo "Checkpoint 6 — GitHub Release: skipped because the documented workflow does not publish one." + fi + + echo "COMPLETE — source $SOURCE_SHA; PR $PR_NUMBER merged at $MERGE_COMMIT; target $TARGET_SHA; tag $TAG_COMMIT." ``` 4. **Only after all six checkpoints pass** report the release as complete, including the source SHA, PR URL and merged state, target SHA, tag SHA, and published diff --git a/test/release-contract.test.js b/test/release-contract.test.js index 9285872..8a9dbe9 100644 --- a/test/release-contract.test.js +++ b/test/release-contract.test.js @@ -41,6 +41,8 @@ describe('/do:release remote promotion contracts', () => { const determineVersion = body.indexOf('## Determine Version and Finalize Changelog'); assert.ok(recovery >= 0 && recovery < determineVersion, 'prepared recovery must precede version determination'); assert.match(body, /Skip this entire section when `PREPARED_RELEASE` is non-empty/); + assert.match(body, /PREPARED_RELEASE="\$\(git log[^\n]+origin\/\{target\}\.\.HEAD/); + assert.doesNotMatch(body, /PREVIOUS_TAG=.*git describe/); assert.match(body, /PR_STATE="\$\(printf '[^\n]+' \"\$MATCHING_RELEASE_PRS\" \| jq -r '\.\[0\]\.state'/); assert.match(body, /If the selected PR already has `PR_STATE=MERGED`, skip this section entirely[\s\S]*?Do not request another review/); assert.match(body, /If `PR_STATE=MERGED`, skip the CI gate and merge command below/); @@ -55,16 +57,19 @@ describe('/do:release remote promotion contracts', () => { }); it('verifies target ancestry and makes tag publication idempotent', () => { - assert.match(body, /git ls-remote --heads origin "refs\/heads\/\{target\}"/); + assert.match(body, /git fetch origin "refs\/heads\/\{target\}"/); assert.match(body, /git cat-file -e "\$TARGET_SHA\^\{tree\}"/); assert.match(body, /git merge-base --is-ancestor "\$MERGE_COMMIT" "\$TARGET_SHA"/); assert.match(body, /refs\/tags\/v\{version\}\^\{/); assert.match(body, /refusing to overwrite it/); assert.match(body, /git push origin "refs\/tags\/v\{version\}" \|\| true/); - assert.match(body, /RELEASE_TREE="\$\(git rev-parse "\$MERGE_COMMIT\^\{tree\}"\)"/); - assert.match(body, /TAG_TREE.*RELEASE_TREE/); + assert.match(body, /RELEASE_TREE="\$\(git rev-parse --verify --quiet "\$MERGE_COMMIT\^\{tree\}"\)/); + assert.match(body, /git rev-parse --verify --quiet FETCH_HEAD\^\{commit\}/); + assert.match(body, /git merge-base --is-ancestor "\$MERGE_COMMIT" "\$TAG_COMMIT"/); + assert.match(body, /git merge-base --is-ancestor "\$TAG_COMMIT" "\$TARGET_SHA"/); assert.match(body, /git tag "v\{version\}" "\$MERGE_COMMIT"/); - assert.match(body, /expected merged release tree \$RELEASE_TREE, got \$\{TAG_TREE:-empty\}/); + assert.match(body, /publishes_github_release/); + assert.match(body, /\[ "\$ATTEMPT" -lt 30 \] && sleep 10/); }); it('polls for a published GitHub Release and fails closed on timeout', () => { From 088b555b77cc38b34fb22d22ec67abf40ad794e9 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 27 Aug 2026 07:25:18 +0000 Subject: [PATCH 4/7] fix: preserve release state across retries (#211) --- commands/do/release.md | 43 +++++++++++++++++++++++++++++++---- test/release-contract.test.js | 3 ++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/commands/do/release.md b/commands/do/release.md index 3cc8de6..8ac2d40 100644 --- a/commands/do/release.md +++ b/commands/do/release.md @@ -108,6 +108,17 @@ not bump it again: git fetch origin "refs/heads/{target}:refs/remotes/origin/{target}" >/dev/null 2>&1 || true if git show-ref --verify --quiet "refs/remotes/origin/{target}"; then PREPARED_RELEASE="$(git log --format='%H%x09%s' "origin/{target}..HEAD" | awk -F '\t' '$2 ~ /^chore: release v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')" + TARGET_PREPARED_RELEASE="$(git log -1 --format='%H%x09%s' "origin/{target}" | awk -F '\t' '$2 ~ /^chore: release v[0-9]+\.[0-9]+\.[0-9]+$/ { print }')" + if [ -z "$PREPARED_RELEASE" ] && [ -n "$TARGET_PREPARED_RELEASE" ]; then + TARGET_VERSION="$(printf '%s\n' "$TARGET_PREPARED_RELEASE" | sed -E 's/.*release v//')" + TARGET_TAG="$(git ls-remote origin "refs/tags/v${TARGET_VERSION}^{}" | awk 'NF { print $1; exit }')" + if ! printf '%s\n' "$TARGET_TAG" | grep -Eq '^[0-9a-f]{40}$'; then + TARGET_TAG="$(git ls-remote origin "refs/tags/v${TARGET_VERSION}" | awk 'NF { print $1; exit }')" + fi + if [ -z "$TARGET_TAG" ] || { [ "{publishes_github_release}" = "true" ] && ! gh release view "v${TARGET_VERSION}" >/dev/null 2>&1; }; then + PREPARED_RELEASE="$TARGET_PREPARED_RELEASE" + fi + fi else PREPARED_RELEASE="$(git log --format='%H%x09%s' | awk -F '\t' '$2 ~ /^chore: release v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')" fi @@ -257,6 +268,7 @@ Verification — self-check before proceeding (no user prompt needed): fi PR_STATE="OPEN" fi + printf 'RELEASE_PR_HANDOFF\tPR_NUMBER=%s\tPR_URL=%s\tPR_STATE=%s\n' "$PR_NUMBER" "$PR_URL" "$PR_STATE" ``` - Title: `Release v{version}` (read version from package.json or equivalent) - Body: include the changelog content for this version if available, otherwise summarize commits since last release @@ -264,6 +276,11 @@ Verification — self-check before proceeding (no user prompt needed): **Note**: Do NOT bump the version for review fixes — the version was already set during the release preparation. +Record the printed `RELEASE_PR_HANDOFF` line and carry its literal `PR_NUMBER`, +`PR_URL`, and `PR_STATE` values into the review and merge steps. Shell variables do +not survive separate tool calls; do not re-expand them later expecting them to be +populated. + ## Run the Review Loop If the selected PR already has `PR_STATE=MERGED`, skip this section entirely. @@ -353,8 +370,11 @@ already succeeded remotely. echo "INCOMPLETE — Merged release PR is unverified; the forge state query failed. Preserve the prepared release state and retry." exit 1 } - if [ "$CURRENT_PR_STATE" != "MERGED" ]; then + if [ "$CURRENT_PR_STATE" = "OPEN" ]; then gh pr merge "$PR_NUMBER" --merge + elif [ "$CURRENT_PR_STATE" != "MERGED" ]; then + echo "INCOMPLETE — Merged release PR is unverified; expected OPEN or MERGED, got ${CURRENT_PR_STATE:-empty}. Preserve the prepared release state and retry." + exit 1 fi ``` - **Checkpoint 3 — merged release PR.** Do not infer completion from the merge @@ -379,10 +399,7 @@ already succeeded remotely. exit 1 fi MERGE_COMMIT="$(printf '%s\n' "$MERGE_JSON" | jq -r '.mergeCommit.oid')" - RELEASE_TREE="$(git rev-parse --verify --quiet "$MERGE_COMMIT^{tree}")" || { - echo "INCOMPLETE — Merged release PR is unverified; the merge commit tree could not be read locally. Preserve the prepared release state and retry." - exit 1 - } + printf 'RELEASE_PR_HANDOFF\tPR_NUMBER=%s\tPR_URL=%s\tPR_STATE=MERGED\tMERGE_COMMIT=%s\n' "$PR_NUMBER" "$(gh pr view "$PR_NUMBER" --json url -q .url)" "$MERGE_COMMIT" ``` ## Post-Merge @@ -396,6 +413,22 @@ already succeeded remotely. Checkpoint 6 commands below must run as one shell invocation so verified values survive between checkpoints: ```bash + PR_NUMBER="" + SOURCE_SHA="$(git rev-parse HEAD)" + MERGE_JSON="$(gh pr view "$PR_NUMBER" --json state,mergedAt,mergeCommit)" || { + echo "INCOMPLETE — Merged release PR is unverified; the forge query failed. Preserve the prepared release state and retry." + exit 1 + } + if ! printf '%s\n' "$MERGE_JSON" | jq -e \ + 'type == "object" and .state == "MERGED" and (.mergedAt | type == "string") and (.mergeCommit.oid | type == "string") and (.mergeCommit.oid | length > 0)' >/dev/null; then + echo "INCOMPLETE — Merged release PR is unverified; the remote merge state is incomplete. Preserve the prepared release state and retry." + exit 1 + fi + MERGE_COMMIT="$(printf '%s\n' "$MERGE_JSON" | jq -r '.mergeCommit.oid')" + PR_URL="$(gh pr view "$PR_NUMBER" --json url -q .url)" || { + echo "INCOMPLETE — Merged release PR is unverified; the PR URL could not be read. Preserve the prepared release state and retry." + exit 1 + } # Checkpoint 4 — FETCH_HEAD pins the exact target ref fetched; do not resolve # a second moving tip with ls-remote. git fetch origin "refs/heads/{target}" || { diff --git a/test/release-contract.test.js b/test/release-contract.test.js index 8a9dbe9..bf743ae 100644 --- a/test/release-contract.test.js +++ b/test/release-contract.test.js @@ -63,13 +63,14 @@ describe('/do:release remote promotion contracts', () => { assert.match(body, /refs\/tags\/v\{version\}\^\{/); assert.match(body, /refusing to overwrite it/); assert.match(body, /git push origin "refs\/tags\/v\{version\}" \|\| true/); - assert.match(body, /RELEASE_TREE="\$\(git rev-parse --verify --quiet "\$MERGE_COMMIT\^\{tree\}"\)/); assert.match(body, /git rev-parse --verify --quiet FETCH_HEAD\^\{commit\}/); assert.match(body, /git merge-base --is-ancestor "\$MERGE_COMMIT" "\$TAG_COMMIT"/); assert.match(body, /git merge-base --is-ancestor "\$TAG_COMMIT" "\$TARGET_SHA"/); assert.match(body, /git tag "v\{version\}" "\$MERGE_COMMIT"/); assert.match(body, /publishes_github_release/); assert.match(body, /\[ "\$ATTEMPT" -lt 30 \] && sleep 10/); + assert.match(body, /TARGET_PREPARED_RELEASE=.*git log -1 .*origin\/\{target\}/); + assert.match(body, /RELEASE_PR_HANDOFF/); }); it('polls for a published GitHub Release and fails closed on timeout', () => { From 570301b8e811551c656027b8f3b7880001657c40 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 27 Aug 2026 07:34:50 +0000 Subject: [PATCH 5/7] fix: close release retry edge cases (#211) --- commands/do/release.md | 77 ++++++++++++++++++++++------------- test/release-contract.test.js | 5 ++- 2 files changed, 52 insertions(+), 30 deletions(-) diff --git a/commands/do/release.md b/commands/do/release.md index 8ac2d40..8b0cc53 100644 --- a/commands/do/release.md +++ b/commands/do/release.md @@ -105,27 +105,29 @@ on the current source history. An interrupted run must resume the prepared versi not bump it again: ```bash -git fetch origin "refs/heads/{target}:refs/remotes/origin/{target}" >/dev/null 2>&1 || true -if git show-ref --verify --quiet "refs/remotes/origin/{target}"; then - PREPARED_RELEASE="$(git log --format='%H%x09%s' "origin/{target}..HEAD" | awk -F '\t' '$2 ~ /^chore: release v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')" - TARGET_PREPARED_RELEASE="$(git log -1 --format='%H%x09%s' "origin/{target}" | awk -F '\t' '$2 ~ /^chore: release v[0-9]+\.[0-9]+\.[0-9]+$/ { print }')" - if [ -z "$PREPARED_RELEASE" ] && [ -n "$TARGET_PREPARED_RELEASE" ]; then - TARGET_VERSION="$(printf '%s\n' "$TARGET_PREPARED_RELEASE" | sed -E 's/.*release v//')" - TARGET_TAG="$(git ls-remote origin "refs/tags/v${TARGET_VERSION}^{}" | awk 'NF { print $1; exit }')" - if ! printf '%s\n' "$TARGET_TAG" | grep -Eq '^[0-9a-f]{40}$'; then - TARGET_TAG="$(git ls-remote origin "refs/tags/v${TARGET_VERSION}" | awk 'NF { print $1; exit }')" - fi - if [ -z "$TARGET_TAG" ] || { [ "{publishes_github_release}" = "true" ] && ! gh release view "v${TARGET_VERSION}" >/dev/null 2>&1; }; then - PREPARED_RELEASE="$TARGET_PREPARED_RELEASE" - fi +if ! git fetch origin "refs/heads/{target}:refs/remotes/origin/{target}" >/dev/null 2>&1 \ + || ! git show-ref --verify --quiet "refs/remotes/origin/{target}"; then + echo "INCOMPLETE — Prepared release state is unverified; origin/{target} could not be resolved. Preserve the prepared state and retry." + exit 1 +fi + PREPARED_RELEASE="$(git log --extended-regexp --format='%H%x09%s' "origin/{target}..HEAD" | awk -F '\t' '$2 ~ /^chore: release v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')" + TARGET_PREPARED_RELEASE="$(git log --extended-regexp --format='%H%x09%s' "origin/{target}" | awk -F '\t' '$2 ~ /^chore: release v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')" +if [ -z "$PREPARED_RELEASE" ] && [ -n "$TARGET_PREPARED_RELEASE" ]; then + TARGET_VERSION="$(printf '%s\n' "$TARGET_PREPARED_RELEASE" | sed -E 's/.*release v//')" + TARGET_TAG="$(git ls-remote origin "refs/tags/v${TARGET_VERSION}^{}" | awk 'NF { print $1; exit }')" + if ! printf '%s\n' "$TARGET_TAG" | grep -Eq '^[0-9a-f]{40}$'; then + TARGET_TAG="$(git ls-remote origin "refs/tags/v${TARGET_VERSION}" | awk 'NF { print $1; exit }')" + fi + TARGET_RELEASE_JSON="$(gh release view "v${TARGET_VERSION}" --json isDraft,isPrerelease,publishedAt 2>/dev/null || true)" + if [ -z "$TARGET_TAG" ] || { [ "{publishes_github_release}" = "true" ] && ! printf '%s\n' "$TARGET_RELEASE_JSON" | jq -e 'type == "object" and .isDraft == false and .isPrerelease == false and (.publishedAt | type == "string") and (.publishedAt | length > 0)' >/dev/null 2>&1; }; then + PREPARED_RELEASE="$TARGET_PREPARED_RELEASE" fi -else - PREPARED_RELEASE="$(git log --format='%H%x09%s' | awk -F '\t' '$2 ~ /^chore: release v[0-9]+\.[0-9]+\.[0-9]+$/ { print; exit }')" fi if [ -n "$PREPARED_RELEASE" ]; then PREPARED_RELEASE_SHA="$(printf '%s\n' "$PREPARED_RELEASE" | cut -f1)" VERSION="$(printf '%s\n' "$PREPARED_RELEASE" | sed -E 's/.*release v//')" echo "Resuming prepared release v${VERSION} at ${PREPARED_RELEASE_SHA}; skipping version bump and changelog generation." + printf 'RELEASE_PREPARED_HANDOFF\tPREPARED_RELEASE_SHA=%s\tVERSION=%s\n' "$PREPARED_RELEASE_SHA" "$VERSION" else echo "No prepared release commit found; determine a new version and finalize its changelog below." fi @@ -217,6 +219,11 @@ Verification — self-check before proceeding (no user prompt needed): ```bash git push -u origin "HEAD:refs/heads/{source}" SOURCE_SHA="$(git rev-parse HEAD)" + PREPARED_RELEASE_SHA="$(git log --extended-regexp --format='%H' --grep='^chore: release v[0-9]+\.[0-9]+\.[0-9]+$' -n 1)" + if ! printf '%s\n' "$PREPARED_RELEASE_SHA" | grep -Eq '^[0-9a-f]{40}$'; then + echo "INCOMPLETE — Prepared release state is unverified; the release preparation commit could not be identified. Preserve the prepared state and retry." + exit 1 + fi REMOTE_SOURCE_SHA="$(git ls-remote --heads origin "refs/heads/{source}" | awk 'NF { print $1; exit }')" if ! printf '%s\n' "$REMOTE_SOURCE_SHA" | grep -Eq '^[0-9a-f]{40}$' || [ "$REMOTE_SOURCE_SHA" != "$SOURCE_SHA" ]; then echo "INCOMPLETE — Source push is unverified; expected $SOURCE_SHA, got ${REMOTE_SOURCE_SHA:-empty}. Preserve the prepared release state and retry." @@ -232,6 +239,7 @@ Verification — self-check before proceeding (no user prompt needed): pushed source SHA: ```bash SOURCE_SHA="$(git rev-parse HEAD)" + PREPARED_RELEASE_SHA="$(git log --extended-regexp --format='%H' --grep='^chore: release v[0-9]+\.[0-9]+\.[0-9]+$' -n 1)" REMOTE_SOURCE_SHA="$(git ls-remote --heads origin "refs/heads/{source}" | awk 'NF { print $1; exit }')" if ! printf '%s\n' "$REMOTE_SOURCE_SHA" | grep -Eq '^[0-9a-f]{40}$' || [ "$REMOTE_SOURCE_SHA" != "$SOURCE_SHA" ]; then echo "INCOMPLETE — Source push is unverified; expected $SOURCE_SHA, got ${REMOTE_SOURCE_SHA:-empty}. Preserve the prepared release state and retry." @@ -268,7 +276,7 @@ Verification — self-check before proceeding (no user prompt needed): fi PR_STATE="OPEN" fi - printf 'RELEASE_PR_HANDOFF\tPR_NUMBER=%s\tPR_URL=%s\tPR_STATE=%s\n' "$PR_NUMBER" "$PR_URL" "$PR_STATE" + printf 'RELEASE_PR_HANDOFF\tPREPARED_RELEASE_SHA=%s\tPR_NUMBER=%s\tPR_URL=%s\tPR_STATE=%s\n' "$PREPARED_RELEASE_SHA" "$PR_NUMBER" "$PR_URL" "$PR_STATE" ``` - Title: `Release v{version}` (read version from package.json or equivalent) - Body: include the changelog content for this version if available, otherwise summarize commits since last release @@ -276,10 +284,10 @@ Verification — self-check before proceeding (no user prompt needed): **Note**: Do NOT bump the version for review fixes — the version was already set during the release preparation. -Record the printed `RELEASE_PR_HANDOFF` line and carry its literal `PR_NUMBER`, -`PR_URL`, and `PR_STATE` values into the review and merge steps. Shell variables do -not survive separate tool calls; do not re-expand them later expecting them to be -populated. +Record the printed `RELEASE_PREPARED_HANDOFF` and `RELEASE_PR_HANDOFF` lines and +carry their literal `PREPARED_RELEASE_SHA`, `PR_NUMBER`, `PR_URL`, and `PR_STATE` +values into the review and merge steps. Shell variables do not survive separate +tool calls; do not re-expand them later expecting them to be populated. ## Run the Review Loop @@ -325,6 +333,10 @@ Each pass uses the matching single-reviewer loop: ## Merge the PR (only after a CLEAN multi-reviewer result) +If `PR_STATE=MERGED`, skip all review-verdict and CI/merge gates in this section +and continue directly to Checkpoint 3's remote read-back. An already-merged PR +does not need another reviewer verdict to recover its post-merge checkpoints. + The merge gate consumes the **wrapper's `{OVERALL_STATUS}`** plus, for any copilot pass that ran, the standard copilot post-pass checks. ### Wrapper status @@ -383,10 +395,11 @@ already succeeded remotely. malformed, timed-out, queued, or otherwise inconclusive output is incomplete; name `Merged release PR` as the first unverified checkpoint and preserve the prepared state: - Run the Checkpoint 3 through Checkpoint 6 blocks below as one shell invocation; - this keeps their verified values together. Substitute the selected PR number - for `` in the invocation rather than relying on a variable from an - earlier shell call. + Run the Checkpoint 3 through Checkpoint 6 blocks below with a command timeout of + at least 600 seconds and as one shell invocation; + this keeps their verified values together. Substitute the carried preparation + SHA and selected PR number for `` and `` rather + than relying on variables from earlier shell calls. ```bash PR_NUMBER="" MERGE_JSON="$(gh pr view "$PR_NUMBER" --json state,mergedAt,mergeCommit)" || { @@ -413,6 +426,7 @@ already succeeded remotely. Checkpoint 6 commands below must run as one shell invocation so verified values survive between checkpoints: ```bash + PREPARED_RELEASE_SHA="" PR_NUMBER="" SOURCE_SHA="$(git rev-parse HEAD)" MERGE_JSON="$(gh pr view "$PR_NUMBER" --json state,mergedAt,mergeCommit)" || { @@ -453,7 +467,7 @@ already succeeded remotely. fi if printf '%s\n' "$TAG_SHA" | grep -Eq '^[0-9a-f]{40}$'; then TAG_COMMIT="$TAG_SHA" - if ! git merge-base --is-ancestor "$MERGE_COMMIT" "$TAG_COMMIT" \ + if ! git merge-base --is-ancestor "$PREPARED_RELEASE_SHA" "$TAG_COMMIT" \ || ! git merge-base --is-ancestor "$TAG_COMMIT" "$TARGET_SHA"; then echo "INCOMPLETE — Version tag v{version} is not on the merged release lineage; refusing to overwrite it." exit 1 @@ -464,7 +478,7 @@ already succeeded remotely. echo "INCOMPLETE — Version tag is unverified; the local tag could not be read. Preserve the prepared release state and retry." exit 1 } - if ! git merge-base --is-ancestor "$MERGE_COMMIT" "$LOCAL_TAG_COMMIT" \ + if ! git merge-base --is-ancestor "$PREPARED_RELEASE_SHA" "$LOCAL_TAG_COMMIT" \ || ! git merge-base --is-ancestor "$LOCAL_TAG_COMMIT" "$TARGET_SHA"; then echo "INCOMPLETE — Local version tag v{version} is not on the merged release lineage; refusing to overwrite it." exit 1 @@ -482,7 +496,7 @@ already succeeded remotely. fi TAG_COMMIT="$TAG_SHA" if ! printf '%s\n' "$TAG_COMMIT" | grep -Eq '^[0-9a-f]{40}$' \ - || ! git merge-base --is-ancestor "$MERGE_COMMIT" "$TAG_COMMIT" \ + || ! git merge-base --is-ancestor "$PREPARED_RELEASE_SHA" "$TAG_COMMIT" \ || ! git merge-base --is-ancestor "$TAG_COMMIT" "$TARGET_SHA"; then echo "INCOMPLETE — Version tag is unverified; expected a tag on the merged release lineage, got ${TAG_COMMIT:-empty}. Preserve the prepared release state and retry." exit 1 @@ -492,6 +506,13 @@ already succeeded remotely. # Checkpoint 6 — GitHub Release. The release workflow may need time to publish # after the tag. Missing, empty, malformed, or timed-out output is incomplete # when GitHub Release publication is part of the documented workflow. + case "{publishes_github_release}" in + true|false) ;; + *) + echo "INCOMPLETE — GitHub Release publication flag is unresolved; preserve the prepared release state." + exit 1 + ;; + esac if [ "{publishes_github_release}" = "true" ]; then RELEASE_JSON="" for ATTEMPT in $(seq 1 30); do @@ -514,7 +535,7 @@ already succeeded remotely. echo "COMPLETE — source $SOURCE_SHA; PR $PR_NUMBER merged at $MERGE_COMMIT; target $TARGET_SHA; tag $TAG_COMMIT." ``` -4. **Only after all six checkpoints pass** report the release as complete, including +2. **Only after all six checkpoints pass** report the release as complete, including the source SHA, PR URL and merged state, target SHA, tag SHA, and published GitHub Release. A local prepared commit, a successful PR merge command, or a pushed tag is never sufficient on its own. Switch back to the source branch diff --git a/test/release-contract.test.js b/test/release-contract.test.js index bf743ae..4416281 100644 --- a/test/release-contract.test.js +++ b/test/release-contract.test.js @@ -64,13 +64,14 @@ describe('/do:release remote promotion contracts', () => { assert.match(body, /refusing to overwrite it/); assert.match(body, /git push origin "refs\/tags\/v\{version\}" \|\| true/); assert.match(body, /git rev-parse --verify --quiet FETCH_HEAD\^\{commit\}/); - assert.match(body, /git merge-base --is-ancestor "\$MERGE_COMMIT" "\$TAG_COMMIT"/); + assert.match(body, /git merge-base --is-ancestor "\$PREPARED_RELEASE_SHA" "\$TAG_COMMIT"/); assert.match(body, /git merge-base --is-ancestor "\$TAG_COMMIT" "\$TARGET_SHA"/); assert.match(body, /git tag "v\{version\}" "\$MERGE_COMMIT"/); assert.match(body, /publishes_github_release/); assert.match(body, /\[ "\$ATTEMPT" -lt 30 \] && sleep 10/); - assert.match(body, /TARGET_PREPARED_RELEASE=.*git log -1 .*origin\/\{target\}/); + assert.match(body, /TARGET_PREPARED_RELEASE=.*git log --format=.*origin\/\{target\}/); assert.match(body, /RELEASE_PR_HANDOFF/); + assert.match(body, /case "\{publishes_github_release\}" in[\s\S]*true\|false/); }); it('polls for a published GitHub Release and fails closed on timeout', () => { From bbce545be4768c3b957a5d459b1a575e81528386 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 27 Aug 2026 07:36:05 +0000 Subject: [PATCH 6/7] test: match multiline release recovery contract (#211) --- test/release-contract.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/release-contract.test.js b/test/release-contract.test.js index 4416281..8ee288d 100644 --- a/test/release-contract.test.js +++ b/test/release-contract.test.js @@ -69,7 +69,7 @@ describe('/do:release remote promotion contracts', () => { assert.match(body, /git tag "v\{version\}" "\$MERGE_COMMIT"/); assert.match(body, /publishes_github_release/); assert.match(body, /\[ "\$ATTEMPT" -lt 30 \] && sleep 10/); - assert.match(body, /TARGET_PREPARED_RELEASE=.*git log --format=.*origin\/\{target\}/); + assert.match(body, /TARGET_PREPARED_RELEASE="\$\(git log[\s\S]*?origin\/\{target\}/); assert.match(body, /RELEASE_PR_HANDOFF/); assert.match(body, /case "\{publishes_github_release\}" in[\s\S]*true\|false/); }); From e3b5fc7b82184969c352e1b0168cbdacc8fde08e Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 27 Aug 2026 07:45:54 +0000 Subject: [PATCH 7/7] fix: harden target release recovery (#211) --- commands/do/release.md | 68 +++++++++++++++++++++++++++++++---- test/release-contract.test.js | 6 ++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/commands/do/release.md b/commands/do/release.md index 8b0cc53..9f4ce0e 100644 --- a/commands/do/release.md +++ b/commands/do/release.md @@ -105,6 +105,14 @@ on the current source history. An interrupted run must resume the prepared versi not bump it again: ```bash +case "{publishes_github_release}" in + true|false) ;; + *) + echo "INCOMPLETE — GitHub Release publication flag is unresolved; preserve the prepared release state." + exit 1 + ;; +esac +RECOVERED_TARGET_RELEASE=false if ! git fetch origin "refs/heads/{target}:refs/remotes/origin/{target}" >/dev/null 2>&1 \ || ! git show-ref --verify --quiet "refs/remotes/origin/{target}"; then echo "INCOMPLETE — Prepared release state is unverified; origin/{target} could not be resolved. Preserve the prepared state and retry." @@ -118,16 +126,50 @@ if [ -z "$PREPARED_RELEASE" ] && [ -n "$TARGET_PREPARED_RELEASE" ]; then if ! printf '%s\n' "$TARGET_TAG" | grep -Eq '^[0-9a-f]{40}$'; then TARGET_TAG="$(git ls-remote origin "refs/tags/v${TARGET_VERSION}" | awk 'NF { print $1; exit }')" fi - TARGET_RELEASE_JSON="$(gh release view "v${TARGET_VERSION}" --json isDraft,isPrerelease,publishedAt 2>/dev/null || true)" + TARGET_RELEASE_STATUS="$(gh api --include "repos/{owner}/{repo}/releases/tags/v${TARGET_VERSION}" 2>/dev/null | awk '$1 ~ /^HTTP\// { print $2; exit }' || true)" + case "$TARGET_RELEASE_STATUS" in + 200) + TARGET_RELEASE_JSON="$(gh release view "v${TARGET_VERSION}" --json isDraft,isPrerelease,publishedAt 2>/dev/null)" || { + echo "INCOMPLETE — Prepared release state is unverified; GitHub Release metadata could not be read. Preserve the prepared state and retry." + exit 1 + } + ;; + 404) TARGET_RELEASE_JSON="" ;; + *) + echo "INCOMPLETE — Prepared release state is unverified; GitHub Release lookup returned ${TARGET_RELEASE_STATUS:-empty}. Preserve the prepared state and retry." + exit 1 + ;; + esac if [ -z "$TARGET_TAG" ] || { [ "{publishes_github_release}" = "true" ] && ! printf '%s\n' "$TARGET_RELEASE_JSON" | jq -e 'type == "object" and .isDraft == false and .isPrerelease == false and (.publishedAt | type == "string") and (.publishedAt | length > 0)' >/dev/null 2>&1; }; then PREPARED_RELEASE="$TARGET_PREPARED_RELEASE" + RECOVERED_TARGET_RELEASE=true fi fi if [ -n "$PREPARED_RELEASE" ]; then PREPARED_RELEASE_SHA="$(printf '%s\n' "$PREPARED_RELEASE" | cut -f1)" VERSION="$(printf '%s\n' "$PREPARED_RELEASE" | sed -E 's/.*release v//')" echo "Resuming prepared release v${VERSION} at ${PREPARED_RELEASE_SHA}; skipping version bump and changelog generation." - printf 'RELEASE_PREPARED_HANDOFF\tPREPARED_RELEASE_SHA=%s\tVERSION=%s\n' "$PREPARED_RELEASE_SHA" "$VERSION" + if [ "$RECOVERED_TARGET_RELEASE" = "true" ]; then + TARGET_RELEASE_PRS_JSON="$(gh pr list --state merged --base "{target}" --limit 100 --json number,state,headRefOid,baseRefName,headRefName,url,mergedAt,mergeCommit)" || { + echo "INCOMPLETE — Merged release PR is unverified; the forge query failed. Preserve the prepared state and retry." + exit 1 + } + if ! printf '%s\n' "$TARGET_RELEASE_PRS_JSON" | jq -e 'type == "array"' >/dev/null; then + echo "INCOMPLETE — Merged release PR is unverified; the forge returned empty or malformed data. Preserve the prepared state and retry." + exit 1 + fi + MATCHING_TARGET_RELEASE_PRS="$(printf '%s\n' "$TARGET_RELEASE_PRS_JSON" | jq -c --arg sha "$PREPARED_RELEASE_SHA" --arg source "{source}" '[.[] | select(.headRefOid == $sha and .baseRefName == "{target}" and .headRefName == $source)]')" + MATCHING_TARGET_RELEASE_COUNT="$(printf '%s\n' "$MATCHING_TARGET_RELEASE_PRS" | jq 'length')" + if [ "$MATCHING_TARGET_RELEASE_COUNT" -ne 1 ]; then + echo "INCOMPLETE — Merged release PR is unverified; expected exactly one merged PR for prepared SHA $PREPARED_RELEASE_SHA, found $MATCHING_TARGET_RELEASE_COUNT. Preserve the prepared state and retry." + exit 1 + fi + PR_NUMBER="$(printf '%s\n' "$MATCHING_TARGET_RELEASE_PRS" | jq -r '.[0].number')" + PR_URL="$(printf '%s\n' "$MATCHING_TARGET_RELEASE_PRS" | jq -r '.[0].url')" + PR_STATE="MERGED" + printf 'RELEASE_TARGET_HANDOFF\tPREPARED_RELEASE_SHA=%s\tPR_NUMBER=%s\tPR_URL=%s\tPR_STATE=%s\n' "$PREPARED_RELEASE_SHA" "$PR_NUMBER" "$PR_URL" "$PR_STATE" + fi + printf 'RELEASE_PREPARED_HANDOFF\tPREPARED_RELEASE_SHA=%s\tVERSION=%s\tTARGET_RECOVERY=%s\n' "$PREPARED_RELEASE_SHA" "$VERSION" "$RECOVERED_TARGET_RELEASE" else echo "No prepared release commit found; determine a new version and finalize its changelog below." fi @@ -137,7 +179,11 @@ When `PREPARED_RELEASE` is non-empty, verify that the checked-out package versio is `{version}` and continue directly to **Local Code Review**. Do not determine a new bump, rewrite release notes, or create another `chore: release` commit. If the package version does not match the prepared commit's version, fail closed and -preserve the prepared state for investigation. +preserve the prepared state for investigation. When `TARGET_RECOVERY=true`, the +prepared release is already merged into `{target}`: carry the +`RELEASE_TARGET_HANDOFF` values and skip Local Code Review, Checkpoints 1–2, and +the open-PR review/CI/merge gates; continue directly to Checkpoint 3 and then +verify the target tree, tag, and GitHub Release. ## Determine Version and Finalize Changelog @@ -211,6 +257,10 @@ Verification — self-check before proceeding (no user prompt needed): ## Open the Release PR +When `TARGET_RECOVERY=true`, use the carried `RELEASE_TARGET_HANDOFF` instead of +running Checkpoints 1–2; the already-merged PR is the release PR for this retry. +Continue with Checkpoint 3 and the post-merge verification blocks below. + - **Checkpoint 1 — source push.** Push the prepared source commit and verify the forge reports the exact same commit before creating or reusing a PR. A successful `git push` by itself is not proof that the remote ref was updated; empty, @@ -219,7 +269,7 @@ Verification — self-check before proceeding (no user prompt needed): ```bash git push -u origin "HEAD:refs/heads/{source}" SOURCE_SHA="$(git rev-parse HEAD)" - PREPARED_RELEASE_SHA="$(git log --extended-regexp --format='%H' --grep='^chore: release v[0-9]+\.[0-9]+\.[0-9]+$' -n 1)" + PREPARED_RELEASE_SHA="$(git log --extended-regexp --format='%H%x09%s' | awk -F '\t' '$2 ~ /^chore: release v[0-9]+\.[0-9]+\.[0-9]+$/ { print $1; exit }')" if ! printf '%s\n' "$PREPARED_RELEASE_SHA" | grep -Eq '^[0-9a-f]{40}$'; then echo "INCOMPLETE — Prepared release state is unverified; the release preparation commit could not be identified. Preserve the prepared state and retry." exit 1 @@ -239,7 +289,7 @@ Verification — self-check before proceeding (no user prompt needed): pushed source SHA: ```bash SOURCE_SHA="$(git rev-parse HEAD)" - PREPARED_RELEASE_SHA="$(git log --extended-regexp --format='%H' --grep='^chore: release v[0-9]+\.[0-9]+\.[0-9]+$' -n 1)" + PREPARED_RELEASE_SHA="$(git log --extended-regexp --format='%H%x09%s' | awk -F '\t' '$2 ~ /^chore: release v[0-9]+\.[0-9]+\.[0-9]+$/ { print $1; exit }')" REMOTE_SOURCE_SHA="$(git ls-remote --heads origin "refs/heads/{source}" | awk 'NF { print $1; exit }')" if ! printf '%s\n' "$REMOTE_SOURCE_SHA" | grep -Eq '^[0-9a-f]{40}$' || [ "$REMOTE_SOURCE_SHA" != "$SOURCE_SHA" ]; then echo "INCOMPLETE — Source push is unverified; expected $SOURCE_SHA, got ${REMOTE_SOURCE_SHA:-empty}. Preserve the prepared release state and retry." @@ -428,13 +478,17 @@ already succeeded remotely. ```bash PREPARED_RELEASE_SHA="" PR_NUMBER="" - SOURCE_SHA="$(git rev-parse HEAD)" + if [ "" = "true" ]; then + SOURCE_SHA="$PREPARED_RELEASE_SHA" + else + SOURCE_SHA="$(git rev-parse HEAD)" + fi MERGE_JSON="$(gh pr view "$PR_NUMBER" --json state,mergedAt,mergeCommit)" || { echo "INCOMPLETE — Merged release PR is unverified; the forge query failed. Preserve the prepared release state and retry." exit 1 } if ! printf '%s\n' "$MERGE_JSON" | jq -e \ - 'type == "object" and .state == "MERGED" and (.mergedAt | type == "string") and (.mergeCommit.oid | type == "string") and (.mergeCommit.oid | length > 0)' >/dev/null; then + 'type == "object" and .state == "MERGED" and (.mergedAt | type == "string") and (.mergedAt | length > 0) and (.mergeCommit.oid | type == "string") and (.mergeCommit.oid | length > 0)' >/dev/null; then echo "INCOMPLETE — Merged release PR is unverified; the remote merge state is incomplete. Preserve the prepared release state and retry." exit 1 fi diff --git a/test/release-contract.test.js b/test/release-contract.test.js index 8ee288d..f6dc819 100644 --- a/test/release-contract.test.js +++ b/test/release-contract.test.js @@ -46,6 +46,9 @@ describe('/do:release remote promotion contracts', () => { assert.match(body, /PR_STATE="\$\(printf '[^\n]+' \"\$MATCHING_RELEASE_PRS\" \| jq -r '\.\[0\]\.state'/); assert.match(body, /If the selected PR already has `PR_STATE=MERGED`, skip this section entirely[\s\S]*?Do not request another review/); assert.match(body, /If `PR_STATE=MERGED`, skip the CI gate and merge command below/); + assert.match(body, /TARGET_RECOVERY=true/); + assert.match(body, /RELEASE_TARGET_HANDOFF/); + assert.match(body, /skip Local Code Review, Checkpoints 1–2/); }); it('requires mergedAt and mergeCommit instead of trusting merge exit status', () => { @@ -72,6 +75,9 @@ describe('/do:release remote promotion contracts', () => { assert.match(body, /TARGET_PREPARED_RELEASE="\$\(git log[\s\S]*?origin\/\{target\}/); assert.match(body, /RELEASE_PR_HANDOFF/); assert.match(body, /case "\{publishes_github_release\}" in[\s\S]*true\|false/); + assert.match(body, /TARGET_RELEASE_STATUS=.*gh api --include/); + assert.match(body, /404\) TARGET_RELEASE_JSON=""/); + assert.match(body, /SOURCE_SHA="\$PREPARED_RELEASE_SHA"/); }); it('polls for a published GitHub Release and fails closed on timeout', () => {