newspaper_wedding/.aidlc/tools/aidlc-release.ts
Andrew Ridgway bec1eaac87
Some checks failed
Test / test (push) Has been cancelled
first pass at the newspaper builder
2026-09-14 11:57:22 +10:00

836 lines
30 KiB
TypeScript

import { createHash } from "node:crypto";
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, isAbsolute, join, resolve } from "node:path";
import {
compareVersions,
parseVersion,
PREVIEW_CHANNEL,
PREVIEW_VERSION,
requireVersion,
} from "./aidlc-channel.ts";
import { resolvedReleaseSettings } from "./aidlc-machine-config.ts";
export type ReleaseAsset = {
name: string;
sha256: string;
bytes: number;
kind: "binary" | "runtime" | "installer";
target?: string;
verification?: {
status: "VERIFIED" | "UNVERIFIED";
mode: "full-runtime" | "inspection-only";
hostTarget: string;
};
};
export type ReleaseManifest = {
schemaVersion: 1;
version: string;
date: string;
sourceRef?: string;
sourceDigest?: string;
distributions: Array<{ name: string; productName: string }>;
assets: ReleaseAsset[];
};
export function releaseRuntimeAsset(version: string): string {
return `aidlc-runtime-${requireVersion(version)}.tar.gz`;
}
export class ReleaseUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "ReleaseUnavailableError";
}
}
const MAX_ASSET_BYTES = 1024 * 1024 * 1024;
const MAX_METADATA_BYTES = 1024 * 1024;
const MAX_RELEASE_LIST_PAGES = 10;
const PROGRESS_WIDTH = 72;
const PROVENANCE_BUNDLE = "aidlc-release.intoto.jsonl";
const DEFAULT_RELEASE_REPOSITORY = "awslabs/aidlc-workflows";
const GITHUB_API_HEADERS = {
Accept: "application/vnd.github+json",
"User-Agent": "aidlc-native-lifecycle",
"X-GitHub-Api-Version": "2022-11-28",
};
function releaseTrust(version: string): { repository: string; workflow: string } {
const repository =
process.env.AIDLC_RELEASE_REPOSITORY ?? DEFAULT_RELEASE_REPOSITORY;
const workflowName = parseVersion(version).channel === PREVIEW_CHANNEL
? "preview-release.yml"
: "release.yml";
const workflow =
process.env.AIDLC_RELEASE_WORKFLOW ??
`${repository}/.github/workflows/${workflowName}`;
return { repository, workflow };
}
function defaultReleaseBaseUrl(): string {
const repository =
process.env.AIDLC_RELEASE_REPOSITORY ?? DEFAULT_RELEASE_REPOSITORY;
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
throw new Error("AIDLC_RELEASE_REPOSITORY must be owner/name");
}
return `https://github.com/${repository}/releases`;
}
// The releases-list API endpoint behind a release base URL. github.com hosts
// derive it (`https://github.com/<owner>/<repo>/releases` becomes
// `https://api.github.com/repos/<owner>/<repo>/releases`); every other host,
// including the loopback test fixture, must name it explicitly through
// AIDLC_RELEASE_API_URL or `--release-api-url`.
export function releaseApiUrl(baseUrl: string, explicit?: string): string {
const configured = explicit || process.env.AIDLC_RELEASE_API_URL;
if (configured) {
assertReleaseUrl(configured);
return configured.replace(/\/+$/, "");
}
const parsed = new URL(baseUrl);
const match = /^\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/releases\/?$/.exec(parsed.pathname);
if (parsed.protocol !== "https:" || parsed.hostname !== "github.com" || !match) {
throw new ReleaseUnavailableError(
`${PREVIEW_CHANNEL} releases cannot be listed for ${redact(baseUrl)}; pass --release-api-url or set AIDLC_RELEASE_API_URL`,
);
}
return `https://api.github.com/repos/${match[1]}/${match[2]}/releases`;
}
function progress(url: string, complete: boolean): void {
if (process.env.AIDLC_ROUTE_OUTPUT_MODE !== "human") return;
const name = basename(new URL(url).pathname) || "release asset";
const message = complete ? `Downloaded ${name}` : `Downloading ${name}...`;
if (process.stderr.isTTY) {
process.stderr.write(`\r${message.slice(0, PROGRESS_WIDTH).padEnd(PROGRESS_WIDTH)}${complete ? "\n" : ""}`);
} else if (complete) {
process.stderr.write(`${message}\n`);
}
}
function assertMetadataSize(path: string, name: string): void {
if (statSync(path).size > MAX_METADATA_BYTES) {
throw new Error(`${name} exceeds the 1 MiB metadata limit`);
}
}
function safeAssetName(name: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || basename(name) !== name) {
throw new Error(`unsafe release asset name: ${name}`);
}
return name;
}
function readChecksums(path: string): Map<string, string> {
assertMetadataSize(path, "checksums.txt");
const rows = new Map<string, string>();
for (const line of readFileSync(path, "utf-8").trim().split(/\r?\n/).filter(Boolean)) {
const match = /^([a-f0-9]{64}) {2}([A-Za-z0-9][A-Za-z0-9._-]*)$/.exec(line);
if (!match) throw new Error(`malformed checksums.txt row: ${line}`);
if (rows.has(match[2])) throw new Error(`duplicate checksums.txt row: ${match[2]}`);
rows.set(match[2], match[1]);
}
return rows;
}
export function digest(path: string): string {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}
function verifiedChecksums(directory: string): Map<string, string> {
const checksumsPath = join(directory, "checksums.txt");
if (!existsSync(checksumsPath)) throw new Error("release is missing checksums.txt");
const rows = readChecksums(checksumsPath);
const expected = rows.get("version.json");
if (!expected) throw new Error("checksums.txt has no version.json checksum");
const manifestPath = join(directory, "version.json");
if (!existsSync(manifestPath)) throw new Error("release is missing version.json");
const actual = digest(manifestPath);
if (actual !== expected) {
throw new Error(`version.json: checksum mismatch (expected ${expected}, got ${actual})`);
}
return rows;
}
export function verifyReleaseProvenance(
directory: string,
manifest: ReleaseManifest,
): void {
const bundle = join(directory, PROVENANCE_BUNDLE);
if (!existsSync(bundle)) {
throw new Error(`release is missing ${PROVENANCE_BUNDLE}`);
}
assertMetadataSize(bundle, PROVENANCE_BUNDLE);
const trust = releaseTrust(manifest.version);
const configuredGh = process.env.AIDLC_GH_BIN?.trim();
const gh = configuredGh || "gh";
let capabilityAvailable = false;
try {
const capability = Bun.spawnSync([gh, "attestation", "verify", "--help"], {
env: { ...process.env },
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const help = [
Buffer.from(capability.stdout ?? new Uint8Array()).toString("utf-8"),
Buffer.from(capability.stderr ?? new Uint8Array()).toString("utf-8"),
].join("\n");
capabilityAvailable = capability.exitCode === 0 &&
["--signer-workflow", "--source-ref", "--source-digest"].every((flag) =>
help.includes(flag)
);
} catch {
return;
}
if (!capabilityAvailable) return;
const result = Bun.spawnSync([
gh,
"attestation",
"verify",
join(directory, "checksums.txt"),
"--bundle",
bundle,
"--repo",
trust.repository,
"--signer-workflow",
trust.workflow,
"--source-ref",
manifest.sourceRef ?? `refs/tags/v${manifest.version}`,
...(manifest.sourceDigest
? ["--source-digest", manifest.sourceDigest]
: []),
], {
env: { ...process.env },
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
if (result.exitCode !== 0) {
const stderr = Buffer.from(result.stderr ?? new Uint8Array()).toString("utf-8").trim();
throw new Error(
`release provenance verification failed${
stderr ? `: ${stderr.split(/\r?\n/)[0]}` : ""
}`,
);
}
}
export function readReleaseManifest(directory: string): ReleaseManifest {
assertMetadataSize(join(directory, "version.json"), "version.json");
let manifest: ReleaseManifest;
try {
manifest = JSON.parse(readFileSync(join(directory, "version.json"), "utf-8")) as ReleaseManifest;
} catch (error) {
throw new Error(`invalid version.json: ${error instanceof Error ? error.message : String(error)}`);
}
if (manifest.schemaVersion !== 1) throw new Error(`unsupported release schema ${manifest.schemaVersion}`);
const releaseVersion = parseVersion(manifest.version);
const hasSourceRef = manifest.sourceRef !== undefined;
const hasSourceDigest = manifest.sourceDigest !== undefined;
if (hasSourceRef !== hasSourceDigest) {
throw new Error("version.json must provide sourceRef and sourceDigest together");
}
if (
hasSourceRef &&
(
manifest.sourceRef !== (
releaseVersion.channel === PREVIEW_CHANNEL
? "refs/heads/main"
: `refs/tags/v${manifest.version}`
) ||
!/^[a-f0-9]{40}$/.test(manifest.sourceDigest ?? "")
)
) {
throw new Error("version.json contains an invalid release source identity");
}
if (!Array.isArray(manifest.distributions) || manifest.distributions.length === 0) {
throw new Error("version.json contains no distributions");
}
const distributions = new Set<string>();
for (const distribution of manifest.distributions) {
if (
!distribution ||
!/^[a-z0-9][a-z0-9-]*$/.test(distribution.name) ||
typeof distribution.productName !== "string" ||
distribution.productName.trim().length === 0 ||
distributions.has(distribution.name)
) {
throw new Error("version.json contains an invalid or duplicate distribution");
}
distributions.add(distribution.name);
}
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
throw new Error("version.json contains no assets");
}
const names = new Set<string>();
for (const asset of manifest.assets) {
safeAssetName(asset.name);
if (names.has(asset.name)) throw new Error(`duplicate release asset: ${asset.name}`);
names.add(asset.name);
if (!/^[a-f0-9]{64}$/.test(asset.sha256)) {
throw new Error(`${asset.name}: invalid SHA-256`);
}
if (!Number.isSafeInteger(asset.bytes) || asset.bytes < 0 || asset.bytes > MAX_ASSET_BYTES) {
throw new Error(`${asset.name}: invalid byte length`);
}
const verificationValid = asset.verification === undefined ||
(
asset.verification !== null &&
typeof asset.verification === "object" &&
asset.kind === "binary" &&
(asset.verification.status === "VERIFIED" ||
asset.verification.status === "UNVERIFIED") &&
(asset.verification.mode === "full-runtime" ||
asset.verification.mode === "inspection-only") &&
/^[a-z0-9][a-z0-9-]*$/.test(asset.verification.hostTarget)
);
if (
!["binary", "runtime", "installer"].includes(asset.kind) ||
!verificationValid ||
(asset.kind === "binary" &&
(!asset.target || asset.name !== `aidlc-${asset.target}${asset.target.startsWith("windows-") ? ".exe" : ""}`)) ||
(asset.kind === "runtime" && asset.name !== releaseRuntimeAsset(manifest.version)) ||
(asset.kind === "installer" &&
asset.name !== "install.sh" &&
asset.name !== "install.ps1")
) {
throw new Error(`${asset.name}: invalid asset metadata`);
}
}
return manifest;
}
export function verifyReleaseDirectory(
directory: string,
required: readonly string[] = [],
allowSubset = false,
): ReleaseManifest {
const root = isAbsolute(directory) ? directory : resolve(process.cwd(), directory);
const checksumRows = verifiedChecksums(root);
const manifest = readReleaseManifest(root);
const manifestNames = new Set(manifest.assets.map((asset) => asset.name));
for (const name of checksumRows.keys()) {
if (name !== "version.json" && !manifestNames.has(name)) {
throw new Error(`checksums.txt contains unknown asset ${name}`);
}
}
for (const asset of manifest.assets) {
const path = join(root, asset.name);
if (!existsSync(path)) {
if (allowSubset && !required.includes(asset.name)) continue;
throw new Error(`release is missing ${asset.name}`);
}
const actual = digest(path);
if (actual !== asset.sha256 || checksumRows.get(asset.name) !== actual) {
throw new Error(`${asset.name}: checksum mismatch (expected ${asset.sha256}, got ${actual})`);
}
if (readFileSync(path).byteLength !== asset.bytes) {
throw new Error(`${asset.name}: size mismatch`);
}
}
for (const name of required) {
if (!manifest.assets.some((asset) => asset.name === name)) {
throw new Error(`release manifest does not provide ${name}`);
}
}
return manifest;
}
function releaseUrl(base: string, version: string | undefined, name: string): string {
const clean = base.replace(/\/+$/, "");
const segment = version ? `download/v${version}` : "latest/download";
return `${clean}/${segment}/${name}`;
}
function redact(url: string): string {
try {
const parsed = new URL(url);
if (parsed.username || parsed.password) {
parsed.username = "***";
parsed.password = "***";
}
if (parsed.search) parsed.search = "?<redacted>";
if (parsed.hash) parsed.hash = "#<redacted>";
return parsed.toString();
} catch {
return "<invalid-url>";
}
}
function remainingTimeout(deadline: number, label: string): number {
const remaining = deadline - Date.now();
if (remaining <= 0) {
throw new ReleaseUnavailableError(`${label} timed out before the release metadata completed`);
}
return remaining;
}
function assertReleaseUrl(url: string, allowQuery = false): URL {
const parsed = new URL(url);
if (parsed.username || parsed.password) {
throw new Error(`release URL must not include credentials: ${redact(url)}`);
}
if ((!allowQuery && parsed.search) || parsed.hash) {
throw new Error(
`release URL must not include ${allowQuery ? "a fragment" : "a query or fragment"}: ${redact(url)}`,
);
}
if (
parsed.protocol !== "https:" &&
!(parsed.protocol === "http:" &&
(parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost"))
) {
throw new Error(`release URL must use HTTPS: ${redact(url)}`);
}
return parsed;
}
function proxyFor(url: URL): string | undefined {
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy;
if (!proxy) return undefined;
const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
const port = url.port || (url.protocol === "https:" ? "443" : "80");
for (const rawEntry of noProxy.split(",")) {
const entry = rawEntry.trim();
if (!entry) continue;
if (entry === "*") return undefined;
const withoutScheme = entry.replace(/^[a-z]+:\/\//i, "").split("/")[0];
const lastColon = withoutScheme.lastIndexOf(":");
const hasPort = lastColon > 0 && /^\d+$/.test(withoutScheme.slice(lastColon + 1));
const host = (hasPort ? withoutScheme.slice(0, lastColon) : withoutScheme)
.replace(/^\./, "")
.toLowerCase();
const entryPort = hasPort ? withoutScheme.slice(lastColon + 1) : undefined;
const hostname = url.hostname.toLowerCase();
if (
host &&
(!entryPort || entryPort === port) &&
(hostname === host || hostname.endsWith(`.${host}`))
) {
return undefined;
}
}
const parsedProxy = new URL(proxy);
if (parsedProxy.protocol !== "http:" && parsedProxy.protocol !== "https:") {
throw new Error(`HTTPS_PROXY must use HTTP or HTTPS: ${redact(proxy)}`);
}
return proxy;
}
type TransportOptions = {
timeoutMs: number;
caBundle?: string;
maxBytes?: number;
contentTypes?: readonly string[];
reportedTimeoutMs?: number;
// Release asset URLs never carry a query; API list requests do.
allowQuery?: boolean;
headers?: Record<string, string>;
notFound?: string;
};
const NO_RELEASE_PUBLISHED =
"No published native release is available yet. Pass --from <release-directory>, or run bun scripts/package.ts in an aidlc-workflows source checkout to materialize the development copy channel.";
async function fetchBytes(
url: string,
options: TransportOptions,
): Promise<{ bytes: Buffer; headers: Headers }> {
const {
timeoutMs,
caBundle,
maxBytes = MAX_ASSET_BYTES,
contentTypes = [],
reportedTimeoutMs = timeoutMs,
allowQuery = false,
headers,
notFound = NO_RELEASE_PUBLISHED,
} = options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
let current = url;
let response: Response | undefined;
for (let redirects = 0; redirects <= 5; redirects++) {
const parsed = assertReleaseUrl(current, allowQuery || redirects > 0);
const proxy = proxyFor(parsed);
try {
response = await fetch(current, {
redirect: "manual",
signal: controller.signal,
...(headers ? { headers } : {}),
...(proxy ? { proxy } : {}),
...(caBundle ? { tls: { ca: readFileSync(caBundle) } } : {}),
});
} catch (error) {
if (
controller.signal.aborted ||
error instanceof DOMException && error.name === "AbortError" ||
error instanceof Error && error.name === "TimeoutError"
) {
throw new ReleaseUnavailableError(
`${redact(current)} timed out after ${reportedTimeoutMs}ms`,
);
}
throw new ReleaseUnavailableError(`${redact(current)} transport failure`);
}
if (![301, 302, 303, 307, 308].includes(response.status)) break;
const location = response.headers.get("location");
if (!location) {
throw new ReleaseUnavailableError(`${redact(current)} returned a redirect without Location`);
}
if (redirects === 5) {
throw new ReleaseUnavailableError(`${redact(url)} exceeded the 5 redirect limit`);
}
await response.body?.cancel();
current = new URL(location, current).toString();
try {
assertReleaseUrl(current, true);
} catch (error) {
throw new ReleaseUnavailableError(
error instanceof Error ? error.message : String(error),
);
}
}
if (!response) throw new ReleaseUnavailableError(`${redact(url)} returned no response`);
if (response.status === 404) throw new ReleaseUnavailableError(notFound);
if (!response.ok) {
throw new ReleaseUnavailableError(`${redact(url)} returned HTTP ${response.status}`);
}
const contentType = response.headers.get("content-type")?.split(";", 1)[0]
.trim().toLowerCase() ?? "";
if (contentTypes.length > 0 && !contentTypes.includes(contentType)) {
throw new Error(
`${redact(url)} returned unexpected content type ${contentType || "<missing>"}`,
);
}
const length = Number(response.headers.get("content-length") || 0);
if (Number.isFinite(length) && length > maxBytes) {
throw new Error(`${redact(url)} exceeds the ${maxBytes} byte download limit`);
}
if (!response.body) {
throw new ReleaseUnavailableError(`${redact(url)} returned an empty response body`);
}
const chunks: Uint8Array[] = [];
let bytes = 0;
const reader = response.body.getReader();
while (true) {
const chunk = await reader.read().catch(() => {
throw new ReleaseUnavailableError(`${redact(current)} transport failure`);
});
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > maxBytes) {
await reader.cancel();
throw new Error(`${redact(url)} exceeds the ${maxBytes} byte download limit`);
}
chunks.push(chunk.value);
}
return {
bytes: Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), bytes),
headers: response.headers,
};
} catch (error) {
if (error instanceof ReleaseUnavailableError) throw error;
if (
error instanceof DOMException && error.name === "AbortError" ||
error instanceof Error && error.name === "TimeoutError"
) {
throw new ReleaseUnavailableError(
`${redact(url)} timed out after ${reportedTimeoutMs}ms`,
);
}
throw error;
} finally {
clearTimeout(timer);
}
}
async function download(
url: string,
path: string,
timeoutMs: number,
caBundle?: string,
maxBytes = MAX_ASSET_BYTES,
contentTypes: readonly string[] = [],
reportedTimeoutMs = timeoutMs,
): Promise<void> {
progress(url, false);
try {
const { bytes } = await fetchBytes(url, {
timeoutMs,
caBundle,
maxBytes,
contentTypes,
reportedTimeoutMs,
});
writeFileSync(path, bytes);
progress(url, true);
} catch (error) {
if (process.env.AIDLC_ROUTE_OUTPUT_MODE === "human" && process.stderr.isTTY) {
process.stderr.write(`\r${"".padEnd(PROGRESS_WIDTH)}\r`);
}
throw error;
}
}
// Newest published preview in the repository behind the release base URL:
// the GitHub releases list, filtered to non-draft prereleases whose tag is
// `v` plus a preview id. Stable discovery never comes through here (it is the
// `latest/download` redirect), and a preview lookup never falls back to stable:
// every API or transport failure, rate limiting included, is "unavailable".
export async function resolvePreviewVersion(options: {
baseUrl?: string;
apiUrl?: string;
caBundle?: string;
timeoutMs?: number;
} = {}): Promise<string> {
const settings = resolvedReleaseSettings(options);
if (settings.offline) {
throw new ReleaseUnavailableError(`${PREVIEW_CHANNEL} discovery is unavailable while offline`);
}
const baseUrl = settings.baseUrl || defaultReleaseBaseUrl();
const listUrl = releaseApiUrl(baseUrl, options.apiUrl);
const timeoutMs = options.timeoutMs ?? 15_000;
const deadline = Date.now() + timeoutMs;
let newest: string | undefined;
let next: string | null = `${listUrl}?per_page=100`;
for (let page = 0; next && page < MAX_RELEASE_LIST_PAGES; page++) {
const { bytes, headers } = await fetchBytes(next, {
timeoutMs: remainingTimeout(deadline, `${PREVIEW_CHANNEL} release list`),
caBundle: settings.caBundle,
maxBytes: MAX_METADATA_BYTES,
contentTypes: ["application/json"],
reportedTimeoutMs: timeoutMs,
allowQuery: true,
headers: GITHUB_API_HEADERS,
notFound: `${redact(listUrl)} returned HTTP 404; the release repository has no releases API`,
});
let listed: unknown;
try {
listed = JSON.parse(bytes.toString("utf-8"));
} catch {
throw new ReleaseUnavailableError(`${redact(listUrl)} returned malformed release JSON`);
}
if (!Array.isArray(listed)) {
throw new ReleaseUnavailableError(`${redact(listUrl)} returned a non-array release list`);
}
for (const entry of listed) {
if (
!entry ||
typeof entry !== "object" ||
!("draft" in entry) ||
entry.draft !== false ||
!("prerelease" in entry) ||
entry.prerelease !== true ||
!("tag_name" in entry) ||
typeof entry.tag_name !== "string"
) {
continue;
}
const tag = entry.tag_name;
if (!tag.startsWith("v") || !PREVIEW_VERSION.test(tag.slice(1))) continue;
const version = tag.slice(1);
if (!newest || compareVersions(version, newest) > 0) newest = version;
}
const link = headers.get("link") ?? "";
next = /<([^>]+)>;\s*rel="next"/.exec(link)?.[1] ?? null;
}
if (!newest) {
throw new ReleaseUnavailableError(
`no ${PREVIEW_CHANNEL} release is published at ${redact(listUrl)}`,
);
}
return newest;
}
export async function fetchReleaseMetadata(options: {
version?: string;
offline?: boolean;
baseUrl?: string;
caBundle?: string;
metadataTimeoutMs?: number;
} = {}): Promise<{
directory: string;
manifest: ReleaseManifest;
cleanup: string;
}> {
if (process.env.AIDLC_ROUTE_NETWORK_POLICY === "forbidden") {
throw new Error(`route ${process.env.AIDLC_ROUTE_ID ?? "unknown"} forbids network access`);
}
const settings = resolvedReleaseSettings(options);
if (settings.offline) {
throw new ReleaseUnavailableError("update metadata is unavailable while offline");
}
const version = options.version ? requireVersion(options.version) : undefined;
const baseUrl = settings.baseUrl || defaultReleaseBaseUrl();
const metadataTimeoutMs = options.metadataTimeoutMs ?? 15_000;
const metadataDeadline = Date.now() + metadataTimeoutMs;
const temporary = mkdtempSync(join(tmpdir(), "aidlc-release-metadata-"));
try {
await download(
releaseUrl(baseUrl, version, "version.json"),
join(temporary, "version.json"),
remainingTimeout(metadataDeadline, "release metadata"),
settings.caBundle,
MAX_METADATA_BYTES,
["application/json", "text/json", "application/octet-stream", "binary/octet-stream", "text/plain"],
metadataTimeoutMs,
);
await download(
releaseUrl(baseUrl, version, "checksums.txt"),
join(temporary, "checksums.txt"),
remainingTimeout(metadataDeadline, "release metadata"),
settings.caBundle,
MAX_METADATA_BYTES,
["text/plain", "application/octet-stream", "binary/octet-stream"],
metadataTimeoutMs,
);
await download(
releaseUrl(baseUrl, version, PROVENANCE_BUNDLE),
join(temporary, PROVENANCE_BUNDLE),
remainingTimeout(metadataDeadline, "release provenance"),
settings.caBundle,
MAX_METADATA_BYTES,
["application/json", "application/octet-stream", "binary/octet-stream", "text/plain"],
metadataTimeoutMs,
);
const manifest = readReleaseManifest(temporary);
verifyReleaseProvenance(temporary, {
...manifest,
sourceDigest: undefined,
});
verifiedChecksums(temporary);
if (manifest.sourceDigest) verifyReleaseProvenance(temporary, manifest);
if (version && manifest.version !== version) {
throw new Error(`release endpoint returned ${manifest.version}, not requested ${version}`);
}
return { directory: temporary, manifest, cleanup: temporary };
} catch (error) {
rmSync(temporary, { recursive: true, force: true });
throw error;
}
}
export async function acquireRelease(options: {
version?: string;
from?: string;
names?: readonly string[] | ((manifest: ReleaseManifest) => readonly string[]);
offline?: boolean;
baseUrl?: string;
caBundle?: string;
metadataTimeoutMs?: number;
}): Promise<{ directory: string; manifest: ReleaseManifest; cleanup?: string }> {
if (options.from) {
const directory = isAbsolute(options.from) ? options.from : resolve(process.cwd(), options.from);
const manifest = readReleaseManifest(directory);
const names = typeof options.names === "function" ? options.names(manifest) : options.names;
verifyReleaseProvenance(directory, {
...manifest,
sourceDigest: undefined,
});
verifiedChecksums(directory);
if (manifest.sourceDigest) verifyReleaseProvenance(directory, manifest);
verifyReleaseDirectory(directory, names, Boolean(names?.length));
if (options.version && manifest.version !== options.version) {
throw new Error(`local release is ${manifest.version}, not requested ${options.version}`);
}
return { directory, manifest };
}
if (process.env.AIDLC_ROUTE_NETWORK_POLICY === "forbidden") {
throw new Error(`route ${process.env.AIDLC_ROUTE_ID ?? "unknown"} forbids network access`);
}
const settings = resolvedReleaseSettings(options);
if (settings.offline) {
throw new ReleaseUnavailableError("--offline requires --from <release-directory>");
}
const version = options.version ? requireVersion(options.version) : undefined;
const metadata = await fetchReleaseMetadata({
version,
offline: settings.offline,
baseUrl: settings.baseUrl,
caBundle: settings.caBundle,
metadataTimeoutMs: options.metadataTimeoutMs,
});
const temporary = metadata.directory;
try {
const releasedChecksums = verifiedChecksums(temporary);
const manifest = metadata.manifest;
const names = typeof options.names === "function" ? options.names(manifest) : options.names;
const selected = names?.length
? manifest.assets.filter((asset) => names.includes(asset.name))
: manifest.assets;
const missing = (names ?? []).filter((name) => !selected.some((asset) => asset.name === name));
if (missing.length > 0) {
throw new ReleaseUnavailableError(`release does not provide: ${missing.join(", ")}`);
}
for (const asset of selected) {
if (releasedChecksums.get(asset.name) !== asset.sha256) {
throw new Error(`${asset.name}: released checksum does not match version.json`);
}
await download(
releaseUrl(
settings.baseUrl ||
defaultReleaseBaseUrl(),
version || manifest.version,
asset.name,
),
join(temporary, asset.name),
Math.max(60_000, Math.ceil(asset.bytes / (128 * 1024)) * 1000),
settings.caBundle,
);
}
const subset: ReleaseManifest = { ...manifest, assets: selected };
writeFileSync(join(temporary, "version.json"), `${JSON.stringify(subset, null, 2)}\n`);
writeFileSync(
join(temporary, "checksums.txt"),
`${
[
`${digest(join(temporary, "version.json"))} version.json`,
...selected.map((asset) => `${releasedChecksums.get(asset.name)} ${asset.name}`),
].join("\n")
}\n`,
);
verifyReleaseDirectory(temporary, names);
return { directory: temporary, manifest: subset, cleanup: temporary };
} catch (error) {
rmSync(temporary, { recursive: true, force: true });
throw error;
}
}
export function copyReleaseSubset(
source: string,
destination: string,
names: readonly string[],
): ReleaseManifest {
const manifest = verifyReleaseDirectory(source, names);
mkdirSync(destination, { recursive: true, mode: 0o700 });
const assets = manifest.assets.filter((asset) => names.includes(asset.name));
for (const asset of assets) copyFileSync(join(source, asset.name), join(destination, asset.name));
const subset: ReleaseManifest = { ...manifest, assets };
writeFileSync(join(destination, "version.json"), `${JSON.stringify(subset, null, 2)}\n`);
writeFileSync(
join(destination, "checksums.txt"),
`${
[
`${digest(join(destination, "version.json"))} version.json`,
...assets.map((asset) => `${asset.sha256} ${asset.name}`),
].join("\n")
}\n`,
);
return subset;
}