Compare commits
1
Commits
v0.1.4
..
6b1b29c5de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b1b29c5de |
+30
-75
@@ -1,10 +1,9 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# Signage is a standalone repository: every job installs it with --frozen-lockfile from its own
|
# Signage is a standalone repository: every job checks out only this repository, installs it with
|
||||||
# pnpm-lock.yaml and takes Ekklesia's module-development packages (@ekklesia/*) from Ekklesia's
|
# --frozen-lockfile from its own pnpm-lock.yaml, and takes Ekklesia's module-development packages
|
||||||
# public package registry (.npmrc; platform ADRs 0053 and 0054). Only the integration job fetches
|
# (@ekklesia/*) from Ekklesia's public package registry (.npmrc; platform ADRs 0053 and 0054). No job
|
||||||
# Core source: one immutable commit through the pinned Gitea pull ref in ci/platform.env, used as the
|
# clones Ekklesia.
|
||||||
# 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;
|
||||||
@@ -15,8 +14,6 @@ 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:
|
||||||
@@ -61,9 +58,7 @@ 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: |
|
run: bash ci/check-cleanup.sh
|
||||||
bash ci/check-cleanup.sh
|
|
||||||
python3 -m unittest ci/test_release_assets.py
|
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
name: typecheck
|
name: typecheck
|
||||||
@@ -154,58 +149,33 @@ 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 may not repackage.
|
# Signage inside Ekklesia, end to end, in a real browser: the platform's own Dockerfile builds the
|
||||||
package:
|
# deployment image with Signage in it, and the platform's split-topology deployment smoke runs
|
||||||
name: package
|
# against it, pairing a kiosk display and running the scenarios in ci/display-scenarios. A
|
||||||
runs-on: ubuntu-latest
|
# disposable database and object store with generated secrets; nothing is pushed or logged in to.
|
||||||
timeout-minutes: 20
|
#
|
||||||
outputs:
|
# DISABLED. From Ekklesia 0.2.0 on, the platform's image is the one generic image (platform ADR
|
||||||
sha256: ${{ steps.canonical.outputs.sha256 }}
|
# 0048): deploy/Dockerfile keeps `modules/` out of its build context and refuses one that has it,
|
||||||
steps:
|
# and the deployment smoke installs modules only as canonical Module Packages (ADR 0049). Signage is
|
||||||
- name: Check out repository
|
# not one yet: its static sharp dependency needs the native linux-x64-glibc package flow. Run as it
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
# was, this job would build an image without Signage and drive the module template's display
|
||||||
with:
|
# instead, so it is switched off rather than left reporting on something else.
|
||||||
persist-credentials: false
|
#
|
||||||
|
# It returns when Signage is built as a canonical linux-x64-glibc Module Package and installed into
|
||||||
- name: Set up Node.js at the repository's version
|
# Ekklesia's generic image; that change removes `if: false` and rewrites these steps. They still
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
# expect the platform tree ci/assemble.sh used to build at build/workspace, and that assembly is
|
||||||
with:
|
# gone now that Signage builds on its own. The platform's deployment smoke and stack sweep will
|
||||||
node-version-file: .node-version
|
# still come from Ekklesia, at the release Signage targets, because they are the platform's to run.
|
||||||
|
|
||||||
- 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
|
||||||
@@ -215,32 +185,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Node.js at the repository's version
|
- name: Fetch Ekklesia's deployment smoke and build Signage's Module Package
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
||||||
with:
|
|
||||||
node-version-file: .node-version
|
|
||||||
|
|
||||||
- name: Install (frozen lockfile)
|
|
||||||
run: |
|
run: |
|
||||||
corepack enable
|
echo "integration: rewritten when Signage is a canonical linux-x64-glibc Module Package." >&2
|
||||||
corepack install
|
exit 1
|
||||||
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/platform/scripts/ci-sweep.sh --stale
|
run: bash build/workspace/scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
- name: Install and run the prebuilt external Signage package
|
- name: Build the image and run the display scenarios
|
||||||
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,
|
||||||
@@ -248,6 +203,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/platform/scripts/ci-sweep.sh ]; then
|
if [ -f build/workspace/scripts/ci-sweep.sh ]; then
|
||||||
bash build/platform/scripts/ci-sweep.sh --job
|
bash build/workspace/scripts/ci-sweep.sh --job
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -3,7 +3,5 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.turbo/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
build/
|
|
||||||
__pycache__/
|
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Ekklesia and vendors none of its SDKs.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Version 0.1.4 is feature-complete for its declared scope, and Signage has run in production since
|
Version 0.1.2 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
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"**",
|
"**",
|
||||||
"!**/.pnpm-store",
|
"!**/.pnpm-store",
|
||||||
"!**/.turbo",
|
"!**/.turbo",
|
||||||
"!**/build",
|
|
||||||
"!**/coverage",
|
"!**/coverage",
|
||||||
"!**/dist",
|
"!**/dist",
|
||||||
"!**/node_modules",
|
"!**/node_modules",
|
||||||
|
|||||||
+3
-58
@@ -6,12 +6,11 @@
|
|||||||
#
|
#
|
||||||
# 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. The pinned platform smoke owns and labels every image and container.
|
# `if: always()` step; and the image ci/integration.sh builds must carry the CI labels the sweep finds.
|
||||||
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
|
||||||
@@ -32,67 +31,13 @@ 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_SMOKE_MODULE_RELEASE_DIR" "$root/ci/integration.sh" && grep -q "bash scripts/smoke-deploy.sh" "$root/ci/integration.sh"' \
|
check 'grep -q "EKKLESIA_CI_LABEL" "$root/ci/integration.sh" && grep -q "EKKLESIA_CI_CREATED_LABEL" "$root/ci/integration.sh"' \
|
||||||
"the integration delegates its external package to the platform smoke"
|
"the image ci/integration.sh builds carries the CI labels"
|
||||||
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
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
#!/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}"
|
|
||||||
+26
-33
@@ -1,53 +1,46 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Gives the canonical linux-x64-glibc Module Package pair from the package job to the pinned
|
# Signage inside Ekklesia, end to end: builds the deployment image from the assembled workspace with
|
||||||
# platform's generic deployment smoke. The platform image contains no Signage source:
|
# the platform's own Dockerfile, then runs the platform's split-topology deployment smoke against it
|
||||||
# its verifier extracts the package into installation-owned module storage before migrations,
|
# with the display-device scenarios listed in ci/display-scenarios. A disposable PostgreSQL and object
|
||||||
# discovery, browser UI/display checks, restart, container recreation, disable/re-enable, removal,
|
# store, generated secrets, and an explicit migration apply against that fresh database only; nothing
|
||||||
# and reinstall are exercised against disposable PostgreSQL and object-storage services.
|
# is pushed, published, or logged in to. The image is removed afterwards.
|
||||||
#
|
#
|
||||||
# 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)"
|
||||||
platform="$root/build/platform"
|
workspace="$root/build/workspace"
|
||||||
release="${1:-$root/build/module-package}"
|
[[ -f "$workspace/deploy/Dockerfile" ]] || {
|
||||||
[[ -f "$platform/scripts/smoke-deploy.sh" ]] || {
|
echo "integration: Ekklesia's deployment tree is not at build/workspace." >&2
|
||||||
echo "integration: run bash ci/fetch-platform.sh first." >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
mapfile -t archives < <(find "$release" -maxdepth 1 -type f -name 'signage-*-linux-x64-glibc.tar.gz' -print)
|
# Named and labeled as a CI stack of this run and job (the platform's scripts/lib/ci-project.sh), so
|
||||||
[[ "${#archives[@]}" -eq 1 ]] || {
|
# the workflow's final `ci-sweep.sh --job` removes it even if this script is killed before its trap.
|
||||||
echo "integration: expected exactly one prebuilt Module Package archive." >&2
|
# shellcheck source=/dev/null
|
||||||
exit 1
|
source "$workspace/scripts/lib/ci-project.sh"
|
||||||
}
|
project="$(ci_project_name signage-image)"
|
||||||
archive="${archives[0]}"
|
image="${project}:latest"
|
||||||
checksum="$archive.sha256"
|
trap 'docker image rm --force "$image" >/dev/null 2>&1 || true' EXIT
|
||||||
[[ -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}"
|
|
||||||
|
|
||||||
cd "$platform"
|
DOCKER_BUILDKIT=1 docker build --quiet --file "$workspace/deploy/Dockerfile" --tag "$image" \
|
||||||
|
--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_MODULE_RELEASE_DIR="$release" \
|
EKKLESIA_SMOKE_IMAGE="$image" \
|
||||||
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 \
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
#!/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")."
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
#!/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:]))
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -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.4",
|
"version": "0.1.2",
|
||||||
"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.4",
|
"version": "0.1.2",
|
||||||
"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.4",
|
version: "0.1.2",
|
||||||
styles: displayStyles,
|
styles: displayStyles,
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|||||||
@@ -177,11 +177,10 @@ 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,
|
||||||
};
|
};
|
||||||
// Keep peak native CPU and memory bounded inside the shared API process. Upload concurrency is
|
const [display, thumbnail] = await Promise.all([
|
||||||
// already bounded at the module UI; running both renditions for every upload in parallel would
|
renderRendition(bytes, SIGNAGE_IMAGE_DISPLAY_RENDITION),
|
||||||
// multiply that concurrency again and can starve the database and storage clients on a small host.
|
renderRendition(bytes, SIGNAGE_IMAGE_THUMBNAIL_RENDITION),
|
||||||
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,
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
MODULE_STORAGE_ERROR_CODES,
|
|
||||||
type ModuleStorage,
|
type ModuleStorage,
|
||||||
type ModuleStorageBucket,
|
type ModuleStorageBucket,
|
||||||
type ModuleStorageErrorCode,
|
ModuleStorageError,
|
||||||
} 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";
|
||||||
@@ -18,20 +17,6 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -56,7 +41,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 (storageErrorCode(error) === "STORAGE_OBJECT_NOT_FOUND") {
|
if (error instanceof ModuleStorageError && error.code === "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");
|
||||||
@@ -165,7 +150,7 @@ export async function ingestNativeImage(
|
|||||||
if (error instanceof SignageError) {
|
if (error instanceof SignageError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (storageErrorCode(error) !== undefined) {
|
if (error instanceof ModuleStorageError) {
|
||||||
throw storageFailure(error);
|
throw storageFailure(error);
|
||||||
}
|
}
|
||||||
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||||
|
|||||||
@@ -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.4",
|
version: "0.1.2",
|
||||||
|
|
||||||
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.4",
|
moduleVersion: "0.1.2",
|
||||||
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.4",
|
version: "0.1.2",
|
||||||
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.4",
|
moduleVersion: "0.1.2",
|
||||||
nativeImageDurationMs: 7000,
|
nativeImageDurationMs: 7000,
|
||||||
nativePresentation: {
|
nativePresentation: {
|
||||||
backgroundColor: "#000000",
|
backgroundColor: "#000000",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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";
|
||||||
@@ -373,30 +372,6 @@ 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);
|
||||||
|
|||||||
@@ -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.4");
|
expect(manifest.version).toBe("0.1.2");
|
||||||
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.4" });
|
expect(moduleRuntime).toMatchObject({ database: { abi: 1 }, id: "signage", version: "0.1.2" });
|
||||||
});
|
});
|
||||||
|
|
||||||
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.4",
|
moduleVersion: "0.1.2",
|
||||||
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