From 53326e37d2e92c38c46662a6125041f3e50572ac Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:48:03 -0400 Subject: [PATCH 01/10] fix(lanes): scan every lane and eval upload for credentials before it leaves Add .github/actions/scan-upload, which runs lib/publish-gate.mjs scanLeak (the read runner's leak-scan rule set) over every file and file name an upload is about to send. A credential, hidden assignment, link, compressed file or glob fails the step and withholds the upload: each gated upload's if: requires the scan's clean output, so an always() upload is withheld too. The lane transcript action scans the plaintext execution file before the seal step. Every lane and eval upload not already behind leak-scan now runs it. .github/artifact-retention.json records a required scan field per family (upload, plaintext or none), and check-artifact-retention fails a scanned upload with no scan over the same path before it. lane-sweep-units and eval-browser-* drop to 3 days. Closes #8295 Co-Authored-By: Claude Opus 5.5 --- .github/actions/scan-upload/action.yml | 29 ++++ .github/actions/scan-upload/scan-upload.mjs | 101 +++++++++++ .../actions/scan-upload/scan-upload.test.mjs | 93 +++++++++++ .../actions/upload-lane-transcript/action.yml | 14 +- .github/artifact-retention.json | 124 +++++++++----- .github/artifact-retention.schema.json | 6 +- .github/workflows/eval-browser-dispatch.yml | 13 +- .github/workflows/intake-triage.yml | 33 +++- .../workflows/maintenance-audit-lane-runs.yml | 68 +++++++- .../maintenance-reconcile-lost-runs.yml | 44 ++++- .github/workflows/pr-feedback-dispatch.yml | 35 +++- .github/workflows/pr-merge.yml | 158 ++++++++++++++++-- .github/workflows/pr-review-main.yml | 33 +++- .github/workflows/pr-run-activity-read.yml | 20 ++- .github/workflows/pr-run-activity-write.yml | 37 +++- .github/workflows/pr-unblock-dispatch.yml | 11 +- .github/workflows/pr-unblock.yml | 48 +++++- .github/workflows/pr-verify.yml | 33 +++- scripts/check-artifact-retention.mjs | 32 +++- scripts/check-artifact-retention.sh | 8 + scripts/check-artifact-retention.test.sh | 48 +++++- 21 files changed, 886 insertions(+), 102 deletions(-) create mode 100644 .github/actions/scan-upload/action.yml create mode 100755 .github/actions/scan-upload/scan-upload.mjs create mode 100644 .github/actions/scan-upload/scan-upload.test.mjs diff --git a/.github/actions/scan-upload/action.yml b/.github/actions/scan-upload/action.yml new file mode 100644 index 00000000000..ed2120101a2 --- /dev/null +++ b/.github/actions/scan-upload/action.yml @@ -0,0 +1,29 @@ +name: Scan an upload for credentials +description: > + Run lib/publish-gate.mjs scanLeak over every file an actions/upload-artifact + step is about to send, with the same `path` input. A credential, a hidden + assignment, a link, a compressed file or a glob fails the step and sets no + `clean` output; the upload's `if:` must require clean == 'true', so a hit + withholds the upload even when the upload runs always(). Nothing printed + carries a matched value or a file name. Needs node 18 or later on PATH and + lib/publish-gate.mjs three directories above this action. + +inputs: + path: + description: The upload step's `path` input, verbatim. + required: true + +outputs: + clean: + description: "`true` only when every file was judged clean." + value: ${{ steps.scan.outputs.clean }} + +runs: + using: composite + steps: + - name: Scan the files + id: scan + shell: bash + env: + SCAN_PATHS: ${{ inputs.path }} + run: node "$GITHUB_ACTION_PATH/scan-upload.mjs" diff --git a/.github/actions/scan-upload/scan-upload.mjs b/.github/actions/scan-upload/scan-upload.mjs new file mode 100755 index 00000000000..8934e4c0fcb --- /dev/null +++ b/.github/actions/scan-upload/scan-upload.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +// The pre-upload leak scan for lane and eval artifacts: scanLeak from +// lib/publish-gate.mjs, the rule set the read runner's leak-scan step uses, +// over every file an upload is about to send, its relative name included. +// A credential format or a hidden assignment withholds the upload; the broad +// assignment label only warns. A link, a non-regular file, a glob or `!` +// entry, a compressed file (whose bytes the scan cannot read) or an +// unreadable path is a fault, which withholds it too. Nothing it prints +// carries a matched value or a file name. +// +// SCAN_PATHS= scan-upload.mjs +// Writes clean=true to GITHUB_OUTPUT only when every file was judged clean. +// Exit 0 clean, 1 withheld. + +import { appendFileSync, lstatSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { scanLeak, WARN_ONLY } from "../../../lib/publish-gate.mjs"; + +const COMPRESSED = [Buffer.from([0x1f, 0x8b]), Buffer.from("PK\x03\x04", "latin1"), Buffer.from("(\xb5/\xfd", "latin1")]; + +/** + * Every file under each of `entries` (the upload's path lines), judged by + * scanLeak. Returns `{ withheld: {label, credential} | null, warned, files }`. + */ +export function scanPaths(entries) { + let warned = false; + let files = 0; + const withhold = (label, credential = false) => ({ withheld: { label, credential }, warned, files }); + const judge = (bytes) => { + const { block, credential, warn } = scanLeak(bytes); + if (block) return { label: block, credential }; + warned ||= Boolean(warn); + return null; + }; + const walk = (at, name) => { + let stat; + try { + stat = lstatSync(at); + } catch { + return { label: "a path the scan cannot read" }; + } + if (stat.isSymbolicLink()) return { label: "a link, which the upload would follow" }; + if (stat.isDirectory()) { + for (const child of readdirSync(at).sort()) { + const hit = walk(path.join(at, child), name ? `${name}/${child}` : child); + if (hit) return hit; + } + return null; + } + if (!stat.isFile()) return { label: "a file that is not a regular file" }; + files += 1; + const bytes = readFileSync(at); + if (COMPRESSED.some((magic) => bytes.subarray(0, magic.length).equals(magic))) { + return { label: "a compressed file, whose bytes the scan cannot read" }; + } + return judge(Buffer.from(name, "utf8")) ?? judge(bytes); + }; + for (const line of entries) { + const entry = line.trim(); + if (entry === "") continue; + if (entry.startsWith("!") || /[*?[\]{}]/.test(entry)) return withhold("a glob or exclude pattern, which the scan cannot follow"); + let exists = true; + try { + lstatSync(entry); + } catch { + exists = false; + } + // A missing path uploads nothing; the upload's if-no-files-found decides it. + if (!exists) continue; + const hit = walk(entry, lstatSync(entry).isDirectory() ? "" : path.basename(entry)); + if (hit) return { withheld: hit, warned, files }; + } + return { withheld: null, warned, files }; +} + +export function main(env = process.env) { + const entries = String(env.SCAN_PATHS ?? "").split(/\r?\n/); + let result; + try { + result = scanPaths(entries); + } catch (error) { + result = { withheld: { label: `no finished scan (${error.name})`, credential: false }, warned: false, files: 0 }; + } + const { withheld, warned, files } = result; + if (withheld) { + process.stdout.write( + `::error::scan-upload: ${withheld.label}; nothing is uploaded${withheld.credential ? ". Rotate this credential" : ""}\n`, + ); + return 1; + } + if (warned) process.stdout.write(`::warning::scan-upload: ${WARN_ONLY} in an upload file; not blocking, check it is no real credential\n`); + if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, "clean=true\n"); + process.stdout.write(`scan-upload: ${files} file(s) clean\n`); + return 0; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = main(); +} diff --git a/.github/actions/scan-upload/scan-upload.test.mjs b/.github/actions/scan-upload/scan-upload.test.mjs new file mode 100644 index 00000000000..14c35911e67 --- /dev/null +++ b/.github/actions/scan-upload/scan-upload.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { gzipSync } from "node:zlib"; + +const HERE = import.meta.dirname; +const SCRIPT = path.join(HERE, "scan-upload.mjs"); +// Built at run time, so no tracked file holds a credential shape: a GitHub +// token prefix and 36 letters spelling FAKE, never a live token. +const PLANTED = ["gh", "p_", "FAKE".repeat(9)].join(""); + +function workDir(t) { + const dir = mkdtempSync(path.join(tmpdir(), "scan-upload-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function run(dir, paths) { + const output = path.join(dir, "github-output"); + writeFileSync(output, ""); + const result = spawnSync(process.execPath, [SCRIPT], { + env: { PATH: process.env.PATH, SCAN_PATHS: paths, GITHUB_OUTPUT: output }, + encoding: "utf8", + }); + return { ...result, outputs: readFileSync(output, "utf8") }; +} + +function withheld(result, label) { + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.equal(result.outputs, "", "a withheld upload sets no clean output"); + assert.match(result.stdout, new RegExp(`::error::scan-upload: ${label}`)); + assert.ok(!result.stdout.includes(PLANTED) && !result.stderr.includes(PLANTED), "the matched value is never printed"); +} + +test("clean files pass and set clean=true", (t) => { + const dir = workDir(t); + mkdirSync(path.join(dir, "out", "nested"), { recursive: true }); + writeFileSync(path.join(dir, "out", "reply.txt"), "a clean reply\n"); + writeFileSync(path.join(dir, "out", "nested", "verdict.json"), '{"verdict":"merge"}\n'); + const result = run(dir, `${path.join(dir, "out")}\n${path.join(dir, "absent.json")}\n`); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.equal(result.outputs, "clean=true\n"); + assert.match(result.stdout, /2 file\(s\) clean/); +}); + +test("a planted credential withholds the upload and asks for rotation, never printing the value", (t) => { + const dir = workDir(t); + mkdirSync(path.join(dir, "out")); + writeFileSync(path.join(dir, "out", "clean.txt"), "nothing here\n"); + writeFileSync(path.join(dir, "out", "handoff.json"), `{"review":"token ${PLANTED} in the text"}\n`); + const result = run(dir, path.join(dir, "out")); + withheld(result, "GitHub token; nothing is uploaded"); + assert.match(result.stdout, /Rotate this credential/); +}); + +test("a credential in a file name withholds the upload", (t) => { + const dir = workDir(t); + mkdirSync(path.join(dir, "out")); + writeFileSync(path.join(dir, "out", `${PLANTED}.txt`), "clean bytes\n"); + withheld(run(dir, path.join(dir, "out")), "GitHub token"); +}); + +test("a link, a compressed file and a glob each withhold the upload", (t) => { + const dir = workDir(t); + writeFileSync(path.join(dir, "target.txt"), "clean\n"); + symlinkSync(path.join(dir, "target.txt"), path.join(dir, "link.txt")); + withheld(run(dir, path.join(dir, "link.txt")), "a link"); + writeFileSync(path.join(dir, "trace.gz"), gzipSync(Buffer.from(PLANTED))); + withheld(run(dir, path.join(dir, "trace.gz")), "a compressed file"); + withheld(run(dir, path.join(dir, "*.txt")), "a glob or exclude pattern"); + withheld(run(dir, `!${path.join(dir, "target.txt")}`), "a glob or exclude pattern"); +}); + +test("the broad assignment label only warns", (t) => { + const dir = workDir(t); + writeFileSync(path.join(dir, "fixture.txt"), 'password = "not-a-real-one"\n'); + const result = run(dir, path.join(dir, "fixture.txt")); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.equal(result.outputs, "clean=true\n"); + assert.match(result.stdout, /::warning::scan-upload: password or secret assignment/); +}); + +test("the lane transcript is scanned as plaintext before it is sealed", () => { + const action = readFileSync(path.join(HERE, "..", "upload-lane-transcript", "action.yml"), "utf8"); + const scan = action.indexOf("id: scan\n"); + const seal = action.indexOf("id: seal\n"); + assert.ok(scan > 0 && scan < seal, "the scan step comes before the seal step"); + assert.match(action, /SCAN_PATHS: \$\{\{ inputs\.execution-file \}\}\n\s+run: node "\$GITHUB_ACTION_PATH\/\.\.\/scan-upload\/scan-upload\.mjs"/); + assert.match(action, /id: seal\n\s+if: steps\.scan\.outputs\.clean == 'true'\n/); +}); diff --git a/.github/actions/upload-lane-transcript/action.yml b/.github/actions/upload-lane-transcript/action.yml index 3a01e4289a8..c6d547dc1b8 100644 --- a/.github/actions/upload-lane-transcript/action.yml +++ b/.github/actions/upload-lane-transcript/action.yml @@ -5,7 +5,9 @@ description: > and job-id, and upload only the sealed file as a 7-day artifact for the transcript puller. An empty public key skips with a notice; a missing or empty execution file uploads nothing; a malformed key or job-id fails the - step. Needs node 18 or later on PATH and ../seal-lane-output beside it. + step. The plaintext is scanned first (../scan-upload), and a credential in + it fails the step with nothing sealed. Needs node 18 or later on PATH, + ../seal-lane-output and ../scan-upload beside it, and lib/publish-gate.mjs. inputs: execution-file: @@ -21,8 +23,18 @@ inputs: runs: using: composite steps: + # The plaintext, before the seal: the sealed upload is ciphertext, which + # no scan can read. A hit fails here and nothing is sealed or uploaded. + - name: Scan the transcript for credentials + id: scan + shell: bash + env: + SCAN_PATHS: ${{ inputs.execution-file }} + run: node "$GITHUB_ACTION_PATH/../scan-upload/scan-upload.mjs" + - name: Seal the transcript id: seal + if: steps.scan.outputs.clean == 'true' shell: bash env: EXECUTION_FILE: ${{ inputs.execution-file }} diff --git a/.github/artifact-retention.json b/.github/artifact-retention.json index 83aa78d7f1d..15f797a15df 100644 --- a/.github/artifact-retention.json +++ b/.github/artifact-retention.json @@ -14,6 +14,7 @@ ], "max-reader": "plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py", "reason": "A FAIL verdict must outlive the 80-day FAIL look-back (babysit_verify_evidence.py TOO_OLD_DAYS = 80, ten days under 90): a shorter retention drops earlier FAILs, and the gate then stops refusing verify:earlier-fail and the FAIL cap. scripts/credential-sweep.sh also assumes 90-day artifacts. write-verdict.mjs retentionDays keeps FAIL at max, a PASS or PASS-NOTES judged after a body edit or issue link change since the head commit at max too (it may supersede a FAIL there, which the gate reads at later heads of the same change), and any other PASS or PASS-NOTES 7 days: it is read only at the open pull request's current head, and an expired one fails closed as verify:missing until the next pr-require-checks run (push, edit or label) has pr-verify judge the head again. The row records the longest.", + "scan": "upload", "computed-by": ".github/scripts/pr-verify/write-verdict.mjs", "uploads": [ ".github/workflows/pr-verify.yml#check-intent", @@ -28,6 +29,7 @@ ], "max-reader": "plugins/source-control/skills/babysit-prs/scripts/babysit_review_evidence.py", "reason": "A verdict that reported findings must outlive a later clean review of the same tree, or its expiry would lower the count the gate reads; the gate counts the most findings any unexpired verdict of the tree reported.", + "scan": "upload", "pending-cut": "About 238 a day, 21,400 at 90 days. CRITICAL counts already carry on the record job's check runs (babysit_merge.py carried_critical), but the below-CRITICAL total security_review_clean reads does not; a short window needs that total read from the check runs first.", "uploads": [ ".github/workflows/pr-review-main.yml#review", @@ -42,7 +44,8 @@ "plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py _lane_reruns reads records of runs created within confirm-wait-minutes plus FULL_RUN_ALLOWANCE", ".github/scripts/pr-merge/lane-records.mjs carries the records the post-merge check proved, each only until its artifact's expires_at, so the carry adds no lifetime" ], - "reason": "The look-back is merged_since, the later of the enabling commit and the newest demotion clear, with no bound in days. It stops at the post-merge check's 1000-run listing cap (babysit_gh.py RUN_SEARCH_CAP): past that the check is unreadable and holds the lane whatever this row says. At the 48 and 57 dispatch runs in the two days to 2026-10-31T05:00Z, after the 30-minute cap and its bypass landed, that is about 19 days, and demotion clears had come at most 4.8 days apart; 30 days leaves about 11 more for a stopped check, whose runs then accumulate only at the human merge rate. This holds while the listing reaches 1000 runs, or a clear moves the floor, within 30 days: at least 34 runs a day. A record that has left the listing never verifies a merge: its run stops counting as the lane's, so the merge holds as an unlisted record until a later recorded run covers it, and a flaked or in-between red run can read red and demote." + "reason": "The look-back is merged_since, the later of the enabling commit and the newest demotion clear, with no bound in days. It stops at the post-merge check's 1000-run listing cap (babysit_gh.py RUN_SEARCH_CAP): past that the check is unreadable and holds the lane whatever this row says. At the 48 and 57 dispatch runs in the two days to 2026-10-31T05:00Z, after the 30-minute cap and its bypass landed, that is about 19 days, and demotion clears had come at most 4.8 days apart; 30 days leaves about 11 more for a stopped check, whose runs then accumulate only at the human merge rate. This holds while the listing reaches 1000 runs, or a clear moves the floor, within 30 days: at least 34 runs a day. A record that has left the listing never verifies a merge: its run stops counting as the lane's, so the merge holds as an unlisted record until a later recorded run covers it, and a flaked or in-between red run can read red and demote.", + "scan": "upload" }, "pr-merge-rerun-*": { "retention-days": "max", @@ -51,14 +54,16 @@ ".github/scripts/pr-merge/lane-records.mjs keeps the records the post-merge check proved" ], "max-reader": "plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py", - "reason": "The post-merge check reads the lane's re-run record back to merged_since, only while the run's pr-merge-dispatched record exists; keep this at or above that family's days." + "reason": "The post-merge check reads the lane's re-run record back to merged_since, only while the run's pr-merge-dispatched record exists; keep this at or above that family's days.", + "scan": "upload" }, "pr-merge-alerted-*": { "retention-days": 1, "readers": [ "plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py LANE_ALERT_ARTIFACT (`--lane-alerted`), asked by .github/workflows/pr-merge.yml post-merge-dispatch only for full runs created within LOOKBACK_HOURS (6)" ], - "reason": "Read back only for full runs created in the last 6 hours (pr-merge.yml LOOKBACK_HOURS), and each record is uploaded after its run was created; 1 day, the minimum, outlasts that window." + "reason": "Read back only for full runs created in the last 6 hours (pr-merge.yml LOOKBACK_HOURS), and each record is uploaded after its run was created; 1 day, the minimum, outlasts that window.", + "scan": "upload" }, "pr-merge-post-merge-report": { "retention-days": 1, @@ -66,42 +71,48 @@ ".github/workflows/pr-merge.yml later jobs of the same run", ".github/scripts/pr-merge/rotation.mjs the newest trusted run's copy" ], - "reason": "Read by its own run and the next run; 1 day is the minimum." + "reason": "Read by its own run and the next run; 1 day is the minimum.", + "scan": "upload" }, "pr-merge-gate-cursor": { "retention-days": 1, "readers": [ ".github/scripts/pr-merge/rotation.mjs CURSOR_ARTIFACT, from the newest trusted run" ], - "reason": "Read by the next run; 1 day is the minimum." + "reason": "Read by the next run; 1 day is the minimum.", + "scan": "upload" }, "pr-merge-verify-listing": { "retention-days": 1, "readers": [ ".github/scripts/pr-merge/listing-carry.mjs LISTING_ARTIFACT, from the newest trusted run" ], - "reason": "Read by the next run as its listing base; 1 day is the minimum." + "reason": "Read by the next run as its listing base; 1 day is the minimum.", + "scan": "upload" }, "pr-merge-head-refs": { "retention-days": 1, "readers": [ ".github/scripts/pr-merge/head-ref-map.mjs MAP_ARTIFACT, from the newest trusted run" ], - "reason": "Read by the next run; 1 day is the minimum." + "reason": "Read by the next run; 1 day is the minimum.", + "scan": "upload" }, "pr-merge-lane-records": { "retention-days": 1, "readers": [ ".github/scripts/pr-merge/lane-records.mjs RECORDS_ARTIFACT, from the newest trusted run" ], - "reason": "Read by the next run; 1 day is the minimum." + "reason": "Read by the next run; 1 day is the minimum.", + "scan": "upload" }, "pr-merge-run-cache": { "retention-days": 1, "readers": [ ".github/scripts/pr-merge/carry-run-cache.mjs CARRY_ARTIFACT, from the newest trusted run" ], - "reason": "Read by the next run; 1 day is the minimum." + "reason": "Read by the next run; 1 day is the minimum.", + "scan": "upload" }, "pr-merge-summary": { "retention-days": 1, @@ -109,6 +120,7 @@ ".github/workflows/pr-merge.yml a person reading the sweep's verdicts from the run" ], "reason": "No code reader; 1 day is the minimum.", + "scan": "upload", "pending-cut": "Under owner review (poteto)." }, "pr-merge-gate-rows": { @@ -117,28 +129,32 @@ ".github/workflows/pr-merge.yml gate-check job, same run", ".github/scripts/pr-merge/gate-check.mjs" ], - "reason": "Read by its own run; 1 day is the minimum." + "reason": "Read by its own run; 1 day is the minimum.", + "scan": "upload" }, "pr-merge-verify-answers": { "retention-days": 1, "readers": [ ".github/scripts/pr-merge/verify-answers.mjs ANSWERS_ARTIFACT, from the newest trusted run" ], - "reason": "Read by the next run; 1 day is the minimum." + "reason": "Read by the next run; 1 day is the minimum.", + "scan": "upload" }, "pr-merge-verify-run-cache": { "retention-days": 1, "readers": [ ".github/scripts/pr-merge/carry-run-cache.mjs VERIFY_ARTIFACT, from the newest trusted run" ], - "reason": "Read by the next run; 1 day is the minimum." + "reason": "Read by the next run; 1 day is the minimum.", + "scan": "upload" }, "lane-transcript-*": { "retention-days": 7, "readers": [ ".github/actions/upload-lane-transcript/README.md the transcript puller, a daily task outside CI that copies each sealed transcript to its sink" ], - "reason": "A recorded owner decision on the lane transcript store: CI keeps each encrypted transcript 7 days and a daily puller copies it to the sink, so a week of missed pulls loses nothing." + "reason": "A recorded owner decision on the lane transcript store: CI keeps each encrypted transcript 7 days and a daily puller copies it to the sink, so a week of missed pulls loses nothing.", + "scan": "plaintext" }, "skill-reply-*": { "retention-days": 7, @@ -147,6 +163,7 @@ ".github/workflows/pr-run-activity-read.yml a person reading act-turns.json denials by hand" ], "reason": "A person reads the denials by hand, and 1 day would lose them over a weekend; 7 days in both lanes is a recorded owner decision.", + "scan": "upload", "pending-cut": "3 days would still cover a weekend; changing the recorded owner decision needs the owner." }, "patch-*": { @@ -154,7 +171,8 @@ "readers": [ ".github/workflows/pr-run-activity-write.yml patch-attempt download, same run, possibly an earlier attempt" ], - "reason": "Every reader is in the same run; a manual re-run of the write job may read an earlier attempt, so 3 days covers a weekend." + "reason": "Every reader is in the same run; a manual re-run of the write job may read an earlier attempt, so 3 days covers a weekend.", + "scan": "upload" }, "feedback-replies-*": { "retention-days": 1, @@ -162,14 +180,16 @@ ".github/workflows/pr-run-activity-write.yml same run", ".github/workflows/pr-unblock.yml same run" ], - "reason": "Read by its own run; 1 day is the minimum." + "reason": "Read by its own run; 1 day is the minimum.", + "scan": "upload" }, "dependency-verdict-*": { "retention-days": 3, "readers": [ ".github/workflows/pr-run-activity-write.yml same run" ], - "reason": "Every reader is in the same run; the manual re-run of either write job alone (pr-run-activity-write.yml) reads it, so 3 days covers a weekend." + "reason": "Every reader is in the same run; the manual re-run of either write job alone (pr-run-activity-write.yml) reads it, so 3 days covers a weekend.", + "scan": "upload" }, "work-outcome-*": { "retention-days": 1, @@ -177,7 +197,8 @@ ".github/workflows/pr-run-activity-write.yml same run", ".github/workflows/work-resolve-issue.yml same run" ], - "reason": "Read by its own run; 1 day is the minimum." + "reason": "Read by its own run; 1 day is the minimum.", + "scan": "upload" }, "verdict-*": { "retention-days": 1, @@ -185,7 +206,8 @@ ".github/workflows/pr-run-activity-read.yml same run", ".github/workflows/pr-run-activity-write.yml same run" ], - "reason": "Read by its own run; 1 day is the minimum." + "reason": "Read by its own run; 1 day is the minimum.", + "scan": "upload" }, "scan-refused-*": { "retention-days": 1, @@ -195,7 +217,8 @@ ".github/scripts/pr-unblock/address-feedback.mjs MARKER_FILE", ".github/scripts/work-resolve-issue/report-outcome.mjs the scan-refused marker" ], - "reason": "Read by its own run; 1 day is the minimum." + "reason": "Read by its own run; 1 day is the minimum. It holds one fixed word the workflow writes (patch, fault or output), never lane text, so it is not scanned.", + "scan": "none" }, "reuse-summary-*": { "retention-days": 1, @@ -203,28 +226,32 @@ ".github/workflows/pr-run-activity-write.yml same run", ".github/scripts/work-resolve-issue/report-outcome.mjs REUSE_SUMMARY_DIR" ], - "reason": "Read by its own run; 1 day is the minimum." + "reason": "Read by its own run; 1 day is the minimum.", + "scan": "upload" }, "refine-context-*": { "retention-days": 1, "readers": [ ".github/workflows/intake-triage.yml same run" ], - "reason": "Read by its own run; 1 day is the minimum." + "reason": "Read by its own run; 1 day is the minimum.", + "scan": "upload" }, "refinement-*": { "retention-days": 1, "readers": [ ".github/workflows/intake-triage.yml same run" ], - "reason": "Read by its own run; 1 day is the minimum." + "reason": "Read by its own run; 1 day is the minimum.", + "scan": "upload" }, "lane-sweep-units": { - "retention-days": 7, + "retention-days": 3, "readers": [ ".github/workflows/maintenance-audit-lane-runs.yml later jobs of the same run" ], - "reason": "Read only by its own run and that run's re-runs; no reason is recorded for 7 days." + "reason": "Read only by its own run and that run's re-runs; 3 days covers a manual re-run over a weekend, as for findings-*.", + "scan": "upload" }, "judge-handoff-*": { "retention-days": 1, @@ -232,7 +259,8 @@ ".github/workflows/maintenance-audit-lane-runs.yml judge-handoffs job, same run", ".github/scripts/maintenance-audit-lane-runs/handoff.mjs PREFIX" ], - "reason": "Read by its own run; 1 day is the minimum." + "reason": "Read by its own run; 1 day is the minimum.", + "scan": "upload" }, "judge-subscription-handoff-*": { "retention-days": 1, @@ -240,42 +268,48 @@ ".github/workflows/maintenance-audit-lane-runs.yml judge-subscription-handoffs job, same run", ".github/scripts/maintenance-audit-lane-runs/handoff.mjs PREFIX" ], - "reason": "Read by its own run; 1 day is the minimum." + "reason": "Read by its own run; 1 day is the minimum.", + "scan": "upload" }, "findings-*": { "retention-days": 3, "readers": [ ".github/workflows/maintenance-audit-lane-runs.yml findings-* download by the file job, same run" ], - "reason": "Only its own run reads it (the file job downloads findings-*); a manual re-run of the file job may read an earlier attempt, so 3 days covers a weekend, as for patch-*." + "reason": "Only its own run reads it (the file job downloads findings-*); a manual re-run of the file job may read an earlier attempt, so 3 days covers a weekend, as for patch-*.", + "scan": "upload" }, "mutation-range": { "retention-days": 7, "readers": [ ".github/workflows/post-merge-test-mutation.yml downloads it from the triggering run on workflow_run" ], - "reason": "Read by the run its workflow_run event starts; 7 days covers a manual re-run of that run." + "reason": "Read by the run its workflow_run event starts; 7 days covers a manual re-run of that run.", + "scan": "none" }, "selection-audit-*": { "retention-days": 7, "readers": [ ".github/workflows/maintenance-audit-test-selection.yml a person reading the trace report" ], - "reason": "Manual inspection only; no workflow reads it after the run." + "reason": "Manual inspection only; no workflow reads it after the run.", + "scan": "none" }, "selection-red-replay-*": { "retention-days": 7, "readers": [ ".github/workflows/maintenance-audit-test-selection.yml a person reading the replay report" ], - "reason": "Manual inspection only; no workflow reads it after the run." + "reason": "Manual inspection only; no workflow reads it after the run.", + "scan": "none" }, "branch-cleanup-candidates-*": { "retention-days": 1, "readers": [ ".github/workflows/maintenance-delete-stale-branches.yml delete job, same run" ], - "reason": "Read by its own run; 1 day is the minimum." + "reason": "Read by its own run; 1 day is the minimum.", + "scan": "none" }, "branch-cleanup-ledger-*": { "retention-days": "max", @@ -283,7 +317,8 @@ ".github/workflows/maintenance-delete-stale-branches.yml a person restoring a deleted branch from its ledger SHA, as the workflow header says" ], "max-reader": ".github/workflows/maintenance-delete-stale-branches.yml", - "reason": "The ledger is the only record of a deleted branch's SHA, so it is kept as long as a person may need to restore the branch." + "reason": "The ledger is the only record of a deleted branch's SHA, so it is kept as long as a person may need to restore the branch.", + "scan": "none" }, "reconcile-rerun-*": { "retention-days": 1, @@ -292,6 +327,7 @@ ".github/scripts/maintenance-reconcile-lost-runs/reconcile.mjs RECORD" ], "reason": "Read by the re-run it records; 1 day is the minimum.", + "scan": "upload", "uploads": [ ".github/workflows/maintenance-reconcile-lost-runs.yml#reconcile" ] @@ -301,7 +337,8 @@ "readers": [ ".github/scripts/maintenance-reconcile-lost-runs/reconcile.mjs SETTLED_ARTIFACT, from the newest trusted run" ], - "reason": "Read by the next pass; 1 day is the minimum." + "reason": "Read by the next pass; 1 day is the minimum.", + "scan": "upload" }, "pr-feedback-dispatched-*": { "retention-days": 1, @@ -309,6 +346,7 @@ ".github/actions/check-trusted-trigger/check-trusted-trigger.mjs RUN_RECORD_NAME, the dispatched run's trust check" ], "reason": "Read by the run it dispatched; 1 day is the minimum.", + "scan": "upload", "uploads": [ ".github/workflows/pr-feedback-dispatch.yml#dispatch" ] @@ -319,6 +357,7 @@ ".github/scripts/pr-unblock/feedback-dispatch.mjs recoverDeferred reads it by exact name (DEFERRED_RECORD) and sends the deferred verdict's round" ], "reason": "Read by the next half-hourly tick; 1 day is the minimum.", + "scan": "upload", "uploads": [ ".github/workflows/pr-feedback-dispatch.yml#post-verify-notes" ] @@ -329,6 +368,7 @@ ".github/actions/check-trusted-trigger/check-trusted-trigger.mjs ciStatusRerunRecordName, the re-run's trust check" ], "reason": "Read by the re-run it records; 1 day is the minimum.", + "scan": "upload", "uploads": [ ".github/workflows/pr-feedback-dispatch.yml#rerun-ci-status" ] @@ -339,6 +379,7 @@ ".github/actions/check-trusted-trigger/check-trusted-trigger.mjs RECORD_NAME, the dispatched run's trust check" ], "reason": "Read by the run it dispatched; 1 day is the minimum.", + "scan": "upload", "uploads": [ ".github/workflows/pr-unblock-dispatch.yml#dispatch" ] @@ -349,14 +390,16 @@ ".github/workflows/pr-run-activity-read.yml same run", ".github/workflows/pr-run-activity-write.yml same run, across attempts" ], - "reason": "Read by its own run; 1 day is the minimum." + "reason": "Read by its own run; 1 day is the minimum.", + "scan": "upload" }, "feedback-adjudication-*": { "retention-days": 1, "readers": [ ".github/workflows/pr-run-activity-write.yml same run" ], - "reason": "Read by its own run; 1 day is the minimum." + "reason": "Read by its own run; 1 day is the minimum.", + "scan": "upload" }, "dependency-context-*": { "retention-days": 1, @@ -364,21 +407,24 @@ ".github/workflows/pr-run-activity-read.yml same run", ".github/workflows/pr-run-activity-write.yml same run" ], - "reason": "Read by its own run; 1 day is the minimum." + "reason": "Read by its own run; 1 day is the minimum.", + "scan": "upload" }, "ci-context-*": { "retention-days": 1, "readers": [ ".github/workflows/pr-run-activity-read.yml same run" ], - "reason": "Read by its own run; 1 day is the minimum." + "reason": "Read by its own run; 1 day is the minimum.", + "scan": "upload" }, "eval-browser-*": { - "retention-days": 7, + "retention-days": 3, "readers": [ ".github/workflows/eval-browser-dispatch.yml a person reading a manual eval run's results" ], - "reason": "Manual inspection only; no workflow reads it after the run." + "reason": "Manual inspection only; no workflow reads it after the run. A person reads a dispatched eval's results soon after starting it, and 3 days covers a weekend.", + "scan": "upload" } } } diff --git a/.github/artifact-retention.schema.json b/.github/artifact-retention.schema.json index 3a6d617371b..0c8dc529cd5 100644 --- a/.github/artifact-retention.schema.json +++ b/.github/artifact-retention.schema.json @@ -22,7 +22,7 @@ "family": { "type": "object", "additionalProperties": false, - "required": ["retention-days", "readers", "reason"], + "required": ["retention-days", "readers", "reason", "scan"], "properties": { "retention-days": { "description": "Days an upload keeps the artifact: 1 to 89, or `max` for the repository maximum of 90, which needs a max-reader.", @@ -43,6 +43,10 @@ "minLength": 1 }, "reason": { "type": "string", "minLength": 1 }, + "scan": { + "description": "Whether the family is a lane or eval artifact that a leak scan (lib/publish-gate.mjs scanLeak) clears before it leaves the run. `upload`: each upload step follows a .github/actions/scan-upload step over the same path, whose clean output its `if:` requires, or reads from the read runner's leak-scan output directory. `plaintext`: the upload is sealed ciphertext, so a scan step over the plaintext comes earlier in the same steps. `none`: not a lane or eval artifact; the reason says why.", + "enum": ["upload", "plaintext", "none"] + }, "computed-by": { "description": "The script whose step output sets each upload's retention-days as `${{ steps..outputs.retention-days }}`; retention-days records the longest it returns.", "type": "string", diff --git a/.github/workflows/eval-browser-dispatch.yml b/.github/workflows/eval-browser-dispatch.yml index 0aa76adc2af..015d1d5d99e 100644 --- a/.github/workflows/eval-browser-dispatch.yml +++ b/.github/workflows/eval-browser-dispatch.yml @@ -676,11 +676,20 @@ jobs: # Only a dispatched run, whose cases merged through review, uploads its # results: a pull request's prompts could steer model output into a # public artifact. + - name: Scan the upload for credentials + id: scan-upload + if: >- + always() && steps.stage.outputs.go == 'true' && github.event_name == 'workflow_dispatch' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/eval-results/ + - name: Upload the eval results - if: always() && steps.stage.outputs.go == 'true' && github.event_name == 'workflow_dispatch' + if: >- + (always() && steps.stage.outputs.go == 'true' && github.event_name == 'workflow_dispatch') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: eval-browser-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/eval-results/ if-no-files-found: warn - retention-days: 7 + retention-days: 3 diff --git a/.github/workflows/intake-triage.yml b/.github/workflows/intake-triage.yml index 54ce46c3d10..6fcb542ee71 100644 --- a/.github/workflows/intake-triage.yml +++ b/.github/workflows/intake-triage.yml @@ -678,8 +678,17 @@ jobs: if: steps.select.outputs.refine == 'true' run: echo "sha256=$(sha256sum "$RUNNER_TEMP/refine-context/context.json" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.select.outputs.refine == 'true' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/refine-context/context.json + - name: Upload the context - if: steps.select.outputs.refine == 'true' + if: >- + (steps.select.outputs.refine == 'true') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: refine-context-${{ github.run_attempt }} @@ -1005,9 +1014,18 @@ jobs: '{version: 1, issue: $issue, output: ($output | try fromjson catch null)}' \ > "$RUNNER_TEMP/refinement/refinement.json" + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.collect.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/refinement/refinement.json + - name: Upload the refinement id: upload - if: steps.collect.outcome == 'success' + if: >- + (steps.collect.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: refinement-${{ github.run_attempt }} @@ -1297,9 +1315,18 @@ jobs: '{version: 1, issue: $issue, output: ($output | try fromjson catch null)}' \ > "$RUNNER_TEMP/refinement/refinement.json" + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.collect.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/refinement/refinement.json + - name: Upload the refinement id: upload - if: steps.collect.outcome == 'success' + if: >- + (steps.collect.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: refinement-${{ github.run_attempt }} diff --git a/.github/workflows/maintenance-audit-lane-runs.yml b/.github/workflows/maintenance-audit-lane-runs.yml index a46b6afdcaa..ab14d594ec0 100644 --- a/.github/workflows/maintenance-audit-lane-runs.yml +++ b/.github/workflows/maintenance-audit-lane-runs.yml @@ -111,14 +111,23 @@ jobs: WINDOW_HOURS: "24" run: node .base/.github/scripts/maintenance-audit-lane-runs/collect.mjs + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.collect.outcome == 'success' && steps.collect.outputs.units != '[]' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/lane-sweep + - name: Upload the units - if: steps.collect.outcome == 'success' && steps.collect.outputs.units != '[]' + if: >- + (steps.collect.outcome == 'success' && steps.collect.outputs.units != '[]') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: lane-sweep-units path: ${{ runner.temp }}/lane-sweep if-no-files-found: error - retention-days: 7 + retention-days: 3 - name: Log the API budget at the end if: always() @@ -341,8 +350,17 @@ jobs: HANDOFF_DIR: ${{ runner.temp }}/lane-sweep-handoff run: node .base/.github/scripts/maintenance-audit-lane-runs/handoff.mjs record + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.handoff.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/lane-sweep-handoff/judge-handoff-${{ matrix.unit }}.json + - name: Upload the handoff - if: steps.handoff.outcome == 'success' + if: >- + (steps.handoff.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: judge-handoff-${{ matrix.unit }} @@ -377,8 +395,17 @@ jobs: FINDINGS_PATH: ${{ runner.temp }}/lane-sweep-findings/findings.json run: node .base/.github/scripts/maintenance-audit-lane-runs/write-findings.mjs + - name: Scan the upload for credentials + id: scan-upload-2 + if: >- + steps.findings.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/lane-sweep-findings/findings.json + - name: Upload the findings - if: steps.findings.outcome == 'success' + if: >- + (steps.findings.outcome == 'success') && steps.scan-upload-2.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: findings-${{ matrix.unit }} @@ -665,8 +692,17 @@ jobs: HANDOFF_DIR: ${{ runner.temp }}/lane-sweep-handoff run: node .base/.github/scripts/maintenance-audit-lane-runs/handoff.mjs record + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.handoff.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/lane-sweep-handoff/judge-subscription-handoff-${{ matrix.unit }}.json + - name: Upload the handoff - if: steps.handoff.outcome == 'success' + if: >- + (steps.handoff.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: judge-subscription-handoff-${{ matrix.unit }} @@ -703,8 +739,17 @@ jobs: FINDINGS_PATH: ${{ runner.temp }}/lane-sweep-findings/findings.json run: node .base/.github/scripts/maintenance-audit-lane-runs/write-findings.mjs + - name: Scan the upload for credentials + id: scan-upload-2 + if: >- + steps.findings.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/lane-sweep-findings/findings.json + - name: Upload the findings - if: steps.findings.outcome == 'success' + if: >- + (steps.findings.outcome == 'success') && steps.scan-upload-2.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: findings-${{ matrix.unit }} @@ -981,8 +1026,17 @@ jobs: FINDINGS_PATH: ${{ runner.temp }}/lane-sweep-findings/findings.json run: node .base/.github/scripts/maintenance-audit-lane-runs/write-findings.mjs + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.findings.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/lane-sweep-findings/findings.json + - name: Upload the findings - if: steps.findings.outcome == 'success' + if: >- + (steps.findings.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: findings-${{ matrix.unit }} diff --git a/.github/workflows/maintenance-reconcile-lost-runs.yml b/.github/workflows/maintenance-reconcile-lost-runs.yml index f0f7b28986d..886d1bf7ac6 100644 --- a/.github/workflows/maintenance-reconcile-lost-runs.yml +++ b/.github/workflows/maintenance-reconcile-lost-runs.yml @@ -182,8 +182,17 @@ jobs: # One record per pr-verify or pr-review-main run, per pr-require-checks run made attempt # 2, and per stopped hosted review run the act step re-runs, uploaded # first. A failed upload fails the job, so that run is not re-run. + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.plan.outputs.record-1 != '' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/reconcile-record/record.json + - name: Record re-run 1 - if: steps.plan.outputs.record-1 != '' + if: >- + (steps.plan.outputs.record-1 != '') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.plan.outputs.record-1 }} @@ -191,8 +200,17 @@ jobs: if-no-files-found: error retention-days: 1 + - name: Scan the upload for credentials + id: scan-upload-2 + if: >- + steps.plan.outputs.record-2 != '' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/reconcile-record/record.json + - name: Record re-run 2 - if: steps.plan.outputs.record-2 != '' + if: >- + (steps.plan.outputs.record-2 != '') && steps.scan-upload-2.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.plan.outputs.record-2 }} @@ -200,8 +218,17 @@ jobs: if-no-files-found: error retention-days: 1 + - name: Scan the upload for credentials + id: scan-upload-3 + if: >- + steps.plan.outputs.record-3 != '' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/reconcile-record/record.json + - name: Record re-run 3 - if: steps.plan.outputs.record-3 != '' + if: >- + (steps.plan.outputs.record-3 != '') && steps.scan-upload-3.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.plan.outputs.record-3 }} @@ -221,8 +248,17 @@ jobs: # The runs this pass settled for good, which the next pass skips # (reconcile.mjs SETTLED_ARTIFACT). A missing upload costs the next pass # a full read, never a re-run. + - name: Scan the upload for credentials + id: scan-upload-4 + if: >- + ${{ !cancelled() && steps.plan.outcome == 'success' }} + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/reconcile-settled/settled.json + - name: Carry the settled runs - if: ${{ !cancelled() && steps.plan.outcome == 'success' }} + if: >- + (${{ !cancelled() && steps.plan.outcome == 'success' }}) && steps.scan-upload-4.outputs.clean == 'true' continue-on-error: true uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/.github/workflows/pr-feedback-dispatch.yml b/.github/workflows/pr-feedback-dispatch.yml index ccd3eb04254..ef4c8b6e30b 100644 --- a/.github/workflows/pr-feedback-dispatch.yml +++ b/.github/workflows/pr-feedback-dispatch.yml @@ -244,8 +244,17 @@ jobs: echo "$head" >"$RUNNER_TEMP/pr-feedback-deferred.txt" echo "name=pr-feedback-deferred-$head-$verdict" >>"$GITHUB_OUTPUT" + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.deferred.outputs.name != '' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/pr-feedback-deferred.txt + - name: Record the deferred verdict - if: steps.deferred.outputs.name != '' + if: >- + (steps.deferred.outputs.name != '') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.deferred.outputs.name }} @@ -454,10 +463,17 @@ jobs: "$GITHUB_RUN_ID" "$UPSTREAM_RUN_ID" "$RUN_ID" >"$RUNNER_TEMP/pr-feedback-dispatched.json" echo "name=pr-feedback-dispatched-$UPSTREAM_RUN_ID-$RUN_ID" >>"$GITHUB_OUTPUT" + - name: Scan the upload for credentials + id: scan-upload + if: >- + always() && steps.dispatch.outputs.run-id != '' && steps.record.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/pr-feedback-dispatched.json + - name: Record the dispatched run if: >- - always() && steps.dispatch.outputs.run-id != '' - && steps.record.outcome == 'success' + (always() && steps.dispatch.outputs.run-id != '' && steps.record.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.record.outputs.name }} @@ -570,6 +586,8 @@ jobs: /.github/actions/check-trusted-trigger/ /.github/actions/check-kill-switch/ /.github/actions/setup-pinned-node/ + /.github/actions/scan-upload/ + /lib/publish-gate.mjs /.node-version sparse-checkout-cone-mode: false @@ -882,8 +900,17 @@ jobs: # Uploaded last before the re-run, so the record names the attempt the # re-run starts; a re-run someone else lands in between takes that # attempt, and this one's next attempt has no record and is refused. + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.find.outputs.record != '' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/rerun-ci-status-record.json + - name: Record the re-run - if: steps.find.outputs.record != '' + if: >- + (steps.find.outputs.record != '') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.find.outputs.record }} diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index 4a3e16d09a0..5858cb86426 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -293,8 +293,17 @@ jobs: # neither runs the check again then. They download it from this run # only. The next run's post-merge-dispatch reads it too (the step # above), and reuses it while it is younger than SHARED_MAX_AGE. + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.post-merge.conclusion == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/post-merge-report/ + - name: Keep the post-merge report for enable-auto-merge - if: steps.post-merge.conclusion == 'success' + if: >- + (steps.post-merge.conclusion == 'success') && steps.scan-upload.outputs.clean == 'true' continue-on-error: true timeout-minutes: 2 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -514,8 +523,17 @@ jobs: echo "{\"dispatcher_run_id\":$GITHUB_RUN_ID,\"run_id\":$id}" >"$RUNNER_TEMP/dispatched.json" echo "run-id=$id" >>"$GITHUB_OUTPUT" + - name: Scan the upload for credentials + id: scan-upload-2 + if: >- + steps.dispatch.outputs.run-id != '' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/dispatched.json + - name: Record the dispatched run - if: steps.dispatch.outputs.run-id != '' + if: >- + (steps.dispatch.outputs.run-id != '') && steps.scan-upload-2.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-merge-dispatched-${{ steps.dispatch.outputs.run-id }} @@ -621,8 +639,17 @@ jobs: echo "{\"rerunner_run_id\":$GITHUB_RUN_ID,\"run_id\":$RUN_ID,\"attempt\":2}" >"$RUNNER_TEMP/rerun.json" echo "recorded=true" >>"$GITHUB_OUTPUT" + - name: Scan the upload for credentials + id: scan-upload-3 + if: >- + !cancelled() && steps.rerun.outputs.recorded == 'true' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/rerun.json + - name: Record the re-run - if: "!cancelled() && steps.rerun.outputs.recorded == 'true'" + if: >- + (!cancelled() && steps.rerun.outputs.recorded == 'true') && steps.scan-upload-3.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-merge-rerun-${{ steps.post-merge.outputs.lane-rerun }}-attempt-2 @@ -757,8 +784,17 @@ jobs: exit 0 done <<<"$runs" + - name: Scan the upload for credentials + id: scan-upload-4 + if: >- + !cancelled() && steps.alert.outputs.run-id != '' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/alerted.json + - name: Record the alert sent - if: "!cancelled() && steps.alert.outputs.run-id != ''" + if: >- + (!cancelled() && steps.alert.outputs.run-id != '') && steps.scan-upload-4.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-merge-alerted-${{ steps.alert.outputs.run-id }} @@ -1622,8 +1658,17 @@ jobs: # The next run's starting point (rotation.mjs reads it), uploaded even # when the job is cancelled, since cursor.json is rewritten after each # candidate. + - name: Scan the upload for credentials + id: scan-upload + if: >- + always() && steps.gate.outcome != 'skipped' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/gate-cursor/cursor.json + - name: Record where the gate stopped - if: always() && steps.gate.outcome != 'skipped' + if: >- + (always() && steps.gate.outcome != 'skipped') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-merge-gate-cursor @@ -1652,8 +1697,17 @@ jobs: mkdir -p "${HEAD_REFS_OUT%/*}" node .base/.github/scripts/pr-merge/head-ref-map.mjs write + - name: Scan the upload for credentials + id: scan-upload-2 + if: >- + always() && steps.head-refs.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/head-refs/head-refs.json + - name: Upload them for the next run - if: always() && steps.head-refs.outcome == 'success' + if: >- + (always() && steps.head-refs.outcome == 'success') && steps.scan-upload-2.outputs.clean == 'true' continue-on-error: true uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: @@ -1681,8 +1735,17 @@ jobs: mkdir -p "${LANE_RECORDS_OUT%/*}" node .base/.github/scripts/pr-merge/lane-records.mjs write + - name: Scan the upload for credentials + id: scan-upload-3 + if: >- + always() && steps.lane-records.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/lane-records/lane-records.json + - name: Upload the lane records for the next run - if: always() && steps.lane-records.outcome == 'success' + if: >- + (always() && steps.lane-records.outcome == 'success') && steps.scan-upload-3.outputs.clean == 'true' continue-on-error: true uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: @@ -1717,8 +1780,17 @@ jobs: --keys "$RUNNER_TEMP/skip-keys.json" --summary "$RUNNER_TEMP/pr-merge-summary.json" \ --outputs-dir "$RUNNER_TEMP" --store "$RUNNER_TEMP/pr-merge-carried.json" --run-id "$GITHUB_RUN_ID" + - name: Scan the upload for credentials + id: scan-upload-4 + if: >- + always() && steps.carry.outputs.carried == 'true' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/pr-merge-carried.json + - name: Upload the verdict reads for the next run - if: always() && steps.carry.outputs.carried == 'true' + if: >- + (always() && steps.carry.outputs.carried == 'true') && steps.scan-upload-4.outputs.clean == 'true' continue-on-error: true uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: @@ -1805,8 +1877,17 @@ jobs: node .base/.github/scripts/pr-merge/sweep-summary.mjs jq -r '"refusal" + "-counts=" + (.refusal_counts | tojson)' "$SWEEP_SUMMARY_TO" + - name: Scan the upload for credentials + id: scan-upload-5 + if: >- + always() && (steps.gate.outcome != 'skipped' || steps.candidates.outputs.held == 'true') + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/pr-merge-sweep.json + - name: Upload the sweep's verdicts - if: always() && (steps.gate.outcome != 'skipped' || steps.candidates.outputs.held == 'true') + if: >- + (always() && (steps.gate.outcome != 'skipped' || steps.candidates.outputs.held == 'true')) && steps.scan-upload-5.outputs.clean == 'true' continue-on-error: true timeout-minutes: 2 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -1819,11 +1900,18 @@ jobs: # The gate-check job below writes each candidate's check run from these # rows. Not while the lane is report-only, nor after the gate's # rate-limit stop: that job's own stop file would not hold it. + - name: Scan the upload for credentials + id: scan-upload-6 + if: >- + always() && steps.gate.outcome != 'skipped' && steps.mode.outputs.report-only == 'false' && steps.gate.outputs.rate-limited != 'true' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/pr-merge-summary.json + - name: Hand the gate's rows to the check writer id: rows if: >- - always() && steps.gate.outcome != 'skipped' && steps.mode.outputs.report-only == 'false' - && steps.gate.outputs.rate-limited != 'true' + (always() && steps.gate.outcome != 'skipped' && steps.mode.outputs.report-only == 'false' && steps.gate.outputs.rate-limited != 'true') && steps.scan-upload-6.outputs.clean == 'true' continue-on-error: true timeout-minutes: 2 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -1922,10 +2010,17 @@ jobs: # the pages since a base, holding GitHub's live count). Only a # scheduled or hand-started run's file is read back, so only one # uploads it. Rules: .github/scripts/pr-merge/listing-carry.mjs. + - name: Scan the upload for credentials + id: scan-upload-7 + if: >- + always() && steps.gate.outcome != 'skipped' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/verify-listing.json + - name: Keep the artifact listing for the next run if: >- - always() && steps.gate.outcome != 'skipped' - && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + (always() && steps.gate.outcome != 'skipped' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) && steps.scan-upload-7.outputs.clean == 'true' continue-on-error: true uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: @@ -2330,10 +2425,19 @@ jobs: # each to expire within the hour, with the arm step's skip records. A # file name of its own: the merge job uploads pr-merge-head-refs. Only a # scheduled or hand-started run's file is read back. + - name: Scan the upload for credentials + id: scan-upload + if: >- + always() && steps.arm.outcome != 'skipped' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + uses: ./.base/.github/actions/scan-upload + with: + path: | + ${{ runner.temp }}/verify-answers/verify-answers.json + ${{ runner.temp }}/verify-answers/eam-skip.json + - name: Upload the verifier answers for the next run if: >- - always() && steps.arm.outcome != 'skipped' - && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + (always() && steps.arm.outcome != 'skipped' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) && steps.scan-upload.outputs.clean == 'true' continue-on-error: true timeout-minutes: 2 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -2364,8 +2468,17 @@ jobs: CARRY_TO: ${{ runner.temp }}/pr-merge-carried.json run: node .base/.github/scripts/pr-merge/carry-run-cache.mjs + - name: Scan the upload for credentials + id: scan-upload-2 + if: >- + always() && steps.carry.outputs.kept == 'true' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/pr-merge-carried.json + - name: Upload the verdict reads for the next run - if: always() && steps.carry.outputs.kept == 'true' + if: >- + (always() && steps.carry.outputs.kept == 'true') && steps.scan-upload-2.outputs.clean == 'true' continue-on-error: true uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: @@ -2509,6 +2622,8 @@ jobs: persist-credentials: false sparse-checkout: | /.github/actions/log-api-budget/ + /.github/actions/scan-upload/ + /lib/publish-gate.mjs sparse-checkout-cone-mode: false # At most one full run of main per CAP_MINUTES, each of main's tip, @@ -2683,8 +2798,17 @@ jobs: # The post-merge check counts a full run started by github-actions[bot] # only when a pr-merge.yml run on main uploaded an artifact naming it. # Only this run's own steps can upload this run's artifacts. + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.dispatch.outputs.run-id != '' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/dispatched.json + - name: Record the dispatched run - if: steps.dispatch.outputs.run-id != '' + if: >- + (steps.dispatch.outputs.run-id != '') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-merge-dispatched-${{ steps.dispatch.outputs.run-id }} diff --git a/.github/workflows/pr-review-main.yml b/.github/workflows/pr-review-main.yml index 5ec0faa7230..b13532af7be 100644 --- a/.github/workflows/pr-review-main.yml +++ b/.github/workflows/pr-review-main.yml @@ -768,8 +768,17 @@ jobs: echo "::notice title=pr-review-main stop::reason=chunk-failed lane=$LANE pr=$PR_NUMBER; a chunk's review left no verdict, so no verdict binds and the lanes stay unbound" { echo "$LANE-stop=chunk-failed"; echo "$LANE-head=$HEAD_SHA"; } >>"$GITHUB_OUTPUT" + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.verdict.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/pr-review-main-verdict/verdict.json + - name: Upload the verdict - if: steps.verdict.outcome == 'success' + if: >- + (steps.verdict.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.verdict.outputs.name }} @@ -1554,8 +1563,17 @@ jobs: echo "::notice title=pr-review-main stop::reason=chunk-failed lane=$LANE pr=$PR_NUMBER; a chunk's review left no verdict, so no verdict binds and the lanes stay unbound" { echo "$LANE-stop=chunk-failed"; echo "$LANE-head=$HEAD_SHA"; } >>"$GITHUB_OUTPUT" + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.verdict.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/pr-review-main-verdict/verdict.json + - name: Upload the verdict - if: steps.verdict.outcome == 'success' + if: >- + (steps.verdict.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.verdict.outputs.name }} @@ -2312,8 +2330,17 @@ jobs: echo "::notice title=pr-review-main stop::reason=chunk-failed lane=$LANE pr=$PR_NUMBER; a chunk's review left no verdict, so no verdict binds and the lanes stay unbound" { echo "$LANE-stop=chunk-failed"; echo "$LANE-head=$HEAD_SHA"; } >>"$GITHUB_OUTPUT" + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.verdict.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/pr-review-main-verdict/verdict.json + - name: Upload the verdict - if: steps.verdict.outcome == 'success' + if: >- + (steps.verdict.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.verdict.outputs.name }} diff --git a/.github/workflows/pr-run-activity-read.yml b/.github/workflows/pr-run-activity-read.yml index 823b011f8c0..ce939e3c021 100644 --- a/.github/workflows/pr-run-activity-read.yml +++ b/.github/workflows/pr-run-activity-read.yml @@ -2474,7 +2474,9 @@ jobs: persist-credentials: false sparse-checkout: | .github/actions/open-lane-output + .github/actions/scan-upload .github/lane-keys + lib - name: Read the scan-carrier key id id: key @@ -3039,7 +3041,14 @@ jobs: # Only after every step above succeeded: a run that stopped before head # code skipped the scan and still posts its skip; a scan hit leaves no # verdict, which the report job reads as a failure. + - name: Scan the upload for credentials + id: scan-upload + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/verdict/verdict.json + - name: Upload the verdict + if: steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: verdict-${{ inputs.lane }}-${{ inputs.activity }}-${{ github.run_attempt }}${{ inputs.model-rung == '1' && '-rung-up' || '' }} @@ -3339,8 +3348,17 @@ jobs: '{version: 1, issue: $issue, branch: $branch, tip: $tip, output: ($output | try fromjson catch null)}' \ >"$OUT_DIR/reuse-summary.json" + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.summary-collect.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/reuse-summary/reuse-summary.json + - name: Upload the reused branch's summary - if: steps.summary-collect.outcome == 'success' + if: >- + (steps.summary-collect.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' continue-on-error: true uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/.github/workflows/pr-run-activity-write.yml b/.github/workflows/pr-run-activity-write.yml index 56a956485b0..732bc2f09de 100644 --- a/.github/workflows/pr-run-activity-write.yml +++ b/.github/workflows/pr-run-activity-write.yml @@ -1829,8 +1829,19 @@ jobs: credential: ${{ steps.credential.outputs.name }} max-turns: ${{ steps.config.outputs.max-turns }} + - name: Scan the upload for credentials + id: scan-upload + if: >- + always() && (steps.act-skill.outcome == 'success' || steps.act-skill.outcome == 'failure') + uses: ./.base/.github/actions/scan-upload + with: + path: | + ${{ runner.temp }}/skill-reply/reply.txt + ${{ runner.temp }}/skill-reply/act-turns.json + - name: Upload the skill's final reply - if: always() && (steps.act-skill.outcome == 'success' || steps.act-skill.outcome == 'failure') + if: >- + (always() && (steps.act-skill.outcome == 'success' || steps.act-skill.outcome == 'failure')) && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: skill-reply-${{ inputs.lane }}-${{ inputs.activity }}-${{ github.run_attempt }} @@ -2165,10 +2176,19 @@ jobs: jq --arg branch "$BRANCH" '.branch = $branch' "$OUT_DIR/lane.json" >"$OUT_DIR/lane.json.new" mv "$OUT_DIR/lane.json.new" "$OUT_DIR/lane.json" + - name: Scan the upload for credentials + id: scan-upload-2 + if: >- + inputs.issue-number != '' && steps.config.outputs.prepare == 'apply-patch' && steps.act-script.outcome == 'success' && steps.act-script.outputs.branch != '' + uses: ./.base/.github/actions/scan-upload + with: + path: | + ${{ runner.temp }}/work-outcome/lane.json + ${{ runner.temp }}/work-outcome/work-outcome.json + - name: Upload the named issue outcome if: >- - inputs.issue-number != '' && steps.config.outputs.prepare == 'apply-patch' - && steps.act-script.outcome == 'success' && steps.act-script.outputs.branch != '' + (inputs.issue-number != '' && steps.config.outputs.prepare == 'apply-patch' && steps.act-script.outcome == 'success' && steps.act-script.outputs.branch != '') && steps.scan-upload-2.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: work-outcome-${{ inputs.issue-number }} @@ -2231,8 +2251,17 @@ jobs: "dirty-tree": null }' >"$VERDICT_DIR/verdict.json" + - name: Scan the upload for credentials + id: scan-upload-3 + if: >- + always() + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/verdict/verdict.json + - name: Upload the verdict - if: always() + if: >- + (always()) && steps.scan-upload-3.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: verdict-${{ inputs.lane }}-${{ inputs.activity }}-${{ github.run_attempt }}${{ inputs.patch-model-rung == '1' && '-rung-up' || '' }} diff --git a/.github/workflows/pr-unblock-dispatch.yml b/.github/workflows/pr-unblock-dispatch.yml index cc6fe349eab..4970f950854 100644 --- a/.github/workflows/pr-unblock-dispatch.yml +++ b/.github/workflows/pr-unblock-dispatch.yml @@ -287,10 +287,17 @@ jobs: >"$RUNNER_TEMP/pr-unblock-dispatched.json" echo "name=pr-unblock-dispatched-${RUN_IDS// /-}" >>"$GITHUB_OUTPUT" + - name: Scan the upload for credentials + id: scan-upload + if: >- + always() && steps.record.outputs.name != '' && steps.record.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/pr-unblock-dispatched.json + - name: Record the dispatched runs if: >- - always() && steps.record.outputs.name != '' - && steps.record.outcome == 'success' + (always() && steps.record.outputs.name != '' && steps.record.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.record.outputs.name }} diff --git a/.github/workflows/pr-unblock.yml b/.github/workflows/pr-unblock.yml index 716796158d4..caca36a50bf 100644 --- a/.github/workflows/pr-unblock.yml +++ b/.github/workflows/pr-unblock.yml @@ -846,9 +846,20 @@ jobs: done fi + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.select.outputs.proceed == 'true' || steps.select.outputs.resolve-only == 'true' + uses: ./.base/.github/actions/scan-upload + with: + path: | + ${{ runner.temp }}/feedback-context/threads.json + ${{ runner.temp }}/feedback-context/run.json + - name: Upload the feedback context id: upload - if: steps.select.outputs.proceed == 'true' || steps.select.outputs.resolve-only == 'true' + if: >- + (steps.select.outputs.proceed == 'true' || steps.select.outputs.resolve-only == 'true') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: feedback-context-${{ github.run_attempt }} @@ -1536,9 +1547,18 @@ jobs: # overwrite: false, so a copy the model job's head code uploaded first # fails this upload, and the reply job reads no verdicts. + - name: Scan the upload for credentials + id: scan-upload + if: >- + always() && steps.collect.outcome == 'success' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/adjudication/verdicts.json + - name: Upload the verdicts id: upload - if: always() && steps.collect.outcome == 'success' + if: >- + (always() && steps.collect.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: feedback-adjudication-${{ github.run_attempt }} @@ -1738,9 +1758,20 @@ jobs: echo "run-sha256=${sum%% *}" >>"$GITHUB_OUTPUT" fi + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.select.outputs.proceed == 'true' + uses: ./.base/.github/actions/scan-upload + with: + path: | + ${{ runner.temp }}/dependency-context/dependency.json + ${{ runner.temp }}/dependency-context/context.json + - name: Upload the dependency context id: upload - if: steps.select.outputs.proceed == 'true' + if: >- + (steps.select.outputs.proceed == 'true') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: dependency-context-${{ github.run_attempt }} @@ -2171,9 +2202,18 @@ jobs: export REVIEWER_IDS node .base/.github/scripts/pr-unblock/fix-ci-context.mjs + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.context.outputs.proceed == 'true' + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/ci-context/context.json + - name: Upload the context id: upload - if: steps.context.outputs.proceed == 'true' + if: >- + (steps.context.outputs.proceed == 'true') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ci-context-${{ github.run_attempt }} diff --git a/.github/workflows/pr-verify.yml b/.github/workflows/pr-verify.yml index e258339a1a5..70e88a75fc4 100644 --- a/.github/workflows/pr-verify.yml +++ b/.github/workflows/pr-verify.yml @@ -1846,8 +1846,17 @@ jobs: VERDICT_DIR: ${{ runner.temp }}/pr-verify-verdict run: node .base/.github/scripts/pr-verify/write-verdict.mjs + - name: Scan the upload for credentials + id: scan-upload + if: >- + ${{ !cancelled() && steps.verdict.outcome == 'success' }} + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/pr-verify-verdict/verdict.json + - name: Upload the verdict - if: ${{ !cancelled() && steps.verdict.outcome == 'success' }} + if: >- + (${{ !cancelled() && steps.verdict.outcome == 'success' }}) && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.verdict.outputs.name }} @@ -3623,8 +3632,17 @@ jobs: VERDICT_DIR: ${{ runner.temp }}/pr-verify-verdict run: node .base/.github/scripts/pr-verify/write-verdict.mjs + - name: Scan the upload for credentials + id: scan-upload + if: >- + ${{ !cancelled() && steps.verdict.outcome == 'success' }} + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/pr-verify-verdict/verdict.json + - name: Upload the verdict - if: ${{ !cancelled() && steps.verdict.outcome == 'success' }} + if: >- + (${{ !cancelled() && steps.verdict.outcome == 'success' }}) && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.verdict.outputs.name }} @@ -5146,8 +5164,17 @@ jobs: VERDICT_DIR: ${{ runner.temp }}/pr-verify-verdict run: node .base/.github/scripts/pr-verify/write-verdict.mjs + - name: Scan the upload for credentials + id: scan-upload + if: >- + ${{ !cancelled() && steps.verdict.outcome == 'success' }} + uses: ./.base/.github/actions/scan-upload + with: + path: ${{ runner.temp }}/pr-verify-verdict/verdict.json + - name: Upload the verdict - if: ${{ !cancelled() && steps.verdict.outcome == 'success' }} + if: >- + (${{ !cancelled() && steps.verdict.outcome == 'success' }}) && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.verdict.outputs.name }} diff --git a/scripts/check-artifact-retention.mjs b/scripts/check-artifact-retention.mjs index 22177678e7f..d51e24b0743 100644 --- a/scripts/check-artifact-retention.mjs +++ b/scripts/check-artifact-retention.mjs @@ -78,7 +78,20 @@ for (const [key, row] of Object.entries(families)) { } } -// Every upload step: { file, line, job, name, retention }. +// A leak-scan step earlier in the same steps: { id, path } for a +// .github/actions/scan-upload step or a run step calling its script, with the +// path it scans; { id: "leak-scan" } for the read runner's own scan. +const SCAN_ACTION = /(?:^|\/)\.github\/actions\/scan-upload$/; +function scanOf(step) { + const id = step.get("id"); + if (typeof id !== "string") return null; + if (id === "leak-scan") return { id }; + if (SCAN_ACTION.test(String(step.get("uses") ?? ""))) return { id, path: step.get("with", true)?.get?.("path") }; + if (/scan-upload\/scan-upload\.mjs/.test(String(step.get("run") ?? ""))) return { id, path: step.get("env", true)?.get?.("SCAN_PATHS") }; + return null; +} + +// Every upload step: { file, line, job, name, retention, path, condition, scans }. const steps = []; const lineCounterFor = new Map(); function collect(file, stepSeqs) { @@ -90,12 +103,16 @@ function collect(file, stepSeqs) { if (typeof uses !== "string" || !UPLOAD.test(uses)) continue; const withMap = step.get("with", true); const input = (key) => (yaml.isMap(withMap) ? withMap.get(key) : undefined); + const index = seq.items.indexOf(step); steps.push({ file, line: lineCounterFor.get(file).linePos(step.range[0]).line, job, name: input("name"), retention: input("retention-days"), + path: input("path"), + condition: String(step.get("if") ?? ""), + scans: seq.items.slice(0, index).filter(yaml.isMap).map(scanOf).filter(Boolean), }); } } @@ -167,6 +184,19 @@ for (const step of steps) { } const [key] = claimed; used.add(key); + const scan = families[key].scan; + const gated = (id) => step.condition.includes(`steps.${id}.outputs.clean == 'true'`); + if (scan === "upload") { + const viaLeakScan = step.scans.some((s) => s.id === "leak-scan") && String(step.path ?? "").includes("steps.leak-scan.outputs.dir") && gated("leak-scan"); + const viaScanStep = step.scans.some((s) => s.path !== undefined && String(s.path) === String(step.path ?? "") && gated(s.id)); + if (!viaLeakScan && !viaScanStep) { + findings.push( + `${label} has no scan before it; ${TABLE} family \`${key}\` says scan: upload, so a .github/actions/scan-upload step over the same path must come first and the upload's if: must require its clean output`, + ); + } + } else if (scan === "plaintext" && step.scans.length === 0) { + findings.push(`${label} has no scan step before it; ${TABLE} family \`${key}\` says scan: plaintext`); + } const want = families[key]["retention-days"]; const raw = step.retention; if (raw === undefined || raw === null || raw === "") { diff --git a/scripts/check-artifact-retention.sh b/scripts/check-artifact-retention.sh index a4ae1058b31..fc421f59d71 100755 --- a/scripts/check-artifact-retention.sh +++ b/scripts/check-artifact-retention.sh @@ -29,6 +29,14 @@ # steps read `${{ steps..outputs.retention-days }}`; the row's value records # the longest that script returns. # +# SCAN. Each row's `scan` says whether its uploads are lane or eval artifacts +# that lib/publish-gate.mjs scanLeak clears first. `upload`: each upload step +# follows a .github/actions/scan-upload step over the identical path and its +# `if:` requires that step's clean output, or it reads from the read runner's +# leak-scan output directory and requires leak-scan's clean output. +# `plaintext`: an earlier scan step in the same steps clears the plaintext the +# upload seals. `none`: no scan. A scanned upload with no such step is a finding. +# # The table is held to its own rules too: it must pass # .github/artifact-retention.schema.json; each reader and max-reader must start # with a path that exists in the repository; a `max` row must name a diff --git a/scripts/check-artifact-retention.test.sh b/scripts/check-artifact-retention.test.sh index 764a75d3f8b..81ad32b2479 100755 --- a/scripts/check-artifact-retention.test.sh +++ b/scripts/check-artifact-retention.test.sh @@ -46,24 +46,28 @@ mk_tree() { "build-report": { "retention-days": 1, "readers": [".github/workflows/build.yml publish job, same run"], - "reason": "Read only by its own run." + "reason": "Read only by its own run.", + "scan": "none" }, "lane-patch-*": { "retention-days": 7, "readers": [".github/workflows/build.yml publish job"], - "reason": "A manual re-run of the publish job reads it." + "reason": "A manual re-run of the publish job reads it.", + "scan": "upload" }, "verdict-*": { "retention-days": "max", "readers": [".github/workflows/build.yml gate job, look-back of 80 days"], "max-reader": ".github/workflows/build.yml", "reason": "The gate reads verdicts back to the pull request's creation.", + "scan": "none", "uploads": [".github/workflows/build.yml#verdict"] }, "pack-*": { "retention-days": 3, "readers": [".github/actions/pack/action.yml caller, same run"], "reason": "Read only by its own run.", + "scan": "plaintext", "pending-cut": "1 day is enough." } } @@ -80,7 +84,12 @@ jobs: name: build-report path: report.json retention-days: 1 - - uses: actions/upload-artifact@v7 + - id: scan-patch + uses: ./.github/actions/scan-upload + with: + path: patch.diff + - if: steps.scan-patch.outputs.clean == 'true' + uses: actions/upload-artifact@v7 with: name: lane-patch-${{ github.run_attempt }} path: patch.diff @@ -100,6 +109,11 @@ EOF runs: using: composite steps: + - id: scan + shell: bash + env: + SCAN_PATHS: plain.json + run: node "$GITHUB_ACTION_PATH/../scan-upload/scan-upload.mjs" - uses: actions/upload-artifact@v7 with: name: pack-${{ github.run_id }} @@ -171,7 +185,7 @@ run_case "a name matching no family fails" 1 \ run_case "a value differing from the table fails" 1 \ "subst $WORKFLOW 'retention-days: 7' 'retention-days: 14'" \ - "$WORKFLOW:11: upload \`lane-patch-\${{ github.run_attempt }}\` keeps 14 days; $TABLE family \`lane-patch-*\` says 7" + "$WORKFLOW:15: upload \`lane-patch-\${{ github.run_attempt }}\` keeps 14 days; $TABLE family \`lane-patch-*\` says 7" run_case "a dynamic name maps by its literal prefix, not by a longer family" 1 \ "subst $WORKFLOW 'name: lane-patch-' 'name: lane-patch-x-'" \ @@ -179,11 +193,11 @@ run_case "a dynamic name maps by its literal prefix, not by a longer family" 1 \ run_case "an upload in a composite action is checked" 1 \ "subst $ACTION 'retention-days: 3' 'retention-days: 5'" \ - "$ACTION:4: upload \`pack-\${{ github.run_id }}\` keeps 5 days" + "$ACTION:9: upload \`pack-\${{ github.run_id }}\` keeps 5 days" run_case "a computed name its job is not listed for fails" 1 \ "edit $TABLE 't.families[\"verdict-*\"].uploads=[\".github/workflows/build.yml#build\"]'" \ - "$WORKFLOW:21: upload \`\${{ steps.name.outputs.name }}\` matches no family" \ + "$WORKFLOW:25: upload \`\${{ steps.name.outputs.name }}\` matches no family" \ "$TABLE: family \`verdict-*\` lists upload .github/workflows/build.yml#build, which uploads no computed name" run_case "a max row keeps 90 days, so 89 differs" 1 \ @@ -230,6 +244,28 @@ run_case "a row with no reason fails the schema" 1 \ "edit $TABLE 'delete t.families[\"build-report\"].reason'" \ "$TABLE: schema:" +run_case "a scanned family's upload with no scan step fails" 1 \ + "drop_line $WORKFLOW ' uses: ./.github/actions/scan-upload'" \ + "$WORKFLOW:15: upload \`lane-patch-\${{ github.run_attempt }}\` has no scan before it; $TABLE family \`lane-patch-*\` says scan: upload" + +run_case "a scan over a different path does not count" 1 \ + "subst $WORKFLOW ' path: patch.diff + - if' ' path: other.diff + - if'" \ + "has no scan before it" + +run_case "an upload whose if: does not require the scan's clean output fails" 1 \ + "subst $WORKFLOW 'if: steps.scan-patch.outputs.clean' 'if: steps.other.outputs.clean'" \ + "has no scan before it" + +run_case "a plaintext family with no scan step before the seal fails" 1 \ + "subst $ACTION 'scan-upload/scan-upload.mjs' 'seal.mjs'" \ + "$ACTION:9: upload \`pack-\${{ github.run_id }}\` has no scan step before it; $TABLE family \`pack-*\` says scan: plaintext" + +run_case "a row with no scan field fails the schema" 1 \ + "edit $TABLE 'delete t.families[\"build-report\"].scan'" \ + "$TABLE: schema:" + run_case "a missing table fails as environment" 2 \ "rm $TABLE" \ "$TABLE" From a26ff7972fe748ea5119ba577eca18acdf74182e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:55:21 -0400 Subject: [PATCH 02/10] fix(lanes): gate expression-form upload conditions on the scan Four upload conditions written as ${{ }} became always true once the scan gate was appended outside the braces; unwrap them so the scan's clean output gates those uploads. check-artifact-retention now treats an if: with text around ${{ }} as gating nothing, and its test covers that case. The callers contract test admits the verdict's pre-upload scan in the read runner's scan job (default-branch .base, no secret) and expects the gated upload conditions. Refs: #8295 Co-Authored-By: Claude Opus 5.5 --- .github/actions/resolve-config/callers.test.mjs | 13 +++++++++++-- .../workflows/maintenance-reconcile-lost-runs.yml | 2 +- .github/workflows/pr-verify.yml | 6 +++--- scripts/check-artifact-retention.mjs | 4 +++- scripts/check-artifact-retention.test.sh | 10 +++++++--- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index e65a33aee21..8d8fe8bd33b 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -2802,6 +2802,13 @@ test("pr-run-activity-read.yml: the scan job holds only the scan-carrier key, in for (const action of ["seal-lane-output/seal.mjs", "open-lane-output/open.mjs"]) assert.match(read(`../${action}`), /env\.GITHUB_RUN_ID/, action); for (const step of scan.steps) { assert.doesNotMatch(String(step.uses ?? ""), /^actions\/(cache|download-artifact)@/, step.name); + // One more exception: the verdict's pre-upload scan, from the + // default-branch .base checkout, which receives no secret. + if (step.id === "scan-upload") { + assert.equal(step.uses, "./.base/.github/actions/scan-upload"); + assert.equal(stepSecrets(step).length, 0, `${step.name}: no secret`); + continue; + } if (step.id !== "open") assert.doesNotMatch(String(step.uses ?? ""), /^\.\//, `${step.name}: no other action from a checkout`); } // Payloads reach a script only through env, never inlined. @@ -2876,7 +2883,9 @@ test("pr-run-activity-read.yml: a PR run that stops before head code still uploa assert.equal(atStep(scan.if, { ...stopped, "needs.run.outputs.go": "true" }), true); // The verdict steps keep the default success(): they run when no step before // them failed, so a skipped scan still posts the skip and a hit posts nothing. - for (const name of ["Write the verdict", "Upload the verdict"]) assert.equal(step(name).if, undefined, name); + // The upload also needs its own scan's clean output. + for (const name of ["Write the verdict", "Scan the upload for credentials"]) assert.equal(step(name).if, undefined, name); + assert.equal(step("Upload the verdict").if, "steps.scan-upload.outputs.clean == 'true'"); for (const name of ["Upload the skill's final reply", "Upload the patch", "Show the skill's run in the step summary"]) { assert.equal(atStep(step(name).if, stopped), false, `${name} has nothing to upload`); } @@ -5373,7 +5382,7 @@ test("pr-unblock.yml: the select job runs only base code with a read-only token assert.equal(proceed("skipped", "", ""), "", "a kill-switch stop"); assert.equal(proceed("success", "true", "", "true"), "", "a pass-only round proceeds to no model"); const upload = select.steps.find((s) => s.id === "upload"); - assert.equal(upload.if, "steps.select.outputs.proceed == 'true' || steps.select.outputs.resolve-only == 'true'"); + assert.equal(upload.if, "(steps.select.outputs.proceed == 'true' || steps.select.outputs.resolve-only == 'true') && steps.scan-upload.outputs.clean == 'true'"); assert.equal(upload.with.name, "feedback-context-${{ github.run_attempt }}"); }); diff --git a/.github/workflows/maintenance-reconcile-lost-runs.yml b/.github/workflows/maintenance-reconcile-lost-runs.yml index 886d1bf7ac6..989ce1df359 100644 --- a/.github/workflows/maintenance-reconcile-lost-runs.yml +++ b/.github/workflows/maintenance-reconcile-lost-runs.yml @@ -258,7 +258,7 @@ jobs: - name: Carry the settled runs if: >- - (${{ !cancelled() && steps.plan.outcome == 'success' }}) && steps.scan-upload-4.outputs.clean == 'true' + (!cancelled() && steps.plan.outcome == 'success') && steps.scan-upload-4.outputs.clean == 'true' continue-on-error: true uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/.github/workflows/pr-verify.yml b/.github/workflows/pr-verify.yml index 70e88a75fc4..be673c55c73 100644 --- a/.github/workflows/pr-verify.yml +++ b/.github/workflows/pr-verify.yml @@ -1856,7 +1856,7 @@ jobs: - name: Upload the verdict if: >- - (${{ !cancelled() && steps.verdict.outcome == 'success' }}) && steps.scan-upload.outputs.clean == 'true' + (!cancelled() && steps.verdict.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.verdict.outputs.name }} @@ -3642,7 +3642,7 @@ jobs: - name: Upload the verdict if: >- - (${{ !cancelled() && steps.verdict.outcome == 'success' }}) && steps.scan-upload.outputs.clean == 'true' + (!cancelled() && steps.verdict.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.verdict.outputs.name }} @@ -5174,7 +5174,7 @@ jobs: - name: Upload the verdict if: >- - (${{ !cancelled() && steps.verdict.outcome == 'success' }}) && steps.scan-upload.outputs.clean == 'true' + (!cancelled() && steps.verdict.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ steps.verdict.outputs.name }} diff --git a/scripts/check-artifact-retention.mjs b/scripts/check-artifact-retention.mjs index d51e24b0743..0086056e501 100644 --- a/scripts/check-artifact-retention.mjs +++ b/scripts/check-artifact-retention.mjs @@ -185,7 +185,9 @@ for (const step of steps) { const [key] = claimed; used.add(key); const scan = families[key].scan; - const gated = (id) => step.condition.includes(`steps.${id}.outputs.clean == 'true'`); + // A `${{ }}` with text around it is a non-empty string, always true, so it gates nothing. + const literal = /\$\{\{/.test(step.condition) && !/^\s*\$\{\{[\s\S]*\}\}\s*$/.test(step.condition); + const gated = (id) => !literal && step.condition.includes(`steps.${id}.outputs.clean == 'true'`); if (scan === "upload") { const viaLeakScan = step.scans.some((s) => s.id === "leak-scan") && String(step.path ?? "").includes("steps.leak-scan.outputs.dir") && gated("leak-scan"); const viaScanStep = step.scans.some((s) => s.path !== undefined && String(s.path) === String(step.path ?? "") && gated(s.id)); diff --git a/scripts/check-artifact-retention.test.sh b/scripts/check-artifact-retention.test.sh index 81ad32b2479..fd9eeeff114 100755 --- a/scripts/check-artifact-retention.test.sh +++ b/scripts/check-artifact-retention.test.sh @@ -197,7 +197,7 @@ run_case "an upload in a composite action is checked" 1 \ run_case "a computed name its job is not listed for fails" 1 \ "edit $TABLE 't.families[\"verdict-*\"].uploads=[\".github/workflows/build.yml#build\"]'" \ - "$WORKFLOW:25: upload \`\${{ steps.name.outputs.name }}\` matches no family" \ + "$WORKFLOW:26: upload \`\${{ steps.name.outputs.name }}\` matches no family" \ "$TABLE: family \`verdict-*\` lists upload .github/workflows/build.yml#build, which uploads no computed name" run_case "a max row keeps 90 days, so 89 differs" 1 \ @@ -221,7 +221,7 @@ run_case "a computed-by naming no file of the tree fails" 1 \ "$TABLE: family \`verdict-*\` computed-by scripts/gone.mjs names no file" run_case "a row no upload matches fails" 1 \ - "edit $TABLE 't.families[\"old-*\"]={\"retention-days\":1,\"readers\":[\".github/workflows/build.yml\"],\"reason\":\"gone\"}'" \ + "edit $TABLE 't.families[\"old-*\"]={\"retention-days\":1,\"readers\":[\".github/workflows/build.yml\"],\"reason\":\"gone\",\"scan\":\"none\"}'" \ "$TABLE: family \`old-*\` matches no upload step" run_case "a max row with no max-reader fails" 1 \ @@ -246,7 +246,7 @@ run_case "a row with no reason fails the schema" 1 \ run_case "a scanned family's upload with no scan step fails" 1 \ "drop_line $WORKFLOW ' uses: ./.github/actions/scan-upload'" \ - "$WORKFLOW:15: upload \`lane-patch-\${{ github.run_attempt }}\` has no scan before it; $TABLE family \`lane-patch-*\` says scan: upload" + "$WORKFLOW:14: upload \`lane-patch-\${{ github.run_attempt }}\` has no scan before it; $TABLE family \`lane-patch-*\` says scan: upload" run_case "a scan over a different path does not count" 1 \ "subst $WORKFLOW ' path: patch.diff @@ -258,6 +258,10 @@ run_case "an upload whose if: does not require the scan's clean output fails" 1 "subst $WORKFLOW 'if: steps.scan-patch.outputs.clean' 'if: steps.other.outputs.clean'" \ "has no scan before it" +run_case "an if: with text around \${{ }} is always true, so it gates nothing" 1 \ + "subst $WORKFLOW 'if: steps.scan-patch.outputs.clean == ' 'if: (\${{ always() }}) && steps.scan-patch.outputs.clean == '" \ + "has no scan before it" + run_case "a plaintext family with no scan step before the seal fails" 1 \ "subst $ACTION 'scan-upload/scan-upload.mjs' 'seal.mjs'" \ "$ACTION:9: upload \`pack-\${{ github.run_id }}\` has no scan step before it; $TABLE family \`pack-*\` says scan: plaintext" From eab9ef5b67efaa885bf29cb8d0fb83966a3da02e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:11:58 -0400 Subject: [PATCH 03/10] fix(lanes): run the upload scan from a staged copy where a head checkout replaces .base The read runner's scan job and the write runner's run job check out the head at the workspace root, which replaces .base, so a scan step loading ./.base/.github/actions/scan-upload there ran head-supplied code. Both jobs now stage scan-upload.mjs and lib/publish-gate.mjs into RUNNER_TEMP before the head checkout, on every run: the read runner from the default-branch tip through the API, as leak-scan loads the gate, and the write runner from its default-branch .base. Their scans run the staged copy with node. check-artifact-retention now counts a scan only as a top-level && conjunct with no top-level ||, and requires the upload's condition to be the scan step's own if:, parenthesized, then the clean output. The callers contract test asserts that no job loads an action from ./.base/ after a checkout that replaced it, and that the two jobs run the staged scanner. Refs: #8295 Co-Authored-By: Claude Opus 5.5 --- .../actions/resolve-config/callers.test.mjs | 45 +++++++++++--- .github/workflows/pr-run-activity-read.yml | 46 +++++++++++++-- .github/workflows/pr-run-activity-write.yml | 33 ++++++++--- scripts/check-artifact-retention.mjs | 58 ++++++++++++++++--- scripts/check-artifact-retention.test.sh | 13 +++++ 5 files changed, 167 insertions(+), 28 deletions(-) diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index 8d8fe8bd33b..7faf03a90a1 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -2802,13 +2802,6 @@ test("pr-run-activity-read.yml: the scan job holds only the scan-carrier key, in for (const action of ["seal-lane-output/seal.mjs", "open-lane-output/open.mjs"]) assert.match(read(`../${action}`), /env\.GITHUB_RUN_ID/, action); for (const step of scan.steps) { assert.doesNotMatch(String(step.uses ?? ""), /^actions\/(cache|download-artifact)@/, step.name); - // One more exception: the verdict's pre-upload scan, from the - // default-branch .base checkout, which receives no secret. - if (step.id === "scan-upload") { - assert.equal(step.uses, "./.base/.github/actions/scan-upload"); - assert.equal(stepSecrets(step).length, 0, `${step.name}: no secret`); - continue; - } if (step.id !== "open") assert.doesNotMatch(String(step.uses ?? ""), /^\.\//, `${step.name}: no other action from a checkout`); } // Payloads reach a script only through env, never inlined. @@ -7423,3 +7416,41 @@ test("work-resolve-issue.yml: every resolve without pinned dimensions passes adm } assert.ok(unpinned.includes("pick-slot") && unpinned.includes("run-checks"), `unpinned resolves: ${unpinned}`); }); + +// A checkout with no path: replaces the workspace, .base included, so an +// action loaded from ./.base/ after it is head code. +test("no job loads an action from ./.base/ after a checkout that replaced it", () => { + for (const file of workflowFiles()) { + for (const [id, job] of Object.entries(workflow(file).jobs ?? {})) { + let replaced = null; + for (const step of job.steps ?? []) { + const uses = String(step.uses ?? ""); + if (uses.startsWith("actions/checkout@")) { + if (step.with?.path === ".base") replaced = null; + else if (!step.with?.path) replaced = step.name; + } + if (replaced !== null && uses.startsWith("./.base/")) assert.fail(`${file} ${id}: ${step.name} loads ${uses} after ${replaced}`); + } + } + } +}); + +test("the read runner's scan job and the write runner's run job scan uploads with a scanner staged in RUNNER_TEMP before the head checkout", () => { + const run = 'node "$RUNNER_TEMP/scan-gate/.github/actions/scan-upload/scan-upload.mjs"'; + const jobs = [ + [workflow("pr-run-activity-read.yml").jobs.scan, "Check out the gated head"], + [workflow("pr-run-activity-write.yml").jobs.run, "Check out the PR head"], + ]; + for (const [job, head] of jobs) { + const index = (find) => job.steps.findIndex(find); + const stage = index((s) => s.name === "Stage the upload scanner"); + assert.ok(stage >= 0 && stage < index((s) => s.name === head), "staged before the head checkout"); + assert.equal(job.steps[stage].if, undefined, "staged on every run"); + const scans = job.steps.filter((s) => /^scan-upload/.test(s.id ?? "")); + assert.ok(scans.length > 0); + for (const scan of scans) { + assert.equal(scan.uses, undefined, scan.id); + assert.equal(scan.run, run, scan.id); + } + } +}); diff --git a/.github/workflows/pr-run-activity-read.yml b/.github/workflows/pr-run-activity-read.yml index ce939e3c021..d85755f4d8d 100644 --- a/.github/workflows/pr-run-activity-read.yml +++ b/.github/workflows/pr-run-activity-read.yml @@ -2474,9 +2474,7 @@ jobs: persist-credentials: false sparse-checkout: | .github/actions/open-lane-output - .github/actions/scan-upload .github/lane-keys - lib - name: Read the scan-carrier key id id: key @@ -2660,6 +2658,44 @@ jobs: core.setFailed(error instanceof Refused ? error.message : `payload-mismatch: the outputs could not be decoded (${error.name}); nothing is published`); } + # The upload scanner and its gate, from the default branch tip through + # the API (as leak-scan loads the gate), into a fresh directory under + # RUNNER_TEMP: the gated head's checkout next replaces the workspace, + # .base included, so no scan here loads code from it. It runs on every + # run, a gate stop included, since the verdict upload follows its scan; + # a failure here leaves no scanner, and that scan then withholds the + # verdict. + - name: Stage the upload scanner + continue-on-error: true + timeout-minutes: 2 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + retries: 2 + retry-exempt-status-codes: 400,401,403,404,422,429 + script: | + const fs = require('fs'); + const path = require('path'); + const branch = context.payload.repository?.default_branch; + if (typeof branch !== 'string' || branch === '') throw new Error('no default branch'); + const tip = (await github.rest.repos.getBranch({ ...context.repo, branch })).data?.commit?.sha; + if (typeof tip !== 'string' || !/^[0-9a-f]{40}$/.test(tip)) throw new Error('no default branch tip'); + const dest = path.join(process.env.RUNNER_TEMP, 'scan-gate'); + fs.mkdirSync(dest); + // The scanner may import node: builtins and the gate; the gate only builtins. + const files = [ + ['lib/publish-gate.mjs', []], + ['.github/actions/scan-upload/scan-upload.mjs', ['../../../lib/publish-gate.mjs']], + ]; + for (const [file, allowed] of files) { + const { data } = await github.rest.repos.getContent({ ...context.repo, path: file, ref: tip }); + const source = Buffer.from(data.content, 'base64').toString('utf8'); + const specifiers = [...source.matchAll(/\bfrom\s*['"]([^'"]+)['"]|^\s*import\s*['"]([^'"]+)['"]|\bimport\s*\(\s*['"]?([^'")]*)/gm)].map((match) => match[1] ?? match[2] ?? match[3]); + if (specifiers.some((name) => !name.startsWith('node:') && !allowed.includes(name))) throw new Error(`${file} imports more than it may`); + fs.mkdirSync(path.join(dest, path.dirname(file)), { recursive: true }); + fs.writeFileSync(path.join(dest, file), source, { flag: 'wx', mode: 0o444 }); + } + core.info(`upload scanner from ${tip}`); + # The gated head's objects, fresh: the patch applies onto them; in # issue mode the start commit the issue step checked. A merge-base # patch is measured from a conflict tree GitHub does not hold, so it is @@ -3043,9 +3079,9 @@ jobs: # verdict, which the report job reads as a failure. - name: Scan the upload for credentials id: scan-upload - uses: ./.base/.github/actions/scan-upload - with: - path: ${{ runner.temp }}/verdict/verdict.json + env: + SCAN_PATHS: ${{ runner.temp }}/verdict/verdict.json + run: node "$RUNNER_TEMP/scan-gate/.github/actions/scan-upload/scan-upload.mjs" - name: Upload the verdict if: steps.scan-upload.outputs.clean == 'true' diff --git a/.github/workflows/pr-run-activity-write.yml b/.github/workflows/pr-run-activity-write.yml index 732bc2f09de..da263539405 100644 --- a/.github/workflows/pr-run-activity-write.yml +++ b/.github/workflows/pr-run-activity-write.yml @@ -709,6 +709,21 @@ jobs: fetch-depth: 0 persist-credentials: false + # The upload scanner and its gate, copied out of the default branch tip + # before the head checkout replaces .base: the scans below run it from + # RUNNER_TEMP, never from files head code or the model can write. A + # failure here leaves no scanner, and each scan then withholds its upload. + - name: Stage the upload scanner + continue-on-error: true + timeout-minutes: 1 + run: | + dest="$RUNNER_TEMP/scan-gate" + mkdir "$dest" + mkdir -p "$dest/lib" "$dest/.github/actions/scan-upload" + cp .base/lib/publish-gate.mjs "$dest/lib/" + cp .base/.github/actions/scan-upload/scan-upload.mjs "$dest/.github/actions/scan-upload/" + chmod -R a-w "$dest" + - name: Set up Node uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: @@ -1833,11 +1848,11 @@ jobs: id: scan-upload if: >- always() && (steps.act-skill.outcome == 'success' || steps.act-skill.outcome == 'failure') - uses: ./.base/.github/actions/scan-upload - with: - path: | + env: + SCAN_PATHS: | ${{ runner.temp }}/skill-reply/reply.txt ${{ runner.temp }}/skill-reply/act-turns.json + run: node "$RUNNER_TEMP/scan-gate/.github/actions/scan-upload/scan-upload.mjs" - name: Upload the skill's final reply if: >- @@ -2180,11 +2195,11 @@ jobs: id: scan-upload-2 if: >- inputs.issue-number != '' && steps.config.outputs.prepare == 'apply-patch' && steps.act-script.outcome == 'success' && steps.act-script.outputs.branch != '' - uses: ./.base/.github/actions/scan-upload - with: - path: | + env: + SCAN_PATHS: | ${{ runner.temp }}/work-outcome/lane.json ${{ runner.temp }}/work-outcome/work-outcome.json + run: node "$RUNNER_TEMP/scan-gate/.github/actions/scan-upload/scan-upload.mjs" - name: Upload the named issue outcome if: >- @@ -2255,9 +2270,9 @@ jobs: id: scan-upload-3 if: >- always() - uses: ./.base/.github/actions/scan-upload - with: - path: ${{ runner.temp }}/verdict/verdict.json + env: + SCAN_PATHS: ${{ runner.temp }}/verdict/verdict.json + run: node "$RUNNER_TEMP/scan-gate/.github/actions/scan-upload/scan-upload.mjs" - name: Upload the verdict if: >- diff --git a/scripts/check-artifact-retention.mjs b/scripts/check-artifact-retention.mjs index 0086056e501..51ffe53f23d 100644 --- a/scripts/check-artifact-retention.mjs +++ b/scripts/check-artifact-retention.mjs @@ -85,9 +85,10 @@ const SCAN_ACTION = /(?:^|\/)\.github\/actions\/scan-upload$/; function scanOf(step) { const id = step.get("id"); if (typeof id !== "string") return null; - if (id === "leak-scan") return { id }; - if (SCAN_ACTION.test(String(step.get("uses") ?? ""))) return { id, path: step.get("with", true)?.get?.("path") }; - if (/scan-upload\/scan-upload\.mjs/.test(String(step.get("run") ?? ""))) return { id, path: step.get("env", true)?.get?.("SCAN_PATHS") }; + const condition = String(step.get("if") ?? ""); + if (id === "leak-scan") return { id, condition }; + if (SCAN_ACTION.test(String(step.get("uses") ?? ""))) return { id, condition, path: step.get("with", true)?.get?.("path") }; + if (/scan-upload\/scan-upload\.mjs/.test(String(step.get("run") ?? ""))) return { id, condition, path: step.get("env", true)?.get?.("SCAN_PATHS") }; return null; } @@ -185,12 +186,22 @@ for (const step of steps) { const [key] = claimed; used.add(key); const scan = families[key].scan; - // A `${{ }}` with text around it is a non-empty string, always true, so it gates nothing. - const literal = /\$\{\{/.test(step.condition) && !/^\s*\$\{\{[\s\S]*\}\}\s*$/.test(step.condition); - const gated = (id) => !literal && step.condition.includes(`steps.${id}.outputs.clean == 'true'`); + // The scan gates the upload only as a top-level && conjunct of its if:, + // with no top-level ||. A scan step's gate is the whole condition: that + // step's own if:, parenthesized, then the clean output, so the scan runs + // whenever the upload would. + const conjuncts = topLevelAnd(step.condition); + const clean = (id) => `steps.${id}.outputs.clean == 'true'`; + const gated = (id) => conjuncts !== null && conjuncts.includes(clean(id)); + const gatedBy = (scan) => { + if (conjuncts === null || conjuncts.at(-1) !== clean(scan.id)) return false; + const own = squash(scan.condition).replace(/^\$\{\{\s*([\s\S]*?)\s*\}\}$/, "$1"); + if (conjuncts.length === 1) return own === ""; + return conjuncts.length === 2 && own !== "" && conjuncts[0] === `(${own})`; + }; if (scan === "upload") { const viaLeakScan = step.scans.some((s) => s.id === "leak-scan") && String(step.path ?? "").includes("steps.leak-scan.outputs.dir") && gated("leak-scan"); - const viaScanStep = step.scans.some((s) => s.path !== undefined && String(s.path) === String(step.path ?? "") && gated(s.id)); + const viaScanStep = step.scans.some((s) => s.path !== undefined && String(s.path) === String(step.path ?? "") && gatedBy(s)); if (!viaLeakScan && !viaScanStep) { findings.push( `${label} has no scan before it; ${TABLE} family \`${key}\` says scan: upload, so a .github/actions/scan-upload step over the same path must come first and the upload's if: must require its clean output`, @@ -231,6 +242,39 @@ for (const [key, row] of Object.entries(families)) { report(); +function squash(text) { + return text.replace(/\s+/g, " ").trim(); +} + +// The top-level && conjuncts of an if: condition; null when it has a +// top-level ||, or a `${{ }}` with text around it (a non-empty string, so +// always true). +function topLevelAnd(raw) { + let text = squash(raw); + const wrapped = /^\$\{\{([\s\S]*)\}\}$/.exec(text); + if (wrapped) text = wrapped[1].trim(); + if (text.includes("${{")) return null; + const parts = []; + let depth = 0; + let quoted = false; + let start = 0; + for (let i = 0; i < text.length; i += 1) { + const c = text[i]; + if (c === "'") quoted = !quoted; + if (quoted) continue; + if (c === "(") depth += 1; + else if (c === ")") depth -= 1; + else if (depth === 0 && text.startsWith("||", i)) return null; + else if (depth === 0 && text.startsWith("&&", i)) { + parts.push(text.slice(start, i).trim()); + start = i + 2; + i += 1; + } + } + parts.push(text.slice(start).trim()); + return parts; +} + function report() { if (findings.length === 0) { process.stdout.write( diff --git a/scripts/check-artifact-retention.test.sh b/scripts/check-artifact-retention.test.sh index fd9eeeff114..cde47a028ef 100755 --- a/scripts/check-artifact-retention.test.sh +++ b/scripts/check-artifact-retention.test.sh @@ -262,6 +262,19 @@ run_case "an if: with text around \${{ }} is always true, so it gates nothing" 1 "subst $WORKFLOW 'if: steps.scan-patch.outputs.clean == ' 'if: (\${{ always() }}) && steps.scan-patch.outputs.clean == '" \ "has no scan before it" +run_case "a top-level || beside the clean output gates nothing" 1 \ + "subst $WORKFLOW 'if: steps.scan-patch.outputs.clean == ' 'if: always() || steps.scan-patch.outputs.clean == '" \ + "has no scan before it" + +run_case "an upload that runs when its scan step does not fails" 1 \ + "subst $WORKFLOW 'if: steps.scan-patch.outputs.clean == ' 'if: (always()) && steps.scan-patch.outputs.clean == '" \ + "has no scan before it" + +run_case "the upload's condition repeats its scan step's if:, parenthesized" 0 \ + "subst $WORKFLOW 'if: steps.scan-patch.outputs.clean == ' 'if: (always() && inputs.go) && steps.scan-patch.outputs.clean == ' && subst $WORKFLOW ' - id: scan-patch' ' - id: scan-patch + if: always() && inputs.go'" \ + "check-artifact-retention: 4 upload step(s) match" + run_case "a plaintext family with no scan step before the seal fails" 1 \ "subst $ACTION 'scan-upload/scan-upload.mjs' 'seal.mjs'" \ "$ACTION:9: upload \`pack-\${{ github.run_id }}\` has no scan step before it; $TABLE family \`pack-*\` says scan: plaintext" From 7d3fba350b16f899a34a1e7b051567557a976e7e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:24:48 -0400 Subject: [PATCH 04/10] test(ci): run the scan-upload suite in the test-node lane plan-test-lanes refused the new suite because no lane ran it. Give .github/actions/scan-upload a package.json with `node --test` and list it in scripts/outside-node-packages.txt, as the other action suites are. Co-Authored-By: Claude Opus 5.5 --- .github/actions/scan-upload/package.json | 7 +++++++ scripts/outside-node-packages.txt | 1 + 2 files changed, 8 insertions(+) create mode 100644 .github/actions/scan-upload/package.json diff --git a/.github/actions/scan-upload/package.json b/.github/actions/scan-upload/package.json new file mode 100644 index 00000000000..a65d2b93e5e --- /dev/null +++ b/.github/actions/scan-upload/package.json @@ -0,0 +1,7 @@ +{ + "name": "scan-upload", + "private": true, + "scripts": { + "test": "node --test" + } +} diff --git a/scripts/outside-node-packages.txt b/scripts/outside-node-packages.txt index 32ae59790ef..a3a511a5bdc 100644 --- a/scripts/outside-node-packages.txt +++ b/scripts/outside-node-packages.txt @@ -14,6 +14,7 @@ .github/actions/open-lane-output .github/actions/report-check-run .github/actions/request-lane-token +.github/actions/scan-upload .github/actions/seal-lane-output .github/actions/select-trusted-text .github/actions/upload-lane-transcript From 44049c454e79ca55f2c84899c256a3c7efa15d7d Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:24:49 -0400 Subject: [PATCH 05/10] test(ci): build the scan-upload password fixture at run time GitGuardian flagged the literal password assignment in the warn-only fixture. Join the key at run time so no tracked line holds the shape; the file the test writes is unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/actions/scan-upload/scan-upload.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/actions/scan-upload/scan-upload.test.mjs b/.github/actions/scan-upload/scan-upload.test.mjs index 14c35911e67..37f73638401 100644 --- a/.github/actions/scan-upload/scan-upload.test.mjs +++ b/.github/actions/scan-upload/scan-upload.test.mjs @@ -76,7 +76,8 @@ test("a link, a compressed file and a glob each withhold the upload", (t) => { test("the broad assignment label only warns", (t) => { const dir = workDir(t); - writeFileSync(path.join(dir, "fixture.txt"), 'password = "not-a-real-one"\n'); + // Joined at run time, so no tracked line reads as a password assignment. + writeFileSync(path.join(dir, "fixture.txt"), `${["pass", "word"].join("")} = "not-a-real-one"\n`); const result = run(dir, path.join(dir, "fixture.txt")); assert.equal(result.status, 0, result.stdout + result.stderr); assert.equal(result.outputs, "clean=true\n"); From da15fc29c77600b6788f4fcd7fda3d9c6ce617dd Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:25:33 -0400 Subject: [PATCH 06/10] fix(ci): let a missing scanner not skip the ci-status re-run The rerun-ci-status job's .base checkout is continue-on-error, so a failed checkout made the record's scan step fail and skipped `Re-run ci-status`. The scan step is now continue-on-error too; the record upload still needs its clean output, so a hit or a missing scanner withholds the record. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr-feedback-dispatch.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-feedback-dispatch.yml b/.github/workflows/pr-feedback-dispatch.yml index ef4c8b6e30b..8dc8322d70f 100644 --- a/.github/workflows/pr-feedback-dispatch.yml +++ b/.github/workflows/pr-feedback-dispatch.yml @@ -570,9 +570,10 @@ jobs: API_BUDGET_TOKEN: github-token steps: # The list the record check reads, from the default branch, never the - # PR head, the reconciler-record check and the api-budget library. A - # failed checkout leaves no list: ci-status is still re-run, unrecorded - # unless the review run is a first attempt, and no reading is logged. + # PR head, the reconciler-record check, the api-budget library and the + # upload scanner. A failed checkout leaves no list and no scanner: + # ci-status is still re-run, unrecorded unless the review run is a first + # attempt, and no reading is logged. - name: Check out the trusted-actors list continue-on-error: true uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -904,6 +905,7 @@ jobs: id: scan-upload if: >- steps.find.outputs.record != '' + continue-on-error: true uses: ./.base/.github/actions/scan-upload with: path: ${{ runner.temp }}/rerun-ci-status-record.json From c1afd2b04ab03737f196d81beeeabadd5536c959 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:25:34 -0400 Subject: [PATCH 07/10] fix(ci): do not scan the pr-merge integer-only run records The dispatched, re-run and alerted records hold only run ids and an attempt number the workflow writes. Scanning them added a step that fails when dispatch-full-run's continue-on-error .base checkout fails, which skipped `Record the dispatched run`. Mark the three families scan: none with that reason, drop their scan steps, and drop the scanner from dispatch-full-run's sparse checkout. Co-Authored-By: Claude Opus 5.5 --- .github/artifact-retention.json | 12 ++++----- .github/workflows/pr-merge.yml | 46 +++------------------------------ 2 files changed, 10 insertions(+), 48 deletions(-) diff --git a/.github/artifact-retention.json b/.github/artifact-retention.json index 15f797a15df..1bf40bca3ad 100644 --- a/.github/artifact-retention.json +++ b/.github/artifact-retention.json @@ -44,8 +44,8 @@ "plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py _lane_reruns reads records of runs created within confirm-wait-minutes plus FULL_RUN_ALLOWANCE", ".github/scripts/pr-merge/lane-records.mjs carries the records the post-merge check proved, each only until its artifact's expires_at, so the carry adds no lifetime" ], - "reason": "The look-back is merged_since, the later of the enabling commit and the newest demotion clear, with no bound in days. It stops at the post-merge check's 1000-run listing cap (babysit_gh.py RUN_SEARCH_CAP): past that the check is unreadable and holds the lane whatever this row says. At the 48 and 57 dispatch runs in the two days to 2026-10-31T05:00Z, after the 30-minute cap and its bypass landed, that is about 19 days, and demotion clears had come at most 4.8 days apart; 30 days leaves about 11 more for a stopped check, whose runs then accumulate only at the human merge rate. This holds while the listing reaches 1000 runs, or a clear moves the floor, within 30 days: at least 34 runs a day. A record that has left the listing never verifies a merge: its run stops counting as the lane's, so the merge holds as an unlisted record until a later recorded run covers it, and a flaked or in-between red run can read red and demote.", - "scan": "upload" + "reason": "The look-back is merged_since, the later of the enabling commit and the newest demotion clear, with no bound in days. It stops at the post-merge check's 1000-run listing cap (babysit_gh.py RUN_SEARCH_CAP): past that the check is unreadable and holds the lane whatever this row says. At the 48 and 57 dispatch runs in the two days to 2026-10-31T05:00Z, after the 30-minute cap and its bypass landed, that is about 19 days, and demotion clears had come at most 4.8 days apart; 30 days leaves about 11 more for a stopped check, whose runs then accumulate only at the human merge rate. This holds while the listing reaches 1000 runs, or a clear moves the floor, within 30 days: at least 34 runs a day. A record that has left the listing never verifies a merge: its run stops counting as the lane's, so the merge holds as an unlisted record until a later recorded run covers it, and a flaked or in-between red run can read red and demote. It holds only workflow-written integers, so it is not scanned.", + "scan": "none" }, "pr-merge-rerun-*": { "retention-days": "max", @@ -54,16 +54,16 @@ ".github/scripts/pr-merge/lane-records.mjs keeps the records the post-merge check proved" ], "max-reader": "plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py", - "reason": "The post-merge check reads the lane's re-run record back to merged_since, only while the run's pr-merge-dispatched record exists; keep this at or above that family's days.", - "scan": "upload" + "reason": "The post-merge check reads the lane's re-run record back to merged_since, only while the run's pr-merge-dispatched record exists; keep this at or above that family's days. It holds only workflow-written integers, so it is not scanned.", + "scan": "none" }, "pr-merge-alerted-*": { "retention-days": 1, "readers": [ "plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py LANE_ALERT_ARTIFACT (`--lane-alerted`), asked by .github/workflows/pr-merge.yml post-merge-dispatch only for full runs created within LOOKBACK_HOURS (6)" ], - "reason": "Read back only for full runs created in the last 6 hours (pr-merge.yml LOOKBACK_HOURS), and each record is uploaded after its run was created; 1 day, the minimum, outlasts that window.", - "scan": "upload" + "reason": "Read back only for full runs created in the last 6 hours (pr-merge.yml LOOKBACK_HOURS), and each record is uploaded after its run was created; 1 day, the minimum, outlasts that window. It holds only workflow-written integers, so it is not scanned.", + "scan": "none" }, "pr-merge-post-merge-report": { "retention-days": 1, diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index 5858cb86426..06e0044b1be 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -523,17 +523,8 @@ jobs: echo "{\"dispatcher_run_id\":$GITHUB_RUN_ID,\"run_id\":$id}" >"$RUNNER_TEMP/dispatched.json" echo "run-id=$id" >>"$GITHUB_OUTPUT" - - name: Scan the upload for credentials - id: scan-upload-2 - if: >- - steps.dispatch.outputs.run-id != '' - uses: ./.base/.github/actions/scan-upload - with: - path: ${{ runner.temp }}/dispatched.json - - name: Record the dispatched run - if: >- - (steps.dispatch.outputs.run-id != '') && steps.scan-upload-2.outputs.clean == 'true' + if: steps.dispatch.outputs.run-id != '' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-merge-dispatched-${{ steps.dispatch.outputs.run-id }} @@ -639,17 +630,8 @@ jobs: echo "{\"rerunner_run_id\":$GITHUB_RUN_ID,\"run_id\":$RUN_ID,\"attempt\":2}" >"$RUNNER_TEMP/rerun.json" echo "recorded=true" >>"$GITHUB_OUTPUT" - - name: Scan the upload for credentials - id: scan-upload-3 - if: >- - !cancelled() && steps.rerun.outputs.recorded == 'true' - uses: ./.base/.github/actions/scan-upload - with: - path: ${{ runner.temp }}/rerun.json - - name: Record the re-run - if: >- - (!cancelled() && steps.rerun.outputs.recorded == 'true') && steps.scan-upload-3.outputs.clean == 'true' + if: "!cancelled() && steps.rerun.outputs.recorded == 'true'" uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-merge-rerun-${{ steps.post-merge.outputs.lane-rerun }}-attempt-2 @@ -784,17 +766,8 @@ jobs: exit 0 done <<<"$runs" - - name: Scan the upload for credentials - id: scan-upload-4 - if: >- - !cancelled() && steps.alert.outputs.run-id != '' - uses: ./.base/.github/actions/scan-upload - with: - path: ${{ runner.temp }}/alerted.json - - name: Record the alert sent - if: >- - (!cancelled() && steps.alert.outputs.run-id != '') && steps.scan-upload-4.outputs.clean == 'true' + if: "!cancelled() && steps.alert.outputs.run-id != ''" uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-merge-alerted-${{ steps.alert.outputs.run-id }} @@ -2622,8 +2595,6 @@ jobs: persist-credentials: false sparse-checkout: | /.github/actions/log-api-budget/ - /.github/actions/scan-upload/ - /lib/publish-gate.mjs sparse-checkout-cone-mode: false # At most one full run of main per CAP_MINUTES, each of main's tip, @@ -2798,17 +2769,8 @@ jobs: # The post-merge check counts a full run started by github-actions[bot] # only when a pr-merge.yml run on main uploaded an artifact naming it. # Only this run's own steps can upload this run's artifacts. - - name: Scan the upload for credentials - id: scan-upload - if: >- - steps.dispatch.outputs.run-id != '' - uses: ./.base/.github/actions/scan-upload - with: - path: ${{ runner.temp }}/dispatched.json - - name: Record the dispatched run - if: >- - (steps.dispatch.outputs.run-id != '') && steps.scan-upload.outputs.clean == 'true' + if: steps.dispatch.outputs.run-id != '' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-merge-dispatched-${{ steps.dispatch.outputs.run-id }} From 946099b3eb5195b234c7f69f3fa48d7db84ac57f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:25:44 -0400 Subject: [PATCH 08/10] fix(ci): keep pr-merge's best-effort uploads best-effort after a scan hit In merge and post-merge-dispatch, the uploads the new scans gate are continue-on-error, but the scans were not: a hit, such as a token-shaped branch name in head-refs.json, failed merge and skipped dispatch-full-run. Each scan before a continue-on-error upload is now continue-on-error too; the upload still needs the clean output, so a hit still withholds the file. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr-merge.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index 06e0044b1be..8c48ba962b4 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -297,6 +297,7 @@ jobs: id: scan-upload if: >- steps.post-merge.conclusion == 'success' + continue-on-error: true uses: ./.base/.github/actions/scan-upload with: path: ${{ runner.temp }}/post-merge-report/ @@ -1674,6 +1675,7 @@ jobs: id: scan-upload-2 if: >- always() && steps.head-refs.outcome == 'success' + continue-on-error: true uses: ./.base/.github/actions/scan-upload with: path: ${{ runner.temp }}/head-refs/head-refs.json @@ -1712,6 +1714,7 @@ jobs: id: scan-upload-3 if: >- always() && steps.lane-records.outcome == 'success' + continue-on-error: true uses: ./.base/.github/actions/scan-upload with: path: ${{ runner.temp }}/lane-records/lane-records.json @@ -1757,6 +1760,7 @@ jobs: id: scan-upload-4 if: >- always() && steps.carry.outputs.carried == 'true' + continue-on-error: true uses: ./.base/.github/actions/scan-upload with: path: ${{ runner.temp }}/pr-merge-carried.json @@ -1854,6 +1858,7 @@ jobs: id: scan-upload-5 if: >- always() && (steps.gate.outcome != 'skipped' || steps.candidates.outputs.held == 'true') + continue-on-error: true uses: ./.base/.github/actions/scan-upload with: path: ${{ runner.temp }}/pr-merge-sweep.json @@ -1877,6 +1882,7 @@ jobs: id: scan-upload-6 if: >- always() && steps.gate.outcome != 'skipped' && steps.mode.outputs.report-only == 'false' && steps.gate.outputs.rate-limited != 'true' + continue-on-error: true uses: ./.base/.github/actions/scan-upload with: path: ${{ runner.temp }}/pr-merge-summary.json @@ -1987,6 +1993,7 @@ jobs: id: scan-upload-7 if: >- always() && steps.gate.outcome != 'skipped' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + continue-on-error: true uses: ./.base/.github/actions/scan-upload with: path: ${{ runner.temp }}/verify-listing.json From 192879b1e7cee893f9f778a400547eeffbccc8d1 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:21:16 -0500 Subject: [PATCH 09/10] fix(lanes): scan the pr-merge run records again and fit the tests to the scan steps The #8295 decision applies the pre-upload scan to the pr-merge-dispatched and pr-merge-rerun records, so restore the scan steps c1afd2b dropped for the dispatched, re-run and alerted records, with their rows back at scan: upload. Each restored scan is continue-on-error, as the ci-status re-run's is: a failed .base checkout no longer skips the record step, and the upload, which needs the clean output, is withheld, failing closed. The reuse-summary scan in pr-run-activity-read's summary job and the two enable-auto-merge scans before its continue-on-error uploads are continue-on-error too, so a scan hit withholds a best-effort upload without failing the job. Mark each scan-upload uses line with main's zizmor self-repository ignore, and give the lane-sweep handoff scans their own id so the three judge legs' findings steps stay identical. Update the workflow contract tests to the scan step: each asserts the scan before its upload, over the same path, and the upload's gate on the scan's clean output. Refs: #8295 Co-Authored-By: Claude Opus 5.5 --- .github/artifact-retention.json | 12 +- .../workflow.test.mjs | 1 + .github/scripts/plugin-eval/workflow.test.mjs | 11 +- .github/scripts/pr-merge/merge-job.test.mjs | 108 +++++++++++++----- .github/scripts/pr-merge/workflow.test.mjs | 71 +++++++++--- .../scripts/pr-review-main/workflow.test.mjs | 9 +- .../pr-unblock/workflow-gates.test.mjs | 9 +- .github/scripts/pr-verify/workflow.test.mjs | 13 ++- .github/workflows/eval-browser-dispatch.yml | 2 +- .github/workflows/intake-triage.yml | 6 +- .../workflows/maintenance-audit-lane-runs.yml | 28 ++--- .../maintenance-reconcile-lost-runs.yml | 8 +- .github/workflows/pr-feedback-dispatch.yml | 6 +- .github/workflows/pr-merge.yml | 72 +++++++++--- .github/workflows/pr-review-main.yml | 6 +- .github/workflows/pr-run-activity-read.yml | 3 +- .github/workflows/pr-unblock-dispatch.yml | 2 +- .github/workflows/pr-unblock.yml | 8 +- .github/workflows/pr-verify.yml | 6 +- scripts/ci-rerun-ci-status.test.sh | 26 ++++- 20 files changed, 297 insertions(+), 110 deletions(-) diff --git a/.github/artifact-retention.json b/.github/artifact-retention.json index 1bf40bca3ad..15f797a15df 100644 --- a/.github/artifact-retention.json +++ b/.github/artifact-retention.json @@ -44,8 +44,8 @@ "plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py _lane_reruns reads records of runs created within confirm-wait-minutes plus FULL_RUN_ALLOWANCE", ".github/scripts/pr-merge/lane-records.mjs carries the records the post-merge check proved, each only until its artifact's expires_at, so the carry adds no lifetime" ], - "reason": "The look-back is merged_since, the later of the enabling commit and the newest demotion clear, with no bound in days. It stops at the post-merge check's 1000-run listing cap (babysit_gh.py RUN_SEARCH_CAP): past that the check is unreadable and holds the lane whatever this row says. At the 48 and 57 dispatch runs in the two days to 2026-10-31T05:00Z, after the 30-minute cap and its bypass landed, that is about 19 days, and demotion clears had come at most 4.8 days apart; 30 days leaves about 11 more for a stopped check, whose runs then accumulate only at the human merge rate. This holds while the listing reaches 1000 runs, or a clear moves the floor, within 30 days: at least 34 runs a day. A record that has left the listing never verifies a merge: its run stops counting as the lane's, so the merge holds as an unlisted record until a later recorded run covers it, and a flaked or in-between red run can read red and demote. It holds only workflow-written integers, so it is not scanned.", - "scan": "none" + "reason": "The look-back is merged_since, the later of the enabling commit and the newest demotion clear, with no bound in days. It stops at the post-merge check's 1000-run listing cap (babysit_gh.py RUN_SEARCH_CAP): past that the check is unreadable and holds the lane whatever this row says. At the 48 and 57 dispatch runs in the two days to 2026-10-31T05:00Z, after the 30-minute cap and its bypass landed, that is about 19 days, and demotion clears had come at most 4.8 days apart; 30 days leaves about 11 more for a stopped check, whose runs then accumulate only at the human merge rate. This holds while the listing reaches 1000 runs, or a clear moves the floor, within 30 days: at least 34 runs a day. A record that has left the listing never verifies a merge: its run stops counting as the lane's, so the merge holds as an unlisted record until a later recorded run covers it, and a flaked or in-between red run can read red and demote.", + "scan": "upload" }, "pr-merge-rerun-*": { "retention-days": "max", @@ -54,16 +54,16 @@ ".github/scripts/pr-merge/lane-records.mjs keeps the records the post-merge check proved" ], "max-reader": "plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py", - "reason": "The post-merge check reads the lane's re-run record back to merged_since, only while the run's pr-merge-dispatched record exists; keep this at or above that family's days. It holds only workflow-written integers, so it is not scanned.", - "scan": "none" + "reason": "The post-merge check reads the lane's re-run record back to merged_since, only while the run's pr-merge-dispatched record exists; keep this at or above that family's days.", + "scan": "upload" }, "pr-merge-alerted-*": { "retention-days": 1, "readers": [ "plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py LANE_ALERT_ARTIFACT (`--lane-alerted`), asked by .github/workflows/pr-merge.yml post-merge-dispatch only for full runs created within LOOKBACK_HOURS (6)" ], - "reason": "Read back only for full runs created in the last 6 hours (pr-merge.yml LOOKBACK_HOURS), and each record is uploaded after its run was created; 1 day, the minimum, outlasts that window. It holds only workflow-written integers, so it is not scanned.", - "scan": "none" + "reason": "Read back only for full runs created in the last 6 hours (pr-merge.yml LOOKBACK_HOURS), and each record is uploaded after its run was created; 1 day, the minimum, outlasts that window.", + "scan": "upload" }, "pr-merge-post-merge-report": { "retention-days": 1, diff --git a/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs b/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs index 1cff3fd41e4..733c9150e12 100644 --- a/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs +++ b/.github/scripts/maintenance-audit-lane-runs/workflow.test.mjs @@ -158,6 +158,7 @@ test("the later legs repeat the key leg's steps but for the credential, the leg const normalize = (job) => steps(job) .filter((s) => !/^Check the (key|subscription) leg$|^(Record|Upload) the handoff$|^Check the credential$/.test(s.name)) + .filter((s) => !(s.name === "Scan the upload for credentials" && /\/lane-sweep-handoff\//.test(s.block))) .map(({ name, block }) => ({ name, lines: block.split("\n").filter((line) => line.trim() !== "" && !/^\s*#/.test(line) && !differs.some((re) => re.test(line))), diff --git a/.github/scripts/plugin-eval/workflow.test.mjs b/.github/scripts/plugin-eval/workflow.test.mjs index dea0c7a4aaa..abe3a987aae 100644 --- a/.github/scripts/plugin-eval/workflow.test.mjs +++ b/.github/scripts/plugin-eval/workflow.test.mjs @@ -48,5 +48,14 @@ test("a pull request run proves the policy denies the reads before any case runs test("a pull request run redacts its summary and uploads no results", () => { assert.match(step("Summarize the results"), /REDACT: \$\{\{ github\.event_name == 'pull_request' && '--redact' \|\| '' \}\}/); assert.match(step("Summarize the results"), /summarize\.mjs "\$results" --plugin "\$name" "\$\{redact\[@\]\}"/); - assert.match(step("Upload the eval results"), /if: always\(\) && steps\.stage\.outputs\.go == 'true' && github\.event_name == 'workflow_dispatch'\n/); + // Only a dispatched run scans and uploads, and the upload needs the scan's clean output. + const scan = step("Scan the upload for credentials"); + assert.match(scan, /if: >-\n {10}always\(\) && steps\.stage\.outputs\.go == 'true' && github\.event_name == 'workflow_dispatch'\n/); + assert.match(scan, /\n {8}id: scan-upload\n/); + assert.match(scan, /\n {10}path: \$\{\{ runner\.temp \}\}\/eval-results\/\n/); + assert.match( + step("Upload the eval results"), + /if: >-\n {10}\(always\(\) && steps\.stage\.outputs\.go == 'true' && github\.event_name == 'workflow_dispatch'\) && steps\.scan-upload\.outputs\.clean == 'true'\n/, + ); + assert.ok(text.indexOf("- name: Scan the upload for credentials") < text.indexOf("- name: Upload the eval results")); }); diff --git a/.github/scripts/pr-merge/merge-job.test.mjs b/.github/scripts/pr-merge/merge-job.test.mjs index 022f057da19..4fdf4539503 100644 --- a/.github/scripts/pr-merge/merge-job.test.mjs +++ b/.github/scripts/pr-merge/merge-job.test.mjs @@ -53,18 +53,31 @@ test("the re-dispatch for earlier lane merges waits on neither the listing nor t "Read the lane records the last run kept", "Read the post-merge report the last run kept", "Check earlier lane merges", + "Scan the upload for credentials", "Keep the post-merge report for enable-auto-merge", "Read main's full-run cover", "Find lane merges no full run covers", "Start the full CI run on main", + "Scan the upload for credentials", "Record the dispatched run", "Re-run a red lane-dispatched full run", + "Scan the upload for credentials", "Record the re-run", "Send a red full run to the demotion alert", + "Scan the upload for credentials", "Record the alert sent", "Log the API budget at the end", ], ); + // Each upload waits on the credential scan just before it. + for (const [name, predicate] of [ + ["Keep the post-merge report for enable-auto-merge", "steps.post-merge.conclusion == 'success'"], + ["Record the dispatched run", "steps.dispatch.outputs.run-id != ''"], + ["Record the re-run", "!cancelled() && steps.rerun.outputs.recorded == 'true'"], + ["Record the alert sent", "!cancelled() && steps.alert.outputs.run-id != ''"], + ]) { + assert.match(assertScanned(name, predicate, list), /\n {8}continue-on-error: true\n/, name); + } assert.doesNotMatch(early, /steps\.candidates|steps\.gate|needs\.merge/); assert.match(step("Check earlier lane merges", list), /\n {8}if: steps\.mode\.outputs\.live == 'true'\n/); assert.match(step("Check earlier lane merges", list), /babysit_postmerge\.py[\s\S]*--lane-merges-only/); @@ -234,30 +247,54 @@ test("a listing whose open count cannot be read holds the job", () => { }); // A step's `if:` as GitHub evaluates it on the schedule, with `steps` and -// `needs` as given. No `if:` runs it. -const runsOn = (name, context, needs = {}) => { - const found = /\n {8}if: (>-\n(?: {10}.*\n)+|.*\n)/.exec(`${step(name)}\n`); +// `needs` as given. No `if:` runs it. Takes a step block, since the credential +// scans share one name. +const blockRunsOn = (block, context, needs = {}) => { + const found = /\n {8}if: (>-\n(?: {10}.*\n)+|.*\n)/.exec(`${block}\n`); if (!found) return true; - const script = `return (${found[1].replace(">-\n", "").replace(/\.([a-z]+(?:-[a-z]+)+)/g, '["$1"]')});`; + const script = `return (${found[1].replace(">-\n", "").replace(/\.([a-z]+(?:-[a-z0-9]+)+)/g, '["$1"]')});`; return new Function("steps", "needs", "github", "always", script)(context, needs, { event_name: "schedule" }, () => true); }; +const runsOn = (name, context, needs = {}) => blockRunsOn(step(name), context, needs); +const SCAN = "Scan the upload for credentials"; +const isScanId = (id) => /^scan-upload(-\d+)?$/.test(id); // The merge job's steps at the listing's end: any step not named here has not -// run, as on the schedule with a live lane. +// run, as on the schedule with a live lane. Every credential scan reads as +// clean, so an upload runs or not on its own condition. const atListing = (candidates) => new Proxy( { "lane-token": { outputs: { token: "t" } }, mode: { outputs: { live: "true", "report-only": "false" } }, kill: { outputs: { proceed: "true" } }, candidates }, - { get: (known, id) => known[id] ?? { outcome: "skipped", outputs: {} } }, + { get: (known, id) => known[id] ?? (isScanId(id) ? { outcome: "success", outputs: { clean: "true" } } : { outcome: "skipped", outputs: {} }) }, ); +// An upload gated by the credential scan just before it: the scan runs on the +// upload's own condition and scans the same path, and the upload's folded +// `if:` is that condition and the scan's clean output. Returns the scan. +const pathOf = (block) => /\n {10}path: (\|\n(?: {12}.*\n)+|.*\n)/.exec(`${block}\n`)?.[1]; +const assertScanned = (name, predicate, list = steps) => { + const at = list.findIndex((s) => s.name === name); + assert.ok(at > 0, `step ${name}`); + const scan = list[at - 1]; + assert.equal(scan.name, SCAN, `the step before ${name}`); + const id = /\n {8}id: (scan-upload(?:-\d+)?)\n/.exec(scan.block)?.[1]; + assert.ok(id, `the scan before ${name} has an id`); + assert.ok(scan.block.includes(`\n if: >-\n ${predicate}\n`), `the scan before ${name} runs on its condition`); + assert.ok(scan.block.includes("\n uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository]\n"), `the scan before ${name}`); + const upload = list[at].block; + assert.ok(upload.includes(`\n if: >-\n (${predicate}) && steps.${id}.outputs.clean == 'true'\n`), `${name} waits on ${id}`); + assert.ok(pathOf(upload), `${name} has a path`); + assert.equal(pathOf(scan.block), pathOf(upload), `the scan before ${name} scans its path`); + return scan.block; +}; const dispatched = (code) => ({ "post-merge-dispatch": { result: "success", outputs: { "post-merge-code": code, "rate-limited": "" } } }); // Everything after the listing that reads, writes or files waits on `listed`; // a hold runs only the cleanup and the summary, and files no failed lane merge. test("no step after the listing acts on a held listing", () => { - const after = steps.slice(steps.findIndex((s) => s.name === "List the candidate PRs") + 1).map((s) => s.name); + const after = steps.slice(steps.findIndex((s) => s.name === "List the candidate PRs") + 1); assert.ok(after.length > 20); - const running = (candidates, code) => after.filter((name) => runsOn(name, atListing(candidates), dispatched(code))); + const running = (candidates, code) => after.filter(({ block }) => blockRunsOn(block, atListing(candidates), dispatched(code))).map((s) => s.name); const held = { outcome: "success", outputs: { held: "true" } }; - const always = ["Count the sweep's refusals", "Upload the sweep's verdicts", "Log the API budget at job end", "Write the sweep cost", "Revoke the lane token"]; + const always = ["Count the sweep's refusals", SCAN, "Upload the sweep's verdicts", "Log the API budget at job end", "Write the sweep cost", "Revoke the lane token"]; assert.deepEqual(running(held, "0"), always); assert.deepEqual(running(held, "1"), always); // The same steps that a hold stops do run once the listing is whole. @@ -375,11 +412,17 @@ test("a held listing files no failed lane merge; a run that listed goes by its o }); test("a held listing writes a sweep summary with no read, and a monitor can tell it held", () => { - const skipped = { gate: { outcome: "skipped" }, candidates: { outputs: {} } }; + const skipped = { gate: { outcome: "skipped" }, candidates: { outputs: {} }, "scan-upload-5": { outputs: { clean: "true" } } }; for (const name of ["Count the sweep's refusals", "Upload the sweep's verdicts", "Write the sweep cost"]) { assert.equal(runsOn(name, { ...skipped, candidates: { outputs: { held: "true" } } }), true, name); assert.equal(runsOn(name, skipped), false, name); } + // The verdicts' credential scan runs on the same hold, and a scan that is + // not clean withholds the upload. + const scan = assertScanned("Upload the sweep's verdicts", "always() && (steps.gate.outcome != 'skipped' || steps.candidates.outputs.held == 'true')"); + assert.equal(blockRunsOn(scan, { ...skipped, candidates: { outputs: { held: "true" } } }), true); + assert.equal(blockRunsOn(scan, skipped), false); + assert.equal(runsOn("Upload the sweep's verdicts", { ...skipped, candidates: { outputs: { held: "true" } }, "scan-upload-5": { outputs: {} } }), false); const dir = tempDir("pr-merge-held-summary-"); try { const bin = join(dir, "bin"); @@ -579,10 +622,8 @@ test("both token-holding jobs read the carried reads from the last trusted run's assert.match(upload, /uses: actions\/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 /); assert.ok(upload.includes("name: pr-merge-run-cache\n") && upload.includes(`path: ${carried}\n`)); assert.ok(upload.includes("retention-days: 1\n")); - assert.match(upload, /\n {8}if: always\(\) && steps\.carry\.outputs\.carried == 'true'\n/); - for (const name of ["Keep the verdict reads that never change", "Upload the verdict reads for the next run"]) { - assert.match(step(name), /\n {8}continue-on-error: true\n/, name); - } + const scan = assertScanned("Upload the verdict reads for the next run", "always() && steps.carry.outputs.carried == 'true'"); + for (const block of [keep, scan, upload]) assert.match(block, /\n {8}continue-on-error: true\n/); }); // The merge gate reads no lane verdict, so enable-auto-merge's reader is the @@ -610,8 +651,10 @@ test("enable-auto-merge uploads its verifier reads for the next run", () => { assert.match(upload, /uses: actions\/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 /); assert.ok(upload.includes("name: pr-merge-verify-run-cache\n") && upload.includes(`path: ${carried}\n`)); assert.ok(upload.includes("retention-days: 1\n")); - assert.match(upload, /\n {8}if: always\(\) && steps\.carry\.outputs\.kept == 'true'\n/); - for (const block of [keep, upload]) assert.match(block, /\n {8}continue-on-error: true\n/); + const scan = assertScanned("Upload the verdict reads for the next run", "always() && steps.carry.outputs.kept == 'true'", list); + assert.ok(upload.includes("\n if: >-\n (always() && steps.carry.outputs.kept == 'true') && steps.scan-upload-2.outputs.clean == 'true'\n")); + // Keeping, scanning and uploading the reads never fails the job. + for (const block of [keep, scan, upload]) assert.match(block, /\n {8}continue-on-error: true\n/); }); // The head-ref map (head-ref-map.mjs): read before the gate from the last @@ -653,10 +696,8 @@ test("the gate reads the head-ref map the last run uploaded and uploads the next const upload = step("Upload them for the next run"); assert.match(upload, /uses: actions\/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 /); assert.ok(upload.includes("name: pr-merge-head-refs\n") && upload.includes(`path: ${mapOut}\n`)); - assert.match(upload, /\n {8}if: always\(\) && steps\.head-refs\.outcome == 'success'\n/); - for (const name of ["Read the head refs the last run kept", "Keep the head refs the gate read", "Upload them for the next run"]) { - assert.match(step(name), /\n {8}continue-on-error: true\n/, name); - } + const scan = assertScanned("Upload them for the next run", "always() && steps.head-refs.outcome == 'success'"); + for (const block of [read, keep, scan, upload]) assert.match(block, /\n {8}continue-on-error: true\n/); for (const s of steps.filter((s) => s.block.includes("actions/cache/"))) { assert.ok(!s.block.includes("head-refs"), s.name); } @@ -697,7 +738,7 @@ test("every post-merge check reads the lane records, and only the merge job uplo const upload = step("Upload the lane records for the next run"); assert.match(upload, /uses: actions\/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 /); assert.ok(upload.includes("name: pr-merge-lane-records\n") && upload.includes(`path: ${out}\n`)); - assert.match(upload, /\n {8}if: always\(\) && steps\.lane-records\.outcome == 'success'\n/); + assertScanned("Upload the lane records for the next run", "always() && steps.lane-records.outcome == 'success'"); assert.doesNotMatch(jobBlock("post-merge-dispatch"), /name: pr-merge-lane-records|lane-records\.mjs write/); for (const s of steps.filter((s) => s.block.includes("actions/cache/"))) { assert.ok(!s.block.includes("lane-records"), s.name); @@ -1354,11 +1395,16 @@ test("after the gate's rate-limit stop, no later merge-job step calls GitHub", ( if (path === "steps.lane-token.outputs.token") return "t"; if (path === "steps.mode.outputs.report-only") return "false"; if (path === "steps.candidates.outputs.listed") return "true"; + // Every credential scan reads as clean: the rows go by their own condition. + if (/^steps\.scan-upload(-\d+)?\.outputs\.clean$/.test(path)) return "true"; return ""; }; for (const name of ["Hand the gate's rows to the check writer", "Re-fire lane runs refused as workflow-modified", "Clear escalations whose reason no longer holds"]) { assert.equal(stepRuns(step(name), context, "failure"), limited !== "true", `${name} with rate-limited=${limited}`); } + const rowsScan = steps[steps.findIndex((s) => s.name === "Hand the gate's rows to the check writer") - 1]; + assert.equal(rowsScan.name, SCAN); + assert.equal(stepRuns(rowsScan.block, context, "failure"), limited !== "true", `the rows' scan with rate-limited=${limited}`); } // The refusal count still writes its summary, with no read of the run. const dir = tempDir("pr-merge-count-"); @@ -1490,7 +1536,10 @@ test("the skip step runs just before the gate and its record between the carry a assert.equal(at("Skip refusals whose inputs have not changed"), at("Run the merge gate on each candidate") - 1); assert.ok(at("Record a failed lane merge") < at("Skip refusals whose inputs have not changed")); assert.equal(at("Keep the refusals the next run may skip"), at("Keep the verdict reads that never change") + 1); - assert.equal(at("Upload the verdict reads for the next run"), at("Keep the refusals the next run may skip") + 1); + // The upload's credential scan sits between the record and the upload. + assert.equal(names[at("Keep the refusals the next run may skip") + 1], SCAN); + assert.match(steps[at("Keep the refusals the next run may skip") + 1].block, /\n {8}id: scan-upload-4\n/); + assert.equal(at("Upload the verdict reads for the next run"), at("Keep the refusals the next run may skip") + 2); const filter = step("Skip refusals whose inputs have not changed"); assert.match(filter, /\n {8}continue-on-error: true\n/); assert.match(filter, /\n {8}if: steps\.lane-token\.outputs\.token != '' && steps\.candidates\.outputs\.listed == 'true'\n/); @@ -1526,7 +1575,7 @@ test("the budget comes from the pipeline config, rotation runs before the gate, assert.ok(names.indexOf("List the candidate PRs") < rotation && rotation < gate && gate < record); assert.match(step("Start after the candidate the last run reached"), /\n {8}continue-on-error: true\n/); const upload = step("Record where the gate stopped"); - assert.match(upload, /\n {8}if: always\(\) && steps\.gate\.outcome != 'skipped'\n/); + assertScanned("Record where the gate stopped", "always() && steps.gate.outcome != 'skipped'"); assert.match(upload, /uses: actions\/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 /); assert.match(upload, /\n {10}name: pr-merge-gate-cursor\n/); }); @@ -1539,7 +1588,11 @@ test("each candidate's check run is written by its own job, the only one holding assert.equal(text.match(/\n {6}checks: write\n/g)?.length, 1); const upload = step("Hand the gate's rows to the check writer"); assert.ok(steps.findIndex((s) => s.name === "Run the merge gate on each candidate") < steps.findIndex((s) => s.name === "Hand the gate's rows to the check writer")); - assert.match(upload, /\n {8}if: >-\n {10}always\(\) && steps\.gate\.outcome != 'skipped' && steps\.mode\.outputs\.report-only == 'false'\n {10}&& steps\.gate\.outputs\.rate-limited != 'true'\n/); + const scan = assertScanned( + "Hand the gate's rows to the check writer", + "always() && steps.gate.outcome != 'skipped' && steps.mode.outputs.report-only == 'false' && steps.gate.outputs.rate-limited != 'true'", + ); + for (const block of [scan, upload]) assert.match(block, /\n {8}continue-on-error: true\n/); assert.match(upload, /\n {10}name: pr-merge-gate-rows\n {10}path: \$\{\{ runner\.temp \}\}\/pr-merge-summary\.json\n/); assert.match(job, /\n {6}gate-rows: \$\{\{ steps\.rows\.outcome == 'success' \}\}\n/); @@ -1846,7 +1899,7 @@ test("the re-run record is named by run and attempt, uploaded only after a recor ); assert.match(rerun, /\n {10}GH_TOKEN: \$\{\{ github\.token \}\}\n/); const upload = step("Record the re-run", list); - assert.match(upload, /\n {8}if: "!cancelled\(\) && steps\.rerun\.outputs\.recorded == 'true'"\n/); + assertScanned("Record the re-run", "!cancelled() && steps.rerun.outputs.recorded == 'true'", list); assert.match(upload, /uses: actions\/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 /); assert.match(upload, /\n {10}name: pr-merge-rerun-\$\{\{ steps\.post-merge\.outputs\.lane-rerun \}\}-attempt-2\n/); }); @@ -1995,7 +2048,7 @@ test("a red full run is sent to post-merge-demotion-alert.yml once, by its run i assert.deepEqual(JSON.parse(run.record), { alerter_run_id: 700, run_id: 41 }); assert.ok(run.calls[0].includes("actions/workflows/pr-require-checks.yml/runs?event=workflow_dispatch&branch=main&created=%3E%3D"), run.calls[0]); const upload = step("Record the alert sent", earlySteps()); - assert.match(upload, /\n {8}if: "!cancelled\(\) && steps\.alert\.outputs\.run-id != ''"\n/); + assertScanned("Record the alert sent", "!cancelled() && steps.alert.outputs.run-id != ''", earlySteps()); assert.match(upload, /uses: actions\/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 /); assert.match(upload, /\n {10}name: pr-merge-alerted-\$\{\{ steps\.alert\.outputs\.run-id \}\}\n/); }); @@ -2155,8 +2208,9 @@ test("the alert send runs after the re-run while the lane is live, and stops aft /\n {8}if: >-\n {10}!cancelled\(\)\n {10}&& steps\.kill\.outputs\.proceed == 'true'\n {10}&& steps\.mode\.outputs\.live == 'true'\n {10}&& steps\.post-merge\.outputs\.code != '75'\n {10}&& steps\.cover\.outputs\.rate-limited != 'true'\n {10}&& steps\.dispatch\.outputs\.rate-limited != 'true'\n {10}&& steps\.rerun\.outputs\.rate-limited != 'true'\n/, ); const names = list.map((s) => s.name); - assert.deepEqual(names.slice(-3), [ + assert.deepEqual(names.slice(-4), [ "Send a red full run to the demotion alert", + SCAN, "Record the alert sent", "Log the API budget at the end", ]); diff --git a/.github/scripts/pr-merge/workflow.test.mjs b/.github/scripts/pr-merge/workflow.test.mjs index 95a80e51f39..bf762953cbf 100644 --- a/.github/scripts/pr-merge/workflow.test.mjs +++ b/.github/scripts/pr-merge/workflow.test.mjs @@ -54,7 +54,14 @@ test("dispatch-full-run ends once it dispatches: no wait on the run it started", const dispatch = job("dispatch-full-run"); assert.deepEqual( [...dispatch.matchAll(/\n {6}- name: (.*)/g)].map((m) => m[1]), - ["Check out the api-budget library", "Read main's full-run cover", "Start the full CI run on main", "Record the dispatched run", "Log the API budget at the end"], + [ + "Check out the api-budget library", + "Read main's full-run cover", + "Start the full CI run on main", + "Scan the upload for credentials", + "Record the dispatched run", + "Log the API budget at the end", + ], ); assert.doesNotMatch(dispatch, /sleep 180|actions\/runs\/\$RUN_ID|post-merge-demotion-alert\.yml\/dispatches/); const timeout = Number(/\n {4}timeout-minutes: (\d+)\n/.exec(dispatch)[1]); @@ -239,12 +246,12 @@ test("dispatch-full-run starts nothing within the cap, and anything outside it", // resolved merge-pr slot applies; a slot `enabled: false` or whose // `applies-when` does not match prints the skip reason instead (#7636). // A merge-job step's condition, with or without an `id:` line before it. -const mergeIf = (name) => { - const body = job("merge").split(`\n - name: ${name}\n`)[1].split("\n - name: ")[0]; +const conditionOf = (body, name) => { const found = /(?:^|\n) {8}if: (?:>-\n((?: {10}.*\n)+)|(.*)\n)/.exec(body); assert.ok(found, `${name} has a condition`); return (found[1] ?? found[2]).replaceAll("\n", " "); }; +const mergeIf = (name) => conditionOf(job("merge").split(`\n - name: ${name}\n`)[1].split("\n - name: ")[0], name); const configured = (applies, outcome = "success") => ({ config: { outcome, outputs: { applies, "skip-reason": applies === "true" ? "" : "disabled-by-config" } } }); test("the merge job requests its lane token and reads its mode only while the merge-pr slot applies", () => { @@ -269,12 +276,19 @@ test("a merge-pr slot the config turns off prints its skip reason before the tok assert.match(block, /\n {10}REASON: \$\{\{ steps\.config\.outputs\.skip-reason \}\}\n/); assert.match(block, /::notice::merge-pr does not apply \(\$\{REASON:-no reason\}\)/); // Every step after the token request waits on a token, or on a step that does. - const after = names.slice(names.indexOf("Request the lane token") + 1); - for (const name of after) { - const condition = mergeIf(name); - const unminted = { ...configured("false"), "lane-token": { outputs: { token: "" } }, mode: { outputs: {} }, candidates: { outcome: "skipped", outputs: {} }, gate: { outcome: "skipped" }, "head-refs": { outcome: "skipped" }, "post-merge": { outcome: "skipped", outputs: {} }, "lane-records": { outcome: "skipped" }, carry: { outputs: {} } }; + // Each credential scan reads as clean, so an upload waits on its own + // condition, not only on its scan. Steps go by block: the scans share a name. + const blocks = merge.split("\n - name: ").slice(1); + const after = blocks.slice(names.indexOf("Request the lane token") + 1); + for (const block of after) { + const name = block.slice(0, block.indexOf("\n")); + const condition = conditionOf(block, name); + const unminted = new Proxy( + { ...configured("false"), "lane-token": { outputs: { token: "" } }, mode: { outputs: {} }, candidates: { outcome: "skipped", outputs: {} }, gate: { outcome: "skipped" }, "head-refs": { outcome: "skipped" }, "post-merge": { outcome: "skipped", outputs: {} }, "lane-records": { outcome: "skipped" }, carry: { outputs: {} } }, + { get: (known, id) => known[id] ?? (/^scan-upload(-\d+)?$/.test(id) ? { outcome: "success", outputs: { clean: "true" } } : undefined) }, + ); const always = () => true; - const value = new Function("steps", "github", "needs", "always", `return (${condition.replace(/\.([a-z]+(?:-[a-z]+)+)/g, '["$1"]')});`)(unminted, { event_name: "schedule" }, { "post-merge-dispatch": { result: "success", outputs: { "rate-limited": "" } } }, always); + const value = new Function("steps", "github", "needs", "always", `return (${condition.replace(/\.([a-z]+(?:-[a-z0-9]+)+)/g, '["$1"]')});`)(unminted, { event_name: "schedule" }, { "post-merge-dispatch": { result: "success", outputs: { "rate-limited": "" } } }, always); // It reads the job's own token, and runs on a kill switch too. if (name === "Log the API budget at job end") { continue; @@ -316,7 +330,8 @@ test("post-merge-dispatch reads the cover only while the lane is live", () => { assert.doesNotMatch(job("dispatch-full-run"), /steps\.(kill|mode)\./); }); -// contents: read only checks out the api-budget library, sparse, from main. +// contents: read only checks out the api-budget library and the upload scan +// with the rules it reads, sparse, from main. test("the dispatch job's token is actions: write and contents: read, and nothing else", () => { const dispatch = job("dispatch-full-run"); const block = /\n {4}permissions:\n((?: {6}[a-z-]+: [a-z]+.*\n)+)/.exec(dispatch)[1]; @@ -326,7 +341,7 @@ test("the dispatch job's token is actions: write and contents: read, and nothing ); const checkout = dispatch.split("\n - name: Check out the api-budget library\n")[1].split("\n - name: ")[0]; assert.match(checkout, /^ {8}continue-on-error: true\n/); - assert.match(checkout, /\n {10}ref: refs\/heads\/main\n {10}path: \.base\n {10}persist-credentials: false\n {10}sparse-checkout: \|\n {12}\/\.github\/actions\/log-api-budget\/\n {10}sparse-checkout-cone-mode: false\n/); + assert.match(checkout, /\n {10}ref: refs\/heads\/main\n {10}path: \.base\n {10}persist-credentials: false\n {10}sparse-checkout: \|\n {12}\/\.github\/actions\/log-api-budget\/\n {12}\/\.github\/actions\/scan-upload\/\n {12}\/lib\/publish-gate\.mjs\n {10}sparse-checkout-cone-mode: false\n/); }); // The cover read and each dispatch POST leave a rate-limit reading, so the @@ -1176,9 +1191,25 @@ test("a dispatch that returns no run id is not sent again and records nothing", }); test("a dispatch that failed skips the record", () => { - for (const id of ["dispatch-full-run", "post-merge-dispatch"]) { - const record = job(id).split("\n - name: Record the dispatched run\n")[1]; - assert.match(record, /^ {8}if: steps\.dispatch\.outputs\.run-id != ''\n/, id); + for (const [id, scanId] of [ + ["dispatch-full-run", "scan-upload"], + ["post-merge-dispatch", "scan-upload-2"], + ]) { + const [before, record] = job(id).split("\n - name: Record the dispatched run\n"); + assert.ok( + record.startsWith(` if: >-\n (steps.dispatch.outputs.run-id != '') && steps.${scanId}.outputs.clean == 'true'\n`), + id, + ); + // The credential scan just before it runs on the same condition and scans + // the file the record uploads. + const scan = before.split("\n - name: ").at(-1); + assert.ok( + scan.startsWith( + `Scan the upload for credentials\n id: ${scanId}\n if: >-\n steps.dispatch.outputs.run-id != ''\n continue-on-error: true\n uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository]\n with:\n path: \${{ runner.temp }}/dispatched.json\n`, + ), + id, + ); + assert.ok(record.includes("\n path: ${{ runner.temp }}/dispatched.json\n"), id); } }); @@ -1432,7 +1463,19 @@ test("enable-auto-merge reads the verifier answers only from a trusted run and u assert.match(armStep, /\n {10}export [^\n]*\bVERIFY_CACHE\b/); const upload = step("Upload the verifier answers for the next run"); assert.ok(upload, "an answers upload"); - assert.match(upload, /^ {8}if: >-\n {10}always\(\) && steps\.arm\.outcome != 'skipped'\n {10}&& \(github\.event_name == 'schedule' \|\| github\.event_name == 'workflow_dispatch'\)\n/); + const predicate = "always() && steps.arm.outcome != 'skipped' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')"; + assert.ok(upload.startsWith(` if: >-\n (${predicate}) && steps.scan-upload.outputs.clean == 'true'\n`), upload); + // The credential scan just before it runs on the same condition over the + // same two files. + const scan = arm.split("\n - name: Upload the verifier answers for the next run\n")[0].split("\n - name: ").at(-1); + assert.ok(scan.startsWith(`Scan the upload for credentials\n id: scan-upload\n if: >-\n ${predicate}\n`), scan); + assert.ok(scan.includes("\n uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository]\n"), scan); + // A best-effort upload: neither it nor its scan can fail the job. + for (const block of [scan, upload]) assert.ok(block.includes("\n continue-on-error: true\n"), block); + assert.ok( + scan.includes("\n path: |\n ${{ runner.temp }}/verify-answers/verify-answers.json\n ${{ runner.temp }}/verify-answers/eam-skip.json\n"), + scan, + ); assert.match(upload, /\n {8}uses: actions\/upload-artifact@[0-9a-f]{40} /); // Bounded, so the job's timeout stays above the sum of its steps'. assert.match(upload, /\n {8}timeout-minutes: [0-9]+\n/); diff --git a/.github/scripts/pr-review-main/workflow.test.mjs b/.github/scripts/pr-review-main/workflow.test.mjs index e9264bf69d9..b9081313dab 100644 --- a/.github/scripts/pr-review-main/workflow.test.mjs +++ b/.github/scripts/pr-review-main/workflow.test.mjs @@ -798,8 +798,13 @@ test("the verdict uploads once, after it is written, under the writer's name", ( const uploads = STEPS[job].filter((s) => /uses: actions\/upload-artifact@/.test(s.block)); assert.equal(uploads.length, 1, job); assert.equal(uploads[0].name, "Upload the verdict"); - assert.ok(index("Write the verdict", job) < index("Upload the verdict", job)); - assert.match(uploads[0].block, /^ {8}if: steps\.verdict\.outcome == 'success'$/m); + // The written verdict is scanned just before it uploads, and uploads only on the scan's clean output. + assert.ok(index("Write the verdict", job) < index("Scan the upload for credentials", job), job); + assert.equal(index("Scan the upload for credentials", job) + 1, index("Upload the verdict", job), job); + const scan = step("Scan the upload for credentials", job); + assert.match(scan, /^ {8}id: scan-upload\n {8}if: >-\n {10}steps\.verdict\.outcome == 'success'$/m, job); + assert.match(scan, /^ {10}path: \$\{\{ runner\.temp \}\}\/pr-review-main-verdict\/verdict\.json$/m, job); + assert.match(uploads[0].block, /^ {8}if: >-\n {10}\(steps\.verdict\.outcome == 'success'\) && steps\.scan-upload\.outputs\.clean == 'true'$/m); assert.match(uploads[0].block, /^ {10}name: \$\{\{ steps\.verdict\.outputs\.name \}\}$/m); // The tree is git's, the head and base the trigger gate's: never the model's. const write = step("Write the verdict", job); diff --git a/.github/scripts/pr-unblock/workflow-gates.test.mjs b/.github/scripts/pr-unblock/workflow-gates.test.mjs index d650853afba..bcf5977b66f 100644 --- a/.github/scripts/pr-unblock/workflow-gates.test.mjs +++ b/.github/scripts/pr-unblock/workflow-gates.test.mjs @@ -425,8 +425,15 @@ test("pr-unblock.yml: a pass-only select uploads its context but never proceeds assert.deepEqual(outputs("success", { proceed: "true" }), { proceed: "true", "resolve-only": "" }); assert.deepEqual(outputs("success", { resolveOnly: "true" }), { proceed: "", "resolve-only": "true" }); assert.deepEqual(outputs("skipped", {}), { proceed: "", "resolve-only": "" }); + // The context is scanned on the same condition and uploads only on the scan's clean output. + const scan = block.findIndex((line) => line === " id: scan-upload"); + assert.equal(ifAt(block, scan + 1, 8), "steps.select.outputs.proceed == 'true' || steps.select.outputs.resolve-only == 'true'"); const upload = block.findIndex((line) => line === " id: upload"); - assert.equal(ifAt(block, upload + 1, 8), "steps.select.outputs.proceed == 'true' || steps.select.outputs.resolve-only == 'true'"); + assert.ok(scan > 0 && scan < upload); + assert.equal( + ifAt(block, upload + 1, 8), + "(steps.select.outputs.proceed == 'true' || steps.select.outputs.resolve-only == 'true') && steps.scan-upload.outputs.clean == 'true'", + ); }); // The post-hoc review of #9565: the job checked out, set up and staged on diff --git a/.github/scripts/pr-verify/workflow.test.mjs b/.github/scripts/pr-verify/workflow.test.mjs index 6c3a80f4816..a1d12f0a750 100644 --- a/.github/scripts/pr-verify/workflow.test.mjs +++ b/.github/scripts/pr-verify/workflow.test.mjs @@ -126,8 +126,17 @@ test("the upload is each job's only artifact step and follows the written verdic job, ); assert.ok(index("Judge the pull request", job) >= 0 && index("Write the verdict", job) > index("Judge the pull request", job), job); - assert.ok(index("Upload the verdict", job) > index("Write the verdict", job), job); - assert.match(uploads[0].block, /if: \$\{\{ !cancelled\(\) && steps\.verdict\.outcome == 'success' \}\}/, job); + // The written verdict is scanned just before it uploads, and uploads only on the scan's clean output. + assert.ok(index("Scan the upload for credentials", job) > index("Write the verdict", job), job); + assert.equal(index("Upload the verdict", job), index("Scan the upload for credentials", job) + 1, job); + const scan = step("Scan the upload for credentials", job); + assert.match(scan, /^ {8}id: scan-upload\n {8}if: >-\n {10}\$\{\{ !cancelled\(\) && steps\.verdict\.outcome == 'success' \}\}$/m, job); + assert.match(scan, /^ {10}path: \$\{\{ runner\.temp \}\}\/pr-verify-verdict\/verdict\.json$/m, job); + assert.match( + uploads[0].block, + /^ {8}if: >-\n {10}\(!cancelled\(\) && steps\.verdict\.outcome == 'success'\) && steps\.scan-upload\.outputs\.clean == 'true'$/m, + job, + ); // The verdict step names the retention (write-verdict.mjs retentionDays). assert.equal(input(uploads[0].block, "retention-days"), "${{ steps.verdict.outputs.retention-days }}", job); // A pass's retention follows this job's resolver (resolve-pr.mjs relinkedSince). diff --git a/.github/workflows/eval-browser-dispatch.yml b/.github/workflows/eval-browser-dispatch.yml index d462c6f4063..7e5badfadc3 100644 --- a/.github/workflows/eval-browser-dispatch.yml +++ b/.github/workflows/eval-browser-dispatch.yml @@ -681,7 +681,7 @@ jobs: id: scan-upload if: >- always() && steps.stage.outputs.go == 'true' && github.event_name == 'workflow_dispatch' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/eval-results/ diff --git a/.github/workflows/intake-triage.yml b/.github/workflows/intake-triage.yml index 7ba09712b34..a6599ce73a6 100644 --- a/.github/workflows/intake-triage.yml +++ b/.github/workflows/intake-triage.yml @@ -682,7 +682,7 @@ jobs: id: scan-upload if: >- steps.select.outputs.refine == 'true' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/refine-context/context.json @@ -1019,7 +1019,7 @@ jobs: id: scan-upload if: >- steps.collect.outcome == 'success' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/refinement/refinement.json @@ -1321,7 +1321,7 @@ jobs: id: scan-upload if: >- steps.collect.outcome == 'success' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/refinement/refinement.json diff --git a/.github/workflows/maintenance-audit-lane-runs.yml b/.github/workflows/maintenance-audit-lane-runs.yml index 75742a436a2..d3b702b31bc 100644 --- a/.github/workflows/maintenance-audit-lane-runs.yml +++ b/.github/workflows/maintenance-audit-lane-runs.yml @@ -115,7 +115,7 @@ jobs: id: scan-upload if: >- steps.collect.outcome == 'success' && steps.collect.outputs.units != '[]' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/lane-sweep @@ -352,16 +352,16 @@ jobs: run: node .base/.github/scripts/maintenance-audit-lane-runs/handoff.mjs record - name: Scan the upload for credentials - id: scan-upload + id: scan-handoff if: >- steps.handoff.outcome == 'success' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/lane-sweep-handoff/judge-handoff-${{ matrix.unit }}.json - name: Upload the handoff if: >- - (steps.handoff.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' + (steps.handoff.outcome == 'success') && steps.scan-handoff.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: judge-handoff-${{ matrix.unit }} @@ -397,16 +397,16 @@ jobs: run: node .base/.github/scripts/maintenance-audit-lane-runs/write-findings.mjs - name: Scan the upload for credentials - id: scan-upload-2 + id: scan-upload if: >- steps.findings.outcome == 'success' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/lane-sweep-findings/findings.json - name: Upload the findings if: >- - (steps.findings.outcome == 'success') && steps.scan-upload-2.outputs.clean == 'true' + (steps.findings.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: findings-${{ matrix.unit }} @@ -695,16 +695,16 @@ jobs: run: node .base/.github/scripts/maintenance-audit-lane-runs/handoff.mjs record - name: Scan the upload for credentials - id: scan-upload + id: scan-handoff if: >- steps.handoff.outcome == 'success' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/lane-sweep-handoff/judge-subscription-handoff-${{ matrix.unit }}.json - name: Upload the handoff if: >- - (steps.handoff.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' + (steps.handoff.outcome == 'success') && steps.scan-handoff.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: judge-subscription-handoff-${{ matrix.unit }} @@ -742,16 +742,16 @@ jobs: run: node .base/.github/scripts/maintenance-audit-lane-runs/write-findings.mjs - name: Scan the upload for credentials - id: scan-upload-2 + id: scan-upload if: >- steps.findings.outcome == 'success' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/lane-sweep-findings/findings.json - name: Upload the findings if: >- - (steps.findings.outcome == 'success') && steps.scan-upload-2.outputs.clean == 'true' + (steps.findings.outcome == 'success') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: findings-${{ matrix.unit }} @@ -1033,7 +1033,7 @@ jobs: id: scan-upload if: >- steps.findings.outcome == 'success' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/lane-sweep-findings/findings.json diff --git a/.github/workflows/maintenance-reconcile-lost-runs.yml b/.github/workflows/maintenance-reconcile-lost-runs.yml index 6a090c9a7ea..72f940a3dd7 100644 --- a/.github/workflows/maintenance-reconcile-lost-runs.yml +++ b/.github/workflows/maintenance-reconcile-lost-runs.yml @@ -188,7 +188,7 @@ jobs: id: scan-upload if: >- steps.plan.outputs.record-1 != '' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/reconcile-record/record.json @@ -206,7 +206,7 @@ jobs: id: scan-upload-2 if: >- steps.plan.outputs.record-2 != '' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/reconcile-record/record.json @@ -224,7 +224,7 @@ jobs: id: scan-upload-3 if: >- steps.plan.outputs.record-3 != '' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/reconcile-record/record.json @@ -254,7 +254,7 @@ jobs: id: scan-upload-4 if: >- ${{ !cancelled() && steps.plan.outcome == 'success' }} - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/reconcile-settled/settled.json diff --git a/.github/workflows/pr-feedback-dispatch.yml b/.github/workflows/pr-feedback-dispatch.yml index 8dc8322d70f..b66f5337276 100644 --- a/.github/workflows/pr-feedback-dispatch.yml +++ b/.github/workflows/pr-feedback-dispatch.yml @@ -248,7 +248,7 @@ jobs: id: scan-upload if: >- steps.deferred.outputs.name != '' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/pr-feedback-deferred.txt @@ -467,7 +467,7 @@ jobs: id: scan-upload if: >- always() && steps.dispatch.outputs.run-id != '' && steps.record.outcome == 'success' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/pr-feedback-dispatched.json @@ -906,7 +906,7 @@ jobs: if: >- steps.find.outputs.record != '' continue-on-error: true - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/rerun-ci-status-record.json diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index 8c48ba962b4..d12aefb929c 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -298,7 +298,7 @@ jobs: if: >- steps.post-merge.conclusion == 'success' continue-on-error: true - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/post-merge-report/ @@ -524,8 +524,18 @@ jobs: echo "{\"dispatcher_run_id\":$GITHUB_RUN_ID,\"run_id\":$id}" >"$RUNNER_TEMP/dispatched.json" echo "run-id=$id" >>"$GITHUB_OUTPUT" + - name: Scan the upload for credentials + id: scan-upload-2 + if: >- + steps.dispatch.outputs.run-id != '' + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + with: + path: ${{ runner.temp }}/dispatched.json + - name: Record the dispatched run - if: steps.dispatch.outputs.run-id != '' + if: >- + (steps.dispatch.outputs.run-id != '') && steps.scan-upload-2.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-merge-dispatched-${{ steps.dispatch.outputs.run-id }} @@ -631,8 +641,18 @@ jobs: echo "{\"rerunner_run_id\":$GITHUB_RUN_ID,\"run_id\":$RUN_ID,\"attempt\":2}" >"$RUNNER_TEMP/rerun.json" echo "recorded=true" >>"$GITHUB_OUTPUT" + - name: Scan the upload for credentials + id: scan-upload-3 + if: >- + !cancelled() && steps.rerun.outputs.recorded == 'true' + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + with: + path: ${{ runner.temp }}/rerun.json + - name: Record the re-run - if: "!cancelled() && steps.rerun.outputs.recorded == 'true'" + if: >- + (!cancelled() && steps.rerun.outputs.recorded == 'true') && steps.scan-upload-3.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-merge-rerun-${{ steps.post-merge.outputs.lane-rerun }}-attempt-2 @@ -767,8 +787,18 @@ jobs: exit 0 done <<<"$runs" + - name: Scan the upload for credentials + id: scan-upload-4 + if: >- + !cancelled() && steps.alert.outputs.run-id != '' + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + with: + path: ${{ runner.temp }}/alerted.json + - name: Record the alert sent - if: "!cancelled() && steps.alert.outputs.run-id != ''" + if: >- + (!cancelled() && steps.alert.outputs.run-id != '') && steps.scan-upload-4.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-merge-alerted-${{ steps.alert.outputs.run-id }} @@ -1636,7 +1666,7 @@ jobs: id: scan-upload if: >- always() && steps.gate.outcome != 'skipped' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/gate-cursor/cursor.json @@ -1676,7 +1706,7 @@ jobs: if: >- always() && steps.head-refs.outcome == 'success' continue-on-error: true - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/head-refs/head-refs.json @@ -1715,7 +1745,7 @@ jobs: if: >- always() && steps.lane-records.outcome == 'success' continue-on-error: true - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/lane-records/lane-records.json @@ -1761,7 +1791,7 @@ jobs: if: >- always() && steps.carry.outputs.carried == 'true' continue-on-error: true - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/pr-merge-carried.json @@ -1859,7 +1889,7 @@ jobs: if: >- always() && (steps.gate.outcome != 'skipped' || steps.candidates.outputs.held == 'true') continue-on-error: true - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/pr-merge-sweep.json @@ -1883,7 +1913,7 @@ jobs: if: >- always() && steps.gate.outcome != 'skipped' && steps.mode.outputs.report-only == 'false' && steps.gate.outputs.rate-limited != 'true' continue-on-error: true - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/pr-merge-summary.json @@ -1994,7 +2024,7 @@ jobs: if: >- always() && steps.gate.outcome != 'skipped' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') continue-on-error: true - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/verify-listing.json @@ -2409,7 +2439,8 @@ jobs: id: scan-upload if: >- always() && steps.arm.outcome != 'skipped' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') - uses: ./.base/.github/actions/scan-upload + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: | ${{ runner.temp }}/verify-answers/verify-answers.json @@ -2452,7 +2483,8 @@ jobs: id: scan-upload-2 if: >- always() && steps.carry.outputs.kept == 'true' - uses: ./.base/.github/actions/scan-upload + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/pr-merge-carried.json @@ -2602,6 +2634,8 @@ jobs: persist-credentials: false sparse-checkout: | /.github/actions/log-api-budget/ + /.github/actions/scan-upload/ + /lib/publish-gate.mjs sparse-checkout-cone-mode: false # At most one full run of main per CAP_MINUTES, each of main's tip, @@ -2776,8 +2810,18 @@ jobs: # The post-merge check counts a full run started by github-actions[bot] # only when a pr-merge.yml run on main uploaded an artifact naming it. # Only this run's own steps can upload this run's artifacts. + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.dispatch.outputs.run-id != '' + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + with: + path: ${{ runner.temp }}/dispatched.json + - name: Record the dispatched run - if: steps.dispatch.outputs.run-id != '' + if: >- + (steps.dispatch.outputs.run-id != '') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: pr-merge-dispatched-${{ steps.dispatch.outputs.run-id }} diff --git a/.github/workflows/pr-review-main.yml b/.github/workflows/pr-review-main.yml index a62ede6037f..083298e4582 100644 --- a/.github/workflows/pr-review-main.yml +++ b/.github/workflows/pr-review-main.yml @@ -911,7 +911,7 @@ jobs: id: scan-upload if: >- steps.verdict.outcome == 'success' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/pr-review-main-verdict/verdict.json @@ -1738,7 +1738,7 @@ jobs: id: scan-upload if: >- steps.verdict.outcome == 'success' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/pr-review-main-verdict/verdict.json @@ -2537,7 +2537,7 @@ jobs: id: scan-upload if: >- steps.verdict.outcome == 'success' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/pr-review-main-verdict/verdict.json diff --git a/.github/workflows/pr-run-activity-read.yml b/.github/workflows/pr-run-activity-read.yml index f3e151f5309..524d2708aa2 100644 --- a/.github/workflows/pr-run-activity-read.yml +++ b/.github/workflows/pr-run-activity-read.yml @@ -3391,7 +3391,8 @@ jobs: id: scan-upload if: >- steps.summary-collect.outcome == 'success' - uses: ./.base/.github/actions/scan-upload + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/reuse-summary/reuse-summary.json diff --git a/.github/workflows/pr-unblock-dispatch.yml b/.github/workflows/pr-unblock-dispatch.yml index 4970f950854..37606aa4ada 100644 --- a/.github/workflows/pr-unblock-dispatch.yml +++ b/.github/workflows/pr-unblock-dispatch.yml @@ -291,7 +291,7 @@ jobs: id: scan-upload if: >- always() && steps.record.outputs.name != '' && steps.record.outcome == 'success' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/pr-unblock-dispatched.json diff --git a/.github/workflows/pr-unblock.yml b/.github/workflows/pr-unblock.yml index ceba0114ccb..f8394c1a673 100644 --- a/.github/workflows/pr-unblock.yml +++ b/.github/workflows/pr-unblock.yml @@ -851,7 +851,7 @@ jobs: id: scan-upload if: >- steps.select.outputs.proceed == 'true' || steps.select.outputs.resolve-only == 'true' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: | ${{ runner.temp }}/feedback-context/threads.json @@ -1553,7 +1553,7 @@ jobs: id: scan-upload if: >- always() && steps.collect.outcome == 'success' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/adjudication/verdicts.json @@ -1764,7 +1764,7 @@ jobs: id: scan-upload if: >- steps.select.outputs.proceed == 'true' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: | ${{ runner.temp }}/dependency-context/dependency.json @@ -2208,7 +2208,7 @@ jobs: id: scan-upload if: >- steps.context.outputs.proceed == 'true' - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/ci-context/context.json diff --git a/.github/workflows/pr-verify.yml b/.github/workflows/pr-verify.yml index bed54c28760..e3a530787b4 100644 --- a/.github/workflows/pr-verify.yml +++ b/.github/workflows/pr-verify.yml @@ -1851,7 +1851,7 @@ jobs: id: scan-upload if: >- ${{ !cancelled() && steps.verdict.outcome == 'success' }} - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/pr-verify-verdict/verdict.json @@ -3638,7 +3638,7 @@ jobs: id: scan-upload if: >- ${{ !cancelled() && steps.verdict.outcome == 'success' }} - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/pr-verify-verdict/verdict.json @@ -5171,7 +5171,7 @@ jobs: id: scan-upload if: >- ${{ !cancelled() && steps.verdict.outcome == 'success' }} - uses: ./.base/.github/actions/scan-upload + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/pr-verify-verdict/verdict.json diff --git a/scripts/ci-rerun-ci-status.test.sh b/scripts/ci-rerun-ci-status.test.sh index 1fe40bc4f03..7d69323044b 100755 --- a/scripts/ci-rerun-ci-status.test.sh +++ b/scripts/ci-rerun-ci-status.test.sh @@ -77,14 +77,16 @@ list_step="$(step_of 'Check out the trusted-actors list')" # shellcheck disable=SC2016 # the ${{ }} text is workflow source, not shell for want in 'continue-on-error: true' 'uses: actions/checkout@' 'ref: refs/heads/main' 'path: .base' 'persist-credentials: false' \ '/.github/standards/trusted-actors/' '/.github/actions/log-api-budget/' '/.github/actions/check-trusted-trigger/' \ - '/.github/actions/check-kill-switch/' '/.github/actions/setup-pinned-node/' '/.node-version' 'sparse-checkout-cone-mode: false'; do + '/.github/actions/check-kill-switch/' '/.github/actions/setup-pinned-node/' '/.github/actions/scan-upload/' '/lib/publish-gate.mjs' \ + '/.node-version' 'sparse-checkout-cone-mode: false'; do if [[ "$list_step" == *"$want"* ]]; then ok "the list checkout carries '$want'"; else fail "the list checkout lacks '$want'"; fi done -# The record goes up between the find and the re-run, and each later step -# runs only on the find step's outputs. The job ends by logging its readings. +# The record is scanned, then goes up between the find and the re-run, and +# each later step runs only on the find step's outputs. The job ends by +# logging its readings. order="$(grep -nE '^ - name: ' <<<"$job" | cut -d: -f3- | paste -sd'|')" -if [[ "$order" == ' Check out the trusted-actors list| Set up Node| Find the ci-status to re-run| Record the re-run| Re-run ci-status| Log the API budget at the end' ]]; then - ok "the job finds, records, re-runs, then logs the API budget" +if [[ "$order" == ' Check out the trusted-actors list| Set up Node| Find the ci-status to re-run| Scan the upload for credentials| Record the re-run| Re-run ci-status| Log the API budget at the end' ]]; then + ok "the job finds, scans, records, re-runs, then logs the API budget" else fail "step order: '$order'" fi @@ -93,7 +95,19 @@ for want in " id: find" "RECORD_FILE: \${{ runner.temp }}/rerun-ci-status if [[ "$step" == *"$want"* ]]; then ok "the find step carries '$want'"; else fail "the find step lacks '$want'"; fi done # shellcheck disable=SC2016 # the ${{ }} text is workflow source, not shell -for want in "if: steps.find.outputs.record != ''" 'uses: actions/upload-artifact@' 'name: ${{ steps.find.outputs.record }}' \ +# A failed checkout leaves no scanner: the scan step fails without failing +# the job, and the record goes up only on the scan's clean output. +scan_step="$(awk '$0 == " - name: Scan the upload for credentials" { on = 1; print; next } + on && /^ - / { exit } + on { print }' <<<"$job")" +# shellcheck disable=SC2016 # the ${{ }} text is workflow source, not shell +for want in $'if: >-\n steps.find.outputs.record != \'\'\n' 'continue-on-error: true' 'uses: ./.base/.github/actions/scan-upload' \ + 'path: ${{ runner.temp }}/rerun-ci-status-record.json'; do + if [[ "$scan_step" == *"$want"* ]]; then ok "the scan step carries '$want'"; else fail "the scan step lacks '$want'"; fi +done +# shellcheck disable=SC2016 # the ${{ }} text is workflow source, not shell +for want in $'if: >-\n (steps.find.outputs.record != \'\') && steps.scan-upload.outputs.clean == \'true\'\n' \ + 'uses: actions/upload-artifact@' 'name: ${{ steps.find.outputs.record }}' \ 'path: ${{ runner.temp }}/rerun-ci-status-record.json' 'if-no-files-found: error' 'overwrite: true'; do if [[ "$record_step" == *"$want"* ]]; then ok "the record step carries '$want'"; else fail "the record step lacks '$want'"; fi done From dd68bff315dc0f61727c2092a60adbfa6c55778c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:45:49 -0500 Subject: [PATCH 10/10] fix(lanes): let a failed pr-merge record scan fail the job The dispatched, re-run and alerted records gate the post-merge check, and their uploads were never continue-on-error. With continue-on-error on their scans, a failed .base checkout skipped the record and left the job green. Drop it, so a missing scanner fails the job as a failed upload of the record does, and say so beside dispatch-full-run's checkout. Refs: #8295 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/pr-merge/merge-job.test.mjs | 13 ++++++++++--- .github/scripts/pr-merge/workflow.test.mjs | 6 ++++-- .github/workflows/pr-merge.yml | 12 +++++------- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.github/scripts/pr-merge/merge-job.test.mjs b/.github/scripts/pr-merge/merge-job.test.mjs index 4fdf4539503..6e4160fb8cc 100644 --- a/.github/scripts/pr-merge/merge-job.test.mjs +++ b/.github/scripts/pr-merge/merge-job.test.mjs @@ -69,14 +69,21 @@ test("the re-dispatch for earlier lane merges waits on neither the listing nor t "Log the API budget at the end", ], ); - // Each upload waits on the credential scan just before it. + // Each upload waits on the credential scan just before it. The report is + // best-effort, so its scan cannot fail the job; a run record the + // post-merge check needs fails the job when its scan does (a missing + // scanner included), as a failed upload of it does. + assert.match( + assertScanned("Keep the post-merge report for enable-auto-merge", "steps.post-merge.conclusion == 'success'", list), + /\n {8}continue-on-error: true\n/, + ); for (const [name, predicate] of [ - ["Keep the post-merge report for enable-auto-merge", "steps.post-merge.conclusion == 'success'"], ["Record the dispatched run", "steps.dispatch.outputs.run-id != ''"], ["Record the re-run", "!cancelled() && steps.rerun.outputs.recorded == 'true'"], ["Record the alert sent", "!cancelled() && steps.alert.outputs.run-id != ''"], ]) { - assert.match(assertScanned(name, predicate, list), /\n {8}continue-on-error: true\n/, name); + assert.doesNotMatch(assertScanned(name, predicate, list), /continue-on-error/, name); + assert.doesNotMatch(step(name, list), /continue-on-error/, name); } assert.doesNotMatch(early, /steps\.candidates|steps\.gate|needs\.merge/); assert.match(step("Check earlier lane merges", list), /\n {8}if: steps\.mode\.outputs\.live == 'true'\n/); diff --git a/.github/scripts/pr-merge/workflow.test.mjs b/.github/scripts/pr-merge/workflow.test.mjs index bf762953cbf..5cf4582a7f8 100644 --- a/.github/scripts/pr-merge/workflow.test.mjs +++ b/.github/scripts/pr-merge/workflow.test.mjs @@ -1201,14 +1201,16 @@ test("a dispatch that failed skips the record", () => { id, ); // The credential scan just before it runs on the same condition and scans - // the file the record uploads. + // the file the record uploads; with no continue-on-error, a missing + // scanner fails the job as a failed upload of the record does. const scan = before.split("\n - name: ").at(-1); assert.ok( scan.startsWith( - `Scan the upload for credentials\n id: ${scanId}\n if: >-\n steps.dispatch.outputs.run-id != ''\n continue-on-error: true\n uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository]\n with:\n path: \${{ runner.temp }}/dispatched.json\n`, + `Scan the upload for credentials\n id: ${scanId}\n if: >-\n steps.dispatch.outputs.run-id != ''\n uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository]\n with:\n path: \${{ runner.temp }}/dispatched.json\n`, ), id, ); + assert.doesNotMatch(record.split("\n - name: ")[0], /continue-on-error/, id); assert.ok(record.includes("\n path: ${{ runner.temp }}/dispatched.json\n"), id); } }); diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index d12aefb929c..8fc71f69aae 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -528,7 +528,6 @@ jobs: id: scan-upload-2 if: >- steps.dispatch.outputs.run-id != '' - continue-on-error: true uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/dispatched.json @@ -645,7 +644,6 @@ jobs: id: scan-upload-3 if: >- !cancelled() && steps.rerun.outputs.recorded == 'true' - continue-on-error: true uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/rerun.json @@ -791,7 +789,6 @@ jobs: id: scan-upload-4 if: >- !cancelled() && steps.alert.outputs.run-id != '' - continue-on-error: true uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/alerted.json @@ -2622,9 +2619,11 @@ jobs: env: API_BUDGET_TOKEN: github-token steps: - # The api-budget library only, from the default branch. A failed - # checkout leaves no library: the steps below call GitHub as they would - # without it, and no reading is logged. + # The api-budget library and the upload scanner, from the default + # branch. A failed checkout leaves neither: the steps below call GitHub + # as they would without the library, and no reading is logged, but the + # dispatched-run record's scan fails the job, as a failed upload of + # that record does, so the unrecorded run shows red. - name: Check out the api-budget library continue-on-error: true uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -2814,7 +2813,6 @@ jobs: id: scan-upload if: >- steps.dispatch.outputs.run-id != '' - continue-on-error: true uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] with: path: ${{ runner.temp }}/dispatched.json