Skip to content
Merged
147 changes: 140 additions & 7 deletions .github/actions/resolve-config/callers.test.mjs

Large diffs are not rendered by default.

68 changes: 67 additions & 1 deletion .github/artifact-retention.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": [
Expand Down
16 changes: 12 additions & 4 deletions .github/scripts/lib/handoff-evidence.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
;;
Expand Down Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions .github/scripts/lib/handoff-evidence.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
19 changes: 17 additions & 2 deletions .github/scripts/maintenance-audit-lane-runs/handoff.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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]}'`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
Loading
Loading