From 7e0df3c4bc1b66b9739a930936ec40bd07892386 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 12:12:14 +0200 Subject: [PATCH 01/10] M0.12: allow immutable verified Compose image override --- compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose.yaml b/compose.yaml index 12b4dfd..6ce0f34 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,6 @@ services: glowing-bear: - image: ghcr.io/ploos-as/glowing-bear:latest + image: ${GLOWING_BEAR_IMAGE:-ghcr.io/ploos-as/glowing-bear:latest} build: context: . container_name: glowing-bear From 79cf90a4299c2c1d409dc9a8c65c83f8b893c3c1 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 12:12:22 +0200 Subject: [PATCH 02/10] M0.12: add verified Compose deployment gate --- scripts/deploy-verified-compose.sh | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 scripts/deploy-verified-compose.sh diff --git a/scripts/deploy-verified-compose.sh b/scripts/deploy-verified-compose.sh new file mode 100644 index 0000000..4c4d930 --- /dev/null +++ b/scripts/deploy-verified-compose.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${VERSION:?VERSION is required}" +COMPOSE_FILE="${COMPOSE_FILE:-compose.yaml}" + +for command in docker cosign; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "required command not found: $command" >&2 + exit 1 + fi +done + +verified_ref="$(VERSION="$VERSION" PULL=1 bash scripts/verify-release.sh | tail -n1)" +if [[ ! "$verified_ref" =~ ^ghcr\.io/ploos-as/glowing-bear@sha256:[0-9a-f]{64}$ ]]; then + echo "verification did not return an immutable Glowing Bear image ref" >&2 + exit 1 +fi + +export GLOWING_BEAR_IMAGE="$verified_ref" + +echo "Deploying verified image with Compose: $GLOWING_BEAR_IMAGE" +docker compose -f "$COMPOSE_FILE" config >/dev/null +docker compose -f "$COMPOSE_FILE" up -d --no-build + +echo "Verified Compose deployment started: $GLOWING_BEAR_IMAGE" From 9fe16fb541e9b24ca6be9efb9d1a37c3684924f9 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 12:12:31 +0200 Subject: [PATCH 03/10] M0.12: add verified Quadlet renderer --- scripts/render-verified-quadlet.sh | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 scripts/render-verified-quadlet.sh diff --git a/scripts/render-verified-quadlet.sh b/scripts/render-verified-quadlet.sh new file mode 100644 index 0000000..bd622cc --- /dev/null +++ b/scripts/render-verified-quadlet.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${VERSION:?VERSION is required}" +OUTPUT="${OUTPUT:-glowing-bear.verified.container}" +TEMPLATE="${TEMPLATE:-quadlet/glowing-bear.container}" + +for command in docker cosign; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "required command not found: $command" >&2 + exit 1 + fi +done + +verified_ref="$(VERSION="$VERSION" PULL=0 bash scripts/verify-release.sh | tail -n1)" +if [[ ! "$verified_ref" =~ ^ghcr\.io/ploos-as/glowing-bear@sha256:[0-9a-f]{64}$ ]]; then + echo "verification did not return an immutable Glowing Bear image ref" >&2 + exit 1 +fi + +if [[ ! -f "$TEMPLATE" ]]; then + echo "Quadlet template not found: $TEMPLATE" >&2 + exit 1 +fi + +awk -v image="$verified_ref" ' + /^Image=/ { print "Image=" image; next } + { print } +' "$TEMPLATE" > "$OUTPUT" + +if ! grep -Eq '^Image=ghcr\.io/ploos-as/glowing-bear@sha256:[0-9a-f]{64}$' "$OUTPUT"; then + echo "rendered Quadlet does not contain the verified immutable image ref" >&2 + exit 1 +fi + +echo "Rendered verified Quadlet: $OUTPUT" +echo "Immutable image: $verified_ref" From d5202265163dc715fbff93b98c94be5b5b8adda4 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 12:12:42 +0200 Subject: [PATCH 04/10] M0.12: add deployment enforcement qualification --- .github/workflows/deployment-enforcement.yml | 66 ++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/deployment-enforcement.yml diff --git a/.github/workflows/deployment-enforcement.yml b/.github/workflows/deployment-enforcement.yml new file mode 100644 index 0000000..014eeae --- /dev/null +++ b/.github/workflows/deployment-enforcement.yml @@ -0,0 +1,66 @@ +name: Deployment enforcement + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + inputs: + version: + description: Signed release version to deploy through the enforced path + required: true + default: '0.2.3' + +permissions: + contents: read + packages: read + +env: + VERSION: '0.2.3' + +jobs: + qualify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Select release + if: github.event_name == 'workflow_dispatch' + run: echo "VERSION=${{ inputs.version }}" >> "$GITHUB_ENV" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Install Cosign + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Validate scripts + run: | + bash -n scripts/deploy-verified-compose.sh + bash -n scripts/render-verified-quadlet.sh + - name: Qualify verified Compose deployment + env: + VERSION: ${{ env.VERSION }} + run: | + bash scripts/deploy-verified-compose.sh + image="$(docker inspect glowing-bear --format '{{.Config.Image}}')" + [[ "$image" =~ ^ghcr\.io/ploos-as/glowing-bear@sha256:[0-9a-f]{64}$ ]] + for attempt in $(seq 1 30); do + if curl --fail --silent http://127.0.0.1:8080/healthz >/dev/null; then + exit 0 + fi + sleep 1 + done + docker compose logs + exit 1 + - name: Qualify verified Quadlet rendering + env: + VERSION: ${{ env.VERSION }} + run: | + OUTPUT=/tmp/glowing-bear.container bash scripts/render-verified-quadlet.sh + grep -Eq '^Image=ghcr\.io/ploos-as/glowing-bear@sha256:[0-9a-f]{64}$' /tmp/glowing-bear.container + ! grep -q '^Image=.*:latest$' /tmp/glowing-bear.container From c99180ec0e9429a5e0798cecf0105c19d85c4413 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 12:12:59 +0200 Subject: [PATCH 05/10] M0.12: document deployment enforcement policy --- docs/M0_12_DEPLOYMENT_ENFORCEMENT.md | 64 ++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/M0_12_DEPLOYMENT_ENFORCEMENT.md diff --git a/docs/M0_12_DEPLOYMENT_ENFORCEMENT.md b/docs/M0_12_DEPLOYMENT_ENFORCEMENT.md new file mode 100644 index 0000000..a1ba4ff --- /dev/null +++ b/docs/M0_12_DEPLOYMENT_ENFORCEMENT.md @@ -0,0 +1,64 @@ +# M0.12 deployment enforcement + +M0.11 added consumer-side Sigstore/Cosign verification. M0.12 turns that verification into the supported secure deployment path for Docker Compose and Podman Quadlet. + +## Policy + +The secure deployment path is fail-closed: + +1. A semantic release version is supplied by the operator. +2. `scripts/verify-release.sh` resolves the release tag to an immutable OCI digest. +3. Cosign verifies that exact digest against the expected GitHub Actions certificate identity and the GitHub Actions OIDC issuer. +4. Only the verified `ghcr.io/ploos-as/glowing-bear@sha256:...` reference is handed to the deployment mechanism. +5. Any resolution, signature, identity, issuer, or immutable-reference failure stops deployment. + +This is an application/operator deployment policy. It is not a Docker daemon, Podman engine, or host-wide admission controller. + +## Docker Compose + +Use: + +```bash +VERSION=0.2.3 bash scripts/deploy-verified-compose.sh +``` + +The script verifies the release, pulls only the verified immutable digest, exports that digest through `GLOWING_BEAR_IMAGE`, validates the Compose model, and starts the service with `--no-build` so a local build cannot replace the verified release image. + +`compose.yaml` still has a `:latest` fallback for development and compatibility. That fallback is not the verified deployment path. + +## Podman Quadlet + +Generate a Quadlet containing the verified immutable digest: + +```bash +VERSION=0.2.3 \ +OUTPUT="$HOME/.config/containers/systemd/glowing-bear.container" \ +bash scripts/render-verified-quadlet.sh + +systemctl --user daemon-reload +systemctl --user restart glowing-bear.service +``` + +The renderer refuses to produce the deployment file unless verification succeeds and the resulting `Image=` value is an immutable `sha256` reference. + +## CI qualification + +`.github/workflows/deployment-enforcement.yml` qualifies M0.12 against the signed `v0.2.3` release. The gate: + +- syntax-checks both enforcement scripts; +- executes the verified Compose deployment path; +- verifies the running container was created from an immutable digest reference; +- requires `/healthz` to become reachable; +- renders a verified Quadlet; +- requires its `Image=` field to contain an immutable digest and rejects `:latest`. + +## Acceptance criteria + +M0.12 is complete when: + +- the verified Compose path deploys only after successful signature verification; +- the Compose runtime image reference is immutable; +- local build substitution is disabled in the verified path; +- the verified Quadlet path emits only an immutable image reference; +- CI proves both paths against a real signed release; +- failures in verification prevent deployment output or startup. From 97bb830fd2d26f3f8e5dfa6f53e8a7ec167ec7ba Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 12:33:01 +0200 Subject: [PATCH 06/10] Harden verified release output contract --- scripts/verify-release.sh | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index e866232..ae333e9 100644 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -18,6 +18,11 @@ if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then exit 1 fi +if [[ "$PULL" != "0" && "$PULL" != "1" ]]; then + echo "PULL must be 0 or 1" >&2 + exit 1 +fi + for command in docker cosign; do if ! command -v "$command" >/dev/null 2>&1; then echo "required command not found: $command" >&2 @@ -36,24 +41,21 @@ fi signed_ref="$IMAGE@$image_digest" -echo "Resolved release: $primary" -echo "Immutable ref: $signed_ref" -echo "Expected signer: $identity" -echo "Expected issuer: $OIDC_ISSUER" +echo "Resolved release: $primary" >&2 +echo "Immutable ref: $signed_ref" >&2 +echo "Expected signer: $identity" >&2 +echo "Expected issuer: $OIDC_ISSUER" >&2 cosign verify \ --certificate-identity "$identity" \ --certificate-oidc-issuer "$OIDC_ISSUER" \ "$signed_ref" >/dev/null -echo "Signature verification passed for $signed_ref" +echo "Signature verification passed for $signed_ref" >&2 if [[ "$PULL" == "1" ]]; then - docker pull "$signed_ref" - echo "Pulled verified immutable image: $signed_ref" -elif [[ "$PULL" != "0" ]]; then - echo "PULL must be 0 or 1" >&2 - exit 1 + docker pull "$signed_ref" >&2 + echo "Pulled verified immutable image: $signed_ref" >&2 fi printf '%s\n' "$signed_ref" From 316060550801454196f2239060ab377cc613c2ff Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 12:33:09 +0200 Subject: [PATCH 07/10] Separate verification from immutable pull --- scripts/deploy-verified-compose.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/deploy-verified-compose.sh b/scripts/deploy-verified-compose.sh index 4c4d930..b8da85e 100644 --- a/scripts/deploy-verified-compose.sh +++ b/scripts/deploy-verified-compose.sh @@ -11,12 +11,15 @@ for command in docker cosign; do fi done -verified_ref="$(VERSION="$VERSION" PULL=1 bash scripts/verify-release.sh | tail -n1)" +verified_ref="$(VERSION="$VERSION" PULL=0 bash scripts/verify-release.sh)" if [[ ! "$verified_ref" =~ ^ghcr\.io/ploos-as/glowing-bear@sha256:[0-9a-f]{64}$ ]]; then echo "verification did not return an immutable Glowing Bear image ref" >&2 exit 1 fi +echo "Pulling verified immutable image: $verified_ref" +docker pull "$verified_ref" + export GLOWING_BEAR_IMAGE="$verified_ref" echo "Deploying verified image with Compose: $GLOWING_BEAR_IMAGE" From 1c4ae5c934c07dc07db58887609c8c48159b0ef3 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 12:33:17 +0200 Subject: [PATCH 08/10] Use clean immutable ref output for Quadlet --- scripts/render-verified-quadlet.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/render-verified-quadlet.sh b/scripts/render-verified-quadlet.sh index bd622cc..5c18435 100644 --- a/scripts/render-verified-quadlet.sh +++ b/scripts/render-verified-quadlet.sh @@ -12,7 +12,7 @@ for command in docker cosign; do fi done -verified_ref="$(VERSION="$VERSION" PULL=0 bash scripts/verify-release.sh | tail -n1)" +verified_ref="$(VERSION="$VERSION" PULL=0 bash scripts/verify-release.sh)" if [[ ! "$verified_ref" =~ ^ghcr\.io/ploos-as/glowing-bear@sha256:[0-9a-f]{64}$ ]]; then echo "verification did not return an immutable Glowing Bear image ref" >&2 exit 1 From 205ca490c33a75e795f9d98aca52ad6a94d892ad Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 12:33:31 +0200 Subject: [PATCH 09/10] Retry transient browser network changes --- scripts/qualify_browser_relay.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/qualify_browser_relay.py b/scripts/qualify_browser_relay.py index 5fdd207..0817663 100644 --- a/scripts/qualify_browser_relay.py +++ b/scripts/qualify_browser_relay.py @@ -5,9 +5,21 @@ import asyncio import sys +from playwright.async_api import Error as PlaywrightError from playwright.async_api import async_playwright +async def goto_with_network_retry(page, base_url: str) -> None: + for attempt in range(3): + try: + await page.goto(base_url, wait_until="networkidle", timeout=30_000) + return + except PlaywrightError as exc: + if "ERR_NETWORK_CHANGED" not in str(exc) or attempt == 2: + raise + await asyncio.sleep(1) + + async def qualify( base_url: str, relay_host: str, @@ -26,7 +38,7 @@ async def qualify( page.on("websocket", lambda ws: websocket_urls.append(ws.url)) try: - await page.goto(base_url, wait_until="networkidle", timeout=30_000) + await goto_with_network_retry(page, base_url) await page.locator("#host").fill(relay_host) await page.locator("#port").fill(str(relay_port)) await page.locator("#password").fill(password) From cbff34f98a66b7790547551b2a0a66ae81a9dbf1 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 5 Sep 2026 13:59:31 +0200 Subject: [PATCH 10/10] Fix Buildx digest resolution pipe race --- scripts/verify-release.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index ae333e9..9ff4ebb 100644 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -33,7 +33,11 @@ done primary="$IMAGE:$VERSION" identity="https://github.com/Ploos-AS/glowing-bear/.github/workflows/sign-release.yml@refs/tags/$TAG" -image_digest="$(docker buildx imagetools inspect "$primary" | awk '/^Digest:/ {print $2; exit}')" +# Capture the complete Buildx output before parsing it. Piping imagetools inspect +# directly into an awk program that exits after the Digest line can close the +# pipe early; with pipefail enabled Buildx may then report SIGPIPE as exit 255. +inspect_output="$(docker buildx imagetools inspect "$primary")" +image_digest="$(awk '/^Digest:/ {print $2; found=1} END {if (!found) exit 1}' <<<"$inspect_output")" if [[ ! "$image_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then echo "could not resolve an immutable sha256 digest for $primary" >&2 exit 1