import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { gunzipSync } from "node:zlib"; import { artifactDigests, PACKAGE_TRAIN_FORMAT, PACKAGE_TRAIN_MANIFEST, type PackageTrainManifest, releaseDirectoryProblems, type TrainPackage, } from "./package-release.ts"; /** * Publishes one package release train (ADR 0053) to an npm-protocol registry: exactly the `.tgz` * files `pnpm packages:release` produced, as `ekklesia-package-train.json` records them, and nothing * else. It never builds, packs, or edits a package, and never deletes or unpublishes anything. * * EKKLESIA_PACKAGES_PUBLISH_TOKEN=… node scripts/publish-package-train.ts \ * --release --registry --version \ * [--revision ] [--manifest-sha256 ] [--record ] \ * [--staging-tag ] [--final-tag ] [--require-public-read] * * 1. The whole local train is validated before anything is sent: the manifest, every artifact's * name, size, SHA-256, integrity, and the package.json inside it. * 2. Packages go out in dependency order. For each: absent from the registry, it is published with * `npm publish ` under a staging dist-tag; present with the same bytes, it is accepted * as already published; present with other bytes, publication stops for good (the version is * burned). Every package is then read back: its metadata, and its tarball, which must be the * release artifact byte for byte. * 3. Only when all of them are verified does the final dist-tag (`latest`) move, and the train is * published once every tag agrees. A rerun converges: it publishes only what is missing and moves * only tags that are not yet right. * * The publish token is read from the environment only, and reaches npm through a temporary npm * configuration that refers to the variable, never the value; the configuration is removed after. */ const execFileAsync = promisify(execFile); export const PUBLISH_TOKEN_VARIABLE = "EKKLESIA_PACKAGES_PUBLISH_TOKEN"; export const DEFAULT_STAGING_TAG = "ekklesia-staging"; export const DEFAULT_FINAL_TAG = "latest"; export const PUBLICATION_RECORD_FORMAT = 1; const DEPENDENCY_FIELDS = Object.freeze([ "dependencies", "peerDependencies", "optionalDependencies", ] as const); export type PublicationErrorCode = | "INVALID_OPTIONS" | "INVALID_RELEASE" | "BURNED_VERSION" | "REGISTRY_UNAVAILABLE" | "PUBLISH_FAILED" | "VERIFICATION_FAILED" | "TAGS_NOT_CONVERGED"; export class PublicationError extends Error { readonly code: PublicationErrorCode; readonly problems: readonly string[]; constructor(code: PublicationErrorCode, message: string, problems: readonly string[] = []) { super(message); this.name = "PublicationError"; this.code = code; this.problems = problems; } } function isRecord(value: unknown): value is Readonly> { return typeof value === "object" && value !== null && !Array.isArray(value); } // --------------------------------------------------------------------------------------------- // The local train // --------------------------------------------------------------------------------------------- /** The `package/package.json` inside an npm tarball, read without unpacking anything to disk. */ export function tarballPackageJson(tarball: Uint8Array): Record { const data = gunzipSync(tarball); for (let offset = 0; offset + 512 <= data.length; ) { const header = data.subarray(offset, offset + 512); if (header.every((byte) => byte === 0)) break; const field = (start: number, length: number) => header .subarray(start, start + length) .toString("utf8") .replace(/\0.*$/su, ""); const name = field(0, 100); const prefix = header.subarray(257, 262).toString("latin1") === "ustar" ? field(345, 155) : ""; const size = Number.parseInt(field(124, 12).trim() || "0", 8); const type = field(156, 1); const fullName = prefix ? `${prefix}/${name}` : name; if ((type === "0" || type === "") && fullName === "package/package.json") { const body = data.subarray(offset + 512, offset + 512 + size); return JSON.parse(body.toString("utf8")) as Record; } offset += 512 + Math.ceil(size / 512) * 512; } throw new Error("The tarball holds no package/package.json."); } export interface LocalPackage { readonly entry: TrainPackage; /** The artifact's absolute path: the exact file validated against the release manifest. */ readonly path: string; readonly data: Buffer; readonly packageJson: Readonly>; } export interface LocalTrain { readonly manifest: PackageTrainManifest; /** SHA-256 of `ekklesia-package-train.json`'s exact bytes. */ readonly manifestSha256: string; readonly packages: readonly LocalPackage[]; } /** * Reads and validates a release directory completely, or throws `INVALID_RELEASE` with every * problem: nothing may be published from a train that is not whole and unchanged. */ export async function loadReleaseTrain( releaseDirectory: string, expected: { readonly version: string; readonly revision?: string }, ): Promise { // Resolved once, here: every artifact path from now on is absolute, so the file npm is handed // is the file validated below, whatever directory npm itself runs in. const directory = path.resolve(releaseDirectory); const files = new Map(); for (const file of await readdir(directory)) { files.set(file, await readFile(path.join(directory, file))); } const manifestBytes = files.get(PACKAGE_TRAIN_MANIFEST); if (manifestBytes === undefined) { throw new PublicationError("INVALID_RELEASE", `The release has no ${PACKAGE_TRAIN_MANIFEST}.`); } let manifest: PackageTrainManifest; try { manifest = JSON.parse(manifestBytes.toString("utf8")) as PackageTrainManifest; } catch { throw new PublicationError("INVALID_RELEASE", `${PACKAGE_TRAIN_MANIFEST} is not JSON.`); } const problems = trainProblems(manifest, files, expected); if (problems.length > 0) { throw new PublicationError("INVALID_RELEASE", "The release train failed validation.", problems); } return { manifest, manifestSha256: createHash("sha256").update(manifestBytes).digest("hex"), packages: manifest.packages.map((entry) => { const data = files.get(entry.file) as Buffer; return { data, entry, packageJson: tarballPackageJson(data), path: path.join(directory, entry.file), }; }), }; } /** Every reason a release directory's contents may not be published, or nothing. */ export function trainProblems( manifest: PackageTrainManifest, files: ReadonlyMap, expected: { readonly version: string; readonly revision?: string }, ): string[] { if ( !isRecord(manifest) || manifest.packageTrain !== PACKAGE_TRAIN_FORMAT || !Array.isArray(manifest.packages) || !isRecord(manifest.source) ) { return ["The release manifest is not a package train manifest."]; } const problems = releaseDirectoryProblems(manifest, files); if (manifest.source.modified !== false) { problems.push("The release was built from a modified source tree; it is never published."); } if (manifest.version !== expected.version) { problems.push( `The release is version ${String(manifest.version)}, not the requested ${expected.version}.`, ); } if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/u.test(String(manifest.source.revision))) { problems.push("The release names no full source revision."); } if (expected.revision !== undefined && manifest.source.revision !== expected.revision) { problems.push( `The release was built from ${String(manifest.source.revision)}, not the expected ${expected.revision}.`, ); } const sums = files.get("SHA256SUMS"); const expectedSums = [...manifest.packages] .sort((left, right) => (left.file < right.file ? -1 : 1)) .map((entry) => `${entry.sha256} ${entry.file}\n`) .join(""); if (sums === undefined || Buffer.from(sums).toString("utf8") !== expectedSums) { problems.push("SHA256SUMS does not match the release manifest."); } for (const entry of manifest.packages) { const data = files.get(entry.file); if (data === undefined) continue; try { const packageJson = tarballPackageJson(data); if (packageJson.name !== entry.name || packageJson.version !== entry.version) { problems.push( `${entry.file} holds ${String(packageJson.name)}@${String(packageJson.version)}, not ${entry.name}@${entry.version}.`, ); } } catch { problems.push(`${entry.file} is not a readable npm package tarball.`); } } return problems; } /** * The order packages are published in: every package after the train packages it depends on (in * `dependencies`, `peerDependencies`, or `optionalDependencies`), and otherwise by name, so the order * never depends on how a directory happens to list its files. */ export function publicationOrder< Package extends { readonly entry: { readonly name: string }; readonly packageJson: Readonly>; }, >(packages: readonly Package[]): Package[] { const byName = new Map(packages.map((entry) => [entry.entry.name, entry])); const dependsOn = new Map>(); for (const entry of packages) { const names = new Set(); for (const field of DEPENDENCY_FIELDS) { const dependencies = entry.packageJson[field]; if (!isRecord(dependencies)) continue; for (const name of Object.keys(dependencies)) { if (byName.has(name) && name !== entry.entry.name) names.add(name); } } dependsOn.set(entry.entry.name, names); } const order: Package[] = []; const done = new Set(); while (order.length < packages.length) { const ready = [...byName.keys()] .filter((name) => !done.has(name)) .filter((name) => [...(dependsOn.get(name) ?? [])].every((dependency) => done.has(dependency)), ) .sort(); const next = ready[0]; if (next === undefined) { throw new PublicationError( "INVALID_RELEASE", "The release's packages depend on each other in a cycle.", ); } done.add(next); order.push(byName.get(next) as Package); } return order; } // --------------------------------------------------------------------------------------------- // Comparing a registry copy with the release // --------------------------------------------------------------------------------------------- /** `bin` as npm publishes it: a string becomes `{ : path }`, and paths * lose a leading `./`. The tarball keeps the author's form; the registry metadata gets this one. */ export function normalizeBin(bin: unknown, packageName: string): Record { const entries: [string, string][] = typeof bin === "string" ? [[packageName.replace(/^@[^/]+\//u, ""), bin]] : isRecord(bin) ? Object.entries(bin).map(([name, target]) => [name, String(target)]) : []; return Object.fromEntries( entries .map(([name, target]): [string, string] => [name, target.replace(/^(?:\.\/)+/u, "")]) .sort(([left], [right]) => (left < right ? -1 : 1)), ); } function sortedRecord(value: unknown): Record { if (!isRecord(value)) return {}; return Object.fromEntries( Object.keys(value) .sort() .map((key) => [key, value[key]]), ); } /** Whether `url` is the registry itself or below it: same origin, same path prefix. */ export function isUnderRegistry(url: string, registry: string): boolean { try { const target = new URL(url); const base = new URL(registry); return target.origin === base.origin && target.pathname.startsWith(base.pathname); } catch { return false; } } /** * Every way a registry's copy of a package differs from the release artifact, or nothing: * the version metadata the registry serves, and the bytes it serves for it. */ export function registryCopyProblems( local: Pick, version: Readonly>, downloaded: Uint8Array, registry: string, ): string[] { const problems: string[] = []; const { entry, packageJson } = local; if (version.name !== entry.name || version.version !== entry.version) { problems.push(`the registry names ${String(version.name)}@${String(version.version)}`); } const dist = isRecord(version.dist) ? version.dist : {}; if (dist.integrity !== entry.integrity) { problems.push("the registry reports a different integrity"); } const sha1 = createHash("sha1").update(local.data).digest("hex"); if (dist.shasum !== undefined && dist.shasum !== sha1) { problems.push("the registry reports a different shasum"); } if (typeof dist.tarball !== "string" || !isUnderRegistry(dist.tarball, registry)) { problems.push("the registry's tarball URL is not on the configured registry"); } for (const field of DEPENDENCY_FIELDS) { if ( JSON.stringify(sortedRecord(version[field])) !== JSON.stringify(sortedRecord(packageJson[field])) ) { problems.push(`the registry's ${field} differ from the package's`); } } if ( JSON.stringify(normalizeBin(version.bin, entry.name)) !== JSON.stringify(normalizeBin(packageJson.bin, entry.name)) ) { problems.push("the registry's bin differs from the package's"); } const digests = artifactDigests(downloaded); if ( !Buffer.from(downloaded).equals(local.data) || digests.sha256 !== entry.sha256 || digests.integrity !== entry.integrity ) { problems.push("the registry serves different bytes from the release artifact"); } return problems; } /** SemVer 2.0.0 precedence: negative, zero, or positive. Build metadata is ignored. */ export function compareSemver(left: string, right: string): number { const parse = (version: string) => { const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(version); if (match === null) throw new Error(`Not a semantic version: ${version}`); return { core: [Number(match[1]), Number(match[2]), Number(match[3])], pre: match[4] === undefined ? [] : match[4].split("."), }; }; const a = parse(left); const b = parse(right); for (let index = 0; index < 3; index += 1) { const difference = (a.core[index] ?? 0) - (b.core[index] ?? 0); if (difference !== 0) return Math.sign(difference); } if (a.pre.length === 0 || b.pre.length === 0) return Math.sign(b.pre.length - a.pre.length); for (let index = 0; index < Math.max(a.pre.length, b.pre.length); index += 1) { const x = a.pre[index]; const y = b.pre[index]; if (x === undefined || y === undefined) return x === undefined ? -1 : 1; if (x === y) continue; const xNumber = /^\d+$/u.test(x); const yNumber = /^\d+$/u.test(y); if (xNumber && yNumber) return Math.sign(Number(x) - Number(y)); if (xNumber !== yNumber) return xNumber ? -1 : 1; return x < y ? -1 : 1; } return 0; } // --------------------------------------------------------------------------------------------- // The registry: read over HTTP, written with npm // --------------------------------------------------------------------------------------------- /** The registry URL, validated, with exactly one trailing slash. */ export function normalizeRegistry(registry: string, allowHttp = false): string { let url: URL; try { url = new URL(registry); } catch { throw new PublicationError("INVALID_OPTIONS", "The registry is not a URL."); } if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") { throw new PublicationError( "INVALID_OPTIONS", "The registry URL may hold no credentials, query, or fragment.", ); } if (url.protocol !== "https:" && !(allowHttp && url.protocol === "http:")) { throw new PublicationError("INVALID_OPTIONS", "The registry must be reached over HTTPS."); } url.pathname = url.pathname.endsWith("/") ? url.pathname : `${url.pathname}/`; return url.toString(); } /** The npm configuration a publish uses: the scope and registry, and the token by reference. */ export function npmConfiguration(registry: string): string { const url = new URL(registry); return [ `registry=${registry}`, `@ekklesia:registry=${registry}`, `//${url.host}${url.pathname}:_authToken=\${${PUBLISH_TOKEN_VARIABLE}}`, "", ].join("\n"); } /** `@ekklesia/module-sdk` → `@ekklesia%2fmodule-sdk`, the npm packument path. */ export function packumentUrl(registry: string, name: string): string { return `${registry}${name.replace("/", "%2f")}`; } export interface RegistryReader { /** The package's packument, or `undefined` when the registry has no such package. */ packument(name: string): Promise | undefined>; /** A tarball's bytes; authenticated only when the URL is on the registry itself. */ download(url: string, anonymous?: boolean): Promise; } export function registryReader( registry: string, token: string | undefined, fetchImplementation: typeof fetch = fetch, ): RegistryReader { const headers = (url: string, anonymous: boolean): Record => !anonymous && token && isUnderRegistry(url, registry) ? { authorization: `Bearer ${token}` } : {}; const get = async (url: string, accept: string, anonymous: boolean) => { try { return await fetchImplementation(url, { headers: { accept, ...headers(url, anonymous) }, redirect: "error", }); } catch { throw new PublicationError("REGISTRY_UNAVAILABLE", "The registry could not be reached."); } }; return { async download(url, anonymous = false) { const response = await get(url, "application/octet-stream", anonymous); if (!response.ok) { throw new PublicationError( "REGISTRY_UNAVAILABLE", `The registry answered ${response.status} for a package tarball.`, ); } return Buffer.from(await response.arrayBuffer()); }, async packument(name) { const response = await get(packumentUrl(registry, name), "application/json", false); if (response.status === 404) return undefined; if (!response.ok) { throw new PublicationError( "REGISTRY_UNAVAILABLE", `The registry answered ${response.status} for ${name}.`, ); } return (await response.json()) as Record; }, }; } function versionOf( packument: Record | undefined, version: string, ): Record | undefined { const versions = packument?.versions; const found = isRecord(versions) ? versions[version] : undefined; return isRecord(found) ? { ...found } : undefined; } function distTagsOf(packument: Record | undefined): Record { const tags = packument?.["dist-tags"]; return isRecord(tags) ? Object.fromEntries(Object.entries(tags).map(([tag, version]) => [tag, String(version)])) : {}; } interface NpmSession { publish(file: string, tag: string): Promise; addTag(name: string, version: string, tag: string): Promise; dispose(): Promise; } /** npm with only a temporary configuration: no user or global npmrc, no project directory. */ async function npmSession(registry: string, token: string, npm: string): Promise { const home = await mkdtemp(path.join(tmpdir(), "ekklesia-publish-")); const userconfig = path.join(home, "npmrc"); const globalconfig = path.join(home, "global-npmrc"); await writeFile(userconfig, npmConfiguration(registry), { mode: 0o600 }); await writeFile(globalconfig, "", { mode: 0o600 }); const environment = { [PUBLISH_TOKEN_VARIABLE]: token, HOME: home, PATH: process.env.PATH ?? "", npm_config_audit: "false", npm_config_cache: path.join(home, "cache"), npm_config_fund: "false", npm_config_globalconfig: globalconfig, npm_config_update_notifier: "false", npm_config_userconfig: userconfig, }; const run = async (args: readonly string[], what: string) => { try { await execFileAsync(npm, [...args], { cwd: home, env: environment, maxBuffer: 16 << 20 }); } catch (error) { const stderr = String((error as { stderr?: unknown }).stderr ?? ""); const summary = stderr .split("\n") .filter((line) => /^npm error (?:code|path|syscall|errno|enoent|\d{3}|[A-Z])/u.test(line)) .filter((line) => !line.includes("complete log")) .slice(0, 5) .join(" / ") .replaceAll(token, "***") .replaceAll(home, ""); throw new PublicationError( "PUBLISH_FAILED", `${what} failed${summary ? `: ${summary}` : "."}`, ); } }; return { addTag: (name, version, tag) => run(["dist-tag", "add", `${name}@${version}`, tag], `Moving ${tag} to ${name}@${version}`), dispose: () => rm(home, { force: true, recursive: true }), publish: async (file, tag) => { // npm runs in its own temporary home, so a relative path would name a file there, not the // release artifact. Only the artifact's absolute path is ever passed. if (!path.isAbsolute(file)) { throw new PublicationError( "PUBLISH_FAILED", `Refusing to publish ${file}: npm is only ever given an artifact's absolute path.`, ); } await run( // A file spec runs no lifecycle script and sends the file's own bytes; --ignore-scripts // says so explicitly. --access public is what a scoped package needs on a public registry. ["publish", file, "--tag", tag, "--access", "public", "--ignore-scripts"], `Publishing ${path.basename(file)} from ${file}`, ); }, }; } // --------------------------------------------------------------------------------------------- // Publishing a train // --------------------------------------------------------------------------------------------- export type PackageOutcome = "published" | "already-present"; export type TagOutcome = "moved" | "already-current" | "kept-newer"; export interface PublicationHooks { /** Called before each package is published (not for one already present). Tests use it to * interrupt a train part way. */ beforePublish?(name: string, index: number): void | Promise; /** Called before each final dist-tag is moved. */ beforeTag?(name: string, index: number): void | Promise; } export interface PublishOptions { readonly releaseDirectory: string; readonly registry: string; readonly version: string; readonly revision?: string; /** SHA-256 of the release manifest the package-release gate proved, which binds the artifact a * publish job received to that gate. */ readonly manifestSha256?: string; readonly token: string; readonly stagingTag?: string; readonly finalTag?: string; readonly requirePublicRead?: boolean; /** Only for a disposable test registry. */ readonly allowHttp?: boolean; readonly npm?: string; readonly fetch?: typeof fetch; readonly hooks?: PublicationHooks; readonly log?: (line: string) => void; readonly now?: () => Date; } export interface PublishedPackage { readonly name: string; readonly version: string; readonly file: string; readonly outcome: PackageOutcome; readonly artifact: { readonly sha256: string; readonly integrity: string; readonly size: number }; readonly registry: { readonly tarball: string; readonly sha256: string; readonly integrity: string; readonly publicRead: boolean; }; readonly finalTag: TagOutcome; readonly distTags: Readonly>; } /** Evidence that one train was published: not a package catalog, and never a secret. */ export interface PublicationRecord { readonly publicationRecord: typeof PUBLICATION_RECORD_FORMAT; readonly packageTrain: { readonly version: string; readonly revision: string; readonly manifestSha256: string; }; readonly registry: string; readonly finalTag: string; readonly completedAt: string; readonly packages: readonly PublishedPackage[]; } export function serializeRecord(record: PublicationRecord): string { return `${JSON.stringify(record, null, 2)}\n`; } const TOKEN_FORMAT = /^[\x21-\x7e]{8,}$/u; export async function publishPackageTrain(options: PublishOptions): Promise { const log = options.log ?? (() => {}); const stagingTag = options.stagingTag ?? DEFAULT_STAGING_TAG; const finalTag = options.finalTag ?? DEFAULT_FINAL_TAG; if (!TOKEN_FORMAT.test(options.token)) { throw new PublicationError( "INVALID_OPTIONS", `${PUBLISH_TOKEN_VARIABLE} must hold the publish token.`, ); } for (const tag of [stagingTag, finalTag]) { if (!/^[a-z][a-z0-9-]*$/u.test(tag)) { throw new PublicationError("INVALID_OPTIONS", `${tag} is not a usable dist-tag.`); } } if (stagingTag === finalTag) { throw new PublicationError("INVALID_OPTIONS", "The staging and final tags must differ."); } const registry = normalizeRegistry(options.registry, options.allowHttp); // Nothing is sent until the whole local train is valid. const train = await loadReleaseTrain(options.releaseDirectory, { version: options.version, ...(options.revision === undefined ? {} : { revision: options.revision }), }); if (options.manifestSha256 !== undefined && options.manifestSha256 !== train.manifestSha256) { throw new PublicationError( "INVALID_RELEASE", "The release manifest is not the one the package-release gate proved.", ); } const order = publicationOrder(train.packages); log( `Package train ${train.manifest.version} (${train.manifest.source.revision}) validated: ${order .map((entry) => entry.entry.name) .join(", ")}`, ); const reader = registryReader(registry, options.token, options.fetch); const npm = await npmSession(registry, options.token, options.npm ?? "npm"); try { const verified: { local: LocalPackage; outcome: PackageOutcome; tarball: string; publicRead: boolean; downloaded: { sha256: string; integrity: string }; }[] = []; for (const [index, local] of order.entries()) { const { name, version } = local.entry; let outcome: PackageOutcome = "already-present"; let existing = versionOf(await reader.packument(name), version); if (existing === undefined) { await options.hooks?.beforePublish?.(name, index); // The file npm reads must still be the validated artifact, byte for byte. const current = await readFile(local.path).catch(() => undefined); if (current === undefined || !current.equals(local.data)) { throw new PublicationError( "INVALID_RELEASE", `${local.path} is missing or no longer the artifact validated against the release manifest.`, ); } log(`${name}@${version}: publishing ${local.path} (sha256 ${local.entry.sha256})`); try { await npm.publish(local.path, stagingTag); outcome = "published"; } catch (error) { // Someone may have published it meanwhile; what is there now decides. if (versionOf(await reader.packument(name), version) === undefined) throw error; } existing = versionOf(await reader.packument(name), version); if (existing === undefined) { throw new PublicationError( "VERIFICATION_FAILED", `${name}@${version} is not on the registry after publishing it.`, ); } } const tarball = isRecord(existing.dist) ? String(existing.dist.tarball) : ""; if (!isUnderRegistry(tarball, registry)) { throw new PublicationError( "VERIFICATION_FAILED", `${name}@${version}'s tarball is not on the configured registry.`, ); } const downloaded = await reader.download(tarball); if (!downloaded.equals(local.data)) { throw new PublicationError( "BURNED_VERSION", `${name}@${version} is already on the registry with different bytes. It is never deleted or replaced: the version is burned, publication stops here, and the train needs a new version after an investigation.`, ); } const problems = registryCopyProblems(local, existing, downloaded, registry); if (problems.length > 0) { throw new PublicationError( "VERIFICATION_FAILED", `${name}@${version} does not match the release artifact.`, problems, ); } const publicRead = await reader .download(tarball, true) .then((anonymous) => anonymous.equals(local.data)) .catch(() => false); if (options.requirePublicRead === true && !publicRead) { throw new PublicationError( "VERIFICATION_FAILED", `${name}@${version} cannot be downloaded anonymously.`, ); } log(`${name}@${version}: ${outcome}, verified byte for byte`); verified.push({ downloaded: artifactDigests(downloaded), local, outcome, publicRead, tarball, }); } // Every package is there and verified: only now does the final tag move. const tagOutcomes = new Map(); for (const [index, { local }] of verified.entries()) { const { name, version } = local.entry; const current = distTagsOf(await reader.packument(name))[finalTag]; if (current === version) { tagOutcomes.set(name, "already-current"); } else if (current !== undefined && compareSemver(current, version) > 0) { tagOutcomes.set(name, "kept-newer"); } else { await options.hooks?.beforeTag?.(name, index); await npm.addTag(name, version, finalTag); tagOutcomes.set(name, "moved"); } log(`${name}: ${finalTag} ${tagOutcomes.get(name)}`); } const packages: PublishedPackage[] = []; const unconverged: string[] = []; for (const { downloaded, local, outcome, publicRead, tarball } of verified) { const { name, version } = local.entry; const distTags = distTagsOf(await reader.packument(name)); const finalVersion = distTags[finalTag]; const converged = finalVersion === version || (tagOutcomes.get(name) === "kept-newer" && finalVersion !== undefined && compareSemver(finalVersion, version) > 0); if (!converged) unconverged.push(`${name}: ${finalTag} is ${finalVersion ?? "unset"}`); packages.push({ artifact: { integrity: local.entry.integrity, sha256: local.entry.sha256, size: local.entry.size, }, distTags: Object.fromEntries(Object.entries(distTags).sort()), file: local.entry.file, finalTag: tagOutcomes.get(name) ?? "moved", name, outcome, registry: { ...downloaded, publicRead, tarball }, version, }); } if (unconverged.length > 0) { throw new PublicationError( "TAGS_NOT_CONVERGED", "The final dist-tags did not converge.", unconverged, ); } return { completedAt: (options.now ?? (() => new Date()))().toISOString(), finalTag, packageTrain: { manifestSha256: train.manifestSha256, revision: train.manifest.source.revision, version: train.manifest.version, }, packages, publicationRecord: PUBLICATION_RECORD_FORMAT, registry, }; } finally { await npm.dispose(); } } // --------------------------------------------------------------------------------------------- // Command line // --------------------------------------------------------------------------------------------- const VALUE_OPTIONS = Object.freeze({ "--final-tag": "finalTag", "--manifest-sha256": "manifestSha256", "--record": "record", "--registry": "registry", "--release": "release", "--revision": "revision", "--staging-tag": "stagingTag", "--version": "version", } as const); const FLAG_OPTIONS = Object.freeze({ "--allow-http": "allowHttp", "--require-public-read": "requirePublicRead", } as const); const USAGE = `Usage: ${PUBLISH_TOKEN_VARIABLE}=… node scripts/publish-package-train.ts --release --registry --version [--revision ] [--manifest-sha256 ] [--record ] [--staging-tag ] [--final-tag ] [--require-public-read]`; export function parsePublishArguments(argv: readonly string[]): { values: Partial>; flags: Partial>; } { const values: Partial> = {}; const flags: Partial> = {}; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index] ?? ""; if (Object.hasOwn(FLAG_OPTIONS, argument)) { flags[FLAG_OPTIONS[argument as keyof typeof FLAG_OPTIONS]] = true; continue; } const key = Object.hasOwn(VALUE_OPTIONS, argument) ? VALUE_OPTIONS[argument as keyof typeof VALUE_OPTIONS] : undefined; const value = argv[index + 1]; if (key === undefined || value === undefined || value.startsWith("--") || key in values) { throw new PublicationError("INVALID_OPTIONS", USAGE); } values[key] = value; index += 1; } if (!values.release || !values.registry || !values.version) { throw new PublicationError("INVALID_OPTIONS", USAGE); } return { flags, values }; } async function main(argv: readonly string[]): Promise { try { const { flags, values } = parsePublishArguments(argv); const record = await publishPackageTrain({ allowHttp: flags.allowHttp === true, log: (line) => process.stdout.write(`${line}\n`), registry: values.registry ?? "", releaseDirectory: values.release ?? "", requirePublicRead: flags.requirePublicRead === true, token: process.env[PUBLISH_TOKEN_VARIABLE] ?? "", version: values.version ?? "", ...(values.revision === undefined ? {} : { revision: values.revision }), ...(values.manifestSha256 === undefined ? {} : { manifestSha256: values.manifestSha256 }), ...(values.stagingTag === undefined ? {} : { stagingTag: values.stagingTag }), ...(values.finalTag === undefined ? {} : { finalTag: values.finalTag }), }); if (values.record !== undefined) await writeFile(values.record, serializeRecord(record)); process.stdout.write( `Published package train ${record.packageTrain.version}: ${record.packages.length} packages verified, ${record.finalTag} converged.\n`, ); return 0; } catch (error) { if (error instanceof PublicationError) { process.stderr.write(`${error.message}\n`); for (const problem of error.problems) process.stderr.write(` ${problem}\n`); if (error.code === "INVALID_OPTIONS") return 64; return error.code === "BURNED_VERSION" ? 3 : 1; } process.stderr.write("Publishing the package train failed unexpectedly.\n"); return 1; } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { process.exitCode = await main(process.argv.slice(2)); }