diff --git a/.github/scripts/__tests__/sync_pr_merge_contract.test.js b/.github/scripts/__tests__/sync_pr_merge_contract.test.js index e9022fb2d..31d031607 100644 --- a/.github/scripts/__tests__/sync_pr_merge_contract.test.js +++ b/.github/scripts/__tests__/sync_pr_merge_contract.test.js @@ -56,6 +56,7 @@ const { campaignNoChangeRequiresLiveGate, collectReviewerEvidence, legacyStatusAsCheck, + mergeMethodPolicyAllowsFallback, normalizeReviewPolicy, parseNoChangeEvidenceDocument, parseReviewResolutionProofs, @@ -64,6 +65,28 @@ const { validateReviewResolutionProof, } = require('../maint71_merge_sync_prs'); +test('Maint 71 falls back only when repository policy rejects a merge method', () => { + for (const [method, message] of [ + ['merge', 'Merge commits are not allowed on this repository.'], + ['squash', 'Squash merges are not allowed on this repository.'], + ['rebase', 'Rebase merges are not allowed on this repository.'], + ['squash', 'The selected merge method is not allowed.'], + ]) { + assert.equal(mergeMethodPolicyAllowsFallback(new Error(message), method), true, message); + } + + for (const [method, message] of [ + ['merge', 'Repository rule violations found: Required status check gate-summary is expected.'], + ['squash', 'Repository rule violations found: At least 1 approving review is required.'], + ['merge', 'Head branch was modified. Review and try the merge again.'], + ['squash', 'Required status check gate-summary is failing.'], + ['rebase', 'Resource not accessible by integration.'], + ['merge', 'Merge conflict'], + ]) { + assert.equal(mergeMethodPolicyAllowsFallback(new Error(message), method), false, message); + } +}); + test('Maint 71 keeps Collab-Admin out of fleet runs but allows an explicit manual reconciliation', () => { const excludedRepos = new Set(['stranske/Collab-Admin']); const manuallyReconcilableRepos = new Set(['stranske/Collab-Admin']); diff --git a/.github/scripts/maint71_merge_sync_prs.js b/.github/scripts/maint71_merge_sync_prs.js index be853b9f7..6bf5a31ff 100644 --- a/.github/scripts/maint71_merge_sync_prs.js +++ b/.github/scripts/maint71_merge_sync_prs.js @@ -147,6 +147,25 @@ function campaignNoChangeRequiresLiveGate(evidence = {}) { return evidence?.evidence_source !== 'no-change-delivery'; } +const MERGE_METHOD_DENIAL_PATTERNS = { + merge: ['merge commits are not allowed', 'merge commit is not allowed'], + squash: ['squash merges are not allowed', 'squash merge is not allowed'], + rebase: ['rebase merges are not allowed', 'rebase merge is not allowed'], +}; + +function mergeMethodPolicyAllowsFallback(error, mergeMethod) { + const message = String(error?.message || error || '').toLowerCase(); + const method = String(mergeMethod || '').toLowerCase(); + const patterns = MERGE_METHOD_DENIAL_PATTERNS[method] || []; + if (patterns.some((pattern) => message.includes(pattern))) { + return true; + } + if (message.includes('merge method is not allowed')) { + return true; + } + return false; +} + function validateReviewResolutionProof(proof = {}, { owner, repo, @@ -2429,7 +2448,7 @@ async function run({ github, context, core }) { lastError = e; const message = String(e?.message || 'unknown error'); console.log(`⚠ Merge attempt failed (method=${merge_method}): ${message}`); - if (!message.toLowerCase().includes('repository rule violations')) { + if (!mergeMethodPolicyAllowsFallback(e, merge_method)) { break; } } @@ -2617,6 +2636,7 @@ module.exports = { campaignNoChangeRequiresLiveGate, collectReviewerEvidence, legacyStatusAsCheck, + mergeMethodPolicyAllowsFallback, normalizeReviewPolicy, parseNoChangeEvidenceDocument, parseReviewResolutionProofs, diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index 0553a15fb..bf70db09f 100644 --- a/docs/WORKFLOW_GUIDE.md +++ b/docs/WORKFLOW_GUIDE.md @@ -64,7 +64,7 @@ _Inline Gate helper_ - **`maint-69-sync-labels.yml`** — Propagates the canonical `.github/labels-core.yml` set to every registered consumer repo (or a provided subset), reusing the registered-repo helper + load-balanced API client without any additional App-token minting. - **`maint-70-fix-integration-formatting.yml`** — Manual formatter for Workflows-Integration-Tests that resolves the repo default branch, applies `black`+`ruff` fixes, and pushes via PAT only when a token is available; runs read-only otherwise. - **`maint-71-auto-fix-integration.yml`** — Auto-triggered integration fixer that watches “Integration CI failed” issues/comments, re-runs the formatting routine, and pushes via PAT when available (otherwise posting a skipped note). -- **`maint-71-merge-sync-prs.yml`** — Scans each registered non-admin consumer repo for generated deliveries, closes stale duplicates, deletes leftover branches, and is the only lane allowed to merge a stable consumer delivery. Its cross-repository mutations require `OWNER_PR_PAT`; the workflow fails closed rather than rotating to a partial-scope service or default token. Stable PRs advance `staging` → `reviewing` → `sealed`: one substantive configured-reviewer response is sufficient after seven minutes, all-capacity-unavailable responses may degrade after that quiet period, and zero response degrades after fifteen minutes. It never waits for all configured reviewers. A status that explicitly says the review was skipped, excluded, review-disabled, or not performed is unavailable evidence rather than a response; a completed negative verdict with substantive output is still a response. None of those fallbacks waives an active non-outdated review thread. A specific thread may be resolved only from an authenticated exact-head proof that names the merged Workflows source fix and whose source commit is contained in the delivery. The exact sealed head must be validly GitHub-signed and then pass a freshly triggered Gate before merge; unsigned or non-GitHub-signed heads are returned to Maint 68 for replacement. The staging label remains a shared merger hold until Maint 71 completes the merge. Candidate evidence authorizes promotion but leaves the stable candidate PRs open. Promotion prepares the non-canary delivery PRs, and a campaign pass merges both stable lanes only after every registered repo is exact-head prepared or proven unchanged. A promotion-produced `no-change-delivery` row is terminal when its immutable plan/source binding and exact-tree validation are valid and the recorded default-branch head is still current; because no consumer head changed, campaign reconciliation does not create a redundant default-branch Gate. Canary no-change rows retain their live required-check revalidation. A workflow-sync selector ignores sibling dev-tool PRs when deciding whether its expected stable branch exists, so independent lanes do not create false `target_missing` failures. Required checks come from legacy branch protection when visible, otherwise active repository and inherited organization rulesets. +- **`maint-71-merge-sync-prs.yml`** — Scans each registered non-admin consumer repo for generated deliveries, closes stale duplicates, deletes leftover branches, and is the only lane allowed to merge a stable consumer delivery. Its cross-repository mutations require `OWNER_PR_PAT`; the workflow fails closed rather than rotating to a partial-scope service or default token. Stable PRs advance `staging` → `reviewing` → `sealed`: one substantive configured-reviewer response is sufficient after seven minutes, all-capacity-unavailable responses may degrade after that quiet period, and zero response degrades after fifteen minutes. It never waits for all configured reviewers. A status that explicitly says the review was skipped, excluded, review-disabled, or not performed is unavailable evidence rather than a response; a completed negative verdict with substantive output is still a response. None of those fallbacks waives an active non-outdated review thread. A specific thread may be resolved only from an authenticated exact-head proof that names the merged Workflows source fix and whose source commit is contained in the delivery. The exact sealed head must be validly GitHub-signed and then pass a freshly triggered Gate before merge; unsigned or non-GitHub-signed heads are returned to Maint 68 for replacement. If repository policy disables one GitHub merge method, Maint 71 tries the remaining methods in order and repeats the exact-head/review-thread gate before each fallback; unrelated merge failures remain terminal. The staging label remains a shared merger hold until Maint 71 completes the merge. Candidate evidence authorizes promotion but leaves the stable candidate PRs open. Promotion prepares the non-canary delivery PRs, and a campaign pass merges both stable lanes only after every registered repo is exact-head prepared or proven unchanged. A promotion-produced `no-change-delivery` row is terminal when its immutable plan/source binding and exact-tree validation are valid and the recorded default-branch head is still current; because no consumer head changed, campaign reconciliation does not create a redundant default-branch Gate. Canary no-change rows retain their live required-check revalidation. A workflow-sync selector ignores sibling dev-tool PRs when deciding whether its expected stable branch exists, so independent lanes do not create false `target_missing` failures. Required checks come from legacy branch protection when visible, otherwise active repository and inherited organization rulesets. - **`maint-72-fix-pr-body-conflicts.yml`** — Periodically removes stray `pr_body.md` files from consumer repos and ensures `.gitignore` blocks them, reusing the registered-repo helper + PAT discovery so cleanups only run when push access is available. - **`maint-74-ledger-base-sync.yml`** — Keeps `.agents` ledger base entries aligned with the repo’s default branch by running `scripts/ledger_migrate_base.py` and opening a helper PR (no extra App token mint needed). - **`maint-auto-update-pypi-versions.yml`** — Monday 03:00 UTC canonical source lane that batches routine PyPI pin updates into one mutable source PR; an explicit security override may run outside that window. diff --git a/docs/ops/CONSUMER_REPO_MAINTENANCE.md b/docs/ops/CONSUMER_REPO_MAINTENANCE.md index 3c72a7dd5..d84a124d3 100644 --- a/docs/ops/CONSUMER_REPO_MAINTENANCE.md +++ b/docs/ops/CONSUMER_REPO_MAINTENANCE.md @@ -471,6 +471,9 @@ generated PR lands. Maint 71 also enforces the seven-minute exact-head post-push window and performs a final head plus active-review-thread query immediately before each generated merge. +If repository policy disables the first merge method, Maint 71 tries the remaining +GitHub-supported methods in order and repeats that exact-head/review-thread gate before +each fallback. Other merge failures remain terminal and are not retried as policy drift. Maint 68 records the exact head and its post-publication observation time in the delivery record. That SHA-bound observation anchors the window; PR body edits, labels, comments, and review-thread resolution do not restart it, while a