CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
CI / package-publication (pull_request) Successful in 44s
Every platform workspace package and every public @ekklesia/* package declares 0.2.0, and the deploy README's image build example names it. templates/module keeps its own version. 0.2.0, not 0.1.3: before 1.0 a minor version is the breaking one (docs/development/releases.md, ADR 0054), and since 0.1.2 main has broken modules, module authors, and operators: - #64 (ADR 0048): one generic image; installed modules live outside it; - #66, #72 (ADR 0049, 0050): modules install as Module Package v1, and move to the Module Database ABI; - #71: the manifest v8 cutover; v7 manifests are refused; - #79 (ADR 0053): the curated SDK drops defineModuleSchema and the legacy ModuleDatabase type; plus #67 (AGPL), #74, #77 (licensing, native targets), and the public package train (#80, #81). It is also the first package train to publish: 0.1.2 never will be. Because 0.2.0 is outside ^0.1.0, the same change moves to ^0.2.0: - the standalone fixture's @ekklesia ranges and its exact module-pack pin; - the compatibility ranges of the modules built for this release: the template, the standalone fixture, and the native proof module; - the test fixtures that model a module of the current release, or the current platform's versions; - the module-development guide's examples, and the release guide, which now names what a minor bump moves. Co-Authored-By: Claude Opus 5.5 <[email protected]>
215 lines
7.7 KiB
TypeScript
215 lines
7.7 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 schemaSource?: 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[];
|
|
/** Extra payload files, listed in the metadata like every other file. */
|
|
readonly files?: Readonly<Record<string, Uint8Array | string>>;
|
|
}
|
|
|
|
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, {
|
|
...(options.schemaSource === undefined ? {} : { schemaSource: options.schemaSource }),
|
|
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"));
|
|
files.set("LICENSE", Buffer.from("Example module license text.\n"));
|
|
for (const [filePath, data] of Object.entries(options.files ?? {})) {
|
|
files.set(filePath, Buffer.from(data));
|
|
}
|
|
|
|
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 },
|
|
license: "MIT",
|
|
target: "any",
|
|
runtime: { node: ">=20" },
|
|
provenance: {
|
|
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
|
|
packager: { name: "@ekklesia/module-pack", version: "0.2.0" },
|
|
},
|
|
incorporates: { "@ekklesia/module-sdk": "0.2.0" },
|
|
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) };
|
|
}
|