Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/deployment-enforcement.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
64 changes: 64 additions & 0 deletions docs/M0_12_DEPLOYMENT_ENFORCEMENT.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions scripts/deploy-verified-compose.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/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=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"
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"
14 changes: 13 additions & 1 deletion scripts/qualify_browser_relay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions scripts/render-verified-quadlet.sh
Original file line number Diff line number Diff line change
@@ -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)"
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"
28 changes: 17 additions & 11 deletions scripts/verify-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,32 +33,33 @@ 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
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"
Loading