Files
rea/scripts/ci/release-recovery.mjs
T

223 lines
6.5 KiB
JavaScript

import { execFile } from "node:child_process";
import { appendFile, readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { valid } from "semver";
import { z } from "zod";
import {
validateVerifiedPackage,
verifyPublishedPackageIntegrity,
} from "./verified-package.mjs";
const execute = promisify(execFile);
const sha = z.string().regex(/^[a-f0-9]{40}$/u);
const positive = z.coerce
.number()
.int()
.positive()
.max(Number.MAX_SAFE_INTEGER);
const versionSchema = z
.string()
.refine(
(value) => valid(value) === value && !value.includes("+"),
"Expected an exact release version without build metadata",
);
const repositorySchema = z.string().regex(/^[\w.-]+\/[\w.-]+$/u);
/** Require the original release's successful qualification and npm write. */
export function selectRecoveryRelease({
repository,
version,
run,
jobs,
release,
tagSha,
}) {
repositorySchema.parse(repository);
versionSchema.parse(version);
sha.parse(tagSha);
const original = z
.object({
id: positive,
run_attempt: positive,
path: z.literal(".github/workflows/release.yml"),
event: z.literal("pull_request"),
status: z.literal("completed"),
repository: z.object({ full_name: z.literal(repository) }),
head_repository: z.object({ full_name: z.literal(repository) }),
})
.parse(run);
z.object({
tag_name: z.literal(`rea-agents-${version}`),
draft: z.literal(false),
published_at: z.string().min(1),
}).parse(release);
const parsedJobs = z
.array(
z.object({
name: z.string(),
status: z.string(),
conclusion: z.string().nullable(),
steps: z
.array(
z.object({ name: z.string(), conclusion: z.string().nullable() }),
)
.default([]),
}),
)
.parse(jobs);
const requireJob = (name) => {
const matches = parsedJobs.filter((job) => job.name === name);
if (matches.length !== 1 || matches[0].status !== "completed")
throw new Error(`Expected one completed original job: ${name}`);
return matches[0];
};
for (const name of [
"Release reviewed main merge",
"Qualify exact release revision / CI required",
])
if (requireJob(name).conclusion !== "success")
throw new Error(`Original release did not pass ${name}`);
const publish = requireJob("Publish to npm");
const writes = publish.steps.filter((step) => step.name === "Publish");
const verified = publish.steps.filter(
(step) => step.name === "Verify published npm bytes and dist-tag",
);
if (
!(
(writes.length === 1 && writes[0].conclusion === "success") ||
(verified.length === 1 && verified[0].conclusion === "success")
)
)
throw new Error(
"Original npm publication did not succeed or verify existing bytes",
);
return {
version,
source_sha: tagSha,
run_id: original.id,
run_attempt: original.run_attempt,
};
}
/** Bind existing npm bytes to the original qualified archive, without rebuilding it. */
export async function verifyRecoveryPackage({
directory,
sourceSha,
runId,
runAttempt,
version,
integrity,
}) {
const tarball = await validateVerifiedPackage({
directory,
sourceSha,
runId,
runAttempt,
packageVersion: version,
nodeVersion: process.version,
});
await verifyPublishedPackageIntegrity(tarball, integrity);
return { version, sourceSha, runId, runAttempt, integrity };
}
async function command(program, args, cwd) {
return (
await execute(program, args, { cwd, maxBuffer: 16 * 1024 * 1024 })
).stdout.trim();
}
async function main() {
const [operation, ...extra] = process.argv.slice(2);
if (extra.length || !["resolve", "verify"].includes(operation))
throw new Error(
"Usage: node scripts/ci/release-recovery.mjs resolve|verify",
);
const version = versionSchema.parse(process.env.RECOVERY_VERSION);
const runId = positive.parse(process.env.RECOVERY_RUN_ID);
if (operation === "resolve") {
if (process.env.GITHUB_REF !== "refs/heads/main")
throw new Error("Run release recovery from main");
const repository = repositorySchema.parse(process.env.GITHUB_REPOSITORY);
const api = async (path) =>
JSON.parse(await command("gh", ["api", `repos/${repository}/${path}`]));
const run = await api(`actions/runs/${runId}`);
const pages = z
.array(z.object({ jobs: z.array(z.unknown()) }))
.parse(
JSON.parse(
await command("gh", [
"api",
"--paginate",
"--slurp",
`repos/${repository}/actions/runs/${runId}/attempts/${positive.parse(run.run_attempt)}/jobs?per_page=100`,
]),
),
);
const release = await api(`releases/tags/rea-agents-${version}`);
const tagSha = await command("git", [
"rev-parse",
"--verify",
`refs/tags/rea-agents-${version}^{commit}`,
]);
const selection = selectRecoveryRelease({
repository,
version,
run,
jobs: pages.flatMap((page) => page.jobs),
release,
tagSha,
});
await appendFile(
z.string().min(1).parse(process.env.GITHUB_OUTPUT),
Object.entries(selection)
.map(([key, value]) => `${key}=${value}\n`)
.join(""),
);
process.stdout.write(`${JSON.stringify(selection)}\n`);
return;
}
const sourceDirectory = z
.string()
.min(1)
.parse(process.env.RECOVERY_SOURCE_DIRECTORY);
const sourceSha = sha.parse(process.env.RECOVERY_SOURCE_SHA);
if (
(await command("git", ["rev-parse", "HEAD"], sourceDirectory)) !== sourceSha
)
throw new Error("Recovery checkout differs from the released source");
z.object({ version: z.literal(version) }).parse(
JSON.parse(await readFile(join(sourceDirectory, "package.json"), "utf8")),
);
const integrity = z
.string()
.parse(
JSON.parse(
await command("npm", [
"view",
`rea-agents@${version}`,
"dist.integrity",
"--json",
"--registry=https://registry.npmjs.org",
]),
),
);
const result = await verifyRecoveryPackage({
directory: z.string().min(1).parse(process.env.RECOVERY_ARTIFACT_DIRECTORY),
sourceSha,
runId,
runAttempt: positive.parse(process.env.RECOVERY_RUN_ATTEMPT),
version,
integrity,
});
process.stdout.write(`${JSON.stringify(result)}\n`);
}
if (
process.argv[1] !== undefined &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
)
await main();