import { chmod, cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { gzipSync } from "node:zlib"; import { afterEach, describe, expect, it } from "vitest"; import { parse } from "yaml"; import { artifactDigests, PACKAGE_TRAIN_MANIFEST, type PackageTrainManifest, PUBLIC_PACKAGES, serializeManifest, tarballName, } from "../scripts/package-release.js"; import { compareSemver, isUnderRegistry, loadReleaseTrain, normalizeBin, normalizeRegistry, npmConfiguration, PUBLISH_TOKEN_VARIABLE, PublicationError, packumentUrl, parsePublishArguments, publicationOrder, publishPackageTrain, registryCopyProblems, registryReader, tarballPackageJson, trainProblems, } from "../scripts/publish-package-train.js"; /** * The package train publication tool's own rules (ADR 0053), without a registry: what it accepts * as a publishable train, the order it publishes in, how it compares a registry copy with the * release, and what it refuses before it ever reaches the network. The registry behaviour itself is * proven against a disposable Gitea by `scripts/test-package-publication.sh`. */ const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const VERSION = "0.1.2"; const REVISION = "86ae907f59458508e3277666cdd214f5bd7c1078"; const REGISTRY = "https://registry.example/api/packages/owner/npm/"; const scratch: string[] = []; afterEach(async () => { await Promise.all(scratch.splice(0).map((dir) => rm(dir, { force: true, recursive: true }))); }); /** A minimal npm tarball: gzip of a ustar archive holding the given files under `package/`. */ function tarball(files: Record): Buffer { const blocks: Buffer[] = []; for (const [name, contents] of Object.entries(files)) { const body = Buffer.from(contents); const header = Buffer.alloc(512); header.write(`package/${name}`, 0, "utf8"); header.write("0000644\0", 100); header.write("0000000\0", 108); header.write("0000000\0", 116); header.write(`${body.length.toString(8).padStart(11, "0")}\0`, 124); header.write("00000000000\0", 136); header.write(" ", 148); header.write("0", 156); header.write("ustar\0", 257); header.write("00", 263); let checksum = 0; for (const byte of header) checksum += byte; header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148); blocks.push(header, body, Buffer.alloc((512 - (body.length % 512)) % 512)); } blocks.push(Buffer.alloc(1024)); return gzipSync(Buffer.concat(blocks)); } const TRAIN_DEPENDENCIES: Record = { "@ekklesia/contracts": [], "@ekklesia/design-tokens": [], "@ekklesia/module-database": ["@ekklesia/contracts"], "@ekklesia/module-pack": ["@ekklesia/contracts", "@ekklesia/module-ui-build"], "@ekklesia/module-sdk": ["@ekklesia/contracts"], "@ekklesia/module-ui-build": ["@ekklesia/contracts"], "@ekklesia/module-ui-sdk": [], "@ekklesia/typescript-config": [], "@ekklesia/ui": ["@ekklesia/design-tokens"], }; function packageJsonFor(name: string): Record { return { dependencies: Object.fromEntries((TRAIN_DEPENDENCIES[name] ?? []).map((dep) => [dep, VERSION])), name, version: VERSION, ...(name === "@ekklesia/module-pack" ? { bin: { "ekklesia-pack-module": "./bin/ekklesia-pack-module.mjs" } } : {}), }; } async function releaseDirectory( mutate?: (files: Map, manifest: PackageTrainManifest) => PackageTrainManifest, ): Promise { const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-publication-test-")); scratch.push(directory); const files = new Map(); for (const entry of PUBLIC_PACKAGES) { files.set( tarballName(entry.name, VERSION), tarball({ "package.json": JSON.stringify(packageJsonFor(entry.name)), LICENSE: "AGPL\n" }), ); } let manifest: PackageTrainManifest = { packageTrain: 1, packages: PUBLIC_PACKAGES.map((entry) => { const file = tarballName(entry.name, VERSION); const data = files.get(file) as Buffer; return { file, name: entry.name, role: entry.role, size: data.length, version: VERSION, ...artifactDigests(data), }; }), source: { modified: false, revision: REVISION }, version: VERSION, }; files.set( "SHA256SUMS", Buffer.from( [...manifest.packages] .sort((left, right) => (left.file < right.file ? -1 : 1)) .map((entry) => `${entry.sha256} ${entry.file}\n`) .join(""), ), ); manifest = mutate ? mutate(files, manifest) : manifest; files.set(PACKAGE_TRAIN_MANIFEST, Buffer.from(serializeManifest(manifest))); for (const [file, data] of files) await writeFile(path.join(directory, file), data); return directory; } describe("the local train", () => { it("reads package.json from inside an npm tarball", () => { const data = tarball({ LICENSE: "x", "package.json": '{"name":"@ekklesia/x","version":"1.0.0"}', }); expect(tarballPackageJson(data)).toEqual({ name: "@ekklesia/x", version: "1.0.0" }); expect(() => tarballPackageJson(tarball({ LICENSE: "x" }))).toThrow(); }); it("accepts a whole, unchanged train and records its manifest's own hash", async () => { const directory = await releaseDirectory(); const train = await loadReleaseTrain(directory, { revision: REVISION, version: VERSION }); expect(train.packages).toHaveLength(9); expect(train.manifestSha256).toBe( artifactDigests(await readFile(path.join(directory, PACKAGE_TRAIN_MANIFEST))).sha256, ); }); it("refuses a modified build, another version or revision, and any changed, missing, or extra file", async () => { const cases: Record[0]> = { altered: (files, manifest) => { files.set(tarballName("@ekklesia/ui", VERSION), tarball({ "package.json": "{}" })); return manifest; }, extra: (files, manifest) => { files.set("ekklesia-core-0.1.2.tgz", Buffer.from("x")); return manifest; }, missing: (files, manifest) => { files.delete(tarballName("@ekklesia/module-sdk", VERSION)); return manifest; }, modified: (_files, manifest) => ({ ...manifest, source: { ...manifest.source, modified: true }, }), sums: (files, manifest) => { files.set("SHA256SUMS", Buffer.from("0 x\n")); return manifest; }, wrongInside: (files, manifest) => { // Bytes that match the manifest, but a package.json that names another package. const file = tarballName("@ekklesia/ui", VERSION); const data = tarball({ "package.json": JSON.stringify({ name: "@ekklesia/other", version: VERSION }), }); files.set(file, data); return { ...manifest, packages: manifest.packages.map((entry) => entry.file === file ? { ...entry, size: data.length, ...artifactDigests(data) } : entry, ), }; }, }; for (const [name, mutate] of Object.entries(cases)) { const directory = await releaseDirectory(mutate); await expect( loadReleaseTrain(directory, { revision: REVISION, version: VERSION }), name, ).rejects.toMatchObject({ code: "INVALID_RELEASE" }); } const valid = await releaseDirectory(); await expect(loadReleaseTrain(valid, { version: "0.1.3" })).rejects.toMatchObject({ code: "INVALID_RELEASE", }); await expect( loadReleaseTrain(valid, { revision: "f".repeat(40), version: VERSION }), ).rejects.toMatchObject({ code: "INVALID_RELEASE" }); }); it("explains every problem at once", () => { const problems = trainProblems( { packageTrain: 1, packages: [], source: { modified: true, revision: "abc" }, version: "9.9.9", }, new Map(), { version: VERSION }, ); expect(problems.join(" ")).toContain("modified source tree"); expect(problems.join(" ")).toContain("not the requested 0.1.2"); expect(problems.join(" ")).toContain("no full source revision"); expect(problems.join(" ")).toContain("is missing"); }); }); describe("the publication order", () => { const entries = (names: readonly string[]) => names.map((name) => ({ entry: { name }, packageJson: packageJsonFor(name) })); it("puts every package after the train packages it depends on, and is otherwise alphabetical", () => { const order = publicationOrder(entries(Object.keys(TRAIN_DEPENDENCIES))).map( (e) => e.entry.name, ); expect(order).toEqual([ "@ekklesia/contracts", "@ekklesia/design-tokens", "@ekklesia/module-database", "@ekklesia/module-sdk", "@ekklesia/module-ui-build", "@ekklesia/module-pack", "@ekklesia/module-ui-sdk", "@ekklesia/typescript-config", "@ekklesia/ui", ]); }); it("does not depend on the order the files are listed in", () => { const names = Object.keys(TRAIN_DEPENDENCIES); const forward = publicationOrder(entries(names)).map((e) => e.entry.name); const backward = publicationOrder(entries([...names].reverse())).map((e) => e.entry.name); expect(backward).toEqual(forward); }); it("follows the real packages' own dependencies", async () => { const real = await Promise.all( PUBLIC_PACKAGES.map(async (entry) => ({ entry: { name: entry.name }, packageJson: JSON.parse( await readFile(path.join(repositoryRoot, entry.directory, "package.json"), "utf8"), ) as Record, })), ); const order = publicationOrder(real).map((e) => e.entry.name); for (const { entry, packageJson } of real) { for (const dependency of Object.keys((packageJson.dependencies as object) ?? {})) { if (!dependency.startsWith("@ekklesia/")) continue; expect(order.indexOf(dependency), `${dependency} before ${entry.name}`).toBeLessThan( order.indexOf(entry.name), ); } } }); it("refuses a dependency cycle", () => { expect(() => publicationOrder([ { entry: { name: "a" }, packageJson: { dependencies: { b: "1" } } }, { entry: { name: "b" }, packageJson: { dependencies: { a: "1" } } }, ]), ).toThrow(PublicationError); }); }); describe("comparing a registry copy with the release", () => { const data = tarball({ "package.json": JSON.stringify(packageJsonFor("@ekklesia/module-pack")) }); const digests = artifactDigests(data); const local = { data, entry: { file: "ekklesia-module-pack-0.1.2.tgz", name: "@ekklesia/module-pack", role: "tooling" as const, size: data.length, version: VERSION, ...digests, }, packageJson: packageJsonFor("@ekklesia/module-pack"), }; const registryVersion = { // npm normalizes bin paths in the metadata it uploads; the tarball keeps the author's form. bin: { "ekklesia-pack-module": "bin/ekklesia-pack-module.mjs" }, dependencies: { "@ekklesia/module-ui-build": VERSION, "@ekklesia/contracts": VERSION }, dist: { integrity: digests.integrity, tarball: `${REGISTRY}%40ekklesia%2Fmodule-pack/-/0.1.2/module-pack-0.1.2.tgz`, }, name: "@ekklesia/module-pack", version: VERSION, }; it("accepts the same bytes and metadata, however npm normalized bin", () => { expect(registryCopyProblems(local, registryVersion, data, REGISTRY)).toEqual([]); expect(normalizeBin("./cli.js", "@ekklesia/tool")).toEqual({ tool: "cli.js" }); }); it("refuses other bytes, integrity, dependencies, bin, or a tarball elsewhere", () => { const other = tarball({ "package.json": "{}" }); expect(registryCopyProblems(local, registryVersion, other, REGISTRY).join(" ")).toContain( "different bytes", ); const cases = [ { ...registryVersion, dist: { ...registryVersion.dist, integrity: "sha512-AAAA" } }, { ...registryVersion, dependencies: { "@ekklesia/contracts": "0.1.1" } }, { ...registryVersion, bin: { other: "bin/other.mjs" } }, { ...registryVersion, dist: { ...registryVersion.dist, tarball: "https://elsewhere.example/x.tgz" }, }, { ...registryVersion, version: "0.1.3" }, ]; for (const version of cases) { expect( registryCopyProblems(local, version, data, REGISTRY), JSON.stringify(version), ).not.toEqual([]); } }); it("orders versions by SemVer precedence", () => { expect(compareSemver("0.1.10", "0.1.9")).toBe(1); expect(compareSemver("0.2.0", "0.10.0")).toBe(-1); expect(compareSemver("1.0.0-rc.1", "1.0.0")).toBe(-1); expect(compareSemver("1.0.0-rc.2", "1.0.0-rc.10")).toBe(-1); expect(compareSemver("1.0.0-alpha", "1.0.0-1")).toBe(1); expect(compareSemver("0.1.2", "0.1.2+build")).toBe(0); }); }); describe("the registry and its credentials", () => { it("accepts an HTTPS registry, adds the trailing slash, and refuses anything else", () => { expect(normalizeRegistry("https://registry.example/api/packages/o/npm")).toBe( "https://registry.example/api/packages/o/npm/", ); expect(() => normalizeRegistry("http://registry.example/npm/")).toThrow(PublicationError); expect(normalizeRegistry("http://gitea:3000/npm/", true)).toBe("http://gitea:3000/npm/"); expect(() => normalizeRegistry("https://user:secret@registry.example/npm/")).toThrow( PublicationError, ); }); it("refers to the token by its variable in npm's configuration, never by value", () => { const configuration = npmConfiguration(REGISTRY); expect(configuration).toContain( `//registry.example/api/packages/owner/npm/:_authToken=\${${PUBLISH_TOKEN_VARIABLE}}`, ); expect(configuration).toContain(`@ekklesia:registry=${REGISTRY}`); expect(packumentUrl(REGISTRY, "@ekklesia/module-sdk")).toBe( `${REGISTRY}@ekklesia%2fmodule-sdk`, ); }); it("sends the token only to the registry itself", async () => { const seen: Record = {}; const fake = (async (url: string, init?: RequestInit) => { seen[url] = ((init?.headers ?? {}) as Record).authorization; return new Response(new Uint8Array([1]), { status: 200 }); }) as typeof fetch; const reader = registryReader(REGISTRY, "token-value", fake); await reader.download(`${REGISTRY}x.tgz`); await reader.download("https://elsewhere.example/x.tgz"); await reader.download(`${REGISTRY}y.tgz`, true); expect(seen[`${REGISTRY}x.tgz`]).toBe("Bearer token-value"); expect(seen["https://elsewhere.example/x.tgz"]).toBeUndefined(); expect(seen[`${REGISTRY}y.tgz`]).toBeUndefined(); expect(isUnderRegistry("https://registry.example/api/packages/other/npm/x", REGISTRY)).toBe( false, ); }); it("refuses to start without a token, or with an invalid train, before any request", async () => { let requests = 0; const counting = (async () => { requests += 1; return new Response("{}"); }) as typeof fetch; const directory = await releaseDirectory(); await expect( publishPackageTrain({ fetch: counting, registry: REGISTRY, releaseDirectory: directory, token: "", version: VERSION, }), ).rejects.toMatchObject({ code: "INVALID_OPTIONS" }); const invalid = await releaseDirectory((_files, manifest) => ({ ...manifest, source: { ...manifest.source, modified: true }, })); await expect( publishPackageTrain({ fetch: counting, npm: "/nonexistent/npm", registry: REGISTRY, releaseDirectory: invalid, token: "publish-token-value", version: VERSION, }), ).rejects.toMatchObject({ code: "INVALID_RELEASE" }); const valid = await releaseDirectory(); await expect( publishPackageTrain({ fetch: counting, manifestSha256: "0".repeat(64), npm: "/nonexistent/npm", registry: REGISTRY, releaseDirectory: valid, token: "publish-token-value", version: VERSION, }), ).rejects.toMatchObject({ code: "INVALID_RELEASE" }); expect(requests).toBe(0); }); it("parses its command line, and takes no token from it", () => { expect( parsePublishArguments([ "--release", "r", "--registry", REGISTRY, "--version", VERSION, "--require-public-read", ]), ).toEqual({ flags: { requirePublicRead: true }, values: { registry: REGISTRY, release: "r", version: VERSION }, }); expect(() => parsePublishArguments(["--release", "r", "--token", "x"])).toThrow( PublicationError, ); expect(() => parsePublishArguments(["--release", "r"])).toThrow(PublicationError); }); }); describe("the artifact npm is given", () => { /** * The release where the Actions artifact puts it, `/package-train/`, named * relatively from the current directory, which is not the release directory: exactly how the * publish workflow called the tool when npm was handed a path relative to its own temporary home. */ async function nestedRelease(): Promise<{ relative: string; absolute: string }> { const workspace = await mkdtemp(path.join(tmpdir(), "ekklesia-workspace-")); scratch.push(workspace); const absolute = path.join(workspace, "package-train", `ekklesia-packages-${VERSION}`); await mkdir(path.dirname(absolute), { recursive: true }); await cp(await releaseDirectory(), absolute, { recursive: true }); const relative = path.relative(process.cwd(), absolute); expect(path.isAbsolute(relative)).toBe(false); expect(process.cwd()).not.toBe(absolute); return { absolute, relative }; } /** An npm that records what it was given, and where it ran, then fails as a registry would. */ async function recordingNpm(): Promise<{ npm: string; calls: () => Promise<{ argv: string[]; cwd: string; exists: boolean }[]>; }> { const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-fake-npm-")); scratch.push(directory); const log = path.join(directory, "calls.jsonl"); const npm = path.join(directory, "npm"); await writeFile( npm, `#!/usr/bin/env node const { appendFileSync, existsSync } = require("node:fs"); const argv = process.argv.slice(2); appendFileSync(${JSON.stringify(log)}, JSON.stringify({ argv, cwd: process.cwd(), exists: existsSync(argv[1] ?? "") }) + "\\n"); process.stderr.write("npm error code E503\\nnpm error 503 Service Unavailable\\n"); process.exit(1); `, ); await chmod(npm, 0o755); return { calls: async () => (await readFile(log, "utf8").catch(() => "")) .split("\n") .filter(Boolean) .map((line) => JSON.parse(line)), npm, }; } const notFound = (async () => new Response("{}", { status: 404 })) as typeof fetch; it("resolves a relative, nested release to absolute artifact paths when it loads the train", async () => { const { absolute, relative } = await nestedRelease(); const train = await loadReleaseTrain(relative, { revision: REVISION, version: VERSION }); for (const local of train.packages) { expect(local.path).toBe(path.join(absolute, local.entry.file)); expect(path.isAbsolute(local.path)).toBe(true); expect((await readFile(local.path)).equals(local.data)).toBe(true); } }); it("hands npm the exact validated tarball by its absolute path, from npm's own directory, and says which", async () => { const { absolute, relative } = await nestedRelease(); const { calls, npm } = await recordingNpm(); const error = await publishPackageTrain({ fetch: notFound, npm, registry: REGISTRY, releaseDirectory: relative, revision: REVISION, token: "publish-token-value", version: VERSION, }).catch((caught: unknown) => caught); expect(error).toMatchObject({ code: "PUBLISH_FAILED" }); const first = tarballName("@ekklesia/contracts", VERSION); const expected = path.join(absolute, first); // The failure names the exact file npm was given, and never the token. expect((error as Error).message).toContain(expected); expect((error as Error).message).toContain("E503"); expect((error as Error).message).not.toContain("publish-token-value"); const [call] = await calls(); expect(call?.argv.slice(0, 2)).toEqual(["publish", expected]); expect(call?.exists).toBe(true); // npm ran in a directory of its own, where a relative path would have named nothing. expect(call?.cwd).not.toBe(absolute); expect(path.isAbsolute(call?.cwd ?? "")).toBe(true); expect(JSON.stringify(call)).not.toContain("publish-token-value"); }); it("refuses to publish an artifact that changed after it was validated, before npm runs", async () => { const { absolute, relative } = await nestedRelease(); const { calls, npm } = await recordingNpm(); const file = path.join(absolute, tarballName("@ekklesia/contracts", VERSION)); await expect( publishPackageTrain({ fetch: notFound, hooks: { beforePublish: async (_name, index) => { if (index === 0) await writeFile(file, "changed after validation"); }, }, npm, registry: REGISTRY, releaseDirectory: relative, revision: REVISION, token: "publish-token-value", version: VERSION, }), ).rejects.toMatchObject({ code: "INVALID_RELEASE" }); expect(await calls()).toEqual([]); }); }); describe("what the publisher may do to a registry", () => { it("never deletes, unpublishes, or overwrites: it only publishes new versions and moves a tag", async () => { // Code only: the tool's comments may say what it never does. const source = ( await readFile(path.join(repositoryRoot, "scripts/publish-package-train.ts"), "utf8") ) .replace(/\/\*[\s\S]*?\*\//gu, "") .replace(/^\s*\/\/.*$/gmu, ""); expect(source).not.toMatch( /unpublish|deprecate|"DELETE"|method:\s*"(?:PUT|DELETE|POST)"|-rev\/|--force/u, ); const npmCommands = [...source.matchAll(/\["(publish|dist-tag)"/gu)].map((match) => match[1]); expect([...new Set(npmCommands)].sort()).toEqual(["dist-tag", "publish"]); expect(source).toMatch(/\["dist-tag", "add",/u); }); }); describe("when the real registry can be written to", () => { type Workflow = { on: Record; jobs: Record< string, { if?: string; needs?: string | string[]; steps: { run?: string; env?: Record; uses?: string }[]; } >; }; const workflow = async (file: string) => parse(await readFile(path.join(repositoryRoot, ".github/workflows", file), "utf8")) as Workflow; it("never from CI: no pull request or push job holds the publish secret or publishes", async () => { const ci = await readFile(path.join(repositoryRoot, ".github/workflows/ci.yml"), "utf8"); expect(ci).not.toContain("EKKLESIA_PACKAGES_PUBLISH_TOKEN"); expect(ci).not.toContain("secrets."); expect(ci).not.toContain("publish-package-train.ts"); // CI publishes only to the disposable registry its own job starts. expect(ci).toContain("bash scripts/test-package-publication.sh --release"); }); it("only by hand, on main, after this run's own gate, with the secret in the publish step alone", async () => { const publish = await workflow("publish-packages.yml"); expect(Object.keys(publish.on)).toEqual(["workflow_dispatch"]); for (const [name, job] of Object.entries(publish.jobs)) { expect(job.if, name).toBe("github.ref == 'refs/heads/main'"); } expect(publish.jobs.publish?.needs).toBe("package-release"); const steps = Object.entries(publish.jobs).flatMap(([job, { steps }]) => steps.map((step) => ({ job, step })), ); const withSecret = steps.filter(({ step }) => JSON.stringify(step).includes("secrets.")); expect(withSecret).toHaveLength(1); expect(withSecret[0]?.job).toBe("publish"); expect(withSecret[0]?.step.env?.EKKLESIA_PACKAGES_PUBLISH_TOKEN).toBe( "${{ secrets.EKKLESIA_PACKAGES_PUBLISH_TOKEN }}", ); const command = withSecret[0]?.step.run ?? ""; for (const option of ["--revision", "--manifest-sha256", "--require-public-read", "--record"]) { expect(command).toContain(option); } expect(command).not.toContain("--allow-http"); // The publish job never builds or packs: it installs nothing and runs no package script. const publishRuns = (publish.jobs.publish?.steps ?? []) .map((step) => step.run ?? "") .join("\n"); expect(publishRuns).not.toMatch(/pnpm|npm (?:install|ci|run|pack)|package-release\.ts/u); }); it("uses the artifact actions that work on this Gitea, pinned by commit, on visible paths", async () => { for (const file of ["ci.yml", "publish-packages.yml"]) { const text = await readFile(path.join(repositoryRoot, ".github/workflows", file), "utf8"); for (const use of text.matchAll(/uses: (actions\/(?:up|down)load-artifact)@(\S+)/gu)) { expect(use[2], `${file} ${use[1]}`).toMatch(/^[0-9a-f]{40}$/u); } // The artifact actions leave hidden files out: a path starting with a dot uploads nothing. for (const artifactPath of text.matchAll(/^\s+path: (\S+)$/gmu)) { expect(artifactPath[1], file).not.toMatch(/^\.|\/\./u); } } }); });