diff --git a/.github/workflows/sync-patches.yml b/.github/workflows/sync-patches.yml index 980eb49041..b0dd811ced 100644 --- a/.github/workflows/sync-patches.yml +++ b/.github/workflows/sync-patches.yml @@ -1,12 +1,11 @@ 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. +# 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: - - "📦 Release stable tooling (CLI/SDK/MCP)" + - '📦 Release stable tooling (CLI/SDK/MCP)' types: - completed workflow_dispatch: @@ -14,7 +13,6 @@ on: permissions: actions: read contents: write - pull-requests: write concurrency: group: sync-stable-to-main @@ -27,15 +25,13 @@ 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: + # 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 }} @@ -52,7 +48,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 @@ -85,7 +81,8 @@ jobs: - name: Sync stable into main env: - GH_TOKEN: ${{ steps.generate_token.outputs.token }} + EVENT_NAME: ${{ github.event_name }} + TRIGGER_STABLE_SHA: ${{ github.event.workflow_run.head_sha }} run: | set -euo pipefail @@ -93,18 +90,59 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git fetch origin main stable --tags --prune + stable_sha=$(git rev-parse origin/stable) - SYNC_BRANCH="sync/stable-to-main-$(date +%Y%m%d-%H%M%S)" - git checkout -b "$SYNC_BRANCH" origin/main + 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 + + release_subject_pattern='^chore(\([^)]+\))?: [^[:space:]]+ \[skip ci\]$' + unexpected_commit=false + while read -r sha subject; do + [ -z "$sha" ] && continue + + 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 + exit 1 + fi + fi - if git merge-base --is-ancestor origin/stable origin/main; then + git checkout --detach origin/main + + 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 - 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=(",", ":")))' "$@" } @@ -140,17 +178,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 origin/stable; then - MERGE_STATUS="clean" - else + # 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) @@ -166,7 +200,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." @@ -176,45 +209,10 @@ jobs: fi fi - if ! git merge-base --is-ancestor origin/stable 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 - 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 < { +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 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:'), '.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 +417,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 tooling release', + ); + assert.equal( + workflow.includes("github.event.workflow_run.conclusion == 'failure'"), + false, + '.github/workflows/sync-patches.yml: failed stable tooling 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'), @@ -423,10 +439,31 @@ 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.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', + 'id: verify_release_lane', + 'require_successful_release()', + 'steps.verify_release_lane.outputs.stable_sha', + ]) { + assert.equal( + workflow.includes(removedGate), + false, + `.github/workflows/sync-patches.yml: must not retain the aggregate release-health gate (${removedGate})`, + ); + } }); 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( workflow.includes('git merge --squash'), @@ -434,13 +471,26 @@ 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', + 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('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', + 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') && + pinIndex < verificationIndex && + verificationIndex < mergeIndex && + workflow.includes('TRIGGER_STABLE_SHA: ${{ github.event.workflow_run.head_sha }}') && + workflow.includes('"$TRIGGER_STABLE_SHA..$stable_sha"') && + 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 verify release writeback provenance and files before merging the pinned stable SHA', ); assert.ok( workflow.includes('release_artifact_only_conflict') && @@ -451,11 +501,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', ); });