feat: standalone module-build readiness for the public @ekklesia packages (ADR 0053)
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 28s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / standalone-module (pull_request) Successful in 26s
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 1m42s

Makes the public module-development package set usable from a module
repository outside the monorepo. Nothing is published.

- Curated @ekklesia/module-sdk: an explicit author API, no contracts
  barrel, no identity/authorization internals, no Drizzle and no legacy
  ModuleDatabase type. defineModuleRuntime returns the SDK's own named
  interfaces so a module's emitted declarations never reach into
  @ekklesia/contracts. defineModuleSchema is gone; createModuleTable and
  defineModuleSchemaMetadata live in @ekklesia/module-database.
- Drizzle: no longer a dependency of module-sdk or contracts. Core keeps
  the legacy Drizzle schema validator (moved to core/src/modules) and
  its legacy runtime support; drizzle-orm is an optional peer only.
- React is a peer (plus devDependency) of @ekklesia/ui; the boundary
  checker allows exactly that consumer-selected peer.
- @ekklesia/typescript-config is publishable; module-ui-host is private.
  Every public package carries "ekklesia.distribution":
  "module-development" and no monorepo-root prepack.
- module-pack reads the Node.js range from the module's own engines.node
  and recognizes Ekklesia packages by name plus that marker, so
  registry-installed packages are incorporated, SDK-compatibility
  checked, and never credited as third-party.
- scripts/pack-public-packages.ts (pnpm packages:pack) rebuilds, packs
  and inspects the nine public tarballs; scripts/test-standalone-module.sh
  (pnpm test:standalone-module, new CI job) installs them into a fixture
  module outside the repository and proves it typechecks, builds,
  packages, and verifies.
- Public-package boundary tests and module-development docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-08 07:28:54 -05:00
co-authored by Claude Opus 5.5
parent 9426c83109
commit 89d5d0f2da
63 changed files with 2320 additions and 237 deletions
+304
View File
@@ -0,0 +1,304 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readdir, readFile, realpath, rm, stat } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
/**
* The public module-development package set (ADR 0053), and the release check that packs it.
*
* node scripts/pack-public-packages.ts --out <directory>
*
* Rebuilds each public package from clean output, packs it with `pnpm pack`, and inspects every
* tarball before anything may consume it: only the package's built files, its declarations, and a
* `LICENSE`; a manifest with no `workspace:` range, no install-time lifecycle script, the explicit
* distribution marker, and the one coordinated version. It publishes nothing.
*/
const execFileAsync = promisify(execFile);
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export type PublicPackageRole = "author-api" | "tooling" | "pre-1.0" | "implementation";
export interface PublicPackage {
readonly name: string;
/** Relative to the repository root. */
readonly directory: string;
readonly role: PublicPackageRole;
}
/** ADR 0053's public set. Every other workspace package is private and never published. */
export const PUBLIC_PACKAGES: readonly PublicPackage[] = Object.freeze([
{ directory: "packages/module-sdk", name: "@ekklesia/module-sdk", role: "author-api" },
{ directory: "packages/module-database", name: "@ekklesia/module-database", role: "author-api" },
{ directory: "packages/module-ui-sdk", name: "@ekklesia/module-ui-sdk", role: "author-api" },
{ directory: "packages/design-tokens", name: "@ekklesia/design-tokens", role: "author-api" },
{ directory: "packages/module-ui-build", name: "@ekklesia/module-ui-build", role: "tooling" },
{ directory: "packages/module-pack", name: "@ekklesia/module-pack", role: "tooling" },
{
directory: "packages/typescript-config",
name: "@ekklesia/typescript-config",
role: "tooling",
},
{ directory: "packages/ui", name: "@ekklesia/ui", role: "pre-1.0" },
// A dependency of the packages above, never a supported direct module-author dependency.
{ directory: "packages/contracts", name: "@ekklesia/contracts", role: "implementation" },
]);
/** `ekklesia.distribution` in a public package's manifest: the identity the canonical packager
* recognizes an installed Ekklesia package by (`packages/module-pack/src/packages.ts`). */
export const PUBLIC_PACKAGE_DISTRIBUTION = "module-development";
/** Scripts a package manager runs on a consumer's machine when it installs, packs, or publishes. */
const LIFECYCLE_SCRIPTS = Object.freeze([
"preinstall",
"install",
"postinstall",
"prepare",
"prepack",
"postpack",
"prepublish",
"prepublishOnly",
"publish",
"postpublish",
]);
/** The fields a consumer's install resolves. `devDependencies` are never installed from a published
* package, so a tooling package's test-only use of a private package is not a problem there. */
const INSTALLED_DEPENDENCY_FIELDS = Object.freeze([
"dependencies",
"peerDependencies",
"optionalDependencies",
]);
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** What is wrong with a packed `package.json`, or nothing. */
export function packedManifestProblems(
manifest: Readonly<Record<string, unknown>>,
expected: { readonly name: string; readonly version: string },
): string[] {
const problems: string[] = [];
if (manifest.name !== expected.name) problems.push(`is named ${String(manifest.name)}`);
if (manifest.version !== expected.version) {
problems.push(
`has version ${String(manifest.version)}, not the coordinated ${expected.version}`,
);
}
if (manifest.private !== undefined) problems.push("declares private");
if (manifest.license !== "AGPL-3.0-or-later") problems.push("does not declare its licence");
if (JSON.stringify(manifest).includes("workspace:")) problems.push("keeps a workspace: range");
const ekklesia = manifest.ekklesia;
if (!isRecord(ekklesia) || ekklesia.distribution !== PUBLIC_PACKAGE_DISTRIBUTION) {
problems.push("lacks the module-development distribution marker");
}
const scripts = isRecord(manifest.scripts) ? manifest.scripts : {};
for (const script of LIFECYCLE_SCRIPTS) {
if (Object.hasOwn(scripts, script)) problems.push(`runs a ${script} script`);
}
const publicNames = new Set(PUBLIC_PACKAGES.map((entry) => entry.name));
for (const field of INSTALLED_DEPENDENCY_FIELDS) {
const dependencies = manifest[field];
if (!isRecord(dependencies)) continue;
for (const [name, range] of Object.entries(dependencies)) {
if (!name.startsWith("@ekklesia/")) continue;
if (!publicNames.has(name)) problems.push(`${field} names the private ${name}`);
else if (range !== expected.version) {
problems.push(`${field} names ${name}@${String(range)}, not ${expected.version}`);
}
}
}
return problems;
}
const BUILT_FILE = /^dist\/(.+?)(?:\.d\.ts|\.js)(?:\.map)?$/u;
/**
* What is wrong with a tarball's file list, or nothing. Every file is `package.json`, `LICENSE`, or
* under one of the manifest's `files` entries; nothing is a source, test, or configuration file; and
* every built file has the source it was compiled from, so stale output from a deleted source fails.
*/
export function tarballFileProblems(
files: readonly string[],
manifest: Readonly<Record<string, unknown>>,
hasSource: (relativeWithoutExtension: string) => boolean,
): string[] {
const problems: string[] = [];
const declared = Array.isArray(manifest.files) ? manifest.files.map(String) : [];
if (!files.includes("LICENSE")) problems.push("has no LICENSE");
if (!files.includes("package.json")) problems.push("has no package.json");
for (const file of files) {
if (file === "package.json" || file === "LICENSE") continue;
if (!declared.some((entry) => file === entry || file.startsWith(`${entry}/`))) {
problems.push(`ships ${file}, which its files field does not name`);
continue;
}
if (
/(?:^|\/)(?:src|tests?|node_modules)\//u.test(file) ||
/\.test\.[cm]?[jt]sx?$/u.test(file)
) {
problems.push(`ships ${file}`);
continue;
}
const built = BUILT_FILE.exec(file);
if (file.startsWith("dist/") && (built === null || !hasSource(built[1] ?? ""))) {
problems.push(`ships ${file}, which no source produces`);
}
}
return problems;
}
async function readJson(file: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
}
async function filesUnder(directory: string): Promise<string[]> {
const entries = await readdir(directory, { recursive: true, withFileTypes: true });
return entries
.filter((entry) => entry.isFile())
.map((entry) =>
path.relative(directory, path.join(entry.parentPath, entry.name)).split(path.sep).join("/"),
)
.sort();
}
async function sourceExists(packageDirectory: string, relative: string): Promise<boolean> {
for (const extension of [".ts", ".tsx"]) {
try {
await stat(path.join(packageDirectory, "src", `${relative}${extension}`));
return true;
} catch {}
}
return false;
}
export interface PackedPackage {
readonly name: string;
readonly tarball: string;
readonly files: number;
readonly bytes: number;
}
async function pnpm(arguments_: readonly string[], cwd: string): Promise<string> {
const { stdout } = await execFileAsync("pnpm", [...arguments_], {
cwd,
maxBuffer: 64 * 1024 * 1024,
});
return stdout;
}
async function packOne(
entry: PublicPackage,
version: string,
outDirectory: string,
scratch: string,
): Promise<{ packed: PackedPackage; problems: string[] }> {
const directory = path.join(repositoryRoot, entry.directory);
const report = JSON.parse(
await pnpm(["pack", "--json", "--pack-destination", outDirectory], directory),
) as { filename: string };
const tarball = report.filename;
const extracted = path.join(scratch, entry.name.replace("/", "+"));
await mkdir(extracted, { recursive: true });
await execFileAsync("tar", ["-xzf", tarball, "-C", extracted]);
const root = path.join(extracted, "package");
const files = await filesUnder(root);
const manifest = await readJson(path.join(root, "package.json"));
const sources = new Map<string, boolean>();
for (const file of files) {
const built = BUILT_FILE.exec(file)?.[1];
if (built !== undefined && !sources.has(built)) {
sources.set(built, await sourceExists(directory, built));
}
}
const problems = [
...packedManifestProblems(manifest, { name: entry.name, version }),
...tarballFileProblems(files, manifest, (relative) => sources.get(relative) === true),
];
// Nothing the package says may name the machine or checkout it was packed on.
for (const file of files) {
const text = (await readFile(path.join(root, file))).toString("latin1");
for (const local of [repositoryRoot, homedir()]) {
if (text.includes(local)) problems.push(`${file} names a local path`);
}
}
return {
packed: { bytes: (await stat(tarball)).size, files: files.length, name: entry.name, tarball },
problems: [...new Set(problems)],
};
}
/** Rebuilds the public set from clean output, packs it into `outDirectory`, and inspects it. */
export async function packPublicPackages(outDirectory: string): Promise<{
readonly packed: readonly PackedPackage[];
readonly problems: readonly string[];
}> {
const version = String((await readJson(path.join(repositoryRoot, "package.json"))).version);
await mkdir(outDirectory, { recursive: true });
const out = await realpath(outDirectory);
// Stale output from a deleted source would otherwise ship: the build only ever adds files, and
// Turbo's cache would restore whatever an earlier build left behind.
for (const entry of PUBLIC_PACKAGES) {
await rm(path.join(repositoryRoot, entry.directory, "dist"), { force: true, recursive: true });
}
await pnpm(
[
"exec",
"turbo",
"run",
"build",
"--force",
"--output-logs=errors-only",
...PUBLIC_PACKAGES.map((entry) => `--filter=${entry.name}`),
],
repositoryRoot,
);
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-public-packages-"));
try {
const packed: PackedPackage[] = [];
const problems: string[] = [];
for (const entry of PUBLIC_PACKAGES) {
const result = await packOne(entry, version, out, scratch);
packed.push(result.packed);
problems.push(...result.problems.map((problem) => `${entry.name} ${problem}.`));
}
return { packed, problems };
} finally {
await rm(scratch, { force: true, recursive: true });
}
}
async function main(argv: readonly string[]): Promise<number> {
if (argv.length !== 2 || argv[0] !== "--out" || !argv[1]) {
process.stderr.write("Usage: node scripts/pack-public-packages.ts --out <directory>\n");
return 64;
}
try {
const { packed, problems } = await packPublicPackages(path.resolve(argv[1]));
for (const entry of packed) {
process.stdout.write(
`${path.basename(entry.tarball)}: ${entry.files} files, ${entry.bytes} bytes\n`,
);
}
if (problems.length > 0) {
for (const problem of problems) process.stderr.write(`${problem}\n`);
return 1;
}
process.stdout.write(`Packed and inspected ${packed.length} public packages.\n`);
return 0;
} catch {
process.stderr.write("Packing the public packages failed while building or packing.\n");
return 1;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exitCode = await main(process.argv.slice(2));
}