diff --git a/.github/scripts/__tests__/sync_pr_lease_contract.test.js b/.github/scripts/__tests__/sync_pr_lease_contract.test.js index de62e1e68..ca648f6df 100644 --- a/.github/scripts/__tests__/sync_pr_lease_contract.test.js +++ b/.github/scripts/__tests__/sync_pr_lease_contract.test.js @@ -8,6 +8,7 @@ const { parseDeliveryRecord, mergeEligibility, } = require('../sync_pr_lease_contract'); +const { selectMergeEligibleSyncPr } = require('../sync_pr_merge_contract'); const current = { schema: DELIVERY_RECORD_SCHEMA, @@ -37,3 +38,38 @@ test('an unexpired matching delivery record is merge eligible', () => { }).reason, 'lease_expired'); assert.equal(mergeEligibility(parsed, { now: '2026-08-01T22:00:00Z', desiredTreeHash: 'other' }).reason, 'desired_tree_mismatch'); }); + +test('only the newest matching generation is selected for merge', () => { + const old = { + number: 10, + created_at: '2026-08-01T20:00:00Z', + head: { ref: 'sync/workflows-old' }, + body: formatDeliveryRecord({ ...current, generation: 'new', desired_tree_hash: 'tree-new' }), + }; + const newest = { + number: 11, + created_at: '2026-08-01T21:00:00Z', + head: { ref: 'sync/workflows-new' }, + body: formatDeliveryRecord({ ...current, generation: 'new', desired_tree_hash: 'tree-new' }), + }; + + const result = selectMergeEligibleSyncPr([old, newest], { + syncHash: 'new', + now: '2026-08-01T22:00:00Z', + planId: 'plan-abc', + repository: 'stranske/Ready', + desiredTreeHash: 'tree-new', + }); + + assert.equal(result.active.number, newest.number); + assert.deepEqual(result.stale.map((pr) => pr.number), [old.number]); + assert.deepEqual(result.eligibility, { eligible: true, reason: 'current_unexpired' }); +}); + +test('deliberate break: expired or terminal records cannot merge', () => { + const now = '2026-08-01T22:00:00Z'; + assert.equal(mergeEligibility({ ...current, lease_expires_at: '2026-08-01T21:59:59Z' }, { now }).eligible, false); + for (const terminal_disposition of ['merged', 'superseded', 'expired', 'blocked']) { + assert.equal(mergeEligibility({ ...current, terminal_disposition }, { now }).eligible, false); + } +}); diff --git a/.github/workflows/maint-68-sync-consumer-repos.yml b/.github/workflows/maint-68-sync-consumer-repos.yml index 1dec44a5e..7287b63f2 100644 --- a/.github/workflows/maint-68-sync-consumer-repos.yml +++ b/.github/workflows/maint-68-sync-consumer-repos.yml @@ -742,24 +742,66 @@ jobs: branch_name="sync/workflows-${{ needs.prepare.outputs.template_hash }}" + # Configure git for push/fetch authentication using credential helper + # This avoids exposing token in git remote URL or command output. + # Must be configured before any git operations that need auth + # (including the early fetch of an existing sync branch). + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + credential_helper="!f() { echo \"username=x-access-token\"; echo \"password=\$GH_TOKEN\"; }; f" + git config credential.helper "$credential_helper" + # Keep an existing generated PR current instead of treating it as a - # terminal delivery attempt: rebuild its branch and refresh its lease. + # terminal delivery attempt: rebuild its branch and refresh its lease + # only when the existing attempt still has a current delivery record. existing_pr=$(gh pr list --head "$branch_name" --json number -q '.[0].number' || echo "") + existing_head="" + existing_base="" + existing_tree="" + existing_refreshable=false + existing_refresh_reason="none" if [ -n "$existing_pr" ]; then - echo "Refreshing existing sync PR #$existing_pr" + echo "Inspecting existing sync PR #$existing_pr for same-generation refresh" + # Capture the remote head now so a later force push cannot overwrite + # an intervening consumer edit. + git fetch origin "$branch_name" + existing_head=$(git rev-parse FETCH_HEAD) + existing_base=$(git rev-parse "${existing_head}^") + existing_tree=$(git rev-parse "${existing_head}^{tree}") + existing_body_file=$(mktemp) + gh pr view "$existing_pr" --json body -q .body >"$existing_body_file" || true + # shellcheck disable=SC2016 # The embedded Node source must remain literal. + refresh_check=$( + EXISTING_BODY_FILE="$existing_body_file" \ + DELIVERY_GENERATION="$DELIVERY_GENERATION" \ + PLAN_ID="$PLAN_ID" \ + DELIVERY_REPOSITORY="$DELIVERY_REPOSITORY" \ + node -e ' + const fs = require("fs"); + const { parseDeliveryRecord, mergeEligibility } = require("../workflows/.github/scripts/sync_pr_lease_contract"); + const body = fs.readFileSync(process.env.EXISTING_BODY_FILE || "", "utf8"); + const record = parseDeliveryRecord(body); + if (!record) { + process.stdout.write("false missing_or_invalid"); + process.exit(0); + } + if (record.generation !== (process.env.DELIVERY_GENERATION || "")) { + process.stdout.write("false generation_mismatch"); + process.exit(0); + } + const result = mergeEligibility(record, { + planId: process.env.PLAN_ID || "", + repository: process.env.DELIVERY_REPOSITORY || "", + }); + process.stdout.write(`${result.eligible ? "true" : "false"} ${result.reason}`); + ' + ) + rm -f "$existing_body_file" + existing_refreshable=${refresh_check%% *} + existing_refresh_reason=${refresh_check#* } + echo "Existing PR #$existing_pr refreshable=${existing_refreshable} reason=${existing_refresh_reason}" fi - # Configure git for push authentication using credential helper - # This avoids exposing token in git remote URL or command output - # Must be configured before any git operations that need auth - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - # shellcheck disable=SC2016 # GH_TOKEN expands at runtime - credential_helper='!f() { echo "username=x-access-token";' - credential_helper+=" echo \"password=${GH_TOKEN}\";" - credential_helper+=' }; f' - git config credential.helper "$credential_helper" - # Remote branch exists but no PR was found # (likely from a failed previous run) - deleting and recreating if [ -z "$existing_pr" ] && git ls-remote --exit-code origin "refs/heads/$branch_name" >/dev/null 2>&1; then @@ -779,7 +821,8 @@ jobs: } fi - # Create branch and commit + # Create the current-generation branch from the current consumer base. + base_sha=$(git rev-parse HEAD) git checkout -B "$branch_name" # Stage ONLY manifest-declared targets, to avoid accidentally committing @@ -801,23 +844,45 @@ jobs: git add --force -A fi - if git diff --cached --quiet; then + desired_tree_hash=$(git write-tree) + matching_existing=false + if [ -n "$existing_pr" ] \ + && [ "$existing_refreshable" = "true" ] \ + && [ "$existing_base" = "$base_sha" ] \ + && [ "$existing_tree" = "$desired_tree_hash" ]; then + matching_existing=true + echo "Existing PR #$existing_pr already matches this base and desired tree with a current lease; refreshing only its lease" + elif [ -n "$existing_pr" ] \ + && [ "$existing_base" = "$base_sha" ] \ + && [ "$existing_tree" = "$desired_tree_hash" ] \ + && [ "$existing_refreshable" != "true" ]; then + echo "Existing PR #$existing_pr matches base/tree but is not refreshable (${existing_refresh_reason}); leaving it for Maint 71 disposition." + echo "status=existing_pr_not_refreshable" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if git diff --cached --quiet && [ "$matching_existing" != "true" ]; then echo "No changes to commit after sync; skipping PR creation." echo "status=no_committed_changes" >> "$GITHUB_OUTPUT" exit 0 fi - git commit -m "chore: sync workflow templates from Workflows repo + if [ "$matching_existing" != "true" ]; then + git commit -m "chore: sync workflow templates from Workflows repo - Automated sync from stranske/Workflows - Template hash: ${{ needs.prepare.outputs.template_hash }} + Automated sync from stranske/Workflows + Template hash: ${{ needs.prepare.outputs.template_hash }} - Changes synced from sync-manifest.yml" + Changes synced from sync-manifest.yml" - git push --quiet -u origin "$branch_name" + if [ -n "$existing_pr" ]; then + git push --quiet --force-with-lease="refs/heads/$branch_name:$existing_head" -u origin "$branch_name" + else + git push --quiet -u origin "$branch_name" + fi + fi # Create PR - desired_tree_hash=$(git rev-parse 'HEAD^{tree}') lease_expires_at=$(date -u -d '+72 hours' +%Y-%m-%dT%H:%M:%SZ) sync_marker=$(jq -nc \ --arg schema "workflows-consumer-sync-pr/v1" \ diff --git a/tests/workflows/test_sync_manifest_delivery.py b/tests/workflows/test_sync_manifest_delivery.py index 86dd33c10..1f4cd242f 100644 --- a/tests/workflows/test_sync_manifest_delivery.py +++ b/tests/workflows/test_sync_manifest_delivery.py @@ -245,3 +245,39 @@ def test_maint_71_emits_canary_evidence_with_review_debt() -> None: assert "active_review_thread_count" in source assert "required_check_state" in source assert "plan_id" in source + + +def test_maint68_refreshes_only_a_same_base_and_tree_delivery_attempt() -> None: + """A current generation reuses its PR; a changed base/tree must be replaced safely.""" + source = SYNC_WORKFLOW_PATH.read_text(encoding="utf-8") + + credential_idx = source.index('git config credential.helper "$credential_helper"') + fetch_idx = source.index('git fetch origin "$branch_name"') + assert credential_idx < fetch_idx + + assert 'git fetch origin "$branch_name"' in source + assert 'existing_base=$(git rev-parse "${existing_head}^")' in source + assert 'existing_tree=$(git rev-parse "${existing_head}^{tree}")' in source + assert "desired_tree_hash=$(git write-tree)" in source + assert "existing_refreshable=false" in source + assert "parseDeliveryRecord" in source + assert "mergeEligibility" in source + assert "status=existing_pr_not_refreshable" in source + assert '[ "$existing_refreshable" = "true" ]' in source + assert '[ "$existing_base" = "$base_sha" ]' in source + assert '[ "$existing_tree" = "$desired_tree_hash" ]' in source + assert "matching_existing=true" in source + commit_push_guard = source.index('if [ "$matching_existing" != "true" ]; then') + assert ( + source.index( + 'git commit -m "chore: sync workflow templates from Workflows repo', commit_push_guard + ) + > commit_push_guard + ) + assert ( + source.index( + 'git push --quiet --force-with-lease="refs/heads/$branch_name:$existing_head"', + commit_push_guard, + ) + > commit_push_guard + )