diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 564cd74e9dd..fd76934310f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,6 +1,6 @@ name: Docker image -# Builds and publishes the public Buzz relay images as ghcr.io/block/buzz. +# Builds and publishes Launchpad Buzz relay images as ghcr.io/launchpad-26/buzz. # Normal tags contain stripped binaries; matching debug-* tags contain the same # optimized binaries with line-table debug information for native profilers. # @@ -16,8 +16,8 @@ name: Docker image # the relay image version tracks crates/buzz-relay/Cargo.toml, never desktop. # # Triggers: -# - push to main → :main + :sha-<7> -# + :debug-main + :debug-sha-<7> +# - push to launchpad → :launchpad + :sha- +# + :debug-launchpad + :debug-sha- # - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major} # + matching :debug-* tags # (+ :latest/:debug-latest for stable releases) @@ -37,11 +37,11 @@ name: Docker image # The :latest tag tracks the latest STABLE relay release: metadata-action's # `flavor.latest=auto` (its default) emits :latest only for non-prerelease # semver, so relay-v0.3.0-rc.1 publishes :0.3.0-rc.1 without moving :latest, -# and main pushes (no semver tag) never produce :latest. +# and launchpad pushes (no semver tag) never produce :latest. on: push: - branches: [main] + branches: [launchpad] tags: ["relay-v[0-9]*"] pull_request: paths: @@ -65,7 +65,7 @@ on: required: true # One image build per ref; cancel superseded PR builds, but never cancel -# tag/main builds (publishing must not be aborted mid-flight). +# tag/launchpad builds (publishing must not be aborted mid-flight). concurrency: group: docker-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref_type == 'branch' && github.event_name == 'pull_request' }} @@ -73,10 +73,8 @@ concurrency: permissions: {} env: - # Single source of truth for the image name. Set GHCR_IMAGE as a repo - # variable to override (e.g., for forks that want to push to their own - # namespace without forking this file). - IMAGE_NAME: ${{ vars.GHCR_IMAGE != '' && vars.GHCR_IMAGE || 'ghcr.io/block/buzz' }} + # Launchpad publication must never fall back to the upstream Block package. + IMAGE_NAME: ghcr.io/launchpad-26/buzz jobs: build: @@ -138,29 +136,35 @@ jobs: uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: ${{ env.IMAGE_NAME }} - # Tag matrix — every main commit gets sha-<7>, relay releases get the - # full semver family. The semver entries carry match=^relay-v(.*)$ + # Tag matrix — every build gets a full commit-SHA tag, while relay + # releases also get the semver family. The sha tag carries no + # enable= gate, deliberately: a workflow_dispatch rescue still needs + # a commit-pinned tag for launchpad/deploy/run.sh's guard to accept, + # so it publishes on every event including a rescue dispatch. The + # semver entries carry match=^relay-v(.*)$ # because metadata-action does NOT strip a `relay-v` prefix on its # own — it only strips refs/tags/, then runs the raw ref through # semver.valid(), which rejects "relay-v0.3.0". The match capture # group feeds the bare version to the semver parser. value= supplies - # the version on a manual rescue dispatch (github.ref is `main` + # the version on a manual rescue dispatch (github.ref is `launchpad` # there, not the tag): it is already bare, so match no-ops (warns, # value intact) and the bare version validates as-is. On push value= # is empty, so the ref drives it and match strips relay-v — push # behavior is unchanged. Pull requests get nothing (push: false # below). :latest is intentionally absent — flavor.latest defaults to # `auto`, which adds :latest for stable semver tags only (not - # prereleases, not main pushes). + # prereleases, not launchpad pushes). tags: | type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} - type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} + type=sha,prefix=sha-,format=long type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }} labels: | org.opencontainers.image.title=Buzz org.opencontainers.image.description=WebSocket relay server for the Buzz communications platform + org.opencontainers.image.source=https://github.com/launchpad-26/buzz + org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.licenses=Apache-2.0 - name: Build and push release image by digest @@ -275,7 +279,7 @@ jobs: prefix=${{ matrix.tag_prefix }},onlatest=true tags: | type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} - type=sha,prefix=${{ matrix.tag_prefix }}sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} + type=sha,prefix=${{ matrix.tag_prefix }}sha-,format=long type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }} @@ -310,7 +314,7 @@ jobs: - name: Attest provenance for the merged image # Sigstore-signed in-toto attestation, verifiable with: - # gh attestation verify oci://ghcr.io/block/buzz: --owner block + # gh attestation verify oci://ghcr.io/launchpad-26/buzz: --owner launchpad-26 uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-name: ${{ env.IMAGE_NAME }} @@ -336,12 +340,15 @@ jobs: echo echo "Verify provenance:" echo '```' - echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --owner block" + echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --owner launchpad-26" echo '```' } >> "$GITHUB_STEP_SUMMARY" push-gateway-build: name: Build public push gateway (${{ matrix.platform }}) + # Launchpad does not operate the separate APNs gateway. Preserve the + # inherited lane for upstream while preventing fork publication attempts. + if: github.repository == 'block/buzz' runs-on: ${{ matrix.runner }} timeout-minutes: 60 permissions: @@ -419,7 +426,7 @@ jobs: push-gateway-merge: name: Publish public push gateway image - if: github.event_name != 'pull_request' + if: github.repository == 'block/buzz' && github.event_name != 'pull_request' runs-on: ubuntu-24.04 needs: push-gateway-build timeout-minutes: 15 diff --git a/Dockerfile b/Dockerfile index d883ac6b015..81ad184bc65 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 # -# Public Buzz relay image — published as ghcr.io/block/buzz:. +# Launchpad Buzz relay image — published as ghcr.io/launchpad-26/buzz:. # # Builds the `buzz-relay` binary (Rust 1.95) and the `buzz-web` static bundle # (pnpm + vite), then assembles them into a small debian-slim runtime with @@ -126,9 +126,9 @@ FROM debian:${DEBIAN_VERSION}-slim AS runtime-base # one — without it GHCR keeps the image private even when the repo is public. LABEL org.opencontainers.image.title="Buzz" \ org.opencontainers.image.description="WebSocket relay server for the Buzz communications platform" \ - org.opencontainers.image.source="https://github.com/block/buzz" \ - org.opencontainers.image.url="https://github.com/block/buzz" \ - org.opencontainers.image.documentation="https://github.com/block/buzz#readme" \ + org.opencontainers.image.source="https://github.com/launchpad-26/buzz" \ + org.opencontainers.image.url="https://github.com/launchpad-26/buzz" \ + org.opencontainers.image.documentation="https://github.com/launchpad-26/buzz#readme" \ org.opencontainers.image.licenses="Apache-2.0" RUN apt-get update \ diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index f6ab4fcab97..bcbf4565653 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -2,8 +2,9 @@ # Copy to .env and replace every CHANGE_ME value before running. # The bootstrap script should generate this file for normal users. -# Image published by the public image pipeline. Use `:main` for pre-release testing. Pin `:sha-<7>` or a semver release tag for production. -BUZZ_IMAGE=ghcr.io/block/buzz:main +# Image published from the Launchpad `launchpad` branch. Production must use +# either a digest or the full 40-character commit-SHA tag from the workflow. +BUZZ_IMAGE=ghcr.io/launchpad-26/buzz:sha-CHANGE_ME_FULL_40_CHARACTER_GIT_COMMIT # Public host name. Used by compose.caddy.yml and URL-derived settings below. BUZZ_DOMAIN=buzz.example.com diff --git a/deploy/compose/README.md b/deploy/compose/README.md index bb0e63fe15d..6a25e355dd3 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -8,15 +8,23 @@ the root `docker-compose.yml`, which remains local development infrastructure. ```bash cd deploy/compose cp .env.example .env -$EDITOR .env # replace every CHANGE_ME value -./run.sh start +$EDITOR .env +cd ../.. +``` + +In `.env`, replace every `CHANGE_ME` value and set `BUZZ_IMAGE` to either the +published image digest or the full 40-character commit-SHA tag. From the +repository root, validate and start through the Launchpad guard: + +```bash +./launchpad/deploy/run.sh check +./launchpad/deploy/run.sh start ``` For a public VPS with automatic Let's Encrypt certificates: ```bash -cd deploy/compose -BUZZ_COMPOSE_TLS=true ./run.sh start +BUZZ_COMPOSE_TLS=true ./launchpad/deploy/run.sh start ``` The bootstrap script should eventually replace manual `.env` editing for normal @@ -27,7 +35,14 @@ keypair. - Requires Docker Compose v2.24.4 or newer; the TLS override uses Compose's `!reset` tag to remove the direct relay port when Caddy terminates HTTPS. -- Default `BUZZ_IMAGE` tracks `ghcr.io/block/buzz:main` for early testing. Pin it to `ghcr.io/block/buzz:sha-<7>` or a semver release tag for production once available. +- GitHub Actions builds the root `Dockerfile` when a commit reaches the + `launchpad` branch and publishes `ghcr.io/launchpad-26/buzz`. Normal VPS + deployment consumes that prebuilt image; it does not build the Dockerfile. +- `BUZZ_IMAGE` has no default. Compose fails when it is missing. Production + should use `ghcr.io/launchpad-26/buzz@sha256:` or the workflow's + `ghcr.io/launchpad-26/buzz:sha-` tag. The moving + `:launchpad` tag is only a convenience pointer and is rejected by the + Launchpad guard unless explicitly allowed for development/testing. - Keep `BUZZ_RELAY_PRIVATE_KEY`, `BUZZ_GIT_HOOK_HMAC_SECRET`, database/Redis, and S3 secrets stable across restarts. - `RELAY_OWNER_PUBKEY` is intentionally not prefixed with `BUZZ_`; it must be a @@ -44,18 +59,51 @@ keypair. `.env`; use the Helm chart or a custom Compose configuration for providers such as new Railway Storage Buckets that require `virtual` addressing. -Run `./run.sh backup-hint` for the backup checklist. +Run `./launchpad/deploy/run.sh backup-hint` from the repository root for the +backup checklist. + +## Image lifecycle and traceability + +The command behavior is intentionally distinct: + +- `start` runs `docker compose up -d --wait`. It does not explicitly pull; an + absent image may be fetched by Compose, while an already-present tag may be + reused. +- `upgrade` runs `docker compose pull` and then `up -d --wait`. Use this after + intentionally changing `BUZZ_IMAGE`. +- `restart` force-recreates only the relay and does not pull an image. + +To see the configured image and the immutable digest of the running relay: + +```bash +cd deploy/compose +docker compose config --images +container_id=$(docker compose ps -q relay) +docker inspect --format 'configured={{.Config.Image}} image_id={{.Image}}' "$container_id" +image_id=$(docker inspect --format '{{.Image}}' "$container_id") +docker image inspect --format 'repo_digests={{json .RepoDigests}}' "$image_id" +``` + +The full `sha-<40-character-commit>` tag maps directly to Git. A digest is the +strongest runtime pin; the corresponding workflow run summary records both the +digest and full commit-SHA tag and includes the provenance verification command. + +To upgrade, replace only `BUZZ_IMAGE` in the local, untracked `.env` with the +new verified digest or full commit-SHA tag, run +`./launchpad/deploy/run.sh check`, back up state, and run +`./launchpad/deploy/run.sh upgrade`. To roll back, restore the previous +immutable `BUZZ_IMAGE` value and run the same check and upgrade commands. An +image-only rollback is safe only when intervening database migrations are +backward-compatible; otherwise restore the matching pre-upgrade database and +object/git snapshots as a coordinated recovery. ## Validation Before sharing an install link publicly, verify a fresh install with: ```bash -cd deploy/compose -cp .env.example .env -$EDITOR .env -./run.sh config -./run.sh start -curl -fsS "http://127.0.0.1:$(grep -E '^BUZZ_HTTP_PORT=' .env | cut -d= -f2-)/_liveness" -./run.sh status +./launchpad/deploy/run.sh check +./launchpad/deploy/run.sh start +curl -fsS "http://127.0.0.1:$(grep -E '^BUZZ_HTTP_PORT=' deploy/compose/.env | cut -d= -f2-)/_liveness" +./launchpad/deploy/run.sh status ``` diff --git a/deploy/compose/compose.yml b/deploy/compose/compose.yml index 15337c92a27..2e6f8d95e0f 100644 --- a/deploy/compose/compose.yml +++ b/deploy/compose/compose.yml @@ -2,7 +2,7 @@ name: buzz-prod services: relay: - image: ${BUZZ_IMAGE:-ghcr.io/block/buzz:main} + image: ${BUZZ_IMAGE:?BUZZ_IMAGE must be set to an immutable relay image} env_file: - .env environment: diff --git a/launchpad/deploy/AGENTS.md b/launchpad/deploy/AGENTS.md index 2dec6843f30..7a045fe20fc 100644 --- a/launchpad/deploy/AGENTS.md +++ b/launchpad/deploy/AGENTS.md @@ -1,7 +1,9 @@ -# AGENTS.md — failed deployment archive +# AGENTS.md — Launchpad deployment guard and failed deployment archive The deployment method formerly stored in this directory **failed** and has been -moved to `archived/` for historical reference only. +moved to `archived/` for historical reference only. The active `run.sh` in this +directory is a thin Launchpad image guard that delegates all orchestration to +`../../deploy/compose/run.sh`; it is not a replacement Compose implementation. ## Mandatory rule diff --git a/launchpad/deploy/README.md b/launchpad/deploy/README.md index 4d4aa174d04..760041ac632 100644 --- a/launchpad/deploy/README.md +++ b/launchpad/deploy/README.md @@ -1,4 +1,26 @@ -# Failed deployment method — archived +# Launchpad VPS deployment guard + +Use [`run.sh`](run.sh) for Launchpad VPS operations. It validates that +`deploy/compose/.env` selects an immutable `ghcr.io/launchpad-26/buzz` relay +image, rejects upstream Block images, checks Docker Compose compatibility, and +then delegates to the canonical `deploy/compose/run.sh` implementation. + +```bash +./launchpad/deploy/run.sh check +./launchpad/deploy/run.sh start +./launchpad/deploy/run.sh upgrade +``` + +Digest references and full 40-character `sha-...` tags are accepted for normal +deployment. Floating tags are rejected unless +`BUZZ_ALLOW_FLOATING_IMAGE=true` is explicitly set for development or testing. +The override never permits `ghcr.io/block/buzz`. + +The guard reads exactly one `BUZZ_IMAGE` assignment from the local `.env` and +exports that value before delegation, so an ambient shell variable cannot +silently replace the reviewed deployment image. + +## Failed deployment method — archived Everything from the former Launchpad VPS deployment experiment has been moved to `archived/` for future reference. diff --git a/launchpad/deploy/run.sh b/launchpad/deploy/run.sh new file mode 100755 index 00000000000..676c8ea10cb --- /dev/null +++ b/launchpad/deploy/run.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +COMPOSE_ENV="${REPO_ROOT}/deploy/compose/.env" +CANONICAL_RUNNER="${REPO_ROOT}/deploy/compose/run.sh" +MINIMUM_COMPOSE_VERSION="2.24.4" + +fail() { + echo "Launchpad deployment check failed: $*" >&2 + exit 1 +} + +version_at_least() { + local actual=$1 + local minimum=$2 + local actual_major actual_minor actual_patch + local minimum_major minimum_minor minimum_patch + + IFS=. read -r actual_major actual_minor actual_patch <<<"${actual}" + IFS=. read -r minimum_major minimum_minor minimum_patch <<<"${minimum}" + + ((actual_major > minimum_major)) || + ((actual_major == minimum_major && actual_minor > minimum_minor)) || + ((actual_major == minimum_major && actual_minor == minimum_minor && actual_patch >= minimum_patch)) +} + +usage() { + cat <<'MSG' +Usage: ./launchpad/deploy/run.sh + +Runs Launchpad image and Compose preflight checks, then delegates to the +canonical deploy/compose/run.sh implementation. + +Use `check` to validate without changing services. All canonical commands are +accepted. A floating Launchpad tag is rejected unless the operator explicitly +sets BUZZ_ALLOW_FLOATING_IMAGE=true for development or testing. +MSG +} + +case "${1:-help}" in + help|-h|--help) + usage + exit 0 + ;; +esac + +command -v docker >/dev/null 2>&1 || fail "Docker is not installed or is not on PATH." +docker compose version >/dev/null 2>&1 || fail "Docker Compose V2 is not available." + +compose_version=$(docker compose version --short) +compose_version=${compose_version#v} +compose_version=${compose_version%%-*} +[[ "${compose_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || + fail "Could not parse Docker Compose version '${compose_version}'." +version_at_least "${compose_version}" "${MINIMUM_COMPOSE_VERSION}" || + fail "Docker Compose ${MINIMUM_COMPOSE_VERSION} or newer is required; found ${compose_version}." + +[[ -f "${COMPOSE_ENV}" ]] || + fail "Missing deploy/compose/.env. Copy deploy/compose/.env.example and configure it first." + +image_count=$(awk ' + /^[[:space:]]*#/ { next } + /^[[:space:]]*BUZZ_IMAGE[[:space:]]*=/ { count++ } + END { print count + 0 } +' "${COMPOSE_ENV}") +[[ "${image_count}" == "1" ]] || + fail "deploy/compose/.env must contain exactly one BUZZ_IMAGE assignment." + +image=$(awk ' + /^[[:space:]]*#/ { next } + /^[[:space:]]*BUZZ_IMAGE[[:space:]]*=/ { + value = $0 + sub(/^[^=]*=/, "", value) + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + print value + } +' "${COMPOSE_ENV}") + +if [[ "${image}" == \"*\" && "${image}" == *\" ]]; then + image=${image:1:${#image}-2} +elif [[ "${image}" == \'*\' && "${image}" == *\' ]]; then + image=${image:1:${#image}-2} +fi + +[[ -n "${image}" ]] || fail "BUZZ_IMAGE is empty." +lower_image=$(printf '%s' "${image}" | tr '[:upper:]' '[:lower:]') + +case "${lower_image}" in + ghcr.io/block/buzz|ghcr.io/block/buzz:*|ghcr.io/block/buzz@*) + fail "Upstream Block image '${image}' is forbidden for Launchpad deployment." + ;; +esac + +case "${lower_image}" in + ghcr.io/launchpad-26/buzz:*|ghcr.io/launchpad-26/buzz@*) ;; + *) fail "BUZZ_IMAGE must select ghcr.io/launchpad-26/buzz; found '${image}'." ;; +esac + +case "${image}" in + *CHANGE_ME*) + fail "BUZZ_IMAGE still contains the .env.example placeholder. Replace it with a digest or a full 40-character sha- tag." + ;; +esac + +immutable=false +if [[ "${lower_image}" =~ ^ghcr\.io/launchpad-26/buzz@sha256:[0-9a-f]{64}$ ]] || + [[ "${lower_image}" =~ ^ghcr\.io/launchpad-26/buzz:(debug-)?sha-[0-9a-f]{40}$ ]]; then + immutable=true +fi + +if [[ "${immutable}" != "true" ]]; then + echo "WARNING: BUZZ_IMAGE is not a digest or full commit-SHA tag: ${image}" >&2 + [[ "${BUZZ_ALLOW_FLOATING_IMAGE:-false}" == "true" ]] || + fail "Floating images are rejected. Set BUZZ_ALLOW_FLOATING_IMAGE=true only for intentional development/testing use." +fi + +echo "Launchpad relay image: ${image}" +echo "Docker Compose version: ${compose_version}" + +export BUZZ_IMAGE="${image}" +if [[ "$1" == "check" ]]; then + "${CANONICAL_RUNNER}" config >/dev/null + echo "Launchpad deployment configuration is valid." + exit 0 +fi + +exec "${CANONICAL_RUNNER}" "$@" diff --git a/launchpad/deploy/test-run-guard.sh b/launchpad/deploy/test-run-guard.sh new file mode 100755 index 00000000000..1c77fe04d97 --- /dev/null +++ b/launchpad/deploy/test-run-guard.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Exercises launchpad/deploy/run.sh's BUZZ_IMAGE guard end-to-end against a +# stubbed docker/compose runner, so the immutability and placeholder checks +# can be verified without a real Docker daemon. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REAL_RUN_SH="${SCRIPT_DIR}/run.sh" + +WORK=$(mktemp -d) +trap 'rm -rf "${WORK}"' EXIT + +mkdir -p "${WORK}/launchpad/deploy" "${WORK}/deploy/compose" "${WORK}/bin" +cp "${REAL_RUN_SH}" "${WORK}/launchpad/deploy/run.sh" +chmod +x "${WORK}/launchpad/deploy/run.sh" + +# Stub canonical runner: always succeeds, so the guard's own exit code/message +# is what we're asserting on, not real Docker Compose behaviour. +cat >"${WORK}/deploy/compose/run.sh" <<'STUB' +#!/usr/bin/env bash +echo "STUB canonical runner invoked: $*" +exit 0 +STUB +chmod +x "${WORK}/deploy/compose/run.sh" + +# Stub docker: only `compose version --short` and `compose version` are used +# by run.sh before the guard's own checks run. +cat >"${WORK}/bin/docker" <<'STUB' +#!/usr/bin/env bash +if [[ "$1" == "compose" && "$2" == "version" ]]; then + if [[ "$3" == "--short" ]]; then + echo "2.24.4" + else + echo "Docker Compose version v2.24.4" + fi + exit 0 +fi +exit 1 +STUB +chmod +x "${WORK}/bin/docker" + +PASS=0 +FAIL=0 + +run_guard() { + local image=$1 + local allow_floating=${2:-false} + printf 'BUZZ_IMAGE=%s\n' "${image}" >"${WORK}/deploy/compose/.env" + ( + cd "${WORK}" + PATH="${WORK}/bin:${PATH}" BUZZ_ALLOW_FLOATING_IMAGE="${allow_floating}" \ + "${WORK}/launchpad/deploy/run.sh" check + ) +} + +assert_accepted() { + local name=$1 image=$2 + local output status + output=$(run_guard "${image}" 2>&1) && status=0 || status=$? + if [[ "${status}" -eq 0 ]] && ! grep -q "Floating images are rejected" <<<"${output}"; then + echo "PASS: ${name}" + PASS=$((PASS + 1)) + else + echo "FAIL: ${name} — expected acceptance, got (exit ${status}):" + echo "${output}" | sed 's/^/ /' + FAIL=$((FAIL + 1)) + fi +} + +assert_rejected_with() { + local name=$1 image=$2 expected_substring=$3 + local output status + output=$(run_guard "${image}" 2>&1) && status=0 || status=$? + if [[ "${status}" -ne 0 ]] && grep -qF "${expected_substring}" <<<"${output}"; then + echo "PASS: ${name}" + PASS=$((PASS + 1)) + else + echo "FAIL: ${name} — expected rejection containing '${expected_substring}', got (exit ${status}):" + echo "${output}" | sed 's/^/ /' + FAIL=$((FAIL + 1)) + fi +} + +echo "--- #155: debug-sha-<40hex> must be accepted as immutable ---" +assert_accepted "plain sha- tag still accepted (regression guard)" \ + "ghcr.io/launchpad-26/buzz:sha-$(printf 'a%.0s' {1..40})" +assert_accepted "digest form still accepted (regression guard)" \ + "ghcr.io/launchpad-26/buzz@sha256:$(printf 'a%.0s' {1..64})" +assert_accepted "debug-sha- tag now accepted" \ + "ghcr.io/launchpad-26/buzz:debug-sha-$(printf 'a%.0s' {1..40})" + +echo "" +echo "--- #156: unreplaced CHANGE_ME placeholder must fail with its own message ---" +assert_rejected_with "CHANGE_ME placeholder names the real problem" \ + "ghcr.io/launchpad-26/buzz:sha-CHANGE_ME_FULL_40_CHARACTER_GIT_COMMIT" \ + "still contains the .env.example placeholder" + +echo "" +echo "--- regression guard: genuinely floating tags are still rejected ---" +assert_rejected_with "a plain :main tag is still rejected as floating" \ + "ghcr.io/launchpad-26/buzz:main" \ + "Floating images are rejected" +assert_rejected_with "upstream image is still forbidden outright" \ + "ghcr.io/block/buzz:main" \ + "forbidden for Launchpad deployment" + +echo "" +echo "===================================================" +echo "${PASS} passed, ${FAIL} failed" +[[ "${FAIL}" -eq 0 ]]