From 9b1fe2f8a1fa9c6e159bd30a93c826be7421745e Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Jul 2026 19:54:42 +0000 Subject: [PATCH 1/9] fix(ci): preserve stable ancestry in main sync --- .github/workflows/sync-patches.yml | 57 ++++-------------------------- 1 file changed, 7 insertions(+), 50 deletions(-) diff --git a/.github/workflows/sync-patches.yml b/.github/workflows/sync-patches.yml index 980eb49041..02fb6ce759 100644 --- a/.github/workflows/sync-patches.yml +++ b/.github/workflows/sync-patches.yml @@ -6,7 +6,7 @@ name: 🔄 Sync stable → main on: workflow_run: workflows: - - "📦 Release stable tooling (CLI/SDK/MCP)" + - '📦 Release stable tooling (CLI/SDK/MCP)' types: - completed workflow_dispatch: @@ -14,7 +14,6 @@ on: permissions: actions: read contents: write - pull-requests: write concurrency: group: sync-stable-to-main @@ -84,8 +83,6 @@ jobs: token: ${{ steps.generate_token.outputs.token }} - name: Sync stable into main - env: - GH_TOKEN: ${{ steps.generate_token.outputs.token }} run: | set -euo pipefail @@ -94,17 +91,13 @@ jobs: git fetch origin main stable --tags --prune - SYNC_BRANCH="sync/stable-to-main-$(date +%Y%m%d-%H%M%S)" - git checkout -b "$SYNC_BRANCH" origin/main + git checkout --detach origin/main if git merge-base --is-ancestor origin/stable origin/main; then echo "No changes to sync - stable is already in main's ancestry." exit 0 fi - PR_TITLE="🔄 Sync stable → main" - MERGE_STATUS="clean" - normalize_json_without_keys() { python3 -c 'import json, sys; data = json.load(sys.stdin); [data.pop(key, None) for key in sys.argv[1:]]; sys.stdout.write(json.dumps(data, sort_keys=True, separators=(",", ":")))' "$@" } @@ -143,9 +136,7 @@ jobs: # Use a real merge, not a squash, so stable release tags become # reachable from main. semantic-release uses reachable tags as the # version floor for @next prereleases. - if git merge --no-ff --no-edit origin/stable; then - MERGE_STATUS="clean" - else + if ! git merge --no-ff --no-edit origin/stable; then echo "Merge conflict — attempting auto-resolution of release artifacts..." # Auto-resolve release artifact conflicts only when the conflict is @@ -166,7 +157,6 @@ jobs: done < <(git diff --name-only --diff-filter=U) if [ -z "$(git diff --name-only --diff-filter=U)" ]; then - MERGE_STATUS="auto_resolved" git commit -m "chore: merge stable into main (release conflicts auto-resolved)" else echo "Unresolved conflicts remain after version-only release artifact auto-resolution." @@ -181,40 +171,7 @@ jobs: exit 1 fi - git push origin "$SYNC_BRANCH" - - # Check for existing open sync PR - EXISTING=$(gh pr list --base main --head "$SYNC_BRANCH" --state open --json number -q '.[0].number' 2>/dev/null || true) - if [ -n "$EXISTING" ]; then - echo "Sync PR #$EXISTING already exists." - exit 0 - fi - - gh pr create \ - --base main \ - --head "$SYNC_BRANCH" \ - --title "$PR_TITLE" \ - --body "$(cat < Date: Mon, 13 Jul 2026 20:13:10 +0000 Subject: [PATCH 2/9] fix(ci): require successful stable release before sync --- .github/workflows/sync-patches.yml | 10 ++++------ scripts/__tests__/release-local.test.mjs | 22 +++++++++++++++------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/.github/workflows/sync-patches.yml b/.github/workflows/sync-patches.yml index 02fb6ce759..9edd427b12 100644 --- a/.github/workflows/sync-patches.yml +++ b/.github/workflows/sync-patches.yml @@ -1,8 +1,9 @@ name: 🔄 Sync stable → main # Stable pushes first run semantic-release, which can append version/tag -# commits back to stable. Sync only after that release lane finishes so main -# never gets a PR from an intermediate stable head. +# commits back to stable. Automatic sync waits for a successful release so +# main never advances from a failed or intermediate stable head. Manual +# dispatch remains available after an investigated failure. on: workflow_run: workflows: @@ -26,10 +27,7 @@ jobs: github.event_name == 'workflow_dispatch' || ( github.event.workflow_run.head_branch == 'stable' && - ( - github.event.workflow_run.conclusion == 'success' || - github.event.workflow_run.conclusion == 'failure' - ) + github.event.workflow_run.conclusion == 'success' ) steps: diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index bd4ab3b30d..ab6d5b854f 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -396,7 +396,7 @@ test('stable-to-main sync waits for stable release completion', async () => { '.github/workflows/sync-patches.yml: must trigger from release workflow completion, not directly from stable pushes', ); assert.ok( - /workflows:\s*\n\s*-\s*"📦 Release stable tooling \(CLI\/SDK\/MCP\)"/.test(workflow), + /workflows:\s*\n\s*-\s*['"]📦 Release stable tooling \(CLI\/SDK\/MCP\)['"]/.test(workflow), '.github/workflows/sync-patches.yml: must trigger after the stable release orchestrator completes', ); assert.equal( @@ -409,9 +409,17 @@ test('stable-to-main sync waits for stable release completion', async () => { '.github/workflows/sync-patches.yml: must scope automatic syncs to stable release runs', ); assert.ok( - workflow.includes("github.event.workflow_run.conclusion == 'success'") && - workflow.includes("github.event.workflow_run.conclusion == 'failure'"), - '.github/workflows/sync-patches.yml: must wait for release completion while still surfacing failed-release sync PRs for review', + workflow.includes("github.event.workflow_run.conclusion == 'success'"), + '.github/workflows/sync-patches.yml: automatic syncs must require a successful stable release', + ); + assert.equal( + workflow.includes("github.event.workflow_run.conclusion == 'failure'"), + false, + '.github/workflows/sync-patches.yml: failed stable releases must not push to main automatically', + ); + assert.ok( + workflow.includes("github.event_name == 'workflow_dispatch'"), + '.github/workflows/sync-patches.yml: manual dispatch must remain available for investigated recovery', ); assert.ok( workflow.includes('actions: read'), @@ -451,11 +459,11 @@ test('stable-to-main sync preserves stable release ancestry', async () => { assert.equal( workflow.includes('git add -A'), false, - '.github/workflows/sync-patches.yml: sync must not commit unresolved conflict markers into review PRs', + '.github/workflows/sync-patches.yml: sync must not commit unresolved conflict markers', ); assert.ok( - workflow.includes("This PR must be merged with GitHub's merge-commit option"), - '.github/workflows/sync-patches.yml: generated PRs must warn reviewers not to squash away stable ancestry', + workflow.includes('git push origin HEAD:main'), + '.github/workflows/sync-patches.yml: sync must push the verified merge commit directly so stable ancestry is preserved', ); }); From 5d2b3b996558e913c597aaf439fe8b44817424fc Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Jul 2026 20:30:04 +0000 Subject: [PATCH 3/9] fix(ci): require successful sibling releases before sync --- .github/workflows/sync-patches.yml | 32 +++++++++++++++++++++--- scripts/__tests__/release-local.test.mjs | 7 ++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sync-patches.yml b/.github/workflows/sync-patches.yml index 9edd427b12..9567c766a2 100644 --- a/.github/workflows/sync-patches.yml +++ b/.github/workflows/sync-patches.yml @@ -1,9 +1,10 @@ name: 🔄 Sync stable → main # Stable pushes first run semantic-release, which can append version/tag -# commits back to stable. Automatic sync waits for a successful release so -# main never advances from a failed or intermediate stable head. Manual -# dispatch remains available after an investigated failure. +# commits back to stable. Automatic sync waits for every triggered stable +# release workflow to succeed so main never advances from a failed or +# intermediate stable head. Manual dispatch remains available after an +# investigated failure. on: workflow_run: workflows: @@ -36,6 +37,8 @@ jobs: env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} + RELEASE_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + RELEASE_CREATED_AT: ${{ github.event.workflow_run.created_at }} run: | set -euo pipefail @@ -68,6 +71,29 @@ jobs: sleep 30 done + release_runs=$(gh run list \ + --repo "$REPO" \ + --branch stable \ + --event push \ + --limit 100 \ + --json conclusion,createdAt,headSha,name,status,url) + + failed_runs=$(jq \ + --arg head_sha "$RELEASE_HEAD_SHA" \ + --arg created_at "$RELEASE_CREATED_AT" \ + '[.[] | select( + (.name == "📦 Release stable tooling (CLI/SDK/MCP)" or .name == "📦 Release esign" or .name == "📦 Release template-builder") and + .status == "completed" and + (.headSha == $head_sha or .createdAt >= $created_at) and + (.conclusion == "failure" or .conclusion == "cancelled" or .conclusion == "timed_out" or .conclusion == "action_required" or .conclusion == "startup_failure" or .conclusion == "stale") + )]' <<< "$release_runs") + + if [ "$(jq 'length' <<< "$failed_runs")" -ne 0 ]; then + echo "Stable release lane contains failed runs." + jq -r '.[] | "\(.name)\t\(.conclusion)\t\(.url)"' <<< "$failed_runs" + exit 1 + fi + - name: Generate token id: generate_token uses: actions/create-github-app-token@v2 diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index ab6d5b854f..a516882930 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -431,6 +431,13 @@ test('stable-to-main sync waits for stable release completion', async () => { workflow.includes('"📦 Release template-builder"'), '.github/workflows/sync-patches.yml: must wait for the remaining stable release workflows before syncing origin/stable', ); + assert.ok( + workflow.includes('RELEASE_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}') && + workflow.includes('RELEASE_CREATED_AT: ${{ github.event.workflow_run.created_at }}') && + workflow.includes('.headSha == $head_sha or .createdAt >= $created_at') && + workflow.includes('.conclusion == "failure"'), + '.github/workflows/sync-patches.yml: must reject failed release runs from the triggering stable push and any newer stable pushes', + ); }); test('stable-to-main sync preserves stable release ancestry', async () => { From 9af293493068320ef72e1d3158881cbee9148381 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Jul 2026 20:47:09 +0000 Subject: [PATCH 4/9] fix(ci): gate sync on latest relevant releases --- .github/workflows/sync-patches.yml | 81 ++++++++++++++++-------- scripts/__tests__/release-local.test.mjs | 12 ++-- 2 files changed, 63 insertions(+), 30 deletions(-) diff --git a/.github/workflows/sync-patches.yml b/.github/workflows/sync-patches.yml index 9567c766a2..632a99a75c 100644 --- a/.github/workflows/sync-patches.yml +++ b/.github/workflows/sync-patches.yml @@ -37,8 +37,6 @@ jobs: env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} - RELEASE_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - RELEASE_CREATED_AT: ${{ github.event.workflow_run.created_at }} run: | set -euo pipefail @@ -71,29 +69,6 @@ jobs: sleep 30 done - release_runs=$(gh run list \ - --repo "$REPO" \ - --branch stable \ - --event push \ - --limit 100 \ - --json conclusion,createdAt,headSha,name,status,url) - - failed_runs=$(jq \ - --arg head_sha "$RELEASE_HEAD_SHA" \ - --arg created_at "$RELEASE_CREATED_AT" \ - '[.[] | select( - (.name == "📦 Release stable tooling (CLI/SDK/MCP)" or .name == "📦 Release esign" or .name == "📦 Release template-builder") and - .status == "completed" and - (.headSha == $head_sha or .createdAt >= $created_at) and - (.conclusion == "failure" or .conclusion == "cancelled" or .conclusion == "timed_out" or .conclusion == "action_required" or .conclusion == "startup_failure" or .conclusion == "stale") - )]' <<< "$release_runs") - - if [ "$(jq 'length' <<< "$failed_runs")" -ne 0 ]; then - echo "Stable release lane contains failed runs." - jq -r '.[] | "\(.name)\t\(.conclusion)\t\(.url)"' <<< "$failed_runs" - exit 1 - fi - - name: Generate token id: generate_token uses: actions/create-github-app-token@v2 @@ -106,6 +81,62 @@ jobs: fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} + - name: Verify stable release lane succeeded + if: github.event_name == 'workflow_run' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + git fetch origin main stable --tags --prune + + require_successful_release() { + local workflow_file="$1" + local label="$2" + local run + local status + local conclusion + local url + + run=$(gh run list \ + --repo "$REPO" \ + --workflow "$workflow_file" \ + --branch stable \ + --limit 1 \ + --json conclusion,status,url) + status=$(jq -r '.[0].status // "missing"' <<< "$run") + conclusion=$(jq -r '.[0].conclusion // "missing"' <<< "$run") + url=$(jq -r '.[0].url // "unavailable"' <<< "$run") + + if [ "$status" != "completed" ] || [ "$conclusion" != "success" ]; then + echo "$label release is not successful: status=$status conclusion=$conclusion url=$url" + exit 1 + fi + } + + require_successful_release release-stable.yml "Stable tooling" + + requires_esign=false + requires_template_builder=false + while IFS= read -r path; do + case "$path" in + *.md) ;; + packages/esign/*|pnpm-workspace.yaml) requires_esign=true ;; + esac + case "$path" in + *.md) ;; + packages/template-builder/*|pnpm-workspace.yaml) requires_template_builder=true ;; + esac + done < <(git diff --name-only origin/main...origin/stable) + + if [ "$requires_esign" = true ]; then + require_successful_release release-esign.yml "eSign" + fi + if [ "$requires_template_builder" = true ]; then + require_successful_release release-template-builder.yml "Template Builder" + fi + - name: Sync stable into main run: | set -euo pipefail diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index a516882930..b39490cc92 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -432,11 +432,13 @@ test('stable-to-main sync waits for stable release completion', async () => { '.github/workflows/sync-patches.yml: must wait for the remaining stable release workflows before syncing origin/stable', ); assert.ok( - workflow.includes('RELEASE_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}') && - workflow.includes('RELEASE_CREATED_AT: ${{ github.event.workflow_run.created_at }}') && - workflow.includes('.headSha == $head_sha or .createdAt >= $created_at') && - workflow.includes('.conclusion == "failure"'), - '.github/workflows/sync-patches.yml: must reject failed release runs from the triggering stable push and any newer stable pushes', + workflow.includes('Verify stable release lane succeeded') && + workflow.includes('--workflow "$workflow_file"') && + workflow.includes('--limit 1') && + workflow.includes('git diff --name-only origin/main...origin/stable') && + workflow.includes('require_successful_release release-esign.yml') && + workflow.includes('require_successful_release release-template-builder.yml'), + '.github/workflows/sync-patches.yml: must require the latest relevant stable release workflows to succeed before syncing', ); }); From fc964921086eaa356810fd9a164fee7f56f5bcbf Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Jul 2026 20:53:21 +0000 Subject: [PATCH 5/9] fix(ci): pin verified stable head during sync --- .github/workflows/sync-patches.yml | 25 +++++++++++++++++++++--- scripts/__tests__/release-local.test.mjs | 15 ++++++++------ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/.github/workflows/sync-patches.yml b/.github/workflows/sync-patches.yml index 632a99a75c..ea00ebd0d6 100644 --- a/.github/workflows/sync-patches.yml +++ b/.github/workflows/sync-patches.yml @@ -82,6 +82,7 @@ jobs: token: ${{ steps.generate_token.outputs.token }} - name: Verify stable release lane succeeded + id: verify_release_lane if: github.event_name == 'workflow_run' env: GH_TOKEN: ${{ github.token }} @@ -137,7 +138,12 @@ jobs: require_successful_release release-template-builder.yml "Template Builder" fi + echo "stable_sha=$(git rev-parse origin/stable)" >> "$GITHUB_OUTPUT" + - name: Sync stable into main + env: + EVENT_NAME: ${{ github.event_name }} + VERIFIED_STABLE_SHA: ${{ steps.verify_release_lane.outputs.stable_sha }} run: | set -euo pipefail @@ -146,9 +152,22 @@ jobs: git fetch origin main stable --tags --prune + stable_ref=origin/stable + if [ "$EVENT_NAME" = "workflow_run" ]; then + if [ -z "$VERIFIED_STABLE_SHA" ]; then + echo "Verified stable SHA is missing; refusing automatic sync." + exit 1 + fi + if ! git merge-base --is-ancestor "$VERIFIED_STABLE_SHA" origin/stable; then + echo "Verified stable SHA is no longer in origin/stable; refusing automatic sync." + exit 1 + fi + stable_ref="$VERIFIED_STABLE_SHA" + fi + git checkout --detach origin/main - if git merge-base --is-ancestor origin/stable origin/main; then + if git merge-base --is-ancestor "$stable_ref" origin/main; then echo "No changes to sync - stable is already in main's ancestry." exit 0 fi @@ -191,7 +210,7 @@ jobs: # Use a real merge, not a squash, so stable release tags become # reachable from main. semantic-release uses reachable tags as the # version floor for @next prereleases. - if ! git merge --no-ff --no-edit origin/stable; then + if ! git merge --no-ff --no-edit "$stable_ref"; then echo "Merge conflict — attempting auto-resolution of release artifacts..." # Auto-resolve release artifact conflicts only when the conflict is @@ -221,7 +240,7 @@ jobs: fi fi - if ! git merge-base --is-ancestor origin/stable HEAD; then + if ! git merge-base --is-ancestor "$stable_ref" HEAD; then echo "Stable is still not in the sync branch ancestry after merge." exit 1 fi diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index b39490cc92..47f1338f31 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -437,7 +437,8 @@ test('stable-to-main sync waits for stable release completion', async () => { workflow.includes('--limit 1') && workflow.includes('git diff --name-only origin/main...origin/stable') && workflow.includes('require_successful_release release-esign.yml') && - workflow.includes('require_successful_release release-template-builder.yml'), + workflow.includes('require_successful_release release-template-builder.yml') && + workflow.includes('stable_sha=$(git rev-parse origin/stable)'), '.github/workflows/sync-patches.yml: must require the latest relevant stable release workflows to succeed before syncing', ); }); @@ -451,13 +452,15 @@ test('stable-to-main sync preserves stable release ancestry', async () => { '.github/workflows/sync-patches.yml: stable-to-main sync must not squash because semantic-release needs stable tags reachable from main', ); assert.ok( - workflow.includes('git merge --no-ff --no-edit origin/stable'), - '.github/workflows/sync-patches.yml: stable-to-main sync must create a real merge commit', + workflow.includes('git merge --no-ff --no-edit "$stable_ref"'), + '.github/workflows/sync-patches.yml: stable-to-main sync must create a real merge commit from the verified stable ref', ); assert.ok( - workflow.includes('git merge-base --is-ancestor origin/stable origin/main') && - workflow.includes('git merge-base --is-ancestor origin/stable HEAD'), - '.github/workflows/sync-patches.yml: sync must guard on and verify stable ancestry', + workflow.includes('VERIFIED_STABLE_SHA: ${{ steps.verify_release_lane.outputs.stable_sha }}') && + workflow.includes('git merge-base --is-ancestor "$VERIFIED_STABLE_SHA" origin/stable') && + workflow.includes('git merge-base --is-ancestor "$stable_ref" origin/main') && + workflow.includes('git merge-base --is-ancestor "$stable_ref" HEAD'), + '.github/workflows/sync-patches.yml: automatic sync must pin the verified stable SHA and guard on its ancestry', ); assert.ok( workflow.includes('release_artifact_only_conflict') && From 6047b95533897ba83d7a2808ab1f6c5cff16fe1d Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Jul 2026 21:26:07 +0000 Subject: [PATCH 6/9] fix(ci): simplify stable ancestry sync --- .github/workflows/sync-patches.yml | 105 +++-------------------- scripts/__tests__/release-local.test.mjs | 60 +++++++++---- 2 files changed, 53 insertions(+), 112 deletions(-) diff --git a/.github/workflows/sync-patches.yml b/.github/workflows/sync-patches.yml index ea00ebd0d6..b94a7bc4c1 100644 --- a/.github/workflows/sync-patches.yml +++ b/.github/workflows/sync-patches.yml @@ -1,10 +1,7 @@ name: 🔄 Sync stable → main -# Stable pushes first run semantic-release, which can append version/tag -# commits back to stable. Automatic sync waits for every triggered stable -# release workflow to succeed so main never advances from a failed or -# intermediate stable head. Manual dispatch remains available after an -# investigated failure. +# Stable releases can append version and tag commits. Wait for that lane to +# drain, then pin its settled head before merging it into main. on: workflow_run: workflows: @@ -50,7 +47,7 @@ jobs: --jq '[.[] | select((.name == "📦 Release stable tooling (CLI/SDK/MCP)" or .name == "📦 Release esign" or .name == "📦 Release template-builder") and .status != "completed")] | length') if [ "$active_runs" -eq 0 ]; then - echo "Stable release lane is idle." + echo "Stable release lane is drained." break fi @@ -81,69 +78,7 @@ jobs: fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} - - name: Verify stable release lane succeeded - id: verify_release_lane - if: github.event_name == 'workflow_run' - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - run: | - set -euo pipefail - - git fetch origin main stable --tags --prune - - require_successful_release() { - local workflow_file="$1" - local label="$2" - local run - local status - local conclusion - local url - - run=$(gh run list \ - --repo "$REPO" \ - --workflow "$workflow_file" \ - --branch stable \ - --limit 1 \ - --json conclusion,status,url) - status=$(jq -r '.[0].status // "missing"' <<< "$run") - conclusion=$(jq -r '.[0].conclusion // "missing"' <<< "$run") - url=$(jq -r '.[0].url // "unavailable"' <<< "$run") - - if [ "$status" != "completed" ] || [ "$conclusion" != "success" ]; then - echo "$label release is not successful: status=$status conclusion=$conclusion url=$url" - exit 1 - fi - } - - require_successful_release release-stable.yml "Stable tooling" - - requires_esign=false - requires_template_builder=false - while IFS= read -r path; do - case "$path" in - *.md) ;; - packages/esign/*|pnpm-workspace.yaml) requires_esign=true ;; - esac - case "$path" in - *.md) ;; - packages/template-builder/*|pnpm-workspace.yaml) requires_template_builder=true ;; - esac - done < <(git diff --name-only origin/main...origin/stable) - - if [ "$requires_esign" = true ]; then - require_successful_release release-esign.yml "eSign" - fi - if [ "$requires_template_builder" = true ]; then - require_successful_release release-template-builder.yml "Template Builder" - fi - - echo "stable_sha=$(git rev-parse origin/stable)" >> "$GITHUB_OUTPUT" - - name: Sync stable into main - env: - EVENT_NAME: ${{ github.event_name }} - VERIFIED_STABLE_SHA: ${{ steps.verify_release_lane.outputs.stable_sha }} run: | set -euo pipefail @@ -151,23 +86,11 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git fetch origin main stable --tags --prune - - stable_ref=origin/stable - if [ "$EVENT_NAME" = "workflow_run" ]; then - if [ -z "$VERIFIED_STABLE_SHA" ]; then - echo "Verified stable SHA is missing; refusing automatic sync." - exit 1 - fi - if ! git merge-base --is-ancestor "$VERIFIED_STABLE_SHA" origin/stable; then - echo "Verified stable SHA is no longer in origin/stable; refusing automatic sync." - exit 1 - fi - stable_ref="$VERIFIED_STABLE_SHA" - fi + stable_sha=$(git rev-parse origin/stable) git checkout --detach origin/main - if git merge-base --is-ancestor "$stable_ref" origin/main; then + if git merge-base --is-ancestor "$stable_sha" origin/main; then echo "No changes to sync - stable is already in main's ancestry." exit 0 fi @@ -207,15 +130,13 @@ jobs: return "${status}" } - # Use a real merge, not a squash, so stable release tags become - # reachable from main. semantic-release uses reachable tags as the - # version floor for @next prereleases. - if ! git merge --no-ff --no-edit "$stable_ref"; then + # A real merge makes stable release tags reachable from main, which + # semantic-release needs as the version floor for @next. + if ! git merge --no-ff --no-edit "$stable_sha"; then echo "Merge conflict — attempting auto-resolution of release artifacts..." - # Auto-resolve release artifact conflicts only when the conflict is - # version-only: keep stable's already-published version, but never - # drop dependency, script, export, or package metadata changes. + # Only published-version fields may take stable's side. Other + # package metadata conflicts remain manual. while read -r f; do case "$f" in package.json|*/package.json|pyproject.toml|*/pyproject.toml|version.json|*/version.json) @@ -240,12 +161,10 @@ jobs: fi fi - if ! git merge-base --is-ancestor "$stable_ref" HEAD; then + if ! git merge-base --is-ancestor "$stable_sha" HEAD; then echo "Stable is still not in the sync branch ancestry after merge." exit 1 fi - # The merge commit must land unchanged so semantic-release can see - # stable release tags in main's ancestry. A normal push fails safely - # if main advanced while this job was running. + # Push this merge unchanged. A normal push fails safely if main moved. git push origin HEAD:main diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index 47f1338f31..5523dd79ea 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -388,7 +388,7 @@ test('release workflows queue (do not cancel) and use queue: max so multi-packag } }); -test('stable-to-main sync waits for stable release completion', async () => { +test('stable-to-main sync waits for the stable release lane after stable tooling succeeds', async () => { const workflow = await readRepoFile('.github/workflows/sync-patches.yml'); assert.ok( @@ -410,12 +410,12 @@ test('stable-to-main sync waits for stable release completion', async () => { ); assert.ok( workflow.includes("github.event.workflow_run.conclusion == 'success'"), - '.github/workflows/sync-patches.yml: automatic syncs must require a successful stable release', + '.github/workflows/sync-patches.yml: automatic syncs must require a successful stable tooling release', ); assert.equal( workflow.includes("github.event.workflow_run.conclusion == 'failure'"), false, - '.github/workflows/sync-patches.yml: failed stable releases must not push to main automatically', + '.github/workflows/sync-patches.yml: failed stable tooling releases must not push to main automatically', ); assert.ok( workflow.includes("github.event_name == 'workflow_dispatch'"), @@ -431,20 +431,38 @@ test('stable-to-main sync waits for stable release completion', async () => { workflow.includes('"📦 Release template-builder"'), '.github/workflows/sync-patches.yml: must wait for the remaining stable release workflows before syncing origin/stable', ); - assert.ok( - workflow.includes('Verify stable release lane succeeded') && - workflow.includes('--workflow "$workflow_file"') && - workflow.includes('--limit 1') && - workflow.includes('git diff --name-only origin/main...origin/stable') && - workflow.includes('require_successful_release release-esign.yml') && - workflow.includes('require_successful_release release-template-builder.yml') && - workflow.includes('stable_sha=$(git rev-parse origin/stable)'), - '.github/workflows/sync-patches.yml: must require the latest relevant stable release workflows to succeed before syncing', + for (const removedGate of [ + 'Verify stable release lane succeeded', + 'require_successful_release', + '--workflow "$workflow_file"', + 'git diff --name-only origin/main...origin/stable', + 'GITHUB_OUTPUT', + 'release-esign.yml', + 'release-template-builder.yml', + ]) { + assert.equal( + workflow.includes(removedGate), + false, + `.github/workflows/sync-patches.yml: must not retain the aggregate release-health gate (${removedGate})`, + ); + } + assert.equal( + /--limit 1(?:\s|$)/.test(workflow), + false, + '.github/workflows/sync-patches.yml: must not query the latest release run', + ); + assert.equal( + workflow.includes("workflow_run.conclusion == 'success'"), + true, + '.github/workflows/sync-patches.yml: only the triggering stable tooling release determines whether automatic sync starts', ); }); test('stable-to-main sync preserves stable release ancestry', async () => { const workflow = await readRepoFile('.github/workflows/sync-patches.yml'); + const stableShaAssignment = 'stable_sha=$(git rev-parse origin/stable)'; + const pinIndex = workflow.indexOf(stableShaAssignment); + const mergeIndex = workflow.indexOf('git merge --no-ff --no-edit "$stable_sha"'); assert.equal( workflow.includes('git merge --squash'), @@ -452,15 +470,19 @@ test('stable-to-main sync preserves stable release ancestry', async () => { '.github/workflows/sync-patches.yml: stable-to-main sync must not squash because semantic-release needs stable tags reachable from main', ); assert.ok( - workflow.includes('git merge --no-ff --no-edit "$stable_ref"'), - '.github/workflows/sync-patches.yml: stable-to-main sync must create a real merge commit from the verified stable ref', + mergeIndex !== -1, + '.github/workflows/sync-patches.yml: stable-to-main sync must create a real merge commit from the pinned stable SHA', ); assert.ok( - workflow.includes('VERIFIED_STABLE_SHA: ${{ steps.verify_release_lane.outputs.stable_sha }}') && - workflow.includes('git merge-base --is-ancestor "$VERIFIED_STABLE_SHA" origin/stable') && - workflow.includes('git merge-base --is-ancestor "$stable_ref" origin/main') && - workflow.includes('git merge-base --is-ancestor "$stable_ref" HEAD'), - '.github/workflows/sync-patches.yml: automatic sync must pin the verified stable SHA and guard on its ancestry', + pinIndex !== -1 && + (workflow.match(/stable_sha=\$\(git rev-parse origin\/stable\)/g) ?? []).length === 1 && + workflow.indexOf('git fetch origin main stable --tags --prune') < pinIndex && + pinIndex < mergeIndex && + !workflow.slice(pinIndex + stableShaAssignment.length, mergeIndex).includes('git fetch origin') && + !workflow.slice(pinIndex + stableShaAssignment.length, mergeIndex).includes('origin/stable') && + workflow.includes('git merge-base --is-ancestor "$stable_sha" origin/main') && + workflow.includes('git merge-base --is-ancestor "$stable_sha" HEAD'), + '.github/workflows/sync-patches.yml: must pin stable once after fetching and merge that immutable SHA without refreshing stable', ); assert.ok( workflow.includes('release_artifact_only_conflict') && From f9cde058b65882b5be0c1011aa20628910ebfdab Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Jul 2026 21:45:11 +0000 Subject: [PATCH 7/9] fix(ci): guard manual stable sync --- .github/workflows/sync-patches.yml | 3 ++- scripts/__tests__/release-local.test.mjs | 28 ++++++++++-------------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/.github/workflows/sync-patches.yml b/.github/workflows/sync-patches.yml index b94a7bc4c1..2a0cae9fc0 100644 --- a/.github/workflows/sync-patches.yml +++ b/.github/workflows/sync-patches.yml @@ -29,8 +29,9 @@ jobs: ) steps: + # Manual dispatch is the recovery path after an investigated failure, + # but it still waits for active stable releases before pinning stable. - name: Wait for stable release lane to drain - if: github.event_name == 'workflow_run' env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index 5523dd79ea..cd3dc49ecd 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -390,6 +390,10 @@ test('release workflows queue (do not cancel) and use queue: max so multi-packag test('stable-to-main sync waits for the stable release lane after stable tooling succeeds', async () => { const workflow = await readRepoFile('.github/workflows/sync-patches.yml'); + const laneDrainStep = workflow.slice( + workflow.indexOf('- name: Wait for stable release lane to drain'), + workflow.indexOf('- name: Generate token'), + ); assert.ok( workflow.includes('workflow_run:'), @@ -431,14 +435,16 @@ test('stable-to-main sync waits for the stable release lane after stable tooling workflow.includes('"📦 Release template-builder"'), '.github/workflows/sync-patches.yml: must wait for the remaining stable release workflows before syncing origin/stable', ); + assert.equal( + laneDrainStep.includes("if: github.event_name == 'workflow_run'"), + false, + '.github/workflows/sync-patches.yml: manual recovery must also wait for active stable release runs to finish', + ); for (const removedGate of [ 'Verify stable release lane succeeded', - 'require_successful_release', - '--workflow "$workflow_file"', - 'git diff --name-only origin/main...origin/stable', - 'GITHUB_OUTPUT', - 'release-esign.yml', - 'release-template-builder.yml', + 'id: verify_release_lane', + 'require_successful_release()', + 'steps.verify_release_lane.outputs.stable_sha', ]) { assert.equal( workflow.includes(removedGate), @@ -446,16 +452,6 @@ test('stable-to-main sync waits for the stable release lane after stable tooling `.github/workflows/sync-patches.yml: must not retain the aggregate release-health gate (${removedGate})`, ); } - assert.equal( - /--limit 1(?:\s|$)/.test(workflow), - false, - '.github/workflows/sync-patches.yml: must not query the latest release run', - ); - assert.equal( - workflow.includes("workflow_run.conclusion == 'success'"), - true, - '.github/workflows/sync-patches.yml: only the triggering stable tooling release determines whether automatic sync starts', - ); }); test('stable-to-main sync preserves stable release ancestry', async () => { From 5671e069a7672397ce9264538db09922f11e021f Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Jul 2026 21:54:32 +0000 Subject: [PATCH 8/9] fix(ci): validate pinned stable history --- .github/workflows/sync-patches.yml | 26 ++++++++++++++++++++++++ scripts/__tests__/release-local.test.mjs | 20 +++++++++++++----- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/.github/workflows/sync-patches.yml b/.github/workflows/sync-patches.yml index 2a0cae9fc0..f0238e4702 100644 --- a/.github/workflows/sync-patches.yml +++ b/.github/workflows/sync-patches.yml @@ -80,6 +80,9 @@ jobs: token: ${{ steps.generate_token.outputs.token }} - name: Sync stable into main + env: + EVENT_NAME: ${{ github.event_name }} + TRIGGER_STABLE_SHA: ${{ github.event.workflow_run.head_sha }} run: | set -euo pipefail @@ -89,6 +92,29 @@ jobs: git fetch origin main stable --tags --prune stable_sha=$(git rev-parse origin/stable) + if [ "$EVENT_NAME" = "workflow_run" ]; then + if ! git merge-base --is-ancestor "$TRIGGER_STABLE_SHA" "$stable_sha"; then + echo "Pinned stable SHA does not descend from the successful tooling release." + exit 1 + fi + + unexpected_commit=false + while read -r sha subject; do + [ -z "$sha" ] && continue + case "$subject" in + *"[skip ci]") ;; + *) + echo "Pinned stable includes a newer unverified commit: $sha $subject" + unexpected_commit=true + ;; + esac + done < <(git log --format='%H %s' "$TRIGGER_STABLE_SHA..$stable_sha") + + if [ "$unexpected_commit" = true ]; then + exit 1 + fi + fi + git checkout --detach origin/main if git merge-base --is-ancestor "$stable_sha" origin/main; then diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index cd3dc49ecd..376b61e146 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -390,10 +390,14 @@ test('release workflows queue (do not cancel) and use queue: max so multi-packag test('stable-to-main sync waits for the stable release lane after stable tooling succeeds', async () => { const workflow = await readRepoFile('.github/workflows/sync-patches.yml'); - const laneDrainStep = workflow.slice( - workflow.indexOf('- name: Wait for stable release lane to drain'), - workflow.indexOf('- name: Generate token'), - ); + const laneDrainStart = workflow.indexOf('- name: Wait for stable release lane to drain'); + const laneDrainEnd = workflow.indexOf('- name: Generate token'); + + assert.notEqual(laneDrainStart, -1, '.github/workflows/sync-patches.yml: must include the stable lane drain step'); + assert.notEqual(laneDrainEnd, -1, '.github/workflows/sync-patches.yml: must include the token generation step'); + assert.ok(laneDrainStart < laneDrainEnd, '.github/workflows/sync-patches.yml: must drain the stable lane before checkout'); + + const laneDrainStep = workflow.slice(laneDrainStart, laneDrainEnd); assert.ok( workflow.includes('workflow_run:'), @@ -458,6 +462,7 @@ test('stable-to-main sync preserves stable release ancestry', async () => { const workflow = await readRepoFile('.github/workflows/sync-patches.yml'); const stableShaAssignment = 'stable_sha=$(git rev-parse origin/stable)'; const pinIndex = workflow.indexOf(stableShaAssignment); + const verificationIndex = workflow.indexOf('git merge-base --is-ancestor "$TRIGGER_STABLE_SHA" "$stable_sha"'); const mergeIndex = workflow.indexOf('git merge --no-ff --no-edit "$stable_sha"'); assert.equal( @@ -476,9 +481,14 @@ test('stable-to-main sync preserves stable release ancestry', async () => { pinIndex < mergeIndex && !workflow.slice(pinIndex + stableShaAssignment.length, mergeIndex).includes('git fetch origin') && !workflow.slice(pinIndex + stableShaAssignment.length, mergeIndex).includes('origin/stable') && + pinIndex < verificationIndex && + verificationIndex < mergeIndex && + workflow.includes('TRIGGER_STABLE_SHA: ${{ github.event.workflow_run.head_sha }}') && + workflow.includes('"$TRIGGER_STABLE_SHA..$stable_sha"') && + workflow.includes('*"[skip ci]"') && workflow.includes('git merge-base --is-ancestor "$stable_sha" origin/main') && workflow.includes('git merge-base --is-ancestor "$stable_sha" HEAD'), - '.github/workflows/sync-patches.yml: must pin stable once after fetching and merge that immutable SHA without refreshing stable', + '.github/workflows/sync-patches.yml: must allow only release writebacks after the successful trigger before merging the pinned stable SHA', ); assert.ok( workflow.includes('release_artifact_only_conflict') && From f150f298bccb946ed3526300db5b38e1b89c414b Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Jul 2026 22:02:41 +0000 Subject: [PATCH 9/9] fix(ci): verify release writeback provenance --- .github/workflows/sync-patches.yml | 35 +++++++++++++++++++----- scripts/__tests__/release-local.test.mjs | 6 ++-- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/.github/workflows/sync-patches.yml b/.github/workflows/sync-patches.yml index f0238e4702..b0dd811ced 100644 --- a/.github/workflows/sync-patches.yml +++ b/.github/workflows/sync-patches.yml @@ -98,16 +98,37 @@ jobs: exit 1 fi + release_subject_pattern='^chore(\([^)]+\))?: [^[:space:]]+ \[skip ci\]$' unexpected_commit=false while read -r sha subject; do [ -z "$sha" ] && continue - case "$subject" in - *"[skip ci]") ;; - *) - echo "Pinned stable includes a newer unverified commit: $sha $subject" - unexpected_commit=true - ;; - esac + + author_email=$(git show -s --format='%ae' "$sha") + committer_email=$(git show -s --format='%ce' "$sha") + if [ "$author_email" != "semantic-release-bot@martynus.net" ] || + [ "$committer_email" != "semantic-release-bot@martynus.net" ] || + [[ ! "$subject" =~ $release_subject_pattern ]]; then + echo "Pinned stable includes a non-release commit: $sha $subject" + unexpected_commit=true + continue + fi + + changed_files=$(git diff-tree --no-commit-id --name-only -r "$sha") + if [ -z "$changed_files" ]; then + echo "Release writeback has no version files: $sha" + unexpected_commit=true + continue + fi + + while read -r f; do + case "$f" in + package.json|*/package.json|pyproject.toml|*/pyproject.toml|version.json|*/version.json) ;; + *) + echo "Release writeback changes an unexpected file: $sha $f" + unexpected_commit=true + ;; + esac + done <<< "$changed_files" done < <(git log --format='%H %s' "$TRIGGER_STABLE_SHA..$stable_sha") if [ "$unexpected_commit" = true ]; then diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index 376b61e146..607ac75eab 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -485,10 +485,12 @@ test('stable-to-main sync preserves stable release ancestry', async () => { verificationIndex < mergeIndex && workflow.includes('TRIGGER_STABLE_SHA: ${{ github.event.workflow_run.head_sha }}') && workflow.includes('"$TRIGGER_STABLE_SHA..$stable_sha"') && - workflow.includes('*"[skip ci]"') && + workflow.includes('semantic-release-bot@martynus.net') && + workflow.includes('release_subject_pattern=') && + workflow.includes('git diff-tree --no-commit-id --name-only -r "$sha"') && workflow.includes('git merge-base --is-ancestor "$stable_sha" origin/main') && workflow.includes('git merge-base --is-ancestor "$stable_sha" HEAD'), - '.github/workflows/sync-patches.yml: must allow only release writebacks after the successful trigger before merging the pinned stable SHA', + '.github/workflows/sync-patches.yml: must verify release writeback provenance and files before merging the pinned stable SHA', ); assert.ok( workflow.includes('release_artifact_only_conflict') &&