feat: Ekklesia Module Package v1, the canonical packager and verifier (ADR 0049)
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m42s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Failing after 54s
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m42s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Failing after 54s
A separately versioned module is now distributed as one deterministic .tar.gz and its SHA-256, produced by an Ekklesia-owned packager and checked by Core before anything is installed. This slice implements the portable `any` target, with the module template as the end-to-end proof. ADR 0049 is accepted. @ekklesia/contracts: the Module Package v1 contract. The strict ekklesia.package.json schema (packageFormat 1, identity, target, runtime, provenance, incorporated Ekklesia packages, strict transitional facts, an exact byte-wise-sorted file list with size and SHA-256), target names, and the generated runtime package.json. @ekklesia/module-pack (new, tooling): `ekklesia-pack-module --module <dir> --target any --out <dir> --source-revision <commit>`. - Bundles server, schema, and jobs with esbuild, SDK, contracts, and Drizzle included; only Node built-ins remain imports. Any warning, unresolved import, external dependency, or native or platform-specific package fails. - Copies browser artifacts as built, after proving them current, and the migrations; generates package.json and ekklesia.package.json; never ships src, tests, declarations, source maps, or workspace ranges. - THIRD_PARTY_NOTICES: every third-party package traced from the bundles' own inputs, credited once with its declared licence and every licence, copying, copyright, and notice file it ships. A missing licence fails; a missing text fails unless its standard text is complete without copyright lines (Apache-2.0, vendored, needed by drizzle-orm). - Deterministic ustar and gzip: sorted paths, uid/gid 0, empty names, mtime 0, 0755/0644, no links. Byte-identical across runs and machines. - Writes only the archive and its .sha256; never deploys, links, or installs, so the workspace's install state is untouched. Core: verifyModulePackage and `verify-module-package.js <archive> [--sha256 | --sha256-file] [--extract-to <new dir>]`. Reads the archive in memory and refuses links, special entries, setuid/setgid/sticky modes, unsafe or escaping paths, and a second top-level directory; checks metadata, every file, identity, target, Node.js range, SDK, and the transitional Drizzle agreement; runs the platform's module and migration discovery; loads the backend once in a forked process with an empty environment; only then extracts, with normalized modes, into a new directory. Errors are sanitized. @ekklesia/module-ui-build: analyzeModuleUi reports each artifact's inputs and whether the built file is current. The nested stylesheet build now works from the module directory, so artifacts no longer depend on the caller's working directory. The split deploy smoke builds the package with the canonical packager (scripts/lib/build-module-package.Dockerfile replaces the test-only pnpm deploy packaging), verifies it with the same generic image, installs the verified tree, removes it, and reinstalls the exact same archive. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
45d6506c10
commit
35ef31df9f
@@ -0,0 +1,176 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { BundledPackage } from "./packages.js";
|
||||
|
||||
/**
|
||||
* `THIRD_PARTY_NOTICES` (ADR 0049): the licence and notice text that redistributing a module
|
||||
* package requires, generated from what was actually bundled.
|
||||
*
|
||||
* - **Discovery.** Every source file in every bundle (the backend, and each browser artifact) is
|
||||
* traced to the package it belongs to. Packages installed under `node_modules` are third-party
|
||||
* software and are credited here. The Ekklesia platform's own packages, and the module itself,
|
||||
* are not third-party software: the platform packages appear in `ekklesia.package.json` under
|
||||
* `incorporates`.
|
||||
* - **Content.** For each third-party package: its name, version, declared licence, where in the
|
||||
* package it is used, and the complete text of every licence, copying, copyright, and notice file
|
||||
* it ships at its root, plus the file a `SEE LICENSE IN` declaration names.
|
||||
* - **Duplicates.** A package bundled into several artifacts is credited once, listing every
|
||||
* artifact kind it is used in. Different packages are never merged, even when their texts match,
|
||||
* so each component keeps its own notice.
|
||||
* - **Gaps fail.** A package that declares no licence, or whose licence cannot be established,
|
||||
* stops packaging. When a package ships no licence file, the standard text of its declared licence
|
||||
* is supplied only if that licence's standard text is complete without project-specific lines
|
||||
* (Apache-2.0). An MIT, ISC, or BSD package without its file is refused, because its copyright line
|
||||
* exists only in that file.
|
||||
*
|
||||
* Everything is sorted, and line endings are normalized, so the file is byte-identical for identical
|
||||
* inputs.
|
||||
*/
|
||||
|
||||
export type NoticeErrorCode = "MODULE_PACK_LICENCE_TEXT_MISSING" | "MODULE_PACK_LICENCE_UNKNOWN";
|
||||
|
||||
export class NoticeError extends Error {
|
||||
readonly code: NoticeErrorCode;
|
||||
readonly component: string;
|
||||
|
||||
constructor(code: NoticeErrorCode, component: string) {
|
||||
super(code);
|
||||
this.name = "NoticeError";
|
||||
this.code = code;
|
||||
this.component = component;
|
||||
}
|
||||
}
|
||||
|
||||
export type NoticeUse = "backend" | "browser";
|
||||
|
||||
export interface NoticeComponent {
|
||||
readonly bundled: BundledPackage;
|
||||
readonly uses: ReadonlySet<NoticeUse>;
|
||||
}
|
||||
|
||||
/** Licences whose standard text is complete without any project-specific line, so supplying it
|
||||
* for a package that ships none is faithful. Checked into this package (`licenses/`). */
|
||||
const STANDARD_TEXTS: Readonly<Record<string, string>> = Object.freeze({
|
||||
"Apache-2.0": "Apache-2.0.txt",
|
||||
});
|
||||
|
||||
const LICENCE_FILE =
|
||||
/^(?:licen[cs]e(?:[-._][a-z0-9.-]+)?|copying(?:[-._][a-z0-9.-]+)?|notice(?:[-._][a-z0-9.-]+)?|copyright(?:[-._][a-z0-9.-]+)?)$/iu;
|
||||
|
||||
const RULE = "-".repeat(80);
|
||||
|
||||
function licenceOf(bundled: BundledPackage): string | undefined {
|
||||
const declared = bundled.manifest.license;
|
||||
if (typeof declared === "string" && declared.trim() !== "") {
|
||||
return declared.trim();
|
||||
}
|
||||
if (typeof declared === "object" && declared !== null && "type" in declared) {
|
||||
const type = (declared as { type?: unknown }).type;
|
||||
return typeof type === "string" && type.trim() !== "" ? type.trim() : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalized(text: string): string {
|
||||
return `${text.replaceAll("\r\n", "\n").replaceAll("\r", "\n").replace(/\s+$/u, "")}\n`;
|
||||
}
|
||||
|
||||
function compare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
async function licenceTexts(
|
||||
bundled: BundledPackage,
|
||||
licence: string,
|
||||
): Promise<readonly { readonly title: string; readonly text: string }[]> {
|
||||
const names = (await readdir(bundled.root, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isFile() && LICENCE_FILE.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort(compare);
|
||||
const seeFile = /^SEE LICEN[CS]E IN (.+)$/iu.exec(licence)?.[1]?.trim();
|
||||
if (seeFile && !names.includes(seeFile)) {
|
||||
const resolved = path.resolve(bundled.root, seeFile);
|
||||
if (resolved.startsWith(`${bundled.root}${path.sep}`)) {
|
||||
names.push(seeFile);
|
||||
}
|
||||
}
|
||||
const texts: { title: string; text: string }[] = [];
|
||||
for (const name of names) {
|
||||
texts.push({
|
||||
text: normalized(await readFile(path.join(bundled.root, name), "utf8")),
|
||||
title: name,
|
||||
});
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
async function standardText(licence: string): Promise<string | undefined> {
|
||||
const file = STANDARD_TEXTS[licence];
|
||||
return file === undefined
|
||||
? undefined
|
||||
: normalized(await readFile(path.join(packageRoot, "licenses", file), "utf8"));
|
||||
}
|
||||
|
||||
export async function thirdPartyNotices(
|
||||
components: ReadonlyMap<string, NoticeComponent>,
|
||||
): Promise<string> {
|
||||
const ordered = [...components.values()].sort(
|
||||
(left, right) =>
|
||||
compare(left.bundled.name, right.bundled.name) ||
|
||||
compare(left.bundled.version, right.bundled.version),
|
||||
);
|
||||
const sections: string[] = [];
|
||||
for (const { bundled, uses } of ordered) {
|
||||
const component = `${bundled.name}@${bundled.version}`;
|
||||
const licence = licenceOf(bundled);
|
||||
if (licence === undefined || /^UNLICEN[CS]ED$/iu.test(licence)) {
|
||||
throw new NoticeError("MODULE_PACK_LICENCE_UNKNOWN", component);
|
||||
}
|
||||
let texts = await licenceTexts(bundled, licence);
|
||||
// A NOTICE file alone is not the licence: the licence text must still come from somewhere.
|
||||
if (!texts.some((text) => !/^notice/iu.test(text.title))) {
|
||||
const standard = await standardText(licence);
|
||||
if (standard === undefined) {
|
||||
throw new NoticeError("MODULE_PACK_LICENCE_TEXT_MISSING", component);
|
||||
}
|
||||
texts = [
|
||||
...texts,
|
||||
{
|
||||
text: `The package ships no licence file. This is the standard text of the licence it declares (${licence}).\n\n${standard}`,
|
||||
title: `Standard ${licence} text`,
|
||||
},
|
||||
];
|
||||
}
|
||||
sections.push(
|
||||
[
|
||||
RULE,
|
||||
`${bundled.name} ${bundled.version}`,
|
||||
`License: ${licence}`,
|
||||
`Used in: ${[...uses].sort(compare).join(", ")}`,
|
||||
RULE,
|
||||
"",
|
||||
...texts.flatMap((text) => [`[${text.title}]`, "", text.text]),
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
const header = [
|
||||
"THIRD-PARTY SOFTWARE NOTICES",
|
||||
"",
|
||||
"This module package includes the third-party software listed below. Each entry names the",
|
||||
"component, its version, its declared licence, where in the package it is used, and the licence",
|
||||
"and notice text that came with it.",
|
||||
"",
|
||||
"The Ekklesia platform's own packages are not third-party software. They are listed in",
|
||||
'ekklesia.package.json under "incorporates".',
|
||||
"",
|
||||
ordered.length === 0
|
||||
? "This module package includes no third-party software."
|
||||
: `${ordered.length} ${ordered.length === 1 ? "component" : "components"}.`,
|
||||
"",
|
||||
].join("\n");
|
||||
return `${[header, ...sections].join("\n").replace(/\n+$/u, "")}\n`;
|
||||
}
|
||||
Reference in New Issue
Block a user