Author SHA1 Message Date
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
16 changed files with 863 additions and 26 deletions
+50 -2
View File
@@ -15,6 +15,8 @@ name: CI
#
# 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.
# 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:
pull_request:
@@ -59,7 +61,9 @@ jobs:
run: pnpm lint
- 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:
name: typecheck
@@ -150,6 +154,42 @@ jobs:
- name: Validate the built module and its migrations
run: pnpm validate
# Build the canonical pair once. Integration downloads these exact bytes and may not repackage.
package:
name: package
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
sha256: ${{ steps.canonical.outputs.sha256 }}
steps:
- name: Check out repository
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 (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
@@ -160,10 +200,12 @@ jobs:
needs:
- test
- build
- package
runs-on: ubuntu-latest
timeout-minutes: 45
env:
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
# scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: signage-integration
@@ -184,6 +226,12 @@ jobs:
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
@@ -192,7 +240,7 @@ jobs:
- name: Sweep stale CI stacks from the runner
run: bash build/platform/scripts/ci-sweep.sh --stale
- name: Build, install, and run the external Signage package
- name: Install and run the prebuilt external Signage package
run: bash ci/integration.sh
# Whatever happened above, this job's own stacks do not outlive it. If the first step failed,
+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"
+1
View File
@@ -4,5 +4,6 @@ dist/
.turbo/
coverage/
build/
__pycache__/
*.log
.DS_Store
+1 -1
View File
@@ -16,7 +16,7 @@ Ekklesia and vendors none of its SDKs.
## Status
Version 0.1.2 is feature-complete for its declared scope, and Signage has run in production since
Version 0.1.3 is feature-complete for its declared scope, and Signage has run in production since
0.1.1. What exists today:
- The module package, manifest (v8), four capabilities, six permissions, ten operator settings, and
+50
View File
@@ -11,6 +11,7 @@ set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
workflow="$root/.gitea/workflows/ci.yml"
publish_workflow="$root/.gitea/workflows/publish-release.yml"
failures=0
check() {
if eval "$1"; then
@@ -44,6 +45,55 @@ check 'grep -Eq "^EKKLESIA_PLATFORM_REF=refs/(pull/[0-9]+/head|heads/main)$" "$r
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)) || {
echo "check-cleanup: ${failures} check(s) failed." >&2
exit 1
+20 -13
View File
@@ -1,40 +1,47 @@
#!/usr/bin/env bash
# Builds Signage as the canonical linux-x64-glibc Module Package, then gives that exact release pair
# to the pinned platform's generic deployment smoke. The platform image contains no Signage source:
# Gives the canonical linux-x64-glibc Module Package pair from the package job to the pinned
# platform's generic deployment smoke. The platform image contains no Signage source:
# its verifier extracts the package into installation-owned module storage before migrations,
# discovery, browser UI/display checks, restart, container recreation, disable/re-enable, removal,
# and reinstall are exercised against disposable PostgreSQL and object-storage services.
#
# bash ci/package.sh
# bash ci/fetch-platform.sh
# bash ci/integration.sh
set -Eeuo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
platform="$root/build/platform"
release="$root/build/module-package"
release="${1:-$root/build/module-package}"
[[ -f "$platform/scripts/smoke-deploy.sh" ]] || {
echo "integration: run bash ci/fetch-platform.sh first." >&2
exit 1
}
source_revision="${GITHUB_SHA:-$(git -C "$root" rev-parse HEAD 2>/dev/null || true)}"
[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || {
echo "integration: no full source revision is available for the package." >&2
mapfile -t archives < <(find "$release" -maxdepth 1 -type f -name 'signage-*-linux-x64-glibc.tar.gz' -print)
[[ "${#archives[@]}" -eq 1 ]] || {
echo "integration: expected exactly one prebuilt Module Package archive." >&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"
archive="${archives[0]}"
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' ' ')"
[[ -n "${scenarios// /}" ]] || {
echo "integration: ci/display-scenarios lists no scenario." >&2
exit 1
}
echo "integration: external Signage package built; scenarios: ${scenarios}"
echo "integration: consuming prebuilt $(basename "$archive"); scenarios: ${scenarios}"
cd "$platform"
EKKLESIA_SMOKE_TOPOLOGY=split \
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")."
+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",
"name": "Signage",
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
"version": "0.1.2",
"version": "0.1.3",
"compatibility": {
"platform": "^0.2.0",
"moduleSdk": "^0.2.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ekklesia/signage",
"version": "0.1.2",
"version": "0.1.3",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+1 -1
View File
@@ -59,7 +59,7 @@ function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }):
*/
export default defineModuleUi({
id: "signage",
version: "0.1.2",
version: "0.1.3",
styles: displayStyles,
mount(container, context) {
const root = createRoot(container);
+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: 1 },
id: "signage",
version: "0.1.2",
version: "0.1.3",
register({ capabilities, config, http, realtime }) {
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.
return Response.json({
crossfadeMs: current.crossfadeMs,
moduleVersion: "0.1.2",
moduleVersion: "0.1.3",
nativeImageDurationMs: current.nativeImageDurationMs,
nativePresentation: current.nativePresentation,
rendererInputVersion: SIGNAGE_SLIDE_RENDERER_INPUT_VERSION,
+1 -1
View File
@@ -15,7 +15,7 @@ import ownStyles from "./styles.css";
*/
export default defineModuleUi({
id: "signage",
version: "0.1.2",
version: "0.1.3",
styles: `${EKKLESIA_UI_STYLESHEET}\n${ownStyles}`,
mount(container, context) {
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. */
export const FAKE_SETTINGS = Object.freeze({
crossfadeMs: 1000,
moduleVersion: "0.1.2",
moduleVersion: "0.1.3",
nativeImageDurationMs: 7000,
nativePresentation: {
backgroundColor: "#000000",
+3 -3
View File
@@ -66,7 +66,7 @@ describe("Signage module manifest", () => {
expect(manifest.manifestVersion).toBe(8);
expect(manifest.id).toBe("signage");
expect(manifest.name).toBe("Signage");
expect(manifest.version).toBe("0.1.2");
expect(manifest.version).toBe("0.1.3");
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.2.0", platform: "^0.2.0" });
});
@@ -151,7 +151,7 @@ describe("Signage module manifest", () => {
describe("Signage server runtime", () => {
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.
expect(moduleRuntime).toMatchObject({ database: { abi: 1 }, id: "signage", version: "0.1.2" });
expect(moduleRuntime).toMatchObject({ database: { abi: 1 }, id: "signage", version: "0.1.3" });
});
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(await response.json()).toEqual({
crossfadeMs: 400,
moduleVersion: "0.1.2",
moduleVersion: "0.1.3",
nativeImageDurationMs: 12_000,
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
rendererInputVersion: "1.0.0",