Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
391 lines
12 KiB
JavaScript
391 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { getPublicWorkspacePackages } from "./release-packages.mjs";
|
|
|
|
const RELEASES_PREFIX = "releases/v1";
|
|
const INSTALLER_PREFIX = "installer/v1";
|
|
const INSTALLER_PACKAGE_NAME = "@earendil-works/pi-coding-agent-install";
|
|
const REGISTRY_URL = "https://registry.npmjs.org";
|
|
const RETRY_DELAY_MS = 5000;
|
|
const RETRY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
const MAX_POINTER_UPDATE_ATTEMPTS = 5;
|
|
const STABLE_SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
|
|
function parseArgs(args) {
|
|
const options = {
|
|
bucket: undefined,
|
|
endpoint: undefined,
|
|
installerPackageJson: undefined,
|
|
installerPackageLock: undefined,
|
|
sourceCommit: undefined,
|
|
version: undefined,
|
|
};
|
|
|
|
for (let index = 0; index < args.length; index++) {
|
|
const arg = args[index];
|
|
if (
|
|
arg !== "--bucket" &&
|
|
arg !== "--endpoint" &&
|
|
arg !== "--installer-package-json" &&
|
|
arg !== "--installer-package-lock" &&
|
|
arg !== "--source-commit" &&
|
|
arg !== "--version"
|
|
) {
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
const value = args[++index];
|
|
if (!value) throw new Error(`${arg} requires a value`);
|
|
options[
|
|
{
|
|
"--bucket": "bucket",
|
|
"--endpoint": "endpoint",
|
|
"--installer-package-json": "installerPackageJson",
|
|
"--installer-package-lock": "installerPackageLock",
|
|
"--source-commit": "sourceCommit",
|
|
"--version": "version",
|
|
}[arg]
|
|
] = value;
|
|
}
|
|
|
|
if (!options.bucket) throw new Error("--bucket is required");
|
|
if (!options.endpoint) throw new Error("--endpoint is required");
|
|
if (!options.version || !STABLE_SEMVER_RE.test(options.version)) {
|
|
throw new Error("--version must be a stable semver version");
|
|
}
|
|
if (!options.installerPackageJson || !options.installerPackageLock) {
|
|
throw new Error("--installer-package-json and --installer-package-lock are required");
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function sleep(milliseconds) {
|
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
}
|
|
|
|
async function resolvePackageFromNpm(pkg) {
|
|
const packageUrl = `${REGISTRY_URL}/${encodeURIComponent(pkg.name)}/${pkg.version}`;
|
|
const response = await fetch(packageUrl, { headers: { accept: "application/json" } });
|
|
if (!response.ok) {
|
|
throw new Error(`${pkg.name}@${pkg.version}: npm registry returned ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (
|
|
typeof data !== "object" ||
|
|
data === null ||
|
|
Array.isArray(data) ||
|
|
data.version !== pkg.version ||
|
|
typeof data.dist !== "object" ||
|
|
data.dist === null ||
|
|
Array.isArray(data.dist) ||
|
|
typeof data.dist.tarball !== "string" ||
|
|
typeof data.dist.integrity !== "string"
|
|
) {
|
|
throw new Error(`${pkg.name}@${pkg.version}: npm registry returned invalid metadata`);
|
|
}
|
|
|
|
const tarball = await fetch(data.dist.tarball, { method: "HEAD" });
|
|
if (!tarball.ok) {
|
|
throw new Error(`${pkg.name}@${pkg.version}: tarball returned ${tarball.status}`);
|
|
}
|
|
|
|
return {
|
|
name: pkg.name,
|
|
version: pkg.version,
|
|
tarball: data.dist.tarball,
|
|
integrity: data.dist.integrity,
|
|
};
|
|
}
|
|
|
|
async function verifyPackagesAreAvailable(packages) {
|
|
const deadline = Date.now() + RETRY_TIMEOUT_MS;
|
|
let attempt = 0;
|
|
let failures = [];
|
|
|
|
do {
|
|
attempt++;
|
|
const results = await Promise.allSettled(packages.map(resolvePackageFromNpm));
|
|
failures = results.flatMap((result, index) =>
|
|
result.status === "rejected" ? [`${packages[index].name}: ${result.reason}`] : [],
|
|
);
|
|
if (failures.length === 0) {
|
|
console.log(`All ${packages.length} Pi packages are available from npm (attempt ${attempt}).`);
|
|
return results.map((result) => result.value);
|
|
}
|
|
|
|
console.log(`Waiting for ${failures.length} Pi package${failures.length === 1 ? "" : "s"} on npm (attempt ${attempt}):`);
|
|
for (const failure of failures) console.log(` ${failure}`);
|
|
if (Date.now() < deadline) await sleep(RETRY_DELAY_MS);
|
|
} while (Date.now() < deadline);
|
|
|
|
throw new Error(`Timed out waiting for Pi packages to become available from npm:\n${failures.map((failure) => ` ${failure}`).join("\n")}`);
|
|
}
|
|
|
|
function gitSourceCommit() {
|
|
return execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
}
|
|
|
|
function runAws(args, { allowNotFound = false, allowPreconditionFailure = false } = {}) {
|
|
const result = spawnSync("aws", args, {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION || "auto",
|
|
AWS_EC2_METADATA_DISABLED: "true",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
if (result.error) throw result.error;
|
|
if (result.status === 0) return result.stdout;
|
|
|
|
const message = `${result.stdout}\n${result.stderr}`.trim();
|
|
if (allowNotFound && /(?:404|NoSuchKey|Not Found)/i.test(message)) return undefined;
|
|
if (allowPreconditionFailure && /(?:412|PreconditionFailed|ConditionalRequestConflict)/i.test(message)) {
|
|
return undefined;
|
|
}
|
|
throw new Error(`aws ${args.slice(0, 2).join(" ")} failed:\n${message}`);
|
|
}
|
|
|
|
function readLatestRelease(bucket, endpoint, key, outputPath) {
|
|
const head = runAws(
|
|
[
|
|
"s3api",
|
|
"head-object",
|
|
"--bucket",
|
|
bucket,
|
|
"--key",
|
|
key,
|
|
"--endpoint-url",
|
|
endpoint,
|
|
],
|
|
{ allowNotFound: true },
|
|
);
|
|
if (head === undefined) return undefined;
|
|
|
|
const metadata = JSON.parse(head);
|
|
if (typeof metadata.ETag !== "string") {
|
|
throw new Error("Latest Pi release marker has no ETag.");
|
|
}
|
|
runAws([
|
|
"s3api",
|
|
"get-object",
|
|
"--bucket",
|
|
bucket,
|
|
"--key",
|
|
key,
|
|
"--endpoint-url",
|
|
endpoint,
|
|
outputPath,
|
|
]);
|
|
const release = JSON.parse(readFileSync(outputPath, "utf8"));
|
|
if (
|
|
typeof release !== "object" ||
|
|
release === null ||
|
|
Array.isArray(release) ||
|
|
typeof release.version !== "string" ||
|
|
!STABLE_SEMVER_RE.test(release.version)
|
|
) {
|
|
throw new Error("Latest Pi release marker has an invalid version.");
|
|
}
|
|
return { etag: metadata.ETag, version: release.version };
|
|
}
|
|
|
|
function putObject(bucket, endpoint, path, key, cacheControl, condition) {
|
|
const args = [
|
|
"s3api",
|
|
"put-object",
|
|
"--bucket",
|
|
bucket,
|
|
"--key",
|
|
key,
|
|
"--body",
|
|
path,
|
|
"--endpoint-url",
|
|
endpoint,
|
|
"--content-type",
|
|
"application/json; charset=utf-8",
|
|
"--cache-control",
|
|
cacheControl,
|
|
];
|
|
if (condition?.etag) args.push("--if-match", condition.etag);
|
|
if (condition?.missing) args.push("--if-none-match", "*");
|
|
return runAws(args, { allowPreconditionFailure: Boolean(condition) }) !== undefined;
|
|
}
|
|
|
|
function putJson(bucket, endpoint, path, key, cacheControl, condition) {
|
|
return putObject(bucket, endpoint, path, key, cacheControl, condition);
|
|
}
|
|
|
|
function validateInstallerArtifacts(packageJsonPath, packageLockPath, version) {
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
const packageLock = JSON.parse(readFileSync(packageLockPath, "utf8"));
|
|
const root = packageLock.packages?.[""];
|
|
|
|
if (packageJson.name !== INSTALLER_PACKAGE_NAME || packageJson.version !== version) {
|
|
throw new Error(`Installer package.json must describe ${INSTALLER_PACKAGE_NAME}@${version}`);
|
|
}
|
|
if (
|
|
packageLock.lockfileVersion !== 3 ||
|
|
packageLock.version !== version ||
|
|
root?.version !== version ||
|
|
root.dependencies?.["@earendil-works/pi-coding-agent"] !== version
|
|
) {
|
|
throw new Error(`Installer package-lock.json must describe Pi ${version}`);
|
|
}
|
|
}
|
|
|
|
export function compareReleaseVersions(left, right) {
|
|
const leftMatch = STABLE_SEMVER_RE.exec(left);
|
|
const rightMatch = STABLE_SEMVER_RE.exec(right);
|
|
if (!leftMatch || !rightMatch) throw new Error("Release versions must be stable semver versions.");
|
|
|
|
for (const index of [1, 2, 3]) {
|
|
const difference = Number(leftMatch[index]) - Number(rightMatch[index]);
|
|
if (difference !== 0) return difference;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
export async function advanceLatestRelease(version, readLatest, writeLatest) {
|
|
for (let attempt = 0; attempt < MAX_POINTER_UPDATE_ATTEMPTS; attempt++) {
|
|
const current = await readLatest();
|
|
if (current && compareReleaseVersions(version, current.version) <= 0) {
|
|
return { advanced: false, version: current.version };
|
|
}
|
|
|
|
const updated = await writeLatest(current ? { etag: current.etag } : { missing: true });
|
|
if (updated) {
|
|
return { advanced: true, version };
|
|
}
|
|
}
|
|
throw new Error(`Could not advance the Pi release marker to ${version} after ${MAX_POINTER_UPDATE_ATTEMPTS} attempts.`);
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const packages = getPublicWorkspacePackages();
|
|
for (const pkg of packages) {
|
|
if (pkg.version !== options.version) {
|
|
throw new Error(`${pkg.name} is ${pkg.version}; expected ${options.version}`);
|
|
}
|
|
}
|
|
|
|
const publishedPackages = await verifyPackagesAreAvailable(packages);
|
|
const release = {
|
|
schemaVersion: 1,
|
|
version: options.version,
|
|
sourceCommit: options.sourceCommit ?? gitSourceCommit(),
|
|
publishedAt: new Date().toISOString(),
|
|
packages: publishedPackages,
|
|
};
|
|
const temporaryDirectory = mkdtempSync(join(tmpdir(), "pi-release-announcement-"));
|
|
try {
|
|
const releasePath = join(temporaryDirectory, "release.json");
|
|
const latestPath = join(temporaryDirectory, "latest.json");
|
|
writeFileSync(releasePath, `${JSON.stringify(release, null, "\t")}\n`);
|
|
if (
|
|
!putJson(
|
|
options.bucket,
|
|
options.endpoint,
|
|
releasePath,
|
|
`${RELEASES_PREFIX}/releases/${options.version}.json`,
|
|
"public, max-age=31536000, immutable",
|
|
{ missing: true },
|
|
)
|
|
) {
|
|
console.log(`Release record ${options.version} already exists.`);
|
|
}
|
|
|
|
writeFileSync(latestPath, `${JSON.stringify(release, null, "\t")}\n`);
|
|
validateInstallerArtifacts(options.installerPackageJson, options.installerPackageLock, options.version);
|
|
const installerReleasePrefix = `${INSTALLER_PREFIX}/releases/${options.version}`;
|
|
putObject(
|
|
options.bucket,
|
|
options.endpoint,
|
|
options.installerPackageJson,
|
|
`${installerReleasePrefix}/package.json`,
|
|
"public, max-age=31536000, immutable",
|
|
{ missing: true },
|
|
);
|
|
putObject(
|
|
options.bucket,
|
|
options.endpoint,
|
|
options.installerPackageLock,
|
|
`${installerReleasePrefix}/package-lock.json`,
|
|
"public, max-age=31536000, immutable",
|
|
{ missing: true },
|
|
);
|
|
putJson(
|
|
options.bucket,
|
|
options.endpoint,
|
|
releasePath,
|
|
`${installerReleasePrefix}/metadata.json`,
|
|
"public, max-age=31536000, immutable",
|
|
{ missing: true },
|
|
);
|
|
const installerLatest = await advanceLatestRelease(
|
|
options.version,
|
|
() =>
|
|
readLatestRelease(
|
|
options.bucket,
|
|
options.endpoint,
|
|
`${INSTALLER_PREFIX}/latest.json`,
|
|
join(temporaryDirectory, "installer-latest-current.json"),
|
|
),
|
|
(condition) =>
|
|
putJson(
|
|
options.bucket,
|
|
options.endpoint,
|
|
latestPath,
|
|
`${INSTALLER_PREFIX}/latest.json`,
|
|
"no-store",
|
|
condition,
|
|
),
|
|
);
|
|
console.log(
|
|
installerLatest.advanced
|
|
? `Published installer artifacts for Pi ${options.version} through s3://${options.bucket}/${INSTALLER_PREFIX}/latest.json`
|
|
: `Pi ${installerLatest.version} is already the latest installer release.`,
|
|
);
|
|
|
|
const result = await advanceLatestRelease(
|
|
options.version,
|
|
() =>
|
|
readLatestRelease(
|
|
options.bucket,
|
|
options.endpoint,
|
|
`${RELEASES_PREFIX}/latest.json`,
|
|
join(temporaryDirectory, "latest-current.json"),
|
|
),
|
|
(condition) =>
|
|
putJson(
|
|
options.bucket,
|
|
options.endpoint,
|
|
latestPath,
|
|
`${RELEASES_PREFIX}/latest.json`,
|
|
"no-store",
|
|
condition,
|
|
),
|
|
);
|
|
console.log(
|
|
result.advanced
|
|
? `Announced Pi ${options.version} through s3://${options.bucket}/${RELEASES_PREFIX}/latest.json`
|
|
: `Pi ${result.version} is already the latest announced release.`,
|
|
);
|
|
} finally {
|
|
rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|