The package-release job failed its clean-tree assertion in CI. Reproduced as root in a container with a bind-mounted checkout, as the Gitea runner does: after checkout the tree is clean; `pnpm install --frozen-lockfile` then creates `.pnpm-store/` (17,272 untracked files) at the checkout's root, because pnpm keeps its store on the node_modules file system and the checkout is its own mount; `pnpm build` changes nothing. No tracked file is ever modified. - `.pnpm-store/` is ignored, like node_modules: a package cache, never source. The clean-commit requirement is unchanged for every tracked file and every untracked file Git does not ignore. - The clean-tree failure now lists `git status --short` and `git diff --stat`. - Nothing is built before the clean-source assertion: the gate no longer runs a global `pnpm build` first. The builder asserts a clean tree, builds, then fails if its build changed the tree, before packing. Core's verifier is built after both releases, for the consumer proof only. - Every secondary offline install (the second worktree, the consumer module) uses the workspace install's own store explicitly, since pnpm picks a store per file system and those directories are on another. Co-Authored-By: Claude Opus 5.5 <[email protected]>
11 KiB
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): 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
version set ─▶ build ─▶ test ─▶ pack the public package train ─▶ standalone consumer proof
│
tag and release (maintainer) ◀─ [future: publish] ◀──┘
- Version set. A
chore(release): prepare Xpull request sets the release version: the rootpackage.json, every platform workspace package (the module template versions as a module, not with the platform), the image build example indeploy/README.md, and the standalone fixture's exact@ekklesia/module-packpin (tests/fixtures/standalone-module/package.json). The tag must point at a commit that carries the version it names. - Build and test.
pnpm verify, the integration suite, and the deploy smoke, as for any change. - Pack the public package train.
pnpm packages:release(below). - Standalone consumer proof. A module outside the repository consumes exactly those artifacts (below).
- [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.
- 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
pnpm packages:release # writes .package-release/ekklesia-packages-<version>/
scripts/package-release.ts:
- Refuses a modified source tree, before it builds anything. A release is built from a
commit. A tree is clean when
git statusshows no change to a tracked file and no untracked file that Git does not ignore. Ignored files are build output and package-manager state:node_modules,dist,.turbo, and pnpm's.pnpm-store, which pnpm creates at the checkout's root when the checkout is its own mount (a CI runner's workspace volume). The failure lists every change.--allow-modifiedexists for local development only; its manifest then says"modified": true, and such a release must never be published. - Checks the release set and versions in the workspace.
- 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. It then checks the source tree again: a build that changed a tracked file, or wrote an untracked one Git does not ignore, fails the release, because what is packed must be exactly the commit. - Packs each package with
pnpm pack. Pack-only pnpm hooks (scripts/lib/pack-hooks.cjs) apply, never the workspace's install settings (Reproducibility). - Inspects every tarball. It checks:
- the expected name, version, and file name;
- a
LICENSE, and only thefilesthe manifest declares, each built file with the source it came from, and no source, test, or configuration file; - no
workspace:range, nodevDependencies, nopublishConfig, 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.
- Checks the set. The release must hold exactly the expected tarballs: none missing, none extra.
- Writes the release directory only when everything passed, in one step. It refuses to overwrite one that exists.
ekklesia-packages-<version>/
├── ekklesia-contracts-<version>.tgz
├── ekklesia-design-tokens-<version>.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.
{
"packageTrain": 1,
"version": "0.1.2",
"source": { "modified": false, "revision": "<full commit id>" },
"packages": [
{
"file": "ekklesia-contracts-0.1.2.tgz",
"integrity": "sha512-<base64>",
"name": "@ekklesia/contracts",
"role": "implementation",
"sha256": "<hex>",
"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
pnpm test:package-release # scripts/test-package-release.sh
- Builds the train from this checkout. Nothing is built before it.
- 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. - Builds Core's verifier, then runs the standalone consumer proof (
scripts/test-standalone-module.sh --release <dir>) 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 workspace install's own pnpm store, named explicitly because pnpm otherwise picks a store per file system, 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 onereact, which@ekklesia/uiresolves to as well.@ekklesia/contractsis installed only transitively: it is not in the module'spackage.jsonand not directly undernode_modules/@ekklesia. Every installed@ekklesia/*package,module-packincluded, is exactly the release version, and the fixture pinsmodule-packexactly. - 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 oneworkspace: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'spackage.jsonordered@ekklesia/module-sdkand@ekklesia/module-databasedifferently.- The fix is pnpm's own hook. The pack-only
beforePackinghook puts each dependency map back in the sourcepackage.json's key order. It changes no name, range, or field. The tarball stays an ordinarypnpm packtarball. - The inspection enforces it. It fails any packed map whose order differs from its source.
- The fix is pnpm's own hook. The pack-only
- The hook also drops
devDependenciesfrom 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<name>@<version>. - Byte identity is proven for the pinned toolchain: Node.js from
.node-version, and pnpm frompackageManager. 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.