From 3445cf7bdc32d35bf48fe6ebd0887822750e5f54 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:50:43 -0500 Subject: [PATCH 1/4] feat(lanes): upload each reusable model leg's transcript as a sealed 7-day artifact Add the upload-lane-transcript action: it scans a leg's execution file, seals it in claude-code-plugins' seal-lane-output record format to the caller's public key, and uploads only the sealed file as lane-transcript-- for 7 days. An empty key skips the scan and the seal with a notice. pr-review-cli.yml and intake-triage.yml take a new optional lane-transcript-public-key input and run the action for every run-claude-cli leg, after the outcome step, whose timeout check reads the clock. Each model job's limit grows by the new steps' limits. Closes #964 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VQb9Q1VgLEiwGgRVshK3b7 --- .github/actions/run-claude-cli/action.yml | 3 +- .../actions/upload-lane-transcript/action.yml | 66 ++++++ .../seal-transcript.cjs | 139 +++++++++++ .../seal-transcript.test.cjs | 218 ++++++++++++++++++ .github/scripts/intake-triage-legs.test.cjs | 44 ++++ .github/scripts/pr-review-cli.test.cjs | 54 +++++ .github/workflows/intake-triage.yml | 57 ++++- .github/workflows/pr-require-checks.yml | 2 + .github/workflows/pr-review-cli.yml | 144 +++++++++++- README.md | 6 + 10 files changed, 725 insertions(+), 8 deletions(-) create mode 100644 .github/actions/upload-lane-transcript/action.yml create mode 100644 .github/actions/upload-lane-transcript/seal-transcript.cjs create mode 100644 .github/actions/upload-lane-transcript/seal-transcript.test.cjs diff --git a/.github/actions/run-claude-cli/action.yml b/.github/actions/run-claude-cli/action.yml index 0c53ff76..9dac4e4f 100644 --- a/.github/actions/run-claude-cli/action.yml +++ b/.github/actions/run-claude-cli/action.yml @@ -237,7 +237,8 @@ outputs: description: >- The leg's CLI messages as a JSON array (the execution_file shape claude-code-action writes). It holds every tool result and model reply - and is never scanned for credentials, so do not upload it. + and this action never scans it for credentials, so upload it only + through upload-lane-transcript, which scans and seals it. value: ${{ steps.run.outputs.execution-file }} log-file: description: JSONL execution log, one object for the leg; empty when the credential scan failed. diff --git a/.github/actions/upload-lane-transcript/action.yml b/.github/actions/upload-lane-transcript/action.yml new file mode 100644 index 00000000..44fdfd89 --- /dev/null +++ b/.github/actions/upload-lane-transcript/action.yml @@ -0,0 +1,66 @@ +name: Upload lane transcript +description: > + Keep one lane leg's transcript (run-claude-cli's execution-file) for 7 days + as an encrypted artifact, so a puller outside CI can copy it to a private + sink. CI holds only the public key: it can seal a transcript but never open + one. The plaintext is scanned first (../scan-upload); a credential in it + fails the step with nothing sealed. It is then gzipped and sealed in memory + as one record, and only the sealed file is uploaded, as + `lane-transcript--` holding `transcript.json.gz.sealed`. + The record is claude-code-plugins' seal-lane-output format for the payload + name `transcript.json.gz`, bound to GITHUB_RUN_ID and job-id, so a record + moved to another run or job name does not open. An empty public key skips + with a notice and scans nothing; a missing, empty or linked execution file + uploads nothing; a malformed key or job-id fails the step. Callers run it + with `if: always()` and `continue-on-error: true` after the leg, so a failed + leg's transcript is kept and no failure here changes the lane's outcome. + Needs node on PATH. + +inputs: + execution-file: + description: The leg's execution file, run-claude-cli's execution-file output; empty when no leg ran. + required: true + public-key: + description: "The caller's LANE_TRANSCRIPT_PUBLIC_KEY variable: one line, `x25519 `." + required: true + job-id: + description: >- + A fixed key for the leg, unique in the run, matching + `^[A-Za-z_][A-Za-z0-9_-]{0,99}$`; the puller reads it back from the + artifact name (everything between `lane-transcript-` and the last `-`). + required: true + +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 + if: inputs.public-key != '' + shell: bash + env: + SCAN_PATHS: ${{ inputs.execution-file }} + run: node "$GITHUB_ACTION_PATH/../scan-upload/scan.cjs" + + - name: Seal the transcript + id: seal + if: inputs.public-key == '' || steps.scan.outputs.clean == 'true' + shell: bash + env: + EXECUTION_FILE: ${{ inputs.execution-file }} + PUBLIC_KEY: ${{ inputs.public-key }} + JOB_ID: ${{ inputs.job-id }} + run: node "$GITHUB_ACTION_PATH/seal-transcript.cjs" + + - name: Upload the sealed transcript + if: steps.seal.outputs.file != '' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: lane-transcript-${{ inputs.job-id }}-${{ github.run_attempt }} + path: ${{ steps.seal.outputs.file }} + retention-days: 7 + # Ciphertext does not compress. + compression-level: 0 + if-no-files-found: error + overwrite: false diff --git a/.github/actions/upload-lane-transcript/seal-transcript.cjs b/.github/actions/upload-lane-transcript/seal-transcript.cjs new file mode 100644 index 00000000..bd1b6b60 --- /dev/null +++ b/.github/actions/upload-lane-transcript/seal-transcript.cjs @@ -0,0 +1,139 @@ +// Seal one lane leg's transcript, gzipped, to the transcript public key as one +// record, written under RUNNER_TEMP for the upload step. The plaintext stays in +// memory: only the sealed record reaches disk, and nothing here prints it. +// +// A record is version (1) | key id (8) | ephemeral public key (32) | nonce (12) +// | ciphertext | tag (16). Each record gets its own ephemeral X25519 key and +// random nonce; the key comes from HKDF-SHA256 over the shared secret, and +// AES-256-GCM binds the header, the payload name, the run id and the job id. +// The format is claude-code-plugins' seal-lane-output; its open-lane-output +// opens these records, so a change here must keep them byte-compatible. +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const zlib = require("node:zlib"); + +const VERSION = 1; +const PAYLOAD = "transcript.json.gz"; +const JOB_ID = /^[A-Za-z_][A-Za-z0-9_-]{0,99}$/u; +const RUN_ID = /^[1-9][0-9]{0,19}$/u; +const KEY_LINE = /^x25519 ([0-9a-f]{16}) ([A-Za-z0-9+/]{43}=)$/u; +const SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex"); + +class SealError extends Error {} + +const keyIdOf = (raw) => + crypto.createHash("sha256").update(raw).digest("hex").slice(0, 16); + +/** The one-line key `x25519 `; the id is the first 16 hex digits of the raw key's SHA-256. */ +function parsePublicKey(text) { + const match = KEY_LINE.exec(String(text).trim()); + const raw = match && Buffer.from(match[2], "base64"); + if (!match || raw.length !== 32 || keyIdOf(raw) !== match[1]) { + throw new SealError( + "the public key holds no x25519 key line with a matching key id", + ); + } + return { + keyId: match[1], + raw, + key: crypto.createPublicKey({ + key: Buffer.concat([SPKI_PREFIX, raw]), + format: "der", + type: "spki", + }), + }; +} + +function sealRecord(recipient, name, data, runId, jobId) { + if (!RUN_ID.test(String(runId))) { + throw new SealError("GITHUB_RUN_ID is not a run id"); + } + const bound = Buffer.from( + `lane-output/v${VERSION}\0${name}\0${runId}\0${jobId}`, + ); + const ephemeral = crypto.generateKeyPairSync("x25519"); + const epk = ephemeral.publicKey + .export({ format: "der", type: "spki" }) + .subarray(-32); + const nonce = crypto.randomBytes(12); + const header = Buffer.concat([ + Buffer.from([VERSION]), + Buffer.from(recipient.keyId, "hex"), + epk, + nonce, + ]); + const shared = crypto.diffieHellman({ + privateKey: ephemeral.privateKey, + publicKey: recipient.key, + }); + const key = Buffer.from( + crypto.hkdfSync( + "sha256", + shared, + Buffer.concat([epk, recipient.raw]), + Buffer.concat([bound, Buffer.from([0]), header]), + 32, + ), + ); + const cipher = crypto.createCipheriv("aes-256-gcm", key, nonce); + cipher.setAAD(Buffer.concat([header, bound])); + return Buffer.concat([ + header, + cipher.update(data), + cipher.final(), + cipher.getAuthTag(), + ]); +} + +function main(env = process.env) { + if (String(env.PUBLIC_KEY ?? "").trim() === "") { + process.stdout.write( + "::notice::upload-lane-transcript: the public key input is empty, so this leg's transcript is not uploaded\n", + ); + return; + } + // The key first: a malformed key fails whether or not a leg ran. + const recipient = parsePublicKey(env.PUBLIC_KEY); + const jobId = env.JOB_ID ?? ""; + if (!JOB_ID.test(jobId)) { + throw new SealError("the job-id input is not a job key"); + } + const file = env.EXECUTION_FILE ?? ""; + let stat; + try { + stat = file === "" ? undefined : fs.lstatSync(file); + } catch { + stat = undefined; + } + // No regular, non-empty file: no leg ran, so there is nothing to keep. + if (!stat?.isFile() || stat.size === 0) return; + const record = sealRecord( + recipient, + PAYLOAD, + zlib.gzipSync(fs.readFileSync(file)), + env.GITHUB_RUN_ID, + jobId, + ); + const directory = path.join(env.RUNNER_TEMP, "lane-transcript", jobId); + fs.rmSync(directory, { recursive: true, force: true }); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + const sealed = path.join(directory, `${PAYLOAD}.sealed`); + fs.writeFileSync(sealed, record, { flag: "wx" }); + fs.appendFileSync(env.GITHUB_OUTPUT, `file=${sealed}\n`); +} + +module.exports = { main, parsePublicKey, sealRecord }; + +if (require.main === module) { + try { + main(); + } catch (error) { + process.stdout.write( + `::error::upload-lane-transcript: ${error instanceof SealError ? error.message : `the transcript could not be sealed (${error.name})`}\n`, + ); + process.exitCode = 1; + } +} diff --git a/.github/actions/upload-lane-transcript/seal-transcript.test.cjs b/.github/actions/upload-lane-transcript/seal-transcript.test.cjs new file mode 100644 index 00000000..fdf01761 --- /dev/null +++ b/.github/actions/upload-lane-transcript/seal-transcript.test.cjs @@ -0,0 +1,218 @@ +// upload-lane-transcript: the sealed record and the step's contract. The opener +// here reads the record layout seal-transcript.cjs's header states, as +// claude-code-plugins' open-lane-output does, with no code from the sealer. +"use strict"; + +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const zlib = require("node:zlib"); + +const { + makeTemporaryDirectory, +} = require("../../scripts/temporary-directories.cjs"); +const { parseWorkflow } = require("../../scripts/workflow-yaml.cjs"); + +const ACTION = parseWorkflow( + fs.readFileSync(path.join(__dirname, "action.yml"), "utf8"), +); +const SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex"); +const MARKER = "plaintext-marker-7f3c"; +const TRANSCRIPT = JSON.stringify([ + { type: "assistant", message: { content: [{ type: "text", text: MARKER }] } }, +]); +const JOB = "code-review-key-2"; + +function generate() { + const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519"); + const raw = publicKey.export({ format: "der", type: "spki" }).subarray(-32); + const keyId = crypto + .createHash("sha256") + .update(raw) + .digest("hex") + .slice(0, 16); + return { + keyId, + raw, + privateKey, + line: `x25519 ${keyId} ${raw.toString("base64")}`, + }; +} + +function open(recipient, record, runId = "42", jobId = JOB) { + const header = record.subarray(0, 53); + const epk = header.subarray(9, 41); + const bound = Buffer.from( + `lane-output/v1\0transcript.json.gz\0${runId}\0${jobId}`, + ); + const shared = crypto.diffieHellman({ + privateKey: recipient.privateKey, + publicKey: crypto.createPublicKey({ + key: Buffer.concat([SPKI_PREFIX, epk]), + format: "der", + type: "spki", + }), + }); + const key = Buffer.from( + crypto.hkdfSync( + "sha256", + shared, + Buffer.concat([epk, recipient.raw]), + Buffer.concat([bound, Buffer.from([0]), header]), + 32, + ), + ); + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + key, + header.subarray(41), + ); + decipher.setAAD(Buffer.concat([header, bound])); + decipher.setAuthTag(record.subarray(record.length - 16)); + return zlib + .gunzipSync( + Buffer.concat([ + decipher.update(record.subarray(53, record.length - 16)), + decipher.final(), + ]), + ) + .toString("utf8"); +} + +function workspace() { + const dir = makeTemporaryDirectory("lane-transcript-"); + const file = path.join(dir, "execution.json"); + fs.writeFileSync(file, TRANSCRIPT); + return { dir, file }; +} + +function run(dir, { key, file = "", jobId = JOB }) { + const output = path.join(dir, "github-output"); + fs.writeFileSync(output, ""); + const result = spawnSync( + process.execPath, + [path.join(__dirname, "seal-transcript.cjs")], + { + env: { + PATH: process.env.PATH, + GITHUB_OUTPUT: output, + RUNNER_TEMP: dir, + GITHUB_RUN_ID: "42", + PUBLIC_KEY: key, + EXECUTION_FILE: file, + JOB_ID: jobId, + }, + encoding: "utf8", + }, + ); + return { ...result, output: fs.readFileSync(output, "utf8") }; +} + +const sealedPath = (result) => result.output.trim().replace(/^file=/u, ""); +const nothingSealed = (dir) => + assert.equal(fs.existsSync(path.join(dir, "lane-transcript")), false); + +test("a transcript seals to the public key and opens, gunzipped, to the same bytes", () => { + const { dir, file } = workspace(); + const recipient = generate(); + const result = run(dir, { key: recipient.line, file }); + assert.equal(result.status, 0, result.stdout + result.stderr); + const sealedFile = sealedPath(result); + assert.equal( + sealedFile, + path.join(dir, "lane-transcript", JOB, "transcript.json.gz.sealed"), + ); + const sealed = fs.readFileSync(sealedFile); + assert.equal(sealed[0], 1, "the record's version"); + assert.equal(sealed.subarray(1, 9).toString("hex"), recipient.keyId); + assert.equal(sealed.includes(Buffer.from(MARKER)), false); + assert.equal(open(recipient, sealed), TRANSCRIPT); + assert.doesNotMatch(result.stdout + result.stderr, new RegExp(MARKER, "u")); +}); + +test("the record opens only for its own key, run and job", () => { + const { dir, file } = workspace(); + const recipient = generate(); + const sealed = fs.readFileSync( + sealedPath(run(dir, { key: recipient.line, file })), + ); + assert.throws(() => open(recipient, sealed, "43")); + assert.throws(() => open(recipient, sealed, "42", "code-review-key-3")); + assert.throws(() => open(generate(), sealed)); +}); + +test("an empty key passes with a notice and seals nothing", () => { + const { dir, file } = workspace(); + for (const key of ["", " "]) { + const result = run(dir, { key, file }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^::notice::upload-lane-transcript: /mu); + assert.equal(result.output, ""); + nothingSealed(dir); + } +}); + +test("a malformed key fails the step and seals nothing, even with no transcript", () => { + const { dir, file } = workspace(); + const [, id, key] = generate().line.split(" "); + const otherId = id.replace(/^./u, id[0] === "0" ? "1" : "0"); + for (const [bad, source] of [ + ["x25519 0000000000000000 REPLACE_WITH_PUBLIC_KEY", file], + [`x25519 ${otherId} ${key}`, file], + ["-----BEGIN PUBLIC KEY-----", ""], + ]) { + const result = run(dir, { key: bad, file: source }); + assert.equal(result.status, 1, bad); + assert.match(result.stdout, /^::error::upload-lane-transcript: /mu, bad); + assert.equal(result.output, "", bad); + assert.equal(result.stdout.includes(key), false, "the key is not printed"); + nothingSealed(dir); + } +}); + +test("a missing, empty or linked transcript seals nothing; a bad job id fails", () => { + const { dir, file } = workspace(); + const { line } = generate(); + const empty = path.join(dir, "empty.json"); + const linked = path.join(dir, "linked.json"); + fs.writeFileSync(empty, ""); + fs.symlinkSync(file, linked); + for (const source of ["", path.join(dir, "absent.json"), empty, linked]) { + const result = run(dir, { key: line, file: source }); + assert.equal(result.status, 0, source); + assert.equal(result.output, "", source); + } + nothingSealed(dir); + for (const jobId of ["", "1st", "has space", "a.b"]) { + const result = run(dir, { key: line, file, jobId }); + assert.equal(result.status, 1, jobId); + nothingSealed(dir); + } +}); + +test("an empty key skips the scan, and a scan that is not clean withholds the seal", () => { + const [scan, seal] = ACTION.runs.steps; + assert.match(scan.run, /\/\.\.\/scan-upload\/scan\.cjs"$/u); + assert.equal(scan.env.SCAN_PATHS, `\${{ inputs.execution-file }}`); + assert.equal(scan.if, "inputs.public-key != ''"); + assert.equal( + seal.if, + "inputs.public-key == '' || steps.scan.outputs.clean == 'true'", + ); +}); + +test("the upload keeps only the sealed file, for 7 days, named by job and attempt", () => { + const upload = ACTION.runs.steps.at(-1); + assert.match(upload.uses, /^actions\/upload-artifact@[0-9a-f]{40}$/u); + assert.equal(upload.if, "steps.seal.outputs.file != ''"); + assert.equal( + upload.with.name, + `lane-transcript-\${{ inputs.job-id }}-\${{ github.run_attempt }}`, + ); + assert.equal(upload.with.path, `\${{ steps.seal.outputs.file }}`); + assert.equal(upload.with["retention-days"], 7); + assert.equal(upload.with.overwrite, false); +}); diff --git a/.github/scripts/intake-triage-legs.test.cjs b/.github/scripts/intake-triage-legs.test.cjs index 7871a232..4b696542 100644 --- a/.github/scripts/intake-triage-legs.test.cjs +++ b/.github/scripts/intake-triage-legs.test.cjs @@ -194,6 +194,50 @@ test("the fallback job repeats the subscription job's steps but for the credenti } }); +test("every model job's leg uploads its sealed transcript under a job id unique in the run", () => { + const artifact = /^lane-transcript-(.+)-([1-9][0-9]{0,4})$/u; + const jobId = /^[A-Za-z_][A-Za-z0-9_-]{0,99}$/u; + const seen = new Set(); + for (const [name, job] of Object.entries({ + claude, + subscription, + fallback, + })) { + const uploads = job.steps.filter( + (step) => step.uses === "$/.github/actions/upload-lane-transcript", + ); + assert.equal(uploads.length, 1, name); + const [upload] = uploads; + assert.ok( + job.steps.indexOf(upload) > + job.steps.findIndex((step) => step.id === "review-outcome"), + name, + ); + assert.equal(upload["continue-on-error"], true); + assert.equal( + upload.if, + "always() && steps.claude.outputs.execution-file != ''", + ); + assert.equal( + upload.with["execution-file"], + `\${{ steps.claude.outputs.execution-file }}`, + ); + assert.equal( + upload.with["public-key"], + `\${{ inputs.lane-transcript-public-key }}`, + ); + const id = upload.with["job-id"].replace(`\${{ github.job }}`, name); + assert.match(id, jobId); + assert.equal(seen.has(id), false, id); + seen.add(id); + assert.equal(artifact.exec(`lane-transcript-${id}-1`)?.[1], id); + } + assert.equal( + workflow.on.workflow_call.inputs["lane-transcript-public-key"].default, + "", + ); +}); + test("the subscription job runs on a fixed GitHub-hosted label, after the claude job", () => { assert.equal(subscription["runs-on"], "ubuntu-24.04"); assert.equal(subscription.needs, "claude"); diff --git a/.github/scripts/pr-review-cli.test.cjs b/.github/scripts/pr-review-cli.test.cjs index d0086d15..69b1678f 100644 --- a/.github/scripts/pr-review-cli.test.cjs +++ b/.github/scripts/pr-review-cli.test.cjs @@ -3467,6 +3467,60 @@ test("a chunk that failed, did not run or returned no verdict keeps the review i assert.equal(JSON.parse(one.review).coverage.over_budget, 700000); }); +// --- every model leg keeps a sealed transcript --- + +test("every model leg uploads its sealed transcript under a job id unique in the run", () => { + const artifact = /^lane-transcript-(.+)-([1-9][0-9]{0,4})$/u; + const jobId = /^[A-Za-z_][A-Za-z0-9_-]{0,99}$/u; + const seen = new Set(); + for (const [name, job] of Object.entries({ + "review-key": key, + "review-subscription": subscription, + "review-fallback": fallback, + })) { + const outcome = job.steps.findIndex((s) => s.id === "review-outcome"); + const uploads = job.steps + .map((s, index) => ({ s, index })) + .filter(({ s }) => s.uses === "$/.github/actions/upload-lane-transcript"); + const legs = job.steps.filter( + (s) => s.uses === "$/.github/actions/run-claude-cli", + ); + assert.equal(uploads.length, legs.length, name); + assert.equal(legs.length, 4, name); + legs.forEach((leg, i) => { + const { s, index } = uploads[i]; + assert.ok(index > outcome, `${name}: ${s.name} follows the outcome step`); + assert.equal(s["continue-on-error"], true); + assert.equal( + s.if, + `always() && steps.${leg.id}.outputs.execution-file != ''`, + ); + assert.equal( + s.with["execution-file"], + `\${{ steps.${leg.id}.outputs.execution-file }}`, + ); + assert.equal( + s.with["public-key"], + `\${{ inputs.lane-transcript-public-key }}`, + ); + for (const lane of ["code", "security"]) { + const id = s.with["job-id"] + .replace(`\${{ inputs.lane }}`, lane) + .replace(`\${{ github.job }}`, name); + assert.match(id, jobId); + assert.equal(seen.has(id), false, id); + seen.add(id); + assert.equal(artifact.exec(`lane-transcript-${id}-1`)?.[1], id); + } + }); + } + assert.equal(seen.size, 24); + assert.equal( + workflow.on.workflow_call.inputs["lane-transcript-public-key"].default, + "", + ); +}); + // --- every artifact the lane uploads is scanned for credentials first --- const SCAN_ACTION = "$/.github/actions/scan-upload"; diff --git a/.github/workflows/intake-triage.yml b/.github/workflows/intake-triage.yml index 9b7b5cff..3f0db604 100644 --- a/.github/workflows/intake-triage.yml +++ b/.github/workflows/intake-triage.yml @@ -31,6 +31,7 @@ name: intake-triage # issue-number: ${{ inputs.issue-number }} # allowed-author-ids: # role: automations +# lane-transcript-public-key: ${{ vars.LANE_TRANSCRIPT_PUBLIC_KEY }} # secrets: # CLAUDE_API_KEY: ${{ secrets.AUTOMATIONS_CLAUDE_API_KEY }} # CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.AUTOMATIONS_CLAUDE_CODE_OAUTH_TOKEN }} @@ -65,6 +66,10 @@ name: intake-triage # issues: read, and `cache-mode: none` closes the Actions cache, which # a workflow_dispatch run could otherwise write. The run-claude-cli step's # env holds the one Claude credential and no GitHub token. +# - Each leg's execution file, which holds every tool result and model reply, +# reaches no log or artifact in plaintext: with lane-transcript-public-key +# set, upload-lane-transcript scans it, seals it to that key, which CI +# cannot open with, and uploads only the sealed file. # - The model has no GitHub access and runs no commands: the tool set is # Read, Grep and Glob (run-claude-cli runs the CLI with --restricted, # which drops Bash unless `tools` names it, and Bash is also disallowed), @@ -261,6 +266,13 @@ on: trailing or doubled comma, skips every bot. type: string default: "" + lane-transcript-public-key: + description: >- + The caller's vars.LANE_TRANSCRIPT_PUBLIC_KEY. Each model leg's + transcript is sealed to it and uploaded for 7 days + (.github/actions/upload-lane-transcript). Empty keeps none. + type: string + default: "" outputs: triage-failed: description: >- @@ -306,8 +318,9 @@ jobs: name: claude-intake-triage runs-on: ${{ inputs.runner }} # The triage step (12 minutes, CLI install included) leaves four minutes - # for runner isolation, checkout and the github-script steps. - timeout-minutes: 16 + # for runner isolation, checkout and the github-script steps, and three + # for the transcript upload. + timeout-minutes: 19 # Author gate: a run spends the subscription, so it starts only when the # sender opened the issue and is its author: a person whose user id is in # allowed-author-ids, or a bot whose sender id is in @@ -819,6 +832,18 @@ jobs: known-failure-class: ${{ steps.claude.outputs.lane-class }} handoff: ${{ steps.claude.outputs.handoff }} + # After the outcome step: its timeout check reads the clock when it + # runs, so time spent here must not count against a failed leg. + - name: Upload the sealed transcript + if: always() && steps.claude.outputs.execution-file != '' + continue-on-error: true + timeout-minutes: 3 + uses: $/.github/actions/upload-lane-transcript + with: + execution-file: ${{ steps.claude.outputs.execution-file }} + public-key: ${{ inputs.lane-transcript-public-key }} + job-id: intake-${{ github.job }} + # No model runs here. It reduces the run to the job outputs the apply # job reads: the result subtype, the error flag and the denial count, # each a closed value, and the structured output, checked against this @@ -925,7 +950,7 @@ jobs: needs: claude if: needs.claude.outputs.handoff == 'true' || needs.claude.outputs.credential-present == 'false' runs-on: ubuntu-24.04 - timeout-minutes: 16 + timeout-minutes: 19 # The model's job reads and never writes: the token can read the issue # but not change it, and no cache is read or written. permissions: @@ -1391,6 +1416,18 @@ jobs: # Why this job ran, for this leg's lane record (ci-workflows#806). fallback-reason: ${{ needs.claude.outputs.lane-class == 'credit-stop' && 'credit-stop' || needs.claude.outputs.lane-class == 'auth' && 'auth' || needs.claude.outputs.credential-present == 'false' && 'no-key' || '' }} + # After the outcome step: its timeout check reads the clock when it + # runs, so time spent here must not count against a failed leg. + - name: Upload the sealed transcript + if: always() && steps.claude.outputs.execution-file != '' + continue-on-error: true + timeout-minutes: 3 + uses: $/.github/actions/upload-lane-transcript + with: + execution-file: ${{ steps.claude.outputs.execution-file }} + public-key: ${{ inputs.lane-transcript-public-key }} + job-id: intake-${{ github.job }} + # No model runs here. It reduces the run to the job outputs the apply # job reads: the result subtype, the error flag and the denial count, # each a closed value, and the structured output, checked against this @@ -1497,7 +1534,7 @@ jobs: needs: [claude, subscription] if: needs.subscription.outputs.handoff == 'true' runs-on: ubuntu-24.04 - timeout-minutes: 16 + timeout-minutes: 19 # The model's job reads and never writes: the token can read the issue # but not change it, and no cache is read or written. permissions: @@ -1963,6 +2000,18 @@ jobs: # Why this job ran, for this leg's lane record. fallback-reason: ${{ needs.subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + # After the outcome step: its timeout check reads the clock when it + # runs, so time spent here must not count against a failed leg. + - name: Upload the sealed transcript + if: always() && steps.claude.outputs.execution-file != '' + continue-on-error: true + timeout-minutes: 3 + uses: $/.github/actions/upload-lane-transcript + with: + execution-file: ${{ steps.claude.outputs.execution-file }} + public-key: ${{ inputs.lane-transcript-public-key }} + job-id: intake-${{ github.job }} + # No model runs here. It reduces the run to the job outputs the apply # job reads: the result subtype, the error flag and the denial count, # each a closed value, and the structured output, checked against this diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index e1d5f757..93b96be4 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -557,6 +557,8 @@ jobs: run: node --test .github/actions/report-lane-outcome/*.test.cjs - name: Test the run-claude-cli launcher run: node --test .github/actions/run-claude-cli/*.test.cjs + - name: Test the lane transcript seal + run: node --test .github/actions/upload-lane-transcript/*.test.cjs - name: Test OSV scan policy guard run: bash .github/scripts/osv-scan-guard.test.sh - name: Test Gitleaks scan policy guard diff --git a/.github/workflows/pr-review-cli.yml b/.github/workflows/pr-review-cli.yml index a35deccc..948fb59c 100644 --- a/.github/workflows/pr-review-cli.yml +++ b/.github/workflows/pr-review-cli.yml @@ -30,6 +30,7 @@ name: pr-review-cli # with: # lane: code # or security # publish: comments # or summary +# lane-transcript-public-key: ${{ vars.LANE_TRANSCRIPT_PUBLIC_KEY }} # secrets: # CLAUDE_API_KEY: ${{ secrets.REVIEWS_CLAUDE_API_KEY }} # CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.REVIEWS_CLAUDE_CODE_OAUTH_TOKEN }} @@ -100,6 +101,10 @@ name: pr-review-cli # (kept one day), and emits only the file's sha256 as a job output. The # model reads only the checkout and the files the lane writes for it, # so a value it quotes is one the pull request already shows. +# - Each leg's execution file, which holds every tool result and model reply, +# reaches no log or artifact in plaintext: with lane-transcript-public-key +# set, upload-lane-transcript scans it, seals it to that key, which CI +# cannot open with, and uploads only the sealed file. # - Only the status job writes, and it runs no model. It reads the # fallback job's outputs when that job ran, else the subscription job's, # else the key job's, and @@ -285,6 +290,13 @@ on: or a replaced key is picked up. 0 always tries the key. type: number default: 6 + lane-transcript-public-key: + description: >- + The caller's vars.LANE_TRANSCRIPT_PUBLIC_KEY. Each model leg's + transcript is sealed to it and uploaded for 7 days + (.github/actions/upload-lane-transcript). Empty keeps none. + type: string + default: "" outputs: hold: description: "`true` when this run held the pull request, else `false`." @@ -395,7 +407,7 @@ jobs: name: ${{ inputs.lane == 'security' && 'security-review-key' || 'claude-review-key' }} needs: check-key-credit runs-on: ubuntu-24.04 - timeout-minutes: ${{ inputs.lane == 'security' && 103 || 95 }} + timeout-minutes: ${{ inputs.lane == 'security' && 115 || 107 }} if: >- needs.check-key-credit.outputs.skip-key != 'true' && !cancelled() @@ -2072,6 +2084,48 @@ jobs: known-failure-class: ${{ steps.claude-4.outputs.lane-class || steps.claude-3.outputs.lane-class || steps.claude-2.outputs.lane-class || steps.claude.outputs.lane-class }} handoff: ${{ steps.claude-4.outputs.handoff || steps.claude-3.outputs.handoff || steps.claude-2.outputs.handoff || steps.claude.outputs.handoff }} + # After the outcome step: its timeout check reads the clock when it + # runs, so time spent here must not count against a failed leg. + - name: Upload the sealed transcript + if: always() && steps.claude.outputs.execution-file != '' + continue-on-error: true + timeout-minutes: 3 + uses: $/.github/actions/upload-lane-transcript + with: + execution-file: ${{ steps.claude.outputs.execution-file }} + public-key: ${{ inputs.lane-transcript-public-key }} + job-id: ${{ inputs.lane }}-${{ github.job }} + + - name: Upload the sealed transcript, chunk 2 + if: always() && steps.claude-2.outputs.execution-file != '' + continue-on-error: true + timeout-minutes: 3 + uses: $/.github/actions/upload-lane-transcript + with: + execution-file: ${{ steps.claude-2.outputs.execution-file }} + public-key: ${{ inputs.lane-transcript-public-key }} + job-id: ${{ inputs.lane }}-${{ github.job }}-2 + + - name: Upload the sealed transcript, chunk 3 + if: always() && steps.claude-3.outputs.execution-file != '' + continue-on-error: true + timeout-minutes: 3 + uses: $/.github/actions/upload-lane-transcript + with: + execution-file: ${{ steps.claude-3.outputs.execution-file }} + public-key: ${{ inputs.lane-transcript-public-key }} + job-id: ${{ inputs.lane }}-${{ github.job }}-3 + + - name: Upload the sealed transcript, chunk 4 + if: always() && steps.claude-4.outputs.execution-file != '' + continue-on-error: true + timeout-minutes: 3 + uses: $/.github/actions/upload-lane-transcript + with: + execution-file: ${{ steps.claude-4.outputs.execution-file }} + public-key: ${{ inputs.lane-transcript-public-key }} + job-id: ${{ inputs.lane }}-${{ github.job }}-4 + # The job token's api-budget notice, from the readings the scope # step's requests and the shell steps' `gh api -i` files left. It # spends no request. @@ -2094,7 +2148,7 @@ jobs: name: ${{ inputs.lane == 'security' && 'security-review-subscription' || 'claude-review-subscription' }} needs: [check-key-credit, review-key] runs-on: ubuntu-24.04 - timeout-minutes: ${{ inputs.lane == 'security' && 103 || 95 }} + timeout-minutes: ${{ inputs.lane == 'security' && 115 || 107 }} if: >- ((needs.review-key.result == 'success' && (needs.review-key.outputs.handoff == 'true' || needs.review-key.outputs.credential-present == 'false')) @@ -3776,6 +3830,48 @@ jobs: # Why this job ran, for this leg's lane record. fallback-reason: ${{ (needs.review-key.result == 'skipped' || needs.review-key.outputs.lane-class == 'credit-stop') && 'credit-stop' || needs.review-key.outputs.lane-class == 'auth' && 'auth' || needs.review-key.outputs.credential-present == 'false' && 'no-key' || '' }} + # After the outcome step: its timeout check reads the clock when it + # runs, so time spent here must not count against a failed leg. + - name: Upload the sealed transcript + if: always() && steps.claude.outputs.execution-file != '' + continue-on-error: true + timeout-minutes: 3 + uses: $/.github/actions/upload-lane-transcript + with: + execution-file: ${{ steps.claude.outputs.execution-file }} + public-key: ${{ inputs.lane-transcript-public-key }} + job-id: ${{ inputs.lane }}-${{ github.job }} + + - name: Upload the sealed transcript, chunk 2 + if: always() && steps.claude-2.outputs.execution-file != '' + continue-on-error: true + timeout-minutes: 3 + uses: $/.github/actions/upload-lane-transcript + with: + execution-file: ${{ steps.claude-2.outputs.execution-file }} + public-key: ${{ inputs.lane-transcript-public-key }} + job-id: ${{ inputs.lane }}-${{ github.job }}-2 + + - name: Upload the sealed transcript, chunk 3 + if: always() && steps.claude-3.outputs.execution-file != '' + continue-on-error: true + timeout-minutes: 3 + uses: $/.github/actions/upload-lane-transcript + with: + execution-file: ${{ steps.claude-3.outputs.execution-file }} + public-key: ${{ inputs.lane-transcript-public-key }} + job-id: ${{ inputs.lane }}-${{ github.job }}-3 + + - name: Upload the sealed transcript, chunk 4 + if: always() && steps.claude-4.outputs.execution-file != '' + continue-on-error: true + timeout-minutes: 3 + uses: $/.github/actions/upload-lane-transcript + with: + execution-file: ${{ steps.claude-4.outputs.execution-file }} + public-key: ${{ inputs.lane-transcript-public-key }} + job-id: ${{ inputs.lane }}-${{ github.job }}-4 + # The job token's api-budget notice, from the readings the scope # step's requests and the shell steps' `gh api -i` files left. It # spends no request. @@ -3801,7 +3897,7 @@ jobs: name: ${{ inputs.lane == 'security' && 'security-review-fallback' || 'claude-review-fallback' }} needs: [check-key-credit, review-key, review-subscription] runs-on: ubuntu-24.04 - timeout-minutes: ${{ inputs.lane == 'security' && 103 || 95 }} + timeout-minutes: ${{ inputs.lane == 'security' && 115 || 107 }} if: >- needs.review-subscription.result == 'success' && needs.review-subscription.outputs.handoff == 'true' @@ -5480,6 +5576,48 @@ jobs: # Why this job ran, for this leg's lane record. fallback-reason: ${{ needs.review-subscription.outputs.handoff-reason == 'limit-stop' && 'limit-stop' || needs.review-subscription.outputs.handoff-reason == 'credit-stop' && 'credit-stop' || needs.review-subscription.outputs.handoff-reason == 'auth' && 'auth' || '' }} + # After the outcome step: its timeout check reads the clock when it + # runs, so time spent here must not count against a failed leg. + - name: Upload the sealed transcript + if: always() && steps.claude.outputs.execution-file != '' + continue-on-error: true + timeout-minutes: 3 + uses: $/.github/actions/upload-lane-transcript + with: + execution-file: ${{ steps.claude.outputs.execution-file }} + public-key: ${{ inputs.lane-transcript-public-key }} + job-id: ${{ inputs.lane }}-${{ github.job }} + + - name: Upload the sealed transcript, chunk 2 + if: always() && steps.claude-2.outputs.execution-file != '' + continue-on-error: true + timeout-minutes: 3 + uses: $/.github/actions/upload-lane-transcript + with: + execution-file: ${{ steps.claude-2.outputs.execution-file }} + public-key: ${{ inputs.lane-transcript-public-key }} + job-id: ${{ inputs.lane }}-${{ github.job }}-2 + + - name: Upload the sealed transcript, chunk 3 + if: always() && steps.claude-3.outputs.execution-file != '' + continue-on-error: true + timeout-minutes: 3 + uses: $/.github/actions/upload-lane-transcript + with: + execution-file: ${{ steps.claude-3.outputs.execution-file }} + public-key: ${{ inputs.lane-transcript-public-key }} + job-id: ${{ inputs.lane }}-${{ github.job }}-3 + + - name: Upload the sealed transcript, chunk 4 + if: always() && steps.claude-4.outputs.execution-file != '' + continue-on-error: true + timeout-minutes: 3 + uses: $/.github/actions/upload-lane-transcript + with: + execution-file: ${{ steps.claude-4.outputs.execution-file }} + public-key: ${{ inputs.lane-transcript-public-key }} + job-id: ${{ inputs.lane }}-${{ github.job }}-4 + # The job token's api-budget notice, from the readings the scope # step's requests and the shell steps' `gh api -i` files left. It # spends no request. diff --git a/README.md b/README.md index 8a3ca3cc..685c398e 100644 --- a/README.md +++ b/README.md @@ -782,6 +782,12 @@ consumer to audit it. pieces: a gate job before its key job, a record step named for its run after the launcher's `credit-stop`, and a subscription job that also starts on the gate's skip (its `action.yml` describes the inputs). +- `.github/actions/upload-lane-transcript` — the step after each model leg in + `pr-review-cli.yml` and `intake-triage.yml`: scans the leg's execution file, + seals it to the caller's `lane-transcript-public-key` input and uploads only + the sealed file for 7 days, for claude-code-plugins' transcript puller. An + empty key skips with a notice (its `action.yml` describes the record and + artifact name). Each input's meaning and default is documented inline in the action's `inputs:` block. From 87cb9b2b1147b286fb799f125924b1adc151c115 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:14:13 -0500 Subject: [PATCH 2/4] test(lanes): harden the transcript upload ordering checks The ordering check passed if the outcome step was renamed, since findIndex returns -1. One test name promised behaviour it only pins as step conditions. The intake comment no longer held once a sealed transcript leaves the job. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VQb9Q1VgLEiwGgRVshK3b7 --- .../upload-lane-transcript/seal-transcript.test.cjs | 2 +- .github/scripts/intake-triage-legs.test.cjs | 8 +++----- .github/scripts/pr-review-cli.test.cjs | 1 + .github/workflows/intake-triage.yml | 6 +++--- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/actions/upload-lane-transcript/seal-transcript.test.cjs b/.github/actions/upload-lane-transcript/seal-transcript.test.cjs index fdf01761..09bc10e4 100644 --- a/.github/actions/upload-lane-transcript/seal-transcript.test.cjs +++ b/.github/actions/upload-lane-transcript/seal-transcript.test.cjs @@ -193,7 +193,7 @@ test("a missing, empty or linked transcript seals nothing; a bad job id fails", } }); -test("an empty key skips the scan, and a scan that is not clean withholds the seal", () => { +test("the step conditions skip the scan on an empty key and seal only after a clean scan", () => { const [scan, seal] = ACTION.runs.steps; assert.match(scan.run, /\/\.\.\/scan-upload\/scan\.cjs"$/u); assert.equal(scan.env.SCAN_PATHS, `\${{ inputs.execution-file }}`); diff --git a/.github/scripts/intake-triage-legs.test.cjs b/.github/scripts/intake-triage-legs.test.cjs index 4b696542..5c75287c 100644 --- a/.github/scripts/intake-triage-legs.test.cjs +++ b/.github/scripts/intake-triage-legs.test.cjs @@ -208,11 +208,9 @@ test("every model job's leg uploads its sealed transcript under a job id unique ); assert.equal(uploads.length, 1, name); const [upload] = uploads; - assert.ok( - job.steps.indexOf(upload) > - job.steps.findIndex((step) => step.id === "review-outcome"), - name, - ); + const outcome = job.steps.findIndex((step) => step.id === "review-outcome"); + assert.ok(outcome >= 0, name); + assert.ok(job.steps.indexOf(upload) > outcome, name); assert.equal(upload["continue-on-error"], true); assert.equal( upload.if, diff --git a/.github/scripts/pr-review-cli.test.cjs b/.github/scripts/pr-review-cli.test.cjs index 69b1678f..32dd28f4 100644 --- a/.github/scripts/pr-review-cli.test.cjs +++ b/.github/scripts/pr-review-cli.test.cjs @@ -3479,6 +3479,7 @@ test("every model leg uploads its sealed transcript under a job id unique in the "review-fallback": fallback, })) { const outcome = job.steps.findIndex((s) => s.id === "review-outcome"); + assert.ok(outcome >= 0, `${name} has the outcome step`); const uploads = job.steps .map((s, index) => ({ s, index })) .filter(({ s }) => s.uses === "$/.github/actions/upload-lane-transcript"); diff --git a/.github/workflows/intake-triage.yml b/.github/workflows/intake-triage.yml index 3f0db604..081cfc92 100644 --- a/.github/workflows/intake-triage.yml +++ b/.github/workflows/intake-triage.yml @@ -847,7 +847,7 @@ jobs: # No model runs here. It reduces the run to the job outputs the apply # job reads: the result subtype, the error flag and the denial count, # each a closed value, and the structured output, checked against this - # job's allowlist and enums, as JSON. Nothing else leaves the job. The + # job's allowlist and enums, as JSON. Only the sealed transcript also leaves. The # reason, turns and denied tool names go to this job's summary only. - name: Extract the triage id: extract @@ -1431,7 +1431,7 @@ jobs: # No model runs here. It reduces the run to the job outputs the apply # job reads: the result subtype, the error flag and the denial count, # each a closed value, and the structured output, checked against this - # job's allowlist and enums, as JSON. Nothing else leaves the job. The + # job's allowlist and enums, as JSON. Only the sealed transcript also leaves. The # reason, turns and denied tool names go to this job's summary only. - name: Extract the triage id: extract @@ -2015,7 +2015,7 @@ jobs: # No model runs here. It reduces the run to the job outputs the apply # job reads: the result subtype, the error flag and the denial count, # each a closed value, and the structured output, checked against this - # job's allowlist and enums, as JSON. Nothing else leaves the job. The + # job's allowlist and enums, as JSON. Only the sealed transcript also leaves. The # reason, turns and denied tool names go to this job's summary only. - name: Extract the triage id: extract From 1f5acaae7d0185fd0ee764b64d4c4c88ea27119d Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:19:39 -0500 Subject: [PATCH 3/4] test(lanes): run the seal test from the contracts glob test-contracts already runs .github/scripts/*.test.cjs, so the seal test needs no new step. Leaving pr-require-checks.yml unchanged lets the review lanes run on this pull request: claude-code-action skips itself when the calling workflow differs from the default branch's copy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VQb9Q1VgLEiwGgRVshK3b7 --- .../upload-lane-transcript-seal.test.cjs} | 8 ++++---- .github/workflows/pr-require-checks.yml | 2 -- 2 files changed, 4 insertions(+), 6 deletions(-) rename .github/{actions/upload-lane-transcript/seal-transcript.test.cjs => scripts/upload-lane-transcript-seal.test.cjs} (96%) diff --git a/.github/actions/upload-lane-transcript/seal-transcript.test.cjs b/.github/scripts/upload-lane-transcript-seal.test.cjs similarity index 96% rename from .github/actions/upload-lane-transcript/seal-transcript.test.cjs rename to .github/scripts/upload-lane-transcript-seal.test.cjs index 09bc10e4..e4a628d5 100644 --- a/.github/actions/upload-lane-transcript/seal-transcript.test.cjs +++ b/.github/scripts/upload-lane-transcript-seal.test.cjs @@ -13,11 +13,11 @@ const zlib = require("node:zlib"); const { makeTemporaryDirectory, -} = require("../../scripts/temporary-directories.cjs"); -const { parseWorkflow } = require("../../scripts/workflow-yaml.cjs"); +} = require("./temporary-directories.cjs"); +const { parseWorkflow } = require("./workflow-yaml.cjs"); const ACTION = parseWorkflow( - fs.readFileSync(path.join(__dirname, "action.yml"), "utf8"), + fs.readFileSync(path.join(__dirname, "../actions/upload-lane-transcript/action.yml"), "utf8"), ); const SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex"); const MARKER = "plaintext-marker-7f3c"; @@ -94,7 +94,7 @@ function run(dir, { key, file = "", jobId = JOB }) { fs.writeFileSync(output, ""); const result = spawnSync( process.execPath, - [path.join(__dirname, "seal-transcript.cjs")], + [path.join(__dirname, "../actions/upload-lane-transcript/seal-transcript.cjs")], { env: { PATH: process.env.PATH, diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index 93b96be4..e1d5f757 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -557,8 +557,6 @@ jobs: run: node --test .github/actions/report-lane-outcome/*.test.cjs - name: Test the run-claude-cli launcher run: node --test .github/actions/run-claude-cli/*.test.cjs - - name: Test the lane transcript seal - run: node --test .github/actions/upload-lane-transcript/*.test.cjs - name: Test OSV scan policy guard run: bash .github/scripts/osv-scan-guard.test.sh - name: Test Gitleaks scan policy guard From 155848268805132ca3843768a26fe4cc9eb6b560 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:23:51 -0500 Subject: [PATCH 4/4] style(lanes): format the moved seal test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VQb9Q1VgLEiwGgRVshK3b7 --- .../scripts/upload-lane-transcript-seal.test.cjs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/scripts/upload-lane-transcript-seal.test.cjs b/.github/scripts/upload-lane-transcript-seal.test.cjs index e4a628d5..8b15412b 100644 --- a/.github/scripts/upload-lane-transcript-seal.test.cjs +++ b/.github/scripts/upload-lane-transcript-seal.test.cjs @@ -11,13 +11,14 @@ const path = require("node:path"); const test = require("node:test"); const zlib = require("node:zlib"); -const { - makeTemporaryDirectory, -} = require("./temporary-directories.cjs"); +const { makeTemporaryDirectory } = require("./temporary-directories.cjs"); const { parseWorkflow } = require("./workflow-yaml.cjs"); const ACTION = parseWorkflow( - fs.readFileSync(path.join(__dirname, "../actions/upload-lane-transcript/action.yml"), "utf8"), + fs.readFileSync( + path.join(__dirname, "../actions/upload-lane-transcript/action.yml"), + "utf8", + ), ); const SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex"); const MARKER = "plaintext-marker-7f3c"; @@ -94,7 +95,12 @@ function run(dir, { key, file = "", jobId = JOB }) { fs.writeFileSync(output, ""); const result = spawnSync( process.execPath, - [path.join(__dirname, "../actions/upload-lane-transcript/seal-transcript.cjs")], + [ + path.join( + __dirname, + "../actions/upload-lane-transcript/seal-transcript.cjs", + ), + ], { env: { PATH: process.env.PATH,