#!/usr/bin/env -S deno run --allow-read --allow-env --allow-net /** * The relay: the one CI principal that writes to the store. Runs in the * workflow_run follower with base-repository credentials, walks the * test-records-* artifacts of the run attempt that triggered it, composes * each artifact's context line from the event payload plus the artifact's * own job.json, and creates one gzip-encoded object per artifact. Object * names derive from that attempt's start, the run id, and the artifact * name, none of which a later attempt changes, so re-running the relay * re-ships idempotently: an object that already exists collides on create * and is treated as shipped. * * deno run -A tasks/test-records-relay.ts --artifacts * * The directory holds one subdirectory per downloaded artifact. The run's * facts come from the workflow_run event payload (GITHUB_EVENT_PATH), or * from --run-json for a manual re-ship of one attempt. The store * coordinates come from TEST_RECORDS_BUCKET and TEST_RECORDS_PREFIX, and * the credential is the federated access token in TEST_RECORDS_GCS_TOKEN. * A failed artifact is reported and the relay exits nonzero, visibly; * rerunning it re-ships only what is missing. */ import { join } from "@std/path"; import { ulid } from "@std/ulid"; import { buildObjectBody, ciObjectName, createObject, type Environment, gzipText, isSeed, parseRecordLine, readEnv, RECORD_SCHEMA_VERSION, type RunContext, type TestRecord, } from "@commonfabric/test-support/records"; import { isObjectOrArray } from "@commonfabric/utils/types"; import { ciSubmissionsPrefix, REPO, storeBucket, } from "./test-records-config.ts"; /** The run facts the relay needs from the workflow_run payload. */ export interface RunFacts { workflowRunId: string; runAttempt: number; workflow: string; event: string; headSha: string; headBranch?: string; /** * When this attempt started. A later attempt reports a later start for * the run, but never changes this attempt's. */ runStartedAt: string; /** True when the head repository differs from the base repository. */ fork: boolean; /** Numeric id of the user whose push or pull request ran, when known. */ actorId?: string; } /** Extracts the run facts from a workflow_run event payload. */ export function runFactsOfPayload(payload: unknown): RunFacts { if (!isObjectOrArray(payload)) { throw new Error("the event payload is not an object"); } const run = (payload as Record).workflow_run ?? payload; if (!isObjectOrArray(run)) { throw new Error("the event payload has no workflow_run"); } const raw = run as Record; const id = raw.id; const headSha = raw.head_sha; const runStartedAt = raw.run_started_at; const name = raw.name; if ( (typeof id !== "number" && typeof id !== "string") || typeof headSha !== "string" || typeof runStartedAt !== "string" || typeof name !== "string" ) { throw new Error("the workflow_run payload is missing run facts"); } const headRepository = (raw.head_repository as | Record | undefined)?.full_name; const baseRepository = (raw.repository as | Record | undefined)?.full_name; const facts: RunFacts = { workflowRunId: String(id), runAttempt: typeof raw.run_attempt === "number" ? raw.run_attempt : 1, workflow: name, event: typeof raw.event === "string" ? raw.event : "unknown", headSha, runStartedAt, // Fork when the repositories provably differ; a payload without both // names reads as a fork, so a run this repository cannot place never // stands as a baseline. fork: typeof headRepository === "string" && typeof baseRepository === "string" ? headRepository !== baseRepository : true, }; if (typeof raw.head_branch === "string" && raw.head_branch.length > 0) { facts.headBranch = raw.head_branch; } const actorId = (raw.actor as Record | undefined)?.id; if (typeof actorId === "number" || typeof actorId === "string") { facts.actorId = String(actorId); } return facts; } /** * Whether a run's artifacts ship at all. Same-repository runs always * ship: only people with write access can create them. A fork run ships * only when its actor — the person whose push or pull request the run * executed, from the trusted payload — is on the team's member list, so * the public, immutable store accepts content authored by team members * working from their forks and nothing from anyone else. An empty list, * or a run with no readable actor, fails closed. */ export function shouldShipRun( run: RunFacts, memberActorIds: ReadonlySet, ): boolean { if (!run.fork) return true; if (run.actorId === undefined) return false; return memberActorIds.has(run.actorId); } interface ArtifactFacts { job?: string; shard?: string; commit?: string; headCommit?: string; os?: string; arch?: string; denoVersion?: string; shuffleSeed?: number; } function artifactFactsOf(value: unknown): ArtifactFacts { if (!isObjectOrArray(value)) return {}; const raw = value as Record; const facts: ArtifactFacts = {}; for ( const field of [ "job", "shard", "commit", "headCommit", "os", "arch", "denoVersion", ] as const ) { const v = raw[field]; if (typeof v === "string" && v.length > 0) facts[field] = v; } if (isSeed(raw.shuffleSeed)) facts.shuffleSeed = raw.shuffleSeed; return facts; } /** * Composes the context line for one artifact. Run identity comes from the * trusted event payload; the merge commit the jobs checked out, and the * machine facts, come from the artifact's job.json since the payload does * not carry them. */ export function composeCiContext( run: RunFacts, artifact: ArtifactFacts, artifactName: string, ): RunContext { const context: RunContext = { schema: RECORD_SCHEMA_VERSION, line: "context", reportId: ulid(), repo: REPO, commit: artifact.commit ?? run.headSha, dirty: false, env: "ci", ci: { workflowRunId: run.workflowRunId, runAttempt: run.runAttempt, workflow: run.workflow, job: artifact.job ?? artifactName, }, os: artifact.os ?? "unknown", arch: artifact.arch ?? "unknown", denoVersion: artifact.denoVersion ?? "unknown", startedAt: run.runStartedAt, }; if (run.headBranch !== undefined) context.branch = run.headBranch; if (artifact.shuffleSeed !== undefined) { context.shuffleSeed = artifact.shuffleSeed; } if (artifact.shard !== undefined) context.ci!.shard = artifact.shard; if (run.event === "pull_request") { context.ci!.headCommit = artifact.headCommit ?? run.headSha; } // Provenance for decision-feeding consumers, from the trusted payload // only: the record lines and job facts of a fork run are fork-authored. context.ci!.event = run.event; context.ci!.fork = run.fork; return context; } // The gather step always writes records.ndjson, empty or not, so an // artifact without a readable one is truncated; the raised error lands the // artifact in the failed list rather than shipping a context-only object // that would read as a run with no tests. async function readArtifactRecords(dir: string): Promise { const records: TestRecord[] = []; const text = await Deno.readTextFile(join(dir, "records.ndjson")); for (const line of text.split("\n")) { if (line.length === 0) continue; const record = parseRecordLine(line); if (record === undefined) { console.warn(`test records: dropped a line no reader accepts in ${dir}`); continue; } records.push(record); } return records; } export interface RelayOptions { artifactsDir: string; run: RunFacts; bucket: string; prefix: string; token: string; fetch?: typeof fetch; } /** * Ships every artifact directory the run's attempt produced; returns the * names that failed. An artifact's name ends in `-a`, and one * another attempt produced is left to the relay that attempt triggered, * whose payload carries that attempt's start. A name with no attempt * cannot be dated, so it fails. */ export async function relayArtifacts(options: RelayOptions): Promise { const failed: string[] = []; const names: string[] = []; for await (const entry of Deno.readDir(options.artifactsDir)) { if (!entry.isDirectory) continue; const attempt = entry.name.match(/-a(\d+)$/)?.[1]; if (attempt === undefined) { console.error(`test records: ${entry.name} names no attempt`); failed.push(entry.name); } else if (Number(attempt) === options.run.runAttempt) { names.push(entry.name); } else { console.log(`test records: ${entry.name} is another attempt's`); } } names.sort(); if (names.length === 0) { console.log( "test records: the attempt produced no test-records artifacts", ); return failed; } for (const name of names) { const dir = join(options.artifactsDir, name); try { let facts: ArtifactFacts = {}; try { facts = artifactFactsOf( JSON.parse(await Deno.readTextFile(join(dir, "job.json"))), ); } catch { console.warn(`test records: ${name} has no readable job.json`); } const records = await readArtifactRecords(dir); const context = composeCiContext(options.run, facts, name); const body = buildObjectBody(context, records); const gzipped = await gzipText(body); const objectName = `${options.prefix}/` + ciObjectName({ runStartedAt: options.run.runStartedAt, workflowRunId: options.run.workflowRunId, artifactName: name, }); const createOptions: Parameters[0] = { bucket: options.bucket, name: objectName, body: gzipped, token: options.token, contentType: "application/x-ndjson", contentEncoding: "gzip", }; if (options.fetch !== undefined) createOptions.fetch = options.fetch; const result = await createObject(createOptions); console.log( `test records: ${name}: ${records.length} record(s) ` + `${result === "created" ? "shipped to" : "already at"} ${objectName}`, ); } catch (error) { console.error(`test records: shipping ${name} failed: ${error}`); failed.push(name); } } return failed; } function usage(): never { console.error( "usage: test-records-relay.ts --artifacts [--run-json ]", ); Deno.exit(2); } /** Parses the command line; undefined means a malformed one. */ export function parseRelayArgs( argsIn: readonly string[], ): { artifactsDir: string; runJson?: string } | undefined { let artifactsDir: string | undefined; let runJson: string | undefined; const args = [...argsIn]; while (args.length > 0) { const flag = args.shift()!; const value = args.shift(); if (value === undefined) return undefined; switch (flag) { case "--artifacts": artifactsDir = value; break; case "--run-json": runJson = value; break; default: return undefined; } } if (artifactsDir === undefined) return undefined; const parsed: { artifactsDir: string; runJson?: string } = { artifactsDir }; if (runJson !== undefined) parsed.runJson = runJson; return parsed; } /** The member list an environment carries: comma-separated numeric ids. */ export function memberActorIdsOf( env: Environment = Deno.env.get, ): Set { return new Set( (readEnv("TEST_RECORDS_MEMBER_ACTOR_IDS", env) ?? "") .split(",") .map((id) => id.trim()) .filter((id) => id.length > 0), ); } export interface RelayRunOptions { artifactsDir: string; runJson?: string; env?: Environment; fetchImpl?: typeof fetch; } /** * The whole relay run: reads the payload, applies the member gate, and * ships every artifact. Returns the process's exit code. */ export async function runRelay(options: RelayRunOptions): Promise { const env = options.env ?? Deno.env.get; const payloadPath = options.runJson ?? readEnv("GITHUB_EVENT_PATH", env); if (payloadPath === undefined) { throw new Error("no event payload: set GITHUB_EVENT_PATH or --run-json"); } const run = runFactsOfPayload( JSON.parse(await Deno.readTextFile(payloadPath)), ); if (!shouldShipRun(run, memberActorIdsOf(env))) { console.log( `test records: run ${run.workflowRunId} is a fork run whose actor ` + "is not on the team member list; nothing ships.", ); return 0; } const token = readEnv("TEST_RECORDS_GCS_TOKEN", env); if (token === undefined || token.length === 0) { throw new Error("TEST_RECORDS_GCS_TOKEN is not set"); } const relayOptions: RelayOptions = { artifactsDir: options.artifactsDir, run, bucket: storeBucket(env), prefix: ciSubmissionsPrefix(env), token, }; if (options.fetchImpl !== undefined) relayOptions.fetch = options.fetchImpl; const failed = await relayArtifacts(relayOptions); if (failed.length > 0) { console.error( `test records: ${failed.length} artifact(s) failed: ${failed.join(", ")}`, ); return 1; } return 0; } async function main(): Promise { const parsed = parseRelayArgs(Deno.args); if (parsed === undefined) usage(); const code = await runRelay(parsed); if (code !== 0) Deno.exit(code); } if (import.meta.main) { await main(); }