diff --git a/.github/scripts/__tests__/token-load-balancer.test.js b/.github/scripts/__tests__/token-load-balancer.test.js index 06fcf2b1d..1fa77b099 100644 --- a/.github/scripts/__tests__/token-load-balancer.test.js +++ b/.github/scripts/__tests__/token-load-balancer.test.js @@ -170,3 +170,98 @@ test('hasHealthyTokens: returns true when mixed critical and healthy tokens exis ]); assert.equal(balancer.hasHealthyTokens(), true); }); + +// --------------------------------------------------------------------------- +// statuses:write capability filtering +// +// Regression guard for the defect these cover: `statuses:write` aliased to the generic +// `write-repo`, which GITHUB_TOKEN, PAT *and* APP all claim, so declaring the capability +// filtered nothing and the balancer could hand out an App installation without the Commit +// statuses scope. Observed in stranske/Orchestrator on 2026-08-23: the Gate's own status post +// selected WORKFLOWS_APP and got a 403, the swallow left the previous status in place, and a +// fully green run kept a red `Gate / gate` that nothing could clear. +// --------------------------------------------------------------------------- + +test('TOKEN_CAPABILITIES: statuses is held by GITHUB_TOKEN and PAT but NOT by APP', () => { + assert.ok(balancer.TOKEN_CAPABILITIES.GITHUB_TOKEN.includes('statuses')); + assert.ok(balancer.TOKEN_CAPABILITIES.PAT.includes('statuses')); + assert.equal( + balancer.TOKEN_CAPABILITIES.APP.includes('statuses'), + false, + 'APP must not claim `statuses`: an App installation only has Commit statuses if it was ' + + 'granted them, and the installations in use were not. A wrong entry here is silent -- the ' + + 'balancer hands out a token that 403s on POST /statuses/{sha}.' + ); +}); + +// A multi-token "the App must not win on capacity" test was written and then REMOVED. Selection +// scores `percentRemaining + priority*10 + typeBonus + taskBonus`, so an ineligible App with more +// headroom should out-score a statuses-capable token -- but the test passed even with the alias +// deliberately broken, i.e. for a reason not established (getOptimalToken refreshes rate limits, +// which appears to discard seeded capacities). A test that passes for an unknown reason is a false +// comfort, not coverage, so the guard here is the two assertions below, both of which DO fail when +// the alias is reverted to ['write-repo']: the table itself, and the App-only selection. + +/** Register a single token of one type, healthy, so eligibility alone decides the answer. */ +function seedOnly(type) { + balancer.tokenRegistry.tokens.clear(); + balancer.tokenRegistry.lastRefresh = 0; + balancer.registerToken({ + id: type, + token: `fake-token-${type}`, + type, + source: type, + capabilities: balancer.TOKEN_CAPABILITIES[type], + priority: 5, + }); + const info = balancer.tokenRegistry.tokens.get(type); + info.rateLimit.remaining = 5000; + info.rateLimit.limit = 5000; + info.rateLimit.used = 0; + info.rateLimit.percentUsed = 0; + info.rateLimit.percentRemaining = 100; +} + +test('a broad write-repo request still accepts APP, so the fix narrowed nothing else', async () => { + // Asserted on ELIGIBILITY, not on who wins: selection is deterministic given equal capacity, + // so "APP shows up eventually" would never hold regardless of the capability tables. + seedOnly('APP'); + const selection = await balancer.getOptimalToken({ + capabilities: ['contents:write'], + minRemaining: 1, + }); + assert.equal( + selection?.source ?? null, + 'APP', + 'APP should still satisfy a generic write-repo request; if it does not, the capability change ' + + 'over-narrowed and every App-backed caller just lost its token' + ); +}); + +test('statuses:write with only an APP registered returns no token rather than a doomed one', async () => { + balancer.tokenRegistry.tokens.clear(); + balancer.tokenRegistry.lastRefresh = 0; + balancer.registerToken({ + id: 'APP', + token: 'fake-token-APP', + type: 'APP', + source: 'APP', + capabilities: balancer.TOKEN_CAPABILITIES.APP, + priority: 5, + }); + const info = balancer.tokenRegistry.tokens.get('APP'); + info.rateLimit.remaining = 5000; + info.rateLimit.limit = 5000; + info.rateLimit.percentRemaining = 100; + + const selection = await balancer.getOptimalToken({ + capabilities: ['statuses:write'], + minRemaining: 1, + }); + assert.equal( + selection?.source ?? null, + null, + 'handing back an APP that cannot write statuses is worse than handing back nothing: the ' + + 'caller falls through to its own github client, which is the token that actually has the scope' + ); +}); diff --git a/.github/scripts/github-api-with-retry.js b/.github/scripts/github-api-with-retry.js index 90b72cbe4..1cc677fa9 100755 --- a/.github/scripts/github-api-with-retry.js +++ b/.github/scripts/github-api-with-retry.js @@ -352,10 +352,20 @@ async function withRetry(fn, options = {}) { } if (integrationPermissionError && task === 'gate-commit-status') { + // NAME THE TOKEN. This swallow is deliberate -- a status post must not fail the Gate -- + // but until 2026-08-23 it said only "blocked by permissions", which reads as a repo + // misconfiguration and sent a reader to check `permissions:` blocks that were already + // correct. The real cause is WHICH token was selected, so the message has to carry it: + // a green run leaving a red status is otherwise indistinguishable from a settings problem. + const refusedBy = currentTokenSource || 'the workflow token'; logWithCore( core, 'warning', - 'Gate commit status update blocked by permissions; leaving existing status untouched.' + `Gate commit status update blocked by permissions (token: ${refusedBy}); ` + + 'leaving the EXISTING status in place, so a stale one can outlive this run. ' + + 'That token lacks the `statuses` scope: declare ' + + "`capabilities: ['statuses:write']` or pin the call to the workflow token with " + + '`env: {}`.' ); return null; } diff --git a/.github/scripts/token_load_balancer.js b/.github/scripts/token_load_balancer.js index 970a93aba..af6aa4935 100644 --- a/.github/scripts/token_load_balancer.js +++ b/.github/scripts/token_load_balancer.js @@ -65,8 +65,16 @@ const invalidAuthWarningMemory = new Set(); * Based on analysis of actual usage across workflows */ const TOKEN_CAPABILITIES = { - GITHUB_TOKEN: ['read-repo', 'write-repo', 'pr-update', 'labels', 'comments'], - PAT: ['read-repo', 'write-repo', 'pr-update', 'labels', 'comments', 'cross-repo', 'workflow-dispatch'], + GITHUB_TOKEN: ['read-repo', 'write-repo', 'pr-update', 'labels', 'comments', 'statuses'], + PAT: ['read-repo', 'write-repo', 'pr-update', 'labels', 'comments', 'cross-repo', 'workflow-dispatch', 'statuses'], + // NO `statuses` FOR APP, and that omission is measured, not assumed. A GitHub App only holds the + // scopes its INSTALLATION was granted, and the installations in use here do not include Commit + // statuses. Observed 2026-08-23 in stranske/Orchestrator: the Gate's own status post selected + // WORKFLOWS_APP and got `POST /repos/.../statuses/ - 403`, while the same job's + // GITHUB_TOKEN was granted `Statuses: write` and posted fine minutes earlier. + // If an App installation is later granted Commit statuses, add 'statuses' back here -- but + // verify against the installation, because a wrong entry here is silent: the balancer hands out + // a token that cannot do the job and the caller sees a 403 it did not ask for. APP: ['read-repo', 'write-repo', 'pr-update', 'labels', 'comments', 'workflow-dispatch'], }; @@ -140,7 +148,12 @@ const CAPABILITY_ALIASES = { 'rate_limit:read': ['read-repo'], 'deployments:write': ['write-repo'], 'checks:read': ['read-repo'], - 'statuses:write': ['write-repo'], + // `statuses:write` maps to its OWN capability, not to the generic `write-repo`. It aliased to + // `write-repo` until 2026-08-23, which all three token types claim -- so declaring + // `capabilities: ['statuses:write']` selected an App that cannot write statuses and the + // declaration was decorative. A capability alias must name what the API endpoint actually + // requires; collapsing a narrow scope into a broad one makes the filter unable to filter. + 'statuses:write': ['statuses'], }; function normalizeCapabilities(capabilities = []) { diff --git a/.github/workflows/pr-00-gate.yml b/.github/workflows/pr-00-gate.yml index 28b5792d1..b7c9245aa 100644 --- a/.github/workflows/pr-00-gate.yml +++ b/.github/workflows/pr-00-gate.yml @@ -1091,6 +1091,29 @@ jobs: github, core, task: 'gate-commit-status', + // `env: {}` PINS THIS CALL TO THE WORKFLOW TOKEN, and it is load-bearing. + // With the default `env: process.env` the token load balancer collects every App + // and PAT secret this job exposes and scores them + // (`percentRemaining + priority*10 + typeBonus + taskBonus`), so which token posts + // the Gate's status depends on rate-limit state and varies run to run. `POST /statuses/{sha}` needs the + // `statuses` scope: GITHUB_TOKEN has it here (this job declares `statuses: write`), + // an App installation only has it if it was granted Commit statuses -- and the + // installations in use are not. When the balancer picked the App the post 403'd and + // the swallow left the PREVIOUS status in place, so a green run kept a red + // `Gate / gate` that nothing could clear. Measured in stranske/Orchestrator on + // 2026-08-23: `Selected token: WORKFLOWS_APP` then `POST .../statuses/... - 403`, + // one line after `STATE: success`; a run minutes earlier posted fine with identical + // declared permissions because the balancer chose differently. + // + // WHY THE PIN AND NOT `capabilities: ['statuses:write']`: the declaration is now + // honest (that alias maps to its own `statuses` capability, which APP does not + // claim), but this file is distributed `create_only`, so a consumer's Gate can sit + // at an old revision while token_load_balancer.js is synced forward independently. + // The pin needs no agreement between the two files; the declaration would. + // + // This is one API call per run, so losing rate-limit spreading costs nothing. + // Retries still apply -- only the token source is fixed. + env: {}, }); const owner = context.repo.owner; const repo = context.repo.repo; diff --git a/config/template-drift-allowlist.txt b/config/template-drift-allowlist.txt index 2dc7c2701..01434675c 100644 --- a/config/template-drift-allowlist.txt +++ b/config/template-drift-allowlist.txt @@ -205,9 +205,9 @@ fingerprint_refreshed = 2026-08-23 [pair.19] main = .github/workflows/pr-00-gate.yml template = templates/consumer-repo/.github/workflows/pr-00-gate.yml -main_sha256 = e65e060fd26897215b04311a9b030460c5b95758ec83e68bb970b458f1264541 -template_sha256 = fb63d85eec60e6822b1c0b35466f5677653051b7db436bd74383fdbff154f1b9 -divergence = Named-secrets rollout 2026-08-23: both surfaces now pass named setup-api-client secret inputs instead of the whole-secrets-context blob, applied identically to root and consumer, and scoped to each workflow's DECLARED workflow_call secrets where that context is a closed set. Removing that handoff is the demonstrated remedy for GitHub's suspicious-workflow hold (agents-dedup then ran at run_attempt 1 with nothing approved after 22 days held). Prior divergence unchanged: Intentional divergence reviewed 2026-08-23: the source Gate runs Workflows-only package, ledger, diff-quality, and live sync-manifest issue-state checks with GH_TOKEN and GITHUB_TOKEN exported for the pytest guard; the consumer Gate uses published reusable workflows, pinned actions, and skips unavailable Workflows-local deliberate-break helpers. The consumer template must remain a bootstrap-safe deployment surface. +main_sha256 = 6ac00cf1b2ec2a4e5bac5f7ad280c404810ca69e256900360446e909fed349af +template_sha256 = a5780f26a137f1d0889e9ebc26deaaa40644b7cdedd3a01dccdb3e83ec9a057e +divergence = Gate commit-status token pinned 2026-08-23: both surfaces now pass `env: {}` to createTokenAwareRetry for the `gate-commit-status` call, applied IDENTICALLY to root and consumer, so the divergence between them is unchanged by it. Without the pin the balancer could select a token lacking the `statuses` scope; the post then 403s and the swallow leaves the PREVIOUS status in place, so a fully green Gate keeps a red `Gate / gate` that nothing clears and a sealed sync PR cannot merge (observed in stranske/Orchestrator, PR #54). Prior divergence unchanged: Named-secrets rollout 2026-08-23: both surfaces now pass named setup-api-client secret inputs instead of the whole-secrets-context blob, applied identically to root and consumer, and scoped to each workflow's DECLARED workflow_call secrets where that context is a closed set. Removing that handoff is the demonstrated remedy for GitHub's suspicious-workflow hold (agents-dedup then ran at run_attempt 1 with nothing approved after 22 days held). Prior divergence unchanged: Intentional divergence reviewed 2026-08-23: the source Gate runs Workflows-only package, ledger, diff-quality, and live sync-manifest issue-state checks with GH_TOKEN and GITHUB_TOKEN exported for the pytest guard; the consumer Gate uses published reusable workflows, pinned actions, and skips unavailable Workflows-local deliberate-break helpers. The consumer template must remain a bootstrap-safe deployment surface. divergence_reviewed = 2026-08-23 fingerprint_refreshed = 2026-08-23 diff --git a/templates/consumer-repo/.github/scripts/github-api-with-retry.js b/templates/consumer-repo/.github/scripts/github-api-with-retry.js index 90b72cbe4..1cc677fa9 100755 --- a/templates/consumer-repo/.github/scripts/github-api-with-retry.js +++ b/templates/consumer-repo/.github/scripts/github-api-with-retry.js @@ -352,10 +352,20 @@ async function withRetry(fn, options = {}) { } if (integrationPermissionError && task === 'gate-commit-status') { + // NAME THE TOKEN. This swallow is deliberate -- a status post must not fail the Gate -- + // but until 2026-08-23 it said only "blocked by permissions", which reads as a repo + // misconfiguration and sent a reader to check `permissions:` blocks that were already + // correct. The real cause is WHICH token was selected, so the message has to carry it: + // a green run leaving a red status is otherwise indistinguishable from a settings problem. + const refusedBy = currentTokenSource || 'the workflow token'; logWithCore( core, 'warning', - 'Gate commit status update blocked by permissions; leaving existing status untouched.' + `Gate commit status update blocked by permissions (token: ${refusedBy}); ` + + 'leaving the EXISTING status in place, so a stale one can outlive this run. ' + + 'That token lacks the `statuses` scope: declare ' + + "`capabilities: ['statuses:write']` or pin the call to the workflow token with " + + '`env: {}`.' ); return null; } diff --git a/templates/consumer-repo/.github/scripts/token_load_balancer.js b/templates/consumer-repo/.github/scripts/token_load_balancer.js index 970a93aba..af6aa4935 100644 --- a/templates/consumer-repo/.github/scripts/token_load_balancer.js +++ b/templates/consumer-repo/.github/scripts/token_load_balancer.js @@ -65,8 +65,16 @@ const invalidAuthWarningMemory = new Set(); * Based on analysis of actual usage across workflows */ const TOKEN_CAPABILITIES = { - GITHUB_TOKEN: ['read-repo', 'write-repo', 'pr-update', 'labels', 'comments'], - PAT: ['read-repo', 'write-repo', 'pr-update', 'labels', 'comments', 'cross-repo', 'workflow-dispatch'], + GITHUB_TOKEN: ['read-repo', 'write-repo', 'pr-update', 'labels', 'comments', 'statuses'], + PAT: ['read-repo', 'write-repo', 'pr-update', 'labels', 'comments', 'cross-repo', 'workflow-dispatch', 'statuses'], + // NO `statuses` FOR APP, and that omission is measured, not assumed. A GitHub App only holds the + // scopes its INSTALLATION was granted, and the installations in use here do not include Commit + // statuses. Observed 2026-08-23 in stranske/Orchestrator: the Gate's own status post selected + // WORKFLOWS_APP and got `POST /repos/.../statuses/ - 403`, while the same job's + // GITHUB_TOKEN was granted `Statuses: write` and posted fine minutes earlier. + // If an App installation is later granted Commit statuses, add 'statuses' back here -- but + // verify against the installation, because a wrong entry here is silent: the balancer hands out + // a token that cannot do the job and the caller sees a 403 it did not ask for. APP: ['read-repo', 'write-repo', 'pr-update', 'labels', 'comments', 'workflow-dispatch'], }; @@ -140,7 +148,12 @@ const CAPABILITY_ALIASES = { 'rate_limit:read': ['read-repo'], 'deployments:write': ['write-repo'], 'checks:read': ['read-repo'], - 'statuses:write': ['write-repo'], + // `statuses:write` maps to its OWN capability, not to the generic `write-repo`. It aliased to + // `write-repo` until 2026-08-23, which all three token types claim -- so declaring + // `capabilities: ['statuses:write']` selected an App that cannot write statuses and the + // declaration was decorative. A capability alias must name what the API endpoint actually + // requires; collapsing a narrow scope into a broad one makes the filter unable to filter. + 'statuses:write': ['statuses'], }; function normalizeCapabilities(capabilities = []) { diff --git a/templates/consumer-repo/.github/workflows/pr-00-gate.yml b/templates/consumer-repo/.github/workflows/pr-00-gate.yml index 351795909..4854916b7 100644 --- a/templates/consumer-repo/.github/workflows/pr-00-gate.yml +++ b/templates/consumer-repo/.github/workflows/pr-00-gate.yml @@ -1066,6 +1066,29 @@ jobs: github, core, task: 'gate-commit-status', + // `env: {}` PINS THIS CALL TO THE WORKFLOW TOKEN, and it is load-bearing. + // With the default `env: process.env` the token load balancer collects every App + // and PAT secret this job exposes and scores them + // (`percentRemaining + priority*10 + typeBonus + taskBonus`), so which token posts + // the Gate's status depends on rate-limit state and varies run to run. `POST /statuses/{sha}` needs the + // `statuses` scope: GITHUB_TOKEN has it here (this job declares `statuses: write`), + // an App installation only has it if it was granted Commit statuses -- and the + // installations in use are not. When the balancer picked the App the post 403'd and + // the swallow left the PREVIOUS status in place, so a green run kept a red + // `Gate / gate` that nothing could clear. Measured in stranske/Orchestrator on + // 2026-08-23: `Selected token: WORKFLOWS_APP` then `POST .../statuses/... - 403`, + // one line after `STATE: success`; a run minutes earlier posted fine with identical + // declared permissions because the balancer chose differently. + // + // WHY THE PIN AND NOT `capabilities: ['statuses:write']`: the declaration is now + // honest (that alias maps to its own `statuses` capability, which APP does not + // claim), but this file is distributed `create_only`, so a consumer's Gate can sit + // at an old revision while token_load_balancer.js is synced forward independently. + // The pin needs no agreement between the two files; the declaration would. + // + // This is one API call per run, so losing rate-limit spreading costs nothing. + // Retries still apply -- only the token source is fixed. + env: {}, }); const owner = context.repo.owner; const repo = context.repo.repo; diff --git a/templates/consumer-repo/scripts/sync_status_file_ignores.py b/templates/consumer-repo/scripts/sync_status_file_ignores.py index 5c5f965d9..d6f714888 100755 --- a/templates/consumer-repo/scripts/sync_status_file_ignores.py +++ b/templates/consumer-repo/scripts/sync_status_file_ignores.py @@ -68,6 +68,18 @@ "workloop-state.md", # Test/coverage artifacts "coverage.xml", + # Per-run agent execution telemetry (HIGH conflict risk). reusable-codex-run.yml rewrites + # this into the checkout root every agent round to stage its upload-artifact step; while + # tracked, codex-autofix committed the diff onto whatever PR was open and the next PR + # collided with main's copy. Patterns, not the literal name, because the file is named after + # the role recorded; bounded by extension so langsmith_*.py sources stay committable. + # ROOT-ANCHORED, and that leading slash is load-bearing. Unanchored, a gitignore pattern + # matches at EVERY depth, so `langsmith-fleet*.json` also swallowed this repo's own tracked + # docs/contracts/schemas/langsmith-fleet-v1.schema.json -- verified with check-ignore, not + # inferred. Same near-miss as the node_modules work: the debris lands in the checkout ROOT, + # so that is the only place the pattern should reach. + "/langsmith-fleet*.json", + "/langsmith-fleet*.ndjson", # Wrong package manager artifacts (defense-in-depth) "Pipfile.lock", "poetry.lock", @@ -85,7 +97,7 @@ # Sync from: stranske/Workflows templates/consumer-repo/.gitignore # Validate: python scripts/sync_status_file_ignores.py --check # ============================================================================= -# Template-Version: 5 +# Template-Version: 6 # BEGIN WORKFLOWS STATUS FILES """