14 Commits
Author SHA1 Message Date
The_River 86d49846ff Merge pull request 'chore: release signage 0.1.4' (#17) from release/0.1.4 into main
CI / lint (push) Successful in 12s
CI / typecheck (push) Successful in 9s
CI / test (push) Successful in 16s
CI / build (push) Successful in 11s
CI / package (push) Successful in 13s
CI / integration (push) Successful in 7m14s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/17
Reviewed-by: The_River <[email protected]>
2026-10-11 04:18:30 +00:00
ekklesia a8c35db201 chore: release signage 0.1.4
CI / lint (pull_request) Successful in 13s
CI / typecheck (pull_request) Successful in 10s
CI / test (pull_request) Successful in 16s
CI / build (pull_request) Successful in 11s
CI / package (pull_request) Successful in 14s
CI / integration (pull_request) Successful in 7m19s
2026-10-10 23:02:55 -05:00
The_River 91bdba3136 Merge pull request 'ci: separate manual Signage release publishing' (#16) from fix/manual-release-workflow into main
CI / lint (push) Successful in 13s
CI / typecheck (push) Successful in 12s
CI / test (push) Successful in 17s
CI / build (push) Successful in 11s
CI / package (push) Successful in 14s
CI / integration (push) Successful in 7m28s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/16
Reviewed-by: The_River <[email protected]>
2026-10-11 03:59:22 +00:00
ekklesia 86121d7931 ci: separate manual release publishing
CI / lint (pull_request) Successful in 12s
CI / typecheck (pull_request) Successful in 10s
CI / test (pull_request) Successful in 17s
CI / build (pull_request) Successful in 11s
CI / package (pull_request) Successful in 13s
CI / integration (pull_request) Successful in 7m19s
2026-10-10 22:38:36 -05:00
The_River 8a7e96dd61 Merge pull request 'ci: publish the exact tested Signage module package' (#15) from fix/release-module-assets into main
CI / lint (push) Successful in 13s
CI / typecheck (push) Successful in 10s
CI / test (push) Successful in 17s
CI / build (push) Successful in 11s
CI / package (push) Successful in 14s
CI / release (push) Canceled after 0s
CI / integration (push) Canceled after 6m50s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/15
Reviewed-by: The_River <[email protected]>
2026-10-11 03:05:00 +00:00
ekklesia 844bbcf2f1 ci: publish tested module release assets
CI / lint (pull_request) Successful in 12s
CI / typecheck (pull_request) Successful in 9s
CI / test (pull_request) Successful in 16s
CI / build (pull_request) Successful in 11s
CI / package (pull_request) Successful in 14s
CI / integration (pull_request) Successful in 7m26s
CI / release (pull_request) Skipped
2026-10-10 21:16:05 -05:00
The_River 88a536464a Merge pull request 'chore: release signage 0.1.3' (#14) from chore/release-0.1.3 into main
CI / lint (push) Successful in 10s
CI / typecheck (push) Successful in 10s
CI / test (push) Successful in 16s
CI / build (push) Successful in 11s
CI / integration (push) Successful in 7m36s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/14
Reviewed-by: The_River <[email protected]>
2026-10-10 17:34:55 +00:00
ekklesiaandClaude Opus 5.5 dd656eb18a chore: release signage 0.1.3
CI / lint (pull_request) Successful in 11s
CI / typecheck (pull_request) Successful in 11s
CI / test (pull_request) Successful in 16s
CI / build (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 7m17s
0.1.3 ships the external package integration validated in #13 (Pass 2):
the canonical linux-x64-glibc Module Package installed and exercised
end to end by CI, with bounded external runtime image processing.

Only the version moves: the manifest, package.json, the server runtime,
both browser artifacts, the settings payload's moduleVersion, the test
fixtures, and the README status line. compatibility stays
platform ^0.2.0 and moduleSdk ^0.2.0; Core 0.2.4 already satisfies it,
so the range is not tightened. pnpm-lock.yaml carries no root version
and is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 12:20:06 -05:00
The_River e83cdc62a7 Merge pull request 'ci: restore external package integration' (#13) from feat/external-signage-integration into main
CI / lint (push) Successful in 10s
CI / typecheck (push) Successful in 10s
CI / test (push) Successful in 16s
CI / build (push) Successful in 11s
CI / integration (push) Successful in 7m17s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/13
Reviewed-by: The_River <[email protected]>
2026-10-10 16:33:18 +00:00
ekklesia fa2a1493ea ci: use bounded storage deployment
CI / lint (pull_request) Successful in 12s
CI / typecheck (pull_request) Successful in 10s
CI / test (pull_request) Successful in 16s
CI / build (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 7m17s
2026-10-10 11:08:22 -05:00
ekklesia 88038476b3 fix(images): bound external runtime processing
CI / lint (pull_request) Successful in 11s
CI / typecheck (pull_request) Successful in 10s
CI / test (pull_request) Successful in 16s
CI / build (pull_request) Successful in 10s
CI / integration (pull_request) Failing after 2m14s
2026-10-10 10:54:33 -05:00
ekklesia 871d198221 ci: widen display upload deadline
CI / lint (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 13s
CI / test (pull_request) Successful in 18s
CI / build (pull_request) Successful in 14s
CI / integration (pull_request) Failing after 1m57s
2026-10-09 22:37:50 -05:00
ekklesia c28e4b2de7 ci: restore external package integration
CI / lint (pull_request) Successful in 12s
CI / typecheck (pull_request) Successful in 13s
CI / test (pull_request) Successful in 16s
CI / build (pull_request) Successful in 10s
CI / integration (pull_request) Failing after 1m57s
2026-10-09 22:30:52 -05:00
Joey 6233de0e5c Merge pull request 'build: package signage with ekklesia 0.2.3' (#12) from feat/native-package-0.2.3 into main
CI / lint (push) Successful in 11s
CI / typecheck (push) Successful in 10s
CI / test (push) Successful in 16s
CI / build (push) Successful in 12s
CI / integration (push) Skipped
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia_Signage/pulls/12
Reviewed-by: Joey <[email protected]>
2026-10-10 00:39:25 +00:00
22 changed files with 1003 additions and 77 deletions
+75 -30
View File
@@ -1,9 +1,10 @@
name: CI name: CI
# Signage is a standalone repository: every job checks out only this repository, installs it with # Signage is a standalone repository: every job installs it with --frozen-lockfile from its own
# --frozen-lockfile from its own pnpm-lock.yaml, and takes Ekklesia's module-development packages # pnpm-lock.yaml and takes Ekklesia's module-development packages (@ekklesia/*) from Ekklesia's
# (@ekklesia/*) from Ekklesia's public package registry (.npmrc; platform ADRs 0053 and 0054). No job # public package registry (.npmrc; platform ADRs 0053 and 0054). Only the integration job fetches
# clones Ekklesia. # Core source: one immutable commit through the pinned Gitea pull ref in ci/platform.env, used as the
# generic deployment harness and image source, never as a workspace dependency.
# #
# The same trust rules as the platform's own CI (docs/development/security.md there): # The same trust rules as the platform's own CI (docs/development/security.md there):
# * every action is pinned by a full commit SHA; # * every action is pinned by a full commit SHA;
@@ -14,6 +15,8 @@ name: CI
# #
# The server is Gitea 1.27, which honours `concurrency` cancellation; `permissions` and # The server is Gitea 1.27, which honours `concurrency` cancellation; `permissions` and
# `timeout-minutes` are declared so the workflow is correct wherever they are enforced. # `timeout-minutes` are declared so the workflow is correct wherever they are enforced.
# Artifact actions v3 are deliberate: Gitea implements that protocol, while v4 refuses non-GitHub
# servers. This workflow never publishes or mutates a release; publishing is a separate manual gate.
on: on:
pull_request: pull_request:
@@ -58,7 +61,9 @@ jobs:
run: pnpm lint run: pnpm lint
- name: Check that CI cleans up every stack it starts - name: Check that CI cleans up every stack it starts
run: bash ci/check-cleanup.sh run: |
bash ci/check-cleanup.sh
python3 -m unittest ci/test_release_assets.py
typecheck: typecheck:
name: typecheck name: typecheck
@@ -149,33 +154,58 @@ jobs:
- name: Validate the built module and its migrations - name: Validate the built module and its migrations
run: pnpm validate run: pnpm validate
# Signage inside Ekklesia, end to end, in a real browser: the platform's own Dockerfile builds the # Build the canonical pair once. Integration downloads these exact bytes and may not repackage.
# deployment image with Signage in it, and the platform's split-topology deployment smoke runs package:
# against it, pairing a kiosk display and running the scenarios in ci/display-scenarios. A name: package
# disposable database and object store with generated secrets; nothing is pushed or logged in to. runs-on: ubuntu-latest
# timeout-minutes: 20
# DISABLED. From Ekklesia 0.2.0 on, the platform's image is the one generic image (platform ADR outputs:
# 0048): deploy/Dockerfile keeps `modules/` out of its build context and refuses one that has it, sha256: ${{ steps.canonical.outputs.sha256 }}
# and the deployment smoke installs modules only as canonical Module Packages (ADR 0049). Signage is steps:
# not one yet: its static sharp dependency needs the native linux-x64-glibc package flow. Run as it - name: Check out repository
# was, this job would build an image without Signage and drive the module template's display uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# instead, so it is switched off rather than left reporting on something else. with:
# persist-credentials: false
# It returns when Signage is built as a canonical linux-x64-glibc Module Package and installed into
# Ekklesia's generic image; that change removes `if: false` and rewrites these steps. They still - name: Set up Node.js at the repository's version
# expect the platform tree ci/assemble.sh used to build at build/workspace, and that assembly is uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# gone now that Signage builds on its own. The platform's deployment smoke and stack sweep will with:
# still come from Ekklesia, at the release Signage targets, because they are the platform's to run. node-version-file: .node-version
- name: Install (frozen lockfile)
run: |
corepack enable
corepack install
pnpm install --frozen-lockfile
- name: Build the canonical Module Package pair
id: canonical
run: bash ci/package.sh
- name: Hand the canonical pair to its consumers
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with:
name: signage-module-package
path: build/module-package/
if-no-files-found: error
retention-days: 30
# The real linux-x64-glibc Signage Module Package inside Ekklesia, end to end. The pinned platform
# builds one generic image with no bundled module source, verifies and installs Signage into
# persistent module storage, applies its migrations, and drives its split-origin Dashboard and
# kiosk display in real browsers. It then checks process restart, container recreation, lifecycle
# disable/re-enable, removal, and reinstall against the same database and package.
integration: integration:
name: integration name: integration
if: false
needs: needs:
- test - test
- build - build
- package
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 45
env: env:
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: "1"
EXPECTED_PACKAGE_SHA256: ${{ needs.package.outputs.sha256 }}
# Names this job's stacks, so its final step removes exactly these (the platform's # Names this job's stacks, so its final step removes exactly these (the platform's
# scripts/lib/ci-project.sh). # scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: signage-integration EKKLESIA_CI_JOB_KEY: signage-integration
@@ -185,17 +215,32 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Fetch Ekklesia's deployment smoke and build Signage's Module Package - name: Set up Node.js at the repository's version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Install (frozen lockfile)
run: | run: |
echo "integration: rewritten when Signage is a canonical linux-x64-glibc Module Package." >&2 corepack enable
exit 1 corepack install
pnpm install --frozen-lockfile
- name: Receive the canonical Module Package pair
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
name: signage-module-package
path: build/module-package/
- name: Fetch the pinned Ekklesia integration harness
run: bash ci/fetch-platform.sh
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its # A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first. # cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner - name: Sweep stale CI stacks from the runner
run: bash build/workspace/scripts/ci-sweep.sh --stale run: bash build/platform/scripts/ci-sweep.sh --stale
- name: Build the image and run the display scenarios - name: Install and run the prebuilt external Signage package
run: bash ci/integration.sh run: bash ci/integration.sh
# Whatever happened above, this job's own stacks do not outlive it. If the first step failed, # Whatever happened above, this job's own stacks do not outlive it. If the first step failed,
@@ -203,6 +248,6 @@ jobs:
- name: Remove this job's CI stacks - name: Remove this job's CI stacks
if: always() if: always()
run: | run: |
if [ -f build/workspace/scripts/ci-sweep.sh ]; then if [ -f build/platform/scripts/ci-sweep.sh ]; then
bash build/workspace/scripts/ci-sweep.sh --job bash build/platform/scripts/ci-sweep.sh --job
fi fi
+180
View File
@@ -0,0 +1,180 @@
name: Publish Signage Release
# Builds, proves, and publishes one Signage release. This is deliberately separate from CI: only an
# operator's workflow_dispatch on a selected branch can start it. The normal release ref is main.
# Every local gate, including the full external-package integration and ADR 0055 redistribution
# verification, finishes before the release/tag is created or an existing release is touched.
on:
workflow_dispatch:
inputs:
version:
description: The Signage version to publish, equal to package and module metadata.
required: true
type: string
permissions:
contents: read
# Never cancel a publication part way, and never run two at once. A rerun safely resumes an
# identical partial upload, but refuses a tag, release, or asset that differs from this run.
concurrency:
group: publish-signage-release
cancel-in-progress: false
jobs:
release-gate:
name: release gate
runs-on: ubuntu-latest
timeout-minutes: 75
outputs:
sha256: ${{ steps.canonical.outputs.sha256 }}
env:
DOCKER_BUILDKIT: "1"
EKKLESIA_CI_JOB_KEY: signage-publish-release
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_TAG: v${{ inputs.version }}
RELEASE_REVISION: ${{ github.sha }}
steps:
- name: Check out the operator-selected revision
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js at the repository's version
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Install the reviewed build and release tooling
run: |
corepack enable
corepack install
pnpm install --frozen-lockfile
# Match Core's manual publisher: releases come only from reviewed main. The source version is
# checked before the expensive gate, and the selected commit remains the immutable tag target.
- name: Validate requested version and selected revision
run: |
[[ "$GITHUB_REF" == refs/heads/main ]] || {
echo "publish: select main; got $GITHUB_REF" >&2
exit 1
}
[[ "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "publish: version must be X.Y.Z; got $RELEASE_VERSION" >&2
exit 1
}
[[ "$RELEASE_REVISION" =~ ^[0-9a-f]{40}$ ]]
[[ "$(git rev-parse HEAD)" == "$RELEASE_REVISION" ]]
[[ -z "$(git status --porcelain)" ]]
node --input-type=module -e '
import { readFileSync } from "node:fs";
const requested = process.env.RELEASE_VERSION;
const packageVersion = JSON.parse(readFileSync("package.json", "utf8")).version;
const moduleVersion = JSON.parse(readFileSync("ekklesia.module.json", "utf8")).version;
if (packageVersion !== requested || moduleVersion !== requested) {
console.error(`publish: requested ${requested}, package ${packageVersion}, module ${moduleVersion}`);
process.exit(1);
}
console.log(`publish: source metadata agrees on ${requested}`);
'
# This is the only package build in the workflow. Every later step consumes this directory.
- name: Build the canonical Module Package pair once
id: canonical
run: bash ci/package.sh build/release-assets
- name: Verify the canonical Module Package
env:
EXPECTED_PACKAGE_SHA256: ${{ steps.canonical.outputs.sha256 }}
run: |
pnpm validate
python3 ci/release-assets.py verify-package \
build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
- name: Fetch the pinned Ekklesia integration harness
run: bash ci/fetch-platform.sh
- name: Sweep stale CI stacks from the runner
run: bash build/platform/scripts/ci-sweep.sh --stale
- name: Run full integration with the exact release package
env:
EXPECTED_PACKAGE_SHA256: ${{ steps.canonical.outputs.sha256 }}
run: bash ci/integration.sh "$GITHUB_WORKSPACE/build/release-assets"
- name: Remove this publication's CI stacks
if: always()
run: |
if [ -f build/platform/scripts/ci-sweep.sh ]; then
bash build/platform/scripts/ci-sweep.sh --job
fi
- name: Build and verify required redistribution assets
env:
EXPECTED_PACKAGE_SHA256: ${{ steps.canonical.outputs.sha256 }}
run: |
package="$(find build/release-assets -maxdepth 1 -type f -name 'signage-*-linux-x64-glibc.tar.gz')"
pnpm exec ekklesia-corresponding-source acquire \
--package "$package" --cache "$RUNNER_TEMP/signage-corresponding-sources"
pnpm exec ekklesia-corresponding-source build \
--package "$package" --cache "$RUNNER_TEMP/signage-corresponding-sources" \
--out build/release-assets
source="${package%.tar.gz}-corresponding-source.tar.gz"
metadata="${package%.tar.gz}.redistribution.json"
pnpm exec ekklesia-corresponding-source verify \
--package "$package" --source "$source" --metadata "$metadata"
python3 ci/release-assets.py verify \
build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
# The write-authorized job receives only the completely proven bundle. Artifact actions v3
# are required by Gitea; v4 refuses servers other than github.com.
- name: Hand the proven release bundle to the publisher
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with:
name: signage-release-assets
path: build/release-assets/
if-no-files-found: error
retention-days: 90
publish:
name: publish
needs: release-gate
if: github.ref == 'refs/heads/main'
permissions:
contents: write
runs-on: ubuntu-latest
timeout-minutes: 15
env:
EXPECTED_PACKAGE_SHA256: ${{ needs.release-gate.outputs.sha256 }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_TAG: v${{ inputs.version }}
RELEASE_REVISION: ${{ github.sha }}
steps:
# The checked-out helper is the same reviewed revision the gate proved. Credentials are not
# persisted; the automatic token is exposed only to the final API call below.
- name: Check out the proven revision
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Receive the proven release bundle
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
name: signage-release-assets
path: build/release-assets/
- name: Recheck the received release bundle
run: |
python3 ci/release-assets.py verify \
build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
# Only this final step can mutate Gitea. It creates v<version> at the selected commit and a
# prerelease if absent, then uploads the already-tested bytes. A rerun resumes only identical
# state. Every asset is read back without Authorization and checked byte for byte.
- name: Create the release and publish the exact tested assets
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
python3 ci/release-assets.py publish \
build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
+2
View File
@@ -3,5 +3,7 @@ node_modules/
dist/ dist/
.turbo/ .turbo/
coverage/ coverage/
build/
__pycache__/
*.log *.log
.DS_Store .DS_Store
+1 -1
View File
@@ -16,7 +16,7 @@ Ekklesia and vendors none of its SDKs.
## Status ## Status
Version 0.1.2 is feature-complete for its declared scope, and Signage has run in production since Version 0.1.4 is feature-complete for its declared scope, and Signage has run in production since
0.1.1. What exists today: 0.1.1. What exists today:
- The module package, manifest (v8), four capabilities, six permissions, ten operator settings, and - The module package, manifest (v8), four capabilities, six permissions, ten operator settings, and
+1
View File
@@ -5,6 +5,7 @@
"**", "**",
"!**/.pnpm-store", "!**/.pnpm-store",
"!**/.turbo", "!**/.turbo",
"!**/build",
"!**/coverage", "!**/coverage",
"!**/dist", "!**/dist",
"!**/node_modules", "!**/node_modules",
+58 -3
View File
@@ -6,11 +6,12 @@
# #
# The `integration` job is the one that starts Docker stacks. It must have its own # The `integration` job is the one that starts Docker stacks. It must have its own
# EKKLESIA_CI_JOB_KEY, sweep stale CI stacks before it starts anything, and remove its own in a final # EKKLESIA_CI_JOB_KEY, sweep stale CI stacks before it starts anything, and remove its own in a final
# `if: always()` step; and the image ci/integration.sh builds must carry the CI labels the sweep finds. # `if: always()` step. The pinned platform smoke owns and labels every image and container.
set -Eeuo pipefail set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
workflow="$root/.gitea/workflows/ci.yml" workflow="$root/.gitea/workflows/ci.yml"
publish_workflow="$root/.gitea/workflows/publish-release.yml"
failures=0 failures=0
check() { check() {
if eval "$1"; then if eval "$1"; then
@@ -31,13 +32,67 @@ own="$(line_of 'ci-sweep.sh --job')"
last_step="$(grep -n -- " - name:" <<<"$job" | tail -1 | cut -d: -f1 || true)" last_step="$(grep -n -- " - name:" <<<"$job" | tail -1 | cut -d: -f1 || true)"
check '[[ -n "$job" ]]' "the workflow has an integration job" check '[[ -n "$job" ]]' "the workflow has an integration job"
check '! grep -q "if: false" <<<"$job"' "the integration job is enabled"
check 'grep -q "EKKLESIA_CI_JOB_KEY:" <<<"$job"' "the integration job names its stacks with its own key" check 'grep -q "EKKLESIA_CI_JOB_KEY:" <<<"$job"' "the integration job names its stacks with its own key"
check '[[ -n "$stale" && -n "$run" ]] && ((stale < run))' "it sweeps stale CI stacks before it starts anything" check '[[ -n "$stale" && -n "$run" ]] && ((stale < run))' "it sweeps stale CI stacks before it starts anything"
check '[[ -n "$own" && -n "$run" ]] && ((own > run)) && ((own > last_step))' \ check '[[ -n "$own" && -n "$run" ]] && ((own > run)) && ((own > last_step))' \
"its last step removes its own stacks" "its last step removes its own stacks"
check 'sed -n "${last_step},\$p" <<<"$job" | grep -q "if: always()"' "that last step always runs" check 'sed -n "${last_step},\$p" <<<"$job" | grep -q "if: always()"' "that last step always runs"
check 'grep -q "EKKLESIA_CI_LABEL" "$root/ci/integration.sh" && grep -q "EKKLESIA_CI_CREATED_LABEL" "$root/ci/integration.sh"' \ check 'grep -q "EKKLESIA_SMOKE_MODULE_RELEASE_DIR" "$root/ci/integration.sh" && grep -q "bash scripts/smoke-deploy.sh" "$root/ci/integration.sh"' \
"the image ci/integration.sh builds carries the CI labels" "the integration delegates its external package to the platform smoke"
check 'grep -Eq "^EKKLESIA_PLATFORM_REF=refs/(pull/[0-9]+/head|heads/main)$" "$root/ci/platform.env" && grep -Eq "^EKKLESIA_PLATFORM_COMMIT=[0-9a-f]{40}$" "$root/ci/platform.env"' \
"the platform harness is pinned by full ref and commit"
check 'grep -q "rev-parse refs/remotes/pinned" "$root/ci/fetch-platform.sh" && grep -q "does not resolve to EKKLESIA_PLATFORM_COMMIT" "$root/ci/fetch-platform.sh"' \
"the fetch verifies that the pinned ref resolves to the pinned commit"
# Normal CI builds the canonical archive once and hands those exact bytes to integration through a
# Gitea Actions v3 artifact. It must contain no release trigger, permission, or mutation path.
package_job="$(awk '/^ package:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$workflow")"
check 'grep -q "pull_request:" "$workflow" && grep -q "push:" "$workflow"' \
"normal CI still runs for pull requests and main pushes"
check '! grep -q "workflow_dispatch:" "$workflow" && ! grep -q "tags:" "$workflow"' \
"normal CI has no manual-publish or tag trigger"
check '! grep -q "contents: write" "$workflow" && ! grep -q "github.token" "$workflow"' \
"normal CI has no release-specific permission or token"
check '! grep -q "release-assets.py publish" "$workflow" && ! grep -q "ekklesia-corresponding-source" "$workflow"' \
"normal CI cannot publish or prepare release assets"
check 'grep -q "bash ci/package.sh" <<<"$package_job" && grep -q "actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de" <<<"$package_job"' \
"the package job builds and transfers one canonical artifact"
check 'grep -q "actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b" <<<"$job" && ! grep -Eq "ci/package.sh|ekklesia-pack-module" <<<"$job"' \
"integration downloads that artifact and never rebuilds it"
check 'grep -q "needs.package.outputs.sha256" <<<"$job" && grep -q "GITHUB_OUTPUT" "$root/ci/package.sh"' \
"integration binds the downloaded bytes to the package job's digest"
# Publishing is an explicit operator action. One job validates its dispatch input and selected ref,
# builds the package exactly once, integration-tests that directory, prepares redistribution files,
# and only then gives the same directory to the Gitea publisher.
gate_job="$(awk '/^ release-gate:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$publish_workflow")"
publish_job="$(awk '/^ publish:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$publish_workflow")"
check 'grep -q "workflow_dispatch:" "$publish_workflow" && ! grep -q "pull_request:" "$publish_workflow" && ! grep -q "push:" "$publish_workflow"' \
"publishing is workflow_dispatch only and cannot run for a PR or push"
check 'grep -q "inputs:" "$publish_workflow" && grep -q " version:" "$publish_workflow" && grep -q "required: true" "$publish_workflow"' \
"manual publishing requires an explicit version input"
check 'grep -q "refs/heads/main" <<<"$gate_job" && grep -q "packageVersion.*requested" <<<"$gate_job" && grep -q "moduleVersion.*requested" <<<"$gate_job"' \
"the publish gate requires selected main and validates both source versions"
check '[[ "$(grep -c "bash ci/package.sh" "$publish_workflow")" -eq 1 ]]' \
"the publish workflow builds the canonical Module Package exactly once"
check 'grep -q "ci/integration.sh.*build/release-assets" <<<"$gate_job" && grep -q "name: signage-release-assets" <<<"$gate_job" && grep -q "name: signage-release-assets" <<<"$publish_job" && grep -q "release-assets.py publish" <<<"$publish_job"' \
"integration proves the package handed unchanged to the publisher"
check 'grep -q "EXPECTED_PACKAGE_SHA256:.*steps.canonical.outputs.sha256" <<<"$gate_job" && grep -q "EXPECTED_PACKAGE_SHA256:.*needs.release-gate.outputs.sha256" <<<"$publish_job"' \
"the publish gate binds consumers to the canonical package digest"
check 'grep -q "ekklesia-corresponding-source verify" <<<"$gate_job" && grep -q "needs: release-gate" <<<"$publish_job"' \
"corresponding-source verification finishes before the publishing job"
check 'grep -q "GITHUB_TOKEN:.*github.token" <<<"$publish_job" && grep -q "contents: write" <<<"$publish_job" && ! grep -q "contents: write" <<<"$gate_job"' \
"only the final manual publishing job receives release write authority"
# The manual workflow also owns a Docker integration stack, so it follows the same cleanup rule.
publish_stale="$(grep -n -F -- 'ci-sweep.sh --stale' <<<"$gate_job" | cut -d: -f1 || true)"
publish_run="$(grep -n -F -- 'bash ci/integration.sh' <<<"$gate_job" | cut -d: -f1 || true)"
publish_own="$(grep -n -F -- 'ci-sweep.sh --job' <<<"$gate_job" | cut -d: -f1 || true)"
check '[[ -n "$publish_stale" && -n "$publish_run" ]] && ((publish_stale < publish_run))' \
"manual publishing sweeps stale stacks before integration"
check '[[ -n "$publish_own" && -n "$publish_run" ]] && ((publish_own > publish_run))' \
"manual publishing always has a post-integration cleanup step"
((failures == 0)) || { ((failures == 0)) || {
echo "check-cleanup: ${failures} check(s) failed." >&2 echo "check-cleanup: ${failures} check(s) failed." >&2
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Fetches the exact Ekklesia source revision Signage's external-package integration runs against.
# The ref is fetched only to make the pinned object reachable; the checkout is always by full SHA.
set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
out="$root/build/platform"
fail() {
echo "fetch-platform: $*" >&2
exit 1
}
(($# == 0)) || fail "this command takes no arguments."
pin() {
local value
value="$(sed -n "s/^$1=//p" "$root/ci/platform.env")"
[[ -n "$value" ]] || fail "ci/platform.env has no $1."
printf '%s' "$value"
}
repository="$(pin EKKLESIA_PLATFORM_REPOSITORY)"
ref="$(pin EKKLESIA_PLATFORM_REF)"
commit="$(pin EKKLESIA_PLATFORM_COMMIT)"
[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || fail "EKKLESIA_PLATFORM_COMMIT must be a full commit SHA."
[[ "$ref" == refs/* ]] || fail "EKKLESIA_PLATFORM_REF must be a full Git ref."
rm -rf "$out"
mkdir -p "$out"
git -C "$out" init --quiet
git -C "$out" remote add origin "$repository"
git -C "$out" fetch --quiet --no-tags --depth=1 origin "+${ref}:refs/remotes/pinned" ||
fail "cannot fetch the pinned platform ref."
[[ "$(git -C "$out" rev-parse refs/remotes/pinned)" == "$commit" ]] ||
fail "the pinned ref does not resolve to EKKLESIA_PLATFORM_COMMIT."
git -C "$out" -c advice.detachedHead=false checkout --quiet --detach "$commit"
rm -rf "$out/.git"
echo "fetch-platform: Ekklesia ${commit}"
+33 -26
View File
@@ -1,46 +1,53 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Signage inside Ekklesia, end to end: builds the deployment image from the assembled workspace with # Gives the canonical linux-x64-glibc Module Package pair from the package job to the pinned
# the platform's own Dockerfile, then runs the platform's split-topology deployment smoke against it # platform's generic deployment smoke. The platform image contains no Signage source:
# with the display-device scenarios listed in ci/display-scenarios. A disposable PostgreSQL and object # its verifier extracts the package into installation-owned module storage before migrations,
# store, generated secrets, and an explicit migration apply against that fresh database only; nothing # discovery, browser UI/display checks, restart, container recreation, disable/re-enable, removal,
# is pushed, published, or logged in to. The image is removed afterwards. # and reinstall are exercised against disposable PostgreSQL and object-storage services.
# #
# bash ci/package.sh
# bash ci/fetch-platform.sh
# bash ci/integration.sh # bash ci/integration.sh
#
# NOT RUN: the integration job is disabled (.gitea/workflows/ci.yml says why). This script still
# expects Ekklesia's deployment tree at build/workspace, which the removed ci/assemble.sh used to
# build; it is rewritten when Signage is installed into the generic image as a Module Package.
set -Eeuo pipefail set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
workspace="$root/build/workspace" platform="$root/build/platform"
[[ -f "$workspace/deploy/Dockerfile" ]] || { release="${1:-$root/build/module-package}"
echo "integration: Ekklesia's deployment tree is not at build/workspace." >&2 [[ -f "$platform/scripts/smoke-deploy.sh" ]] || {
echo "integration: run bash ci/fetch-platform.sh first." >&2
exit 1 exit 1
} }
# Named and labeled as a CI stack of this run and job (the platform's scripts/lib/ci-project.sh), so mapfile -t archives < <(find "$release" -maxdepth 1 -type f -name 'signage-*-linux-x64-glibc.tar.gz' -print)
# the workflow's final `ci-sweep.sh --job` removes it even if this script is killed before its trap. [[ "${#archives[@]}" -eq 1 ]] || {
# shellcheck source=/dev/null echo "integration: expected exactly one prebuilt Module Package archive." >&2
source "$workspace/scripts/lib/ci-project.sh" exit 1
project="$(ci_project_name signage-image)" }
image="${project}:latest" archive="${archives[0]}"
trap 'docker image rm --force "$image" >/dev/null 2>&1 || true' EXIT checksum="$archive.sha256"
[[ -f "$checksum" ]] || {
echo "integration: missing $(basename "$checksum")." >&2
exit 1
}
(cd "$release" && sha256sum --check --strict "$(basename "$checksum")")
digest="$(sha256sum "$archive" | cut -d' ' -f1)"
if [[ -n "${EXPECTED_PACKAGE_SHA256:-}" && "$digest" != "$EXPECTED_PACKAGE_SHA256" ]]; then
echo "integration: prebuilt package sha256 $digest differs from the package job's $EXPECTED_PACKAGE_SHA256." >&2
exit 1
fi
scenarios="$(sed -e 's/#.*//' -e '/^[[:space:]]*$/d' "$root/ci/display-scenarios" | tr '\n' ' ')" scenarios="$(sed -e 's/#.*//' -e '/^[[:space:]]*$/d' "$root/ci/display-scenarios" | tr '\n' ' ')"
[[ -n "${scenarios// /}" ]] || { [[ -n "${scenarios// /}" ]] || {
echo "integration: ci/display-scenarios lists no scenario." >&2 echo "integration: ci/display-scenarios lists no scenario." >&2
exit 1 exit 1
} }
echo "integration: consuming prebuilt $(basename "$archive"); scenarios: ${scenarios}"
DOCKER_BUILDKIT=1 docker build --quiet --file "$workspace/deploy/Dockerfile" --tag "$image" \ cd "$platform"
--label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
"$workspace" >/dev/null
echo "integration: built the deployment image with Signage; scenarios: ${scenarios}"
cd "$workspace"
EKKLESIA_SMOKE_TOPOLOGY=split \ EKKLESIA_SMOKE_TOPOLOGY=split \
EKKLESIA_SMOKE_IMAGE="$image" \ EKKLESIA_SMOKE_MODULE_RELEASE_DIR="$release" \
EKKLESIA_SMOKE_MODULE_ID=signage \
EKKLESIA_SMOKE_MODULE_PROBE_PATH=/api/v1/modules/signage/channel-groups \
EKKLESIA_SMOKE_DISPLAY_DEVICE=1 \ EKKLESIA_SMOKE_DISPLAY_DEVICE=1 \
EKKLESIA_SMOKE_DISPLAY_SCENARIOS="$scenarios" \ EKKLESIA_SMOKE_DISPLAY_SCENARIOS="$scenarios" \
EKKLESIA_SMOKE_WATCH_MS=120000 \ EKKLESIA_SMOKE_WATCH_MS=120000 \
Executable
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Builds the one canonical linux-x64-glibc Module Package pair that later jobs must consume.
set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
release="${1:-$root/build/module-package}"
source_revision="${GITHUB_SHA:-$(git -C "$root" rev-parse HEAD 2>/dev/null || true)}"
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || {
echo "package: no full source revision is available." >&2
exit 1
}
rm -rf -- "$release"
mkdir -p -- "$release"
pnpm --dir "$root" build
pnpm --dir "$root" exec ekklesia-pack-module --module "$root" --target linux-x64-glibc \
--out "$release" --license-file "$root/LICENSE" --source-revision "$source_revision"
mapfile -t archives < <(find "$release" -maxdepth 1 -type f -name 'signage-*-linux-x64-glibc.tar.gz' -print)
[[ "${#archives[@]}" -eq 1 ]] || {
echo "package: expected exactly one Module Package archive." >&2
exit 1
}
archive="${archives[0]}"
checksum="$archive.sha256"
[[ -f "$checksum" ]] || {
echo "package: missing $(basename "$checksum")." >&2
exit 1
}
(cd "$release" && sha256sum --check --strict "$(basename "$checksum")")
digest="$(sha256sum "$archive" | cut -d' ' -f1)"
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
echo "sha256=$digest" >>"$GITHUB_OUTPUT"
fi
echo "package: canonical pair is $(basename "$archive") and $(basename "$checksum")."
+5
View File
@@ -0,0 +1,5 @@
# The companion Core harness Signage uses to install and run its real external package. The pull ref
# remains reachable after its branch is deleted; the full commit pin makes the checkout immutable.
EKKLESIA_PLATFORM_REPOSITORY=https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia.git
EKKLESIA_PLATFORM_REF=refs/pull/90/head
EKKLESIA_PLATFORM_COMMIT=40ef0ff802c124ae5a3a0b9aa51a1502a7b72644
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Validate and publish one tested Signage release bundle through the Gitea API."""
from __future__ import annotations
import hashlib
import json
import os
import re
import sys
import tarfile
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
ARCHIVE = re.compile(r"^signage-(\d+\.\d+\.\d+)-linux-x64-glibc\.tar\.gz$")
class Refused(Exception):
"""The release does not meet a condition that must fail closed."""
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def package_metadata(archive: Path) -> dict:
try:
with tarfile.open(archive, "r:gz") as bundle:
member = next(
entry for entry in bundle.getmembers()
if entry.name == "signage/ekklesia.package.json" and entry.isfile()
)
extracted = bundle.extractfile(member)
if extracted is None:
raise KeyError(member.name)
return json.loads(extracted.read())
except (KeyError, StopIteration, tarfile.TarError, json.JSONDecodeError) as error:
raise Refused(f"cannot read signage/ekklesia.package.json from {archive.name}") from error
def package_pair(directory: Path, tag: str, revision: str) -> list[Path]:
archives = [path for path in directory.iterdir() if path.is_file() and ARCHIVE.fullmatch(path.name)]
if len(archives) != 1:
raise Refused(f"expected one Signage Module Package, found {len(archives)}")
package = archives[0]
match = ARCHIVE.fullmatch(package.name)
assert match is not None
version = match.group(1)
if tag != f"v{version}":
raise Refused(f"tag {tag} does not match package version {version}")
if not re.fullmatch(r"[0-9a-f]{40}", revision):
raise Refused("release revision is not a full Git object ID")
checksum = directory / f"{package.name}.sha256"
expected = [package, checksum]
missing = [path.name for path in expected if not path.is_file()]
if missing:
raise Refused(f"Module Package pair is incomplete; missing={missing}")
package_digest = sha256(package)
checksum_line = checksum.read_text(encoding="utf-8")
wanted_line = f"{package_digest} {package.name}\n"
if checksum_line != wanted_line:
raise Refused(f"{checksum.name} does not match {package.name}")
built_digest = os.environ.get("EXPECTED_PACKAGE_SHA256")
if built_digest is not None and built_digest != package_digest:
raise Refused("selected Module Package differs from the package job's bytes")
metadata = package_metadata(package)
if metadata.get("module") != {"id": "signage", "version": version}:
raise Refused("package metadata does not identify the expected Signage version")
if metadata.get("provenance", {}).get("revision") != revision:
raise Refused("package provenance revision does not match the tag commit")
return expected
def expected_paths(directory: Path, tag: str, revision: str) -> list[Path]:
package, checksum = package_pair(directory, tag, revision)
package_digest = sha256(package)
match = ARCHIVE.fullmatch(package.name)
assert match is not None
version = match.group(1)
source = directory / f"signage-{version}-linux-x64-glibc-corresponding-source.tar.gz"
source_checksum = directory / f"{source.name}.sha256"
redistribution = directory / f"signage-{version}-linux-x64-glibc.redistribution.json"
notice = directory / f"signage-{version}-linux-x64-glibc.source-notice.txt"
expected = [package, checksum, source, source_checksum, redistribution, notice]
missing = [path.name for path in expected if not path.is_file()]
extras = sorted(path.name for path in directory.iterdir() if path.is_file() and path not in expected)
if missing or extras:
raise Refused(f"release bundle differs from the six required files; missing={missing}, extra={extras}")
source_line = source_checksum.read_text(encoding="utf-8")
wanted_source_line = f"{sha256(source)} {source.name}\n"
if source_line != wanted_source_line:
raise Refused(f"{source_checksum.name} does not match {source.name}")
binding = json.loads(redistribution.read_text(encoding="utf-8"))
package_binding = binding.get("package", {})
if package_binding.get("file") != package.name or package_binding.get("sha256") != package_digest:
raise Refused("redistribution metadata does not bind the selected Module Package")
corresponding = binding.get("correspondingSource", {})
if corresponding.get("file") != source.name or corresponding.get("sha256") != sha256(source):
raise Refused("redistribution metadata does not bind the selected corresponding source")
return expected
class Gitea:
def __init__(self) -> None:
self.server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
self.repository = os.environ.get("GITHUB_REPOSITORY", "")
self.token = os.environ.get("GITHUB_TOKEN", "")
if not self.server or not self.repository or not self.token:
raise Refused("GITHUB_SERVER_URL, GITHUB_REPOSITORY, and GITHUB_TOKEN are required")
def request(self, method: str, path: str, body: bytes | None = None,
headers: dict[str, str] | None = None, *, authenticated: bool = True,
missing_ok: bool = False) -> bytes | None:
target = path if path.startswith("http") else f"{self.server}/api/v1/repos/{self.repository}{path}"
request_headers = dict(headers or {})
if authenticated:
request_headers["Authorization"] = f"token {self.token}"
request = urllib.request.Request(target, data=body, method=method, headers=request_headers)
try:
with urllib.request.urlopen(request, timeout=120) as response:
return response.read()
except urllib.error.HTTPError as error:
if missing_ok and error.code == 404:
return None
detail = error.read()[:300].decode("utf-8", "replace")
raise Refused(f"{method} {path} answered {error.code}: {detail}") from error
def json(self, method: str, path: str, body: object | None = None,
*, missing_ok: bool = False) -> object:
encoded = json.dumps(body).encode() if body is not None else None
headers = {"Content-Type": "application/json"} if encoded is not None else None
data = self.request(method, path, encoded, headers, missing_ok=missing_ok)
return json.loads(data) if data is not None else None
def public_download(self, url: str) -> bytes:
return self.request("GET", url, authenticated=False)
def publish(directory: Path, tag: str, revision: str) -> None:
# This validates every local byte before the first API request, and therefore before creating or
# mutating a release. Corresponding-source policy verification happens in the workflow first.
files = expected_paths(directory, tag, revision)
gitea = Gitea()
quoted_tag = urllib.parse.quote(tag, safe="")
release = gitea.json("GET", f"/releases/tags/{quoted_tag}", missing_ok=True)
tagged = gitea.json("GET", f"/tags/{quoted_tag}", missing_ok=True)
if tagged is not None and (
not isinstance(tagged, dict) or tagged.get("commit", {}).get("sha") != revision
):
raise Refused(f"existing tag {tag} does not name {revision}")
if release is not None and tagged is None:
raise Refused(f"release {tag} exists without a readable tag")
if release is None:
release = gitea.json("POST", "/releases", {
"tag_name": tag,
# A full immutable commit, never a moving branch, is the release's tag target.
"target_commitish": revision,
"name": f"Ekklesia Signage {tag}",
"body": (
"Published by the manual Publish Signage Release workflow after package, "
"external integration, and corresponding-source verification."
),
# Existing Signage 0.x releases use Gitea's prerelease convention.
"draft": False,
"prerelease": True,
})
if not isinstance(release, dict):
raise Refused(f"Gitea did not return the created release {tag}")
tagged = gitea.json("GET", f"/tags/{quoted_tag}")
if not isinstance(tagged, dict) or tagged.get("commit", {}).get("sha") != revision:
raise Refused(f"created tag {tag} does not name {revision}")
elif not isinstance(release, dict):
raise Refused(f"cannot read release {tag}")
release_id = release.get("id")
if not isinstance(release_id, int):
raise Refused(f"release {tag} has no numeric id")
listed = gitea.json("GET", f"/releases/{release_id}/assets")
present = {asset["name"]: asset for asset in listed or []}
unexpected = sorted(set(present) - {path.name for path in files})
if unexpected:
raise Refused(f"release already has unexpected assets: {unexpected}")
# Preflight every published name before changing the release. This prevents a later conflict
# from leaving newly uploaded earlier files beside immutable, different bytes.
verified: dict[str, str] = {}
for path in files:
asset = present.get(path.name)
if asset is None:
continue
public_digest = hashlib.sha256(gitea.public_download(asset["browser_download_url"])).hexdigest()
expected_digest = sha256(path)
if public_digest != expected_digest:
raise Refused(f"public {path.name} has sha256 {public_digest}, expected {expected_digest}")
verified[path.name] = public_digest
for path in files:
digest = sha256(path)
asset = present.get(path.name)
if asset is None:
boundary = uuid.uuid4().hex
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n'
"Content-Type: application/octet-stream\r\n\r\n"
).encode() + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
gitea.request(
"POST",
f"/releases/{release_id}/assets?name={urllib.parse.quote(path.name)}",
body,
{"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
listed = gitea.json("GET", f"/releases/{release_id}/assets")
asset = next((item for item in listed or [] if item.get("name") == path.name), None)
if asset is None:
raise Refused(f"Gitea did not list {path.name} after upload")
public_digest = verified.get(path.name)
if public_digest is None:
public_digest = hashlib.sha256(gitea.public_download(asset["browser_download_url"])).hexdigest()
if public_digest != digest:
raise Refused(f"public {path.name} has sha256 {public_digest}, expected {digest}")
print(f"release-assets: public {path.name}, sha256 {digest}", flush=True)
def main(argv: list[str]) -> int:
try:
if len(argv) != 4 or argv[0] not in {"verify-package", "verify", "publish"}:
raise Refused(
"usage: release-assets.py (verify-package|verify|publish) DIRECTORY TAG REVISION"
)
command, directory, tag, revision = argv
selected = (package_pair(Path(directory), tag, revision)
if command == "verify-package"
else expected_paths(Path(directory), tag, revision))
if command in {"verify-package", "verify"}:
for path in selected:
print(path)
else:
publish(Path(directory), tag, revision)
return 0
except (OSError, json.JSONDecodeError, Refused) as error:
print(f"release-assets: REFUSED: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""Release selection and Gitea upload tests; no real server or credential is used."""
from __future__ import annotations
import contextlib
import hashlib
import http.server
import importlib.util
import io
import json
import os
import socketserver
import tarfile
import tempfile
import threading
import unittest
import urllib.parse
from pathlib import Path
from unittest import mock
HERE = Path(__file__).resolve().parent
SPEC = importlib.util.spec_from_file_location("release_assets", HERE / "release-assets.py")
assert SPEC is not None and SPEC.loader is not None
release_assets = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(release_assets)
VERSION = "1.2.3"
REVISION = "a" * 40
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def release_bundle(directory: Path) -> list[Path]:
package = directory / f"signage-{VERSION}-linux-x64-glibc.tar.gz"
metadata = json.dumps({
"module": {"id": "signage", "version": VERSION},
"provenance": {"revision": REVISION},
}).encode()
with tarfile.open(package, "w:gz") as archive:
member = tarfile.TarInfo("signage/ekklesia.package.json")
member.size = len(metadata)
archive.addfile(member, io.BytesIO(metadata))
checksum = directory / f"{package.name}.sha256"
checksum.write_text(f"{release_assets.sha256(package)} {package.name}\n", encoding="utf-8")
source = directory / f"signage-{VERSION}-linux-x64-glibc-corresponding-source.tar.gz"
source.write_bytes(b"corresponding source")
source_checksum = directory / f"{source.name}.sha256"
source_checksum.write_text(f"{release_assets.sha256(source)} {source.name}\n", encoding="utf-8")
redistribution = directory / f"signage-{VERSION}-linux-x64-glibc.redistribution.json"
redistribution.write_text(json.dumps({
"package": {"file": package.name, "sha256": release_assets.sha256(package)},
"correspondingSource": {"file": source.name, "sha256": release_assets.sha256(source)},
}), encoding="utf-8")
notice = directory / f"signage-{VERSION}-linux-x64-glibc.source-notice.txt"
notice.write_text("source notice\n", encoding="utf-8")
return [package, checksum, source, source_checksum, redistribution, notice]
class FakeGitea(http.server.BaseHTTPRequestHandler):
assets: dict[str, bytes] = {}
release_exists = True
tag_revision: str | None = REVISION
api_authorizations: list[str | None] = []
public_authorizations: list[str | None] = []
asset_posts = 0
release_posts = 0
release_request: dict[str, object] | None = None
def log_message(self, _format: str, *_args: object) -> None:
pass
def answer(self, status: int, body: object = None) -> None:
data = b"" if body is None else json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def asset_list(self) -> list[dict[str, object]]:
server = self.server
assert isinstance(server, socketserver.TCPServer)
host, port = server.server_address
return [
{"id": index, "name": name, "browser_download_url": f"http://{host}:{port}/public/{name}"}
for index, name in enumerate(self.assets, 1)
]
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's API
if self.path.startswith("/public/"):
self.public_authorizations.append(self.headers.get("Authorization"))
name = urllib.parse.unquote(self.path.removeprefix("/public/"))
data = self.assets[name]
self.send_response(200)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
self.api_authorizations.append(self.headers.get("Authorization"))
if "/releases/tags/" in self.path:
if self.release_exists:
self.answer(200, {"id": 7, "tag_name": f"v{VERSION}"})
else:
self.answer(404, {"message": "not found"})
elif "/tags/" in self.path:
if self.tag_revision is not None:
self.answer(200, {"commit": {"sha": self.tag_revision}})
else:
self.answer(404, {"message": "not found"})
elif self.path.endswith("/releases/7/assets"):
self.answer(200, self.asset_list())
else:
self.answer(404, {"message": "not found"})
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's API
self.api_authorizations.append(self.headers.get("Authorization"))
length = int(self.headers["Content-Length"])
body = self.rfile.read(length)
if self.path.endswith("/releases"):
request = json.loads(body)
self.__class__.release_posts += 1
self.__class__.release_request = request
self.__class__.release_exists = True
self.__class__.tag_revision = request["target_commitish"]
self.answer(201, {"id": 7, "tag_name": request["tag_name"]})
return
self.__class__.asset_posts += 1
name = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)["name"][0]
boundary = self.headers["Content-Type"].split("boundary=", 1)[1].encode()
payload = body.split(b"\r\n\r\n", 1)[1].rsplit(b"\r\n--" + boundary + b"--", 1)[0]
self.assets[name] = payload
self.answer(201, {"name": name})
@contextlib.contextmanager
def fake_gitea(initial: dict[str, bytes] | None = None, *, release_exists: bool = True,
tag_revision: str | None = REVISION):
FakeGitea.assets = dict(initial or {})
FakeGitea.release_exists = release_exists
FakeGitea.tag_revision = tag_revision
FakeGitea.api_authorizations = []
FakeGitea.public_authorizations = []
FakeGitea.asset_posts = 0
FakeGitea.release_posts = 0
FakeGitea.release_request = None
with socketserver.TCPServer(("127.0.0.1", 0), FakeGitea) as server:
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
host, port = server.server_address
old = {name: os.environ.get(name) for name in ("GITHUB_SERVER_URL", "GITHUB_REPOSITORY", "GITHUB_TOKEN")}
os.environ.update({
"GITHUB_SERVER_URL": f"http://{host}:{port}",
"GITHUB_REPOSITORY": "owner/repository",
"GITHUB_TOKEN": "test-token",
})
try:
yield
finally:
server.shutdown()
thread.join()
for name, value in old.items():
if value is None:
os.environ.pop(name, None)
else:
os.environ[name] = value
class ReleaseAssetsTest(unittest.TestCase):
def test_selects_only_the_complete_bound_bundle(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
expected = release_bundle(directory)
self.assertEqual(release_assets.expected_paths(directory, f"v{VERSION}", REVISION), expected)
(directory / "unexpected.txt").write_text("no", encoding="utf-8")
with self.assertRaisesRegex(release_assets.Refused, "six required files"):
release_assets.expected_paths(directory, f"v{VERSION}", REVISION)
def test_refuses_wrong_tag_revision_and_checksum(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
paths = release_bundle(directory)
with self.assertRaisesRegex(release_assets.Refused, "tag"):
release_assets.expected_paths(directory, "v9.9.9", REVISION)
with self.assertRaisesRegex(release_assets.Refused, "provenance"):
release_assets.expected_paths(directory, f"v{VERSION}", "b" * 40)
with mock.patch.dict(os.environ, {"EXPECTED_PACKAGE_SHA256": "0" * 64}):
with self.assertRaisesRegex(release_assets.Refused, "package job's bytes"):
release_assets.expected_paths(directory, f"v{VERSION}", REVISION)
paths[1].write_text(f"{'0' * 64} {paths[0].name}\n", encoding="utf-8")
with self.assertRaisesRegex(release_assets.Refused, "does not match"):
release_assets.expected_paths(directory, f"v{VERSION}", REVISION)
def test_creates_release_uploads_each_byte_and_reads_it_back_anonymously(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
expected = release_bundle(directory)
with fake_gitea(release_exists=False, tag_revision=None):
release_assets.publish(directory, f"v{VERSION}", REVISION)
self.assertEqual(FakeGitea.release_posts, 1)
self.assertEqual(FakeGitea.release_request, {
"tag_name": f"v{VERSION}",
"target_commitish": REVISION,
"name": f"Ekklesia Signage v{VERSION}",
"body": (
"Published by the manual Publish Signage Release workflow after package, "
"external integration, and corresponding-source verification."
),
"draft": False,
"prerelease": True,
})
self.assertEqual(FakeGitea.asset_posts, 6)
self.assertEqual(FakeGitea.assets, {path.name: path.read_bytes() for path in expected})
self.assertTrue(all(value == "token test-token" for value in FakeGitea.api_authorizations))
self.assertEqual(FakeGitea.public_authorizations, [None] * 6)
def test_existing_identical_assets_are_idempotent_but_conflicts_fail(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
expected = release_bundle(directory)
initial = {path.name: path.read_bytes() for path in expected}
with fake_gitea(initial):
release_assets.publish(directory, f"v{VERSION}", REVISION)
self.assertEqual(FakeGitea.release_posts, 0)
self.assertEqual(FakeGitea.asset_posts, 0)
conflicting = {expected[1].name: b"different published bytes"}
with fake_gitea(conflicting):
with self.assertRaisesRegex(release_assets.Refused, "public .* expected"):
release_assets.publish(directory, f"v{VERSION}", REVISION)
self.assertEqual(FakeGitea.release_posts, 0)
self.assertEqual(FakeGitea.asset_posts, 0)
def test_refuses_existing_tag_at_another_revision_before_mutation(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
release_bundle(directory)
with fake_gitea(release_exists=False, tag_revision="b" * 40):
with self.assertRaisesRegex(release_assets.Refused, "existing tag"):
release_assets.publish(directory, f"v{VERSION}", REVISION)
self.assertEqual(FakeGitea.release_posts, 0)
self.assertEqual(FakeGitea.asset_posts, 0)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -3,7 +3,7 @@
"id": "signage", "id": "signage",
"name": "Signage", "name": "Signage",
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.", "description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
"version": "0.1.2", "version": "0.1.4",
"compatibility": { "compatibility": {
"platform": "^0.2.0", "platform": "^0.2.0",
"moduleSdk": "^0.2.0" "moduleSdk": "^0.2.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ekklesia/signage", "name": "@ekklesia/signage",
"version": "0.1.2", "version": "0.1.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
@@ -59,7 +59,7 @@ function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }):
*/ */
export default defineModuleUi({ export default defineModuleUi({
id: "signage", id: "signage",
version: "0.1.2", version: "0.1.4",
styles: displayStyles, styles: displayStyles,
mount(container, context) { mount(container, context) {
const root = createRoot(container); const root = createRoot(container);
+5 -4
View File
@@ -177,10 +177,11 @@ export async function processSignageImage(bytes: Uint8Array): Promise<SignagePro
height: metadata.height as number, height: metadata.height as number,
width: metadata.width as number, width: metadata.width as number,
}; };
const [display, thumbnail] = await Promise.all([ // Keep peak native CPU and memory bounded inside the shared API process. Upload concurrency is
renderRendition(bytes, SIGNAGE_IMAGE_DISPLAY_RENDITION), // already bounded at the module UI; running both renditions for every upload in parallel would
renderRendition(bytes, SIGNAGE_IMAGE_THUMBNAIL_RENDITION), // multiply that concurrency again and can starve the database and storage clients on a small host.
]); const display = await renderRendition(bytes, SIGNAGE_IMAGE_DISPLAY_RENDITION);
const thumbnail = await renderRendition(bytes, SIGNAGE_IMAGE_THUMBNAIL_RENDITION);
return { return {
byteSize: bytes.byteLength, byteSize: bytes.byteLength,
+18 -3
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { import {
MODULE_STORAGE_ERROR_CODES,
type ModuleStorage, type ModuleStorage,
type ModuleStorageBucket, type ModuleStorageBucket,
ModuleStorageError, type ModuleStorageErrorCode,
} from "@ekklesia/module-sdk"; } from "@ekklesia/module-sdk";
import { SignageError } from "../errors.js"; import { SignageError } from "../errors.js";
import { type SignageStore, SignageStoreConflict, type StoredImage } from "../store/types.js"; import { type SignageStore, SignageStoreConflict, type StoredImage } from "../store/types.js";
@@ -17,6 +18,20 @@ export function nativeImageBucket(storage: ModuleStorage): ModuleStorageBucket {
return storage.bucket(SIGNAGE_IMAGE_BUCKET); return storage.bucket(SIGNAGE_IMAGE_BUCKET);
} }
/** Host errors cross the installed-package boundary from a different copy of the SDK, so class
* identity is not part of the contract (ADR 0049). Recognize only the SDK's closed, sanitized
* storage-error shape; provider messages and causes remain inaccessible. */
function storageErrorCode(error: unknown): ModuleStorageErrorCode | undefined {
if (!(error instanceof Error) || error.name !== "ModuleStorageError") {
return undefined;
}
const code = (error as { readonly code?: unknown }).code;
return typeof code === "string" &&
(MODULE_STORAGE_ERROR_CODES as readonly string[]).includes(code)
? (code as ModuleStorageErrorCode)
: undefined;
}
/** /**
* Object keys are scoped to the asset they belong to. * Object keys are scoped to the asset they belong to.
* *
@@ -41,7 +56,7 @@ export function renditionObjectKey(assetId: string, kind: string): string {
* not a caller's business. * not a caller's business.
*/ */
function storageFailure(error: unknown): SignageError { function storageFailure(error: unknown): SignageError {
if (error instanceof ModuleStorageError && error.code === "STORAGE_OBJECT_NOT_FOUND") { if (storageErrorCode(error) === "STORAGE_OBJECT_NOT_FOUND") {
return new SignageError("SIGNAGE_NOT_FOUND"); return new SignageError("SIGNAGE_NOT_FOUND");
} }
return new SignageError("SIGNAGE_STORAGE_UNAVAILABLE"); return new SignageError("SIGNAGE_STORAGE_UNAVAILABLE");
@@ -150,7 +165,7 @@ export async function ingestNativeImage(
if (error instanceof SignageError) { if (error instanceof SignageError) {
throw error; throw error;
} }
if (error instanceof ModuleStorageError) { if (storageErrorCode(error) !== undefined) {
throw storageFailure(error); throw storageFailure(error);
} }
throw new SignageError("SIGNAGE_OPERATION_FAILED"); throw new SignageError("SIGNAGE_OPERATION_FAILED");
+2 -2
View File
@@ -130,7 +130,7 @@ export function createSignageModuleRuntime(
// Database ABI v1 executor, which `createSignageStore` binds Signage's own Drizzle adapter to. // Database ABI v1 executor, which `createSignageStore` binds Signage's own Drizzle adapter to.
database: { abi: 1 }, database: { abi: 1 },
id: "signage", id: "signage",
version: "0.1.2", version: "0.1.4",
register({ capabilities, config, http, realtime }) { register({ capabilities, config, http, realtime }) {
const settings = readSignageSettings(config); const settings = readSignageSettings(config);
@@ -192,7 +192,7 @@ export function createSignageModuleRuntime(
// server enforces — a UI believing a stale limit would offer an upload the server refuses. // server enforces — a UI believing a stale limit would offer an upload the server refuses.
return Response.json({ return Response.json({
crossfadeMs: current.crossfadeMs, crossfadeMs: current.crossfadeMs,
moduleVersion: "0.1.2", moduleVersion: "0.1.4",
nativeImageDurationMs: current.nativeImageDurationMs, nativeImageDurationMs: current.nativeImageDurationMs,
nativePresentation: current.nativePresentation, nativePresentation: current.nativePresentation,
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION, rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
+1 -1
View File
@@ -15,7 +15,7 @@ import ownStyles from "./styles.css";
*/ */
export default defineModuleUi({ export default defineModuleUi({
id: "signage", id: "signage",
version: "0.1.2", version: "0.1.4",
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`, styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
mount(container, context) { mount(container, context) {
const root = createRoot(container); const root = createRoot(container);
+1 -1
View File
@@ -127,7 +127,7 @@ export function createFakeHost(options: FakeHostOptions): FakeHost {
/** The settings payload every screen loads first. */ /** The settings payload every screen loads first. */
export const FAKE_SETTINGS = Object.freeze({ export const FAKE_SETTINGS = Object.freeze({
crossfadeMs: 1000, crossfadeMs: 1000,
moduleVersion: "0.1.2", moduleVersion: "0.1.4",
nativeImageDurationMs: 7000, nativeImageDurationMs: 7000,
nativePresentation: { nativePresentation: {
backgroundColor: "#000000", backgroundColor: "#000000",
+25
View File
@@ -1,3 +1,4 @@
import type { ModuleStorage } from "@ekklesia/module-sdk";
import sharp from "sharp"; import sharp from "sharp";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { SignageError } from "../src/server/errors.js"; import { SignageError } from "../src/server/errors.js";
@@ -372,6 +373,30 @@ describe("native image ingest", () => {
expect(store.images.size).toBe(0); expect(store.images.size).toBe(0);
}); });
it("recognizes a sanitized storage failure from the host's separate SDK copy", async () => {
const bucket = storage.bucket("native-images");
const externalError = Object.assign(new Error("The storage service could not be reached."), {
code: "STORAGE_PROVIDER_UNAVAILABLE",
name: "ModuleStorageError",
});
const externalStorage: ModuleStorage = {
bucket: () => ({ ...bucket, put: () => Promise.reject(externalError) }),
buckets: () => ["native-images"],
};
await expectSignageCode(
ingestNativeImage({
bytes: await makeImage({ format: "png", height: 360, width: 640 }),
displayName: "welcome.png",
expiresAt: null,
filename: "welcome.png",
storage: externalStorage,
store,
}),
"SIGNAGE_STORAGE_UNAVAILABLE",
);
});
it("treats a race with an identical upload as a success rather than a failure", async () => { it("treats a race with an identical upload as a success rather than a failure", async () => {
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 }); const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
const first = await ingest(bytes); const first = await ingest(bytes);
+3 -3
View File
@@ -66,7 +66,7 @@ describe("Signage module manifest", () => {
expect(manifest.manifestVersion).toBe(8); expect(manifest.manifestVersion).toBe(8);
expect(manifest.id).toBe("signage"); expect(manifest.id).toBe("signage");
expect(manifest.name).toBe("Signage"); expect(manifest.name).toBe("Signage");
expect(manifest.version).toBe("0.1.2"); expect(manifest.version).toBe("0.1.4");
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.2.0", platform: "^0.2.0" }); expect(manifest.compatibility).toEqual({ moduleSdk: "^0.2.0", platform: "^0.2.0" });
}); });
@@ -151,7 +151,7 @@ describe("Signage module manifest", () => {
describe("Signage server runtime", () => { describe("Signage server runtime", () => {
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => { it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
// The runtime's database declaration must match the manifest's, or Core refuses to start it. // The runtime's database declaration must match the manifest's, or Core refuses to start it.
expect(moduleRuntime).toMatchObject({ database: { abi: 1 }, id: "signage", version: "0.1.2" }); expect(moduleRuntime).toMatchObject({ database: { abi: 1 }, id: "signage", version: "0.1.4" });
}); });
it("registers its routes declaratively, with no database, storage, or realtime work", async () => { it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
@@ -250,7 +250,7 @@ describe("Signage server runtime", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(await response.json()).toEqual({ expect(await response.json()).toEqual({
crossfadeMs: 400, crossfadeMs: 400,
moduleVersion: "0.1.2", moduleVersion: "0.1.4",
nativeImageDurationMs: 12_000, nativeImageDurationMs: 12_000,
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation, nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
rendererInputVersion: "1.0.0", rendererInputVersion: "1.0.0",