Squashed 'third_party/pi/' content from commit d981de122
git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
390
scripts/publish-release-announcement.mjs
Normal file
390
scripts/publish-release-announcement.mjs
Normal file
@@ -0,0 +1,390 @@
|
||||
#!/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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user