diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f9b64bd..21a525d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,9 +1,10 @@ name: CI -# Signage is a standalone repository: every job checks out only this repository, installs it with -# --frozen-lockfile from its own pnpm-lock.yaml, and takes Ekklesia's module-development packages -# (@ekklesia/*) from Ekklesia's public package registry (.npmrc; platform ADRs 0053 and 0054). No job -# clones Ekklesia. +# Signage is a standalone repository: every job installs it with --frozen-lockfile from its own +# pnpm-lock.yaml and takes Ekklesia's module-development packages (@ekklesia/*) from Ekklesia's +# public package registry (.npmrc; platform ADRs 0053 and 0054). Only the integration job fetches +# Core source: one immutable commit through the pinned Gitea pull ref in ci/platform.env, used as the +# generic deployment harness and image source, never as a workspace dependency. # # The same trust rules as the platform's own CI (docs/development/security.md there): # * every action is pinned by a full commit SHA; @@ -149,26 +150,13 @@ jobs: - name: Validate the built module and its migrations run: pnpm validate - # Signage inside Ekklesia, end to end, in a real browser: the platform's own Dockerfile builds the - # deployment image with Signage in it, and the platform's split-topology deployment smoke runs - # against it, pairing a kiosk display and running the scenarios in ci/display-scenarios. A - # disposable database and object store with generated secrets; nothing is pushed or logged in to. - # - # DISABLED. From Ekklesia 0.2.0 on, the platform's image is the one generic image (platform ADR - # 0048): deploy/Dockerfile keeps `modules/` out of its build context and refuses one that has it, - # and the deployment smoke installs modules only as canonical Module Packages (ADR 0049). Signage is - # not one yet: its static sharp dependency needs the native linux-x64-glibc package flow. Run as it - # was, this job would build an image without Signage and drive the module template's display - # instead, so it is switched off rather than left reporting on something else. - # - # It returns when Signage is built as a canonical linux-x64-glibc Module Package and installed into - # Ekklesia's generic image; that change removes `if: false` and rewrites these steps. They still - # expect the platform tree ci/assemble.sh used to build at build/workspace, and that assembly is - # gone now that Signage builds on its own. The platform's deployment smoke and stack sweep will - # still come from Ekklesia, at the release Signage targets, because they are the platform's to run. + # 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: name: integration - if: false needs: - test - build @@ -185,17 +173,26 @@ jobs: with: persist-credentials: false - - name: Fetch Ekklesia's deployment smoke and build Signage's Module Package + - name: Set up Node.js at the repository's version + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .node-version + + - name: Install (frozen lockfile) run: | - echo "integration: rewritten when Signage is a canonical linux-x64-glibc Module Package." >&2 - exit 1 + corepack enable + corepack install + pnpm install --frozen-lockfile + + - 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 # 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 - run: bash build/workspace/scripts/ci-sweep.sh --stale + run: bash build/platform/scripts/ci-sweep.sh --stale - - name: Build the image and run the display scenarios + - name: Build, install, and run the external Signage package run: bash ci/integration.sh # Whatever happened above, this job's own stacks do not outlive it. If the first step failed, @@ -203,6 +200,6 @@ jobs: - name: Remove this job's CI stacks if: always() run: | - if [ -f build/workspace/scripts/ci-sweep.sh ]; then - bash build/workspace/scripts/ci-sweep.sh --job + if [ -f build/platform/scripts/ci-sweep.sh ]; then + bash build/platform/scripts/ci-sweep.sh --job fi diff --git a/.gitignore b/.gitignore index 04798f7..e9aa3bc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ node_modules/ dist/ .turbo/ coverage/ +build/ *.log .DS_Store diff --git a/biome.json b/biome.json index 69ced2b..c5eec18 100644 --- a/biome.json +++ b/biome.json @@ -5,6 +5,7 @@ "**", "!**/.pnpm-store", "!**/.turbo", + "!**/build", "!**/coverage", "!**/dist", "!**/node_modules", diff --git a/ci/check-cleanup.sh b/ci/check-cleanup.sh index cf50927..f45fd69 100755 --- a/ci/check-cleanup.sh +++ b/ci/check-cleanup.sh @@ -6,7 +6,7 @@ # # 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 -# `if: always()` step; and the image ci/integration.sh builds must carry the CI labels the sweep finds. +# `if: always()` step. The pinned platform smoke owns and labels every image and container. set -Eeuo pipefail root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" @@ -31,13 +31,18 @@ own="$(line_of 'ci-sweep.sh --job')" last_step="$(grep -n -- " - name:" <<<"$job" | tail -1 | cut -d: -f1 || true)" 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 '[[ -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))' \ "its last step removes its own stacks" check 'sed -n "${last_step},\$p" <<<"$job" | grep -q "if: always()"' "that last step always runs" -check 'grep -q "EKKLESIA_CI_LABEL" "$root/ci/integration.sh" && grep -q "EKKLESIA_CI_CREATED_LABEL" "$root/ci/integration.sh"' \ - "the image ci/integration.sh builds carries the CI labels" +check 'grep -q "EKKLESIA_SMOKE_MODULE_RELEASE_DIR" "$root/ci/integration.sh" && grep -q "bash scripts/smoke-deploy.sh" "$root/ci/integration.sh"' \ + "the integration delegates its external package to the platform smoke" +check 'grep -Eq "^EKKLESIA_PLATFORM_REF=refs/(pull/[0-9]+/head|heads/main)$" "$root/ci/platform.env" && grep -Eq "^EKKLESIA_PLATFORM_COMMIT=[0-9a-f]{40}$" "$root/ci/platform.env"' \ + "the platform harness is pinned by full ref and commit" +check 'grep -q "rev-parse refs/remotes/pinned" "$root/ci/fetch-platform.sh" && grep -q "does not resolve to EKKLESIA_PLATFORM_COMMIT" "$root/ci/fetch-platform.sh"' \ + "the fetch verifies that the pinned ref resolves to the pinned commit" ((failures == 0)) || { echo "check-cleanup: ${failures} check(s) failed." >&2 diff --git a/ci/fetch-platform.sh b/ci/fetch-platform.sh new file mode 100755 index 0000000..3ac82cf --- /dev/null +++ b/ci/fetch-platform.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Fetches the exact Ekklesia source revision Signage's external-package integration runs against. +# The ref is fetched only to make the pinned object reachable; the checkout is always by full SHA. +set -Eeuo pipefail + +root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +out="$root/build/platform" + +fail() { + echo "fetch-platform: $*" >&2 + exit 1 +} + +(($# == 0)) || fail "this command takes no arguments." + +pin() { + local value + value="$(sed -n "s/^$1=//p" "$root/ci/platform.env")" + [[ -n "$value" ]] || fail "ci/platform.env has no $1." + printf '%s' "$value" +} + +repository="$(pin EKKLESIA_PLATFORM_REPOSITORY)" +ref="$(pin EKKLESIA_PLATFORM_REF)" +commit="$(pin EKKLESIA_PLATFORM_COMMIT)" +[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || fail "EKKLESIA_PLATFORM_COMMIT must be a full commit SHA." +[[ "$ref" == refs/* ]] || fail "EKKLESIA_PLATFORM_REF must be a full Git ref." + +rm -rf "$out" +mkdir -p "$out" +git -C "$out" init --quiet +git -C "$out" remote add origin "$repository" +git -C "$out" fetch --quiet --no-tags --depth=1 origin "+${ref}:refs/remotes/pinned" || + fail "cannot fetch the pinned platform ref." +[[ "$(git -C "$out" rev-parse refs/remotes/pinned)" == "$commit" ]] || + fail "the pinned ref does not resolve to EKKLESIA_PLATFORM_COMMIT." +git -C "$out" -c advice.detachedHead=false checkout --quiet --detach "$commit" +rm -rf "$out/.git" +echo "fetch-platform: Ekklesia ${commit}" diff --git a/ci/integration.sh b/ci/integration.sh index 9993d18..c6bfd7a 100755 --- a/ci/integration.sh +++ b/ci/integration.sh @@ -1,46 +1,46 @@ #!/usr/bin/env bash -# Signage inside Ekklesia, end to end: builds the deployment image from the assembled workspace with -# the platform's own Dockerfile, then runs the platform's split-topology deployment smoke against it -# with the display-device scenarios listed in ci/display-scenarios. A disposable PostgreSQL and object -# store, generated secrets, and an explicit migration apply against that fresh database only; nothing -# is pushed, published, or logged in to. The image is removed afterwards. +# Builds Signage as the canonical linux-x64-glibc Module Package, then gives that exact release pair +# to the pinned platform's generic deployment smoke. The platform image contains no Signage source: +# its verifier extracts the package into installation-owned module storage before migrations, +# discovery, browser UI/display checks, restart, container recreation, disable/re-enable, removal, +# and reinstall are exercised against disposable PostgreSQL and object-storage services. # +# bash ci/fetch-platform.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 root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -workspace="$root/build/workspace" -[[ -f "$workspace/deploy/Dockerfile" ]] || { - echo "integration: Ekklesia's deployment tree is not at build/workspace." >&2 +platform="$root/build/platform" +release="$root/build/module-package" +[[ -f "$platform/scripts/smoke-deploy.sh" ]] || { + echo "integration: run bash ci/fetch-platform.sh first." >&2 exit 1 } -# Named and labeled as a CI stack of this run and job (the platform's scripts/lib/ci-project.sh), so -# the workflow's final `ci-sweep.sh --job` removes it even if this script is killed before its trap. -# shellcheck source=/dev/null -source "$workspace/scripts/lib/ci-project.sh" -project="$(ci_project_name signage-image)" -image="${project}:latest" -trap 'docker image rm --force "$image" >/dev/null 2>&1 || true' EXIT +source_revision="${GITHUB_SHA:-$(git -C "$root" rev-parse HEAD 2>/dev/null || true)}" +[[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || { + echo "integration: no full source revision is available for the package." >&2 + 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" scenarios="$(sed -e 's/#.*//' -e '/^[[:space:]]*$/d' "$root/ci/display-scenarios" | tr '\n' ' ')" [[ -n "${scenarios// /}" ]] || { echo "integration: ci/display-scenarios lists no scenario." >&2 exit 1 } +echo "integration: external Signage package built; scenarios: ${scenarios}" -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" +cd "$platform" EKKLESIA_SMOKE_TOPOLOGY=split \ - EKKLESIA_SMOKE_IMAGE="$image" \ + EKKLESIA_SMOKE_MODULE_RELEASE_DIR="$release" \ + EKKLESIA_SMOKE_MODULE_ID=signage \ + EKKLESIA_SMOKE_MODULE_PROBE_PATH=/api/v1/modules/signage/channel-groups \ EKKLESIA_SMOKE_DISPLAY_DEVICE=1 \ EKKLESIA_SMOKE_DISPLAY_SCENARIOS="$scenarios" \ EKKLESIA_SMOKE_WATCH_MS=120000 \ diff --git a/ci/platform.env b/ci/platform.env new file mode 100644 index 0000000..3733ed2 --- /dev/null +++ b/ci/platform.env @@ -0,0 +1,5 @@ +# The companion Core harness Signage uses to install and run its real external package. The pull ref +# remains reachable after its branch is deleted; the full commit pin makes the checkout immutable. +EKKLESIA_PLATFORM_REPOSITORY=https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia.git +EKKLESIA_PLATFORM_REF=refs/pull/90/head +EKKLESIA_PLATFORM_COMMIT=40ef0ff802c124ae5a3a0b9aa51a1502a7b72644 diff --git a/src/server/images/processing.ts b/src/server/images/processing.ts index 2236d62..79b2482 100644 --- a/src/server/images/processing.ts +++ b/src/server/images/processing.ts @@ -177,10 +177,11 @@ export async function processSignageImage(bytes: Uint8Array): Promise { 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 () => { const bytes = await makeImage({ format: "png", height: 1080, width: 1920 }); const first = await ingest(bytes);