# Releases and the public package train An Ekklesia platform release is one version. The platform image, the private workspace packages, and the public `@ekklesia/*` module-development packages all carry it. This page covers the order a release happens in and the public package train ([ADR 0053](../architecture/decisions/0053-standalone-module-development-packages.md)): how its artifacts are produced, what proves them, and what a future publication step will do with them. > **Registry publication is not implemented.** Nothing in this repository publishes a package, and > no CI job holds a registry credential. The release artifacts below are produced and proven on > every CI run. A future publication step publishes **exactly these artifacts**, unchanged, and > nothing else. ## Where each step sits ```text version set ─▶ build ─▶ test ─▶ pack the public package train ─▶ standalone consumer proof │ tag and release (maintainer) ◀─ [future: publish] ◀──┘ ``` 1. **Version set.** A `chore(release): prepare X` pull request sets the release version: the root `package.json`, every platform workspace package (the module template versions as a module, not with the platform), the image build example in `deploy/README.md`, and the standalone fixture's exact `@ekklesia/module-pack` pin (`tests/fixtures/standalone-module/package.json`). The tag must point at a commit that carries the version it names. 2. **Build and test.** `pnpm verify`, the integration suite, and the deploy smoke, as for any change. 3. **Pack the public package train.** `pnpm packages:release` (below). 4. **Standalone consumer proof.** A module outside the repository consumes exactly those artifacts (below). 5. **[Future] publish.** Not implemented. Once it is, it must run only after steps 3 and 4 passed for the same commit. It must check every artifact against the release manifest before publishing it, and publish nothing else. 6. **Tag and release.** Unchanged: the maintainer tags the merge commit of the version-set pull request. Nothing here creates, moves, or requires a tag. Steps 2 to 4 run in CI on every pull request and on `main`. The `package-release` job is the gate a future publication job must depend on. ## The public package train | Package | Role | | --- | --- | | `@ekklesia/module-sdk`, `@ekklesia/module-database`, `@ekklesia/module-ui-sdk`, `@ekklesia/design-tokens` | author API | | `@ekklesia/module-ui-build`, `@ekklesia/module-pack`, `@ekklesia/typescript-config` | tooling | | `@ekklesia/ui` | publishable, pre-1.0 component API | | `@ekklesia/contracts` | implementation dependency of the packages above: published only because they need it, never a supported direct dependency of a module | The list lives in `scripts/package-release.ts` (`PUBLIC_PACKAGES`). Every other workspace package is `private`, and the release builder refuses to run if a package outside the list is publishable or carries the `module-development` distribution marker. - **One version.** Every package in the train has the platform release version. - **One train.** Every `@ekklesia/*` dependency in a packed manifest names a package of the same train at exactly that version. `workspace:` ranges become those exact versions when packed. - **No independent versions.** A package cannot be released on its own. ## Producing the release artifacts ```bash pnpm packages:release # writes .package-release/ekklesia-packages-/ ``` `scripts/package-release.ts`: 1. **Refuses a modified source tree.** A release is built from a commit. `--allow-modified` exists for local development only; its manifest then says `"modified": true`, and such a release must never be published. 2. **Checks the release set and versions** in the workspace. 3. **Rebuilds from clean output.** It removes each public package's `dist/` and rebuilds without the build cache, so output from a deleted source file can never ship. 4. **Packs each package with `pnpm pack`.** Pack-only pnpm hooks (`scripts/lib/pack-hooks.cjs`) apply, never the workspace's install settings ([Reproducibility](#reproducibility)). 5. **Inspects every tarball.** It checks: - the expected name, version, and file name; - a `LICENSE`, and only the `files` the manifest declares, each built file with the source it came from, and no source, test, or configuration file; - no `workspace:` range, no `devDependencies`, no `publishConfig`, and no install-time lifecycle script; - only public `@ekklesia/*` dependencies, at the train's version; - the distribution marker; - no local path (the checkout, the home directory, or the temporary directory) in any file. 6. **Checks the set.** The release must hold exactly the expected tarballs: none missing, none extra. 7. **Writes the release directory only when everything passed,** in one step. It refuses to overwrite one that exists. ```text ekklesia-packages-/ ├── ekklesia-contracts-.tgz ├── ekklesia-design-tokens-.tgz ├── … one .tgz per package in the train ├── SHA256SUMS `sha256sum -c SHA256SUMS` compatible └── ekklesia-package-train.json the release manifest ``` ### The release manifest `ekklesia-package-train.json` describes the train by content. It is provider-neutral: it names no registry and is not a registry catalog. ```json { "packageTrain": 1, "version": "0.1.2", "source": { "modified": false, "revision": "" }, "packages": [ { "file": "ekklesia-contracts-0.1.2.tgz", "integrity": "sha512-", "name": "@ekklesia/contracts", "role": "implementation", "sha256": "", "size": 87379, "version": "0.1.2" } ] } ``` `integrity` is the npm `dist.integrity` form: a registry computes the same value from the same bytes, and a module's lockfile records it. The packages are sorted by name, and the file's bytes depend only on the commit. ## Proving the artifacts ```bash pnpm test:package-release # scripts/test-package-release.sh ``` 1. **Builds the train** from this checkout. 2. **Builds it again** from a second worktree of the same commit at another absolute path, and requires both release directories to be byte-identical: every tarball, `SHA256SUMS`, and the manifest. 3. **Runs the standalone consumer proof** (`scripts/test-standalone-module.sh --release `) on that release: - **The release is checked first.** Every artifact must match the manifest's size, SHA-256, and integrity, and the release must not be built from a modified tree. - **The fixture module installs from those tarballs alone.** It is `tests/fixtures/standalone-module`, copied outside the repository. Every `@ekklesia/*` package, direct or transitive, resolves to its release tarball. There is no workspace link and no Ekklesia source checkout. The install runs offline: third-party packages come from the local pnpm store, so no registry is involved at all. - **The lockfile matches the release.** It must record each artifact's own integrity. The module then reinstalls from the lockfile alone with `--frozen-lockfile`. - **The install has the right shape.** There is no `drizzle-orm`, and exactly one `react`, which `@ekklesia/ui` resolves to as well. `@ekklesia/contracts` is installed only transitively: it is not in the module's `package.json` and not directly under `node_modules/@ekklesia`. Every installed `@ekklesia/*` package, `module-pack` included, is exactly the release version, and the fixture pins `module-pack` exactly. - **The module builds.** It typechecks with `skipLibCheck: false`, so every published declaration it reaches is checked, then builds its backend and UI. - **It packages and verifies.** It is packed with the release's own `ekklesia-pack-module`, with no workspace file present. Core's verifier then verifies it. The package must: - incorporate the train's packages at the release version; - name the release's module-pack as its packager; - credit no Ekklesia package and exactly one React; - contain no local path. ## Reproducibility Two builds of the same commit produce byte-identical tarballs, and CI proves it on every run. - **pnpm normalizes the tar entries.** Every entry gets the fixed modification time 1985-10-26, owner 0, and a mode of 0644, or 0755 when executable. The gzip header records no time. - **The build output is deterministic,** and source maps use relative paths. - **One nondeterminism came from pnpm itself.** pnpm rewrites `workspace:` ranges concurrently and inserts each dependency as its lookup resolves. A package with more than one `workspace:` dependency in one field could therefore be packed with those keys in a different order. Builds from two checkouts of one commit differed in exactly that way: `@ekklesia/module-pack`'s `package.json` ordered `@ekklesia/module-sdk` and `@ekklesia/module-database` differently. - **The fix is pnpm's own hook.** The pack-only `beforePacking` hook puts each dependency map back in the source `package.json`'s key order. It changes no name, range, or field. The tarball stays an ordinary `pnpm pack` tarball. - **The inspection enforces it.** It fails any packed map whose order differs from its source. - **The hook also drops `devDependencies`** from packed manifests. A consumer never installs them, and a tooling package's test-only development dependencies named private workspace packages outside the train. **What reproducibility does not cover:** - The module package the consumer proof builds is deterministic only for a fixed install layout. pnpm names a `file:` tarball's install directory after its path, and bundlers keep those names in comments. So the proof always installs from the same relative path. From a registry, the directory is simply `@`. - Byte identity is proven for the pinned toolchain: Node.js from `.node-version`, and pnpm from `packageManager`. Another Node.js or zlib build may compress differently. Each artifact's integrity is recorded either way, and that, not byte identity, is what a publication step must verify.