From f0808c1eed6f6c3d521e6e74abdbc2e3c99ddbbd Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:55:35 -0500 Subject: [PATCH 1/5] ci(lanes): hand an empty subscription token to the fallback leg in every lane The lanes that already ran three legs stopped when their subscription token was empty, even with FALLBACK_CLAUDE_CODE_OAUTH_TOKEN set. They now follow the rule #13590 set: an absent subscription credential hands to the fallback leg, which starts only when the fallback token is set, and the fallback leg's lane record names fallback reason no-key. - pr-run-activity-read.yml: the second call's handed-off output is true on an empty token when the caller runs a third call (fallback-ready), so the caller's subscription-handoff job starts the fallback call; the call ends green instead of failing. This covers every read-runner activity (resolve-conflicts, address-feedback, review-dependency, fix-ci, fix-ci-rung-up, fix-docs, resolve-issue). - pr-verify.yml: check-intent-fallback also runs on the subscription job's empty token with the fallback token set. - maintenance-audit-lane-runs.yml: judge-subscription records a no-key marker for an empty token with the fallback token set. - pr-review-main.yml already followed the rule; unchanged. Refs: #13586 Co-Authored-By: Claude Opus 5.5 --- .../actions/resolve-config/callers.test.mjs | 10 ++++- .../maintenance-audit-lane-runs/handoff.mjs | 2 +- .../handoff.test.mjs | 4 +- .../workflow.test.mjs | 10 ++--- .github/scripts/pr-verify/workflow.test.mjs | 28 +++++++++---- .../workflows/maintenance-audit-lane-runs.yml | 12 ++++-- .github/workflows/pr-run-activity-read.yml | 14 ++++++- .github/workflows/pr-verify.yml | 41 +++++++++++-------- 8 files changed, 81 insertions(+), 40 deletions(-) diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index bf10b133326..79f793bb020 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -2437,6 +2437,8 @@ test("pr-run-activity-read.yml: a handoff skips every upload, the patch and the const run = jobs.run; const base = { "inputs.issue-number": "", + // Before inputs.fallback, so the evaluator replaces the longer name first. + "inputs.fallback-ready": "", "inputs.fallback": "", "job.status": "success", "steps.go.outputs.run": "true", @@ -2474,6 +2476,8 @@ test("pr-run-activity-read.yml: a handoff skips every upload, the patch and the "a credit stop": { ...base, "steps.key-present.outputs.present": "true", "steps.act-key.outcome": "failure", "steps.act-key.outputs.handoff": "true", "steps.mode-key.outcome": "skipped" }, // With fallback-ready: a limit stop, a credit stop or a 401 or 402. "a subscription leg's handoff": { ...second, "steps.act-token.outcome": "failure", "steps.act-token.outputs.handoff": "true" }, + // An empty subscription token hands to the third call when the caller runs one. + "an empty subscription token": { ...second, "inputs.fallback-ready": "true", "steps.token-present.outputs.present": "false", "steps.act-token.outcome": "skipped" }, }; // The third call: the fallback leg on the central fallback token. const third = { @@ -2558,8 +2562,12 @@ test("pr-run-activity-read.yml: a handoff skips every upload, the patch and the assert.equal(handedOff(handoffs["a credit stop"]), true); assert.equal(handedOff(handoffs["a subscription leg's handoff"]), true); assert.equal(handedOff(ran), false); - assert.equal(handedOff({ "steps.key-present.outputs.present": "", "steps.act-key.outputs.handoff": "", "steps.act-token.outputs.handoff": "false" }), false, "a second call that ran is no handoff"); + assert.equal(handedOff({ "inputs.fallback-ready": "true", "steps.key-present.outputs.present": "", "steps.act-key.outputs.handoff": "", "steps.act-token.outputs.handoff": "false", "steps.token-present.outputs.present": "true" }), false, "a second call that ran is no handoff"); assert.equal(handedOff(third), false, "the third call never hands off"); + assert.equal(handedOff(handoffs["an empty subscription token"]), true); + const noThird = { ...second, "inputs.fallback-ready": "false", "steps.token-present.outputs.present": "false", "steps.act-token.outcome": "skipped" }; + assert.equal(handedOff(noThird), false, "with no fallback token an empty subscription token hands nothing on"); + assert.equal(atStep(run.steps.find((s) => s.name === "Fail without a result").if, noThird), true, "and the call fails, as before"); assert.equal(handedOff({ ...ran, "steps.act-key.outcome": "failure", "steps.act-key.outputs.handoff": "false" }), false, "a rate limit or a 403 is no handoff"); const report = (handedOffOutput) => diff --git a/.github/scripts/maintenance-audit-lane-runs/handoff.mjs b/.github/scripts/maintenance-audit-lane-runs/handoff.mjs index ed430d87db5..b4754f38fdd 100644 --- a/.github/scripts/maintenance-audit-lane-runs/handoff.mjs +++ b/.github/scripts/maintenance-audit-lane-runs/handoff.mjs @@ -21,7 +21,7 @@ export const UNIT = /^[acd][1-8]$/; // the key leg an empty key as well. export const REASONS = { key: ["credit-stop", "auth", "no-key"], - subscription: ["limit-stop", "credit-stop", "auth"], + subscription: ["limit-stop", "credit-stop", "auth", "no-key"], }; // The artifact and file name each leg's marker uploads under, before the // unit; the route job downloads by it. diff --git a/.github/scripts/maintenance-audit-lane-runs/handoff.test.mjs b/.github/scripts/maintenance-audit-lane-runs/handoff.test.mjs index 21874f24011..a67190e1b05 100644 --- a/.github/scripts/maintenance-audit-lane-runs/handoff.test.mjs +++ b/.github/scripts/maintenance-audit-lane-runs/handoff.test.mjs @@ -19,7 +19,9 @@ test("a marker takes a collector unit and its leg's handoff reasons only", () => assert.deepEqual(marker("key", "d8", "no-key"), { unit: "d8", reason: "no-key" }); assert.deepEqual(marker("subscription", "c2", "limit-stop"), { unit: "c2", reason: "limit-stop" }); assert.throws(() => marker("key", "a1", "limit-stop"), /not a key handoff reason/); - assert.throws(() => marker("subscription", "a1", "no-key"), /not a subscription handoff reason/); + // An empty subscription token hands on as an empty key does. + assert.deepEqual(marker("subscription", "c3", "no-key"), { unit: "c3", reason: "no-key" }); + assert.throws(() => marker("subscription", "a1", "bogus"), /not a subscription handoff reason/); assert.throws(() => marker("key", "a9", "auth"), /not a collector id/); assert.throws(() => marker("key", "a1\n", "auth"), /not a collector id/); assert.throws(() => marker("fallback", "a1", "auth"), /not key or subscription/); diff --git a/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs b/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs index e79dc95b317..32c85441976 100644 --- a/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs +++ b/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs @@ -55,7 +55,7 @@ const LAUNCHER = /^melodic-software\/ci-workflows\/\.github\/actions\/run-claude const SUB_REASON = "${{ matrix.reason == 'credit-stop' && 'credit-stop' || matrix.reason == 'auth' && 'auth' || matrix.reason == 'no-key' && 'no-key' || '' }}"; const FB_REASON = - "${{ matrix.reason == 'limit-stop' && 'limit-stop' || matrix.reason == 'credit-stop' && 'credit-stop' || matrix.reason == 'auth' && 'auth' || '' }}"; + "${{ matrix.reason == 'limit-stop' && 'limit-stop' || matrix.reason == 'credit-stop' && 'credit-stop' || matrix.reason == 'auth' && 'auth' || matrix.reason == 'no-key' && 'no-key' || '' }}"; test("only a schedule or a manual run starts the lane, with no default permissions", () => { const on = /\non:\n((?: {2}.*\n| {4}.*\n)+)/.exec(text)[1]; @@ -176,10 +176,10 @@ test("a unit reaches the next leg only through a clean handoff its route job rea ); assert.match(jobs[FB], /\n {6}matrix: \$\{\{ fromJson\(needs\.judge-subscription-handoffs\.outputs\.matrix\) \}\}\n/); // The key leg hands off on the launcher's handoff after a denial-free leg, - // or on an empty key; the subscription leg only on the launcher's handoff, - // and only with the fallback token set; the fallback leg never. + // or on an empty key; the subscription leg on the launcher's handoff, or + // on an empty token, and only with the fallback token set; the fallback leg never. assert.equal(value(step("Record the handoff", KEY), "if", 8), "steps.key-leg.outputs.clean == 'true' || steps.credential.outputs.present == 'false'"); - assert.equal(value(step("Record the handoff", SUB), "if", 8), "steps.sub-leg.outputs.clean == 'true'"); + assert.equal(value(step("Record the handoff", SUB), "if", 8), "steps.sub-leg.outputs.clean == 'true' || steps.credential.outputs.present == 'false' && steps.credential.outputs.fallback-ready == 'true'"); assert.ok(!steps(FB).some((s) => /handoff/i.test(s.name)), "the fallback leg records no handoff"); for (const [job, leg] of [ [KEY, "key-leg"], @@ -225,7 +225,7 @@ test("each unit leaves one lane record per leg, the leg that handed off marked h assert.equal(value(report(SUB), "fallback-reason"), SUB_REASON); assert.equal(value(report(FB), "fallback-reason"), FB_REASON); // matrix.reason reaches nothing else. - for (const job of LEGS) assert.equal(jobs[job].match(/matrix\.reason/g)?.length ?? 0, job === KEY ? 0 : 3, job); + for (const job of LEGS) assert.equal(jobs[job].match(/matrix\.reason/g)?.length ?? 0, job === KEY ? 0 : job === FB ? 4 : 3, job); // A handoff spares a leg its red; any other run with no findings fails last. for (const job of LEGS) { const names = steps(job).map((s) => s.name); diff --git a/.github/scripts/pr-verify/workflow.test.mjs b/.github/scripts/pr-verify/workflow.test.mjs index 4348c3c59da..2173a48db29 100644 --- a/.github/scripts/pr-verify/workflow.test.mjs +++ b/.github/scripts/pr-verify/workflow.test.mjs @@ -60,7 +60,7 @@ const FALLBACK_REASON = // The fallback job's record names why the subscription did not judge, from // its handoff-reason (ci-workflows intake-triage.yml, the fallback job). const FB_REASON = - "${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }}"; + "${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.check-intent-subscription.outputs.credential-present == 'false' && 'no-key' || '' }}"; const FALLBACK = "FALLBACK_CLAUDE_CODE_OAUTH_TOKEN"; // Whether the fallback leg can run, never the token (ci-workflows // intake-triage.yml and pr-review-cli.yml, "Check the credential"). @@ -572,6 +572,10 @@ test("the subscription job runs only on a clean handoff, an empty key or a key t ` leg-clean: \${{ ${chainOf((id) => `steps.${id}.outputs.clean`, "sub-leg")} }}`, ...WAKE_OUTPUTS, ...CAPTURE_OUTPUT(" # For check-intent-fallback: the capture this job found (find-capture.mjs)."), + " # For check-intent-fallback: an empty token hands the judgment on when", + " # the fallback token is set.", + " credential-present: ${{ steps.credential.outputs.present }}", + " fallback-ready: ${{ steps.credential.outputs.fallback-ready }}", "", ].join("\n"), ); @@ -582,22 +586,26 @@ test("the subscription job runs only on a clean handoff, an empty key or a key t } }); -test("the fallback job runs only on the subscription job's clean handoff, on a fixed runner", () => { +test("the fallback job runs only on the subscription job's clean handoff, or its empty token with the fallback token set, on a fixed runner", () => { const job = jobText(FB); assert.match(job, /^ {4}needs: check-intent-subscription$/m); const gate = gateOf(FB); - // One hop: an empty subscription token is the subscription job's red, - // never a reason to run the fallback (the reusables gate on handoff alone). + // An empty subscription token hands on as an empty key does, but only to a + // fallback token that is set (#13586). assert.equal( gate, [ "!cancelled()", "&& needs.check-intent-subscription.result == 'success'", `&& ${TRIGGER_GATE}`, - "&& needs.check-intent-subscription.outputs.handoff == 'true'", + "&& (needs.check-intent-subscription.outputs.handoff == 'true'", "&& needs.check-intent-subscription.outputs.leg-clean == 'true'", + "|| needs.check-intent-subscription.outputs.credential-present == 'false'", + "&& needs.check-intent-subscription.outputs.fallback-ready == 'true')", ].join(" "), ); + assert.ok(admits(gate, withNeeds({ [SUB]: { result: "success", outputs: { "credential-present": "false", "fallback-ready": "true" } } })), "an empty subscription token"); + assert.ok(!admits(gate, withNeeds({ [SUB]: { result: "success", outputs: { "credential-present": "false", "fallback-ready": "false" } } })), "every token empty"); // The implicit success() needs every ancestor, so a key job the credit gate // skipped would skip this job; the subscription job's success is spelled out. assert.doesNotMatch(gate, /always\(\)|failure\(\)|success\(\)/); @@ -701,6 +709,7 @@ test("the subscription job repeats the key job's steps but for the credential", .replace(/^( {10}handoff: .*steps\.)sub(-leg)/m, "$1key$2") .replace(`\n fallback-reason: ${FALLBACK_REASON}`, "") .replace(/ && steps\.credential\.outputs\.present != 'false'/, "") + .replace(" && !(steps.credential.outputs.present == 'false' && steps.credential.outputs.fallback-ready == 'true')", "") .replace(/(?: && steps\.(?:key|sub)-leg(?:-\d+|-spec)?\.outcome != 'success')+$/m, ""), ); assert.deepEqual(normalize(STEPS[SUB]), normalize(STEPS[KEY])); @@ -839,14 +848,14 @@ test("the outcome is reported whenever a judge ran to a final class, and a missi } assert.equal(input(step("Report the judgment's outcome", SUB), "fallback-reason"), FALLBACK_REASON); assert.equal(input(step("Report the judgment's outcome", FB), "fallback-reason"), FB_REASON); - // Only a hand-off spares the key job its red; an empty token fails the subscription job. + // Only a hand-off spares the key job its red; an empty subscription token fails its job unless the fallback token takes over. assert.match( STEPS[KEY].at(-1).block, new RegExp(`^ {8}if: always\\(\\) && steps\\.agree\\.outcome == 'success' && steps\\.credit\\.outputs\\.skip-key != 'true' && steps\\.verdict\\.outcome != 'success' && steps\\.credential\\.outputs\\.present != 'false' && ${legsClear("key-leg")}$`, "m"), ); assert.match( STEPS[SUB].at(-1).block, - new RegExp(`^ {8}if: always\\(\\) && steps\\.agree\\.outcome == 'success' && steps\\.verdict\\.outcome != 'success' && ${legsClear("sub-leg")}$`, "m"), + new RegExp(`^ {8}if: always\\(\\) && steps\\.agree\\.outcome == 'success' && steps\\.verdict\\.outcome != 'success' && !\\(steps\\.credential\\.outputs\\.present == 'false' && steps\\.credential\\.outputs\\.fallback-ready == 'true'\\) && ${legsClear("sub-leg")}$`, "m"), ); assert.match(STEPS[FB].at(-1).block, /^ {8}if: always\(\) && steps\.agree\.outcome == 'success' && steps\.verdict\.outcome != 'success'$/m); }); @@ -873,9 +882,10 @@ test("a handoff leaves one lane record per leg, the leg that handed off marked h assert.match(FALLBACK_REASON, /lane-class == 'auth' && 'auth'/); // A clean subscription handoff skips the subscription job's report the // same way; the fallback job, which runs exactly when leg-clean is true, - // records the run with report-lane-outcome's limit-stop, credit-stop or auth. + // records the run with report-lane-outcome's limit-stop, credit-stop or auth, + // or no-key after an empty subscription token. assert.match(jobText(FB), /needs\.check-intent-subscription\.outputs\.leg-clean == 'true'/); - assert.deepEqual([...FB_REASON.matchAll(/&& '([a-z-]+)'/g)].map((m) => m[1]), ["limit-stop", "credit-stop", "auth"]); + assert.deepEqual([...FB_REASON.matchAll(/&& '([a-z-]+)'/g)].map((m) => m[1]), ["limit-stop", "credit-stop", "auth", "no-key"]); // No gate keys the subscription leg on the credit-stop class any more: // run-claude-cli's handoff output is the one signal. for (const line of text.split("\n").filter((l) => /^\s+(- )?if: /.test(l) || /^\s+&& /.test(l))) { diff --git a/.github/workflows/maintenance-audit-lane-runs.yml b/.github/workflows/maintenance-audit-lane-runs.yml index 94c61227acd..93f3804a0a6 100644 --- a/.github/workflows/maintenance-audit-lane-runs.yml +++ b/.github/workflows/maintenance-audit-lane-runs.yml @@ -565,7 +565,11 @@ jobs: if [ "$PRESENT" = true ]; then echo "present=true" >>"$GITHUB_OUTPUT" else - echo "::warning::$SECRET_NAME is empty; this leg does not judge." + if [ "$FALLBACK_PRESENT" = true ]; then + echo "::warning::$SECRET_NAME is empty; judge-fallback judges this unit on FALLBACK_CLAUDE_CODE_OAUTH_TOKEN." + else + echo "::warning::$SECRET_NAME is empty; this leg does not judge." + fi echo "present=false" >>"$GITHUB_OUTPUT" fi if [ "$FALLBACK_PRESENT" = true ]; then @@ -704,11 +708,11 @@ jobs: # each checked against a closed set. - name: Record the handoff id: handoff - if: steps.sub-leg.outputs.clean == 'true' + if: steps.sub-leg.outputs.clean == 'true' || steps.credential.outputs.present == 'false' && steps.credential.outputs.fallback-ready == 'true' env: LEG: subscription UNIT: ${{ matrix.unit }} - REASON: ${{ steps.judge.outputs.handoff-reason }} + REASON: ${{ steps.credential.outputs.present == 'false' && 'no-key' || steps.judge.outputs.handoff-reason }} HANDOFF_DIR: ${{ runner.temp }}/lane-sweep-handoff run: node .base/.github/scripts/maintenance-audit-lane-runs/handoff.mjs record @@ -1048,7 +1052,7 @@ jobs: max-turns: ${{ steps.models.outputs.max-turns }} known-failure-class: ${{ steps.judge.outputs.lane-class }} # Why this leg ran, for its lane record. - fallback-reason: ${{ matrix.reason == 'limit-stop' && 'limit-stop' || matrix.reason == 'credit-stop' && 'credit-stop' || matrix.reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ matrix.reason == 'limit-stop' && 'limit-stop' || matrix.reason == 'credit-stop' && 'credit-stop' || matrix.reason == 'auth' && 'auth' || matrix.reason == 'no-key' && 'no-key' || '' }} # The structured output is untrusted model text: read from the # launcher's file and checked by the script, never interpolated into a diff --git a/.github/workflows/pr-run-activity-read.yml b/.github/workflows/pr-run-activity-read.yml index c08694258ae..7bbfe142e4b 100644 --- a/.github/workflows/pr-run-activity-read.yml +++ b/.github/workflows/pr-run-activity-read.yml @@ -662,7 +662,10 @@ jobs: # files, so the leg cannot set them; the presence steps run before it. lane-class: ${{ steps.act-key.outputs.lane-class || steps.act-token.outputs.lane-class || steps.act-fallback.outputs.lane-class }} credential-present: ${{ steps.key-present.outputs.present || steps.token-present.outputs.present || steps.fallback-present.outputs.present }} - handed-off: ${{ steps.key-present.outputs.present == 'false' || steps.act-key.outputs.handoff == 'true' || steps.act-token.outputs.handoff == 'true' }} + # An empty credential hands on as a handoff does: an empty key to the + # second call, an empty subscription token to the third when the caller + # runs one (fallback-ready). + handed-off: ${{ steps.key-present.outputs.present == 'false' || steps.act-key.outputs.handoff == 'true' || steps.act-token.outputs.handoff == 'true' || steps.token-present.outputs.present == 'false' && inputs.fallback-ready == 'true' }} # The lane record's class and reset, for the report job's check run, # which limit-stop.mjs reads, as pr-run-activity-write.yml's lane-class. # A forged limit stop can only restart the lane after a reset @@ -1609,11 +1612,16 @@ jobs: && inputs.fallback != '' && inputs.fallback != 'fallback' env: PRESENT: ${{ secrets.claude-code-oauth-token != '' }} + FALLBACK_READY: ${{ inputs.fallback-ready }} run: | if [ "$PRESENT" = true ]; then echo "present=true" >>"$GITHUB_OUTPUT" else - echo "::warning::the role's OAuth token is empty; the activity does not run" + if [ "$FALLBACK_READY" = true ]; then + echo "::warning::the role's OAuth token is empty; the caller's fallback call runs the activity on FALLBACK_CLAUDE_CODE_OAUTH_TOKEN" + else + echo "::warning::the role's OAuth token is empty; the activity does not run" + fi echo "present=false" >>"$GITHUB_OUTPUT" fi echo "started-at=$(date +%s)" >>"$GITHUB_OUTPUT" @@ -2065,6 +2073,7 @@ jobs: && steps.config.outputs.kind == 'skill' && steps.key-present.outputs.present != 'false' && steps.act-key.outputs.handoff != 'true' && steps.act-token.outputs.handoff != 'true' + && !(steps.token-present.outputs.present == 'false' && inputs.fallback-ready == 'true') env: SCRATCH_OUTCOME: ${{ github.workspace }}/.lane/scratch/work-outcome.json OUT_DIR: ${{ runner.temp }}/work-outcome @@ -2260,6 +2269,7 @@ jobs: || steps.fallback-present.outcome == 'success') && steps.key-present.outputs.present != 'false' && steps.act-key.outputs.handoff != 'true' && steps.act-token.outputs.handoff != 'true' + && !(steps.token-present.outputs.present == 'false' && inputs.fallback-ready == 'true') && steps.act-key.outcome != 'success' && steps.act-token.outcome != 'success' && steps.act-fallback.outcome != 'success' run: | diff --git a/.github/workflows/pr-verify.yml b/.github/workflows/pr-verify.yml index 8b527473a17..57e60de36ed 100644 --- a/.github/workflows/pr-verify.yml +++ b/.github/workflows/pr-verify.yml @@ -2173,6 +2173,10 @@ jobs: verdict: ${{ steps.upload-verdict.outcome == 'success' && steps.verdict.outputs.verdict || '' }} # For check-intent-fallback: the capture this job found (find-capture.mjs). capture: ${{ steps.capture.outputs.carry }} + # For check-intent-fallback: an empty token hands the judgment on when + # the fallback token is set. + credential-present: ${{ steps.credential.outputs.present }} + fallback-ready: ${{ steps.credential.outputs.fallback-ready }} steps: - name: Resolve main's tip id: tip @@ -4030,10 +4034,11 @@ jobs: with: tokens: github-token - # A clean handoff hands the judgment on; any other run that passed the - # gates and left no verdict, an empty token included, fails here. + # A clean handoff, or an empty token with the fallback token set, hands + # the judgment on; any other run that passed the gates and left no + # verdict fails here. - name: Fail without a verdict - if: always() && steps.agree.outcome == 'success' && steps.verdict.outcome != 'success' && steps.sub-leg.outcome != 'success' && steps.sub-leg-2.outcome != 'success' && steps.sub-leg-3.outcome != 'success' && steps.sub-leg-4.outcome != 'success' && steps.sub-leg-5.outcome != 'success' && steps.sub-leg-6.outcome != 'success' && steps.sub-leg-7.outcome != 'success' && steps.sub-leg-8.outcome != 'success' && steps.sub-leg-9.outcome != 'success' && steps.sub-leg-10.outcome != 'success' && steps.sub-leg-11.outcome != 'success' && steps.sub-leg-12.outcome != 'success' && steps.sub-leg-spec.outcome != 'success' + if: always() && steps.agree.outcome == 'success' && steps.verdict.outcome != 'success' && !(steps.credential.outputs.present == 'false' && steps.credential.outputs.fallback-ready == 'true') && steps.sub-leg.outcome != 'success' && steps.sub-leg-2.outcome != 'success' && steps.sub-leg-3.outcome != 'success' && steps.sub-leg-4.outcome != 'success' && steps.sub-leg-5.outcome != 'success' && steps.sub-leg-6.outcome != 'success' && steps.sub-leg-7.outcome != 'success' && steps.sub-leg-8.outcome != 'success' && steps.sub-leg-9.outcome != 'success' && steps.sub-leg-10.outcome != 'success' && steps.sub-leg-11.outcome != 'success' && steps.sub-leg-12.outcome != 'success' && steps.sub-leg-spec.outcome != 'success' run: | echo "::error::the judgment left no verdict for this head" exit 1 @@ -4058,8 +4063,10 @@ jobs: && github.event.workflow_run.event == 'pull_request' && (github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure') && github.event.workflow_run.head_repository.full_name == github.repository - && needs.check-intent-subscription.outputs.handoff == 'true' + && (needs.check-intent-subscription.outputs.handoff == 'true' && needs.check-intent-subscription.outputs.leg-clean == 'true' + || needs.check-intent-subscription.outputs.credential-present == 'false' + && needs.check-intent-subscription.outputs.fallback-ready == 'true') runs-on: ubuntu-24.04 timeout-minutes: 346 cache-mode: read @@ -4454,7 +4461,7 @@ jobs: max-turns: ${{ steps.models.outputs.max-turns }} known-failure-class: ${{ steps.judge.outputs.lane-class }} # Why this job ran, for its lane record. - fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.check-intent-subscription.outputs.credential-present == 'false' && 'no-key' || '' }} # The leg's transcript, failed legs included, sealed to the transcript # key and kept 7 days for the puller; a failed upload never fails the job. @@ -4550,7 +4557,7 @@ jobs: credential: ${{ steps.judge-2.outputs.credential }} max-turns: ${{ steps.models.outputs.max-turns }} known-failure-class: ${{ steps.judge-2.outputs.lane-class }} - fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.check-intent-subscription.outputs.credential-present == 'false' && 'no-key' || '' }} - name: Upload chunk 2's sealed transcript if: always() && steps.judge-2.outputs.execution-file != '' @@ -4633,7 +4640,7 @@ jobs: credential: ${{ steps.judge-3.outputs.credential }} max-turns: ${{ steps.models.outputs.max-turns }} known-failure-class: ${{ steps.judge-3.outputs.lane-class }} - fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.check-intent-subscription.outputs.credential-present == 'false' && 'no-key' || '' }} - name: Upload chunk 3's sealed transcript if: always() && steps.judge-3.outputs.execution-file != '' @@ -4716,7 +4723,7 @@ jobs: credential: ${{ steps.judge-4.outputs.credential }} max-turns: ${{ steps.models.outputs.max-turns }} known-failure-class: ${{ steps.judge-4.outputs.lane-class }} - fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.check-intent-subscription.outputs.credential-present == 'false' && 'no-key' || '' }} - name: Upload chunk 4's sealed transcript if: always() && steps.judge-4.outputs.execution-file != '' @@ -4800,7 +4807,7 @@ jobs: credential: ${{ steps.judge-5.outputs.credential }} max-turns: ${{ steps.models.outputs.max-turns }} known-failure-class: ${{ steps.judge-5.outputs.lane-class }} - fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.check-intent-subscription.outputs.credential-present == 'false' && 'no-key' || '' }} - name: Upload chunk 5's sealed transcript if: always() && steps.judge-5.outputs.execution-file != '' @@ -4883,7 +4890,7 @@ jobs: credential: ${{ steps.judge-6.outputs.credential }} max-turns: ${{ steps.models.outputs.max-turns }} known-failure-class: ${{ steps.judge-6.outputs.lane-class }} - fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.check-intent-subscription.outputs.credential-present == 'false' && 'no-key' || '' }} - name: Upload chunk 6's sealed transcript if: always() && steps.judge-6.outputs.execution-file != '' @@ -4966,7 +4973,7 @@ jobs: credential: ${{ steps.judge-7.outputs.credential }} max-turns: ${{ steps.models.outputs.max-turns }} known-failure-class: ${{ steps.judge-7.outputs.lane-class }} - fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.check-intent-subscription.outputs.credential-present == 'false' && 'no-key' || '' }} - name: Upload chunk 7's sealed transcript if: always() && steps.judge-7.outputs.execution-file != '' @@ -5049,7 +5056,7 @@ jobs: credential: ${{ steps.judge-8.outputs.credential }} max-turns: ${{ steps.models.outputs.max-turns }} known-failure-class: ${{ steps.judge-8.outputs.lane-class }} - fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.check-intent-subscription.outputs.credential-present == 'false' && 'no-key' || '' }} - name: Upload chunk 8's sealed transcript if: always() && steps.judge-8.outputs.execution-file != '' @@ -5132,7 +5139,7 @@ jobs: credential: ${{ steps.judge-9.outputs.credential }} max-turns: ${{ steps.models.outputs.max-turns }} known-failure-class: ${{ steps.judge-9.outputs.lane-class }} - fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.check-intent-subscription.outputs.credential-present == 'false' && 'no-key' || '' }} - name: Upload chunk 9's sealed transcript if: always() && steps.judge-9.outputs.execution-file != '' @@ -5215,7 +5222,7 @@ jobs: credential: ${{ steps.judge-10.outputs.credential }} max-turns: ${{ steps.models.outputs.max-turns }} known-failure-class: ${{ steps.judge-10.outputs.lane-class }} - fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.check-intent-subscription.outputs.credential-present == 'false' && 'no-key' || '' }} - name: Upload chunk 10's sealed transcript if: always() && steps.judge-10.outputs.execution-file != '' @@ -5298,7 +5305,7 @@ jobs: credential: ${{ steps.judge-11.outputs.credential }} max-turns: ${{ steps.models.outputs.max-turns }} known-failure-class: ${{ steps.judge-11.outputs.lane-class }} - fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.check-intent-subscription.outputs.credential-present == 'false' && 'no-key' || '' }} - name: Upload chunk 11's sealed transcript if: always() && steps.judge-11.outputs.execution-file != '' @@ -5381,7 +5388,7 @@ jobs: credential: ${{ steps.judge-12.outputs.credential }} max-turns: ${{ steps.models.outputs.max-turns }} known-failure-class: ${{ steps.judge-12.outputs.lane-class }} - fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.check-intent-subscription.outputs.credential-present == 'false' && 'no-key' || '' }} - name: Upload chunk 12's sealed transcript if: always() && steps.judge-12.outputs.execution-file != '' @@ -5528,7 +5535,7 @@ jobs: credential: ${{ steps.judge-spec.outputs.credential }} max-turns: ${{ steps.models.outputs.max-turns }} known-failure-class: ${{ steps.judge-spec.outputs.lane-class }} - fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + fallback-reason: ${{ needs.check-intent-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.check-intent-subscription.outputs.handoff-reason == 'auth' && 'auth' || needs.check-intent-subscription.outputs.credential-present == 'false' && 'no-key' || '' }} - name: Upload the spec judgment's sealed transcript if: always() && steps.judge-spec.outputs.execution-file != '' From b74c4534f1ce6d4c38c1e48e711f528f3b265cdb Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:05:12 -0500 Subject: [PATCH 2/5] ci(lanes): run the reuse summary on the third call's token and record every leg The read runner's reuse summary had a key leg and a subscription leg, so on a third call it ran on the empty subscription secret. It now runs on the fallback token there, and writes a lane-outcome record for the leg it ran. upstream-apply-changelog's scrub-probe legs record theirs too. A test now fails any run-claude-cli job with no lane record (lane-id), so every leg's credential, outcome, handoff and durationMs reach lane telemetry; the maintenance role's propose pass is the one listed exception, open on #13586. Refs: #13586 Co-Authored-By: Claude Opus 5.5 --- .../actions/resolve-config/callers.test.mjs | 36 ++++++++++++ .github/workflows/pr-run-activity-read.yml | 57 ++++++++++++++++--- .../workflows/upstream-apply-changelog.yml | 51 +++++++++++++++++ 3 files changed, 137 insertions(+), 7 deletions(-) diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index 79f793bb020..bb0c97c8703 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -8981,3 +8981,39 @@ test("maintenance-tidy-code-upkeep.yml: the sweep runs key, subscription and fal assert.equal(credit.with["key-job"], "model"); for (const id of ["model-subscription", "model-fallback"]) assert.doesNotMatch(JSON.stringify(jobs[id]), /key-credit-stop/, id); }); + +// Every job that runs a run-claude-cli leg writes that leg's lane-outcome +// record (report-lane-outcome with a lane-id): the record carries the leg's +// credential, outcome, handoff and durationMs, which lane telemetry +// aggregates per leg (#13586). +const RECORDLESS_LEGS = new Set([ + // The maintenance role's proposal pass, open on #13586 with that role's + // missing key; its lane record comes with its legs. + "maintenance-measure-skill-edit.yml propose", +]); +test("every run-claude-cli job writes a lane record for its leg", () => { + const missing = []; + for (const file of workflowFiles()) { + for (const [id, job] of Object.entries(workflow(file).jobs ?? {})) { + const steps = job?.steps ?? []; + if (!steps.some((s) => String(s.uses ?? "").includes("/run-claude-cli@"))) continue; + const recorded = steps.some((s) => String(s.uses ?? "").includes("/report-lane-outcome@") && s.with?.["lane-id"]); + if (!recorded && !RECORDLESS_LEGS.has(`${file} ${id}`)) missing.push(`${file} ${id}`); + } + } + assert.deepEqual(missing, []); +}); + +test("pr-run-activity-read.yml: the reuse summary runs on the credential of the call it is in", () => { + const { jobs } = workflow("pr-run-activity-read.yml"); + const step = (id) => jobs.summary.steps.find((s) => s.id === id); + const runs = (id, fallback) => atStep(step(id).if, { "steps.isolate.outcome": "success", "inputs.fallback": fallback }); + for (const [fallback, id] of [["", "summary-key"], ["handoff", "summary-token"], ["credit-stop", "summary-token"], ["fallback", "summary-fallback"]]) { + for (const other of ["summary-key", "summary-token", "summary-fallback"]) { + assert.equal(runs(other, fallback), other === id, `${other} on fallback ${JSON.stringify(fallback)}`); + } + } + assert.equal(step("summary-fallback").with["fallback-token"], "${{ secrets.fallback-claude-code-oauth-token }}"); + assert.equal(step("summary-token").with["subscription-token"], "${{ secrets.claude-code-oauth-token }}"); + assert.equal(step("summary-key").with["api-key"], "${{ secrets.claude-api-key }}"); +}); diff --git a/.github/workflows/pr-run-activity-read.yml b/.github/workflows/pr-run-activity-read.yml index 7bbfe142e4b..1a8735d3056 100644 --- a/.github/workflows/pr-run-activity-read.yml +++ b/.github/workflows/pr-run-activity-read.yml @@ -3482,7 +3482,8 @@ jobs: install-subprocess-isolation: "true" # One launcher step per secret, as in the run job: the first call's - # pass runs on the API key, a fallback call's on the OAuth token. The + # pass runs on the API key, the second call's on the OAuth token and the + # third call's on the central fallback token. The # model reads the data files and nothing else: Read is its one tool, # and the default branch checkout and process files are denied. - name: Summarize the reused branch on the API key @@ -3513,7 +3514,7 @@ jobs: - name: Summarize the reused branch on the OAuth token id: summary-token - if: steps.isolate.outcome == 'success' && inputs.fallback != '' + if: steps.isolate.outcome == 'success' && inputs.fallback != '' && inputs.fallback != 'fallback' continue-on-error: true timeout-minutes: 15 uses: melodic-software/ci-workflows/.github/actions/run-claude-cli@b8bdd7fa2319b2457cbbef32c5348c394b9bc3f6 # b8bdd7f 2026-11-04 @@ -3537,16 +3538,58 @@ jobs: json-schema-file: ${{ runner.temp }}/reuse-summary-schema.json leg-timeout-minutes: "10" + - name: Summarize the reused branch on the fallback token + id: summary-fallback + if: steps.isolate.outcome == 'success' && inputs.fallback == 'fallback' + continue-on-error: true + timeout-minutes: 15 + uses: melodic-software/ci-workflows/.github/actions/run-claude-cli@b8bdd7fa2319b2457cbbef32c5348c394b9bc3f6 # b8bdd7f 2026-11-04 + env: + CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" + with: + lane: summarize-reuse + role: ${{ inputs.role }} + prompt-file: ${{ github.workspace }}/.summary/prompt.md + preamble: You run unattended as a CI lane's read-only summary pass, and no one can answer a question. + fallback-token: ${{ secrets.fallback-claude-code-oauth-token }} + model: ${{ steps.summary-models.outputs.model }} + effort: ${{ steps.summary-models.outputs.effort }} + max-turns: ${{ steps.summary-models.outputs.max-turns }} + permission-mode: dontAsk + tools: Read + allowed-tools: Read(./.summary/**) + disallowed-tools: | + Read(./.base/**) + Read(//proc/**) + json-schema-file: ${{ runner.temp }}/reuse-summary-schema.json + leg-timeout-minutes: "10" + # The leg's transcript, failed legs included, sealed to the transcript # key and kept 7 days for the puller; a failed upload never fails the job. - name: Upload the sealed transcript - if: always() && (steps.summary-key.outputs.execution-file || steps.summary-token.outputs.execution-file) != '' + if: always() && (steps.summary-key.outputs.execution-file || steps.summary-token.outputs.execution-file || steps.summary-fallback.outputs.execution-file) != '' continue-on-error: true uses: ./.base/.github/actions/upload-lane-transcript # zizmor: ignore[self-repository] with: - execution-file: ${{ steps.summary-key.outputs.execution-file || steps.summary-token.outputs.execution-file }} + execution-file: ${{ steps.summary-key.outputs.execution-file || steps.summary-token.outputs.execution-file || steps.summary-fallback.outputs.execution-file }} public-key: ${{ vars.LANE_TRANSCRIPT_PUBLIC_KEY }} - job-id: reuse-summary-${{ inputs.issue-number }}-${{ inputs.fallback == '' && 'key' || 'token' }} + job-id: reuse-summary-${{ inputs.issue-number }}-${{ inputs.fallback == '' && 'key' || inputs.fallback == 'fallback' && 'fallback' || 'token' }} + + # The summary leg's lane-outcome record: its credential, outcome and + # durationMs. + - name: Record the summary leg + if: always() && (steps.summary-key.outcome != 'skipped' || steps.summary-token.outcome != 'skipped' || steps.summary-fallback.outcome != 'skipped') + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/report-lane-outcome@6b9c640bf209c0c7e76f1111c5d18c96707348a2 # 6b9c640 2026-11-02 + with: + outcome: ${{ steps.summary-key.outcome != 'skipped' && steps.summary-key.outcome || steps.summary-token.outcome != 'skipped' && steps.summary-token.outcome || steps.summary-fallback.outcome }} + execution-file: ${{ steps.summary-key.outputs.execution-file || steps.summary-token.outputs.execution-file || steps.summary-fallback.outputs.execution-file }} + log-file: ${{ steps.summary-key.outputs.log-file || steps.summary-token.outputs.log-file || steps.summary-fallback.outputs.log-file }} + lane: summarize-reuse + lane-id: summarize-reuse + credential: ${{ steps.summary-key.outputs.credential || steps.summary-token.outputs.credential || steps.summary-fallback.outputs.credential }} + known-failure-class: ${{ steps.summary-key.outputs.lane-class || steps.summary-token.outputs.lane-class || steps.summary-fallback.outputs.lane-class }} + max-turns: ${{ steps.summary-models.outputs.max-turns }} # The summary file: issue, branch and tip are runner values from before # the model ran; output is the launcher's structured output, model @@ -3554,10 +3597,10 @@ jobs: # it only for the PR's title and body text. - name: Collect the reused branch's summary id: summary-collect - if: steps.summary-key.outcome == 'success' || steps.summary-token.outcome == 'success' + if: steps.summary-key.outcome == 'success' || steps.summary-token.outcome == 'success' || steps.summary-fallback.outcome == 'success' continue-on-error: true env: - OUTPUT_FILE: ${{ steps.summary-key.outputs.structured-output-file || steps.summary-token.outputs.structured-output-file }} + OUTPUT_FILE: ${{ steps.summary-key.outputs.structured-output-file || steps.summary-token.outputs.structured-output-file || steps.summary-fallback.outputs.structured-output-file }} ISSUE_NUMBER: ${{ inputs.issue-number }} REUSE_BRANCH: ${{ needs.issue-fields.outputs.reuse-branch }} TIP: ${{ steps.summary-data.outputs.tip }} diff --git a/.github/workflows/upstream-apply-changelog.yml b/.github/workflows/upstream-apply-changelog.yml index 6bf8c1c7793..dd4d5aae59c 100644 --- a/.github/workflows/upstream-apply-changelog.yml +++ b/.github/workflows/upstream-apply-changelog.yml @@ -1386,6 +1386,23 @@ jobs: public-key: ${{ vars.LANE_TRANSCRIPT_PUBLIC_KEY }} job-id: ${{ github.job }} + # The probe leg's lane-outcome record: its credential, outcome, handoff + # and durationMs. + - name: Record the probe leg + if: always() && steps.probe.outcome != 'skipped' + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/report-lane-outcome@6b9c640bf209c0c7e76f1111c5d18c96707348a2 # 6b9c640 2026-11-02 + with: + outcome: ${{ steps.probe.outcome }} + execution-file: ${{ steps.probe.outputs.execution-file }} + log-file: ${{ steps.probe.outputs.log-file }} + lane: upstream-apply-changelog scrub probe + lane-id: upstream-apply-changelog-scrub-probe + credential: ${{ steps.probe.outputs.credential }} + handoff: ${{ steps.probe.outputs.handoff }} + known-failure-class: ${{ steps.probe.outputs.lane-class }} + max-turns: "6" + # Fails closed, naming the release and hit names only: a failed leg, a # CLI other than the release, no result, a name that never reached the # probe, any hit, a missed canary, or a Read the deny rules let through. @@ -1579,6 +1596,23 @@ jobs: public-key: ${{ vars.LANE_TRANSCRIPT_PUBLIC_KEY }} job-id: ${{ github.job }} + # The probe leg's lane-outcome record: its credential, outcome, handoff + # and durationMs. + - name: Record the probe leg + if: always() && steps.probe.outcome != 'skipped' + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/report-lane-outcome@6b9c640bf209c0c7e76f1111c5d18c96707348a2 # 6b9c640 2026-11-02 + with: + outcome: ${{ steps.probe.outcome }} + execution-file: ${{ steps.probe.outputs.execution-file }} + log-file: ${{ steps.probe.outputs.log-file }} + lane: upstream-apply-changelog scrub probe + lane-id: upstream-apply-changelog-scrub-probe + credential: ${{ steps.probe.outputs.credential }} + handoff: ${{ steps.probe.outputs.handoff }} + known-failure-class: ${{ steps.probe.outputs.lane-class }} + max-turns: "6" + # Fails closed, naming the release and hit names only: a failed leg, a # CLI other than the release, no result, a name that never reached the # probe, any hit, a missed canary, or a Read the deny rules let through. @@ -1763,6 +1797,23 @@ jobs: public-key: ${{ vars.LANE_TRANSCRIPT_PUBLIC_KEY }} job-id: ${{ github.job }} + # The probe leg's lane-outcome record: its credential, outcome, handoff + # and durationMs. + - name: Record the probe leg + if: always() && steps.probe.outcome != 'skipped' + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/report-lane-outcome@6b9c640bf209c0c7e76f1111c5d18c96707348a2 # 6b9c640 2026-11-02 + with: + outcome: ${{ steps.probe.outcome }} + execution-file: ${{ steps.probe.outputs.execution-file }} + log-file: ${{ steps.probe.outputs.log-file }} + lane: upstream-apply-changelog scrub probe + lane-id: upstream-apply-changelog-scrub-probe + credential: ${{ steps.probe.outputs.credential }} + handoff: ${{ steps.probe.outputs.handoff }} + known-failure-class: ${{ steps.probe.outputs.lane-class }} + max-turns: "6" + # Fails closed, naming the release and hit names only: a failed leg, a # CLI other than the release, no result, a name that never reached the # probe, any hit, a missed canary, or a Read the deny rules let through. From 9fc13d9aecbbf06104cbf687d18390d45397c391 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:30:57 -0500 Subject: [PATCH 3/5] ci(lanes): skip a credit-stopped key in the read runner and the lane sweep The read runner's broker-check, which runs no head code, now runs ci-workflows check-key-credit for the first call's key-credit-job. A credit stop inside KEY_CREDIT_WINDOW_HOURS skips the key: the call hands off before any model turn and the caller's next call runs on the subscription. broker-check grants actions: read, so every read-runner caller job does too; the run job keeps contents and pull-requests read. The marker comes from a jobs-API read, never a run job output: the run job's fixed-name "Mark a key credit stop" step runs only on run-claude-cli's handoff-reason, handoff-evidence.sh reads it as the new credit-stop fact, and each first call's handoff job uploads key-credit-stop-- on it. maintenance-audit-lane-runs.yml gets the same gate: collect reads the key's credit history, a skipped key skips judge, judge-handoffs routes every unit to judge-subscription as a credit-stop handoff (handoff.mjs route-skipped), and uploads the marker after a key leg's credit stop. Refs: #13586 Co-Authored-By: Claude Opus 5.5 --- .../actions/resolve-config/callers.test.mjs | 99 ++++++++- .github/artifact-retention.json | 4 +- .github/scripts/lib/handoff-evidence.sh | 16 +- .../maintenance-audit-lane-runs/handoff.mjs | 17 +- .../workflow.test.mjs | 21 +- .../workflows/maintenance-audit-lane-runs.yml | 80 ++++++- .github/workflows/pr-refine.yml | 41 ++++ .github/workflows/pr-run-activity-read.yml | 73 ++++++- .github/workflows/pr-unblock.yml | 205 ++++++++++++++++++ .github/workflows/work-resolve-issue.yml | 41 ++++ 10 files changed, 576 insertions(+), 21 deletions(-) diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index bb0c97c8703..edbd180cbce 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -1386,7 +1386,8 @@ test("pr-run-activity-read.yml: an issue run re-checks its blockers inside the s assert.equal(budget.uses, "./.base/.github/actions/log-api-budget"); assert.equal(budget.if, "always()"); // No job's permissions widen: broker-check's issues grant was already the lease's, and the model job holds read only. - assert.deepEqual(check.permissions, { contents: "read", "id-token": "write", issues: "write" }); + // actions: read is the key-credit gate's, listing the credit-stop markers. + assert.deepEqual(check.permissions, { actions: "read", contents: "read", "id-token": "write", issues: "write" }); assert.deepEqual(jobs.run.permissions, { contents: "read", "pull-requests": "read" }); // Only the first call of an issue run re-checks, and only once it holds its lease; // the subscription and fallback legs, PR mode and a backed-off run do not. @@ -2820,8 +2821,12 @@ for (const [file, firstId, subId, extraNeeds, extraProceed, applyId] of PATCH_LA for (const job of [first, sub]) assert.equal(job.permissions.issues, "write"); assert.equal(first.with.role, "pr-unblock"); assert.equal(first.with.fallback, undefined); + // Only the first call names its key job for the key-credit gate. + const { "key-credit-job": keyCreditJob, ...firstSame } = first.with; + assert.equal(keyCreditJob, firstId, "the first call names its own key job"); + assert.equal(sub.with["key-credit-job"], undefined); const { fallback, "fallback-ready": fallbackReady, ...same } = sub.with; - assert.deepEqual(same, first.with, "the fallback call runs the same activity on the same inputs"); + assert.deepEqual(same, firstSame, "the fallback call runs the same activity on the same inputs"); // The third call: the same activity on the same inputs, on the central // fallback token alone, started on the second call's handoff as read from // this run's jobs, and only with the fallback token set. @@ -2831,7 +2836,7 @@ for (const [file, firstId, subId, extraNeeds, extraProceed, applyId] of PATCH_LA assert.equal(fallbackReady, `\${{ needs.${HANDOFF[firstId]}.outputs.fallback-ready }}`); const { fallback: thirdFallback, ...thirdSame } = third.with; assert.equal(thirdFallback, "fallback"); - assert.deepEqual(thirdSame, first.with, "the third call runs the same activity on the same inputs"); + assert.deepEqual(thirdSame, firstSame, "the third call runs the same activity on the same inputs"); assert.deepEqual(third.secrets, { "fallback-claude-code-oauth-token": "${{ secrets.FALLBACK_CLAUDE_CODE_OAUTH_TOKEN }}", ...CARRIER_SECRETS }); assert.equal(third.permissions.issues, "write"); assert.deepEqual(third.concurrency, first.concurrency, fallbackId); @@ -3002,7 +3007,7 @@ test("pr-refine.yml: fix-docs runs only on a fix-docs dispatch with a PR number, assert.equal(jobs[id].with["trusted-base"], "default-tip", id); assert.equal(jobs[id].with["dispatcher-run-id"], "${{ inputs.dispatcher-run-id }}", id); assert.equal(jobs[id].with.lane, "pr-refine", id); - assert.deepEqual(jobs[id].permissions, { contents: "read", "pull-requests": "read", checks: "write", issues: "write", "id-token": "write" }, id); + assert.deepEqual(jobs[id].permissions, { ...(id === "apply-docs-patch" ? {} : { actions: "read" }), contents: "read", "pull-requests": "read", checks: "write", issues: "write", "id-token": "write" }, id); } const starts = ({ activity = "fix-docs", pr = "7", sender = "1", held = "false" } = {}) => evaluate(jobs["fix-docs"].if, { @@ -7641,14 +7646,16 @@ test("work-resolve-issue.yml: resolve-issue-subscription starts on resolve-issue assert.equal(starts("true", { "needs.pick-slot.outputs.capped": "true" }), false, "at the WIP cap"); assert.equal(sub.with.fallback, "handoff"); assert.equal(sub.with["fallback-ready"], "${{ needs.resolve-issue-handoff.outputs.fallback-ready }}"); + const { "key-credit-job": keyCreditJob, ...firstSame } = jobs["resolve-issue"].with; + assert.equal(keyCreditJob, "resolve-issue", "the first call names its own key job"); const { fallback, "fallback-ready": fallbackReady, ...same } = sub.with; - assert.deepEqual(same, jobs["resolve-issue"].with, "the fallback call runs the same activity on the same inputs"); + assert.deepEqual(same, firstSame, "the fallback call runs the same activity on the same inputs"); assert.deepEqual(sub.concurrency, jobs["resolve-issue"].concurrency, "in the same slot"); // The third call: the same inputs on the fallback token, in the same slot. const third = jobs["resolve-issue-fallback"]; const { fallback: thirdFallback, ...thirdSame } = third.with; assert.equal(thirdFallback, "fallback"); - assert.deepEqual(thirdSame, jobs["resolve-issue"].with, "the third call runs the same activity on the same inputs"); + assert.deepEqual(thirdSame, firstSame, "the third call runs the same activity on the same inputs"); assert.deepEqual(third.concurrency, jobs["resolve-issue"].concurrency, "in the same slot"); // The handoff job: the default branch tip's handoff-evidence.sh on // read-only tokens, no secret, as every pr-unblock handoff job runs it. @@ -7661,7 +7668,8 @@ test("work-resolve-issue.yml: resolve-issue-subscription starts on resolve-issue assert.equal(evaluate(handoff.if, { "needs.admit.outputs.proceed": "false", "needs.resolve-issue.result": "success" }), false, "unadmitted"); assert.deepEqual(handoff.permissions, { actions: "read", contents: "read" }); assert.deepEqual(handoff.outputs, { "handed-off": "${{ steps.handoff.outputs.handed-off }}", "fallback-ready": "${{ steps.fallback.outputs.ready }}" }); - assert.equal(handoff.steps.length, 5); + // Tip, checkout, the handoff read, the credit-stop read and marker, the fallback presence, the budget. + assert.equal(handoff.steps.length, 8); assert.deepEqual(handoff.steps.slice(0, 2), workflow("pr-unblock.yml").jobs["fix-ci-handoff"].steps.slice(0, 2), "the same tip and sparse checkout"); assertFallbackPresence(handoff); assert.doesNotMatch(JSON.stringify(handoff), /needs\.resolve-issue\.outputs\./); @@ -9017,3 +9025,80 @@ test("pr-run-activity-read.yml: the reuse summary runs on the credential of the assert.equal(step("summary-token").with["subscription-token"], "${{ secrets.claude-code-oauth-token }}"); assert.equal(step("summary-key").with["api-key"], "${{ secrets.claude-api-key }}"); }); + +// The read runner's key-credit gate (#13586): broker-check, which runs no +// head code, reads the key's credit history for the first call's +// key-credit-job, and a key inside the window hands off before any model turn. +test("pr-run-activity-read.yml: broker-check skips a credit-stopped key on the first call alone", () => { + const { jobs, on } = workflow("pr-run-activity-read.yml"); + assert.equal(on.workflow_call.inputs["key-credit-job"].default, ""); + const credit = jobs["broker-check"].steps.find((s) => s.id === "credit"); + assert.match(credit.uses, /^melodic-software\/ci-workflows\/\.github\/actions\/check-key-credit@[0-9a-f]{40}$/); + assert.equal(credit.with["key-job"], "${{ inputs.key-credit-job }}"); + assert.equal(credit["continue-on-error"], true, "a failed read runs the key"); + const reads = (job, fallback, allowed = "true") => + atStep(credit.if, { "steps.broker.outcome": "success", "steps.broker.outputs.allowed": allowed, "inputs.key-credit-job": job, "inputs.fallback": fallback }); + assert.equal(reads("fix-ci", ""), true); + assert.equal(reads("", ""), false, "no key job, no gate"); + assert.equal(reads("fix-ci", "handoff"), false, "a later call has no key"); + assert.equal(reads("fix-ci", "", "false"), false, "a call the broker refused"); + assert.equal(jobs["broker-check"].outputs["skip-key"], "${{ steps.credit.outputs.skip-key }}"); + // The run job's key presence step reads the skip, so the call hands off as an empty key does. + const present = jobs.run.steps.find((s) => s.id === "key-present"); + assert.equal(present.env.SKIP_KEY, "${{ needs.broker-check.outputs.skip-key }}"); + assert.match(present.run, /if \[ "\$SKIP_KEY" = true \]; then\n[^\n]*\n\s+echo "present=false"/); + // The skipped key leg's lane record comes from broker-check, not the run job. + const record = jobs["broker-check"].steps.find((s) => s.name === "Report the skipped key leg"); + assert.equal(record.if, "steps.credit.outputs.skip-key == 'true'"); + assert.equal(record.with["known-failure-class"], "credit-stop"); + assert.equal(record.with.handoff, "true"); + // The fixed-name step the caller reads for the marker, right after the key leg's mode check. + const names = jobs.run.steps.map((s) => s.name); + assert.equal(names.indexOf("Mark a key credit stop"), names.indexOf("Check the API key leg's permission mode") + 1); + assert.equal(jobs.run.steps.find((s) => s.name === "Mark a key credit stop").if, "always() && steps.act-key.outputs.handoff-reason == 'credit-stop'"); +}); + +test("every read-runner first call names its key job and grants the gate's actions: read, and its handoff job uploads the marker from a jobs-API read", () => { + const FIRSTS = { + "pr-refine.yml": { "fix-docs": "fix-docs-handoff" }, + "pr-unblock.yml": { "resolve-conflicts": "resolve-conflicts-handoff", "address-feedback": "address-feedback-handoff", "review-dependency": "review-dependency-handoff", "fix-ci": "fix-ci-handoff", "fix-ci-rung-up": "fix-ci-rung-up-handoff" }, + "work-resolve-issue.yml": { "resolve-issue": "resolve-issue-handoff" }, + }; + for (const file of workflowFiles()) { + for (const [id, job] of Object.entries(workflow(file).jobs ?? {})) { + if (job?.uses === READ_RUNNER) assert.equal(job.permissions.actions, "read", `${file} ${id}`); + } + } + for (const [file, firsts] of Object.entries(FIRSTS)) { + const { jobs } = workflow(file); + for (const [call, handoffId] of Object.entries(firsts)) { + assert.equal(jobs[call].with["key-credit-job"], call, `${file} ${call}`); + const steps = jobs[handoffId].steps; + const read = steps.find((s) => s.id === "credit-stop"); + assert.equal(read.run, "bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop", handoffId); + assert.deepEqual(read.env, { GH_TOKEN: "${{ github.token }}", CALLS: call, HANDOFF_EVIDENCE_LAG: "notice" }, handoffId); + assert.equal(read["continue-on-error"], true, handoffId); + const write = steps.find((s) => s.id === "credit-marker"); + assert.equal(write.if, "steps.credit-stop.outputs.credit-stop == 'true'", handoffId); + const upload = steps.find((s) => s.name === "Upload the key's credit-stop marker"); + // check-key-credit gate.cjs markerName: key-credit-stop--. + assert.equal(upload.with.name, `key-credit-stop-${file}-${call}`, handoffId); + assert.equal(upload.with["retention-days"], 1); + assert.doesNotMatch(JSON.stringify(steps), new RegExp(`needs\\.${call}\\.outputs\\.`), `${handoffId} reads no ${call} output`); + } + } +}); + +// The credit-stop fact reads the run job's fixed-name step, which runs only on +// run-claude-cli's handoff-reason; a skipped run job, or one without that step, +// is no credit stop. +test("handoff-evidence.sh: credit-stop is the run job's Mark a key credit stop step", (t) => { + const read = workflow("pr-unblock.yml").jobs["fix-ci-handoff"].steps.find((s) => s.id === "credit-stop"); + const listing = (steps, conclusion = "success") => ({ total_count: 1, jobs: [{ name: "fix-ci / run", conclusion, steps }] }); + const stopped = readPosted(t, read, listing([{ name: "Mark a key credit stop", conclusion: "success" }])); + assert.equal(stopped.status, 0); + assert.deepEqual(stopped.outputs, { "credit-stop": "true" }); + assert.deepEqual(readPosted(t, read, listing([{ name: "Mark a key credit stop", conclusion: "skipped" }])).outputs, { "credit-stop": "false" }); + assert.deepEqual(readPosted(t, read, listing([{ name: "Run the skill on the API key", conclusion: "success" }])).outputs, { "credit-stop": "false" }); + assert.deepEqual(readPosted(t, read, listing([], "skipped")).outputs, { "credit-stop": "false" }); +}); diff --git a/.github/artifact-retention.json b/.github/artifact-retention.json index 0459d17b96b..d21eb87ab3f 100644 --- a/.github/artifact-retention.json +++ b/.github/artifact-retention.json @@ -419,7 +419,9 @@ "key-credit-stop-*": { "retention-days": 1, "readers": [ - ".github/workflows/pr-review-main.yml review-handoffs-credit, through ci-workflows check-key-credit gate.cjs, in later runs inside vars.KEY_CREDIT_WINDOW_HOURS" + ".github/workflows/pr-review-main.yml review-handoffs-credit, through ci-workflows check-key-credit gate.cjs, in later runs inside vars.KEY_CREDIT_WINDOW_HOURS", + ".github/workflows/pr-run-activity-read.yml broker-check's \"Read the key's credit history\" step, through ci-workflows check-key-credit gate.cjs, for the first call's key-credit-job: the markers pr-unblock.yml, pr-refine.yml and work-resolve-issue.yml upload from each first call's handoff job", + ".github/workflows/maintenance-audit-lane-runs.yml collect's \"Read the key's credit history\" step, for key job judge: the marker judge-handoffs uploads after a key leg's credit stop" ], "reason": "The gate counts a marker only inside its window, 1 hour unset, so 1 day covers it; a window over 24 hours ends at expiry. It holds only the run id the workflow writes, never model text, so it is not scanned.", "scan": "none", diff --git a/.github/scripts/lib/handoff-evidence.sh b/.github/scripts/lib/handoff-evidence.sh index c93e774b396..b8e1d7b8528 100755 --- a/.github/scripts/lib/handoff-evidence.sh +++ b/.github/scripts/lib/handoff-evidence.sh @@ -22,6 +22,10 @@ # stale the call's run job stopped at `Stop on stale workflow files` # (issue mode, before any model turn): read from the step, which # no run job can rename. A skipped or absent run job is false. +# credit-stop the call's key leg hit a credit stop: its run job's `Mark a +# key credit stop` step succeeded, a step that runs only on +# run-claude-cli's handoff-reason, set after the leg ends. A skipped +# or absent run job is false. # # GitHub's jobs listing can return a job's steps empty, or a step's conclusion # null, for minutes after the job ends. Job-level fields come at once, so they @@ -40,7 +44,7 @@ # Authorization header, as gh's do # (https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api#use-conditional-requests). # -# Usage: handoff-evidence.sh FACT... (allowed, posted, handed-off, stale) +# Usage: handoff-evidence.sh FACT... (allowed, posted, handed-off, stale, credit-stop) # Environment: GH_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_OUTPUT, CALLS. # Outputs: one FACT=true|false line per requested fact, and one log line # naming them with the read count. A fact still unsettled after the last read: @@ -64,11 +68,11 @@ set -euo pipefail [[ -z "${RUNNER_TEMP:-}" ]] || ! . "${BASH_SOURCE[0]%/*}/../../../.github/actions/log-api-budget/api-budget.bash" 2>/dev/null || api_budget_wrap_gh github-token [[ $# -gt 0 ]] || { - echo "::error::usage: handoff-evidence.sh FACT... (allowed, posted, handed-off, stale)" + echo "::error::usage: handoff-evidence.sh FACT... (allowed, posted, handed-off, stale, credit-stop)" exit 2 } for fact in "$@"; do - case $fact in allowed | posted | handed-off | stale) ;; *) + case $fact in allowed | posted | handed-off | stale | credit-stop) ;; *) echo "::error::unknown handoff fact: $fact" exit 2 ;; @@ -125,13 +129,17 @@ settle=' | if $r == null or $r.conclusion == "skipped" then "false" elif ($r.conclusion // "") == "" then "missing: \($r.name) has no conclusion" else by_step($r; "Stop on stale workflow files") end; + def credit_stop($c): job("\($c) / run") as $r + | if $r == null or $r.conclusion == "skipped" then "false" + elif ($r.conclusion // "") == "" then "missing: \($r.name) has no conclusion" + else by_step($r; "Mark a key credit stop") end; def any_of($vals): if any($vals[]; . == "true") then "true" elif any($vals[]; startswith("missing")) then ([$vals[] | select(startswith("missing"))] | join("; ")) else "false" end; ($calls | split(" ") | map(select(. != ""))) as $cs | $facts | split(" ")[] | . as $f - | "\($f)\t\(any_of([$cs[] | if $f == "allowed" then allowed(.) elif $f == "posted" then posted(.) elif $f == "stale" then stale(.) else handed_off(.) end]))"' + | "\($f)\t\(any_of([$cs[] | if $f == "allowed" then allowed(.) elif $f == "posted" then posted(.) elif $f == "stale" then stale(.) elif $f == "credit-stop" then credit_stop(.) else handed_off(.) end]))"' # Pages read per listing: a Link header that never ends cannot loop. max_pages=20 diff --git a/.github/scripts/maintenance-audit-lane-runs/handoff.mjs b/.github/scripts/maintenance-audit-lane-runs/handoff.mjs index b4754f38fdd..86ba30272c9 100644 --- a/.github/scripts/maintenance-audit-lane-runs/handoff.mjs +++ b/.github/scripts/maintenance-audit-lane-runs/handoff.mjs @@ -63,6 +63,13 @@ export function route(leg, dir) { return { include }; } +// Every collected unit, handed on with reason credit-stop: the credit gate +// skipped the key leg, so no marker exists. +export function routeSkipped(units) { + if (!Array.isArray(units)) throw new Error("the units are not a list"); + return { include: units.map((unit) => marker("key", unit, "credit-stop")) }; +} + if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { const env = process.env; try { @@ -88,10 +95,18 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { } case "route": { const matrix = route(env.LEG, env.HANDOFF_DIR); - appendFileSync(env.GITHUB_OUTPUT, `matrix=${JSON.stringify(matrix)}\ncount=${matrix.include.length}\n`); + // A key leg's credit stop writes the credit gate's marker. + const creditStop = env.LEG === "key" && matrix.include.some((entry) => entry.reason === "credit-stop"); + appendFileSync(env.GITHUB_OUTPUT, `matrix=${JSON.stringify(matrix)}\ncount=${matrix.include.length}\n${env.LEG === "key" ? `credit-stop=${creditStop}\n` : ""}`); console.log(`handoff: ${matrix.include.length} unit(s) for the next leg`); break; } + case "route-skipped": { + const matrix = routeSkipped(JSON.parse(env.UNITS ?? "null")); + appendFileSync(env.GITHUB_OUTPUT, `matrix=${JSON.stringify(matrix)}\ncount=${matrix.include.length}\n`); + console.log(`handoff: the credit gate skipped the key; ${matrix.include.length} unit(s) for the subscription leg`); + break; + } default: throw new Error(`unknown command '${process.argv[2]}'`); } diff --git a/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs b/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs index 32c85441976..1afc1f922c4 100644 --- a/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs +++ b/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs @@ -9,6 +9,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "node:test"; +import { routeSkipped } from "./handoff.mjs"; const text = readFileSync(new URL("../../workflows/maintenance-audit-lane-runs.yml", import.meta.url), "utf8"); const jobs = Object.fromEntries( @@ -69,7 +70,8 @@ test("only a schedule or a manual run starts the lane, with no default permissio test("the jobs run collect, the three legs with a route job between each, file and triage-sweep, in that order", () => { assert.deepEqual(Object.keys(jobs), ["collect", KEY, ROUTES[0], SUB, ROUTES[1], FB, "file", "triage-sweep"]); assert.match(jobs[KEY], /\n {4}needs: collect\n/); - assert.match(jobs[ROUTES[0]], /\n {4}needs: judge\n/); + // The key route job also needs collect: a skipped key leg routes every collected unit. + assert.match(jobs[ROUTES[0]], /\n {4}needs: \[collect, judge\]\n/); assert.match(jobs[SUB], /\n {4}needs: judge-handoffs\n/); assert.match(jobs[ROUTES[1]], /\n {4}needs: judge-subscription\n/); assert.match(jobs[FB], /\n {4}needs: judge-subscription-handoffs\n/); @@ -253,3 +255,20 @@ test("the daily schedule runs the audit, the hourly one only the triage sweep", // Two passes at once could dispatch the same issue twice. assert.match(jobs["triage-sweep"], /\n {4}concurrency:\n {6}group: [^\n]+\n {6}cancel-in-progress: false\n/); }); + +// The key-credit gate (#13586): collect reads the key's credit history, a +// credit-stopped key skips judge, and judge-handoffs routes every unit to +// judge-subscription as a credit-stop handoff; a key leg's credit-stop marker +// makes judge-handoffs upload the gate's marker. +test("a credit-stopped key skips the key leg and every unit goes to the subscription leg", () => { + assert.match(jobs.collect, /\n {8}uses: melodic-software\/ci-workflows\/\.github\/actions\/check-key-credit@[0-9a-f]{40} /); + assert.match(jobs.collect, /\n {10}key-job: judge\n/); + assert.match(jobs.collect, /\n {6}skip-key: \$\{\{ steps\.credit\.outputs\.skip-key \}\}\n/); + assert.match(jobs[KEY], /\n {4}if: .* && needs\.collect\.outputs\.skip-key != 'true'\n/); + assert.match(jobs[ROUTES[0]], /handoff\.mjs route-skipped\n/); + assert.match(jobs[ROUTES[0]], /\n {10}name: key-credit-stop-maintenance-audit-lane-runs\.yml-judge\n/); + assert.match(jobs[ROUTES[0]], /\n {8}if: steps\.route\.outputs\.credit-stop == 'true'\n/); + assert.deepEqual(routeSkipped(["a1", "c2"]), { include: [{ unit: "a1", reason: "credit-stop" }, { unit: "c2", reason: "credit-stop" }] }); + assert.throws(() => routeSkipped(null), /not a list/); + assert.throws(() => routeSkipped(["a9"]), /not a collector id/); +}); diff --git a/.github/workflows/maintenance-audit-lane-runs.yml b/.github/workflows/maintenance-audit-lane-runs.yml index 93f3804a0a6..e49d31f06db 100644 --- a/.github/workflows/maintenance-audit-lane-runs.yml +++ b/.github/workflows/maintenance-audit-lane-runs.yml @@ -68,6 +68,7 @@ jobs: pull-requests: read # list open pull requests outputs: units: ${{ steps.collect.outputs.units }} + skip-key: ${{ steps.credit.outputs.skip-key }} # Each guarded API call's rate-limit reading, for the job-end api-budget # notice (.github/actions/log-api-budget). env: @@ -111,6 +112,36 @@ jobs: WINDOW_HOURS: "24" run: node .base/.github/scripts/maintenance-audit-lane-runs/collect.mjs + # The key-credit gate (ci-workflows check-key-credit, gate.cjs: one + # artifact-list call for the marker judge-handoffs uploads after a key + # leg's credit stop). A credit stop inside vars.KEY_CREDIT_WINDOW_HOURS + # (1 hour unset) skips the key leg. Every failure runs the key. + # Probing is off. + - name: Read the key's credit history + id: credit + if: steps.collect.outputs.units != '' && steps.collect.outputs.units != '[]' + continue-on-error: true + timeout-minutes: 2 + uses: melodic-software/ci-workflows/.github/actions/check-key-credit@b8bdd7fa2319b2457cbbef32c5348c394b9bc3f6 # b8bdd7f 2026-11-04 + with: + key-job: judge + window-hours: ${{ vars.KEY_CREDIT_WINDOW_HOURS || '1' }} + probe-minutes: "0" + + # The skipped key leg's lane record: outcome skip, class credit-stop, + # handed off, so leg counts before and after the gate compare. + - name: Report the skipped key leg + if: steps.credit.outputs.skip-key == 'true' + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/report-lane-outcome@6b9c640bf209c0c7e76f1111c5d18c96707348a2 # 6b9c640 2026-11-02 + with: + outcome: skipped + lane: lane-sweep + lane-id: lane-sweep + credential: AUTOMATIONS_CLAUDE_API_KEY + known-failure-class: credit-stop + handoff: "true" + - name: Scan the upload for credentials id: scan-upload if: >- @@ -140,7 +171,9 @@ jobs: # The key leg, one per unit, on the automations role's API key. judge: needs: collect - if: needs.collect.outputs.units != '' && needs.collect.outputs.units != '[]' + # A key the credit gate skipped runs nothing here; judge-handoffs routes + # every unit to judge-subscription. + if: needs.collect.outputs.units != '' && needs.collect.outputs.units != '[]' && needs.collect.outputs.skip-key != 'true' runs-on: ubuntu-24.04 timeout-minutes: 30 cache-mode: read @@ -437,16 +470,18 @@ jobs: # The units the key leg handed on, as judge-subscription's matrix. It holds # no secret and reads only the markers' names and closed-set values. judge-handoffs: - needs: judge - if: ${{ !cancelled() && needs.judge.result != 'skipped' }} + needs: [collect, judge] + if: >- + ${{ !cancelled() && (needs.judge.result != 'skipped' + || needs.collect.outputs.skip-key == 'true' && needs.collect.outputs.units != '' && needs.collect.outputs.units != '[]') }} runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: actions: read # download the handoff markers contents: read # check out main outputs: - matrix: ${{ steps.route.outputs.matrix }} - count: ${{ steps.route.outputs.count }} + matrix: ${{ steps.route.outputs.matrix || steps.route-skipped.outputs.matrix }} + count: ${{ steps.route.outputs.count || steps.route-skipped.outputs.count }} steps: - name: Check out the run's main commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -469,7 +504,7 @@ jobs: - name: Download the handoff markers id: download - if: steps.kill.outputs.proceed == 'true' + if: steps.kill.outputs.proceed == 'true' && needs.collect.outputs.skip-key != 'true' uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: judge-handoff-* @@ -484,6 +519,39 @@ jobs: HANDOFF_DIR: ${{ runner.temp }}/lane-sweep-handoffs run: node .base/.github/scripts/maintenance-audit-lane-runs/handoff.mjs route + # The credit gate skipped the key: every collected unit goes to the + # subscription leg as a credit-stop handoff. + - name: Route every unit past the skipped key + id: route-skipped + if: steps.kill.outputs.proceed == 'true' && needs.collect.outputs.skip-key == 'true' + env: + UNITS: ${{ needs.collect.outputs.units }} + run: node .base/.github/scripts/maintenance-audit-lane-runs/handoff.mjs route-skipped + + # Read by the next runs' credit gate (collect's "Read the key's credit + # history"): uploaded only when a key leg's marker says credit-stop. It + # holds the run id, no model text. A forged marker only moves later + # runs to the subscription leg. + - name: Write the key's credit-stop marker + id: credit-marker + if: steps.route.outputs.credit-stop == 'true' + continue-on-error: true + timeout-minutes: 1 + run: | + mkdir -p "$RUNNER_TEMP/key-credit-stop" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop/run-id" + + - name: Upload the key's credit-stop marker + if: steps.credit-marker.outcome == 'success' + continue-on-error: true + timeout-minutes: 1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: key-credit-stop-maintenance-audit-lane-runs.yml-judge + path: ${{ runner.temp }}/key-credit-stop/run-id + if-no-files-found: error + retention-days: 1 + # The subscription leg, one per unit the key leg handed on, on a new VM: # the key leg's processes, files and environment do not reach it. Its steps # repeat the key leg's from a fresh checkout but for the credential, the diff --git a/.github/workflows/pr-refine.yml b/.github/workflows/pr-refine.yml index 92bfeca540d..f6957deab1b 100644 --- a/.github/workflows/pr-refine.yml +++ b/.github/workflows/pr-refine.yml @@ -211,6 +211,7 @@ jobs: && format('{0}', github.event.sender.id) != vars.AUTOMATION_LANES_APP_SENDER_ID && needs.fix-docs-head.outputs.held == 'false' permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -224,6 +225,7 @@ jobs: role: pr-unblock lane: pr-refine activity: fix-docs + key-credit-job: fix-docs pr-number: ${{ inputs.pr-number }} dispatcher-run-id: ${{ inputs.dispatcher-run-id }} trusted-base: default-tip @@ -293,6 +295,43 @@ jobs: CALLS: fix-docs run: bash .base/.github/scripts/lib/handoff-evidence.sh posted allowed handed-off + # Whether fix-docs's key leg hit a credit stop, read from this run's jobs + # (handoff-evidence.sh credit-stop), never from a fix-docs output, and + # only when it handed off. A read that cannot settle ends with a notice + # and uploads nothing. + - name: Read whether fix-docs's key hit a credit stop + id: credit-stop + if: steps.posted.outputs.posted == 'false' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + CALLS: fix-docs + HANDOFF_EVIDENCE_LAG: notice + run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop + + # Read by the next runs' key-credit gate (the read runner's broker-check, + # key-credit-job fix-docs): uploaded only after the key leg's credit stop. + # It holds the run id, no model text. + - name: Write the key's credit-stop marker + id: credit-marker + if: steps.credit-stop.outputs.credit-stop == 'true' + continue-on-error: true + timeout-minutes: 1 + run: | + mkdir -p "$RUNNER_TEMP/key-credit-stop" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop/run-id" + + - name: Upload the key's credit-stop marker + if: steps.credit-marker.outcome == 'success' + continue-on-error: true + timeout-minutes: 1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: key-credit-stop-pr-refine.yml-fix-docs + path: ${{ runner.temp }}/key-credit-stop/run-id + if-no-files-found: error + retention-days: 1 + # Whether the third call can run, never the token. fix-docs-subscription passes it # on as fallback-ready, so with the token empty its subscription leg # stays the last. A failed read leaves it empty. A lagging read sets it, and @@ -331,6 +370,7 @@ jobs: && needs.fix-docs-handoff.outputs.allowed == 'true' && needs.fix-docs-handoff.outputs.handed-off == 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -422,6 +462,7 @@ jobs: needs: [fix-docs-head, fix-docs, fix-docs-handoff, fix-docs-subscription, fix-docs-subscription-handoff] if: ${{ !cancelled() && needs.fix-docs-handoff.outputs.fallback-ready == 'true' && needs.fix-docs-subscription-handoff.outputs.handed-off == 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write diff --git a/.github/workflows/pr-run-activity-read.yml b/.github/workflows/pr-run-activity-read.yml index 1a8735d3056..105a96aadaf 100644 --- a/.github/workflows/pr-run-activity-read.yml +++ b/.github/workflows/pr-run-activity-read.yml @@ -90,6 +90,18 @@ on: required: false type: string default: "" + key-credit-job: + description: >- + The first call only: the key job's name for ci-workflows + check-key-credit, the activity's own (`fix-ci`, `resolve-issue`), so + the activities of one caller file keep separate credit-stop markers. + broker-check reads the key's credit history before any model turn, + and a credit stop inside vars.KEY_CREDIT_WINDOW_HOURS skips the key: + the call hands off at once and the caller's next call runs on the + subscription. Empty runs no gate. + required: false + type: string + default: "" fallback-ready: description: >- The second call only: `true` when the caller runs a third call on @@ -428,6 +440,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: + actions: read # the key-credit gate lists this repository's credit-stop markers contents: read id-token: write issues: write @@ -440,6 +453,7 @@ jobs: lease: ${{ steps.lease.outputs.lease }} lease-comment-id: ${{ steps.lease.outputs.lease-comment-id }} blocked: ${{ steps.recheck-blockers.outcome == 'success' && steps.recheck-blockers.outputs.proceed != 'true' }} + skip-key: ${{ steps.credit.outputs.skip-key }} env: API_BUDGET_TOKEN: github-token steps: @@ -497,6 +511,51 @@ jobs: activity: ${{ inputs.activity }} kill-switch-ok: true + # The key-credit gate (ci-workflows check-key-credit, gate.cjs: one + # artifact-list call for the marker the caller uploads after a key + # leg's credit stop), on the first call alone. Every failure runs the + # key: skip-key is then empty. Probing is off. A marker is forgeable by + # any same-repository workflow; a forged one only moves the call to the + # subscription leg: plan capacity, no credential. + - name: Read the key's credit history + id: credit + if: >- + steps.broker.outcome == 'success' && steps.broker.outputs.allowed == 'true' + && inputs.key-credit-job != '' && inputs.fallback == '' + continue-on-error: true + timeout-minutes: 2 + uses: melodic-software/ci-workflows/.github/actions/check-key-credit@b8bdd7fa2319b2457cbbef32c5348c394b9bc3f6 # b8bdd7f 2026-11-04 + with: + key-job: ${{ inputs.key-credit-job }} + window-hours: ${{ vars.KEY_CREDIT_WINDOW_HOURS || '1' }} + probe-minutes: "0" + + # The skipped key's secret name, from the role as run-claude-cli names + # it (`issue-lane` gives ISSUE_LANE_CLAUDE_API_KEY). + - name: Name the skipped key + id: skipped-key + if: steps.credit.outputs.skip-key == 'true' + env: + ROLE: ${{ inputs.role }} + run: | + [[ $ROLE =~ ^[a-z][a-z0-9-]*$ ]] || { echo "::error::the role is not lower-case kebab"; exit 1; } + name=${ROLE^^} + echo "name=${name//-/_}_CLAUDE_API_KEY" >>"$GITHUB_OUTPUT" + + # The skipped key leg's lane record: outcome skip, class credit-stop, + # handed off, so leg counts before and after the gate compare. + - name: Report the skipped key leg + if: steps.credit.outputs.skip-key == 'true' + continue-on-error: true + uses: melodic-software/ci-workflows/.github/actions/report-lane-outcome@6b9c640bf209c0c7e76f1111c5d18c96707348a2 # 6b9c640 2026-11-02 + with: + outcome: skipped + lane: ${{ inputs.lane }} + lane-id: ${{ inputs.lane }} + credential: ${{ steps.skipped-key.outputs.name }} + known-failure-class: credit-stop + handoff: "true" + # Issue mode, after the broker's allow and before any model turn: claim # the issue with a lease comment only, held by the lanes App identity, # through the tracker's claim (no `gh api user`, which this @@ -1596,8 +1655,12 @@ jobs: env: PRESENT: ${{ secrets.claude-api-key != '' }} SECRET_NAME: ${{ steps.launch.outputs.key-name }} + SKIP_KEY: ${{ needs.broker-check.outputs.skip-key }} run: | - if [ "$PRESENT" = true ]; then + if [ "$SKIP_KEY" = true ]; then + echo "::notice::$SECRET_NAME's last credit stop is inside the key-credit window; the caller's next call runs the activity on the subscription token" + echo "present=false" >>"$GITHUB_OUTPUT" + elif [ "$PRESENT" = true ]; then echo "present=true" >>"$GITHUB_OUTPUT" else echo "::warning::$SECRET_NAME is empty; the caller's fallback call runs the activity on the subscription token" @@ -1703,6 +1766,14 @@ jobs: MODE: ${{ steps.config.outputs.permission-mode }} run: bash "$RUNNER_TEMP/read-patch/check-permission-mode.sh" + # A fixed-name step the caller's handoff job reads through the jobs API + # (handoff-evidence.sh credit-stop) to upload the key's credit-stop + # marker: it runs only on run-claude-cli's handoff-reason, which the + # launcher sets after the leg ends, and writes nothing. + - name: Mark a key credit stop + if: always() && steps.act-key.outputs.handoff-reason == 'credit-stop' + run: echo "::notice::the key leg hit a credit stop; the caller uploads its credit-stop marker" + - name: Run the skill on the OAuth token id: act-token if: steps.token-present.outputs.present == 'true' diff --git a/.github/workflows/pr-unblock.yml b/.github/workflows/pr-unblock.yml index b80ac6c15ce..ac03bc32244 100644 --- a/.github/workflows/pr-unblock.yml +++ b/.github/workflows/pr-unblock.yml @@ -500,6 +500,7 @@ jobs: needs: resolve-conflicts-select if: ${{ !cancelled() && needs.resolve-conflicts-select.outputs.proceed == 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -514,6 +515,7 @@ jobs: role: pr-unblock lane: pr-unblock activity: resolve-conflicts + key-credit-job: resolve-conflicts pr-number: ${{ inputs.pr-number }} dispatcher-run-id: ${{ inputs.dispatcher-run-id }} trusted-base: default-tip @@ -574,6 +576,43 @@ jobs: CALLS: resolve-conflicts run: bash .base/.github/scripts/lib/handoff-evidence.sh posted + # Whether resolve-conflicts's key leg hit a credit stop, read from this run's jobs + # (handoff-evidence.sh credit-stop), never from a resolve-conflicts output, and + # only when it handed off. A read that cannot settle ends with a notice + # and uploads nothing. + - name: Read whether resolve-conflicts's key hit a credit stop + id: credit-stop + if: steps.posted.outputs.posted == 'false' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + CALLS: resolve-conflicts + HANDOFF_EVIDENCE_LAG: notice + run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop + + # Read by the next runs' key-credit gate (the read runner's broker-check, + # key-credit-job resolve-conflicts): uploaded only after the key leg's credit stop. + # It holds the run id, no model text. + - name: Write the key's credit-stop marker + id: credit-marker + if: steps.credit-stop.outputs.credit-stop == 'true' + continue-on-error: true + timeout-minutes: 1 + run: | + mkdir -p "$RUNNER_TEMP/key-credit-stop" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop/run-id" + + - name: Upload the key's credit-stop marker + if: steps.credit-marker.outcome == 'success' + continue-on-error: true + timeout-minutes: 1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: key-credit-stop-pr-unblock.yml-resolve-conflicts + path: ${{ runner.temp }}/key-credit-stop/run-id + if-no-files-found: error + retention-days: 1 + # Whether the third call can run, never the token. resolve-conflicts-subscription passes it # on as fallback-ready, so with the token empty its subscription leg # stays the last; a failed read leaves it empty, which hands nothing on. @@ -600,6 +639,7 @@ jobs: ${{ !cancelled() && needs.resolve-conflicts-select.outputs.proceed == 'true' && needs.resolve-conflicts-handoff.outputs.posted != 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -690,6 +730,7 @@ jobs: needs: [resolve-conflicts-select, resolve-conflicts, resolve-conflicts-handoff, resolve-conflicts-subscription, resolve-conflicts-subscription-handoff] if: ${{ !cancelled() && needs.resolve-conflicts-handoff.outputs.fallback-ready == 'true' && needs.resolve-conflicts-subscription-handoff.outputs.handed-off == 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -1565,6 +1606,7 @@ jobs: needs: address-feedback-select if: ${{ !cancelled() && needs.address-feedback-select.outputs.proceed == 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -1578,6 +1620,7 @@ jobs: role: pr-unblock lane: pr-unblock activity: address-feedback + key-credit-job: address-feedback pr-number: ${{ inputs.pr-number }} dispatcher-run-id: ${{ inputs.dispatcher-run-id }} trusted-base: default-tip @@ -1639,6 +1682,43 @@ jobs: CALLS: address-feedback run: bash .base/.github/scripts/lib/handoff-evidence.sh posted + # Whether address-feedback's key leg hit a credit stop, read from this run's jobs + # (handoff-evidence.sh credit-stop), never from a address-feedback output, and + # only when it handed off. A read that cannot settle ends with a notice + # and uploads nothing. + - name: Read whether address-feedback's key hit a credit stop + id: credit-stop + if: steps.posted.outputs.posted == 'false' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + CALLS: address-feedback + HANDOFF_EVIDENCE_LAG: notice + run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop + + # Read by the next runs' key-credit gate (the read runner's broker-check, + # key-credit-job address-feedback): uploaded only after the key leg's credit stop. + # It holds the run id, no model text. + - name: Write the key's credit-stop marker + id: credit-marker + if: steps.credit-stop.outputs.credit-stop == 'true' + continue-on-error: true + timeout-minutes: 1 + run: | + mkdir -p "$RUNNER_TEMP/key-credit-stop" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop/run-id" + + - name: Upload the key's credit-stop marker + if: steps.credit-marker.outcome == 'success' + continue-on-error: true + timeout-minutes: 1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: key-credit-stop-pr-unblock.yml-address-feedback + path: ${{ runner.temp }}/key-credit-stop/run-id + if-no-files-found: error + retention-days: 1 + # Whether the third call can run, never the token. address-feedback-subscription passes it # on as fallback-ready, so with the token empty its subscription leg # stays the last; a failed read leaves it empty, which hands nothing on. @@ -1665,6 +1745,7 @@ jobs: ${{ !cancelled() && needs.address-feedback-select.outputs.proceed == 'true' && needs.address-feedback-handoff.outputs.posted != 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -1756,6 +1837,7 @@ jobs: needs: [address-feedback-select, address-feedback, address-feedback-handoff, address-feedback-subscription, address-feedback-subscription-handoff] if: ${{ !cancelled() && needs.address-feedback-handoff.outputs.fallback-ready == 'true' && needs.address-feedback-subscription-handoff.outputs.handed-off == 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -3214,6 +3296,7 @@ jobs: needs: review-dependency-select if: ${{ !cancelled() && needs.review-dependency-select.outputs.proceed == 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -3227,6 +3310,7 @@ jobs: role: pr-unblock lane: pr-unblock activity: review-dependency + key-credit-job: review-dependency pr-number: ${{ inputs.pr-number }} dispatcher-run-id: ${{ inputs.dispatcher-run-id }} trusted-base: default-tip @@ -3287,6 +3371,43 @@ jobs: CALLS: review-dependency run: bash .base/.github/scripts/lib/handoff-evidence.sh posted + # Whether review-dependency's key leg hit a credit stop, read from this run's jobs + # (handoff-evidence.sh credit-stop), never from a review-dependency output, and + # only when it handed off. A read that cannot settle ends with a notice + # and uploads nothing. + - name: Read whether review-dependency's key hit a credit stop + id: credit-stop + if: steps.posted.outputs.posted == 'false' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + CALLS: review-dependency + HANDOFF_EVIDENCE_LAG: notice + run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop + + # Read by the next runs' key-credit gate (the read runner's broker-check, + # key-credit-job review-dependency): uploaded only after the key leg's credit stop. + # It holds the run id, no model text. + - name: Write the key's credit-stop marker + id: credit-marker + if: steps.credit-stop.outputs.credit-stop == 'true' + continue-on-error: true + timeout-minutes: 1 + run: | + mkdir -p "$RUNNER_TEMP/key-credit-stop" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop/run-id" + + - name: Upload the key's credit-stop marker + if: steps.credit-marker.outcome == 'success' + continue-on-error: true + timeout-minutes: 1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: key-credit-stop-pr-unblock.yml-review-dependency + path: ${{ runner.temp }}/key-credit-stop/run-id + if-no-files-found: error + retention-days: 1 + # Whether the third call can run, never the token. review-dependency-subscription passes it # on as fallback-ready, so with the token empty its subscription leg # stays the last; a failed read leaves it empty, which hands nothing on. @@ -3313,6 +3434,7 @@ jobs: ${{ !cancelled() && needs.review-dependency-select.outputs.proceed == 'true' && needs.review-dependency-handoff.outputs.posted != 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -3403,6 +3525,7 @@ jobs: needs: [review-dependency-select, review-dependency, review-dependency-handoff, review-dependency-subscription, review-dependency-subscription-handoff] if: ${{ !cancelled() && needs.review-dependency-handoff.outputs.fallback-ready == 'true' && needs.review-dependency-subscription-handoff.outputs.handed-off == 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -3761,6 +3884,7 @@ jobs: needs: fix-ci-context if: ${{ !cancelled() && needs.fix-ci-context.outputs.proceed == 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -3774,6 +3898,7 @@ jobs: role: pr-unblock lane: pr-unblock activity: fix-ci + key-credit-job: fix-ci pr-number: ${{ inputs.pr-number }} dispatcher-run-id: ${{ inputs.dispatcher-run-id }} trusted-base: default-tip @@ -3837,6 +3962,43 @@ jobs: CALLS: fix-ci run: bash .base/.github/scripts/lib/handoff-evidence.sh posted + # Whether fix-ci's key leg hit a credit stop, read from this run's jobs + # (handoff-evidence.sh credit-stop), never from a fix-ci output, and + # only when it handed off. A read that cannot settle ends with a notice + # and uploads nothing. + - name: Read whether fix-ci's key hit a credit stop + id: credit-stop + if: steps.posted.outputs.posted == 'false' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + CALLS: fix-ci + HANDOFF_EVIDENCE_LAG: notice + run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop + + # Read by the next runs' key-credit gate (the read runner's broker-check, + # key-credit-job fix-ci): uploaded only after the key leg's credit stop. + # It holds the run id, no model text. + - name: Write the key's credit-stop marker + id: credit-marker + if: steps.credit-stop.outputs.credit-stop == 'true' + continue-on-error: true + timeout-minutes: 1 + run: | + mkdir -p "$RUNNER_TEMP/key-credit-stop" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop/run-id" + + - name: Upload the key's credit-stop marker + if: steps.credit-marker.outcome == 'success' + continue-on-error: true + timeout-minutes: 1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: key-credit-stop-pr-unblock.yml-fix-ci + path: ${{ runner.temp }}/key-credit-stop/run-id + if-no-files-found: error + retention-days: 1 + # Whether the third call can run, never the token. fix-ci-subscription passes it # on as fallback-ready, so with the token empty its subscription leg # stays the last; a failed read leaves it empty, which hands nothing on. @@ -3866,6 +4028,7 @@ jobs: ${{ !cancelled() && needs.fix-ci-context.outputs.proceed == 'true' && needs.fix-ci-handoff.outputs.posted != 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -3957,6 +4120,7 @@ jobs: needs: [fix-ci-context, fix-ci, fix-ci-handoff, fix-ci-subscription, fix-ci-subscription-handoff] if: ${{ !cancelled() && needs.fix-ci-handoff.outputs.fallback-ready == 'true' && needs.fix-ci-subscription-handoff.outputs.handed-off == 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -4051,6 +4215,7 @@ jobs: ${{ !cancelled() && needs.fix-ci-context.outputs.proceed == 'true' && needs.apply-patch.outputs.rung-up == 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -4064,6 +4229,7 @@ jobs: role: pr-unblock lane: pr-unblock activity: fix-ci + key-credit-job: fix-ci-rung-up pr-number: ${{ inputs.pr-number }} dispatcher-run-id: ${{ inputs.dispatcher-run-id }} trusted-base: default-tip @@ -4123,6 +4289,43 @@ jobs: CALLS: fix-ci-rung-up run: bash .base/.github/scripts/lib/handoff-evidence.sh posted + # Whether fix-ci-rung-up's key leg hit a credit stop, read from this run's jobs + # (handoff-evidence.sh credit-stop), never from a fix-ci-rung-up output, and + # only when it handed off. A read that cannot settle ends with a notice + # and uploads nothing. + - name: Read whether fix-ci-rung-up's key hit a credit stop + id: credit-stop + if: steps.posted.outputs.posted == 'false' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + CALLS: fix-ci-rung-up + HANDOFF_EVIDENCE_LAG: notice + run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop + + # Read by the next runs' key-credit gate (the read runner's broker-check, + # key-credit-job fix-ci-rung-up): uploaded only after the key leg's credit stop. + # It holds the run id, no model text. + - name: Write the key's credit-stop marker + id: credit-marker + if: steps.credit-stop.outputs.credit-stop == 'true' + continue-on-error: true + timeout-minutes: 1 + run: | + mkdir -p "$RUNNER_TEMP/key-credit-stop" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop/run-id" + + - name: Upload the key's credit-stop marker + if: steps.credit-marker.outcome == 'success' + continue-on-error: true + timeout-minutes: 1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: key-credit-stop-pr-unblock.yml-fix-ci-rung-up + path: ${{ runner.temp }}/key-credit-stop/run-id + if-no-files-found: error + retention-days: 1 + # Whether the third call can run, never the token. fix-ci-rung-up-subscription passes it # on as fallback-ready, so with the token empty its subscription leg # stays the last; a failed read leaves it empty, which hands nothing on. @@ -4147,6 +4350,7 @@ jobs: && needs.fix-ci-rung-up.result != 'skipped' && needs.fix-ci-rung-up-handoff.outputs.posted != 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -4238,6 +4442,7 @@ jobs: needs: [fix-ci-context, fix-ci-rung-up, fix-ci-rung-up-handoff, fix-ci-rung-up-subscription, fix-ci-rung-up-subscription-handoff] if: ${{ !cancelled() && needs.fix-ci-rung-up-handoff.outputs.fallback-ready == 'true' && needs.fix-ci-rung-up-subscription-handoff.outputs.handed-off == 'true' }} permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write diff --git a/.github/workflows/work-resolve-issue.yml b/.github/workflows/work-resolve-issue.yml index b9b7c9e4497..a766aff631c 100644 --- a/.github/workflows/work-resolve-issue.yml +++ b/.github/workflows/work-resolve-issue.yml @@ -295,6 +295,7 @@ jobs: # which runs only default-branch-tip code. The first call holds the API # key alone. permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -305,6 +306,7 @@ jobs: lane: work-resolve-issue role: issue-lane activity: resolve-issue + key-credit-job: resolve-issue issue-number: ${{ format('{0}', github.event.issue.number) }} trusted-base: default-tip app-label: agent-ready @@ -373,6 +375,43 @@ jobs: CALLS: resolve-issue run: bash .base/.github/scripts/lib/handoff-evidence.sh handed-off + # Whether resolve-issue's key leg hit a credit stop, read from this run's jobs + # (handoff-evidence.sh credit-stop), never from a resolve-issue output, and + # only when it handed off. A read that cannot settle ends with a notice + # and uploads nothing. + - name: Read whether resolve-issue's key hit a credit stop + id: credit-stop + if: steps.handoff.outputs.handed-off == 'true' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + CALLS: resolve-issue + HANDOFF_EVIDENCE_LAG: notice + run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop + + # Read by the next runs' key-credit gate (the read runner's broker-check, + # key-credit-job resolve-issue): uploaded only after the key leg's credit stop. + # It holds the run id, no model text. + - name: Write the key's credit-stop marker + id: credit-marker + if: steps.credit-stop.outputs.credit-stop == 'true' + continue-on-error: true + timeout-minutes: 1 + run: | + mkdir -p "$RUNNER_TEMP/key-credit-stop" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop/run-id" + + - name: Upload the key's credit-stop marker + if: steps.credit-marker.outcome == 'success' + continue-on-error: true + timeout-minutes: 1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: key-credit-stop-work-resolve-issue.yml-resolve-issue + path: ${{ runner.temp }}/key-credit-stop/run-id + if-no-files-found: error + retention-days: 1 + # Whether the third call can run, never the token. resolve-issue-subscription passes it # on as fallback-ready, so with the token empty its subscription leg # stays the last; a failed read leaves it empty, which hands nothing on. @@ -402,6 +441,7 @@ jobs: group: work-resolve-issue-slot-${{ needs.pick-slot.outputs.slot }} queue: max permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write @@ -494,6 +534,7 @@ jobs: group: work-resolve-issue-slot-${{ needs.pick-slot.outputs.slot }} queue: max permissions: + actions: read # the read runner's key-credit gate lists the credit-stop markers contents: read pull-requests: read checks: write From 55a86d968022de1f2d0a8577901764ad5b3cc8f3 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:35:06 -0500 Subject: [PATCH 4/5] ci(lanes): grant the intake triage caller actions: read for its key-credit gate The ci-workflows intake-triage.yml reusable is getting the same check-key-credit gate the review reusable runs, which reads the repository's credit-stop markers with actions: read. A called workflow can hold only what its caller grants, so this caller grants it first; the reusable's gate lands after this merges. Refs: #13586 Co-Authored-By: Claude Opus 5.5 --- .github/actions/resolve-config/callers.test.mjs | 3 ++- .github/workflows/intake-triage.yml | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index bf565491c1a..f5ab2bd227f 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -396,7 +396,8 @@ test("intake-triage.yml: each job keeps its own least-privilege permissions", () "born-triaged": { contents: "read", issues: "read" }, "kill-switch": { contents: "read" }, "intake-label-needs-triage": { issues: "write" }, - "intake-triage": { contents: "read", issues: "write" }, + // actions: read for the reusable's key-credit gate (#13586). + "intake-triage": { actions: "read", contents: "read", issues: "write" }, "apply-verdict": { contents: "read", "id-token": "write" }, "refine-select": { contents: "read", issues: "read", "pull-requests": "read" }, "refine-key-credit": { actions: "read" }, diff --git a/.github/workflows/intake-triage.yml b/.github/workflows/intake-triage.yml index b84dd72a442..5c689064c47 100644 --- a/.github/workflows/intake-triage.yml +++ b/.github/workflows/intake-triage.yml @@ -260,6 +260,7 @@ jobs: && (github.event_name == 'workflow_dispatch' || needs.born-triaged.outputs.skip != 'true') && needs.born-triaged.outputs.claude-args != '' }} permissions: + actions: read # the reusable's key-credit gate lists its credit-stop markers (ci-workflows intake-triage.yml) contents: read # check out the default branch so the lane can read AGENTS.md issues: write # apply labels and post the triage comment concurrency: From bfdae19517160eba0da53d331ff0c8b42cd12ec5 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:00:35 -0500 Subject: [PATCH 5/5] ci(lanes): read the credit-stop marker off the handoff path The handoff jobs' credit-stop read waits for the jobs listing's step lists, which can lag for minutes, and held up every subscription call behind it. Each caller file now reads it in one key-credit-markers job that no job needs, one read and marker per first call. - .github/artifact-retention.json: an exact row per static marker name (a `*` family never takes a static name), scan none. - write-runner-never-rules.test.sh: the reuse summary's third leg. - handoff-evidence.test.mjs: the key-credit-markers jobs also name HANDOFF_EVIDENCE_LAG. - intake-triage.yml: the caller's actions: read waits for the runner-policy contract to approve it in melodic-software/standards. Refs: #13586 Co-Authored-By: Claude Opus 5.5 --- .../actions/resolve-config/callers.test.mjs | 45 ++- .github/artifact-retention.json | 72 +++- .github/scripts/lib/handoff-evidence.test.mjs | 14 +- .github/workflows/intake-triage.yml | 1 - .github/workflows/pr-refine.yml | 94 +++-- .github/workflows/pr-unblock.yml | 382 +++++++++--------- .github/workflows/work-resolve-issue.yml | 92 +++-- scripts/write-runner-never-rules.test.sh | 8 +- 8 files changed, 436 insertions(+), 272 deletions(-) diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index f5ab2bd227f..98f4ba9cdbf 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -396,8 +396,7 @@ test("intake-triage.yml: each job keeps its own least-privilege permissions", () "born-triaged": { contents: "read", issues: "read" }, "kill-switch": { contents: "read" }, "intake-label-needs-triage": { issues: "write" }, - // actions: read for the reusable's key-credit gate (#13586). - "intake-triage": { actions: "read", contents: "read", issues: "write" }, + "intake-triage": { contents: "read", issues: "write" }, "apply-verdict": { contents: "read", "id-token": "write" }, "refine-select": { contents: "read", issues: "read", "pull-requests": "read" }, "refine-key-credit": { actions: "read" }, @@ -7683,8 +7682,7 @@ test("work-resolve-issue.yml: resolve-issue-subscription starts on resolve-issue assert.equal(evaluate(handoff.if, { "needs.admit.outputs.proceed": "false", "needs.resolve-issue.result": "success" }), false, "unadmitted"); assert.deepEqual(handoff.permissions, { actions: "read", contents: "read" }); assert.deepEqual(handoff.outputs, { "handed-off": "${{ steps.handoff.outputs.handed-off }}", "fallback-ready": "${{ steps.fallback.outputs.ready }}" }); - // Tip, checkout, the handoff read, the credit-stop read and marker, the fallback presence, the budget. - assert.equal(handoff.steps.length, 8); + assert.equal(handoff.steps.length, 5); assert.deepEqual(handoff.steps.slice(0, 2), workflow("pr-unblock.yml").jobs["fix-ci-handoff"].steps.slice(0, 2), "the same tip and sparse checkout"); assertFallbackPresence(handoff); assert.doesNotMatch(JSON.stringify(handoff), /needs\.resolve-issue\.outputs\./); @@ -9080,33 +9078,38 @@ test("pr-run-activity-read.yml: broker-check skips a credit-stopped key on the f assert.equal(jobs.run.steps.find((s) => s.name === "Mark a key credit stop").if, "always() && steps.act-key.outputs.handoff-reason == 'credit-stop'"); }); -test("every read-runner first call names its key job and grants the gate's actions: read, and its handoff job uploads the marker from a jobs-API read", () => { +test("every read-runner first call names its key job and grants the gate's actions: read, and a job off the handoff path uploads its marker from a jobs-API read", () => { const FIRSTS = { - "pr-refine.yml": { "fix-docs": "fix-docs-handoff" }, - "pr-unblock.yml": { "resolve-conflicts": "resolve-conflicts-handoff", "address-feedback": "address-feedback-handoff", "review-dependency": "review-dependency-handoff", "fix-ci": "fix-ci-handoff", "fix-ci-rung-up": "fix-ci-rung-up-handoff" }, - "work-resolve-issue.yml": { "resolve-issue": "resolve-issue-handoff" }, + "pr-refine.yml": ["fix-docs"], + "pr-unblock.yml": ["resolve-conflicts", "address-feedback", "review-dependency", "fix-ci", "fix-ci-rung-up"], + "work-resolve-issue.yml": ["resolve-issue"], }; for (const file of workflowFiles()) { for (const [id, job] of Object.entries(workflow(file).jobs ?? {})) { if (job?.uses === READ_RUNNER) assert.equal(job.permissions.actions, "read", `${file} ${id}`); } } - for (const [file, firsts] of Object.entries(FIRSTS)) { + for (const [file, calls] of Object.entries(FIRSTS)) { const { jobs } = workflow(file); - for (const [call, handoffId] of Object.entries(firsts)) { + const markers = jobs["key-credit-markers"]; + assert.deepEqual([markers.needs].flat(), calls, `${file}: the markers job needs every first call`); + assert.deepEqual(markers.permissions, { actions: "read", contents: "read" }); + // No job waits on it, so its read delays no handoff. + for (const [id, job] of Object.entries(jobs)) assert.ok(![job?.needs ?? []].flat().includes("key-credit-markers"), `${file} ${id} waits on the markers job`); + assert.doesNotMatch(JSON.stringify(markers), /needs\.[\w-]+\.outputs\./, `${file}: the markers job reads no call output`); + for (const call of calls) { assert.equal(jobs[call].with["key-credit-job"], call, `${file} ${call}`); - const steps = jobs[handoffId].steps; - const read = steps.find((s) => s.id === "credit-stop"); - assert.equal(read.run, "bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop", handoffId); - assert.deepEqual(read.env, { GH_TOKEN: "${{ github.token }}", CALLS: call, HANDOFF_EVIDENCE_LAG: "notice" }, handoffId); - assert.equal(read["continue-on-error"], true, handoffId); - const write = steps.find((s) => s.id === "credit-marker"); - assert.equal(write.if, "steps.credit-stop.outputs.credit-stop == 'true'", handoffId); - const upload = steps.find((s) => s.name === "Upload the key's credit-stop marker"); + const read = markers.steps.find((s) => s.id === `credit-stop-${call}`); + assert.equal(read.if, `needs.${call}.result != 'skipped'`); + assert.equal(read.run, "bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop", call); + assert.deepEqual(read.env, { GH_TOKEN: "${{ github.token }}", CALLS: call, HANDOFF_EVIDENCE_LAG: "notice" }, call); + assert.equal(read["continue-on-error"], true, call); + assert.equal(markers.steps.find((s) => s.id === `credit-marker-${call}`).if, `steps.credit-stop-${call}.outputs.credit-stop == 'true'`); + const upload = markers.steps.find((s) => s.name === `Upload ${call}'s credit-stop marker`); // check-key-credit gate.cjs markerName: key-credit-stop--. - assert.equal(upload.with.name, `key-credit-stop-${file}-${call}`, handoffId); + assert.equal(upload.with.name, `key-credit-stop-${file}-${call}`, call); assert.equal(upload.with["retention-days"], 1); - assert.doesNotMatch(JSON.stringify(steps), new RegExp(`needs\\.${call}\\.outputs\\.`), `${handoffId} reads no ${call} output`); + assert.doesNotMatch(JSON.stringify(jobs[`${call}-handoff`]), /credit-stop/, `${call}-handoff stays off the marker path`); } } }); @@ -9115,7 +9118,7 @@ test("every read-runner first call names its key job and grants the gate's actio // run-claude-cli's handoff-reason; a skipped run job, or one without that step, // is no credit stop. test("handoff-evidence.sh: credit-stop is the run job's Mark a key credit stop step", (t) => { - const read = workflow("pr-unblock.yml").jobs["fix-ci-handoff"].steps.find((s) => s.id === "credit-stop"); + const read = workflow("pr-unblock.yml").jobs["key-credit-markers"].steps.find((s) => s.id === "credit-stop-fix-ci"); const listing = (steps, conclusion = "success") => ({ total_count: 1, jobs: [{ name: "fix-ci / run", conclusion, steps }] }); const stopped = readPosted(t, read, listing([{ name: "Mark a key credit stop", conclusion: "success" }])); assert.equal(stopped.status, 0); diff --git a/.github/artifact-retention.json b/.github/artifact-retention.json index d21eb87ab3f..9bea06e0a43 100644 --- a/.github/artifact-retention.json +++ b/.github/artifact-retention.json @@ -10,7 +10,9 @@ "reason": "Each index artifact is how the gate finds its FAIL, so it keeps the FAIL's own retention (write-verdict.mjs retentionDays, max for a FAIL): an index that expired first would hide a FAIL that still counts. It holds only the FAIL's artifact name, built from numbers and a SHA, and no model text, so no leak scan runs over it.", "scan": "none", "computed-by": ".github/scripts/pr-verify/write-verdict.mjs", - "uploads": [".github/actions/upload-fail-index/action.yml#runs"] + "uploads": [ + ".github/actions/upload-fail-index/action.yml#runs" + ] }, "pr-verify-*": { "retention-days": "max", @@ -416,12 +418,74 @@ ".github/workflows/maintenance-reconcile-lost-runs.yml#reconcile" ] }, + "key-credit-stop-pr-refine.yml-fix-docs": { + "retention-days": 1, + "readers": [ + ".github/workflows/pr-run-activity-read.yml broker-check's \"Read the key's credit history\" step runs ci-workflows check-key-credit for pr-refine.yml's fix-docs call (key-credit-job fix-docs), which reads the newest marker of this exact name inside KEY_CREDIT_WINDOW_HOURS (1 hour unset, 24 at most for this retention)" + ], + "reason": "The marker only says the key hit a credit stop; the gate counts it for its window, an hour by default, so a day covers any window up to 24 hours and an expired marker runs the key, as before the gate. It holds only the run id the workflow writes, never model text, so it is not scanned.", + "scan": "none" + }, + "key-credit-stop-pr-unblock.yml-resolve-conflicts": { + "retention-days": 1, + "readers": [ + ".github/workflows/pr-run-activity-read.yml broker-check's \"Read the key's credit history\" step runs ci-workflows check-key-credit for pr-unblock.yml's resolve-conflicts call (key-credit-job resolve-conflicts), which reads the newest marker of this exact name inside KEY_CREDIT_WINDOW_HOURS (1 hour unset, 24 at most for this retention)" + ], + "reason": "The marker only says the key hit a credit stop; the gate counts it for its window, an hour by default, so a day covers any window up to 24 hours and an expired marker runs the key, as before the gate. It holds only the run id the workflow writes, never model text, so it is not scanned.", + "scan": "none" + }, + "key-credit-stop-pr-unblock.yml-address-feedback": { + "retention-days": 1, + "readers": [ + ".github/workflows/pr-run-activity-read.yml broker-check's \"Read the key's credit history\" step runs ci-workflows check-key-credit for pr-unblock.yml's address-feedback call (key-credit-job address-feedback), which reads the newest marker of this exact name inside KEY_CREDIT_WINDOW_HOURS (1 hour unset, 24 at most for this retention)" + ], + "reason": "The marker only says the key hit a credit stop; the gate counts it for its window, an hour by default, so a day covers any window up to 24 hours and an expired marker runs the key, as before the gate. It holds only the run id the workflow writes, never model text, so it is not scanned.", + "scan": "none" + }, + "key-credit-stop-pr-unblock.yml-review-dependency": { + "retention-days": 1, + "readers": [ + ".github/workflows/pr-run-activity-read.yml broker-check's \"Read the key's credit history\" step runs ci-workflows check-key-credit for pr-unblock.yml's review-dependency call (key-credit-job review-dependency), which reads the newest marker of this exact name inside KEY_CREDIT_WINDOW_HOURS (1 hour unset, 24 at most for this retention)" + ], + "reason": "The marker only says the key hit a credit stop; the gate counts it for its window, an hour by default, so a day covers any window up to 24 hours and an expired marker runs the key, as before the gate. It holds only the run id the workflow writes, never model text, so it is not scanned.", + "scan": "none" + }, + "key-credit-stop-pr-unblock.yml-fix-ci": { + "retention-days": 1, + "readers": [ + ".github/workflows/pr-run-activity-read.yml broker-check's \"Read the key's credit history\" step runs ci-workflows check-key-credit for pr-unblock.yml's fix-ci call (key-credit-job fix-ci), which reads the newest marker of this exact name inside KEY_CREDIT_WINDOW_HOURS (1 hour unset, 24 at most for this retention)" + ], + "reason": "The marker only says the key hit a credit stop; the gate counts it for its window, an hour by default, so a day covers any window up to 24 hours and an expired marker runs the key, as before the gate. It holds only the run id the workflow writes, never model text, so it is not scanned.", + "scan": "none" + }, + "key-credit-stop-pr-unblock.yml-fix-ci-rung-up": { + "retention-days": 1, + "readers": [ + ".github/workflows/pr-run-activity-read.yml broker-check's \"Read the key's credit history\" step runs ci-workflows check-key-credit for pr-unblock.yml's fix-ci-rung-up call (key-credit-job fix-ci-rung-up), which reads the newest marker of this exact name inside KEY_CREDIT_WINDOW_HOURS (1 hour unset, 24 at most for this retention)" + ], + "reason": "The marker only says the key hit a credit stop; the gate counts it for its window, an hour by default, so a day covers any window up to 24 hours and an expired marker runs the key, as before the gate. It holds only the run id the workflow writes, never model text, so it is not scanned.", + "scan": "none" + }, + "key-credit-stop-work-resolve-issue.yml-resolve-issue": { + "retention-days": 1, + "readers": [ + ".github/workflows/pr-run-activity-read.yml broker-check's \"Read the key's credit history\" step runs ci-workflows check-key-credit for work-resolve-issue.yml's resolve-issue call (key-credit-job resolve-issue), which reads the newest marker of this exact name inside KEY_CREDIT_WINDOW_HOURS (1 hour unset, 24 at most for this retention)" + ], + "reason": "The marker only says the key hit a credit stop; the gate counts it for its window, an hour by default, so a day covers any window up to 24 hours and an expired marker runs the key, as before the gate. It holds only the run id the workflow writes, never model text, so it is not scanned.", + "scan": "none" + }, + "key-credit-stop-maintenance-audit-lane-runs.yml-judge": { + "retention-days": 1, + "readers": [ + ".github/workflows/maintenance-audit-lane-runs.yml collect's \"Read the key's credit history\" step runs ci-workflows check-key-credit for key job judge, which reads the newest marker of this exact name inside KEY_CREDIT_WINDOW_HOURS (1 hour unset, 24 at most for this retention)" + ], + "reason": "The marker only says the key hit a credit stop; the gate counts it for its window, an hour by default, so a day covers any window up to 24 hours and an expired marker runs the key, as before the gate. It holds only the run id the workflow writes, never model text, so it is not scanned.", + "scan": "none" + }, "key-credit-stop-*": { "retention-days": 1, "readers": [ - ".github/workflows/pr-review-main.yml review-handoffs-credit, through ci-workflows check-key-credit gate.cjs, in later runs inside vars.KEY_CREDIT_WINDOW_HOURS", - ".github/workflows/pr-run-activity-read.yml broker-check's \"Read the key's credit history\" step, through ci-workflows check-key-credit gate.cjs, for the first call's key-credit-job: the markers pr-unblock.yml, pr-refine.yml and work-resolve-issue.yml upload from each first call's handoff job", - ".github/workflows/maintenance-audit-lane-runs.yml collect's \"Read the key's credit history\" step, for key job judge: the marker judge-handoffs uploads after a key leg's credit stop" + ".github/workflows/pr-review-main.yml review-handoffs-credit, through ci-workflows check-key-credit gate.cjs, in later runs inside vars.KEY_CREDIT_WINDOW_HOURS" ], "reason": "The gate counts a marker only inside its window, 1 hour unset, so 1 day covers it; a window over 24 hours ends at expiry. It holds only the run id the workflow writes, never model text, so it is not scanned.", "scan": "none", diff --git a/.github/scripts/lib/handoff-evidence.test.mjs b/.github/scripts/lib/handoff-evidence.test.mjs index 30ffb78c752..9c03162d21c 100644 --- a/.github/scripts/lib/handoff-evidence.test.mjs +++ b/.github/scripts/lib/handoff-evidence.test.mjs @@ -262,13 +262,19 @@ function refineJobEnv(name) { // An empty output starts a second leg in pr-unblock.yml, so the opt-in stays on // the two jobs whose empty facts start nothing. -test("only pr-refine.yml's two fix-docs handoff jobs name HANDOFF_EVIDENCE_LAG", () => { +// Beside pr-refine.yml's two fix-docs handoff jobs, the key-credit-markers +// jobs name it: their empty outputs start nothing, so a read that cannot +// settle ends with a notice and uploads no marker. +test("only the fix-docs handoff jobs and the key-credit-markers jobs name HANDOFF_EVIDENCE_LAG", () => { const dir = new URL("../../workflows/", import.meta.url); const text = (file) => readFileSync(new URL(file, dir), "utf8"); const files = readdirSync(dir).filter((file) => /\.ya?ml$/.test(file) && text(file).includes("HANDOFF_EVIDENCE_LAG")); - assert.deepEqual(files, ["pr-refine.yml"]); - const blocks = text("pr-refine.yml").split(/^(?= [\w-]+:$)/m).filter((block) => block.includes("HANDOFF_EVIDENCE_LAG")); - assert.deepEqual(blocks.map((block) => /^ {2}([\w-]+):$/m.exec(block)?.[1]), ["fix-docs-handoff", "fix-docs-subscription-handoff"]); + assert.deepEqual(files, ["pr-refine.yml", "pr-unblock.yml", "work-resolve-issue.yml"]); + const jobsNaming = (file) => + text(file).split(/^(?= [\w-]+:$)/m).filter((block) => block.includes("HANDOFF_EVIDENCE_LAG")).map((block) => /^ {2}([\w-]+):$/m.exec(block)?.[1]); + assert.deepEqual(jobsNaming("pr-refine.yml"), ["fix-docs-handoff", "key-credit-markers", "fix-docs-subscription-handoff"]); + assert.deepEqual(jobsNaming("pr-unblock.yml"), ["key-credit-markers"]); + assert.deepEqual(jobsNaming("work-resolve-issue.yml"), ["key-credit-markers"]); }); test("a fact still unsettled in a listing that was read fails with no output by default", () => { diff --git a/.github/workflows/intake-triage.yml b/.github/workflows/intake-triage.yml index 5c689064c47..b84dd72a442 100644 --- a/.github/workflows/intake-triage.yml +++ b/.github/workflows/intake-triage.yml @@ -260,7 +260,6 @@ jobs: && (github.event_name == 'workflow_dispatch' || needs.born-triaged.outputs.skip != 'true') && needs.born-triaged.outputs.claude-args != '' }} permissions: - actions: read # the reusable's key-credit gate lists its credit-stop markers (ci-workflows intake-triage.yml) contents: read # check out the default branch so the lane can read AGENTS.md issues: write # apply labels and post the triage comment concurrency: diff --git a/.github/workflows/pr-refine.yml b/.github/workflows/pr-refine.yml index f6957deab1b..53941764116 100644 --- a/.github/workflows/pr-refine.yml +++ b/.github/workflows/pr-refine.yml @@ -295,13 +295,66 @@ jobs: CALLS: fix-docs run: bash .base/.github/scripts/lib/handoff-evidence.sh posted allowed handed-off - # Whether fix-docs's key leg hit a credit stop, read from this run's jobs - # (handoff-evidence.sh credit-stop), never from a fix-docs output, and - # only when it handed off. A read that cannot settle ends with a notice - # and uploads nothing. + # Whether the third call can run, never the token. fix-docs-subscription passes it + # on as fallback-ready, so with the token empty its subscription leg + # stays the last. A failed read leaves it empty. A lagging read sets it, and + # the empty handed-off still keeps both later legs from starting. + - name: Check the fallback token + id: fallback + env: + PRESENT: ${{ secrets.FALLBACK_CLAUDE_CODE_OAUTH_TOKEN != '' }} + run: echo "ready=$PRESENT" >>"$GITHUB_OUTPUT" + + - name: Log the API budget at the end + if: always() + continue-on-error: true + timeout-minutes: 1 + uses: ./.base/.github/actions/log-api-budget # zizmor: ignore[self-repository] + with: + tokens: github-token + + # The key's credit-stop markers, read by the read runner's broker-check in + # later runs (each first call's key-credit-job). Each comes from a jobs-API + # read of the call's run job (handoff-evidence.sh credit-stop), never from a + # call's output. No job needs this one, so the read, which waits for the + # jobs listing's steps, delays no handoff. + key-credit-markers: + needs: [fix-docs] + if: ${{ !cancelled() && (needs.fix-docs.result != 'skipped') }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + actions: read # this run's jobs and their steps + contents: read # the default branch tip's handoff-evidence.sh + # Each guarded API call's rate-limit reading, for the job-end api-budget + # notice (.github/actions/log-api-budget). + env: + API_BUDGET_TOKEN: github-token + steps: + - name: Resolve main's tip + id: tip + env: + TIP_BRANCH: ${{ github.event.repository.default_branch }} + GIT_TERMINAL_PROMPT: "0" + run: | + sha=$(timeout 60 git -C "$RUNNER_TEMP" ls-remote "https://github.com/$GITHUB_REPOSITORY.git" "refs/heads/$TIP_BRANCH" | + awk -v ref="refs/heads/$TIP_BRANCH" '$2 == ref { print $1 }') + [[ $sha =~ ^[0-9a-f]{40}$ ]] || { echo "::error::the tip of $TIP_BRANCH did not resolve"; exit 1; } + echo "sha=$sha" >>"$GITHUB_OUTPUT" + - name: Check out the default branch tip + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.tip.outputs.sha }} + path: .base + sparse-checkout: | + .github/scripts/lib/handoff-evidence.sh + .github/actions/log-api-budget + sparse-checkout-cone-mode: false + persist-credentials: false + - name: Read whether fix-docs's key hit a credit stop - id: credit-stop - if: steps.posted.outputs.posted == 'false' + id: credit-stop-fix-docs + if: needs.fix-docs.result != 'skipped' continue-on-error: true env: GH_TOKEN: ${{ github.token }} @@ -309,39 +362,26 @@ jobs: HANDOFF_EVIDENCE_LAG: notice run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop - # Read by the next runs' key-credit gate (the read runner's broker-check, - # key-credit-job fix-docs): uploaded only after the key leg's credit stop. - # It holds the run id, no model text. - - name: Write the key's credit-stop marker - id: credit-marker - if: steps.credit-stop.outputs.credit-stop == 'true' + - name: Write fix-docs's credit-stop marker + id: credit-marker-fix-docs + if: steps.credit-stop-fix-docs.outputs.credit-stop == 'true' continue-on-error: true timeout-minutes: 1 run: | - mkdir -p "$RUNNER_TEMP/key-credit-stop" - echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop/run-id" + mkdir -p "$RUNNER_TEMP/key-credit-stop-fix-docs" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop-fix-docs/run-id" - - name: Upload the key's credit-stop marker - if: steps.credit-marker.outcome == 'success' + - name: Upload fix-docs's credit-stop marker + if: steps.credit-marker-fix-docs.outcome == 'success' continue-on-error: true timeout-minutes: 1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: key-credit-stop-pr-refine.yml-fix-docs - path: ${{ runner.temp }}/key-credit-stop/run-id + path: ${{ runner.temp }}/key-credit-stop-fix-docs/run-id if-no-files-found: error retention-days: 1 - # Whether the third call can run, never the token. fix-docs-subscription passes it - # on as fallback-ready, so with the token empty its subscription leg - # stays the last. A failed read leaves it empty. A lagging read sets it, and - # the empty handed-off still keeps both later legs from starting. - - name: Check the fallback token - id: fallback - env: - PRESENT: ${{ secrets.FALLBACK_CLAUDE_CODE_OAUTH_TOKEN != '' }} - run: echo "ready=$PRESENT" >>"$GITHUB_OUTPUT" - - name: Log the API budget at the end if: always() continue-on-error: true diff --git a/.github/workflows/pr-unblock.yml b/.github/workflows/pr-unblock.yml index e602fa9607d..50192c34015 100644 --- a/.github/workflows/pr-unblock.yml +++ b/.github/workflows/pr-unblock.yml @@ -581,43 +581,6 @@ jobs: CALLS: resolve-conflicts run: bash .base/.github/scripts/lib/handoff-evidence.sh posted - # Whether resolve-conflicts's key leg hit a credit stop, read from this run's jobs - # (handoff-evidence.sh credit-stop), never from a resolve-conflicts output, and - # only when it handed off. A read that cannot settle ends with a notice - # and uploads nothing. - - name: Read whether resolve-conflicts's key hit a credit stop - id: credit-stop - if: steps.posted.outputs.posted == 'false' - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - CALLS: resolve-conflicts - HANDOFF_EVIDENCE_LAG: notice - run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop - - # Read by the next runs' key-credit gate (the read runner's broker-check, - # key-credit-job resolve-conflicts): uploaded only after the key leg's credit stop. - # It holds the run id, no model text. - - name: Write the key's credit-stop marker - id: credit-marker - if: steps.credit-stop.outputs.credit-stop == 'true' - continue-on-error: true - timeout-minutes: 1 - run: | - mkdir -p "$RUNNER_TEMP/key-credit-stop" - echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop/run-id" - - - name: Upload the key's credit-stop marker - if: steps.credit-marker.outcome == 'success' - continue-on-error: true - timeout-minutes: 1 - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: key-credit-stop-pr-unblock.yml-resolve-conflicts - path: ${{ runner.temp }}/key-credit-stop/run-id - if-no-files-found: error - retention-days: 1 - # Whether the third call can run, never the token. resolve-conflicts-subscription passes it # on as fallback-ready, so with the token empty its subscription leg # stays the last; a failed read leaves it empty, which hands nothing on. @@ -1690,43 +1653,6 @@ jobs: CALLS: address-feedback run: bash .base/.github/scripts/lib/handoff-evidence.sh posted - # Whether address-feedback's key leg hit a credit stop, read from this run's jobs - # (handoff-evidence.sh credit-stop), never from a address-feedback output, and - # only when it handed off. A read that cannot settle ends with a notice - # and uploads nothing. - - name: Read whether address-feedback's key hit a credit stop - id: credit-stop - if: steps.posted.outputs.posted == 'false' - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - CALLS: address-feedback - HANDOFF_EVIDENCE_LAG: notice - run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop - - # Read by the next runs' key-credit gate (the read runner's broker-check, - # key-credit-job address-feedback): uploaded only after the key leg's credit stop. - # It holds the run id, no model text. - - name: Write the key's credit-stop marker - id: credit-marker - if: steps.credit-stop.outputs.credit-stop == 'true' - continue-on-error: true - timeout-minutes: 1 - run: | - mkdir -p "$RUNNER_TEMP/key-credit-stop" - echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop/run-id" - - - name: Upload the key's credit-stop marker - if: steps.credit-marker.outcome == 'success' - continue-on-error: true - timeout-minutes: 1 - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: key-credit-stop-pr-unblock.yml-address-feedback - path: ${{ runner.temp }}/key-credit-stop/run-id - if-no-files-found: error - retention-days: 1 - # Whether the third call can run, never the token. address-feedback-subscription passes it # on as fallback-ready, so with the token empty its subscription leg # stays the last; a failed read leaves it empty, which hands nothing on. @@ -3384,43 +3310,6 @@ jobs: CALLS: review-dependency run: bash .base/.github/scripts/lib/handoff-evidence.sh posted - # Whether review-dependency's key leg hit a credit stop, read from this run's jobs - # (handoff-evidence.sh credit-stop), never from a review-dependency output, and - # only when it handed off. A read that cannot settle ends with a notice - # and uploads nothing. - - name: Read whether review-dependency's key hit a credit stop - id: credit-stop - if: steps.posted.outputs.posted == 'false' - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - CALLS: review-dependency - HANDOFF_EVIDENCE_LAG: notice - run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop - - # Read by the next runs' key-credit gate (the read runner's broker-check, - # key-credit-job review-dependency): uploaded only after the key leg's credit stop. - # It holds the run id, no model text. - - name: Write the key's credit-stop marker - id: credit-marker - if: steps.credit-stop.outputs.credit-stop == 'true' - continue-on-error: true - timeout-minutes: 1 - run: | - mkdir -p "$RUNNER_TEMP/key-credit-stop" - echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop/run-id" - - - name: Upload the key's credit-stop marker - if: steps.credit-marker.outcome == 'success' - continue-on-error: true - timeout-minutes: 1 - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: key-credit-stop-pr-unblock.yml-review-dependency - path: ${{ runner.temp }}/key-credit-stop/run-id - if-no-files-found: error - retention-days: 1 - # Whether the third call can run, never the token. review-dependency-subscription passes it # on as fallback-ready, so with the token empty its subscription leg # stays the last; a failed read leaves it empty, which hands nothing on. @@ -3975,43 +3864,6 @@ jobs: CALLS: fix-ci run: bash .base/.github/scripts/lib/handoff-evidence.sh posted - # Whether fix-ci's key leg hit a credit stop, read from this run's jobs - # (handoff-evidence.sh credit-stop), never from a fix-ci output, and - # only when it handed off. A read that cannot settle ends with a notice - # and uploads nothing. - - name: Read whether fix-ci's key hit a credit stop - id: credit-stop - if: steps.posted.outputs.posted == 'false' - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - CALLS: fix-ci - HANDOFF_EVIDENCE_LAG: notice - run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop - - # Read by the next runs' key-credit gate (the read runner's broker-check, - # key-credit-job fix-ci): uploaded only after the key leg's credit stop. - # It holds the run id, no model text. - - name: Write the key's credit-stop marker - id: credit-marker - if: steps.credit-stop.outputs.credit-stop == 'true' - continue-on-error: true - timeout-minutes: 1 - run: | - mkdir -p "$RUNNER_TEMP/key-credit-stop" - echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop/run-id" - - - name: Upload the key's credit-stop marker - if: steps.credit-marker.outcome == 'success' - continue-on-error: true - timeout-minutes: 1 - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: key-credit-stop-pr-unblock.yml-fix-ci - path: ${{ runner.temp }}/key-credit-stop/run-id - if-no-files-found: error - retention-days: 1 - # Whether the third call can run, never the token. fix-ci-subscription passes it # on as fallback-ready, so with the token empty its subscription leg # stays the last; a failed read leaves it empty, which hands nothing on. @@ -4302,43 +4154,6 @@ jobs: CALLS: fix-ci-rung-up run: bash .base/.github/scripts/lib/handoff-evidence.sh posted - # Whether fix-ci-rung-up's key leg hit a credit stop, read from this run's jobs - # (handoff-evidence.sh credit-stop), never from a fix-ci-rung-up output, and - # only when it handed off. A read that cannot settle ends with a notice - # and uploads nothing. - - name: Read whether fix-ci-rung-up's key hit a credit stop - id: credit-stop - if: steps.posted.outputs.posted == 'false' - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - CALLS: fix-ci-rung-up - HANDOFF_EVIDENCE_LAG: notice - run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop - - # Read by the next runs' key-credit gate (the read runner's broker-check, - # key-credit-job fix-ci-rung-up): uploaded only after the key leg's credit stop. - # It holds the run id, no model text. - - name: Write the key's credit-stop marker - id: credit-marker - if: steps.credit-stop.outputs.credit-stop == 'true' - continue-on-error: true - timeout-minutes: 1 - run: | - mkdir -p "$RUNNER_TEMP/key-credit-stop" - echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop/run-id" - - - name: Upload the key's credit-stop marker - if: steps.credit-marker.outcome == 'success' - continue-on-error: true - timeout-minutes: 1 - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: key-credit-stop-pr-unblock.yml-fix-ci-rung-up - path: ${{ runner.temp }}/key-credit-stop/run-id - if-no-files-found: error - retention-days: 1 - # Whether the third call can run, never the token. fix-ci-rung-up-subscription passes it # on as fallback-ready, so with the token empty its subscription leg # stays the last; a failed read leaves it empty, which hands nothing on. @@ -4846,3 +4661,200 @@ jobs: ${{ inputs.activity == 'address-feedback' && inputs.dispatcher-run-id != '' && needs.apply-feedback-patch.outputs.pushed-commit || '' }} trusted-base: default-tip + + # The key's credit-stop markers, read by the read runner's broker-check in + # later runs (each first call's key-credit-job). Each comes from a jobs-API + # read of the call's run job (handoff-evidence.sh credit-stop), never from a + # call's output. No job needs this one, so the read, which waits for the + # jobs listing's steps, delays no handoff. + key-credit-markers: + needs: [resolve-conflicts, address-feedback, review-dependency, fix-ci, fix-ci-rung-up] + if: ${{ !cancelled() && (needs.resolve-conflicts.result != 'skipped' || needs.address-feedback.result != 'skipped' || needs.review-dependency.result != 'skipped' || needs.fix-ci.result != 'skipped' || needs.fix-ci-rung-up.result != 'skipped') }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + actions: read # this run's jobs and their steps + contents: read # the default branch tip's handoff-evidence.sh + # Each guarded API call's rate-limit reading, for the job-end api-budget + # notice (.github/actions/log-api-budget). + env: + API_BUDGET_TOKEN: github-token + steps: + - name: Resolve main's tip + id: tip + env: + TIP_BRANCH: ${{ github.event.repository.default_branch }} + GIT_TERMINAL_PROMPT: "0" + run: | + sha=$(timeout 60 git -C "$RUNNER_TEMP" ls-remote "https://github.com/$GITHUB_REPOSITORY.git" "refs/heads/$TIP_BRANCH" | + awk -v ref="refs/heads/$TIP_BRANCH" '$2 == ref { print $1 }') + [[ $sha =~ ^[0-9a-f]{40}$ ]] || { echo "::error::the tip of $TIP_BRANCH did not resolve"; exit 1; } + echo "sha=$sha" >>"$GITHUB_OUTPUT" + - name: Check out the default branch tip + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.tip.outputs.sha }} + path: .base + sparse-checkout: | + .github/scripts/lib/handoff-evidence.sh + .github/actions/log-api-budget + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Read whether resolve-conflicts's key hit a credit stop + id: credit-stop-resolve-conflicts + if: needs.resolve-conflicts.result != 'skipped' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + CALLS: resolve-conflicts + HANDOFF_EVIDENCE_LAG: notice + run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop + + - name: Write resolve-conflicts's credit-stop marker + id: credit-marker-resolve-conflicts + if: steps.credit-stop-resolve-conflicts.outputs.credit-stop == 'true' + continue-on-error: true + timeout-minutes: 1 + run: | + mkdir -p "$RUNNER_TEMP/key-credit-stop-resolve-conflicts" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop-resolve-conflicts/run-id" + + - name: Upload resolve-conflicts's credit-stop marker + if: steps.credit-marker-resolve-conflicts.outcome == 'success' + continue-on-error: true + timeout-minutes: 1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: key-credit-stop-pr-unblock.yml-resolve-conflicts + path: ${{ runner.temp }}/key-credit-stop-resolve-conflicts/run-id + if-no-files-found: error + retention-days: 1 + + - name: Read whether address-feedback's key hit a credit stop + id: credit-stop-address-feedback + if: needs.address-feedback.result != 'skipped' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + CALLS: address-feedback + HANDOFF_EVIDENCE_LAG: notice + run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop + + - name: Write address-feedback's credit-stop marker + id: credit-marker-address-feedback + if: steps.credit-stop-address-feedback.outputs.credit-stop == 'true' + continue-on-error: true + timeout-minutes: 1 + run: | + mkdir -p "$RUNNER_TEMP/key-credit-stop-address-feedback" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop-address-feedback/run-id" + + - name: Upload address-feedback's credit-stop marker + if: steps.credit-marker-address-feedback.outcome == 'success' + continue-on-error: true + timeout-minutes: 1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: key-credit-stop-pr-unblock.yml-address-feedback + path: ${{ runner.temp }}/key-credit-stop-address-feedback/run-id + if-no-files-found: error + retention-days: 1 + + - name: Read whether review-dependency's key hit a credit stop + id: credit-stop-review-dependency + if: needs.review-dependency.result != 'skipped' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + CALLS: review-dependency + HANDOFF_EVIDENCE_LAG: notice + run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop + + - name: Write review-dependency's credit-stop marker + id: credit-marker-review-dependency + if: steps.credit-stop-review-dependency.outputs.credit-stop == 'true' + continue-on-error: true + timeout-minutes: 1 + run: | + mkdir -p "$RUNNER_TEMP/key-credit-stop-review-dependency" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop-review-dependency/run-id" + + - name: Upload review-dependency's credit-stop marker + if: steps.credit-marker-review-dependency.outcome == 'success' + continue-on-error: true + timeout-minutes: 1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: key-credit-stop-pr-unblock.yml-review-dependency + path: ${{ runner.temp }}/key-credit-stop-review-dependency/run-id + if-no-files-found: error + retention-days: 1 + + - name: Read whether fix-ci's key hit a credit stop + id: credit-stop-fix-ci + if: needs.fix-ci.result != 'skipped' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + CALLS: fix-ci + HANDOFF_EVIDENCE_LAG: notice + run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop + + - name: Write fix-ci's credit-stop marker + id: credit-marker-fix-ci + if: steps.credit-stop-fix-ci.outputs.credit-stop == 'true' + continue-on-error: true + timeout-minutes: 1 + run: | + mkdir -p "$RUNNER_TEMP/key-credit-stop-fix-ci" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop-fix-ci/run-id" + + - name: Upload fix-ci's credit-stop marker + if: steps.credit-marker-fix-ci.outcome == 'success' + continue-on-error: true + timeout-minutes: 1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: key-credit-stop-pr-unblock.yml-fix-ci + path: ${{ runner.temp }}/key-credit-stop-fix-ci/run-id + if-no-files-found: error + retention-days: 1 + + - name: Read whether fix-ci-rung-up's key hit a credit stop + id: credit-stop-fix-ci-rung-up + if: needs.fix-ci-rung-up.result != 'skipped' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + CALLS: fix-ci-rung-up + HANDOFF_EVIDENCE_LAG: notice + run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop + + - name: Write fix-ci-rung-up's credit-stop marker + id: credit-marker-fix-ci-rung-up + if: steps.credit-stop-fix-ci-rung-up.outputs.credit-stop == 'true' + continue-on-error: true + timeout-minutes: 1 + run: | + mkdir -p "$RUNNER_TEMP/key-credit-stop-fix-ci-rung-up" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop-fix-ci-rung-up/run-id" + + - name: Upload fix-ci-rung-up's credit-stop marker + if: steps.credit-marker-fix-ci-rung-up.outcome == 'success' + continue-on-error: true + timeout-minutes: 1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: key-credit-stop-pr-unblock.yml-fix-ci-rung-up + path: ${{ runner.temp }}/key-credit-stop-fix-ci-rung-up/run-id + if-no-files-found: error + retention-days: 1 + + - name: Log the API budget at the end + if: always() + continue-on-error: true + timeout-minutes: 1 + uses: ./.base/.github/actions/log-api-budget # zizmor: ignore[self-repository] + with: + tokens: github-token diff --git a/.github/workflows/work-resolve-issue.yml b/.github/workflows/work-resolve-issue.yml index a766aff631c..06223a2e01f 100644 --- a/.github/workflows/work-resolve-issue.yml +++ b/.github/workflows/work-resolve-issue.yml @@ -375,13 +375,65 @@ jobs: CALLS: resolve-issue run: bash .base/.github/scripts/lib/handoff-evidence.sh handed-off - # Whether resolve-issue's key leg hit a credit stop, read from this run's jobs - # (handoff-evidence.sh credit-stop), never from a resolve-issue output, and - # only when it handed off. A read that cannot settle ends with a notice - # and uploads nothing. + # Whether the third call can run, never the token. resolve-issue-subscription passes it + # on as fallback-ready, so with the token empty its subscription leg + # stays the last; a failed read leaves it empty, which hands nothing on. + - name: Check the fallback token + id: fallback + env: + PRESENT: ${{ secrets.FALLBACK_CLAUDE_CODE_OAUTH_TOKEN != '' }} + run: echo "ready=$PRESENT" >>"$GITHUB_OUTPUT" + + - name: Log the API budget at the end + if: always() + continue-on-error: true + timeout-minutes: 1 + uses: ./.base/.github/actions/log-api-budget # zizmor: ignore[self-repository] + with: + tokens: github-token + + # The key's credit-stop markers, read by the read runner's broker-check in + # later runs (each first call's key-credit-job). Each comes from a jobs-API + # read of the call's run job (handoff-evidence.sh credit-stop), never from a + # call's output. No job needs this one, so the read, which waits for the + # jobs listing's steps, delays no handoff. + key-credit-markers: + needs: [resolve-issue] + if: ${{ !cancelled() && (needs.resolve-issue.result != 'skipped') }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + actions: read # this run's jobs and their steps + contents: read # the default branch tip's handoff-evidence.sh + # Each guarded API call's rate-limit reading, for the job-end api-budget + # notice (.github/actions/log-api-budget). + env: + API_BUDGET_TOKEN: github-token + steps: + - name: Resolve main's tip + id: tip + env: + TIP_BRANCH: ${{ github.event.repository.default_branch }} + GIT_TERMINAL_PROMPT: "0" + run: | + sha=$(timeout 60 git -C "$RUNNER_TEMP" ls-remote "https://github.com/$GITHUB_REPOSITORY.git" "refs/heads/$TIP_BRANCH" | + awk -v ref="refs/heads/$TIP_BRANCH" '$2 == ref { print $1 }') + [[ $sha =~ ^[0-9a-f]{40}$ ]] || { echo "::error::the tip of $TIP_BRANCH did not resolve"; exit 1; } + echo "sha=$sha" >>"$GITHUB_OUTPUT" + - name: Check out the default branch tip + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.tip.outputs.sha }} + path: .base + sparse-checkout: | + .github/scripts/lib/handoff-evidence.sh + .github/actions/log-api-budget + sparse-checkout-cone-mode: false + persist-credentials: false + - name: Read whether resolve-issue's key hit a credit stop - id: credit-stop - if: steps.handoff.outputs.handed-off == 'true' + id: credit-stop-resolve-issue + if: needs.resolve-issue.result != 'skipped' continue-on-error: true env: GH_TOKEN: ${{ github.token }} @@ -389,38 +441,26 @@ jobs: HANDOFF_EVIDENCE_LAG: notice run: bash .base/.github/scripts/lib/handoff-evidence.sh credit-stop - # Read by the next runs' key-credit gate (the read runner's broker-check, - # key-credit-job resolve-issue): uploaded only after the key leg's credit stop. - # It holds the run id, no model text. - - name: Write the key's credit-stop marker - id: credit-marker - if: steps.credit-stop.outputs.credit-stop == 'true' + - name: Write resolve-issue's credit-stop marker + id: credit-marker-resolve-issue + if: steps.credit-stop-resolve-issue.outputs.credit-stop == 'true' continue-on-error: true timeout-minutes: 1 run: | - mkdir -p "$RUNNER_TEMP/key-credit-stop" - echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop/run-id" + mkdir -p "$RUNNER_TEMP/key-credit-stop-resolve-issue" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop-resolve-issue/run-id" - - name: Upload the key's credit-stop marker - if: steps.credit-marker.outcome == 'success' + - name: Upload resolve-issue's credit-stop marker + if: steps.credit-marker-resolve-issue.outcome == 'success' continue-on-error: true timeout-minutes: 1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: key-credit-stop-work-resolve-issue.yml-resolve-issue - path: ${{ runner.temp }}/key-credit-stop/run-id + path: ${{ runner.temp }}/key-credit-stop-resolve-issue/run-id if-no-files-found: error retention-days: 1 - # Whether the third call can run, never the token. resolve-issue-subscription passes it - # on as fallback-ready, so with the token empty its subscription leg - # stays the last; a failed read leaves it empty, which hands nothing on. - - name: Check the fallback token - id: fallback - env: - PRESENT: ${{ secrets.FALLBACK_CLAUDE_CODE_OAUTH_TOKEN != '' }} - run: echo "ready=$PRESENT" >>"$GITHUB_OUTPUT" - - name: Log the API budget at the end if: always() continue-on-error: true diff --git a/scripts/write-runner-never-rules.test.sh b/scripts/write-runner-never-rules.test.sh index 1ee47c93e4d..ebee00c272b 100755 --- a/scripts/write-runner-never-rules.test.sh +++ b/scripts/write-runner-never-rules.test.sh @@ -328,13 +328,13 @@ say(summaryData.env?.GITHUB_TOKEN === "${{ github.token }}" && /GIT_CONFIG_GLOBA && /--filter=blob:none/.test(summaryData.run ?? "") && /diff --no-ext-diff --no-textconv/.test(summaryData.run ?? "") && !/checkout|worktree|npm |bash "\$SRC|node "\$SRC/.test(summaryData.run ?? ""), "the summary data step fetches the branch as objects with the read-only token and diffs it without running anything from it"); -// One launcher step per secret: the first call's pass on the API key, a -// fallback call's on the OAuth token. -const summaryLegs = [["summary-key", "api-key", "claude-api-key", "inputs.fallback == ''"], ["summary-token", "subscription-token", "claude-code-oauth-token", "inputs.fallback != ''"]]; +// One launcher step per secret: the first call's pass on the API key, the +// second call's on the OAuth token, the third call's on the fallback token. +const summaryLegs = [["summary-key", "api-key", "claude-api-key", "inputs.fallback == ''"], ["summary-token", "subscription-token", "claude-code-oauth-token", "inputs.fallback != '' && inputs.fallback != 'fallback'"], ["summary-fallback", "fallback-token", "fallback-claude-code-oauth-token", "inputs.fallback == 'fallback'"]]; say(summaryLegs.every(([id, input, secret, when]) => { const leg = sById(id); return String(leg.uses ?? "") === String(rById("act-key").uses ?? "") && leg.with?.[input] === "${{ secrets." + secret + " }}" - && ["api-key", "subscription-token"].filter((name) => name in (leg.with ?? {})).length === 1 + && ["api-key", "subscription-token", "fallback-token"].filter((name) => name in (leg.with ?? {})).length === 1 && String(leg.if ?? "") === "steps.isolate.outcome == 'success' && " + when; }) && sById("isolate").uses === "./.base/.github/actions/setup-runner-isolation" && /\/apply-claude-managed-settings@/.test(sById("summary-settings").uses ?? "") && String(sById("isolate").if ?? "") === "steps.summary-settings.outcome == 'success'",