diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index 417ab9cf784..a40c3445025 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -2878,7 +2878,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`); } @@ -5380,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 }}"); }); @@ -7446,6 +7448,44 @@ 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); + } + } +}); + // sync-shared-copies-select decides from base code alone: it proceeds once // the sync ran on the clean merge (a written patch, or an empty run whose // merge alone the push job commits), names each stop's skip reason, and 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/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/.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..37f73638401 --- /dev/null +++ b/.github/actions/scan-upload/scan-upload.test.mjs @@ -0,0 +1,94 @@ +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); + // 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"); + 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 3a71a5b6184..a3c7bc5218e 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,14 +171,16 @@ "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" }, "patch-pr-unblock-sync-shared-copies-*": { "retention-days": 1, "readers": [ ".github/workflows/pr-run-activity-write.yml patch-attempt download by the apply-shared-copies job, same run" ], - "reason": "Read by its own run's apply-shared-copies job; a later run regenerates the patch from the base's sync script, so 1 day, the minimum, is enough." + "reason": "Read by its own run's apply-shared-copies job; a later run regenerates the patch from the base's sync script, so 1 day, the minimum, is enough.", + "scan": "upload" }, "feedback-replies-*": { "retention-days": 1, @@ -169,14 +188,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, @@ -184,7 +205,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, @@ -192,7 +214,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, @@ -202,7 +225,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, @@ -210,28 +234,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, @@ -239,7 +267,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, @@ -247,42 +276,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", @@ -290,7 +325,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, @@ -299,6 +335,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" ] @@ -308,7 +345,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, @@ -316,6 +354,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" ] @@ -326,6 +365,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" ] @@ -336,6 +376,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" ] @@ -346,6 +387,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" ] @@ -356,14 +398,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, @@ -371,21 +415,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/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..6e4160fb8cc 100644 --- a/.github/scripts/pr-merge/merge-job.test.mjs +++ b/.github/scripts/pr-merge/merge-job.test.mjs @@ -53,18 +53,38 @@ 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. 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 [ + ["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.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/); assert.match(step("Check earlier lane merges", list), /babysit_postmerge\.py[\s\S]*--lane-merges-only/); @@ -234,30 +254,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 +419,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 +629,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 +658,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 +703,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 +745,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 +1402,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 +1543,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 +1582,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 +1595,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 +1906,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 +2055,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 +2215,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..5cf4582a7f8 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,27 @@ 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; 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 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); } }); @@ -1432,7 +1465,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 5f3ce39a67c..68efbcf22c7 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 8da673426dd..7e5badfadc3 100644 --- a/.github/workflows/eval-browser-dispatch.yml +++ b/.github/workflows/eval-browser-dispatch.yml @@ -677,11 +677,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 # zizmor: ignore[self-repository] + 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 3dc64109943..a6599ce73a6 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 # zizmor: ignore[self-repository] + 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 }} @@ -1006,9 +1015,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 # zizmor: ignore[self-repository] + 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 }} @@ -1299,9 +1317,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 # zizmor: ignore[self-repository] + 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 86885d4a53b..d3b702b31bc 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 # zizmor: ignore[self-repository] + 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() @@ -342,8 +351,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-handoff + if: >- + steps.handoff.outcome == 'success' + 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' + if: >- + (steps.handoff.outcome == 'success') && steps.scan-handoff.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: judge-handoff-${{ matrix.unit }} @@ -378,8 +396,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 # zizmor: ignore[self-repository] + 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 }} @@ -667,8 +694,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-handoff + if: >- + steps.handoff.outcome == 'success' + 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' + if: >- + (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 }} @@ -705,8 +741,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 # zizmor: ignore[self-repository] + 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 }} @@ -984,8 +1029,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 # zizmor: ignore[self-repository] + 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 cc9e53f228c..72f940a3dd7 100644 --- a/.github/workflows/maintenance-reconcile-lost-runs.yml +++ b/.github/workflows/maintenance-reconcile-lost-runs.yml @@ -184,8 +184,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 # zizmor: ignore[self-repository] + 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 }} @@ -193,8 +202,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 # zizmor: ignore[self-repository] + 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 }} @@ -202,8 +220,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 # zizmor: ignore[self-repository] + 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 }} @@ -223,8 +250,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 # zizmor: ignore[self-repository] + 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..b66f5337276 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 # zizmor: ignore[self-repository] + 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 # zizmor: ignore[self-repository] + 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 }} @@ -554,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 @@ -570,6 +587,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 +901,18 @@ 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 != '' + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + 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..8fc71f69aae 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -293,8 +293,18 @@ 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' + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + 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 +524,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 # 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 }} @@ -621,8 +640,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 # 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 @@ -757,8 +785,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 # 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 }} @@ -1622,8 +1659,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 # zizmor: ignore[self-repository] + 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 +1698,18 @@ 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' + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + 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 +1737,18 @@ 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' + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + 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 +1783,18 @@ 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' + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + 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 +1881,18 @@ 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') + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + 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 +1905,19 @@ 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' + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + 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 +2016,18 @@ 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') + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + 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 +2432,20 @@ 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') + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + 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 +2476,18 @@ 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' + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + 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: @@ -2497,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 @@ -2509,6 +2633,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 +2809,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 # 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 a11ea5b2b07..083298e4582 100644 --- a/.github/workflows/pr-review-main.yml +++ b/.github/workflows/pr-review-main.yml @@ -907,8 +907,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 # zizmor: ignore[self-repository] + 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 }} @@ -1725,8 +1734,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 # zizmor: ignore[self-repository] + 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 }} @@ -2515,8 +2533,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 # zizmor: ignore[self-repository] + 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 558bc60c5ec..f0649744cfa 100644 --- a/.github/workflows/pr-run-activity-read.yml +++ b/.github/workflows/pr-run-activity-read.yml @@ -2619,6 +2619,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 @@ -3000,7 +3038,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 + 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' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: verdict-${{ inputs.lane }}-${{ inputs.activity }}-${{ github.run_attempt }}${{ inputs.model-rung == '1' && '-rung-up' || '' }} @@ -3301,8 +3346,18 @@ 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' + continue-on-error: true + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + 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 36acf66edb0..334a292b809 100644 --- a/.github/workflows/pr-run-activity-write.yml +++ b/.github/workflows/pr-run-activity-write.yml @@ -715,6 +715,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: @@ -1789,8 +1804,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') + 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: 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 }} @@ -2134,10 +2160,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 != '' + 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: >- - 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 }} @@ -2200,8 +2235,17 @@ jobs: "dirty-tree": null }' >"$VERDICT_DIR/verdict.json" + - name: Scan the upload for credentials + id: scan-upload-3 + if: >- + always() + 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: 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..37606aa4ada 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 # zizmor: ignore[self-repository] + 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 0cb4a57daf0..b4ffc0ebb1f 100644 --- a/.github/workflows/pr-unblock.yml +++ b/.github/workflows/pr-unblock.yml @@ -989,8 +989,17 @@ jobs: echo "state=$state" >>"$GITHUB_OUTPUT" echo "sha256=$sum" >>"$GITHUB_OUTPUT" + - name: Scan the upload for credentials + id: scan-upload + if: >- + steps.sync.outputs.state == 'written' + uses: ./.base/.github/actions/scan-upload # zizmor: ignore[self-repository] + with: + path: ${{ runner.temp }}/sync-shared-copies-patch/patch.diff + - name: Upload the patch - if: steps.sync.outputs.state == 'written' + if: >- + (steps.sync.outputs.state == 'written') && steps.scan-upload.outputs.clean == 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: patch-pr-unblock-sync-shared-copies-${{ github.run_attempt }} @@ -1259,9 +1268,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 # zizmor: ignore[self-repository] + 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 }} @@ -1950,9 +1970,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 # zizmor: ignore[self-repository] + 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 }} @@ -2152,9 +2181,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 # zizmor: ignore[self-repository] + 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 }} @@ -2585,9 +2625,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 # zizmor: ignore[self-repository] + 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 506315eda01..3d5f492d83f 100644 --- a/.github/workflows/pr-verify.yml +++ b/.github/workflows/pr-verify.yml @@ -1852,8 +1852,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 # zizmor: ignore[self-repository] + 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 }} @@ -3635,8 +3644,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 # zizmor: ignore[self-repository] + 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 }} @@ -5164,8 +5182,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 # zizmor: ignore[self-repository] + 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..51ffe53f23d 100644 --- a/scripts/check-artifact-retention.mjs +++ b/scripts/check-artifact-retention.mjs @@ -78,7 +78,21 @@ 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; + 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; +} + +// Every upload step: { file, line, job, name, retention, path, condition, scans }. const steps = []; const lineCounterFor = new Map(); function collect(file, stepSeqs) { @@ -90,12 +104,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 +185,31 @@ for (const step of steps) { } const [key] = claimed; used.add(key); + const scan = families[key].scan; + // 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 ?? "") && 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`, + ); + } + } 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 === "") { @@ -199,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.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..cde47a028ef 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: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 \ @@ -207,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 \ @@ -230,6 +244,45 @@ 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: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 + - 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 "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" + +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" 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 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