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]>
206 lines
7.2 KiB
TypeScript
206 lines
7.2 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { gzipSync } from "node:zlib";
|
|
import { createMigrationModule } from "./migration-module-fixture.js";
|
|
|
|
/**
|
|
* Test-only builders for Ekklesia Module Package v1 archives (ADR 0049). They write ustar archives
|
|
* by hand so a test can produce exactly what the canonical packager never would: links, device
|
|
* nodes, traversal, a second top-level directory, a corrupted payload.
|
|
*/
|
|
|
|
export type FixtureEntryType = "file" | "directory" | "symlink" | "hardlink" | "character" | "fifo";
|
|
|
|
export interface FixtureEntry {
|
|
readonly path: string;
|
|
readonly type?: FixtureEntryType;
|
|
readonly mode?: number;
|
|
readonly data?: Uint8Array | string;
|
|
readonly linkname?: string;
|
|
}
|
|
|
|
const typeflags: Readonly<Record<FixtureEntryType, string>> = {
|
|
character: "3",
|
|
directory: "5",
|
|
fifo: "6",
|
|
file: "0",
|
|
hardlink: "1",
|
|
symlink: "2",
|
|
};
|
|
|
|
function octal(value: number, width: number): string {
|
|
return `${value.toString(8).padStart(width - 1, "0")}\u0000`;
|
|
}
|
|
|
|
function header(entry: FixtureEntry, size: number): Buffer {
|
|
const block = Buffer.alloc(512);
|
|
const name =
|
|
entry.type === "directory" && !entry.path.endsWith("/") ? `${entry.path}/` : entry.path;
|
|
let prefix = "";
|
|
let base = name;
|
|
if (Buffer.byteLength(name) > 100) {
|
|
const split = name.lastIndexOf("/", name.length - 2);
|
|
prefix = name.slice(0, split);
|
|
base = name.slice(split + 1);
|
|
}
|
|
block.write(base, 0, 100, "latin1");
|
|
block.write(octal(entry.mode ?? (entry.type === "directory" ? 0o755 : 0o644), 8), 100, "latin1");
|
|
block.write(octal(0, 8), 108, "latin1");
|
|
block.write(octal(0, 8), 116, "latin1");
|
|
block.write(octal(size, 12), 124, "latin1");
|
|
block.write(octal(0, 12), 136, "latin1");
|
|
block.write(" ", 148, "latin1");
|
|
block.write(typeflags[entry.type ?? "file"], 156, "latin1");
|
|
block.write(entry.linkname ?? "", 157, 100, "latin1");
|
|
block.write("ustar\u000000", 257, "latin1");
|
|
block.write(prefix, 345, 155, "latin1");
|
|
let checksum = 0;
|
|
for (const byte of block) {
|
|
checksum += byte;
|
|
}
|
|
block.write(`${checksum.toString(8).padStart(6, "0")}\u0000 `, 148, "latin1");
|
|
return block;
|
|
}
|
|
|
|
/** A gzip-compressed ustar archive of exactly these entries, in this order. */
|
|
export function tarGz(entries: readonly FixtureEntry[]): Buffer {
|
|
const parts: Buffer[] = [];
|
|
for (const entry of entries) {
|
|
const data =
|
|
entry.type === undefined || entry.type === "file"
|
|
? Buffer.from(entry.data ?? "")
|
|
: Buffer.alloc(0);
|
|
parts.push(header(entry, data.length), data, Buffer.alloc((512 - (data.length % 512)) % 512));
|
|
}
|
|
parts.push(Buffer.alloc(1024));
|
|
return gzipSync(Buffer.concat(parts), { level: 9 });
|
|
}
|
|
|
|
export function sha256(data: Uint8Array | string): string {
|
|
return createHash("sha256").update(data).digest("hex");
|
|
}
|
|
|
|
export interface PackageFixtureOptions {
|
|
readonly id?: string;
|
|
readonly version?: string;
|
|
readonly serverSource?: string;
|
|
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
|
|
readonly runtimePackage?: Record<string, unknown>;
|
|
readonly metadata?: (metadata: Record<string, unknown>) => Record<string, unknown>;
|
|
/** Changes the payload after its digests were computed. */
|
|
readonly tamper?: (files: Map<string, Buffer>) => void;
|
|
/** Entries appended after the package's own, under any path. */
|
|
readonly extraEntries?: readonly FixtureEntry[];
|
|
}
|
|
|
|
export interface PackageFixture {
|
|
readonly archive: Buffer;
|
|
readonly sha256: string;
|
|
}
|
|
|
|
async function readTree(root: string): Promise<Map<string, Buffer>> {
|
|
const files = new Map<string, Buffer>();
|
|
for (const entry of await readdir(root, { recursive: true, withFileTypes: true })) {
|
|
if (entry.isFile()) {
|
|
const absolute = path.join(entry.parentPath, entry.name);
|
|
files.set(path.relative(root, absolute).split(path.sep).join("/"), await readFile(absolute));
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* A valid any-target package of a minimal module: a server stub with no imports, one migration, a
|
|
* notices file, the generated runtime package.json, and complete metadata. Every option changes one
|
|
* thing, so a test can break exactly the rule it is about.
|
|
*/
|
|
export async function modulePackageFixture(
|
|
options: PackageFixtureOptions = {},
|
|
): Promise<PackageFixture> {
|
|
const id = options.id ?? "example";
|
|
const version = options.version ?? "0.1.0";
|
|
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-fixture-"));
|
|
let files: Map<string, Buffer>;
|
|
try {
|
|
const created = await createMigrationModule(scratch, id, {
|
|
serverSource:
|
|
options.serverSource ??
|
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} };\n`,
|
|
});
|
|
files = await readTree(created.moduleDirectory);
|
|
} finally {
|
|
await rm(scratch, { force: true, recursive: true });
|
|
}
|
|
|
|
const manifest = JSON.parse(
|
|
files.get("ekklesia.module.json")?.toString("utf8") ?? "{}",
|
|
) as Record<string, unknown>;
|
|
files.set(
|
|
"ekklesia.module.json",
|
|
Buffer.from(
|
|
`${JSON.stringify(options.manifest ? options.manifest(manifest) : { ...manifest, version }, null, 2)}\n`,
|
|
),
|
|
);
|
|
files.set(
|
|
"package.json",
|
|
Buffer.from(
|
|
`${JSON.stringify(
|
|
options.runtimePackage ?? {
|
|
name: `@test/module-${id}`,
|
|
version,
|
|
private: true,
|
|
type: "module",
|
|
ekklesia: { layer: "module" },
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
),
|
|
);
|
|
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
|
|
|
|
const listed = [...files.entries()]
|
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
.map(([filePath, data]) => ({ path: filePath, size: data.length, sha256: sha256(data) }));
|
|
const baseMetadata: Record<string, unknown> = {
|
|
packageFormat: 1,
|
|
module: { id, version },
|
|
target: "any",
|
|
runtime: { node: ">=20" },
|
|
provenance: {
|
|
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
|
|
packager: { name: "@ekklesia/module-pack", version: "0.1.2" },
|
|
},
|
|
incorporates: { "@ekklesia/module-sdk": "0.1.2" },
|
|
files: listed,
|
|
};
|
|
const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata;
|
|
options.tamper?.(files);
|
|
|
|
const directories = new Set<string>([id]);
|
|
for (const filePath of files.keys()) {
|
|
const segments = filePath.split("/");
|
|
for (let index = 1; index < segments.length; index += 1) {
|
|
directories.add(`${id}/${segments.slice(0, index).join("/")}`);
|
|
}
|
|
}
|
|
const all = new Map<string, FixtureEntry>();
|
|
for (const directory of directories) {
|
|
all.set(directory, { path: directory, type: "directory" });
|
|
}
|
|
for (const [filePath, data] of files) {
|
|
all.set(`${id}/${filePath}`, { path: `${id}/${filePath}`, data });
|
|
}
|
|
all.set(`${id}/ekklesia.package.json`, {
|
|
path: `${id}/ekklesia.package.json`,
|
|
data: `${JSON.stringify(metadata, null, 2)}\n`,
|
|
});
|
|
const ordered = [...all.values()].sort((left, right) =>
|
|
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
|
|
);
|
|
const archive = tarGz([...ordered, ...(options.extraEntries ?? [])]);
|
|
return { archive, sha256: sha256(archive) };
|
|
}
|