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

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:
ekklesia
2026-10-07 12:44:54 -05:00
co-authored by Claude Opus 5.5
parent 45d6506c10
commit 35ef31df9f
45 changed files with 4876 additions and 151 deletions
+124 -31
View File
@@ -6,7 +6,7 @@ import {
moduleDisplayEntrypointSchema,
packageRelativePathSchema,
} from "@ekklesia/contracts";
import { build, type Plugin } from "esbuild";
import { type BuildOptions, build, type Plugin } from "esbuild";
/**
* The one supported way to turn a module's React/TSX UI sources into the compiled browser artifacts
@@ -108,7 +108,7 @@ const SURVIVING_IMPORT = /@import\s/iu;
*/
const STYLESHEET_PLUGIN_NAME = "ekklesia-compile-stylesheet-imports";
function compileStylesheetImports(): Plugin {
function compileStylesheetImports(recordInputs?: (paths: readonly string[]) => void): Plugin {
const decoder = new TextDecoder();
return {
name: STYLESHEET_PLUGIN_NAME,
@@ -127,15 +127,30 @@ function compileStylesheetImports(): Plugin {
],
}));
// The nested build works from the module directory, exactly like the outer one. Without it
// esbuild names each stylesheet relative to whatever directory the caller ran from, and the
// artifact's bytes would depend on the caller (ADR 0049 requires a deterministic package).
const workingDirectory = pluginBuild.initialOptions.absWorkingDir ?? process.cwd();
pluginBuild.onLoad({ filter: /\.css$/i }, async (args) => {
const compiled = await build({
absWorkingDir: workingDirectory,
bundle: true,
entryPoints: [args.path],
logLevel: "silent",
metafile: recordInputs !== undefined,
minify: false,
target: MODULE_UI_BUILD_TARGET,
write: false,
});
// A stylesheet's own @imports are bundled by this nested build, so the outer build never
// sees them. An analysis must, because they ship inside the artifact.
if (recordInputs && compiled.metafile) {
recordInputs(
Object.keys(compiled.metafile.inputs).map((input) =>
path.resolve(workingDirectory, input),
),
);
}
const stylesheet = compiled.outputFiles
.map((outputFile) => decoder.decode(outputFile.contents))
.join("\n");
@@ -323,6 +338,44 @@ async function resolveDeclaredArtifacts(
* Never runs during discovery, startup, or install: this is an authoring-time and packaging-time
* step only. Core validates the artifact a module ships; it never produces one (ADR 0021).
*/
/** The one esbuild configuration for a declared artifact, shared by building and analysing so the
* two can never describe different builds. */
function moduleUiBuildOptions(
moduleDirectory: string,
artifact: DeclaredModuleUiArtifact,
recordInputs?: (paths: readonly string[]) => void,
): BuildOptions & { write: false } {
return {
absWorkingDir: moduleDirectory,
// Every dependency, React included, is bundled. The artifact must run in any host, including
// one that uses no React at all, so nothing is externalized (ADR 0023).
bundle: true,
define: {
// React reads this at module scope. Without it the bundle would reference `process`, a
// Node global that does not exist in a browser, and crash on load.
"process.env.NODE_ENV": '"production"',
},
entryPoints: [artifact.sourceFile],
// ESM only: the artifact is imported by a host with a dynamic `import()`, and the contract
// requires a real default export.
format: "esm",
jsx: "automatic",
// Unminified deliberately: an operator is expected to review the artifact they install
// (see docs/development/security.md), and nothing else in this repository minifies.
minify: false,
outfile: artifact.outputFile,
platform: "browser",
plugins: [compileStylesheetImports(recordInputs)],
sourcemap: true,
target: MODULE_UI_BUILD_TARGET,
// Fail rather than silently shipping a partial bundle.
logLevel: "silent",
// Compile every role successfully before writing any role. This makes a two-artifact build
// atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set.
write: false,
};
}
export async function buildModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiBuildResult> {
const moduleDirectory = path.resolve(options.moduleDirectory);
const artifacts = await resolveDeclaredArtifacts(moduleDirectory);
@@ -330,35 +383,7 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
for (const artifact of artifacts) {
try {
const result = await build({
absWorkingDir: moduleDirectory,
// Every dependency, React included, is bundled. The artifact must run in any host, including
// one that uses no React at all, so nothing is externalized (ADR 0023).
bundle: true,
define: {
// React reads this at module scope. Without it the bundle would reference `process`, a
// Node global that does not exist in a browser, and crash on load.
"process.env.NODE_ENV": '"production"',
},
entryPoints: [artifact.sourceFile],
// ESM only: the artifact is imported by a host with a dynamic `import()`, and the contract
// requires a real default export.
format: "esm",
jsx: "automatic",
// Unminified deliberately: an operator is expected to review the artifact they install
// (see docs/development/security.md), and nothing else in this repository minifies.
minify: false,
outfile: artifact.outputFile,
platform: "browser",
plugins: [compileStylesheetImports()],
sourcemap: true,
target: MODULE_UI_BUILD_TARGET,
// Fail rather than silently shipping a partial bundle.
logLevel: "silent",
// Compile every role successfully before writing any role. This makes a two-artifact build
// atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set.
write: false,
});
const result = await build(moduleUiBuildOptions(moduleDirectory, artifact));
// Belt and braces on the stylesheet contract. The plugin above turns every `.css` import into
// JavaScript, so esbuild has nothing left to emit a stylesheet from — but a future option or
// loader change could reintroduce a sidecar, and a sidecar is precisely the failure this
@@ -414,3 +439,71 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
artifacts: Object.freeze(artifacts.map((artifact) => Object.freeze({ ...artifact }))),
});
}
export interface ModuleUiAnalysis {
/** Every declared artifact, in the same stable role order `buildModuleUi` produces them. */
readonly artifacts: readonly ModuleUiArtifactAnalysis[];
}
export interface ModuleUiArtifactAnalysis {
readonly kind: ModuleUiArtifactKind;
readonly contributionId?: string;
/** Absolute path of the artifact the manifest declares. */
readonly outputFile: string;
/** Absolute paths of every source file bundled into it, stylesheets and their imports included,
* sorted and unique. What a packager credits for the code it redistributes. */
readonly inputs: readonly string[];
/** Whether the artifact on disk is byte-for-byte what these sources build today. False when it
* was never built or is stale, so a caller never describes one artifact and ships another. */
readonly builtArtifactCurrent: boolean;
}
/**
* Runs the canonical module UI build in memory, exactly as `buildModuleUi` would, and reports what
* went into each declared artifact. Writes nothing. Used by the module packager (ADR 0049) to find
* the third-party code inside already-built browser artifacts, and to refuse a stale build.
*/
export async function analyzeModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiAnalysis> {
const moduleDirectory = path.resolve(options.moduleDirectory);
const artifacts = await resolveDeclaredArtifacts(moduleDirectory);
const analyses: ModuleUiArtifactAnalysis[] = [];
for (const artifact of artifacts) {
const inputs = new Set<string>();
let result: Awaited<ReturnType<typeof build>>;
try {
result = await build({
...moduleUiBuildOptions(moduleDirectory, artifact, (paths) => {
for (const input of paths) {
inputs.add(input);
}
}),
metafile: true,
});
} catch {
throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifact.kind);
}
for (const input of Object.keys(result.metafile?.inputs ?? {})) {
inputs.add(path.resolve(moduleDirectory, input));
}
const built = result.outputFiles?.find((outputFile) => outputFile.path === artifact.outputFile);
let onDisk: Buffer | undefined;
try {
onDisk = await readFile(artifact.outputFile);
} catch {
onDisk = undefined;
}
analyses.push(
Object.freeze({
...(artifact.contributionId === undefined
? {}
: { contributionId: artifact.contributionId }),
builtArtifactCurrent:
built !== undefined && onDisk !== undefined && Buffer.from(built.contents).equals(onDisk),
inputs: Object.freeze([...inputs].sort()),
kind: artifact.kind,
outputFile: artifact.outputFile,
}),
);
}
return Object.freeze({ artifacts: Object.freeze(analyses) });
}
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { buildModuleUi, ModuleUiBuildError } from "../src/index.js";
import { analyzeModuleUi, buildModuleUi, ModuleUiBuildError } from "../src/index.js";
const temporaryDirectories: string[] = [];
@@ -502,3 +502,87 @@ describe("module UI build", () => {
expect(artifact).not.toContain("process.env.NODE_ENV");
});
});
describe("module UI build analysis", () => {
const styledSource = `import styles from "./styles.css";
${plainUiSource.replace("const definition = {", "const definition = {\n styles,")}`;
it("reports every source file behind each artifact, stylesheets included, and writes nothing", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": styledSource,
"src/ui/styles.css":
'@import "./base.css";\n.example { color: var(--ekklesia-color-text); }\n',
"src/ui/base.css": ".example-base { margin: 0; }\n",
},
});
await buildModuleUi({ moduleDirectory: directory });
const built = await readFile(path.join(directory, "dist/ui/index.js"));
const analysis = await analyzeModuleUi({ moduleDirectory: directory });
expect(analysis.artifacts).toHaveLength(1);
const [artifact] = analysis.artifacts;
expect(artifact?.kind).toBe("ui");
expect(artifact?.outputFile).toBe(path.join(directory, "dist/ui/index.js"));
expect(artifact?.builtArtifactCurrent).toBe(true);
expect(artifact?.inputs).toEqual(
expect.arrayContaining([
path.join(directory, "src/ui/index.tsx"),
path.join(directory, "src/ui/styles.css"),
path.join(directory, "src/ui/base.css"),
]),
);
expect([...(artifact?.inputs ?? [])]).toEqual([...new Set(artifact?.inputs)].sort());
// Nothing was written: the built artifact is byte-for-byte what it was.
expect(await readFile(path.join(directory, "dist/ui/index.js"))).toEqual(built);
});
it("compiles stylesheets relative to the module, whatever directory the build runs from", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": styledSource,
"src/ui/styles.css": '@import "./base.css";\n.example { margin: 0; }\n',
"src/ui/base.css": ".example-base { margin: 0; }\n",
},
});
// This test runs from the package directory, never from the module's own: the artifact must
// still name its stylesheets relative to the module, or its bytes would depend on the caller.
expect(process.cwd()).not.toBe(directory);
await buildModuleUi({ moduleDirectory: directory });
const artifact = await readFile(path.join(directory, "dist/ui/index.js"), "utf8");
expect(artifact).toContain("/* src/ui/styles.css */");
expect(artifact).toContain("/* src/ui/base.css */");
expect(artifact).not.toContain(path.basename(directory));
});
it("says when a built artifact no longer matches its sources", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": plainUiSource },
});
await buildModuleUi({ moduleDirectory: directory });
await writeFile(path.join(directory, "dist/ui/index.js"), "export default {};\n");
const analysis = await analyzeModuleUi({ moduleDirectory: directory });
expect(analysis.artifacts[0]?.builtArtifactCurrent).toBe(false);
});
it("says when a declared artifact was never built", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": plainUiSource },
});
const analysis = await analyzeModuleUi({ moduleDirectory: directory });
expect(analysis.artifacts[0]?.builtArtifactCurrent).toBe(false);
});
it("returns an empty analysis for a backend-only module", async () => {
const directory = await createModule();
await expect(analyzeModuleUi({ moduleDirectory: directory })).resolves.toEqual({
artifacts: [],
});
});
});