4 Commits
Author SHA1 Message Date
The_River 1788d17f0a Merge pull request 'fix: hand npm the release artifact's absolute path, so publishing finds it' (#83) from fix/publish-absolute-artifact-path into main
CI / verify (push) Successful in 1m6s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m56s
CI / package-release (push) Successful in 34s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m22s
CI / package-publication (push) Successful in 47s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/83
Reviewed-by: The_River <[email protected]>
2026-10-08 23:22:00 +00:00
ekklesiaandClaude Opus 5.5 1caffc5f26 fix: hand npm the release artifact's absolute path, so publishing finds it
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Successful in 34s
CI / image-build (pull_request) Successful in 24s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
CI / package-publication (pull_request) Successful in 49s
The first real 0.2.0 publish failed on its first package with ENOENT:
"Publishing ekklesia-contracts-0.2.0.tgz failed: npm error code ENOENT".

The publish workflow passes a relative release path,
--release "$(find package-train …)", which is
package-train/ekklesia-packages-0.2.0. The tool validated the artifacts
relative to the job's working directory, but ran npm in its own
temporary home (cwd /tmp/ekklesia-publish-XXXXXX) with the same relative
path, package-train/ekklesia-packages-0.2.0/ekklesia-contracts-0.2.0.tgz,
which there names nothing. The disposable-registry suite always passed
an absolute path, so it never saw this.

- loadReleaseTrain resolves the release directory once; every artifact
  path is absolute from validation on, and npm is given only that exact
  file's absolute path (anything else is refused at the npm boundary).
- Just before each upload the file is checked against the validated
  bytes once more; the tool logs the absolute path it publishes, and a
  failure names it with npm's path/syscall lines (npm's temporary home
  masked, the token never shown).
- Regression tests: unit tests with a recording npm (relative nested
  release from another cwd; npm's argv, cwd, and the file's existence; a
  changed artifact refused before npm runs), which fail on the previous
  tool; and a disposable-Gitea test that runs the CLI exactly as the
  workflow does, from the Actions artifact layout, round-tripping all
  nine packages byte for byte.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 17:51:22 -05:00
The_River debb8df3ce Merge pull request 'chore(release): prepare 0.2.0' (#82) from chore/release-0.2.0 into main
CI / verify (push) Successful in 1m6s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m55s
CI / package-release (push) Successful in 35s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m22s
CI / package-publication (push) Successful in 41s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/82
Reviewed-by: The_River <[email protected]>
2026-10-08 22:36:19 +00:00
ekklesiaandClaude Opus 5.5 98e59d4ba0 chore(release): prepare 0.2.0
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
CI / package-publication (pull_request) Successful in 44s
Every platform workspace package and every public @ekklesia/* package
declares 0.2.0, and the deploy README's image build example names it.
templates/module keeps its own version.

0.2.0, not 0.1.3: before 1.0 a minor version is the breaking one
(docs/development/releases.md, ADR 0054), and since 0.1.2 main has
broken modules, module authors, and operators:
- #64 (ADR 0048): one generic image; installed modules live outside it;
- #66, #72 (ADR 0049, 0050): modules install as Module Package v1, and
  move to the Module Database ABI;
- #71: the manifest v8 cutover; v7 manifests are refused;
- #79 (ADR 0053): the curated SDK drops defineModuleSchema and the
  legacy ModuleDatabase type;
plus #67 (AGPL), #74, #77 (licensing, native targets), and the public
package train (#80, #81). It is also the first package train to publish:
0.1.2 never will be.

Because 0.2.0 is outside ^0.1.0, the same change moves to ^0.2.0:
- the standalone fixture's @ekklesia ranges and its exact module-pack pin;
- the compatibility ranges of the modules built for this release: the
  template, the standalone fixture, and the native proof module;
- the test fixtures that model a module of the current release, or the
  current platform's versions;
- the module-development guide's examples, and the release guide, which
  now names what a minor bump moves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 17:26:13 -05:00
37 changed files with 292 additions and 94 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/api", "name": "@ekklesia/api",
"version": "0.1.2", "version": "0.2.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
+9 -9
View File
@@ -221,9 +221,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
const aggregate = await discoverAllMigrations({ const aggregate = await discoverAllMigrations({
coreMigrationsDirectory, coreMigrationsDirectory,
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.2.0",
modulesDirectory: emptyModulesDirectory, modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0", platformVersion: "0.2.0",
}, },
}); });
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building // Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
@@ -339,7 +339,7 @@ async function createGatedRouteModule(
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id, id,
manifestVersion: 8, manifestVersion: 8,
@@ -390,7 +390,7 @@ async function createFixtureModule(
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
permissions: [], permissions: [],
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
...manifestOverrides, ...manifestOverrides,
@@ -521,7 +521,7 @@ async function createUiModule(
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id, id,
manifestVersion: 8, manifestVersion: 8,
@@ -2592,9 +2592,9 @@ describe.sequential("live API — installed module permission end-to-end", () =>
const report = await diagnoseEkklesia({ const report = await diagnoseEkklesia({
coreMigrationsDirectory, coreMigrationsDirectory,
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.2.0",
modulesDirectory, modulesDirectory,
platformVersion: "0.1.0", platformVersion: "0.2.0",
}, },
runtimeRange: ">=0.0.0", runtimeRange: ">=0.0.0",
runtimeVersion: process.versions.node, runtimeVersion: process.versions.node,
@@ -3906,9 +3906,9 @@ describe.sequential("live API — module lifecycle governs what actually runs",
const aggregate = await discoverAllMigrations({ const aggregate = await discoverAllMigrations({
coreMigrationsDirectory, coreMigrationsDirectory,
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.2.0",
modulesDirectory, modulesDirectory,
platformVersion: "0.1.0", platformVersion: "0.2.0",
}, },
}); });
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]); expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
+1 -1
View File
@@ -118,7 +118,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: { entrypoints: {
display: { artifact: "./dist/ui/display.js", permission: displayPermission }, display: { artifact: "./dist/ui/display.js", permission: displayPermission },
server: "./dist/server/index.js", server: "./dist/server/index.js",
+4 -4
View File
@@ -108,7 +108,7 @@ async function createFixtureModule(
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
permissions: [], permissions: [],
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
...manifestOverrides, ...manifestOverrides,
@@ -267,7 +267,7 @@ function fakeDiscoveredModule(
): DiscoveredModule { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
capabilities: [], capabilities: [],
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
id, id,
manifestVersion: 8, manifestVersion: 8,
@@ -1435,9 +1435,9 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
const recorder = createEventRecorder(); const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true }); await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({ const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.2.0",
modulesDirectory, modulesDirectory,
platformVersion: "0.1.0", platformVersion: "0.2.0",
}); });
mockPoolAndAuth(); mockPoolAndAuth();
mockSuccessfulServe(); mockSuccessfulServe();
@@ -212,9 +212,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
const aggregate = await discoverAllMigrations({ const aggregate = await discoverAllMigrations({
coreMigrationsDirectory, coreMigrationsDirectory,
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.2.0",
modulesDirectory: emptyModulesDirectory, modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0", platformVersion: "0.2.0",
}, },
}); });
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
@@ -241,9 +241,9 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
const aggregate = await discoverAllMigrations({ const aggregate = await discoverAllMigrations({
coreMigrationsDirectory, coreMigrationsDirectory,
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.2.0",
modulesDirectory: fixtureModulesDirectory, modulesDirectory: fixtureModulesDirectory,
platformVersion: "0.1.0", platformVersion: "0.2.0",
}, },
}); });
// Core migrations aggregate first, exactly once each, and always before any module migration. // Core migrations aggregate first, exactly once each, and always before any module migration.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/core", "name": "@ekklesia/core",
"version": "0.1.2", "version": "0.2.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -5,8 +5,8 @@
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.", "description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
"version": "0.1.0", "version": "0.1.0",
"compatibility": { "compatibility": {
"platform": "^0.1.0", "platform": "^0.2.0",
"moduleSdk": "^0.1.0" "moduleSdk": "^0.2.0"
}, },
"permissions": [ "permissions": [
{ {
@@ -35,9 +35,9 @@ async function createModulesDirectory(): Promise<string> {
function discoveryOptions(modulesDirectory: string) { function discoveryOptions(modulesDirectory: string) {
return { return {
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.2.0",
modulesDirectory, modulesDirectory,
platformVersion: "0.1.0", platformVersion: "0.2.0",
}; };
} }
@@ -18,8 +18,8 @@ import {
const platform = { const platform = {
databaseRuntime: { "drizzle-orm": "0.45.2" }, databaseRuntime: { "drizzle-orm": "0.45.2" },
moduleSdkVersion: "0.1.2", moduleSdkVersion: "0.2.0",
platformVersion: "0.1.2", platformVersion: "0.2.0",
} as const; } as const;
const temporaryDirectories: string[] = []; const temporaryDirectories: string[] = [];
+1 -1
View File
@@ -1905,7 +1905,7 @@ describe.sequential("live PostgreSQL migration timeout precedence", () => {
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => { describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const templateModule = path.join(repositoryRoot, "templates", "module"); const templateModule = path.join(repositoryRoot, "templates", "module");
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const; const versions = { moduleSdkVersion: "0.2.0", platformVersion: "0.2.0" } as const;
async function coreWithoutNewestMigration(directory: string): Promise<string> { async function coreWithoutNewestMigration(directory: string): Promise<string> {
const target = path.join(directory, "core-drizzle"); const target = path.join(directory, "core-drizzle");
@@ -54,7 +54,7 @@ export function manifestFor(
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
...(options.dependencies ? { dependencies: options.dependencies } : {}), ...(options.dependencies ? { dependencies: options.dependencies } : {}),
capabilities: [], capabilities: [],
permissions: [], permissions: [],
+2 -2
View File
@@ -180,9 +180,9 @@ export async function modulePackageFixture(
runtime: { node: ">=20" }, runtime: { node: ">=20" },
provenance: { provenance: {
revision: "86ae907f59458508e3277666cdd214f5bd7c1078", revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
packager: { name: "@ekklesia/module-pack", version: "0.1.2" }, packager: { name: "@ekklesia/module-pack", version: "0.2.0" },
}, },
incorporates: { "@ekklesia/module-sdk": "0.1.2" }, incorporates: { "@ekklesia/module-sdk": "0.2.0" },
files: listed, files: listed,
}; };
const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata; const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata;
+2 -2
View File
@@ -500,10 +500,10 @@ finds its files:
```sh ```sh
docker build \ docker build \
--file deploy/Dockerfile \ --file deploy/Dockerfile \
--build-arg VERSION=0.1.2 \ --build-arg VERSION=0.2.0 \
--build-arg REVISION="$(git rev-parse HEAD)" \ --build-arg REVISION="$(git rev-parse HEAD)" \
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \ --build-arg SOURCE=https://git.example.org/your-org/ekklesia \
--tag ekklesia:0.1.2 \ --tag ekklesia:0.2.0 \
. .
``` ```
+18 -9
View File
@@ -25,8 +25,16 @@ version set ─▶ build ─▶ test ─▶ pack the public package train ─▶
1. **Version set.** A `chore(release): prepare X` pull request sets the release version: the root 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 `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 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 exact `@ekklesia/module-pack` pin and `@ekklesia/*` ranges
must point at a commit that carries the version it names. (`tests/fixtures/standalone-module/package.json`). The tag must point at a commit that carries
the version it names.
A minor version (0.x → 0.(x+1)) is a breaking release before 1.0, so manifests that declare
`compatibility.platform` and `compatibility.moduleSdk` as `^0.x.0` no longer match it. In the
same pull request, move to the new range every module built for this release (the template, the
standalone fixture, the native proof module), the test fixtures that model a module of the
current release or the current platform, and the module-development guide's examples. A patch
version changes none of these.
2. **Build and test.** `pnpm verify`, the integration suite, and the deploy smoke, as for any change. 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). 3. **Pack the public package train.** `pnpm packages:release` (below).
4. **Standalone consumer proof.** A module outside the repository consumes exactly those 4. **Standalone consumer proof.** A module outside the repository consumes exactly those
@@ -111,17 +119,17 @@ registry and is not a registry catalog.
```json ```json
{ {
"packageTrain": 1, "packageTrain": 1,
"version": "0.1.2", "version": "0.2.0",
"source": { "modified": false, "revision": "<full commit id>" }, "source": { "modified": false, "revision": "<full commit id>" },
"packages": [ "packages": [
{ {
"file": "ekklesia-contracts-0.1.2.tgz", "file": "ekklesia-contracts-0.2.0.tgz",
"integrity": "sha512-<base64>", "integrity": "sha512-<base64>",
"name": "@ekklesia/contracts", "name": "@ekklesia/contracts",
"role": "implementation", "role": "implementation",
"sha256": "<hex>", "sha256": "<hex>",
"size": 87379, "size": 87379,
"version": "0.1.2" "version": "0.2.0"
} }
] ]
} }
@@ -235,9 +243,10 @@ never builds, packs, or edits a package, and it never deletes, unpublishes, or o
name, size, SHA-256, integrity, `SHA256SUMS` line, and the `package.json` inside it must match. name, size, SHA-256, integrity, `SHA256SUMS` line, and the `package.json` inside it must match.
2. **Publishes in dependency order.** Each package comes after the train packages it depends on, 2. **Publishes in dependency order.** Each package comes after the train packages it depends on,
then alphabetically. For each package: then alphabetically. For each package:
- **absent:** `npm publish <file.tgz>` under the staging dist-tag `ekklesia-staging`. npm sends the - **absent:** `npm publish <file.tgz>` under the staging dist-tag `ekklesia-staging`, given the
file's own bytes and runs no script for a file; the registry checks them against their artifact's absolute path (npm runs in a temporary directory of its own), after the file is
integrity and stores them as they are; checked once more against the manifest. npm sends the file's own bytes and runs no script for a
file; the registry checks them against their integrity and stores them as they are;
- **present with the same bytes:** accepted as already published, and verified; - **present with the same bytes:** accepted as already published, and verified;
- **present with other bytes:** a hard stop. Nothing is deleted or overwritten, nothing after it - **present with other bytes:** a hard stop. Nothing is deleted or overwritten, nothing after it
is published, and the version is burned ([Immutability](#immutability)). is published, and the version is burned ([Immutability](#immutability)).
@@ -343,7 +352,7 @@ public owners, a publisher team, and an outsider. In CI it publishes the very ar
### The first publication ### The first publication
The first train published is the **next** platform release version, not 0.1.2. The `v0.1.2` tag The first train published is **0.2.0**, the next platform release, not 0.1.2. The `v0.1.2` tag
predates the curated SDK and this release tooling, so packages built now and called 0.1.2 would predates the curated SDK and this release tooling, so packages built now and called 0.1.2 would
claim to be source that 0.1.2 never contained. claim to be source that 0.1.2 never contained.
+10 -10
View File
@@ -76,7 +76,7 @@ entrypoints. A module in its own repository depends on the published packages in
> **Status:** the packages are published by release > **Status:** the packages are published by release
> ([ADR 0053](../architecture/decisions/0053-standalone-module-development-packages.md), > ([ADR 0053](../architecture/decisions/0053-standalone-module-development-packages.md),
> [Releases and the public package train](../development/releases.md)). The first published train > [Releases and the public package train](../development/releases.md)). The first published train
> is the next platform release after 0.1.2. > is 0.2.0, the platform release after 0.1.2.
A module does not need an Ekklesia checkout. Do not clone Ekklesia and put your module in its A module does not need an Ekklesia checkout. Do not clone Ekklesia and put your module in its
`modules/` directory: your module is its own repository, and it depends on Ekklesia's `modules/` directory: your module is its own repository, and it depends on Ekklesia's
@@ -131,17 +131,17 @@ import it yourself, because what it exports is the platform's, not a supported a
"build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui" "build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui"
}, },
"dependencies": { "dependencies": {
"@ekklesia/module-database": "^0.1.2", "@ekklesia/module-database": "^0.2.0",
"@ekklesia/module-sdk": "^0.1.2", "@ekklesia/module-sdk": "^0.2.0",
"@ekklesia/module-ui-sdk": "^0.1.2", "@ekklesia/module-ui-sdk": "^0.2.0",
"@ekklesia/ui": "^0.1.2", "@ekklesia/ui": "^0.2.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@ekklesia/module-pack": "0.1.2", "@ekklesia/module-pack": "0.2.0",
"@ekklesia/module-ui-build": "^0.1.2", "@ekklesia/module-ui-build": "^0.2.0",
"@ekklesia/typescript-config": "^0.1.2", "@ekklesia/typescript-config": "^0.2.0",
"typescript": "7.0.2" "typescript": "7.0.2"
} }
} }
@@ -366,8 +366,8 @@ without executing module code:
"name": "Calendar", "name": "Calendar",
"version": "0.1.0", "version": "0.1.0",
"compatibility": { "compatibility": {
"platform": "^0.1.0", "platform": "^0.2.0",
"moduleSdk": "^0.1.0" "moduleSdk": "^0.2.0"
}, },
"dependencies": { "dependencies": {
"modules": { "modules": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ekklesia", "name": "ekklesia",
"version": "0.1.2", "version": "0.2.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/contracts", "name": "@ekklesia/contracts",
"version": "0.1.2", "version": "0.2.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/design-tokens", "name": "@ekklesia/design-tokens",
"version": "0.1.2", "version": "0.2.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/module-database", "name": "@ekklesia/module-database",
"version": "0.1.2", "version": "0.2.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/module-pack", "name": "@ekklesia/module-pack",
"version": "0.1.2", "version": "0.2.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
@@ -5,8 +5,8 @@
"description": "A minimal native module: its backend uses sharp, so it packages for linux-x64-glibc.", "description": "A minimal native module: its backend uses sharp, so it packages for linux-x64-glibc.",
"version": "0.1.0", "version": "0.1.0",
"compatibility": { "compatibility": {
"platform": "^0.1.0", "platform": "^0.2.0",
"moduleSdk": "^0.1.0" "moduleSdk": "^0.2.0"
}, },
"database": { "database": {
"abi": 1 "abi": 1
@@ -183,8 +183,8 @@ describe.runIf(onTargetHost)("the linux-x64-glibc target, proven with sharp", ()
extractTo, extractTo,
platform: { platform: {
databaseRuntime: { "drizzle-orm": "0.45.2" }, databaseRuntime: { "drizzle-orm": "0.45.2" },
moduleSdkVersion: "0.1.2", moduleSdkVersion: "0.2.0",
platformVersion: "0.1.2", platformVersion: "0.2.0",
}, },
}), }),
).resolves.toMatchObject({ moduleId: "native-proof", target: "linux-x64-glibc" }); ).resolves.toMatchObject({ moduleId: "native-proof", target: "linux-x64-glibc" });
@@ -109,7 +109,7 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
id, id,
name: `${id} module`, name: `${id} module`,
version, version,
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
...(options.databaseAbi === 1 ? { database: { abi: 1 } } : {}), ...(options.databaseAbi === 1 ? { database: { abi: 1 } } : {}),
capabilities: [], capabilities: [],
permissions: [], permissions: [],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/module-sdk", "name": "@ekklesia/module-sdk",
"version": "0.1.2", "version": "0.2.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/module-ui-build", "name": "@ekklesia/module-ui-build",
"version": "0.1.2", "version": "0.2.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/module-ui-host", "name": "@ekklesia/module-ui-host",
"version": "0.1.2", "version": "0.2.0",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/module-ui-sdk", "name": "@ekklesia/module-ui-sdk",
"version": "0.1.2", "version": "0.2.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/typescript-config", "name": "@ekklesia/typescript-config",
"version": "0.1.2", "version": "0.2.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"files": [ "files": [
"base.json", "base.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/ui", "name": "@ekklesia/ui",
"version": "0.1.2", "version": "0.2.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+31 -9
View File
@@ -109,6 +109,7 @@ export function tarballPackageJson(tarball: Uint8Array): Record<string, unknown>
export interface LocalPackage { export interface LocalPackage {
readonly entry: TrainPackage; readonly entry: TrainPackage;
/** The artifact's absolute path: the exact file validated against the release manifest. */
readonly path: string; readonly path: string;
readonly data: Buffer; readonly data: Buffer;
readonly packageJson: Readonly<Record<string, unknown>>; readonly packageJson: Readonly<Record<string, unknown>>;
@@ -129,9 +130,12 @@ export async function loadReleaseTrain(
releaseDirectory: string, releaseDirectory: string,
expected: { readonly version: string; readonly revision?: string }, expected: { readonly version: string; readonly revision?: string },
): Promise<LocalTrain> { ): Promise<LocalTrain> {
// Resolved once, here: every artifact path from now on is absolute, so the file npm is handed
// is the file validated below, whatever directory npm itself runs in.
const directory = path.resolve(releaseDirectory);
const files = new Map<string, Buffer>(); const files = new Map<string, Buffer>();
for (const file of await readdir(releaseDirectory)) { for (const file of await readdir(directory)) {
files.set(file, await readFile(path.join(releaseDirectory, file))); files.set(file, await readFile(path.join(directory, file)));
} }
const manifestBytes = files.get(PACKAGE_TRAIN_MANIFEST); const manifestBytes = files.get(PACKAGE_TRAIN_MANIFEST);
if (manifestBytes === undefined) { if (manifestBytes === undefined) {
@@ -156,7 +160,7 @@ export async function loadReleaseTrain(
data, data,
entry, entry,
packageJson: tarballPackageJson(data), packageJson: tarballPackageJson(data),
path: path.join(releaseDirectory, entry.file), path: path.join(directory, entry.file),
}; };
}), }),
}; };
@@ -525,9 +529,9 @@ async function npmSession(registry: string, token: string, npm: string): Promise
const stderr = String((error as { stderr?: unknown }).stderr ?? ""); const stderr = String((error as { stderr?: unknown }).stderr ?? "");
const summary = stderr const summary = stderr
.split("\n") .split("\n")
.filter((line) => /^npm error (?:code|\d{3}|[A-Z])/u.test(line)) .filter((line) => /^npm error (?:code|path|syscall|errno|enoent|\d{3}|[A-Z])/u.test(line))
.filter((line) => !line.includes("complete log")) .filter((line) => !line.includes("complete log"))
.slice(0, 3) .slice(0, 5)
.join(" / ") .join(" / ")
.replaceAll(token, "***") .replaceAll(token, "***")
.replaceAll(home, "<npm home>"); .replaceAll(home, "<npm home>");
@@ -541,13 +545,22 @@ async function npmSession(registry: string, token: string, npm: string): Promise
addTag: (name, version, tag) => addTag: (name, version, tag) =>
run(["dist-tag", "add", `${name}@${version}`, tag], `Moving ${tag} to ${name}@${version}`), run(["dist-tag", "add", `${name}@${version}`, tag], `Moving ${tag} to ${name}@${version}`),
dispose: () => rm(home, { force: true, recursive: true }), dispose: () => rm(home, { force: true, recursive: true }),
publish: (file, tag) => publish: async (file, tag) => {
run( // npm runs in its own temporary home, so a relative path would name a file there, not the
// release artifact. Only the artifact's absolute path is ever passed.
if (!path.isAbsolute(file)) {
throw new PublicationError(
"PUBLISH_FAILED",
`Refusing to publish ${file}: npm is only ever given an artifact's absolute path.`,
);
}
await run(
// A file spec runs no lifecycle script and sends the file's own bytes; --ignore-scripts // A file spec runs no lifecycle script and sends the file's own bytes; --ignore-scripts
// says so explicitly. --access public is what a scoped package needs on a public registry. // says so explicitly. --access public is what a scoped package needs on a public registry.
["publish", file, "--tag", tag, "--access", "public", "--ignore-scripts"], ["publish", file, "--tag", tag, "--access", "public", "--ignore-scripts"],
`Publishing ${path.basename(file)}`, `Publishing ${path.basename(file)} from ${file}`,
), );
},
}; };
} }
@@ -677,6 +690,15 @@ export async function publishPackageTrain(options: PublishOptions): Promise<Publ
let existing = versionOf(await reader.packument(name), version); let existing = versionOf(await reader.packument(name), version);
if (existing === undefined) { if (existing === undefined) {
await options.hooks?.beforePublish?.(name, index); await options.hooks?.beforePublish?.(name, index);
// The file npm reads must still be the validated artifact, byte for byte.
const current = await readFile(local.path).catch(() => undefined);
if (current === undefined || !current.equals(local.data)) {
throw new PublicationError(
"INVALID_RELEASE",
`${local.path} is missing or no longer the artifact validated against the release manifest.`,
);
}
log(`${name}@${version}: publishing ${local.path} (sha256 ${local.entry.sha256})`);
try { try {
await npm.publish(local.path, stagingTag); await npm.publish(local.path, stagingTag);
outcome = "published"; outcome = "published";
+1 -1
View File
@@ -110,7 +110,7 @@ for user in publisher outsider; do
password_variable="${user}_password" password_variable="${user}_password"
api POST /admin/users "{\"username\":\"$user\",\"password\":\"${!password_variable}\",\"email\":\"$user@example.invalid\",\"must_change_password\":false}" >/dev/null api POST /admin/users "{\"username\":\"$user\",\"password\":\"${!password_variable}\",\"email\":\"$user@example.invalid\",\"must_change_password\":false}" >/dev/null
done done
owners=(River_of_Glory_Church ekklesia-burned-version ekklesia-tag-retry) owners=(River_of_Glory_Church ekklesia-burned-version ekklesia-tag-retry ekklesia-artifact-layout)
for owner in "${owners[@]}"; do for owner in "${owners[@]}"; do
api POST /orgs "{\"username\":\"$owner\",\"visibility\":\"public\"}" >/dev/null api POST /orgs "{\"username\":\"$owner\",\"visibility\":\"public\"}" >/dev/null
team="$(api POST "/orgs/$owner/teams" '{"name":"package-publishers","includes_all_repositories":false,"can_create_org_repo":false,"units":["repo.packages"],"units_map":{"repo.packages":"write"}}' | team="$(api POST "/orgs/$owner/teams" '{"name":"package-publishers","includes_all_repositories":false,"can_create_org_repo":false,"units":["repo.packages"],"units_map":{"repo.packages":"write"}}' |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/shell", "name": "@ekklesia/shell",
"version": "0.1.2", "version": "0.2.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
+2 -2
View File
@@ -5,8 +5,8 @@
"description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.", "description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.",
"version": "0.1.0", "version": "0.1.0",
"compatibility": { "compatibility": {
"platform": "^0.1.0", "platform": "^0.2.0",
"moduleSdk": "^0.1.0" "moduleSdk": "^0.2.0"
}, },
"database": { "database": {
"abi": 1 "abi": 1
+2 -2
View File
@@ -5,8 +5,8 @@
"description": "Proves a module builds, packages, and verifies from published Ekklesia packages alone.", "description": "Proves a module builds, packages, and verifies from published Ekklesia packages alone.",
"version": "0.1.0", "version": "0.1.0",
"compatibility": { "compatibility": {
"platform": "^0.1.0", "platform": "^0.2.0",
"moduleSdk": "^0.1.0" "moduleSdk": "^0.2.0"
}, },
"database": { "database": {
"abi": 1 "abi": 1
+8 -8
View File
@@ -13,18 +13,18 @@
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json" "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.ui.json"
}, },
"dependencies": { "dependencies": {
"@ekklesia/design-tokens": "^0.1.2", "@ekklesia/design-tokens": "^0.2.0",
"@ekklesia/module-database": "^0.1.2", "@ekklesia/module-database": "^0.2.0",
"@ekklesia/module-sdk": "^0.1.2", "@ekklesia/module-sdk": "^0.2.0",
"@ekklesia/module-ui-sdk": "^0.1.2", "@ekklesia/module-ui-sdk": "^0.2.0",
"@ekklesia/ui": "^0.1.2", "@ekklesia/ui": "^0.2.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@ekklesia/module-pack": "0.1.2", "@ekklesia/module-pack": "0.2.0",
"@ekklesia/module-ui-build": "^0.1.2", "@ekklesia/module-ui-build": "^0.2.0",
"@ekklesia/typescript-config": "^0.1.2", "@ekklesia/typescript-config": "^0.2.0",
"@types/node": "24.13.3", "@types/node": "24.13.3",
"@types/react": "19.2.18", "@types/react": "19.2.18",
"@types/react-dom": "19.2.4", "@types/react-dom": "19.2.4",
+57 -3
View File
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
@@ -28,8 +28,8 @@ const origin = setting("EKKLESIA_TEST_REGISTRY_ORIGIN");
const releaseDirectory = setting("EKKLESIA_TEST_RELEASE"); const releaseDirectory = setting("EKKLESIA_TEST_RELEASE");
const publishToken = setting("EKKLESIA_TEST_PUBLISH_TOKEN"); const publishToken = setting("EKKLESIA_TEST_PUBLISH_TOKEN");
const outsiderToken = setting("EKKLESIA_TEST_OUTSIDER_TOKEN"); const outsiderToken = setting("EKKLESIA_TEST_OUTSIDER_TOKEN");
const [mainOwner, burnedOwner, tagOwner] = setting("EKKLESIA_TEST_OWNERS").split(" "); const [mainOwner, burnedOwner, tagOwner, layoutOwner] = setting("EKKLESIA_TEST_OWNERS").split(" ");
if (!origin || !releaseDirectory || !publishToken || !outsiderToken || !tagOwner) { if (!origin || !releaseDirectory || !publishToken || !outsiderToken || !layoutOwner) {
throw new Error("Run this suite with scripts/test-package-publication.sh."); throw new Error("Run this suite with scripts/test-package-publication.sh.");
} }
const registryOf = (owner: string | undefined) => const registryOf = (owner: string | undefined) =>
@@ -303,3 +303,57 @@ describe("moving the final tag, interrupted and resumed", () => {
} }
}); });
}); });
describe("the publish workflow's own invocation", () => {
it("publishes from the Actions artifact layout, given a relative release path from another directory", async () => {
// Exactly as .github/workflows/publish-packages.yml runs it: the artifact unpacked under
// `package-train/` in the job's workspace, and `--release "$(find package-train …)"`.
const workspace = await mkdtemp(path.join(tmpdir(), "ekklesia-publish-workspace-"));
scratch.push(workspace);
const releaseName = path.basename(releaseDirectory);
await mkdir(path.join(workspace, "package-train"), { recursive: true });
await cp(releaseDirectory, path.join(workspace, "package-train", releaseName), {
recursive: true,
});
const record = path.join(workspace, "publication-record.json");
const { stdout } = await execFileAsync(
process.execPath,
[
path.resolve("scripts/publish-package-train.ts"),
"--release",
`package-train/${releaseName}`,
"--registry",
registryOf(layoutOwner),
"--version",
train.manifest.version,
"--revision",
train.manifest.source.revision,
"--manifest-sha256",
train.manifestSha256,
"--record",
record,
"--require-public-read",
"--allow-http",
],
{
cwd: workspace,
env: { [PUBLISH_TOKEN_VARIABLE]: publishToken, PATH: process.env.PATH ?? "" },
},
);
expect(stdout).toContain(
`publishing ${path.join(workspace, "package-train", releaseName, local("@ekklesia/contracts").entry.file)}`,
);
expect(stdout).not.toContain(publishToken);
const published = JSON.parse(await readFile(record, "utf8")) as PublicationRecord;
expect(published.packages.map((entry) => entry.outcome)).toEqual(Array(9).fill("published"));
for (const name of order) {
const version = (await anonymousPackument(layoutOwner, name))?.versions[
train.manifest.version
];
expect(
(await anonymousBytes(version?.dist.tarball ?? "")).equals(local(name).data),
name,
).toBe(true);
}
});
});
+114 -1
View File
@@ -1,4 +1,4 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { chmod, cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -462,6 +462,119 @@ describe("the registry and its credentials", () => {
}); });
}); });
describe("the artifact npm is given", () => {
/**
* The release where the Actions artifact puts it, `<workspace>/package-train/<release>`, named
* relatively from the current directory, which is not the release directory: exactly how the
* publish workflow called the tool when npm was handed a path relative to its own temporary home.
*/
async function nestedRelease(): Promise<{ relative: string; absolute: string }> {
const workspace = await mkdtemp(path.join(tmpdir(), "ekklesia-workspace-"));
scratch.push(workspace);
const absolute = path.join(workspace, "package-train", `ekklesia-packages-${VERSION}`);
await mkdir(path.dirname(absolute), { recursive: true });
await cp(await releaseDirectory(), absolute, { recursive: true });
const relative = path.relative(process.cwd(), absolute);
expect(path.isAbsolute(relative)).toBe(false);
expect(process.cwd()).not.toBe(absolute);
return { absolute, relative };
}
/** An npm that records what it was given, and where it ran, then fails as a registry would. */
async function recordingNpm(): Promise<{
npm: string;
calls: () => Promise<{ argv: string[]; cwd: string; exists: boolean }[]>;
}> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-fake-npm-"));
scratch.push(directory);
const log = path.join(directory, "calls.jsonl");
const npm = path.join(directory, "npm");
await writeFile(
npm,
`#!/usr/bin/env node
const { appendFileSync, existsSync } = require("node:fs");
const argv = process.argv.slice(2);
appendFileSync(${JSON.stringify(log)}, JSON.stringify({ argv, cwd: process.cwd(), exists: existsSync(argv[1] ?? "") }) + "\\n");
process.stderr.write("npm error code E503\\nnpm error 503 Service Unavailable\\n");
process.exit(1);
`,
);
await chmod(npm, 0o755);
return {
calls: async () =>
(await readFile(log, "utf8").catch(() => ""))
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line)),
npm,
};
}
const notFound = (async () => new Response("{}", { status: 404 })) as typeof fetch;
it("resolves a relative, nested release to absolute artifact paths when it loads the train", async () => {
const { absolute, relative } = await nestedRelease();
const train = await loadReleaseTrain(relative, { revision: REVISION, version: VERSION });
for (const local of train.packages) {
expect(local.path).toBe(path.join(absolute, local.entry.file));
expect(path.isAbsolute(local.path)).toBe(true);
expect((await readFile(local.path)).equals(local.data)).toBe(true);
}
});
it("hands npm the exact validated tarball by its absolute path, from npm's own directory, and says which", async () => {
const { absolute, relative } = await nestedRelease();
const { calls, npm } = await recordingNpm();
const error = await publishPackageTrain({
fetch: notFound,
npm,
registry: REGISTRY,
releaseDirectory: relative,
revision: REVISION,
token: "publish-token-value",
version: VERSION,
}).catch((caught: unknown) => caught);
expect(error).toMatchObject({ code: "PUBLISH_FAILED" });
const first = tarballName("@ekklesia/contracts", VERSION);
const expected = path.join(absolute, first);
// The failure names the exact file npm was given, and never the token.
expect((error as Error).message).toContain(expected);
expect((error as Error).message).toContain("E503");
expect((error as Error).message).not.toContain("publish-token-value");
const [call] = await calls();
expect(call?.argv.slice(0, 2)).toEqual(["publish", expected]);
expect(call?.exists).toBe(true);
// npm ran in a directory of its own, where a relative path would have named nothing.
expect(call?.cwd).not.toBe(absolute);
expect(path.isAbsolute(call?.cwd ?? "")).toBe(true);
expect(JSON.stringify(call)).not.toContain("publish-token-value");
});
it("refuses to publish an artifact that changed after it was validated, before npm runs", async () => {
const { absolute, relative } = await nestedRelease();
const { calls, npm } = await recordingNpm();
const file = path.join(absolute, tarballName("@ekklesia/contracts", VERSION));
await expect(
publishPackageTrain({
fetch: notFound,
hooks: {
beforePublish: async (_name, index) => {
if (index === 0) await writeFile(file, "changed after validation");
},
},
npm,
registry: REGISTRY,
releaseDirectory: relative,
revision: REVISION,
token: "publish-token-value",
version: VERSION,
}),
).rejects.toMatchObject({ code: "INVALID_RELEASE" });
expect(await calls()).toEqual([]);
});
});
describe("what the publisher may do to a registry", () => { describe("what the publisher may do to a registry", () => {
it("never deletes, unpublishes, or overwrites: it only publishes new versions and moves a tag", async () => { it("never deletes, unpublishes, or overwrites: it only publishes new versions and moves a tag", async () => {
// Code only: the tool's comments may say what it never does. // Code only: the tool's comments may say what it never does.