Files
Ekklesia/tests/package-publication.test.ts
T
ekklesiaandClaude Opus 5.5 1caffc5f26
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Successful in 34s
CI / image-build (pull_request) Successful in 24s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
CI / package-publication (pull_request) Successful in 49s
fix: hand npm the release artifact's absolute path, so publishing finds it
The first real 0.2.0 publish failed on its first package with ENOENT:
"Publishing ekklesia-contracts-0.2.0.tgz failed: npm error code ENOENT".

The publish workflow passes a relative release path,
--release "$(find package-train …)", which is
package-train/ekklesia-packages-0.2.0. The tool validated the artifacts
relative to the job's working directory, but ran npm in its own
temporary home (cwd /tmp/ekklesia-publish-XXXXXX) with the same relative
path, package-train/ekklesia-packages-0.2.0/ekklesia-contracts-0.2.0.tgz,
which there names nothing. The disposable-registry suite always passed
an absolute path, so it never saw this.

- loadReleaseTrain resolves the release directory once; every artifact
  path is absolute from validation on, and npm is given only that exact
  file's absolute path (anything else is refused at the npm boundary).
- Just before each upload the file is checked against the validated
  bytes once more; the tool logs the absolute path it publishes, and a
  failure names it with npm's path/syscall lines (npm's temporary home
  masked, the token never shown).
- Regression tests: unit tests with a recording npm (relative nested
  release from another cwd; npm's argv, cwd, and the file's existence; a
  changed artifact refused before npm runs), which fail on the previous
  tool; and a disposable-Gitea test that runs the CLI exactly as the
  workflow does, from the Actions artifact layout, round-tripping all
  nine packages byte for byte.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 17:51:22 -05:00

660 lines
25 KiB
TypeScript

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<string, string>): 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<string, string[]> = {
"@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<string, unknown> {
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<string, Buffer>, manifest: PackageTrainManifest) => PackageTrainManifest,
): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-publication-test-"));
scratch.push(directory);
const files = new Map<string, Buffer>();
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<string, Parameters<typeof releaseDirectory>[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<string, unknown>,
})),
);
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:[email protected]/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<string, string | undefined> = {};
const fake = (async (url: string, init?: RequestInit) => {
seen[url] = ((init?.headers ?? {}) as Record<string, string>).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, `<workspace>/package-train/<release>`, 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<string, unknown>;
jobs: Record<
string,
{
if?: string;
needs?: string | string[];
steps: { run?: string; env?: Record<string, string>; 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);
}
}
});
});