Compare commits
4
Commits
8a7e96dd61
..
v0.1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86d49846ff | ||
|
|
a8c35db201 | ||
|
|
91bdba3136 | ||
|
|
86121d7931 |
+2
-72
@@ -16,15 +16,13 @@ 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
|
# Artifact actions v3 are deliberate: Gitea implements that protocol, while v4 refuses non-GitHub
|
||||||
# servers. A tag job uses Gitea's automatic per-job token only after every required job succeeds.
|
# servers. This workflow never publishes or mutates a release; publishing is a separate manual gate.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -156,8 +154,7 @@ jobs:
|
|||||||
- name: Validate the built module and its migrations
|
- name: Validate the built module and its migrations
|
||||||
run: pnpm validate
|
run: pnpm validate
|
||||||
|
|
||||||
# Build the canonical pair once. Integration downloads these exact bytes, and a successful tag
|
# Build the canonical pair once. Integration downloads these exact bytes and may not repackage.
|
||||||
# run's release job later downloads the same artifact; neither consumer is allowed to repackage.
|
|
||||||
package:
|
package:
|
||||||
name: package
|
name: package
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -254,70 +251,3 @@ jobs:
|
|||||||
if [ -f build/platform/scripts/ci-sweep.sh ]; then
|
if [ -f build/platform/scripts/ci-sweep.sh ]; then
|
||||||
bash build/platform/scripts/ci-sweep.sh --job
|
bash build/platform/scripts/ci-sweep.sh --job
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Existing Signage releases and tags are created by a person. On a v* tag, attach only the
|
|
||||||
# package pair this run's integration consumed, plus ADR 0055's required corresponding-source
|
|
||||||
# material. No release or tag is created here, and an existing asset is never replaced.
|
|
||||||
release:
|
|
||||||
name: release
|
|
||||||
needs:
|
|
||||||
- lint
|
|
||||||
- typecheck
|
|
||||||
- test
|
|
||||||
- build
|
|
||||||
- package
|
|
||||||
- integration
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
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 the reviewed release tooling
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack install
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Receive the exact pair integration tested
|
|
||||||
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
|
||||||
with:
|
|
||||||
name: signage-module-package
|
|
||||||
path: build/release-assets/
|
|
||||||
|
|
||||||
- name: Verify tag, revision, package bytes, and public-distribution material
|
|
||||||
env:
|
|
||||||
EXPECTED_PACKAGE_SHA256: ${{ needs.package.outputs.sha256 }}
|
|
||||||
RELEASE_TAG: ${{ github.ref_name }}
|
|
||||||
RELEASE_REVISION: ${{ github.sha }}
|
|
||||||
run: |
|
|
||||||
python3 ci/release-assets.py verify-package build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
|
|
||||||
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"
|
|
||||||
|
|
||||||
- name: Attach the exact tested package and its required source material
|
|
||||||
env:
|
|
||||||
EXPECTED_PACKAGE_SHA256: ${{ needs.package.outputs.sha256 }}
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
RELEASE_TAG: ${{ github.ref_name }}
|
|
||||||
RELEASE_REVISION: ${{ github.sha }}
|
|
||||||
run: python3 ci/release-assets.py upload build/release-assets "$RELEASE_TAG" "$RELEASE_REVISION"
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -16,7 +16,7 @@ Ekklesia and vendors none of its SDKs.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.1.3 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
|
||||||
|
|||||||
+42
-17
@@ -11,6 +11,7 @@ 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
|
||||||
@@ -44,30 +45,54 @@ 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"' \
|
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"
|
"the fetch verifies that the pinned ref resolves to the pinned commit"
|
||||||
|
|
||||||
# The package job is the only place that builds the canonical archive. Both integration and release
|
# Normal CI builds the canonical archive once and hands those exact bytes to integration through a
|
||||||
# receive the same Gitea Actions v3 artifact, and release cannot run until every required job passed.
|
# 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")"
|
package_job="$(awk '/^ package:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$workflow")"
|
||||||
release_job="$(awk '/^ release:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$workflow")"
|
check 'grep -q "pull_request:" "$workflow" && grep -q "push:" "$workflow"' \
|
||||||
check 'grep -q "tags:" "$workflow" && grep -q '\"v\*\"' "$workflow"' \
|
"normal CI still runs for pull requests and main pushes"
|
||||||
"v tags start the gated workflow"
|
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"' \
|
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"
|
"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"' \
|
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"
|
"integration downloads that artifact and never rebuilds it"
|
||||||
check 'grep -q "needs.package.outputs.sha256" <<<"$job" && grep -q "GITHUB_OUTPUT" "$root/ci/package.sh"' \
|
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"
|
"integration binds the downloaded bytes to the package job's digest"
|
||||||
check 'for required in lint typecheck test build package integration; do grep -q " - $required" <<<"$release_job" || exit 1; done' \
|
|
||||||
"release waits for every required job"
|
# Publishing is an explicit operator action. One job validates its dispatch input and selected ref,
|
||||||
check 'grep -q "if: startsWith(github.ref, '\''refs/tags/v'\'')" <<<"$release_job" && grep -q "contents: write" <<<"$release_job"' \
|
# builds the package exactly once, integration-tests that directory, prepares redistribution files,
|
||||||
"only a v tag gives the release job write permission"
|
# and only then gives the same directory to the Gitea publisher.
|
||||||
check 'grep -q "actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b" <<<"$release_job" && ! grep -Eq "ci/package.sh|ekklesia-pack-module" <<<"$release_job"' \
|
gate_job="$(awk '/^ release-gate:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$publish_workflow")"
|
||||||
"release downloads the tested artifact and never rebuilds it"
|
publish_job="$(awk '/^ publish:$/ { inside = 1; print; next } inside && /^ [a-z][a-z-]*:$/ { exit } inside { print }' "$publish_workflow")"
|
||||||
check 'grep -q "EXPECTED_PACKAGE_SHA256:.*needs.package.outputs.sha256" <<<"$release_job"' \
|
check 'grep -q "workflow_dispatch:" "$publish_workflow" && ! grep -q "pull_request:" "$publish_workflow" && ! grep -q "push:" "$publish_workflow"' \
|
||||||
"release binds its selected and uploaded bytes to the package job's digest"
|
"publishing is workflow_dispatch only and cannot run for a PR or push"
|
||||||
check 'grep -q "ekklesia-corresponding-source verify" <<<"$release_job" && grep -q "release-assets.py upload" <<<"$release_job"' \
|
check 'grep -q "inputs:" "$publish_workflow" && grep -q " version:" "$publish_workflow" && grep -q "required: true" "$publish_workflow"' \
|
||||||
"the redistribution gate runs before the exact assets are uploaded"
|
"manual publishing requires an explicit version input"
|
||||||
check 'grep -q "GITHUB_TOKEN:.*github.token" <<<"$release_job"' \
|
check 'grep -q "refs/heads/main" <<<"$gate_job" && grep -q "packageVersion.*requested" <<<"$gate_job" && grep -q "moduleVersion.*requested" <<<"$gate_job"' \
|
||||||
"upload uses Gitea's automatic per-job token"
|
"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
|
||||||
|
|||||||
+48
-13
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Validate and attach one tested Signage release bundle to an existing Gitea release."""
|
"""Validate and publish one tested Signage release bundle through the Gitea API."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -124,7 +124,8 @@ class Gitea:
|
|||||||
raise Refused("GITHUB_SERVER_URL, GITHUB_REPOSITORY, and GITHUB_TOKEN are required")
|
raise Refused("GITHUB_SERVER_URL, GITHUB_REPOSITORY, and GITHUB_TOKEN are required")
|
||||||
|
|
||||||
def request(self, method: str, path: str, body: bytes | None = None,
|
def request(self, method: str, path: str, body: bytes | None = None,
|
||||||
headers: dict[str, str] | None = None, *, authenticated: bool = True) -> bytes:
|
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}"
|
target = path if path.startswith("http") else f"{self.server}/api/v1/repos/{self.repository}{path}"
|
||||||
request_headers = dict(headers or {})
|
request_headers = dict(headers or {})
|
||||||
if authenticated:
|
if authenticated:
|
||||||
@@ -134,29 +135,61 @@ class Gitea:
|
|||||||
with urllib.request.urlopen(request, timeout=120) as response:
|
with urllib.request.urlopen(request, timeout=120) as response:
|
||||||
return response.read()
|
return response.read()
|
||||||
except urllib.error.HTTPError as error:
|
except urllib.error.HTTPError as error:
|
||||||
|
if missing_ok and error.code == 404:
|
||||||
|
return None
|
||||||
detail = error.read()[:300].decode("utf-8", "replace")
|
detail = error.read()[:300].decode("utf-8", "replace")
|
||||||
raise Refused(f"{method} {path} answered {error.code}: {detail}") from error
|
raise Refused(f"{method} {path} answered {error.code}: {detail}") from error
|
||||||
|
|
||||||
def json(self, method: str, path: str) -> object:
|
def json(self, method: str, path: str, body: object | None = None,
|
||||||
data = self.request(method, path)
|
*, missing_ok: bool = False) -> object:
|
||||||
return json.loads(data) if data else None
|
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:
|
def public_download(self, url: str) -> bytes:
|
||||||
return self.request("GET", url, authenticated=False)
|
return self.request("GET", url, authenticated=False)
|
||||||
|
|
||||||
|
|
||||||
def upload(directory: Path, tag: str, revision: str) -> None:
|
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)
|
files = expected_paths(directory, tag, revision)
|
||||||
gitea = Gitea()
|
gitea = Gitea()
|
||||||
quoted_tag = urllib.parse.quote(tag, safe="")
|
quoted_tag = urllib.parse.quote(tag, safe="")
|
||||||
release = gitea.json("GET", f"/releases/tags/{quoted_tag}")
|
release = gitea.json("GET", f"/releases/tags/{quoted_tag}", missing_ok=True)
|
||||||
tagged = gitea.json("GET", f"/tags/{quoted_tag}")
|
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):
|
if not isinstance(release, dict):
|
||||||
raise Refused(f"release {tag} does not exist")
|
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:
|
if not isinstance(tagged, dict) or tagged.get("commit", {}).get("sha") != revision:
|
||||||
raise Refused(f"tag {tag} does not name {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")
|
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")
|
listed = gitea.json("GET", f"/releases/{release_id}/assets")
|
||||||
present = {asset["name"]: asset for asset in listed or []}
|
present = {asset["name"]: asset for asset in listed or []}
|
||||||
unexpected = sorted(set(present) - {path.name for path in files})
|
unexpected = sorted(set(present) - {path.name for path in files})
|
||||||
@@ -207,8 +240,10 @@ def upload(directory: Path, tag: str, revision: str) -> None:
|
|||||||
|
|
||||||
def main(argv: list[str]) -> int:
|
def main(argv: list[str]) -> int:
|
||||||
try:
|
try:
|
||||||
if len(argv) != 4 or argv[0] not in {"verify-package", "verify", "upload"}:
|
if len(argv) != 4 or argv[0] not in {"verify-package", "verify", "publish"}:
|
||||||
raise Refused("usage: release-assets.py (verify-package|verify|upload) DIRECTORY TAG REVISION")
|
raise Refused(
|
||||||
|
"usage: release-assets.py (verify-package|verify|publish) DIRECTORY TAG REVISION"
|
||||||
|
)
|
||||||
command, directory, tag, revision = argv
|
command, directory, tag, revision = argv
|
||||||
selected = (package_pair(Path(directory), tag, revision)
|
selected = (package_pair(Path(directory), tag, revision)
|
||||||
if command == "verify-package"
|
if command == "verify-package"
|
||||||
@@ -217,7 +252,7 @@ def main(argv: list[str]) -> int:
|
|||||||
for path in selected:
|
for path in selected:
|
||||||
print(path)
|
print(path)
|
||||||
else:
|
else:
|
||||||
upload(Path(directory), tag, revision)
|
publish(Path(directory), tag, revision)
|
||||||
return 0
|
return 0
|
||||||
except (OSError, json.JSONDecodeError, Refused) as error:
|
except (OSError, json.JSONDecodeError, Refused) as error:
|
||||||
print(f"release-assets: REFUSED: {error}", file=sys.stderr)
|
print(f"release-assets: REFUSED: {error}", file=sys.stderr)
|
||||||
|
|||||||
+63
-15
@@ -63,9 +63,13 @@ def release_bundle(directory: Path) -> list[Path]:
|
|||||||
|
|
||||||
class FakeGitea(http.server.BaseHTTPRequestHandler):
|
class FakeGitea(http.server.BaseHTTPRequestHandler):
|
||||||
assets: dict[str, bytes] = {}
|
assets: dict[str, bytes] = {}
|
||||||
|
release_exists = True
|
||||||
|
tag_revision: str | None = REVISION
|
||||||
api_authorizations: list[str | None] = []
|
api_authorizations: list[str | None] = []
|
||||||
public_authorizations: list[str | None] = []
|
public_authorizations: list[str | None] = []
|
||||||
posts = 0
|
asset_posts = 0
|
||||||
|
release_posts = 0
|
||||||
|
release_request: dict[str, object] | None = None
|
||||||
|
|
||||||
def log_message(self, _format: str, *_args: object) -> None:
|
def log_message(self, _format: str, *_args: object) -> None:
|
||||||
pass
|
pass
|
||||||
@@ -100,9 +104,15 @@ class FakeGitea(http.server.BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
self.api_authorizations.append(self.headers.get("Authorization"))
|
self.api_authorizations.append(self.headers.get("Authorization"))
|
||||||
if "/releases/tags/" in self.path:
|
if "/releases/tags/" in self.path:
|
||||||
self.answer(200, {"id": 7})
|
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:
|
elif "/tags/" in self.path:
|
||||||
self.answer(200, {"commit": {"sha": REVISION}})
|
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"):
|
elif self.path.endswith("/releases/7/assets"):
|
||||||
self.answer(200, self.asset_list())
|
self.answer(200, self.asset_list())
|
||||||
else:
|
else:
|
||||||
@@ -110,10 +120,19 @@ class FakeGitea(http.server.BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's API
|
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's API
|
||||||
self.api_authorizations.append(self.headers.get("Authorization"))
|
self.api_authorizations.append(self.headers.get("Authorization"))
|
||||||
self.__class__.posts += 1
|
|
||||||
name = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)["name"][0]
|
|
||||||
length = int(self.headers["Content-Length"])
|
length = int(self.headers["Content-Length"])
|
||||||
body = self.rfile.read(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()
|
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]
|
payload = body.split(b"\r\n\r\n", 1)[1].rsplit(b"\r\n--" + boundary + b"--", 1)[0]
|
||||||
self.assets[name] = payload
|
self.assets[name] = payload
|
||||||
@@ -121,11 +140,16 @@ class FakeGitea(http.server.BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def fake_gitea(initial: dict[str, bytes] | None = None):
|
def fake_gitea(initial: dict[str, bytes] | None = None, *, release_exists: bool = True,
|
||||||
|
tag_revision: str | None = REVISION):
|
||||||
FakeGitea.assets = dict(initial or {})
|
FakeGitea.assets = dict(initial or {})
|
||||||
|
FakeGitea.release_exists = release_exists
|
||||||
|
FakeGitea.tag_revision = tag_revision
|
||||||
FakeGitea.api_authorizations = []
|
FakeGitea.api_authorizations = []
|
||||||
FakeGitea.public_authorizations = []
|
FakeGitea.public_authorizations = []
|
||||||
FakeGitea.posts = 0
|
FakeGitea.asset_posts = 0
|
||||||
|
FakeGitea.release_posts = 0
|
||||||
|
FakeGitea.release_request = None
|
||||||
with socketserver.TCPServer(("127.0.0.1", 0), FakeGitea) as server:
|
with socketserver.TCPServer(("127.0.0.1", 0), FakeGitea) as server:
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -174,13 +198,25 @@ class ReleaseAssetsTest(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(release_assets.Refused, "does not match"):
|
with self.assertRaisesRegex(release_assets.Refused, "does not match"):
|
||||||
release_assets.expected_paths(directory, f"v{VERSION}", REVISION)
|
release_assets.expected_paths(directory, f"v{VERSION}", REVISION)
|
||||||
|
|
||||||
def test_uploads_each_selected_byte_and_reads_it_back_anonymously(self) -> None:
|
def test_creates_release_uploads_each_byte_and_reads_it_back_anonymously(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
directory = Path(temporary)
|
directory = Path(temporary)
|
||||||
expected = release_bundle(directory)
|
expected = release_bundle(directory)
|
||||||
with fake_gitea():
|
with fake_gitea(release_exists=False, tag_revision=None):
|
||||||
release_assets.upload(directory, f"v{VERSION}", REVISION)
|
release_assets.publish(directory, f"v{VERSION}", REVISION)
|
||||||
self.assertEqual(FakeGitea.posts, 6)
|
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.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.assertTrue(all(value == "token test-token" for value in FakeGitea.api_authorizations))
|
||||||
self.assertEqual(FakeGitea.public_authorizations, [None] * 6)
|
self.assertEqual(FakeGitea.public_authorizations, [None] * 6)
|
||||||
@@ -191,14 +227,26 @@ class ReleaseAssetsTest(unittest.TestCase):
|
|||||||
expected = release_bundle(directory)
|
expected = release_bundle(directory)
|
||||||
initial = {path.name: path.read_bytes() for path in expected}
|
initial = {path.name: path.read_bytes() for path in expected}
|
||||||
with fake_gitea(initial):
|
with fake_gitea(initial):
|
||||||
release_assets.upload(directory, f"v{VERSION}", REVISION)
|
release_assets.publish(directory, f"v{VERSION}", REVISION)
|
||||||
self.assertEqual(FakeGitea.posts, 0)
|
self.assertEqual(FakeGitea.release_posts, 0)
|
||||||
|
self.assertEqual(FakeGitea.asset_posts, 0)
|
||||||
|
|
||||||
conflicting = {expected[1].name: b"different published bytes"}
|
conflicting = {expected[1].name: b"different published bytes"}
|
||||||
with fake_gitea(conflicting):
|
with fake_gitea(conflicting):
|
||||||
with self.assertRaisesRegex(release_assets.Refused, "public .* expected"):
|
with self.assertRaisesRegex(release_assets.Refused, "public .* expected"):
|
||||||
release_assets.upload(directory, f"v{VERSION}", REVISION)
|
release_assets.publish(directory, f"v{VERSION}", REVISION)
|
||||||
self.assertEqual(FakeGitea.posts, 0)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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.3",
|
"version": "0.1.4",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.2.0",
|
"platform": "^0.2.0",
|
||||||
"moduleSdk": "^0.2.0"
|
"moduleSdk": "^0.2.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/signage",
|
"name": "@ekklesia/signage",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function SignageDisplay({ context }: { readonly context: ModuleUiHostContext }):
|
|||||||
*/
|
*/
|
||||||
export default defineModuleUi({
|
export default defineModuleUi({
|
||||||
id: "signage",
|
id: "signage",
|
||||||
version: "0.1.3",
|
version: "0.1.4",
|
||||||
styles: displayStyles,
|
styles: displayStyles,
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|||||||
@@ -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.3",
|
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.3",
|
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
@@ -15,7 +15,7 @@ import ownStyles from "./styles.css";
|
|||||||
*/
|
*/
|
||||||
export default defineModuleUi({
|
export default defineModuleUi({
|
||||||
id: "signage",
|
id: "signage",
|
||||||
version: "0.1.3",
|
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);
|
||||||
|
|||||||
@@ -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.3",
|
moduleVersion: "0.1.4",
|
||||||
nativeImageDurationMs: 7000,
|
nativeImageDurationMs: 7000,
|
||||||
nativePresentation: {
|
nativePresentation: {
|
||||||
backgroundColor: "#000000",
|
backgroundColor: "#000000",
|
||||||
|
|||||||
@@ -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.3");
|
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.3" });
|
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.3",
|
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",
|
||||||
|
|||||||
Reference in New Issue
Block a user