From 50e4ffeb2e6440312397e4dfff2649ebeb112b67 Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Sat, 15 Aug 2026 14:39:17 -0500 Subject: [PATCH 1/7] fix(sync): reserve Maint 71 writes for owner token --- .../__tests__/token-load-balancer.test.js | 18 ++++++++++++++++++ .github/scripts/token_load_balancer.js | 8 +++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/scripts/__tests__/token-load-balancer.test.js b/.github/scripts/__tests__/token-load-balancer.test.js index 06fcf2b1d..a48f099aa 100644 --- a/.github/scripts/__tests__/token-load-balancer.test.js +++ b/.github/scripts/__tests__/token-load-balancer.test.js @@ -124,6 +124,24 @@ test('getBestAvailableToken: handles tokens with zero remaining', () => { assert.equal(best.id, 'ONE'); }); +test('getOptimalToken: reserves Maint 71 cross-repo PR writes for the owner PAT', async () => { + seedRegistry([ + { id: 'SERVICE_BOT_PAT', remaining: 4999 }, + { id: 'OWNER_PR_PAT', remaining: 3000 }, + ]); + balancer.tokenRegistry.lastRefresh = Date.now(); + + const selection = await balancer.getOptimalToken({ + github: {}, + core: {}, + capabilities: ['pull-requests:write'], + task: 'maint-71-merge-sync-prs', + }); + + assert.equal(selection.source, 'OWNER_PR_PAT'); + assert.equal(selection.exclusive, true); +}); + // --------------------------------------------------------------------------- // hasHealthyTokens // --------------------------------------------------------------------------- diff --git a/.github/scripts/token_load_balancer.js b/.github/scripts/token_load_balancer.js index 970a93aba..064b63346 100644 --- a/.github/scripts/token_load_balancer.js +++ b/.github/scripts/token_load_balancer.js @@ -105,7 +105,13 @@ const TOKEN_SPECIALIZATIONS = { description: 'Workflow dispatch triggers and belt conveyor operations', }, OWNER_PR_PAT: { - primaryTasks: ['pr-creation-as-owner', 'cross-repo-sync', 'dependabot-automerge', 'label-sync'], + primaryTasks: [ + 'pr-creation-as-owner', + 'cross-repo-sync', + 'dependabot-automerge', + 'label-sync', + 'maint-71-merge-sync-prs', + ], exclusive: true, description: 'Owner PAT for PR creation and owner-scoped cross-repo operations', }, From 6bf86d40c60195d3b30cb37fd53877e76ad69e4e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:39:42 +0000 Subject: [PATCH 2/7] chore: sync consumer templates --- .../consumer-repo/.github/scripts/token_load_balancer.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/templates/consumer-repo/.github/scripts/token_load_balancer.js b/templates/consumer-repo/.github/scripts/token_load_balancer.js index 970a93aba..064b63346 100644 --- a/templates/consumer-repo/.github/scripts/token_load_balancer.js +++ b/templates/consumer-repo/.github/scripts/token_load_balancer.js @@ -105,7 +105,13 @@ const TOKEN_SPECIALIZATIONS = { description: 'Workflow dispatch triggers and belt conveyor operations', }, OWNER_PR_PAT: { - primaryTasks: ['pr-creation-as-owner', 'cross-repo-sync', 'dependabot-automerge', 'label-sync'], + primaryTasks: [ + 'pr-creation-as-owner', + 'cross-repo-sync', + 'dependabot-automerge', + 'label-sync', + 'maint-71-merge-sync-prs', + ], exclusive: true, description: 'Owner PAT for PR creation and owner-scoped cross-repo operations', }, From 064df10cd50cd499881950325c35f8960fd7ee98 Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Sat, 15 Aug 2026 14:41:24 -0500 Subject: [PATCH 3/7] fix(sync): keep owner routing local to Maint 71 --- .../__tests__/token-load-balancer.test.js | 18 ------------------ .github/scripts/maint71_merge_sync_prs.js | 5 ++++- .github/scripts/token_load_balancer.js | 8 +------- .../.github/scripts/token_load_balancer.js | 8 +------- tests/workflows/test_sync_delivery_liveness.py | 1 + 5 files changed, 7 insertions(+), 33 deletions(-) diff --git a/.github/scripts/__tests__/token-load-balancer.test.js b/.github/scripts/__tests__/token-load-balancer.test.js index a48f099aa..06fcf2b1d 100644 --- a/.github/scripts/__tests__/token-load-balancer.test.js +++ b/.github/scripts/__tests__/token-load-balancer.test.js @@ -124,24 +124,6 @@ test('getBestAvailableToken: handles tokens with zero remaining', () => { assert.equal(best.id, 'ONE'); }); -test('getOptimalToken: reserves Maint 71 cross-repo PR writes for the owner PAT', async () => { - seedRegistry([ - { id: 'SERVICE_BOT_PAT', remaining: 4999 }, - { id: 'OWNER_PR_PAT', remaining: 3000 }, - ]); - balancer.tokenRegistry.lastRefresh = Date.now(); - - const selection = await balancer.getOptimalToken({ - github: {}, - core: {}, - capabilities: ['pull-requests:write'], - task: 'maint-71-merge-sync-prs', - }); - - assert.equal(selection.source, 'OWNER_PR_PAT'); - assert.equal(selection.exclusive, true); -}); - // --------------------------------------------------------------------------- // hasHealthyTokens // --------------------------------------------------------------------------- diff --git a/.github/scripts/maint71_merge_sync_prs.js b/.github/scripts/maint71_merge_sync_prs.js index 9fc382d34..3f13f7517 100644 --- a/.github/scripts/maint71_merge_sync_prs.js +++ b/.github/scripts/maint71_merge_sync_prs.js @@ -350,7 +350,10 @@ async function run({ github, context, core }) { github, core, env: process.env, - task: 'maint-71-merge-sync-prs', + // Maint 71 mutates stable PRs across the complete consumer fleet. + // Use the registry's existing owner-only route instead of letting + // remaining capacity select a token without access to every repo. + task: 'cross-repo-sync', capabilities: [ 'pull-requests:read', 'pull-requests:write', diff --git a/.github/scripts/token_load_balancer.js b/.github/scripts/token_load_balancer.js index 064b63346..970a93aba 100644 --- a/.github/scripts/token_load_balancer.js +++ b/.github/scripts/token_load_balancer.js @@ -105,13 +105,7 @@ const TOKEN_SPECIALIZATIONS = { description: 'Workflow dispatch triggers and belt conveyor operations', }, OWNER_PR_PAT: { - primaryTasks: [ - 'pr-creation-as-owner', - 'cross-repo-sync', - 'dependabot-automerge', - 'label-sync', - 'maint-71-merge-sync-prs', - ], + primaryTasks: ['pr-creation-as-owner', 'cross-repo-sync', 'dependabot-automerge', 'label-sync'], exclusive: true, description: 'Owner PAT for PR creation and owner-scoped cross-repo operations', }, diff --git a/templates/consumer-repo/.github/scripts/token_load_balancer.js b/templates/consumer-repo/.github/scripts/token_load_balancer.js index 064b63346..970a93aba 100644 --- a/templates/consumer-repo/.github/scripts/token_load_balancer.js +++ b/templates/consumer-repo/.github/scripts/token_load_balancer.js @@ -105,13 +105,7 @@ const TOKEN_SPECIALIZATIONS = { description: 'Workflow dispatch triggers and belt conveyor operations', }, OWNER_PR_PAT: { - primaryTasks: [ - 'pr-creation-as-owner', - 'cross-repo-sync', - 'dependabot-automerge', - 'label-sync', - 'maint-71-merge-sync-prs', - ], + primaryTasks: ['pr-creation-as-owner', 'cross-repo-sync', 'dependabot-automerge', 'label-sync'], exclusive: true, description: 'Owner PAT for PR creation and owner-scoped cross-repo operations', }, diff --git a/tests/workflows/test_sync_delivery_liveness.py b/tests/workflows/test_sync_delivery_liveness.py index ef5d5b0d0..508df2420 100644 --- a/tests/workflows/test_sync_delivery_liveness.py +++ b/tests/workflows/test_sync_delivery_liveness.py @@ -27,6 +27,7 @@ def test_maint71_has_proof_bound_review_resolution_and_exact_evidence_promotion( assert "canary_evidence_json: JSON.stringify(evidence)" in workflow assert "cancel-in-progress: false" in workflow assert "EXCLUDED_REPOS_INPUT: stranske/Collab-Admin" in workflow + assert "task: 'cross-repo-sync'" in executor def test_sync_lifecycle_chains_and_has_event_plus_timer_fallbacks(): From 0725a8af4d6ba5cf507e573ee145724313b9ba88 Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Sat, 15 Aug 2026 14:44:34 -0500 Subject: [PATCH 4/7] fix(sync): fail closed on Maint 71 owner auth --- .github/scripts/maint71_merge_sync_prs.js | 32 ++++++++----------- .../workflows/test_sync_delivery_liveness.py | 4 ++- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/.github/scripts/maint71_merge_sync_prs.js b/.github/scripts/maint71_merge_sync_prs.js index 3f13f7517..7c7ded941 100644 --- a/.github/scripts/maint71_merge_sync_prs.js +++ b/.github/scripts/maint71_merge_sync_prs.js @@ -344,24 +344,20 @@ async function run({ github, context, core }) { paginateWithRetry: (githubInstance, method, params) => githubInstance.paginate(method, params), }; - const { createTokenAwareRetry } = retryHelpers; - const { withRetry } = createTokenAwareRetry - ? await createTokenAwareRetry({ - github, - core, - env: process.env, - // Maint 71 mutates stable PRs across the complete consumer fleet. - // Use the registry's existing owner-only route instead of letting - // remaining capacity select a token without access to every repo. - task: 'cross-repo-sync', - capabilities: [ - 'pull-requests:read', - 'pull-requests:write', - 'checks:read', - 'contents:write', - ], - }) - : { github, withRetry: (fn) => fn(github) }; + if (!String(process.env.OWNER_PR_PAT || '').trim()) { + throw new Error( + 'Maint 71 requires OWNER_PR_PAT for fail-closed cross-repository PR mutations', + ); + } + // The actions/github-script client is authenticated with OWNER_PR_PAT by + // the workflow. Keep retries on that exact client so credential or quota + // degradation cannot rotate a cross-repository mutation to a token with a + // smaller repository installation scope. + const withRetry = (fn, options = {}) => retryHelpers.withRetry(fn, { + github, + core, + ...options, + }); async function getRequiredContexts({ owner, repo, branch }) { try { const { data: protection } = await withRetry((client) => diff --git a/tests/workflows/test_sync_delivery_liveness.py b/tests/workflows/test_sync_delivery_liveness.py index 508df2420..87cad8af9 100644 --- a/tests/workflows/test_sync_delivery_liveness.py +++ b/tests/workflows/test_sync_delivery_liveness.py @@ -27,7 +27,9 @@ def test_maint71_has_proof_bound_review_resolution_and_exact_evidence_promotion( assert "canary_evidence_json: JSON.stringify(evidence)" in workflow assert "cancel-in-progress: false" in workflow assert "EXCLUDED_REPOS_INPUT: stranske/Collab-Admin" in workflow - assert "task: 'cross-repo-sync'" in executor + assert "Maint 71 requires OWNER_PR_PAT" in executor + assert "retryHelpers.withRetry(fn" in executor + assert "createTokenAwareRetry" not in executor def test_sync_lifecycle_chains_and_has_event_plus_timer_fallbacks(): From 9ffe3d13442bbfb10384e28b6495966ac374a875 Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Sat, 15 Aug 2026 14:48:16 -0500 Subject: [PATCH 5/7] fix(keepalive): trust approved PAT summary writers --- .../scripts/__tests__/keepalive-loop.test.js | 79 +++++++++++++++++++ .github/scripts/keepalive_loop.js | 20 ++++- .../.github/scripts/keepalive_loop.js | 20 ++++- .../workflows/agents-81-gate-followups.yml | 9 ++- .../test_workflow_agents_consolidation.py | 4 +- 5 files changed, 118 insertions(+), 14 deletions(-) diff --git a/.github/scripts/__tests__/keepalive-loop.test.js b/.github/scripts/__tests__/keepalive-loop.test.js index af1b61e6d..bae554b18 100644 --- a/.github/scripts/__tests__/keepalive-loop.test.js +++ b/.github/scripts/__tests__/keepalive-loop.test.js @@ -1042,6 +1042,49 @@ test('updateKeepaliveLoopSummary migrates legacy state to the selected App write assert.equal(github.actions.some((action) => action.commentId === 44), false); }); +test('updateKeepaliveLoopSummary reuses a summary written by the selected PAT user', async () => { + const existingState = [ + '', + formatStateComment({ + trace: 'pat-summary-trace', + iteration: 1, + max_iterations: 5, + failure_threshold: 3, + }), + ].join('\n'); + const github = buildGithubStub({ + comments: [{ + id: 46, + body: existingState, + html_url: 'https://example.com/46', + user: { login: 'stranske-automation-bot', type: 'User' }, + }], + }); + + await updateKeepaliveLoopSummary({ + github, + context: buildContext(123), + core: buildCore(), + inputs: { + prNumber: 123, + action: 'run', + runResult: 'success', + gateConclusion: 'success', + tasksTotal: 2, + tasksUnchecked: 1, + keepaliveEnabled: true, + iteration: 1, + maxIterations: 5, + failureThreshold: 3, + trace: 'pat-summary-trace', + trusted_summary_author: 'stranske-automation-bot', + }, + }); + + assert.equal(github.actions[0].type, 'update'); + assert.equal(github.actions[0].commentId, 46); +}); + test('updateKeepaliveLoopSummary recovers trusted state past an untrusted summary writer', async () => { const trustedState = [ '', @@ -4930,6 +4973,42 @@ test('markAgentRunning migrates legacy state to the selected App writer', async assert.equal(persistedState.running, true); }); +test('markAgentRunning reuses a running summary written by the selected PAT user', async () => { + const existingStateBody = formatStateComment({ + trace: 'running-pat-trace', + iteration: 2, + tasks: { total: 4, unchecked: 3 }, + }); + const github = buildGithubStub({ + comments: [{ + id: 202, + body: `\n## Summary\n${existingStateBody}`, + html_url: 'https://example.com/202', + user: { login: 'stranske-automation-bot', type: 'User' }, + }], + }); + + await markAgentRunning({ + github, + context: { repo: { owner: 'test', repo: 'repo' } }, + core: buildCore(), + inputs: { + pr_number: 44, + agent_type: 'codex', + iteration: 2, + max_iterations: 5, + tasks_total: 4, + tasks_unchecked: 3, + trace: 'running-pat-trace', + trusted_summary_author: 'stranske-automation-bot', + }, + }); + + assert.equal(github.actions.length, 1); + assert.equal(github.actions[0].type, 'update'); + assert.equal(github.actions[0].commentId, 202); +}); + test('markAgentRunning creates comment when none exists', async () => { const github = buildGithubStub({ comments: [] }); const inputs = { diff --git a/.github/scripts/keepalive_loop.js b/.github/scripts/keepalive_loop.js index b57efc256..21e61b9fb 100644 --- a/.github/scripts/keepalive_loop.js +++ b/.github/scripts/keepalive_loop.js @@ -74,6 +74,10 @@ function normalise(value) { return String(value ?? '').trim(); } +function expectedTrustedSummaryAuthorType(author) { + return normalise(author).toLowerCase().endsWith('[bot]') ? 'bot' : 'user'; +} + function buildAuthorityChallengeEvidence({ agentSummary, summaryReason, @@ -3217,14 +3221,18 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in ).toLowerCase(); const existingSummaryAuthor = normalise(commentAuthorLogin).toLowerCase(); const existingSummaryAuthorType = normalise(commentAuthorType).toLowerCase(); + const trustedSummaryAuthorType = expectedTrustedSummaryAuthorType(trustedSummaryAuthor); const migrateSummaryWriter = Boolean( commentId && trustedSummaryAuthor && - (existingSummaryAuthor !== trustedSummaryAuthor || existingSummaryAuthorType !== 'bot'), + ( + existingSummaryAuthor !== trustedSummaryAuthor || + existingSummaryAuthorType !== trustedSummaryAuthorType + ), ); if (migrateSummaryWriter) { core?.info?.( - `Creating a trusted App-owned keepalive summary; existing writer ` + + `Creating a trusted keepalive summary; existing writer ` + `${existingSummaryAuthor || 'unknown'} is not ${trustedSummaryAuthor}; migrating known state.`, ); } @@ -4795,14 +4803,18 @@ async function markAgentRunning({ github: rawGithub, context, core, inputs }) { ).toLowerCase(); const existingSummaryAuthor = normalise(commentAuthorLogin).toLowerCase(); const existingSummaryAuthorType = normalise(commentAuthorType).toLowerCase(); + const trustedSummaryAuthorType = expectedTrustedSummaryAuthorType(trustedSummaryAuthor); const migrateSummaryWriter = Boolean( commentId && trustedSummaryAuthor && - (existingSummaryAuthor !== trustedSummaryAuthor || existingSummaryAuthorType !== 'bot'), + ( + existingSummaryAuthor !== trustedSummaryAuthor || + existingSummaryAuthorType !== trustedSummaryAuthorType + ), ); if (migrateSummaryWriter) { core?.info?.( - `Creating a trusted App-owned running summary; existing writer ` + + `Creating a trusted running summary; existing writer ` + `${existingSummaryAuthor || 'unknown'} is not ${trustedSummaryAuthor}; migrating known state.`, ); } diff --git a/templates/consumer-repo/.github/scripts/keepalive_loop.js b/templates/consumer-repo/.github/scripts/keepalive_loop.js index b57efc256..21e61b9fb 100644 --- a/templates/consumer-repo/.github/scripts/keepalive_loop.js +++ b/templates/consumer-repo/.github/scripts/keepalive_loop.js @@ -74,6 +74,10 @@ function normalise(value) { return String(value ?? '').trim(); } +function expectedTrustedSummaryAuthorType(author) { + return normalise(author).toLowerCase().endsWith('[bot]') ? 'bot' : 'user'; +} + function buildAuthorityChallengeEvidence({ agentSummary, summaryReason, @@ -3217,14 +3221,18 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in ).toLowerCase(); const existingSummaryAuthor = normalise(commentAuthorLogin).toLowerCase(); const existingSummaryAuthorType = normalise(commentAuthorType).toLowerCase(); + const trustedSummaryAuthorType = expectedTrustedSummaryAuthorType(trustedSummaryAuthor); const migrateSummaryWriter = Boolean( commentId && trustedSummaryAuthor && - (existingSummaryAuthor !== trustedSummaryAuthor || existingSummaryAuthorType !== 'bot'), + ( + existingSummaryAuthor !== trustedSummaryAuthor || + existingSummaryAuthorType !== trustedSummaryAuthorType + ), ); if (migrateSummaryWriter) { core?.info?.( - `Creating a trusted App-owned keepalive summary; existing writer ` + + `Creating a trusted keepalive summary; existing writer ` + `${existingSummaryAuthor || 'unknown'} is not ${trustedSummaryAuthor}; migrating known state.`, ); } @@ -4795,14 +4803,18 @@ async function markAgentRunning({ github: rawGithub, context, core, inputs }) { ).toLowerCase(); const existingSummaryAuthor = normalise(commentAuthorLogin).toLowerCase(); const existingSummaryAuthorType = normalise(commentAuthorType).toLowerCase(); + const trustedSummaryAuthorType = expectedTrustedSummaryAuthorType(trustedSummaryAuthor); const migrateSummaryWriter = Boolean( commentId && trustedSummaryAuthor && - (existingSummaryAuthor !== trustedSummaryAuthor || existingSummaryAuthorType !== 'bot'), + ( + existingSummaryAuthor !== trustedSummaryAuthor || + existingSummaryAuthorType !== trustedSummaryAuthorType + ), ); if (migrateSummaryWriter) { core?.info?.( - `Creating a trusted App-owned running summary; existing writer ` + + `Creating a trusted running summary; existing writer ` + `${existingSummaryAuthor || 'unknown'} is not ${trustedSummaryAuthor}; migrating known state.`, ); } diff --git a/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml b/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml index 8c5282830..d3c34901d 100644 --- a/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml +++ b/templates/consumer-repo/.github/workflows/agents-81-gate-followups.yml @@ -586,7 +586,9 @@ jobs: run: | set -euo pipefail case "$TOKEN_SOURCE:$TOKEN_LOGIN" in - 'ACTIONS_BOT_PAT:stranske'|'SERVICE_BOT_PAT:stranske-automation-bot') ;; + 'ACTIONS_BOT_PAT:stranske'|\ + 'ACTIONS_BOT_PAT:stranske-automation-bot'|\ + 'SERVICE_BOT_PAT:stranske-automation-bot') ;; *) echo "::error::$TOKEN_SOURCE has unexpected identity '$TOKEN_LOGIN'." exit 1 ;; @@ -617,9 +619,8 @@ jobs: 'stranske-keepalive[bot]' || steps.running_workflows_app_token.outputs.token != '' && 'agents-workflows-bot[bot]' || - steps.running_token_writer.outputs.source == 'ACTIONS_BOT_PAT' && - 'stranske' || - 'stranske-automation-bot' + steps.running_pat_identity.outputs.login || + '' }} with: github-token: >- diff --git a/tests/workflows/test_workflow_agents_consolidation.py b/tests/workflows/test_workflow_agents_consolidation.py index 27177694b..234aadd48 100644 --- a/tests/workflows/test_workflow_agents_consolidation.py +++ b/tests/workflows/test_workflow_agents_consolidation.py @@ -840,14 +840,14 @@ def test_consumer_mark_running_supports_identity_checked_pat(): assert "github.rest.users.getAuthenticated()" in mark_running assert "withRetry(() => github.rest.users.getAuthenticated())" in mark_running assert "'ACTIONS_BOT_PAT:stranske'" in mark_running + assert "'ACTIONS_BOT_PAT:stranske-automation-bot'" in mark_running assert "'SERVICE_BOT_PAT:stranske-automation-bot'" in mark_running update = mark_running[mark_running.index("- name: Update summary with running status") :] assert "steps.running_keepalive_app_token.outputs.token ||" in update assert "steps.running_workflows_app_token.outputs.token ||" in update assert "secrets.ACTIONS_BOT_PAT ||" in update assert "secrets.SERVICE_BOT_PAT" in update - assert "steps.running_token_writer.outputs.source == 'ACTIONS_BOT_PAT'" in update - assert "'stranske-automation-bot'" in update + assert "steps.running_pat_identity.outputs.login ||" in update assert "secrets.GITHUB_TOKEN" not in update assert "github.token" not in update From c937abf7cc3270e7022853fdbf42c4abff3744b8 Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Sat, 15 Aug 2026 14:50:14 -0500 Subject: [PATCH 6/7] test(sync): cover Maint 71 owner-token contract --- .../__tests__/sync_pr_merge_contract.test.js | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/scripts/__tests__/sync_pr_merge_contract.test.js b/.github/scripts/__tests__/sync_pr_merge_contract.test.js index fcc25a44c..b929f53dd 100644 --- a/.github/scripts/__tests__/sync_pr_merge_contract.test.js +++ b/.github/scripts/__tests__/sync_pr_merge_contract.test.js @@ -267,6 +267,7 @@ test('maint71 run writes reports and records a no-PR result with fake action cli CLEANUP_BRANCHES_INPUT: process.env.CLEANUP_BRANCHES_INPUT, DRY_RUN_INPUT: process.env.DRY_RUN_INPUT, AUTO_MERGE_INPUT: process.env.AUTO_MERGE_INPUT, + OWNER_PR_PAT: process.env.OWNER_PR_PAT, SYNC_PR_MERGE_REPORT_JSON: process.env.SYNC_PR_MERGE_REPORT_JSON, }; const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'maint71-run-')); @@ -298,6 +299,7 @@ test('maint71 run writes reports and records a no-PR result with fake action cli process.env.CLEANUP_BRANCHES_INPUT = 'false'; process.env.DRY_RUN_INPUT = 'true'; process.env.AUTO_MERGE_INPUT = 'false'; + process.env.OWNER_PR_PAT = 'test-owner-token'; process.env.SYNC_PR_MERGE_REPORT_JSON = reportPath; await run({ @@ -330,6 +332,27 @@ test('maint71 run writes reports and records a no-PR result with fake action cli } }); +test('maint71 fails closed before cross-repository API calls without OWNER_PR_PAT', async () => { + const originalOwnerPat = process.env.OWNER_PR_PAT; + try { + delete process.env.OWNER_PR_PAT; + await assert.rejects( + run({ + github: {}, + core: { warning: () => {} }, + context: { + repo: { owner: 'stranske', repo: 'Workflows' }, + payload: {}, + }, + }), + /Maint 71 requires OWNER_PR_PAT/, + ); + } finally { + if (originalOwnerPat === undefined) delete process.env.OWNER_PR_PAT; + else process.env.OWNER_PR_PAT = originalOwnerPat; + } +}); + test('maint71 recovers exact-head evidence from an already-merged candidate PR', async () => { const originalCwd = process.cwd(); const envKeys = [ @@ -339,6 +362,7 @@ test('maint71 recovers exact-head evidence from an already-merged candidate PR', 'AUTO_MERGE_INPUT', 'EVIDENCE_ONLY_INPUT', 'ACTIVE_SYNC_HASH_INPUT', + 'OWNER_PR_PAT', 'CONSUMER_SYNC_CANARIES_PATH', 'TRUSTED_SYNC_ACTORS', 'SYNC_PR_MERGE_REPORT_JSON', @@ -452,6 +476,7 @@ test('maint71 recovers exact-head evidence from an already-merged candidate PR', process.env.DRY_RUN_INPUT = 'true'; process.env.AUTO_MERGE_INPUT = 'false'; process.env.ACTIVE_SYNC_HASH_INPUT = 'candidate'; + process.env.OWNER_PR_PAT = 'test-owner-token'; process.env.EVIDENCE_ONLY_INPUT = 'true'; process.env.CONSUMER_SYNC_CANARIES_PATH = canaryConfigPath; process.env.TRUSTED_SYNC_ACTORS = 'stranske'; From d5e6f6b3bde5f399357a40476f3ed926b441261c Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Sat, 15 Aug 2026 14:57:29 -0500 Subject: [PATCH 7/7] docs(sync): require Maint 71 owner credential --- .github/workflows/maint-71-merge-sync-prs.yml | 6 +++--- docs/INTEGRATION_GUIDE.md | 2 +- docs/WORKFLOW_GUIDE.md | 2 +- docs/ci/WORKFLOWS.md | 2 +- docs/workflow-updates/maint-workflow-review-2026-02-22.md | 3 ++- tests/workflows/test_sync_delivery_liveness.py | 1 + 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/maint-71-merge-sync-prs.yml b/.github/workflows/maint-71-merge-sync-prs.yml index 9578c401f..475cae58c 100644 --- a/.github/workflows/maint-71-merge-sync-prs.yml +++ b/.github/workflows/maint-71-merge-sync-prs.yml @@ -200,7 +200,7 @@ jobs: SYNC_PR_MERGE_REPORT_JSON: artifacts/sync-review-resolution-report.json REGISTERED_REPOS_INPUT: ${{ steps.repos.outputs.list }} with: - github-token: ${{ secrets.OWNER_PR_PAT || secrets.SERVICE_BOT_PAT || github.token }} + github-token: ${{ secrets.OWNER_PR_PAT }} script: | const { run } = require("./.github/scripts/maint71_merge_sync_prs.js"); await run({ github, context, core }); @@ -225,7 +225,7 @@ jobs: SYNC_PR_MERGE_REPORT_JSON: artifacts/sync-pr-premerge-report.json REGISTERED_REPOS_INPUT: ${{ steps.repos.outputs.list }} with: - github-token: ${{ secrets.OWNER_PR_PAT || secrets.SERVICE_BOT_PAT || github.token }} + github-token: ${{ secrets.OWNER_PR_PAT }} script: | const { run } = require("./.github/scripts/maint71_merge_sync_prs.js"); await run({ github, context, core }); @@ -307,7 +307,7 @@ jobs: SYNC_PR_MERGE_REPORT_JSON: artifacts/sync-pr-merge-report.json REGISTERED_REPOS_INPUT: ${{ steps.repos.outputs.list }} with: - github-token: ${{ secrets.OWNER_PR_PAT || secrets.SERVICE_BOT_PAT || github.token }} + github-token: ${{ secrets.OWNER_PR_PAT }} script: | const { run } = require("./.github/scripts/maint71_merge_sync_prs.js"); await run({ github, context, core }); diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index f7627debc..781bf462e 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -830,7 +830,7 @@ backward compatibility, but new manual setup should prefer the current defaults. |--------|---------|--------------| | `SERVICE_BOT_PAT` | Bot account for comments/labels (stranske-automation-bot); fallback credential for cross-repository label sync | agents, autofix, Maint 69 label sync | | `ACTIONS_BOT_PAT` | Workflow dispatch triggers | event-hub/follow-up automation (`agents-80-pr-event-hub.yml`, `agents-81-gate-followups.yml`); legacy orchestrator/pr-meta only when intentionally retained | -| `OWNER_PR_PAT` | Create PRs on behalf of user; preferred credential for cross-repository label sync | issue-intake, Maint 69 label sync | +| `OWNER_PR_PAT` | Create PRs on behalf of user; required owner-scoped credential for generated-delivery mutations | issue-intake, Maint 69 label sync, Maint 71 sync reconciliation | | `CROSS_REPO_TOKEN` | Read access to a dependency repository for cross-repo smoke | `cross-repo-smoke.yml` when `CROSS_REPO_SMOKE_ENABLED=true` | ### Cross-Repo Smoke (opt-in) diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index f6bcc55ae..127f694f8 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. 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. 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 is persisted and validated before the irreversible candidate merge; once every candidate is merged or safely recovered, Maint 71 dispatches `phase=promote` with that exact evidence. 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. 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 is persisted and validated before the irreversible candidate merge; once every candidate is merged or safely recovered, Maint 71 dispatches `phase=promote` with that exact evidence. 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/ci/WORKFLOWS.md b/docs/ci/WORKFLOWS.md index 20846e0b6..3e0fbffdf 100644 --- a/docs/ci/WORKFLOWS.md +++ b/docs/ci/WORKFLOWS.md @@ -206,7 +206,7 @@ Scheduled health jobs keep the automation ecosystem aligned: * [`maint-69-sync-labels.yml`](../../.github/workflows/maint-69-sync-labels.yml) syncs core functional labels from labels-core.yml to consumer repos (push to labels-core.yml, manual dispatch with dry-run support). * [`maint-70-fix-integration-formatting.yml`](../../.github/workflows/maint-70-fix-integration-formatting.yml) applies Black and Ruff formatting fixes to Integration-Tests repository files (manual dispatch for CI formatting failures). * [`maint-71-auto-fix-integration.yml`](../../.github/workflows/maint-71-auto-fix-integration.yml) automatically applies formatting fixes to Integration-Tests when triggered by issue comments or workflow failures. -* [`maint-71-merge-sync-prs.yml`](../../.github/workflows/maint-71-merge-sync-prs.yml) is the sole sync-PR merge/close reconciler. Stable deliveries use an exact-head staging/reviewing/sealed lifecycle and a capacity-aware policy: one reviewer response suffices after seven minutes; all-capacity-unavailable and zero-response cases degrade after bounded waits, but active review threads never do. A proof-bound resolution input can clear only the named active thread on the named exact head after verifying that its Workflows source fix is contained in the delivery. Sealing triggers a fresh required Gate, and the shared staging hold blocks every other merger. The final exact-head query also requires a valid GitHub-generated signature; unsigned sync heads are never merged. Candidate mode uploads complete plan-bound evidence before merge and automatically dispatches exact-plan promotion only after every canary is terminal. Required contexts come from legacy branch protection or active repository/inherited organization rulesets (scheduled/manual/callable). +* [`maint-71-merge-sync-prs.yml`](../../.github/workflows/maint-71-merge-sync-prs.yml) is the sole sync-PR merge/close reconciler. `OWNER_PR_PAT` is mandatory for its cross-repository reads and mutations; missing, invalid, or exhausted owner credentials fail closed without rotating to a narrower service/default token. Stable deliveries use an exact-head staging/reviewing/sealed lifecycle and a capacity-aware policy: one reviewer response suffices after seven minutes; all-capacity-unavailable and zero-response cases degrade after bounded waits, but active review threads never do. A proof-bound resolution input can clear only the named active thread on the named exact head after verifying that its Workflows source fix is contained in the delivery. Sealing triggers a fresh required Gate, and the shared staging hold blocks every other merger. The final exact-head query also requires a valid GitHub-generated signature; unsigned sync heads are never merged. Candidate mode uploads complete plan-bound evidence before merge and automatically dispatches exact-plan promotion only after every canary is terminal. Required contexts come from legacy branch protection or active repository/inherited organization rulesets (scheduled/manual/callable). * [`maint-72-fix-pr-body-conflicts.yml`](../../.github/workflows/maint-72-fix-pr-body-conflicts.yml) removes pr_body.md from main branch and adds to .gitignore across consumer repos - prevents merge conflicts from PR description files (manual dispatch, weekly schedule). * [`maint-74-ledger-base-sync.yml`](../../.github/workflows/maint-74-ledger-base-sync.yml) aligns `.agents` ledger base entries to the repository default branch on a weekly schedule or manual dispatch. * [`maint-77-model-registry-freshness.yml`](../../.github/workflows/maint-77-model-registry-freshness.yml) checks the canonical LLM registry for overdue or unproved decisions, invalid lifecycle/evidence references, and profile/slot drift. Scheduled and manual runs also perform credential-gated provider catalog discovery; catalog additions become review candidates and never auto-promote (scheduled weekly, manual dispatch, PR gate for registry/slot/policy/checker changes). diff --git a/docs/workflow-updates/maint-workflow-review-2026-02-22.md b/docs/workflow-updates/maint-workflow-review-2026-02-22.md index 9da2635cc..f4604259d 100644 --- a/docs/workflow-updates/maint-workflow-review-2026-02-22.md +++ b/docs/workflow-updates/maint-workflow-review-2026-02-22.md @@ -108,7 +108,8 @@ This log mirrors the health-workflow audit but targets the `maint-*` workflows. ### `maint-71-merge-sync-prs.yml` - **Purpose**: Closes stale sync PRs and merges the latest passing sync PR in each consumer repo. -- **Optimizations applied (2026-02-22)**: Removed the GitHub App token mint, switched to the shared API client, and now rely on `scripts/list_registered_consumer_repos.py` so repo discovery stays centralized. Merge operations run via PAT (`OWNER_PR_PAT`/`SERVICE_BOT_PAT`) with a fallback to the default token for read-only runs. +- **Optimizations applied (2026-02-22)**: Removed the GitHub App token mint, switched to the shared API client, and now rely on `scripts/list_registered_consumer_repos.py` so repo discovery stays centralized. +- **Current credential contract (2026-08-15)**: Maint 71 requires `OWNER_PR_PAT` for every reconciliation mode and fails closed when that owner-scoped credential is missing, invalid, or exhausted; service/default-token fallback is not supported for fleet reads or mutations. - **Next steps**: Surface per-repo merge outcomes in the run summary for quicker triage. ### `maint-72-fix-pr-body-conflicts.yml` diff --git a/tests/workflows/test_sync_delivery_liveness.py b/tests/workflows/test_sync_delivery_liveness.py index 87cad8af9..71ab8e3bb 100644 --- a/tests/workflows/test_sync_delivery_liveness.py +++ b/tests/workflows/test_sync_delivery_liveness.py @@ -30,6 +30,7 @@ def test_maint71_has_proof_bound_review_resolution_and_exact_evidence_promotion( assert "Maint 71 requires OWNER_PR_PAT" in executor assert "retryHelpers.withRetry(fn" in executor assert "createTokenAwareRetry" not in executor + assert workflow.count("github-token: ${{ secrets.OWNER_PR_PAT }}") == 3 def test_sync_lifecycle_chains_and_has_event_plus_timer_fallbacks():