feat: the public @ekklesia package release train, built, reproduced, and proven (ADR 0053)
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Failing after 16s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Failing after 16s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
Produces the exact public package tarballs of a platform release, validates them, and proves a standalone module consumes those artifacts end to end. Nothing is published and no credential is configured. - scripts/package-release.ts (pnpm packages:release) is the canonical release builder: from a clean commit it rebuilds the nine public packages from clean output, packs each, inspects every tarball, checks the release set and coordinated versions, and only then writes ekklesia-packages-<version>/ with the tarballs, SHA256SUMS, and a provider-neutral manifest (ekklesia-package-train.json: version, revision, and each package's file, size, SHA-256, npm integrity). - Packing uses a pack-only pnpm beforePacking hook (passed with --config.pnpmfile, never the workspace's install settings). pnpm inserts rewritten workspace: dependencies in async completion order, so two checkouts of one commit packed module-pack's package.json with keys in a different order; the hook restores the source key order (and the inspection enforces it). It also drops devDependencies, which named private packages outside the train. - scripts/test-package-release.sh (pnpm test:package-release, the CI package-release gate) builds the train twice, the second time from a separate worktree, requires byte-identical release directories, then runs the standalone consumer proof on the release: manifest checked, offline install from the tarballs only, lockfile integrity equal to the manifest's, frozen-lockfile reinstall, no Drizzle, one React, contracts only transitive, every @ekklesia package and module-pack at the release version, skipLibCheck: false, UI build, pack, and Core verification. - docs/development/releases.md documents the release order, the train, the artifacts, and the reproducibility findings; registry publication remains unimplemented. - ADR 0053 is Accepted: everything it decides that does not need a registry is now proven, and registry provisioning is on its own Deferred list. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7ea2d7883f
commit
9debe4e891
@@ -4,17 +4,25 @@ import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
import {
|
||||
artifactDigests,
|
||||
PACKAGE_TRAIN_MANIFEST,
|
||||
type PackageTrainManifest,
|
||||
PUBLIC_PACKAGE_DISTRIBUTION,
|
||||
PUBLIC_PACKAGES,
|
||||
packedManifestProblems,
|
||||
releaseDirectoryProblems,
|
||||
releaseSetProblems,
|
||||
serializeManifest,
|
||||
tarballFileProblems,
|
||||
} from "../scripts/pack-public-packages.js";
|
||||
tarballName,
|
||||
workspacePackages as releaseWorkspacePackages,
|
||||
} from "../scripts/package-release.js";
|
||||
|
||||
/**
|
||||
* The public module-development package boundary (ADR 0053): which packages may be published, what
|
||||
* their manifests may say, and what the author-facing SDK exposes. The tarballs themselves are
|
||||
* inspected by `pnpm packages:pack` and installed into a standalone module by
|
||||
* `scripts/test-standalone-module.sh`.
|
||||
* The public module-development package boundary and its release train (ADR 0053): which packages
|
||||
* may be published, what their manifests may say, what the author-facing SDK exposes, and what a
|
||||
* release directory must hold. The tarballs themselves are built and inspected by
|
||||
* `pnpm packages:release` (`scripts/package-release.ts`), and proven by `pnpm test:package-release`.
|
||||
*/
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -78,6 +86,41 @@ describe("the public package set", () => {
|
||||
expect(packages.get("@ekklesia/module-template")?.manifest.private).toBe(true);
|
||||
});
|
||||
|
||||
it("is what the release builder itself accepts, read without a YAML parser", async () => {
|
||||
const platformVersion = (await readJson("package.json")).version;
|
||||
const read = await releaseWorkspacePackages(repositoryRoot);
|
||||
expect(read.map((entry) => entry.name).sort()).toEqual(
|
||||
[...(await workspacePackages()).keys()].sort(),
|
||||
);
|
||||
expect(releaseSetProblems(read, platformVersion)).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a missing, mis-versioned, or private public package, and anything else publishable", async () => {
|
||||
const read = await releaseWorkspacePackages(repositoryRoot);
|
||||
const without = read.filter((entry) => entry.name !== "@ekklesia/ui");
|
||||
expect(releaseSetProblems(without, "0.1.2").join(" ")).toContain("@ekklesia/ui is missing");
|
||||
const changed = read.map((entry) =>
|
||||
entry.name === "@ekklesia/module-sdk"
|
||||
? { ...entry, manifest: { ...entry.manifest, private: true, version: "0.2.0" } }
|
||||
: entry.name === "@ekklesia/core"
|
||||
? { ...entry, manifest: { ...entry.manifest, private: undefined } }
|
||||
: entry.name === "@ekklesia/module-ui-host"
|
||||
? {
|
||||
...entry,
|
||||
manifest: {
|
||||
...entry.manifest,
|
||||
ekklesia: { distribution: PUBLIC_PACKAGE_DISTRIBUTION },
|
||||
},
|
||||
}
|
||||
: entry,
|
||||
);
|
||||
const problems = releaseSetProblems(changed, "0.1.2").join(" ");
|
||||
expect(problems).toContain("@ekklesia/module-sdk is version 0.2.0");
|
||||
expect(problems).toContain("@ekklesia/module-sdk is private");
|
||||
expect(problems).toContain("@ekklesia/core is publishable but not in the release set");
|
||||
expect(problems).toContain("@ekklesia/module-ui-host carries the distribution marker");
|
||||
});
|
||||
|
||||
it("uses one coordinated version, and depends only on other public packages through the workspace", async () => {
|
||||
const platformVersion = (await readJson("package.json")).version;
|
||||
const publicNames = new Set(PUBLIC_PACKAGES.map((entry) => entry.name));
|
||||
@@ -208,10 +251,11 @@ describe("module authors' dependencies", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("pin module-pack exactly and declare engines.node in the standalone fixture", async () => {
|
||||
it("pin module-pack exactly, at this release, and declare engines.node in the standalone fixture", async () => {
|
||||
const fixture = await readJson("tests/fixtures/standalone-module/package.json");
|
||||
const devDependencies = fixture.devDependencies as Record<string, string>;
|
||||
expect(devDependencies["@ekklesia/module-pack"]).toMatch(/^\d+\.\d+\.\d+$/u);
|
||||
// Part of setting a release version: the fixture consumes exactly this release's module-pack.
|
||||
expect(devDependencies["@ekklesia/module-pack"]).toBe((await readJson("package.json")).version);
|
||||
expect((fixture.engines as Record<string, string>).node).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -231,7 +275,6 @@ describe("@ekklesia/typescript-config", () => {
|
||||
describe("the tarball inspection", () => {
|
||||
const good = {
|
||||
dependencies: { "@ekklesia/contracts": "0.1.2", zod: "4.4.3" },
|
||||
devDependencies: { "@ekklesia/core": "0.1.2" },
|
||||
ekklesia: { distribution: PUBLIC_PACKAGE_DISTRIBUTION, layer: "module-sdk" },
|
||||
files: ["dist"],
|
||||
license: "AGPL-3.0-or-later",
|
||||
@@ -255,6 +298,10 @@ describe("the tarball inspection", () => {
|
||||
{ ...good, dependencies: { "@ekklesia/contracts": "0.1.1" } },
|
||||
{ ...good, peerDependencies: { "@ekklesia/core": "0.1.2" } },
|
||||
{ ...good, version: "0.1.3" },
|
||||
// Development dependencies never ship: they name private packages outside the train.
|
||||
{ ...good, devDependencies: { "@ekklesia/core": "0.1.2" } },
|
||||
// A registry is the publication step's configuration, never part of an artifact.
|
||||
{ ...good, publishConfig: { registry: "https://registry.example/" } },
|
||||
];
|
||||
for (const manifest of cases) {
|
||||
expect(packedManifestProblems(manifest, expected), JSON.stringify(manifest)).not.toEqual([]);
|
||||
@@ -280,4 +327,111 @@ describe("the tarball inspection", () => {
|
||||
expect(tarballFileProblems(files, good, hasSource), files.join(" ")).not.toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a dependency map whose order differs from its source", () => {
|
||||
const source = { dependencies: { "@ekklesia/contracts": "workspace:*", zod: "4.4.3" } };
|
||||
expect(packedManifestProblems(good, expected, source)).toEqual([]);
|
||||
const reordered = { ...good, dependencies: { zod: "4.4.3", "@ekklesia/contracts": "0.1.2" } };
|
||||
expect(packedManifestProblems(reordered, expected, source)).toEqual([
|
||||
"orders dependencies differently from its source",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the pack hook", () => {
|
||||
it("drops devDependencies and restores the source's key order, changing nothing else", async () => {
|
||||
const { hooks } = await import("../scripts/lib/pack-hooks.cjs");
|
||||
const directory = path.join(repositoryRoot, "packages", "module-pack");
|
||||
const source = await readJson("packages/module-pack/package.json");
|
||||
const dependencies = Object.fromEntries(
|
||||
Object.keys(source.dependencies ?? {})
|
||||
.reverse()
|
||||
.map((name) => [name, name.startsWith("@ekklesia/") ? "0.1.2" : "1.0.0"]),
|
||||
);
|
||||
const packed = hooks.beforePacking(
|
||||
{ ...source, dependencies, devDependencies: { "@ekklesia/core": "0.1.2" } },
|
||||
directory,
|
||||
);
|
||||
expect(packed.devDependencies).toBeUndefined();
|
||||
expect(Object.keys(packed.dependencies as object)).toEqual(
|
||||
Object.keys(source.dependencies ?? {}),
|
||||
);
|
||||
expect(packed.dependencies).toEqual(dependencies);
|
||||
expect(packed.name).toBe(source.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the release directory", () => {
|
||||
const version = "0.1.2";
|
||||
function release(mutate?: (files: Map<string, Uint8Array>) => void) {
|
||||
const files = new Map<string, Uint8Array>();
|
||||
for (const entry of PUBLIC_PACKAGES) {
|
||||
files.set(tarballName(entry.name, version), Buffer.from(`tarball of ${entry.name}`));
|
||||
}
|
||||
const manifest: PackageTrainManifest = {
|
||||
packageTrain: 1,
|
||||
packages: PUBLIC_PACKAGES.map((entry) => {
|
||||
const file = tarballName(entry.name, version);
|
||||
const data = files.get(file) ?? new Uint8Array();
|
||||
return {
|
||||
file,
|
||||
name: entry.name,
|
||||
role: entry.role,
|
||||
size: data.length,
|
||||
version,
|
||||
...artifactDigests(data),
|
||||
};
|
||||
}),
|
||||
source: { modified: false, revision: "86ae907f59458508e3277666cdd214f5bd7c1078" },
|
||||
version,
|
||||
};
|
||||
mutate?.(files);
|
||||
files.set(PACKAGE_TRAIN_MANIFEST, Buffer.from(serializeManifest(manifest)));
|
||||
return { files, manifest };
|
||||
}
|
||||
|
||||
it("names each tarball as pnpm pack does", () => {
|
||||
expect(tarballName("@ekklesia/module-sdk", "0.1.2")).toBe("ekklesia-module-sdk-0.1.2.tgz");
|
||||
});
|
||||
|
||||
it("records npm-compatible integrity and SHA-256 for each artifact", () => {
|
||||
const digests = artifactDigests(Buffer.from("abc"));
|
||||
expect(digests.sha256).toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
expect(digests.integrity).toMatch(/^sha512-[A-Za-z0-9+/]{86}==$/u);
|
||||
});
|
||||
|
||||
it("accepts exactly the expected train, matching its manifest", () => {
|
||||
const { files, manifest } = release();
|
||||
expect(releaseDirectoryProblems(manifest, files)).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a missing, extra, or altered artifact", () => {
|
||||
const missing = release((files) => files.delete(tarballName("@ekklesia/ui", version)));
|
||||
expect(releaseDirectoryProblems(missing.manifest, missing.files).join(" ")).toContain(
|
||||
"ekklesia-ui-0.1.2.tgz is missing",
|
||||
);
|
||||
const extra = release((files) =>
|
||||
files.set("ekklesia-module-ui-host-0.1.2.tgz", Buffer.from("x")),
|
||||
);
|
||||
expect(releaseDirectoryProblems(extra.manifest, extra.files).join(" ")).toContain(
|
||||
"ekklesia-module-ui-host-0.1.2.tgz is not part of the release",
|
||||
);
|
||||
const altered = release((files) =>
|
||||
files.set(tarballName("@ekklesia/module-pack", version), Buffer.from("changed")),
|
||||
);
|
||||
expect(releaseDirectoryProblems(altered.manifest, altered.files).join(" ")).toContain(
|
||||
"does not match its size, SHA-256, or integrity",
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes the manifest the same way every time, packages sorted by name", () => {
|
||||
const { manifest } = release();
|
||||
const reversed = { ...manifest, packages: [...manifest.packages].reverse() };
|
||||
expect(serializeManifest(reversed)).toBe(serializeManifest(manifest));
|
||||
const parsed = JSON.parse(serializeManifest(manifest)) as PackageTrainManifest;
|
||||
expect(parsed.packages.map((entry) => entry.name)).toEqual(
|
||||
[...PUBLIC_PACKAGES.map((entry) => entry.name)].sort(),
|
||||
);
|
||||
expect(Object.keys(parsed)).toEqual(["packageTrain", "version", "source", "packages"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user