feat: Module Package v1 carries the module's own license (ADR 0051)
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m32s
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m32s
Every Module Package v1 now declares the module's license and carries its text. packageFormat stays 1; pre-ADR-0051 metadata fails closed. - Contracts: ekklesia.package.json requires an SPDX `license` expression, validated with spdx-expression-parse, with UNLICENSED and non-expressions refused. The `files` list must hold exactly one non-empty root LICENSE. The package contracts move to the @ekklesia/contracts/module-package subpath, so the SPDX parser and its data (spdx-exceptions and spdx-license-ids ship no licence files) never reach a module bundle through the SDK's re-export of the contracts root. - Packager: reads the source package.json's string SPDX declaration and never invents one. It requires an explicit --license-file (a non-empty regular file, no symlink, no parent search, no download) and copies its bytes unchanged to LICENSE, listed with size and SHA-256 and covered by the archive digest. THIRD_PARTY_NOTICES stays third-party only, and bundled Ekklesia code stays in `incorporates`. - Verifier: rejects missing or invalid license metadata, and a missing, unlisted, duplicated, or corrupted LICENSE, through the schema and the existing strict inventory. - Template: packaged as AGPL-3.0-or-later with the repository's canonical LICENSE, passed explicitly (Dockerfile, smoke, docs). Packing it twice gives byte-identical archives. Started by Codex; reviewed and completed in Claude Code (empty license-text refusal, schema non-empty LICENSE, symlink and empty-file tests). ADR 0051 is Accepted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
30f61094e6
commit
85fae9e07e
@@ -9,7 +9,7 @@ import { ModulePackError, type PackModuleOptions, packModule } from "./pack-modu
|
||||
* `ekklesia-pack-module` — the canonical Ekklesia module packager (ADR 0049).
|
||||
*
|
||||
* ekklesia-pack-module --module <built module directory> --target any --out <directory>
|
||||
* --source-revision <full commit id> [--repository <url>]
|
||||
* --license-file <file> --source-revision <full commit id> [--repository <url>]
|
||||
*
|
||||
* Writes `<id>-<version>-any.tar.gz` and its `.sha256` line to the output directory, and nothing
|
||||
* else anywhere. Run it in the module's CI after building the module.
|
||||
@@ -18,9 +18,10 @@ import { ModulePackError, type PackModuleOptions, packModule } from "./pack-modu
|
||||
class PackArgumentsError extends Error {}
|
||||
|
||||
const USAGE =
|
||||
"Usage: ekklesia-pack-module --module <directory> --target any --out <directory> --source-revision <commit> [--repository <url>]";
|
||||
"Usage: ekklesia-pack-module --module <directory> --target any --out <directory> --license-file <file> --source-revision <commit> [--repository <url>]";
|
||||
|
||||
const OPTIONS: Readonly<Record<string, keyof PackModuleOptions>> = Object.freeze({
|
||||
"--license-file": "licenseFile",
|
||||
"--module": "moduleDirectory",
|
||||
"--out": "outDirectory",
|
||||
"--repository": "repository",
|
||||
@@ -43,12 +44,19 @@ export function parsePackModuleArguments(argv: readonly string[]): PackModuleOpt
|
||||
}
|
||||
values.set(option, value);
|
||||
}
|
||||
const required = ["moduleDirectory", "outDirectory", "sourceRevision", "target"] as const;
|
||||
const required = [
|
||||
"licenseFile",
|
||||
"moduleDirectory",
|
||||
"outDirectory",
|
||||
"sourceRevision",
|
||||
"target",
|
||||
] as const;
|
||||
if (required.some((option) => !values.has(option))) {
|
||||
throw new PackArgumentsError(USAGE);
|
||||
}
|
||||
const repository = values.get("repository");
|
||||
return {
|
||||
licenseFile: values.get("licenseFile") ?? "",
|
||||
moduleDirectory: values.get("moduleDirectory") ?? "",
|
||||
outDirectory: values.get("outDirectory") ?? "",
|
||||
...(repository === undefined ? {} : { repository }),
|
||||
|
||||
@@ -3,17 +3,18 @@ import { lstat, mkdir, readdir, readFile, realpath, rename, writeFile } from "no
|
||||
import { isBuiltin } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { type ModuleManifest, safeParseModuleManifest } from "@ekklesia/contracts";
|
||||
import {
|
||||
MODULE_PACKAGE_ANY_TARGET,
|
||||
MODULE_PACKAGE_FORMAT,
|
||||
MODULE_PACKAGE_LICENSE_FILENAME,
|
||||
MODULE_PACKAGE_METADATA_FILENAME,
|
||||
MODULE_PACKAGE_NOTICES_FILENAME,
|
||||
type ModuleManifest,
|
||||
modulePackageArchiveFileName,
|
||||
modulePackageRuntimePackage,
|
||||
safeParseModuleManifest,
|
||||
safeParseModulePackageLicenseExpression,
|
||||
safeParseModulePackageMetadata,
|
||||
} from "@ekklesia/contracts";
|
||||
} from "@ekklesia/contracts/module-package";
|
||||
import { analyzeModuleUi, ModuleUiBuildError } from "@ekklesia/module-ui-build";
|
||||
import { build, type Message, type Plugin } from "esbuild";
|
||||
import { minVersion, satisfies, validRange } from "semver";
|
||||
@@ -40,6 +41,8 @@ export type ModulePackErrorCode =
|
||||
| "MODULE_PACK_IDENTITY_MISMATCH"
|
||||
| "MODULE_PACK_INCORPORATED_CONFLICT"
|
||||
| "MODULE_PACK_LAYOUT_CONFLICT"
|
||||
| "MODULE_PACK_MODULE_LICENSE_FILE_INVALID"
|
||||
| "MODULE_PACK_MODULE_LICENSE_INVALID"
|
||||
| "MODULE_PACK_LICENCE_TEXT_MISSING"
|
||||
| "MODULE_PACK_LICENCE_UNKNOWN"
|
||||
| "MODULE_PACK_MANIFEST_INVALID"
|
||||
@@ -71,6 +74,10 @@ const messages: Readonly<Record<ModulePackErrorCode, string>> = Object.freeze({
|
||||
MODULE_PACK_INCORPORATED_CONFLICT:
|
||||
"The package bundles more than one version of an Ekklesia package.",
|
||||
MODULE_PACK_LAYOUT_CONFLICT: "Two package files would have the same path.",
|
||||
MODULE_PACK_MODULE_LICENSE_FILE_INVALID:
|
||||
"The module license text must be supplied as an explicit, non-empty, readable regular file.",
|
||||
MODULE_PACK_MODULE_LICENSE_INVALID:
|
||||
"The source package.json must declare a valid SPDX license expression as a string.",
|
||||
MODULE_PACK_LICENCE_TEXT_MISSING:
|
||||
"A bundled package ships no licence text, and its licence has no complete standard text to supply.",
|
||||
MODULE_PACK_LICENCE_UNKNOWN: "A bundled package declares no licence that permits redistribution.",
|
||||
@@ -115,6 +122,8 @@ export class ModulePackError extends Error {
|
||||
export interface PackModuleOptions {
|
||||
/** A built module directory: its manifest, package.json, and compiled entrypoints. */
|
||||
readonly moduleDirectory: string;
|
||||
/** Explicit license-text input. Its bytes are copied unchanged to the package root as LICENSE. */
|
||||
readonly licenseFile: string;
|
||||
/** `any` only, in this release. */
|
||||
readonly target: string;
|
||||
readonly outDirectory: string;
|
||||
@@ -377,6 +386,28 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
|
||||
) {
|
||||
throw new ModulePackError("MODULE_PACK_IDENTITY_MISMATCH");
|
||||
}
|
||||
const parsedLicense = safeParseModulePackageLicenseExpression(developmentPackage.license);
|
||||
if (!parsedLicense.success) {
|
||||
throw new ModulePackError("MODULE_PACK_MODULE_LICENSE_INVALID");
|
||||
}
|
||||
const license = parsedLicense.data;
|
||||
let licenseBytes: Buffer;
|
||||
try {
|
||||
if (
|
||||
typeof options.licenseFile !== "string" ||
|
||||
options.licenseFile === "" ||
|
||||
!(await lstat(path.resolve(options.licenseFile))).isFile()
|
||||
) {
|
||||
throw new Error("not a regular file");
|
||||
}
|
||||
licenseBytes = await readFile(path.resolve(options.licenseFile));
|
||||
if (licenseBytes.length === 0) {
|
||||
// An empty file would satisfy the inventory while carrying no license material at all.
|
||||
throw new Error("empty");
|
||||
}
|
||||
} catch {
|
||||
throw new ModulePackError("MODULE_PACK_MODULE_LICENSE_FILE_INVALID");
|
||||
}
|
||||
|
||||
const nodeRange = options.nodeRange ?? (await workspaceNodeRange(moduleDirectory));
|
||||
if (validRange(nodeRange) === null) {
|
||||
@@ -493,6 +524,7 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
|
||||
}
|
||||
}
|
||||
add("ekklesia.module.json", manifestBytes);
|
||||
add(MODULE_PACKAGE_LICENSE_FILENAME, licenseBytes);
|
||||
add(
|
||||
"package.json",
|
||||
Buffer.from(
|
||||
@@ -522,6 +554,7 @@ export async function packModule(options: PackModuleOptions): Promise<ModulePack
|
||||
const metadata = {
|
||||
packageFormat: MODULE_PACKAGE_FORMAT,
|
||||
module: { id: manifest.id, version: manifest.version },
|
||||
license,
|
||||
target: MODULE_PACKAGE_ANY_TARGET,
|
||||
runtime: { node: nodeRange },
|
||||
provenance: {
|
||||
|
||||
@@ -13,11 +13,14 @@ describe("ekklesia-pack-module arguments", () => {
|
||||
"any",
|
||||
"--out",
|
||||
"dist/packages",
|
||||
"--license-file",
|
||||
"LICENSE",
|
||||
"--source-revision",
|
||||
revision,
|
||||
]),
|
||||
).toEqual({
|
||||
moduleDirectory: "templates/module",
|
||||
licenseFile: "LICENSE",
|
||||
outDirectory: "dist/packages",
|
||||
sourceRevision: revision,
|
||||
target: "any",
|
||||
@@ -33,6 +36,8 @@ describe("ekklesia-pack-module arguments", () => {
|
||||
"any",
|
||||
"--out",
|
||||
"out",
|
||||
"--license-file",
|
||||
"LICENSE",
|
||||
"--source-revision",
|
||||
revision,
|
||||
"--repository",
|
||||
@@ -44,6 +49,10 @@ describe("ekklesia-pack-module arguments", () => {
|
||||
it.each([
|
||||
["no arguments", []],
|
||||
["a missing target", ["--module", ".", "--out", "o", "--source-revision", revision]],
|
||||
[
|
||||
"a missing license file",
|
||||
["--module", ".", "--target", "any", "--out", "o", "--source-revision", revision],
|
||||
],
|
||||
["a missing revision", ["--module", ".", "--target", "any", "--out", "o"]],
|
||||
[
|
||||
"an unknown option",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import { isBuiltin } from "node:module";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -21,6 +21,7 @@ async function pack(options: FixtureModuleOptions = {}, overrides: Record<string
|
||||
const moduleDirectory = await fixtureModule(options);
|
||||
const outDirectory = await scratchDirectory("ekklesia-pack-out-");
|
||||
const result = await packModule({
|
||||
licenseFile: path.join(moduleDirectory, "LICENSE"),
|
||||
moduleDirectory,
|
||||
nodeRange: ">=24.11.0 <25",
|
||||
outDirectory,
|
||||
@@ -61,6 +62,7 @@ describe("packModule: what a package contains (ADR 0049)", () => {
|
||||
const { archive } = await pack({ withSchema: true });
|
||||
const files = [...fileEntries(archive).keys()].sort();
|
||||
expect(files).toEqual([
|
||||
"example/LICENSE",
|
||||
"example/THIRD_PARTY_NOTICES",
|
||||
expect.stringMatching(/^example\/dist\/chunks\/chunk-[A-Z0-9]+\.js$/u),
|
||||
"example/dist/schema/index.js",
|
||||
@@ -81,6 +83,62 @@ describe("packModule: what a package contains (ADR 0049)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("copies the explicit license bytes unchanged and records their source SPDX expression", async () => {
|
||||
const licenseText = Buffer.from([0x4d, 0x49, 0x54, 0x0d, 0x0a, 0x00, 0xff]);
|
||||
const { archive } = await pack({ license: "MIT OR Apache-2.0", licenseText });
|
||||
const files = fileEntries(archive);
|
||||
const packaged = files.get("example/LICENSE");
|
||||
const metadata = JSON.parse(
|
||||
files.get("example/ekklesia.package.json")?.toString("utf8") ?? "{}",
|
||||
) as {
|
||||
license?: string;
|
||||
files?: { path: string; size: number; sha256: string }[];
|
||||
};
|
||||
|
||||
expect(metadata.license).toBe("MIT OR Apache-2.0");
|
||||
expect(packaged?.equals(licenseText)).toBe(true);
|
||||
expect(metadata.files?.filter((file) => file.path === "LICENSE")).toEqual([
|
||||
{
|
||||
path: "LICENSE",
|
||||
size: licenseText.length,
|
||||
sha256: createHash("sha256").update(licenseText).digest("hex"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a missing declaration", undefined],
|
||||
["an empty declaration", ""],
|
||||
["UNLICENSED", "UNLICENSED"],
|
||||
["legacy object metadata", { type: "MIT", url: "https://example.invalid/license" }],
|
||||
["a malformed SPDX expression", "MIT OR"],
|
||||
])("refuses %s", async (_label, license) => {
|
||||
await packRejection({ license }, "MODULE_PACK_MODULE_LICENSE_INVALID");
|
||||
});
|
||||
|
||||
it("requires a readable regular license-text input", async () => {
|
||||
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
|
||||
licenseFile: undefined,
|
||||
});
|
||||
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
|
||||
licenseFile: path.join(await scratchDirectory("ekklesia-missing-license-"), "missing"),
|
||||
});
|
||||
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
|
||||
licenseFile: await scratchDirectory("ekklesia-license-directory-"),
|
||||
});
|
||||
const emptyDirectory = await scratchDirectory("ekklesia-empty-license-");
|
||||
await writeFile(path.join(emptyDirectory, "LICENSE"), "");
|
||||
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
|
||||
licenseFile: path.join(emptyDirectory, "LICENSE"),
|
||||
});
|
||||
const linkDirectory = await scratchDirectory("ekklesia-linked-license-");
|
||||
await writeFile(path.join(linkDirectory, "real"), "MIT License\n");
|
||||
await symlink(path.join(linkDirectory, "real"), path.join(linkDirectory, "LICENSE"));
|
||||
await packRejection({}, "MODULE_PACK_MODULE_LICENSE_FILE_INVALID", {
|
||||
licenseFile: path.join(linkDirectory, "LICENSE"),
|
||||
});
|
||||
});
|
||||
|
||||
it("generates the runtime package.json instead of copying the development one", async () => {
|
||||
const { archive } = await pack();
|
||||
const runtimePackage = fileEntries(archive).get("example/package.json")?.toString("utf8");
|
||||
@@ -420,11 +478,12 @@ describe("packModule: THIRD_PARTY_NOTICES", () => {
|
||||
|
||||
describe("packModule: deterministic archives", () => {
|
||||
it("produces byte-identical archives and digests for identical inputs", async () => {
|
||||
const moduleDirectory = await fixtureModule({ withSchema: true });
|
||||
const runs = [];
|
||||
for (let run = 0; run < 2; run += 1) {
|
||||
const moduleDirectory = await fixtureModule({ withSchema: true });
|
||||
const outDirectory = await scratchDirectory("ekklesia-pack-out-");
|
||||
const result = await packModule({
|
||||
licenseFile: path.join(moduleDirectory, "LICENSE"),
|
||||
moduleDirectory,
|
||||
nodeRange: ">=24.11.0 <25",
|
||||
outDirectory,
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface FakePackage {
|
||||
export interface FixtureModuleOptions {
|
||||
readonly databaseAbi?: 1;
|
||||
readonly id?: string;
|
||||
readonly license?: unknown;
|
||||
readonly licenseText?: string | Uint8Array;
|
||||
readonly version?: string;
|
||||
readonly serverSource?: string;
|
||||
readonly withModuleDatabase?: boolean;
|
||||
@@ -114,6 +116,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
|
||||
{
|
||||
name: `@test/module-${id}`,
|
||||
version,
|
||||
license: Object.hasOwn(options, "license") ? options.license : "MIT",
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: { build: "tsc" },
|
||||
@@ -130,6 +133,10 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await write(
|
||||
path.join(moduleDirectory, "LICENSE"),
|
||||
options.licenseText ?? "MIT License\n\nCopyright (c) 2026 Example Module\n",
|
||||
);
|
||||
await write(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
options.serverSource ?? defaultServer(id, version, options.databaseAbi),
|
||||
|
||||
@@ -68,6 +68,7 @@ async function treeListing(directory: string): Promise<readonly string[]> {
|
||||
async function packTemplate() {
|
||||
const outDirectory = await scratchDirectory("ekklesia-template-package-");
|
||||
const result = await packModule({
|
||||
licenseFile: path.join(repositoryRoot, "LICENSE"),
|
||||
moduleDirectory: await templateDirectory(),
|
||||
outDirectory,
|
||||
sourceRevision: SOURCE_REVISION,
|
||||
@@ -104,8 +105,19 @@ describe("the module template as a Module Package v1", () => {
|
||||
const files = fileEntries(archive);
|
||||
const manifest = JSON.parse(files.get("example/ekklesia.module.json")?.toString("utf8") ?? "");
|
||||
const metadata = JSON.parse(files.get("example/ekklesia.package.json")?.toString("utf8") ?? "");
|
||||
const canonicalLicense = await readFile(path.join(repositoryRoot, "LICENSE"));
|
||||
const packagedLicense = files.get("example/LICENSE");
|
||||
|
||||
expect(manifest.database).toEqual({ abi: 1 });
|
||||
expect(metadata.license).toBe("AGPL-3.0-or-later");
|
||||
expect(packagedLicense).toEqual(canonicalLicense);
|
||||
expect(metadata.files.filter((file: { path: string }) => file.path === "LICENSE")).toEqual([
|
||||
{
|
||||
path: "LICENSE",
|
||||
size: canonicalLicense.length,
|
||||
sha256: createHash("sha256").update(canonicalLicense).digest("hex"),
|
||||
},
|
||||
]);
|
||||
expect(metadata.transitional).toBeUndefined();
|
||||
expect(metadata.incorporates).toMatchObject({
|
||||
"@ekklesia/module-database": expect.any(String),
|
||||
@@ -209,6 +221,12 @@ describe("the module template as a Module Package v1", () => {
|
||||
new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\/]/gu, "\\$&")} \\S+$`, "mu"),
|
||||
);
|
||||
}
|
||||
expect(notices).not.toContain("@ekklesia/module-template");
|
||||
expect(notices).not.toMatch(/^@ekklesia\//mu);
|
||||
expect(bundled).not.toContain("spdx-expression-parse");
|
||||
expect(bundled).not.toContain("spdx-exceptions");
|
||||
expect(notices).not.toContain("spdx-expression-parse");
|
||||
expect(notices).not.toContain("spdx-exceptions");
|
||||
const licence = await readFile(path.join(packageRoot, "licenses", "Apache-2.0.txt"));
|
||||
expect(createHash("sha256").update(licence).digest("hex")).toBe(
|
||||
"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
|
||||
|
||||
Reference in New Issue
Block a user