Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 2d861c6bf0 chore: publish and document packages at the registry's current Zayin location
CI / verify (pull_request) Successful in 1m17s
CI / infrastructure-smoke (pull_request) Successful in 31s
CI / package-release (pull_request) Successful in 49s
CI / image-build (pull_request) Successful in 59s
CI / migration-smoke (pull_request) Successful in 2m16s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m39s
The package registry moved with the repositories to
git.zayin.net/api/packages/Zayin/npm/; the old River_of_Glory_Church address
now answers 502, so the next manual Publish packages run would have failed.
Point the workflow, the release and module-development docs, and the
disposable-Gitea publication proof's owner at Zayin. The CI Docker label keys
keep their names: they are identifiers the sweep matches on, not hosts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 22:23:52 -05:00
The_River ae976ca229 Merge pull request 'chore(release): prepare 0.2.4' (#91) from chore/release-0.2.4 into main
CI / verify (push) Successful in 1m13s
CI / infrastructure-smoke (push) Successful in 38s
CI / package-release (push) Successful in 49s
CI / image-build (push) Successful in 30s
CI / migration-smoke (push) Successful in 2m13s
CI / deploy-smoke (same-origin) (push) Successful in 47s
CI / package-publication (push) Successful in 1m4s
CI / deploy-smoke (split) (push) Successful in 2m23s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/91
Reviewed-by: The_River <[email protected]>
2026-10-10 17:09:10 +00:00
ekklesia f9585cc7ec chore(release): prepare 0.2.4
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m51s
CI / package-release (pull_request) Successful in 34s
CI / image-build (pull_request) Successful in 59s
CI / deploy-smoke (same-origin) (pull_request) Successful in 47s
CI / deploy-smoke (split) (pull_request) Successful in 2m21s
CI / package-publication (pull_request) Successful in 49s
2026-10-10 11:54:00 -05:00
The_River 6dae29412f Merge pull request 'test(deploy): exercise external module packages' (#90) from test/external-module-deploy-smoke into main
CI / verify (push) Successful in 1m3s
CI / infrastructure-smoke (push) Successful in 35s
CI / migration-smoke (push) Successful in 1m54s
CI / package-release (push) Successful in 34s
CI / image-build (push) Successful in 26s
CI / deploy-smoke (same-origin) (push) Successful in 40s
CI / deploy-smoke (split) (push) Successful in 2m13s
CI / package-publication (push) Successful in 51s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/90
Reviewed-by: The_River <[email protected]>
2026-10-10 16:32:32 +00:00
ekklesia 40ef0ff802 fix(deploy): bound object storage allocation size
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 50s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 2m19s
CI / package-publication (pull_request) Successful in 52s
2026-10-10 11:07:53 -05:00
ekklesia a0e9948820 fix(storage): recognize errors across module packages
CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 29s
CI / migration-smoke (pull_request) Successful in 1m44s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 53s
CI / deploy-smoke (same-origin) (pull_request) Successful in 44s
CI / deploy-smoke (split) (pull_request) Successful in 2m20s
CI / package-publication (pull_request) Successful in 51s
2026-10-10 10:54:13 -05:00
ekklesia 6e64b3c5f9 test(deploy): allow slower display uploads
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m57s
CI / package-release (pull_request) Successful in 37s
CI / image-build (pull_request) Successful in 54s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 2m21s
CI / package-publication (pull_request) Successful in 52s
2026-10-09 22:37:18 -05:00
ekklesia fa27603133 test(deploy): exercise external module packages
CI / verify (pull_request) Successful in 1m13s
CI / infrastructure-smoke (pull_request) Successful in 40s
CI / migration-smoke (pull_request) Successful in 1m56s
CI / package-release (pull_request) Successful in 37s
CI / image-build (pull_request) Successful in 54s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 2m22s
CI / package-publication (pull_request) Successful in 55s
2026-10-09 22:18:04 -05:00
29 changed files with 374 additions and 122 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ concurrency:
env: env:
# The only registry-specific setting: where the train is published. # The only registry-specific setting: where the train is published.
EKKLESIA_PACKAGES_REGISTRY: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ EKKLESIA_PACKAGES_REGISTRY: https://git.zayin.net/api/packages/Zayin/npm/
jobs: jobs:
package-release: package-release:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/api", "name": "@ekklesia/api",
"version": "0.2.3", "version": "0.2.4",
"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/core", "name": "@ekklesia/core",
"version": "0.2.3", "version": "0.2.4",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
+12
View File
@@ -1,5 +1,6 @@
import { import {
EMPTY_MODULE_STORAGE, EMPTY_MODULE_STORAGE,
isModuleStorageError,
isValidModuleStorageKey, isValidModuleStorageKey,
MODULE_STORAGE_KEY_MAX_BYTES, MODULE_STORAGE_KEY_MAX_BYTES,
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS, MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
@@ -322,6 +323,17 @@ describe("module storage scoping", () => {
} }
}); });
it("recognizes a storage error from an installed module's separate package copy", () => {
const external = Object.assign(new Error("The storage service could not be reached."), {
code: "STORAGE_PROVIDER_UNAVAILABLE",
name: "ModuleStorageError",
});
expect(external).not.toBeInstanceOf(ModuleStorageError);
expect(isModuleStorageError(external)).toBe(true);
expect(isModuleStorageError(Object.assign(new Error("no"), { code: "UNKNOWN" }))).toBe(false);
});
it("gives a module no way to reach another module's storage by knowing its name", async () => { it("gives a module no way to reach another module's storage by knowing its name", async () => {
const shared = fakeProvider(); const shared = fakeProvider();
const announcements = createModuleStorage({ const announcements = createModuleStorage({
+2 -2
View File
@@ -511,10 +511,10 @@ finds its files:
```sh ```sh
docker build \ docker build \
--file deploy/Dockerfile \ --file deploy/Dockerfile \
--build-arg VERSION=0.2.3 \ --build-arg VERSION=0.2.4 \
--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.2.3 \ --tag ekklesia:0.2.4 \
. .
``` ```
+4
View File
@@ -132,6 +132,10 @@ services:
command: command:
- mini - mini
- -dir=/data - -dir=/data
# A bucket is a SeaweedFS collection and claims a block of volumes. The ~1 GB automatic
# volume size leaves modest hosts with room for only a few collections; after that, writes
# stall instead of failing. This changes allocation granularity, not object or total capacity.
- -master.volumeSizeLimitMB=32
- -s3.port.https=8443 - -s3.port.https=8443
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt - -s3.cert.file=/run/ekklesia/tls/object-storage.crt
- -s3.key.file=/run/ekklesia/tls/object-storage.key - -s3.key.file=/run/ekklesia/tls/object-storage.key
+6 -6
View File
@@ -207,10 +207,10 @@ Two builds of the same commit produce byte-identical tarballs, and CI proves it
## Publishing the train ## Publishing the train
The registry is the Gitea npm registry under the `River_of_Glory_Church` owner: The registry is the Gitea npm registry under the `Zayin` owner:
```text ```text
https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ https://git.zayin.net/api/packages/Zayin/npm/
``` ```
| Who | May | | Who | May |
@@ -272,7 +272,7 @@ Evidence that one train was published, not a second package catalog, and never a
{ {
"publicationRecord": 1, "publicationRecord": 1,
"packageTrain": { "version": "0.2.0", "revision": "<commit>", "manifestSha256": "<hex>" }, "packageTrain": { "version": "0.2.0", "revision": "<commit>", "manifestSha256": "<hex>" },
"registry": "https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/", "registry": "https://git.zayin.net/api/packages/Zayin/npm/",
"finalTag": "latest", "finalTag": "latest",
"completedAt": "<ISO time>", "completedAt": "<ISO time>",
"packages": [ "packages": [
@@ -297,7 +297,7 @@ Evidence that one train was published, not a second package catalog, and never a
- **One credential:** `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, a repository Actions secret, used only by - **One credential:** `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, a repository Actions secret, used only by
the publish step. Nobody needs a token to read or install. the publish step. Nobody needs a token to read or install.
- **The publisher** is a dedicated account, member only of a `River_of_Glory_Church` team whose one - **The publisher** is a dedicated account, member only of a `Zayin` team whose one
permission is Packages: Write. Its token has only the `write:package` scope. Gitea's permission is Packages: Write. Its token has only the `write:package` scope. Gitea's
`write:package` also allows deleting packages; the tool never does, and nothing else uses the `write:package` also allows deleting packages; the tool never does, and nothing else uses the
token. token.
@@ -319,7 +319,7 @@ guarantee:
- **A published version is never deleted,** by anyone, for any reason short of a security incident. - **A published version is never deleted,** by anyone, for any reason short of a security incident.
- **A version number is never reused with other bytes.** A version found on the registry with other - **A version number is never reused with other bytes.** A version found on the registry with other
bytes is burned: investigate how it got there, and publish the train under a new version. bytes is burned: investigate how it got there, and publish the train under a new version.
- **No package cleanup rule** may be configured for the `River_of_Glory_Church` owner. - **No package cleanup rule** may be configured for the `Zayin` owner.
- **The publisher never deletes.** The tool has no delete, unpublish, or overwrite, and its tests - **The publisher never deletes.** The tool has no delete, unpublish, or overwrite, and its tests
check that. check that.
@@ -359,7 +359,7 @@ claim to be source that 0.1.2 never contained.
Before it, an administrator of the Gitea instance: Before it, an administrator of the Gitea instance:
1. creates the dedicated publisher account; 1. creates the dedicated publisher account;
2. creates a `River_of_Glory_Church` team, for example `package-publishers`, with only the Packages 2. creates a `Zayin` team, for example `package-publishers`, with only the Packages
unit at Write, and adds the account to it; unit at Write, and adds the account to it;
3. creates a token for the account with only `write:package`, and stores it as the repository 3. creates a token for the account with only `write:package`, and stores it as the repository
Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`; Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`;
+1 -1
View File
@@ -89,7 +89,7 @@ promises no API stability beyond the pre-1.0 SemVer policy. Only Ekklesia's rele
the registry in your repository's committed `.npmrc`; that is all the configuration there is: the registry in your repository's committed `.npmrc`; that is all the configuration there is:
```ini ```ini
@ekklesia:registry=https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/ @ekklesia:registry=https://git.zayin.net/api/packages/Zayin/npm/
``` ```
Then the module builds and packages like any other: Then the module builds and packages like any other:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ekklesia", "name": "ekklesia",
"version": "0.2.3", "version": "0.2.4",
"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.2.3", "version": "0.2.4",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+22
View File
@@ -87,6 +87,28 @@ export class ModuleStorageError extends Error {
} }
} }
/**
* Whether `value` is a module-storage error, judged by its stable public shape rather than its class.
*
* An installed module bundles its own copy of the contracts package (ADR 0049), so an error created
* by the host's storage provider does not share class identity with the module's
* `ModuleStorageError`. `instanceof ModuleStorageError` is therefore not a valid check across the
* module boundary.
*/
export function isModuleStorageError(
value: unknown,
): value is Pick<ModuleStorageError, "code" | "key" | "message" | "name"> {
if (!(value instanceof Error) || value.name !== "ModuleStorageError") {
return false;
}
const { code, key } = value as { code?: unknown; key?: unknown };
return (
typeof code === "string" &&
(MODULE_STORAGE_ERROR_CODES as readonly string[]).includes(code) &&
(key === undefined || typeof key === "string")
);
}
/** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes, /** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes,
* not characters, because that is what the wire format bounds. */ * not characters, because that is what the wire format bounds. */
export const MODULE_STORAGE_KEY_MAX_BYTES = 1024; export const MODULE_STORAGE_KEY_MAX_BYTES = 1024;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/design-tokens", "name": "@ekklesia/design-tokens",
"version": "0.2.3", "version": "0.2.4",
"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.2.3", "version": "0.2.4",
"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.2.3", "version": "0.2.4",
"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-sdk", "name": "@ekklesia/module-sdk",
"version": "0.2.3", "version": "0.2.4",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+1
View File
@@ -37,6 +37,7 @@ export {
isDevicePrincipal, isDevicePrincipal,
isModuleHttpMethod, isModuleHttpMethod,
isModuleRouteBodyMode, isModuleRouteBodyMode,
isModuleStorageError,
isValidModuleStorageKey, isValidModuleStorageKey,
isValidModuleStorageReadUrlFilename, isValidModuleStorageReadUrlFilename,
MODULE_HTTP_BODY_MODES, MODULE_HTTP_BODY_MODES,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/module-ui-build", "name": "@ekklesia/module-ui-build",
"version": "0.2.3", "version": "0.2.4",
"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.2.3", "version": "0.2.4",
"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.2.3", "version": "0.2.4",
"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.2.3", "version": "0.2.4",
"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.2.3", "version": "0.2.4",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
+34 -16
View File
@@ -35,6 +35,10 @@ function required(name) {
return value; return value;
} }
function optional(name) {
return process.env[name] || "";
}
let failures = 0; let failures = 0;
function check(condition, description) { function check(condition, description) {
@@ -462,36 +466,50 @@ async function ownerCookie() {
} }
/** /**
* The installed-module checks (ADR 0048): whether `example` is in the module inventory, and what * The installed-module checks (ADR 0048): whether the selected module is in the UI inventory and,
* its own route returns. Absent, it is in no inventory and its routes are not mounted at all; * when one is supplied, whether its module-owned probe route responds. Core's own `example` fixture
* present, its records route returns exactly the expected values, which proves its tables, its * retains its stronger records assertion, which also proves its database facade.
* dependencies, and the platform's database facade all work from module storage.
*/ */
async function moduleChecks() { async function moduleChecks() {
const expectation = required("SMOKE_MODULE_EXPECT"); const expectation = required("SMOKE_MODULE_EXPECT");
const moduleId = optional("SMOKE_MODULE_ID") || "example";
const probePath = optional("SMOKE_MODULE_PROBE_PATH");
const cookie = await ownerCookie(); const cookie = await ownerCookie();
const headers = { cookie, origin: dashboardOrigin }; const headers = { cookie, origin: dashboardOrigin };
const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers }); const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers });
const inventoryBody = await inventory.json(); const inventoryBody = await inventory.json();
const installed = const installed =
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === "example"); inventory.status === 200 && inventoryBody.modules.some((module) => module.id === moduleId);
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
if (expectation === "absent") { if (expectation === "absent") {
check(inventory.status === 200 && !installed, "the module inventory has no `example` module");
check( check(
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}', inventory.status === 200 && !installed,
"the absent module's routes are not mounted", `the module inventory has no \`${moduleId}\` module`,
); );
} else { } else {
const expected = JSON.parse(expectation); check(installed, `the module inventory lists the \`${moduleId}\` module`);
check(installed, "the module inventory lists the `example` module"); }
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
check( if (moduleId === "example") {
JSON.stringify(values) === JSON.stringify(expected), const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
`the module's records route returns ${JSON.stringify(expected)}`, if (expectation === "absent") {
); check(
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
"the absent module's routes are not mounted",
);
} else {
const expected = JSON.parse(expectation);
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
check(
JSON.stringify(values) === JSON.stringify(expected),
`the module's records route returns ${JSON.stringify(expected)}`,
);
}
} else if (expectation !== "absent" && probePath) {
const probe = await fetch(`${apiOrigin}${probePath}`, { headers });
await probe.arrayBuffer();
check(probe.status === 200, `the module probe ${probePath} responds`);
} }
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, { const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
+42 -17
View File
@@ -426,23 +426,48 @@ async function seedSignage(owner) {
const base = "/api/v1/modules/signage"; const base = "/api/v1/modules/signage";
const images = []; const images = [];
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) { for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
const uploaded = await owner.evaluate( let uploaded;
`(async () => { for (let attempt = 1; attempt <= 2; attempt += 1) {
const canvas = document.createElement("canvas"); uploaded = await owner.evaluate(
canvas.width = 640; `(async () => {
canvas.height = 360; const canvas = document.createElement("canvas");
const context = canvas.getContext("2d"); canvas.width = 640;
context.fillStyle = ${JSON.stringify(colour)}; canvas.height = 360;
context.fillRect(0, 0, 640, 360); const context = canvas.getContext("2d");
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); context.fillStyle = ${JSON.stringify(colour)};
const response = await fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, { context.fillRect(0, 0, 640, 360);
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST", const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
signal: AbortSignal.timeout(${requestTimeoutMs}), return fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
}); body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
return { status: response.status, body: await response.json().catch(() => null) }; signal: AbortSignal.timeout(${requestTimeoutMs}),
})()`, }).then(
`uploading slide ${index}`, async (response) => ({ status: response.status, body: await response.json().catch(() => null) }),
); (error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }),
);
})()`,
`uploading slide ${index}`,
);
if (
attempt === 1 &&
uploaded.status === 503 &&
uploaded.body?.code === "SIGNAGE_STORAGE_UNAVAILABLE"
) {
note(`slide ${index} met one transient storage outage; retrying once`);
await sleep(1_000);
continue;
}
break;
}
if (uploaded.status === "timeout") {
throw new HangError(`HTTP POST ${base}/images from page "owner"`, requestTimeoutMs);
}
if (uploaded.status !== 200 && uploaded.status !== 201) {
const code =
typeof uploaded.body?.code === "string" && /^SIGNAGE_[A-Z_]+$/u.test(uploaded.body.code)
? ` (${uploaded.body.code})`
: "";
throw new Error(`Uploading slide ${index} returned HTTP ${uploaded.status}${code}.`);
}
images.push(uploaded.body?.image?.id); images.push(uploaded.body?.image?.id);
} }
check( check(
+190 -49
View File
@@ -7,6 +7,8 @@
# bash scripts/smoke-deploy.sh builds the image first # bash scripts/smoke-deploy.sh builds the image first
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh # EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh # EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
# EKKLESIA_SMOKE_MODULE_RELEASE_DIR=<dir> EKKLESIA_SMOKE_MODULE_ID=<id> \
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
# #
# Either topology (ADR 0045), and in both the same generic image, built once from deploy/Dockerfile. # Either topology (ADR 0045), and in both the same generic image, built once from deploy/Dockerfile.
# It contains no module; module storage starts empty, as on a fresh installation. `same-origin` (the # It contains no module; module storage starts empty, as on a fresh installation. `same-origin` (the
@@ -15,10 +17,10 @@
# ADR 0048 end to end with that same image: # ADR 0048 end to end with that same image:
# #
# - it boots with no modules; # - it boots with no modules;
# - the repository's module template is packaged by the canonical packager as an Ekklesia Module # - either the repository's module template is packaged by the canonical packager, or an external
# Package v1 (ADR 0049; scripts/lib/build-module-package.Dockerfile only provides the build # canonical release pair is supplied through EKKLESIA_SMOKE_MODULE_RELEASE_DIR; the package is
# environment), verified by the same image's own verifier, extracted to staging, installed into # verified by the same image's own verifier, extracted to staging, installed into module storage,
# module storage, its migrations planned and explicitly applied, and the API restarted; # its migrations planned and explicitly applied, and the API restarted;
# - a real headless Chromium drives the Dashboard, the module, its display, and sign-out; # - a real headless Chromium drives the Dashboard, the module, its display, and sign-out;
# - the module is removed and the API restarted: Core still runs, migration planning reports the # - the module is removed and the API restarted: Core still runs, migration planning reports the
# module's retained history, and its data survives; # module's retained history, and its data survives;
@@ -83,6 +85,28 @@ same-origin | split) ;;
;; ;;
esac esac
module_release_directory="${EKKLESIA_SMOKE_MODULE_RELEASE_DIR:-}"
module_id="${EKKLESIA_SMOKE_MODULE_ID:-example}"
module_probe_path="${EKKLESIA_SMOKE_MODULE_PROBE_PATH:-}"
[[ "$module_id" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]] || {
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_ID is not a valid module id." >&2
exit 1
}
if [[ -n "$module_release_directory" ]]; then
[[ "$topology" == split ]] || {
echo "smoke-deploy: an external module release requires the split topology." >&2
exit 1
}
[[ -d "$module_release_directory" ]] || {
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_RELEASE_DIR is not a directory." >&2
exit 1
}
fi
if [[ -n "$module_probe_path" && "$module_probe_path" != "/api/v1/modules/${module_id}/"* ]]; then
echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_PROBE_PATH must be inside the selected module's API namespace." >&2
exit 1
fi
run_id="$(openssl rand -hex 4)" run_id="$(openssl rand -hex 4)"
# Unique per invocation, and findable by run and job if this script is killed before its trap runs # Unique per invocation, and findable by run and job if this script is killed before its trap runs
# (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries # (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries
@@ -217,22 +241,31 @@ if [[ -z "$image" ]]; then
fi fi
if [[ "$topology" == split ]]; then if [[ "$topology" == split ]]; then
# The module template as a Module Package v1, from the canonical packager. Never a second platform # Never a second platform image: the package is installed into module storage below, after the
# image: the package is installed into module storage below, after the image verifies it. # generic image verifies it. The default keeps Core's own template smoke self-contained; downstream
step "Packaging the module template with the canonical Ekklesia packager" # module CI hands this harness the exact release pair it built instead.
source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}" if [[ -z "$module_release_directory" ]]; then
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package." step "Packaging the module template with the canonical Ekklesia packager"
DOCKER_BUILDKIT=1 docker build --quiet --file scripts/lib/build-module-package.Dockerfile \ source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}"
--label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \ [[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package."
--output "type=local,dest=${work}/module-package" . >/dev/null || DOCKER_BUILDKIT=1 docker build --quiet --file scripts/lib/build-module-package.Dockerfile \
fail "the module package did not build." --label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \
--output "type=local,dest=${work}/module-package" . >/dev/null ||
fail "the module package did not build."
package_output="$work/module-package"
else
step "Accepting the external module's canonical release pair"
package_output="$module_release_directory"
fi
# The archive and its digest are one release: copied together, from this run's own build output, # The archive and its digest are one release: copied together, from this run's own build output,
# into a fresh directory the verifier's unprivileged user can read. BuildKit's output directory is # into a fresh directory the verifier's unprivileged user can read. BuildKit's output directory is
# mode 0700 and owned by whoever built it, so it is never handed to the verifier itself. # mode 0700 and owned by whoever built it, so it is never handed to the verifier itself.
package_archive="$(module_package_release "$work/module-package" "$work/package")" || package_archive="$(module_package_release "$package_output" "$work/package")" ||
fail "the packager did not produce exactly one archive and its digest." fail "the packager did not produce exactly one archive and its digest."
[[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] || if [[ -z "$module_release_directory" ]]; then
fail "the packager produced an unexpected archive name." [[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] ||
fail "the packager produced an unexpected archive name."
fi
echo "ok packaged ${package_archive} ($(wc -c <"$work/package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/package/${package_archive}.sha256"))" echo "ok packaged ${package_archive} ($(wc -c <"$work/package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/package/${package_archive}.sha256"))"
fi fi
@@ -453,18 +486,41 @@ install_test_module() {
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \ -e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
-v "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \ -v "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \ --entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
--sha256-file "/package/${package_archive}.sha256" --extract-to /staging/example 2>&1 --sha256-file "/package/${package_archive}.sha256" --extract-to "/staging/${module_id}" 2>&1
)" || fail "the module package did not verify: ${verified}" )" || fail "the module package did not verify: ${verified}"
echo "ok ${verified%%$'\n'*}" echo "ok ${verified%%$'\n'*}"
docker run --rm --network none --entrypoint sh -v "$daemon_work/modules:/modules" \ docker run --rm --network none --entrypoint sh -v "$daemon_work/modules:/modules" \
-v "$daemon_work/staging:/staging" "$postgres_image" -ec ' -v "$daemon_work/staging:/staging" -e MODULE_ID="$module_id" "$postgres_image" -ec '
rm -rf /modules/.incoming-example && cp -R /staging/example /modules/.incoming-example && incoming="/modules/.incoming-${MODULE_ID}"
rm -rf /staging/example && chown -R 0:0 /modules/.incoming-example && installed="/modules/${MODULE_ID}"
chmod -R a+rX,go-w /modules/.incoming-example && staged="/staging/${MODULE_ID}"
mv /modules/.incoming-example /modules/example' || rm -rf "$incoming" && cp -R "$staged" "$incoming" && rm -rf "$staged" &&
chown -R 0:0 "$incoming" && chmod -R a+rX,go-w "$incoming" && mv "$incoming" "$installed"' ||
fail "the verified module could not be installed into module storage." fail "the verified module could not be installed into module storage."
} }
# The package and its claimed digest are deliberately made to disagree. The verifier must reject it
# before extraction; the valid release pair is left untouched for the installation that follows.
reject_invalid_package() {
local rejected zero_digest
zero_digest="$(printf '0%.0s' {1..64})"
if rejected="$(
docker run --rm --network none --read-only \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
--cap-drop ALL --security-opt no-new-privileges \
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
-v "$daemon_work/package:/package:ro" \
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
--sha256 "$zero_digest" 2>&1
)"; then
fail "the verifier accepted a package whose archive did not match its claimed digest."
fi
grep -q '^\[MODULE_PACKAGE_DIGEST_MISMATCH\]' <<<"$rejected" ||
fail "the invalid package was rejected for an unexpected reason."
echo "ok an invalid package is rejected before installation"
}
# restart_api <command...>: stops the API, runs the command, and starts the API again. A controlled # restart_api <command...>: stops the API, runs the command, and starts the API again. A controlled
# restart is the only way a module change is picked up; nothing is ever hot loaded. # restart is the only way a module change is picked up; nothing is ever hot loaded.
restart_api() { restart_api() {
@@ -476,9 +532,21 @@ restart_api() {
fi fi
} }
# recreate_api removes the application container, not its installation-owned module bind mount or
# database volumes, then creates a fresh container from the same generic image.
recreate_api() {
"${compose[@]}" stop api >/dev/null 2>&1 || fail "the API did not stop for recreation."
"${compose[@]}" rm --force api >/dev/null 2>&1 || fail "the API container was not removed."
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 api 2>&1)"; then
compose_report_not_ready "$up_output" "${compose[@]}"
fail "the recreated API did not become healthy."
fi
}
# module_check <expectation>: "absent", or the exact JSON array of values `example` must return. # module_check <expectation>: "absent", or the exact JSON array of values `example` must return.
module_check() { module_check() {
client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \ client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \
-e SMOKE_MODULE_ID="$module_id" -e SMOKE_MODULE_PROBE_PATH="$module_probe_path" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" -e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password"
} }
@@ -491,21 +559,36 @@ if [[ "$topology" == split ]]; then
step "Booting the generic image with empty module storage" step "Booting the generic image with empty module storage"
module_check absent || fail "the image without modules did not run module-free." module_check absent || fail "the image without modules did not run module-free."
step "Rejecting an invalid package before installation"
reject_invalid_package
step "Installing the test module into module storage, migrating explicitly, and restarting the API" step "Installing the test module into module storage, migrating explicitly, and restarting the API"
install_module_and_migrate() { install_module_and_migrate() {
install_test_module install_test_module
local plan local plan
plan="$(plan_output)" plan="$(plan_output)"
grep -q '^- \[pending\] example:0000_initial ' <<<"$plan" || grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan" ||
fail "the plan does not list the installed module's migration as pending." fail "the plan does not list the installed module's migration as pending."
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null || "${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "applying the installed module's migration failed." fail "applying the installed module's migration failed."
plan="$(plan_output)"
grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" ||
fail "the plan does not list the installed module's migration as applied."
if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then
fail "the installed module still has a pending migration after apply."
fi
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "reapplying migrations was not idempotent."
} }
restart_api install_module_and_migrate restart_api install_module_and_migrate
module_check '[]' || fail "the installed module did not run from module storage." if [[ "$module_id" == example ]]; then
psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \ module_check '[]' || fail "the installed module did not run from module storage."
>/dev/null || fail "could not write the module's own data." psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \
module_check '["Hello, kept"]' || fail "the installed module did not read its own data." >/dev/null || fail "could not write the module's own data."
module_check '["Hello, kept"]' || fail "the installed module did not read its own data."
else
module_check present || fail "the installed module did not run from module storage."
fi
step "Driving a real browser through the split Dashboard, a module, its display, and sign-out" step "Driving a real browser through the split Dashboard, a module, its display, and sign-out"
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA, # Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
@@ -516,24 +599,31 @@ if [[ "$topology" == split ]]; then
spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout | spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout |
openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)" openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)"
browser_container="${project}-browser" browser_container="${project}-browser"
# The image's entry script passes its arguments on unquoted, so none may contain a space: the start_owner_browser() {
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule. docker rm --force "$browser_container" >/dev/null 2>&1 || true
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \ # The image's entry script passes its arguments on unquoted, so none may contain a space: the
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \ # hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \ docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
"$browser_image" \ --cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
"--ignore-certificate-errors-spki-list=${spki}" \ --network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start." "$browser_image" \
"--ignore-certificate-errors-spki-list=${spki}" \
--user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start."
}
cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs" cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs"
stage browser.mjs stage browser.mjs
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \ run_browser_checks() {
--label "${EKKLESIA_CI_LABEL}=${project}" \ start_owner_browser
--network "$edge_network" \ docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \ --label "${EKKLESIA_CI_LABEL}=${project}" \
-e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \ --network "$edge_network" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \ -e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \ -e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \
--entrypoint node "$image" /smoke/browser.mjs || fail "browser checks failed." -e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \
-v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \
--entrypoint node "$image" /smoke/browser.mjs
}
run_browser_checks || fail "browser checks failed."
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
# A kiosk: a second browser whose profile directory is a volume, so it outlives the container # A kiosk: a second browser whose profile directory is a volume, so it outlives the container
@@ -569,6 +659,7 @@ if [[ "$topology" == split ]]; then
--network "$edge_network" \ --network "$edge_network" \
-e SMOKE_CDP_ADDRESS="${browser_address}:9222" \ -e SMOKE_CDP_ADDRESS="${browser_address}:9222" \
-e SMOKE_KIOSK_CDP_ADDRESS="${kiosk_address}:9222" \ -e SMOKE_KIOSK_CDP_ADDRESS="${kiosk_address}:9222" \
-e SMOKE_CALL_TIMEOUT_MS="${EKKLESIA_SMOKE_CALL_TIMEOUT_MS:-30000}" \
-e SMOKE_STEP_BUDGET_MS="$((budget * 1000))" \ -e SMOKE_STEP_BUDGET_MS="$((budget * 1000))" \
-e SMOKE_WATCH_MS="${EKKLESIA_SMOKE_WATCH_MS:-120000}" \ -e SMOKE_WATCH_MS="${EKKLESIA_SMOKE_WATCH_MS:-120000}" \
-e SMOKE_RECYCLE_WATCH_MS="${EKKLESIA_SMOKE_RECYCLE_WATCH_MS:-330000}" \ -e SMOKE_RECYCLE_WATCH_MS="${EKKLESIA_SMOKE_RECYCLE_WATCH_MS:-330000}" \
@@ -610,29 +701,79 @@ if [[ "$topology" == split ]]; then
((display_status == 0)) || fail "display-device checks failed." ((display_status == 0)) || fail "display-device checks failed."
fi fi
step "Restarting the API process with the installed module and data intact"
restart_api
if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the module or its data did not survive an API restart."
else
module_check present || fail "the module did not survive an API restart."
fi
run_browser_checks || fail "the module UI did not survive an API restart."
step "Recreating the API container with the same module storage and database volumes"
recreate_api
if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the module or its data did not survive container recreation."
else
module_check present || fail "the module did not survive container recreation."
fi
plan="$(plan_output)"
if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then
fail "container recreation made an applied module migration pending again."
fi
run_browser_checks || fail "the module UI did not survive container recreation."
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
display_driver 120 restart || fail "the display runtime or its data did not survive container recreation."
fi
step "Disabling and re-enabling the module through the supported lifecycle command"
restart_api "${compose[@]}" run --rm --no-deps migrate node \
core/dist/cli/set-module-enabled.js --disable --module "$module_id"
module_check absent || fail "the disabled module was still reported as running."
module_storage "test -d /modules/${module_id}" ||
fail "disabling the module removed its installed package."
restart_api "${compose[@]}" run --rm --no-deps migrate node \
core/dist/cli/set-module-enabled.js --enable --module "$module_id"
if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the re-enabled module did not return with its data."
else
module_check present || fail "the re-enabled module did not return."
fi
run_browser_checks || fail "the re-enabled module UI did not return."
step "Removing the module from module storage and restarting: Core runs, its data stays" step "Removing the module from module storage and restarting: Core runs, its data stays"
restart_api module_storage 'rm -rf /modules/example' restart_api module_storage "rm -rf /modules/${module_id}"
module_check absent || fail "the removed module is still running." module_check absent || fail "the removed module is still running."
plan="$(plan_output)" plan="$(plan_output)"
grep -q '^- \[retained\] example: 1 applied migration kept; the module is not installed$' \ grep -q "^- \\[retained\\] ${module_id}: .*applied migration.*kept; the module is not installed$" \
<<<"$plan" || fail "migration planning did not report the removed module's retained history." <<<"$plan" || fail "migration planning did not report the removed module's retained history."
grep -q '^Migration plan (.*0 pending, 0 approval-blocked, 1 retained):$' <<<"$plan" || grep -q '^Migration plan (.*0 pending, 0 approval-blocked, 1 retained):$' <<<"$plan" ||
fail "migration planning did not succeed cleanly with the module absent." fail "migration planning did not succeed cleanly with the module absent."
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null || "${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "applying migrations failed with the module absent." fail "applying migrations failed with the module absent."
[[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] || if [[ "$module_id" == example ]]; then
fail "the removed module's data did not survive." [[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] ||
fail "the removed module's data did not survive."
fi
echo "ok the module is gone, Core runs, its history is retained, and its data survives" echo "ok the module is gone, Core runs, its history is retained, and its data survives"
step "Reinstalling the exact same package and restarting: the module returns with its previous data" step "Reinstalling the exact same package and restarting: the module returns with its previous data"
restart_api install_test_module restart_api install_test_module
plan="$(plan_output)" plan="$(plan_output)"
grep -q '^- \[applied\] example:0000_initial ' <<<"$plan" || grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" ||
fail "the restored module's migration history is not valid." fail "the restored module's migration history is not valid."
if grep -q '\[retained\]\|\[pending\]' <<<"$plan"; then if grep -q '\[retained\]\|\[pending\]' <<<"$plan"; then
fail "the restored module left retained or pending migrations." fail "the restored module left retained or pending migrations."
fi fi
module_check '["Hello, kept"]' || fail "the restored module did not return with its data." if [[ "$module_id" == example ]]; then
module_check '["Hello, kept"]' || fail "the restored module did not return with its data."
else
module_check present || fail "the restored module did not return."
fi
run_browser_checks || fail "the restored module UI did not return."
if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then
display_driver 120 restart || fail "the restored display runtime did not return with its data."
fi
echo "ok the restored module runs again, with its history strict and its data intact" echo "ok the restored module runs again, with its history strict and its data intact"
fi fi
+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 ekklesia-artifact-layout) owners=(Zayin 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.2.3", "version": "0.2.4",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
+35 -6
View File
@@ -179,7 +179,7 @@ describe("CI scripts on a shared Docker daemon", () => {
expect(packaging).not.toMatch(/pnpm[^\n]*\bdeploy\b/u); expect(packaging).not.toMatch(/pnpm[^\n]*\bdeploy\b/u);
expect(deploy).toContain("scripts/lib/build-module-package.Dockerfile"); expect(deploy).toContain("scripts/lib/build-module-package.Dockerfile");
expect(deploy).toMatch( expect(deploy).toMatch(
/--entrypoint node "\$image" core\/dist\/cli\/verify-module-package\.js[^\n]*--sha256-file[^\n]*--extract-to \/staging\/example/u, /--entrypoint node "\$image" core\/dist\/cli\/verify-module-package\.js[^\n]*--sha256-file[^\n]*--extract-to "\/staging\/\$\{module_id\}"/u,
); );
expect(deploy).toContain("--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m"); expect(deploy).toContain("--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m");
expect(deploy).toContain( expect(deploy).toContain(
@@ -187,16 +187,20 @@ describe("CI scripts on a shared Docker daemon", () => {
); );
expect(deploy).toContain("-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier"); expect(deploy).toContain("-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier");
// Module storage only ever receives the verified tree from staging. // Module storage only ever receives the verified tree from staging.
expect(deploy).toContain("cp -R /staging/example /modules/.incoming-example"); expect(deploy).toContain(`staged="/staging/\${MODULE_ID}"`);
expect(deploy).toContain('cp -R "$staged" "$incoming"');
expect(deploy).toContain('mv "$incoming" "$installed"');
}); });
it("hand the verifier the package's archive and digest together, from a fresh release pair, never from build output", async () => { it("hand the verifier the package's archive and digest together, from a fresh release pair, never from build output", async () => {
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " "); const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
expect(deploy).toContain('source "$repository_root/scripts/lib/module-package-release.sh"'); expect(deploy).toContain('source "$repository_root/scripts/lib/module-package-release.sh"');
// The pair is made from this run's own build output, into a directory that must not exist yet. // The pair is made from this run's own build output, into a directory that must not exist yet.
expect(deploy).toContain('--output "type=local,dest=${work}/module-package"'); expect(deploy).toContain(`--output "type=local,dest=\${work}/module-package"`);
expect(deploy).toContain('package_output="$work/module-package"');
expect(deploy).toContain('package_output="$module_release_directory"');
expect(deploy).toContain( expect(deploy).toContain(
'package_archive="$(module_package_release "$work/module-package" "$work/package")"', 'package_archive="$(module_package_release "$package_output" "$work/package")"',
); );
expect(deploy).toMatch(/^\s*stage package$/mu); expect(deploy).toMatch(/^\s*stage package$/mu);
// BuildKit's output directory is 0700 and owned by whoever built it: it is never mounted. // BuildKit's output directory is 0700 and owned by whoever built it: it is never mounted.
@@ -207,11 +211,36 @@ describe("CI scripts on a shared Docker daemon", () => {
.split("\n") .split("\n")
.find((line) => line.includes("core/dist/cli/verify-module-package.js")); .find((line) => line.includes("core/dist/cli/verify-module-package.js"));
expect(verifier).toContain('-v "$daemon_work/package:/package:ro"'); expect(verifier).toContain('-v "$daemon_work/package:/package:ro"');
expect(verifier).toContain('"/package/${package_archive}"'); expect(verifier).toContain(`"/package/\${package_archive}"`);
expect(verifier).toContain('--sha256-file "/package/${package_archive}.sha256"'); expect(verifier).toContain(`--sha256-file "/package/\${package_archive}.sha256"`);
expect(verifier).not.toMatch(/--sha256\s/u); expect(verifier).not.toMatch(/--sha256\s/u);
}); });
it("accepts one exact external module release without putting its source in the platform image", async () => {
const deploy = await source("scripts/smoke-deploy.sh");
expect(deploy).toContain(`module_release_directory="\${EKKLESIA_SMOKE_MODULE_RELEASE_DIR:-}"`);
expect(deploy).toContain(`module_id="\${EKKLESIA_SMOKE_MODULE_ID:-example}"`);
expect(deploy).toContain('package_output="$module_release_directory"');
expect(deploy).toContain('-e SMOKE_MODULE_ID="$module_id"');
expect(deploy).toContain(`--extract-to "/staging/\${module_id}"`);
expect(deploy).toContain(`installed="/modules/\${MODULE_ID}"`);
});
it("proves installed module state across restart, container recreation, and lifecycle changes", async () => {
const deploy = await source("scripts/smoke-deploy.sh");
expect(deploy).toContain(`"\${compose[@]}" rm --force api`);
expect(deploy).toContain("container recreation made an applied module migration pending again");
expect(deploy).toContain('core/dist/cli/set-module-enabled.js --disable --module "$module_id"');
expect(deploy).toContain('core/dist/cli/set-module-enabled.js --enable --module "$module_id"');
expect(deploy).toContain("the restored display runtime did not return with its data");
});
it("gives page-side display requests enough time on a constrained shared runner", async () => {
const deploy = await source("scripts/smoke-deploy.sh");
expect(deploy).toContain('-e SMOKE_CALL_TIMEOUT_MS="');
expect(deploy).toContain("EKKLESIA_SMOKE_CALL_TIMEOUT_MS:-30000");
});
it("run every deploy smoke against the one generic image, never a second image with a module in it", async () => { it("run every deploy smoke against the one generic image, never a second image with a module in it", async () => {
// ADR 0048: the split topology installs its test module into module storage at run time. // ADR 0048: the split topology installs its test module into module storage at run time.
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " "); const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
+1 -1
View File
@@ -138,7 +138,7 @@ describe("reference deployment compose files", () => {
]), ]),
); );
expect(services["object-storage"]?.command).toEqual( expect(services["object-storage"]?.command).toEqual(
expect.arrayContaining(["-s3.port.https=8443"]), expect.arrayContaining(["-master.volumeSizeLimitMB=32", "-s3.port.https=8443"]),
); );
const hba = (await text("postgres/pg_hba.conf")) const hba = (await text("postgres/pg_hba.conf"))
+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.2.3", "@ekklesia/design-tokens": "^0.2.4",
"@ekklesia/module-database": "^0.2.3", "@ekklesia/module-database": "^0.2.4",
"@ekklesia/module-sdk": "^0.2.3", "@ekklesia/module-sdk": "^0.2.4",
"@ekklesia/module-ui-sdk": "^0.2.3", "@ekklesia/module-ui-sdk": "^0.2.4",
"@ekklesia/ui": "^0.2.3", "@ekklesia/ui": "^0.2.4",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@ekklesia/module-pack": "0.2.3", "@ekklesia/module-pack": "0.2.4",
"@ekklesia/module-ui-build": "^0.2.3", "@ekklesia/module-ui-build": "^0.2.4",
"@ekklesia/typescript-config": "^0.2.3", "@ekklesia/typescript-config": "^0.2.4",
"@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",