CI / verify (pull_request) Successful in 1m4s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / package-release (pull_request) Successful in 32s
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 1m32s
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]>
195 lines
11 KiB
Markdown
195 lines
11 KiB
Markdown
# 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-<version>/
|
|
```
|
|
|
|
`scripts/package-release.ts`:
|
|
|
|
1. **Refuses a modified source tree, before it builds anything.** A release is built from a
|
|
commit. A tree is clean when `git status` shows 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-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. 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.
|
|
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-<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.
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```bash
|
|
pnpm test:package-release # scripts/test-package-release.sh
|
|
```
|
|
|
|
1. **Builds the train** from this checkout. Nothing is built before it.
|
|
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. **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 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 `<name>@<version>`.
|
|
- 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.
|