diff --git a/.github/workflows/container-validation.yml b/.github/workflows/container-validation.yml index 887c8ff..82763ca 100644 --- a/.github/workflows/container-validation.yml +++ b/.github/workflows/container-validation.yml @@ -50,3 +50,29 @@ jobs: dockerfile_path: "simplerisk-minimal/Dockerfile" image_tag: "simplerisk/simplerisk-minimal:testing" build_args: "php_version=8.5\nPREGA_BUNDLE_FALLBACK=true" + + generator_checks: + name: 'Verify the Dockerfile generators (version/source-mode decoupling)' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # Both generators take (version, source_mode). The RC build depends on + # `context` mode stamping a real ENV version while sourcing the app from + # the build context; the release build depends on `download` mode still + # emitting the hash-verifying downloader stage. These harnesses pin both. + - name: simplerisk-minimal generator + run: ./simplerisk-minimal/test_generate_dockerfile.sh + + - name: simplerisk (full-stack) generator + run: ./simplerisk/test_generate_dockerfile.sh + + - name: Fail if a generator left the committed Dockerfile modified + run: | + set -euo pipefail + if ! git diff --quiet -- simplerisk/Dockerfile simplerisk-minimal/Dockerfile; then + echo "::error::a generator harness left a committed Dockerfile modified" + git --no-pager diff -- simplerisk/Dockerfile simplerisk-minimal/Dockerfile + exit 1 + fi diff --git a/.github/workflows/publish-testing.yml b/.github/workflows/publish-testing.yml index 113c290..e9da8c3 100644 --- a/.github/workflows/publish-testing.yml +++ b/.github/workflows/publish-testing.yml @@ -1,7 +1,8 @@ -name: Publish simplerisk-minimal testing image + promote +name: Publish testing images + promote -# Publishes a TESTING-channel simplerisk-minimal image and promotes it into the -# dedicated-hosting (customers-cdk) account so tier=testing customers auto-update. +# Publishes the TESTING-channel SimpleRisk images and promotes the minimal one +# into the dedicated-hosting (customers-cdk) account so tier=testing customers +# auto-update. # # Trigger: a push to this repo's `testing` branch (code-development mirrors the # current testing version here on each testing publish — see the code-development @@ -11,19 +12,25 @@ name: Publish simplerisk-minimal testing image + promote # code) + the database/master schema, via `generate_dockerfile.sh "$VERSION" # context` (COPYs the app from the assembled context, real `ENV version`) — the # same recipe the code-development `test_docker_deploy` smoke uses, but pushed -# multi-arch to Docker Hub. +# to Docker Hub. # # Tags (see design code-development docs/superpowers/specs/2026-07-10-release-image- -# promotion-design): the RC is built ONCE here from the testing bundle and later -# PROMOTED (not rebuilt) to prod. Publishes immutable per-PHP variants -# -php83/-php84/-php85, the bare (= php85, the default/main), -# and moves the floating :testing alias to it. There is NO -testing tag. -# The floating :latest and the prod SSM tier are moved by the separate GA promote -# (Plan 2), which retags this same digest — it never rebuilds. +# promotion-design): every release image is built ONCE here from the testing +# bundle and later PROMOTED (not rebuilt) to prod by promote-latest.yml on +# master, which retags these same digests. Nothing is rebuilt at GA. +# +# simplerisk/simplerisk-minimal -php83/-php84/-php85, bare +# (= php85, the default), and :testing +# simplerisk/simplerisk -jammy/-noble, bare +# (= noble, the default), and :testing +# +# There is NO -testing tag. The floating :latest and the prod SSM tier +# are moved only by the GA promote. # # Promote: writes SSM /simplerisk/customers/image-tag/testing = -php85 # in the customers account via OIDC; the image-updater Lambda there rolls every -# tier=testing service (new image + fresh extras together). +# tier=testing service (new image + fresh extras together). The full-stack image +# is not part of the managed fleet, so it has no SSM tier. on: push: @@ -40,20 +47,22 @@ concurrency: env: IMAGE_NAME: simplerisk/simplerisk-minimal + FULL_IMAGE_NAME: simplerisk/simplerisk AWS_REGION: us-east-1 SSM_PARAM: /simplerisk/customers/image-tag/testing jobs: - publish: + # Resolve the version ONCE so both publish jobs stamp the same release, even + # if the testing channel rotates mid-run. Each publish job then fetches that + # exact bundle by name and verifies its hash independently. + resolve: + name: Resolve the current testing version runs-on: ubuntu-latest - # 3 sequential multi-arch (arm64 = QEMU-emulated, slow) variant builds run here. - timeout-minutes: 120 + outputs: + version: ${{ steps.ver.outputs.version }} steps: - - name: Checkout (docker@testing) - uses: actions/checkout@v6 - - - name: Resolve current testing version + fetch bundle/schema - id: fetch + - name: Derive VERSION from the testing channel + id: ver run: | set -euo pipefail # The testing channel keeps exactly one current bundle; list it and @@ -70,10 +79,28 @@ jobs: echo "::error::could not derive VERSION from bundle '$BUNDLE'"; exit 1 fi echo "testing bundle: $BUNDLE (version $VERSION)" - curl -fsSL -o /tmp/testing-bundle.tgz "https://bundles-test.simplerisk.com/$BUNDLE" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + publish: + name: Publish simplerisk-minimal RC images + needs: resolve + runs-on: ubuntu-latest + # 3 sequential multi-arch (arm64 = QEMU-emulated, slow) variant builds run here. + timeout-minutes: 120 + steps: + - name: Checkout (docker@testing) + uses: actions/checkout@v6 + + - name: Fetch + verify the testing bundle and schema + env: + VERSION: ${{ needs.resolve.outputs.version }} + run: | + set -euo pipefail + curl -fsSL -o /tmp/testing-bundle.tgz \ + "https://bundles-test.simplerisk.com/simplerisk-${VERSION}.tgz" # Integrity: verify the bundle against the sha256 published in the served # updates-test feed (publish-bundle writes the hash on the same push). - # VERSION is regex-guarded, so it is safe in the sed pattern. + # VERSION is regex-guarded upstream, so it is safe in the sed pattern. EXPECTED_SHA=$(curl -fsSL "https://updates-test.simplerisk.com/releases.xml" \ | sed -n "//,/<\/release>/p" \ | grep -oE '[a-f0-9]{64}' | head -1 | grep -oE '[a-f0-9]{64}') @@ -88,11 +115,10 @@ jobs: SQL_URL="https://raw.githubusercontent.com/simplerisk/database/master/simplerisk-en-${VERSION}.sql" curl -fsSL -o /tmp/testing.sql "$SQL_URL" \ || { echo "::error::release schema not found on database/master: $SQL_URL"; exit 1; } - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Assemble the testing build context env: - VERSION: ${{ steps.fetch.outputs.version }} + VERSION: ${{ needs.resolve.outputs.version }} run: | set -euo pipefail cd simplerisk-minimal @@ -124,7 +150,7 @@ jobs: push: true platforms: linux/amd64,linux/arm64 build-args: php_version=8.3 - tags: ${{ env.IMAGE_NAME }}:${{ steps.fetch.outputs.version }}-php83 + tags: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php83 cache-from: type=gha,scope=minimal-testing-php83 cache-to: type=gha,mode=max,scope=minimal-testing-php83 @@ -136,7 +162,7 @@ jobs: push: true platforms: linux/amd64,linux/arm64 build-args: php_version=8.4 - tags: ${{ env.IMAGE_NAME }}:${{ steps.fetch.outputs.version }}-php84 + tags: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php84 cache-from: type=gha,scope=minimal-testing-php84 cache-to: type=gha,mode=max,scope=minimal-testing-php84 @@ -149,8 +175,8 @@ jobs: platforms: linux/amd64,linux/arm64 build-args: php_version=8.5 tags: | - ${{ env.IMAGE_NAME }}:${{ steps.fetch.outputs.version }}-php85 - ${{ env.IMAGE_NAME }}:${{ steps.fetch.outputs.version }} + ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php85 + ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }} ${{ env.IMAGE_NAME }}:testing cache-from: type=gha,scope=minimal-testing-php85 cache-to: type=gha,mode=max,scope=minimal-testing-php85 @@ -163,10 +189,95 @@ jobs: - name: Promote — SSM /image-tag/testing = -php85 env: - VERSION: ${{ steps.fetch.outputs.version }} + VERSION: ${{ needs.resolve.outputs.version }} run: | set -euo pipefail aws ssm put-parameter --name "$SSM_PARAM" \ --value "${VERSION}-php85" --type String --overwrite \ --region "$AWS_REGION" echo "promoted $SSM_PARAM = ${VERSION}-php85" >> "$GITHUB_STEP_SUMMARY" + + # The full-stack image is built here for the same build-once reason as the + # minimal one: GA promotes this digest rather than rebuilding from the prod + # bundle. It is amd64-only, matching what the release build published before. + publish-full: + name: Publish simplerisk (full-stack) RC images + needs: resolve + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: read + steps: + - name: Checkout (docker@testing) + uses: actions/checkout@v6 + + - name: Fetch + verify the testing bundle and schema + env: + VERSION: ${{ needs.resolve.outputs.version }} + run: | + set -euo pipefail + curl -fsSL -o /tmp/testing-bundle.tgz \ + "https://bundles-test.simplerisk.com/simplerisk-${VERSION}.tgz" + EXPECTED_SHA=$(curl -fsSL "https://updates-test.simplerisk.com/releases.xml" \ + | sed -n "//,/<\/release>/p" \ + | grep -oE '[a-f0-9]{64}' | head -1 | grep -oE '[a-f0-9]{64}') + if ! printf '%s' "$EXPECTED_SHA" | grep -qE '^[a-f0-9]{64}$'; then + echo "::error::no bundle_sha256 for $VERSION in updates-test releases.xml"; exit 1 + fi + ACTUAL_SHA=$(sha256sum /tmp/testing-bundle.tgz | cut -d' ' -f1) + if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then + echo "::error::bundle sha256 mismatch for $VERSION (expected $EXPECTED_SHA, got $ACTUAL_SHA)"; exit 1 + fi + echo "bundle sha256 verified" + SQL_URL="https://raw.githubusercontent.com/simplerisk/database/master/simplerisk-en-${VERSION}.sql" + curl -fsSL -o /tmp/testing.sql "$SQL_URL" \ + || { echo "::error::release schema not found on database/master: $SQL_URL"; exit 1; } + + - name: Assemble the testing build context + env: + VERSION: ${{ needs.resolve.outputs.version }} + run: | + set -euo pipefail + cd simplerisk + # Same context recipe as the minimal image: COPY the app + schema from + # the build context, with a real `ENV version=$VERSION`. + ./generate_dockerfile.sh "$VERSION" context + tar xzf /tmp/testing-bundle.tgz -C . + cp /tmp/testing.sql common/simplerisk.sql + test -d simplerisk || { echo "::error::bundle did not extract a simplerisk/ dir"; exit 1; } + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and push jammy — -jammy + uses: docker/build-push-action@v7 + with: + context: simplerisk + file: simplerisk/Dockerfile + push: true + platforms: linux/amd64 + build-args: ubuntu_version_code=jammy + tags: ${{ env.FULL_IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-jammy + cache-from: type=gha,scope=full-testing-jammy + cache-to: type=gha,mode=max,scope=full-testing-jammy + + - name: Build and push noble (default) — -noble + + :testing + uses: docker/build-push-action@v7 + with: + context: simplerisk + file: simplerisk/Dockerfile + push: true + platforms: linux/amd64 + build-args: ubuntu_version_code=noble + tags: | + ${{ env.FULL_IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-noble + ${{ env.FULL_IMAGE_NAME }}:${{ needs.resolve.outputs.version }} + ${{ env.FULL_IMAGE_NAME }}:testing + cache-from: type=gha,scope=full-testing-noble + cache-to: type=gha,mode=max,scope=full-testing-noble diff --git a/simplerisk/Dockerfile b/simplerisk/Dockerfile index 33b774b..d9fae79 100644 --- a/simplerisk/Dockerfile +++ b/simplerisk/Dockerfile @@ -11,9 +11,11 @@ SHELL [ "/bin/ash", "-eo", "pipefail", "-c" ] # Download the prod bundle, verify its published sha256 (md5 fallback) from the # updates feed, then extract (fail-closed) -- then fetch the release SQL schema. +# -fsSL on the SQL fetch too: without --fail, curl writes the 404 body into +# /simplerisk.sql and the image ships an HTML error page as its schema. COPY common/download_and_verify_bundle.sh /download_and_verify_bundle.sh RUN PREGA_BUNDLE_FALLBACK="$PREGA_BUNDLE_FALLBACK" sh /download_and_verify_bundle.sh 20260519-001 && \ - curl -sL "https://github.com/simplerisk/database/raw/master/simplerisk-$DB_LANG-20260519-001.sql" > /simplerisk.sql + curl -fsSL "https://github.com/simplerisk/database/raw/master/simplerisk-$DB_LANG-20260519-001.sql" > /simplerisk.sql # Using Ubuntu image FROM ubuntu:${ubuntu_version_code} diff --git a/simplerisk/generate_dockerfile.sh b/simplerisk/generate_dockerfile.sh index 6ee759d..10e347a 100755 --- a/simplerisk/generate_dockerfile.sh +++ b/simplerisk/generate_dockerfile.sh @@ -5,11 +5,25 @@ set -euo pipefail SCRIPT_LOCATION="$(dirname "$(readlink -f "$0")")" readonly SCRIPT_LOCATION -if [ $# -eq 1 ]; then +if [ $# -ge 1 ]; then release=$1 else echo "No release version provided. Aborting." && exit 1 fi +# Source mode: `context` = COPY the app from the build context (no downloader +# stage); `download` = COPY --from=downloader (curl + hash-verify the prod +# bundle). Splitting this out of $release lets the RC build stamp a real +# `ENV version=` while still sourcing bytes from the build context -- +# the build-once half of the promote model. Default preserves back-compat: +# context when release==testing, else download. +if [ $# -ge 2 ]; then + source_mode=$2 +else + if [ "$release" == "testing" ]; then source_mode="context"; else source_mode="download"; fi +fi +if [ "$source_mode" != "context" ] && [ "$source_mode" != "download" ]; then + echo "Invalid source mode '$source_mode' (expected context|download). Aborting." && exit 1 +fi cat << EOF > "${SCRIPT_LOCATION}/Dockerfile" # Dockerfile generated by script @@ -17,7 +31,7 @@ ARG ubuntu_version_code=noble EOF -if [ "$release" != "testing" ]; then +if [ "$source_mode" == "download" ]; then cat << EOF >> "${SCRIPT_LOCATION}/Dockerfile" FROM alpine/curl:8.12.1 AS downloader @@ -29,9 +43,11 @@ SHELL [ "/bin/ash", "-eo", "pipefail", "-c" ] # Download the prod bundle, verify its published sha256 (md5 fallback) from the # updates feed, then extract (fail-closed) -- then fetch the release SQL schema. +# -fsSL on the SQL fetch too: without --fail, curl writes the 404 body into +# /simplerisk.sql and the image ships an HTML error page as its schema. COPY common/download_and_verify_bundle.sh /download_and_verify_bundle.sh RUN PREGA_BUNDLE_FALLBACK="\$PREGA_BUNDLE_FALLBACK" sh /download_and_verify_bundle.sh $release && \\ - curl -sL "https://github.com/simplerisk/database/raw/master/simplerisk-\$DB_LANG-$release.sql" > /simplerisk.sql + curl -fsSL "https://github.com/simplerisk/database/raw/master/simplerisk-\$DB_LANG-$release.sql" > /simplerisk.sql EOF fi @@ -96,7 +112,7 @@ RUN echo "\$(< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c\${1:-32})" > /password # Install common files COPY common/ / EOF -if [ "$release" == "testing" ]; then +if [ "$source_mode" == "context" ]; then cat << EOF >> "${SCRIPT_LOCATION}/Dockerfile" COPY common/simplerisk.sql /simplerisk.sql COPY ./simplerisk/ /var/www/simplerisk diff --git a/simplerisk/test_generate_dockerfile.sh b/simplerisk/test_generate_dockerfile.sh new file mode 100755 index 0000000..20a5f58 --- /dev/null +++ b/simplerisk/test_generate_dockerfile.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Regression checks for generate_dockerfile.sh version/source-mode decoupling. +set -euo pipefail +cd "$(dirname "$(readlink -f "$0")")" + +# generate_dockerfile.sh hardcodes its output to the tracked Dockerfile in this +# directory; back it up and restore it on exit (pass or fail) so this checker +# never leaves the committed Dockerfile overwritten. +cp Dockerfile "/tmp/Dockerfile.bak.$$" 2>/dev/null || true +trap 'cp "/tmp/Dockerfile.bak.$$" Dockerfile 2>/dev/null || git checkout -- Dockerfile 2>/dev/null || true; rm -f "/tmp/Dockerfile.bak.$$"' EXIT + +fail=0 +check() { if grep -qF "$2" Dockerfile; then echo "ok: $1"; else echo "FAIL: $1 (missing: $2)"; fail=1; fi; } +absent() { if grep -qF "$2" Dockerfile; then echo "FAIL: $1 (should be absent: $2)"; fail=1; else echo "ok: $1"; fi; } + +# context mode with a real version: no downloader, COPY-from-context, real ENV version +./generate_dockerfile.sh 20260709-001 context +check "context: real ENV version" "ENV version=20260709-001" +check "context: COPY app from context" "COPY ./simplerisk/ /var/www/simplerisk" +check "context: COPY schema" "COPY common/simplerisk.sql /simplerisk.sql" +absent "context: no downloader stage" "FROM alpine/curl" +check "context: ubuntu default noble" "ARG ubuntu_version_code=noble" + +# download mode (explicit): downloader present, COPY-from-downloader, real ENV version +./generate_dockerfile.sh 20260709-001 download +check "download: downloader stage" "FROM alpine/curl" +check "download: COPY from downloader" "COPY --from=downloader /var/www/simplerisk /var/www/simplerisk" +check "download: real ENV version" "ENV version=20260709-001" +# --fail on the schema fetch: without it curl writes the 404 body into +# /simplerisk.sql and the image ships an HTML error page as its schema. +# Single-quoted on purpose: $DB_LANG must stay literal, it is a Dockerfile ARG. +# shellcheck disable=SC2016 +check "download: schema fetch uses -fsSL" 'curl -fsSL "https://github.com/simplerisk/database/raw/master/simplerisk-$DB_LANG-20260709-001.sql"' + +# back-compat: literal "testing" with no mode arg still selects the context recipe +./generate_dockerfile.sh testing +absent "testing back-compat: no downloader" "FROM alpine/curl" +check "testing back-compat: COPY context" "COPY ./simplerisk/ /var/www/simplerisk" + +# back-compat: a bare version with no mode arg still selects the download recipe +./generate_dockerfile.sh 20260709-001 +check "version back-compat: downloader stage" "FROM alpine/curl" + +# invalid source-mode is rejected +if ./generate_dockerfile.sh 20260709-001 bogus >/tmp/bogus-mode.out.$$ 2>&1; then + echo "FAIL: invalid source-mode should be rejected (exited 0)"; fail=1 +else + echo "ok: invalid source-mode rejected" +fi +rm -f "/tmp/bogus-mode.out.$$" + +# idempotence: re-running the same context args does not double anything +./generate_dockerfile.sh 20260709-001 context +copy_count=$(grep -cF "COPY ./simplerisk/ /var/www/simplerisk" Dockerfile) +if [ "$copy_count" -eq 1 ]; then + echo "ok: idempotence (single COPY ./simplerisk/ line after re-run)" +else + echo "FAIL: idempotence (expected 1 COPY ./simplerisk/ line, found $copy_count)"; fail=1 +fi + +exit $fail