diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index 29efddc887d..ff4752d7f1f 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -1400,7 +1400,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. @@ -2451,6 +2452,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", @@ -2488,6 +2491,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 = { @@ -2572,8 +2577,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) => @@ -2826,8 +2835,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. @@ -2837,7 +2850,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); @@ -3008,7 +3021,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, { @@ -7647,14 +7660,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. @@ -8995,6 +9010,124 @@ test("maintenance-tidy-code-upkeep.yml: the sweep runs key, subscription and fal 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 }}"); +}); + +// 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 a job off the handoff path uploads its marker from a jobs-API read", () => { + const FIRSTS = { + "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, calls] of Object.entries(FIRSTS)) { + const { jobs } = workflow(file); + 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 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}`, call); + assert.equal(upload.with["retention-days"], 1); + assert.doesNotMatch(JSON.stringify(jobs[`${call}-handoff`]), /credit-stop/, `${call}-handoff stays off the marker path`); + } + } +}); + +// 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["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); + 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" }); +}); + // --- every model lane runs the three-leg chain (#13586) --- // A lane is the catalog lanes of one workflow and role that share an id once diff --git a/.github/artifact-retention.json b/.github/artifact-retention.json index 0459d17b96b..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,6 +418,70 @@ ".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": [ 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/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/scripts/maintenance-audit-lane-runs/handoff.mjs b/.github/scripts/maintenance-audit-lane-runs/handoff.mjs index ed430d87db5..86ba30272c9 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. @@ -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/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..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( @@ -55,7 +56,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]; @@ -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/); @@ -176,10 +178,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 +227,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); @@ -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/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..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 @@ -565,7 +633,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 +776,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 +1120,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-refine.yml b/.github/workflows/pr-refine.yml index 92bfeca540d..53941764116 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 @@ -311,6 +313,83 @@ jobs: 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-fix-docs + if: needs.fix-docs.result != 'skipped' + 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 + + - 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-fix-docs" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop-fix-docs/run-id" + + - 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-fix-docs/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 + # The subscription fallback, as fix-ci-subscription is for fix-ci: it runs # only when the broker allowed fix-docs, fix-docs posted no check, and # fix-docs handed off: a credit stop or an empty key, whose run job succeeds @@ -331,6 +410,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 +502,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 c08694258ae..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 @@ -662,7 +721,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 @@ -1593,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" @@ -1609,11 +1675,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" @@ -1695,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' @@ -2065,6 +2144,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 +2340,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: | @@ -3472,7 +3553,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 @@ -3503,7 +3585,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 @@ -3527,16 +3609,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 @@ -3544,10 +3668,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/pr-unblock.yml b/.github/workflows/pr-unblock.yml index 1a7a49d9986..50192c34015 100644 --- a/.github/workflows/pr-unblock.yml +++ b/.github/workflows/pr-unblock.yml @@ -505,6 +505,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 @@ -519,6 +520,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 @@ -605,6 +607,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 @@ -695,6 +698,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 @@ -1573,6 +1577,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 @@ -1586,6 +1591,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 @@ -1673,6 +1679,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 @@ -1764,6 +1771,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 @@ -3227,6 +3235,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 @@ -3240,6 +3249,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 @@ -3326,6 +3336,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 @@ -3416,6 +3427,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 @@ -3774,6 +3786,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 @@ -3787,6 +3800,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 @@ -3879,6 +3893,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 @@ -3970,6 +3985,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 @@ -4064,6 +4080,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 @@ -4077,6 +4094,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 @@ -4160,6 +4178,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 @@ -4251,6 +4270,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 @@ -4641,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/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 != '' 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. diff --git a/.github/workflows/work-resolve-issue.yml b/.github/workflows/work-resolve-issue.yml index b9b7c9e4497..06223a2e01f 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 @@ -390,6 +392,83 @@ jobs: 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-resolve-issue + if: needs.resolve-issue.result != 'skipped' + 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 + + - 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-resolve-issue" + echo "$GITHUB_RUN_ID" >"$RUNNER_TEMP/key-credit-stop-resolve-issue/run-id" + + - 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-resolve-issue/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 + # The subscription fallback, a second call on new VMs in the same slot: it # runs only on resolve-issue-handoff's read, holds the OAuth token alone, # and passes fallback handoff, the one fact that read attests. @@ -402,6 +481,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 +574,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 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'",