Files
Ekklesia/docs/architecture/decisions/0053-standalone-module-development-packages.md
T
ekklesiaandClaude Opus 5.5 9debe4e891
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Failing after 16s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
feat: the public @ekklesia package release train, built, reproduced, and proven (ADR 0053)
Produces the exact public package tarballs of a platform release, validates
them, and proves a standalone module consumes those artifacts end to end.
Nothing is published and no credential is configured.

- scripts/package-release.ts (pnpm packages:release) is the canonical
  release builder: from a clean commit it rebuilds the nine public
  packages from clean output, packs each, inspects every tarball, checks
  the release set and coordinated versions, and only then writes
  ekklesia-packages-<version>/ with the tarballs, SHA256SUMS, and a
  provider-neutral manifest (ekklesia-package-train.json: version,
  revision, and each package's file, size, SHA-256, npm integrity).
- Packing uses a pack-only pnpm beforePacking hook (passed with
  --config.pnpmfile, never the workspace's install settings). pnpm inserts
  rewritten workspace: dependencies in async completion order, so two
  checkouts of one commit packed module-pack's package.json with keys in a
  different order; the hook restores the source key order (and the
  inspection enforces it). It also drops devDependencies, which named
  private packages outside the train.
- scripts/test-package-release.sh (pnpm test:package-release, the CI
  package-release gate) builds the train twice, the second time from a
  separate worktree, requires byte-identical release directories, then
  runs the standalone consumer proof on the release: manifest checked,
  offline install from the tarballs only, lockfile integrity equal to the
  manifest's, frozen-lockfile reinstall, no Drizzle, one React, contracts
  only transitive, every @ekklesia package and module-pack at the release
  version, skipLibCheck: false, UI build, pack, and Core verification.
- docs/development/releases.md documents the release order, the train,
  the artifacts, and the reproducibility findings; registry publication
  remains unimplemented.
- ADR 0053 is Accepted: everything it decides that does not need a
  registry is now proven, and registry provisioning is on its own
  Deferred list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 08:02:41 -05:00

14 KiB

0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol

  • Status: Accepted
  • Date: 2026-10-07
  • Extends: 0049. This is the step ADR 0049 deferred: "publishing the Ekklesia SDK packages for module builds outside a platform workspace". A registry is used only to build a module. Installing one stays registry-free: an installed package is self-contained, as ADR 0049 decided.
  • Related: ADR 0002, ADR 0006, ADR 0021, ADR 0023, ADR 0039, ADR 0047, ADR 0048, ADR 0050, ADR 0051, ADR 0052

Context

Modules are independently versioned products in their own repositories (ADR 0021). Yet no module can be built outside Ekklesia's monorepo today. Signage's continuous integration clones the platform at a pinned commit, places Signage under modules/signage, and installs and builds inside that workspace, because its Ekklesia dependencies exist only as workspace:* packages.

An investigation of the current packages found the following.

  • One coordinated version. Every @ekklesia/* package, and the platform, is version 0.1.2. The release process already bumps them together. The platform reports its Module SDK version, which manifests' compatibility.moduleSdk ranges are checked against (ADR 0006), by reading @ekklesia/module-sdk's own package version.

  • The tarballs already stand alone. pnpm pack rewrites workspace:* ranges to exact versions, and each tarball carries only its built dist, declarations, and LICENSE. But every package's prepack runs a command at the monorepo root, and @ekklesia/typescript-config is marked private.

  • The SDK exposes everything. @ekklesia/module-sdk re-exports the whole @ekklesia/contracts barrel. That includes contracts no module needs, such as authorization and identity, and the legacy Drizzle ModuleDatabase type. @ekklesia/contracts depends on drizzle-orm for that type, so every module install pulls Drizzle in.

    • Neither the module template nor Signage imports @ekklesia/contracts directly.
  • Frameworks are declared twice. @ekklesia/ui lists react, and @ekklesia/module-sdk lists drizzle-orm, as both a dependency and a peer dependency. That invites a second copy of a runtime that must be single within one module bundle.

  • The canonical packager assumes the monorepo, in two ways:

    • it finds the module's Node.js range only through an enclosing pnpm-workspace.yaml;
    • it decides that an Ekklesia package is the platform's own, not third-party, because its real path does not contain node_modules.

    An @ekklesia/* package installed from a registry would therefore be treated as third-party. Packaging would omit it from incorporates and skip the Module SDK compatibility check.

  • The registry and its hosts.

    • The current private host is a Gitea 1.27.2 server. Its npm registry is enabled for the organization, and that endpoint and the repositories are currently readable anonymously.
    • pnpm leaves a tarball URL out of its lockfile only when the URL has the canonical <registry>/<name>/-/<name>-<version>.tgz form. Gitea advertises another form, so lockfiles produced against Gitea name the Gitea host.
    • The integrity hashes in those lockfiles are independent of the host.

Decision

The npm registry protocol, not a provider

Ekklesia's module-development packages are distributed over the standard npm registry protocol, and modules install them with ordinary pnpm or npm commands.

  • No provider is part of the architecture. Not Gitea, GitHub, npmjs, or any other. A module repository depends on the protocol and on a scope-to-registry mapping in normal npm/pnpm configuration.
  • Gitea may host the private phase. It is a host for this phase, not a permanent decision.
  • No host-neutral lockfile or Ekklesia proxy is required. Moving to another registry may need a one-time change of a module's registry configuration and a regeneration of its lockfile. The integrity hashes keep verifying the package content across such a move.

A module repository's registry configuration is, for example:

# .npmrc, committed: where the @ekklesia scope lives, and nothing secret.
@ekklesia:registry=https://<registry endpoint>/
# User or CI configuration, never committed: the credential, from the environment.
//<registry endpoint>/:_authToken=${EKKLESIA_PACKAGES_TOKEN}

The public package set

Only packages a module author needs are published. Nothing is published merely because it exists in the workspace.

Category Packages
Author API @ekklesia/module-sdk, @ekklesia/module-database, @ekklesia/module-ui-sdk, @ekklesia/design-tokens
Tooling @ekklesia/module-ui-build, @ekklesia/module-pack, @ekklesia/typescript-config
Pre-1.0 component API @ekklesia/ui: publishable, explicitly unstable before 1.0
Implementation dependency only @ekklesia/contracts: may be published because the packages above depend on it, but it is not a supported direct module-author API
Never published @ekklesia/core, the applications, @ekklesia/module-ui-host, @ekklesia/module-template, and every other platform internal

A curated SDK surface

Before the first external publication, the author-facing SDK is narrowed.

  • A curated API. @ekklesia/module-sdk exposes what a module author needs, and no longer re-exports the complete @ekklesia/contracts barrel.
  • No forced Drizzle. The stable SDK root does not make Drizzle a dependency of modules that do not use it.
  • No legacy database contract. The legacy Drizzle database contract (ADR 0016's facade type) is not part of the standalone author-facing surface. Core still serves the legacy facade for its deprecation window (ADR 0050). This decision only keeps its type out of what new standalone modules build against.
  • Drizzle support lives in one place. Drizzle-specific helpers belong to the @ekklesia/module-database Drizzle adapter (ADR 0050).

Dependency rules

These rules apply to the published packages:

  • React and ReactDOM are peer dependencies of the public UI packages, plus development dependencies for those packages' own builds and tests. This does not change ADR 0023: a module UI artifact still bundles React. The peer only ensures there is one React in that bundle, the module's own.
  • Drizzle is an optional peer dependency only where the Drizzle adapter needs it.
  • Real implementation dependencies, at run time or build time, remain normal dependencies.

These rules apply to a module repository:

  • Runtime and module-code packages go in dependencies: the Author API packages, @ekklesia/ui, React, and the module's ORM if it uses one.
  • Build tooling and configuration go in devDependencies: @ekklesia/module-ui-build, @ekklesia/module-pack, @ekklesia/typescript-config, and type packages.

Versioning

  • One coordinated version during 0.x. Every public Ekklesia module-development package uses one release version, equal to the platform release version. The version the platform reports for moduleSdk compatibility is therefore the version of the packages a module was built with.
  • No mixed trains. Within a published train, Ekklesia-owned dependencies name the coordinated, tested version, never a version from another train.
  • Ranges in source, exact artifacts in lockfiles. A module may declare compatible ranges in its source package.json; its lockfile pins exact artifacts with their integrity hashes.
  • @ekklesia/module-pack is pinned exactly. A module repository pins it in its source package.json, not only in its lockfile, because its version affects the package it produces.
  • Independent package versions are deferred.

Standalone packaging

  • No monorepo checkout. A standalone module builds and packages from its own repository and its installed dependencies.
  • Its own Node.js range. The module declares its own Node.js compatibility, preferably in engines.node, and the canonical packager reads it from there or from an explicit option. It never requires an enclosing pnpm-workspace.yaml.
  • Ekklesia packages recognized by identity. The canonical packager recognizes Ekklesia-owned installed packages by explicit package identity and metadata, never by workspace path. So incorporates and the Module SDK compatibility check (ADR 0049) work the same whether a package came from the monorepo or from a registry. Ekklesia packages are never reported as third-party software (ADR 0051).
  • No monorepo-root commands at install time. Publishing workflows for the public packages run their build in the platform's release pipeline. Nothing in a published package runs a monorepo-root command when a consumer installs or packs it.

Supply chain

  • Immutable versions. A version is never republished with other content, and never withdrawn to be reused.

  • Lockfiles. pnpm lockfile integrity is part of the reproducibility model: modules install with a frozen lockfile.

  • Release CI proves the tarballs before publication. It must:

    1. build and pack the public package set;
    2. inspect every tarball: only the intended contents, a LICENSE, and no workspace: range;
    3. install those tarballs into a clean standalone module fixture, outside any monorepo;
    4. prove there that the fixture builds, packages with the canonical packager, and verifies.

    Only then are the versions published.

  • Signing and provenance are deferred.

Registry access and the private phase

  • No secrets in module repositories. Registry configuration for the scope is normal npm/pnpm configuration.
  • Credentials from the environment. Read and publish credentials come from environment or user configuration: a read credential in module CI where reads need one, and a publish credential only in Ekklesia's release pipeline.
  • No permanent provider.
  • Verify access before the first publication. Check the chosen registry's actual private and read-access behaviour first. Ekklesia's packages must not be published unintentionally to a registry that serves them anonymously while Ekklesia is not public.

The release workflow

During 0.x, public package releases form a coordinated SDK train associated with platform releases:

  1. build;
  2. pack;
  3. standalone tarball smoke (see Supply chain);
  4. publish immutable versions;
  5. downstream module CI installs and tests those versions.

Signage

The intended result is a normal standalone Signage repository:

  • versioned @ekklesia/* dependencies;
  • its own lockfile;
  • continuous integration that installs, builds, packages, and verifies on its own.

That CI must not clone Ekklesia or place Signage under modules/. The migration itself is a separate change.

Consequences

  • A module repository is an ordinary pnpm project. Clone it, install, build, and package it with the canonical packager, with no Ekklesia checkout.
  • Module authors see a smaller, deliberate API, and the platform can change its internal contracts without breaking them.
  • One coordinated version keeps compatibility simple: SDK version X is what platform version X reports and accepts. The cost is republishing unchanged packages with each platform release.
  • A registry move costs each module a configuration change and a lockfile regeneration, but no source change. Package content stays verifiable by integrity hash throughout.
  • Before the first publication, the SDK surface must be curated and the packager's two monorepo assumptions removed. Both are breaking for code that relied on them.
  • ADR 0047, still proposed, assembles a module into the platform workspace to test it. Before it is accepted it must instead consume published packages and canonical module packages, consistent with this decision and with ADR 0048.

Alternatives

  • Git URL, tag, or commit dependencies. Rejected: they tie modules to one Git host, need a build on every install because dist is not committed, and cannot express version ranges.
  • Release-asset tarball URLs. Rejected: each dependency becomes a host-specific URL, and ranges cannot be resolved.
  • OCI artifacts for packages. Rejected: npm and pnpm cannot install from them. (A harness image is a different matter; see ADR 0047.)
  • Vendoring Ekklesia packages into each module repository. Rejected: the copies drift, updates are manual, and there would be no single canonical SDK.
  • Requiring host-neutral lockfiles, or an Ekklesia registry proxy that rewrites tarball URLs. Not required: a one-time lockfile regeneration on a registry move is an acceptable cost, and integrity hashes already keep content verifiable. Ekklesia may still choose a stable endpoint later.
  • Publishing @ekklesia/contracts as a direct author API. Rejected: it holds platform-wide contracts. Modules get a curated surface through the SDK packages.
  • Externalizing React from module UI bundles to share one runtime. Rejected again: ADR 0023's reasoning stands, and peers only prevent duplicates within one bundle.
  • Independent versions per package now. Deferred: during 0.x a coordinated train is simpler for module authors and matches how the platform reports its SDK version.

Deferred

  • Provisioning a registry, and its credentials.
  • Public publication on npmjs or any other public registry.
  • Package signing and provenance.
  • Independent package versioning.
  • Dashboard and UI redesign.
  • The Signage migration itself.