From 57b4026f6615089578fe17db653834735fc3a86d Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 21 Aug 2026 15:00:01 -0500 Subject: [PATCH 1/2] build: converge the Dockerfile generators with the testing branch The generator layer had drifted: testing gained the (version, source_mode) split in July, master never did. That is what made generator_checks a testing-only job and left master unable to produce a context-sourced image with a real ENV version. Takes testing's four generator files verbatim -- both generate_dockerfile.sh and both test_generate_dockerfile.sh -- so the layer is now byte-identical across branches rather than approximately similar. The minimal generator's default php moves 8.4 -> 8.5 as part of that, because the decoupling and the default bump landed in the same upstream commit and the minimal harness asserts 8.5. This corrects stale config rather than changing what ships: every released image is the testing-built php85 one, so master's committed 8.4 default only ever affected container-validation and local `docker build`. Committed Dockerfiles regenerated via `make update_version VERSION=20260820-001`. The only substantive change is that php default; the rest is comment punctuation. The -fsSL guard on the schema fetch survives the regeneration (asserted by the full-stack harness). Co-Authored-By: Claude Opus 5 (1M context) --- simplerisk-minimal/Dockerfile | 4 +- simplerisk-minimal/generate_dockerfile.sh | 21 +++++-- .../test_generate_dockerfile.sh | 51 ++++++++++++++++ simplerisk/Dockerfile | 3 +- simplerisk/generate_dockerfile.sh | 23 +++++-- simplerisk/test_generate_dockerfile.sh | 61 +++++++++++++++++++ 6 files changed, 149 insertions(+), 14 deletions(-) create mode 100755 simplerisk-minimal/test_generate_dockerfile.sh create mode 100755 simplerisk/test_generate_dockerfile.sh diff --git a/simplerisk-minimal/Dockerfile b/simplerisk-minimal/Dockerfile index a1e44ca..0554029 100644 --- a/simplerisk-minimal/Dockerfile +++ b/simplerisk-minimal/Dockerfile @@ -1,5 +1,5 @@ # Dockerfile generated by script -ARG php_version=8.4 +ARG php_version=8.5 FROM alpine/curl:8.12.1 AS downloader @@ -35,7 +35,7 @@ ARG TARGETARCH # amd64: mysql-community-client from MySQL's Debian repo # arm64: default-mysql-client from Debian (MySQL's apt repo has no arm64 packages) # apt-get upgrade patches base-image packages (apache2, curl, ...) with Debian -# security updates -- the pinned php:${php_version}-apache base ships them at its +# security updates — the pinned php:${php_version}-apache base ships them at its # own build-time versions, so without this they accumulate fixed CVEs (Grype gate). RUN apt-get update && \ apt-get -y upgrade && \ diff --git a/simplerisk-minimal/generate_dockerfile.sh b/simplerisk-minimal/generate_dockerfile.sh index 8075266..53ba282 100755 --- a/simplerisk-minimal/generate_dockerfile.sh +++ b/simplerisk-minimal/generate_dockerfile.sh @@ -7,19 +7,30 @@ 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 app from build context (no downloader stage); +# `download` = COPY --from=downloader (curl the prod bundle). 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 -ARG php_version=8.4 +ARG php_version=8.5 EOF -if [ "$release" != "testing" ]; then +if [ "$source_mode" == "download" ]; then cat << EOF >> "${SCRIPT_LOCATION}/Dockerfile" FROM alpine/curl:8.12.1 AS downloader @@ -59,7 +70,7 @@ ARG TARGETARCH # amd64: mysql-community-client from MySQL's Debian repo # arm64: default-mysql-client from Debian (MySQL's apt repo has no arm64 packages) # apt-get upgrade patches base-image packages (apache2, curl, ...) with Debian -# security updates -- the pinned php:\${php_version}-apache base ships them at its +# security updates — the pinned php:\${php_version}-apache base ships them at its # own build-time versions, so without this they accumulate fixed CVEs (Grype gate). RUN apt-get update && \\ apt-get -y upgrade && \\ @@ -116,7 +127,7 @@ RUN echo "0 0 * * * root /usr/sbin/logrotate /etc/logrotate.d/simplerisk.conf > COPY common/ / EOF # shellcheck disable=SC2015 -if [ "$release" == "testing" ]; then +if [ "$source_mode" == "context" ]; then cat << EOF >> "${SCRIPT_LOCATION}/Dockerfile" COPY simplerisk/ /var/www/simplerisk COPY common/simplerisk.sql /var/www/simplerisk/simplerisk.sql diff --git a/simplerisk-minimal/test_generate_dockerfile.sh b/simplerisk-minimal/test_generate_dockerfile.sh new file mode 100755 index 0000000..545de44 --- /dev/null +++ b/simplerisk-minimal/test_generate_dockerfile.sh @@ -0,0 +1,51 @@ +#!/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, php 8.5 default +./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" +absent "context: no downloader stage" "FROM alpine/curl" +check "context: php default 8.5" "ARG php_version=8.5" + +# 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" + +# back-compat: literal "testing" with no mode arg still selects 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" + +# 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 diff --git a/simplerisk/Dockerfile b/simplerisk/Dockerfile index 6b9b7c0..bcaa9ba 100644 --- a/simplerisk/Dockerfile +++ b/simplerisk/Dockerfile @@ -4,8 +4,7 @@ ARG ubuntu_version_code=noble FROM alpine/curl:8.12.1 AS downloader ARG DB_LANG=en - -# CI-ONLY pre-GA switch (default false) -- see common/download_and_verify_bundle.sh. +# CI-ONLY pre-GA switch (default false) — see common/download_and_verify_bundle.sh. ARG PREGA_BUNDLE_FALLBACK=false SHELL [ "/bin/ash", "-eo", "pipefail", "-c" ] diff --git a/simplerisk/generate_dockerfile.sh b/simplerisk/generate_dockerfile.sh index 8dd41c8..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,13 +31,12 @@ 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 ARG DB_LANG=en - -# CI-ONLY pre-GA switch (default false) -- see common/download_and_verify_bundle.sh. +# CI-ONLY pre-GA switch (default false) — see common/download_and_verify_bundle.sh. ARG PREGA_BUNDLE_FALLBACK=false SHELL [ "/bin/ash", "-eo", "pipefail", "-c" ] @@ -99,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 From 59ee1c602f252dcab2d122294d9cee4a6da28a30 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 21 Aug 2026 15:00:01 -0500 Subject: [PATCH 2/2] ci(container-validation): add php 8.5 + generator checks, fix the job-key skew - generator_checks runs both harnesses on PRs and fails if either leaves a committed Dockerfile modified. The generators are load-bearing for the release path but nothing pinned them on master. - Adds the php 8.5 variant, which is the minimal image's default and the one GA actually promotes, yet was unvalidated here. - Fixes a name/key skew that made the matrix misleading: job simplerisk-minimal-php84 built php 8.3 and was labelled "PHP 8.3", and simplerisk-minimal-php85 built 8.4. Keys, labels and build args now agree. CLAUDE.md's CI/CD bullet still claimed jammy/noble/php81/php83; corrected along with the stale php_version build-arg list. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/container-validation.yml | 39 ++++++++++++++++++++-- CLAUDE.md | 4 +-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/workflows/container-validation.yml b/.github/workflows/container-validation.yml index 78a710e..2af75bb 100644 --- a/.github/workflows/container-validation.yml +++ b/.github/workflows/container-validation.yml @@ -27,7 +27,7 @@ jobs: image_tag: "simplerisk/simplerisk:testing" build_args: "ubuntu_version_code=noble\nPREGA_BUNDLE_FALLBACK=true" - simplerisk-minimal-php84: + simplerisk-minimal-php83: name: 'Verify simplerisk/simplerisk-minimal image based on PHP 8.3 with Apache' uses: ./.github/workflows/verify-image_rw.yml with: @@ -36,7 +36,7 @@ jobs: image_tag: "simplerisk/simplerisk-minimal:testing" build_args: "php_version=8.3\nPREGA_BUNDLE_FALLBACK=true" - simplerisk-minimal-php85: + simplerisk-minimal-php84: name: 'Verify simplerisk/simplerisk-minimal image based on PHP 8.4 with Apache' uses: ./.github/workflows/verify-image_rw.yml with: @@ -44,3 +44,38 @@ jobs: dockerfile_path: "simplerisk-minimal/Dockerfile" image_tag: "simplerisk/simplerisk-minimal:testing" build_args: "php_version=8.4\nPREGA_BUNDLE_FALLBACK=true" + + simplerisk-minimal-php85: + name: 'Verify simplerisk/simplerisk-minimal image based on PHP 8.5 with Apache' + uses: ./.github/workflows/verify-image_rw.yml + with: + context_path: "simplerisk-minimal/" + 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/CLAUDE.md b/CLAUDE.md index 1b287d8..8f0fc4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ Both images use multi-stage builds (Alpine curl downloader stage → main stage) # Full-stack (build args: ubuntu_version_code=jammy|noble) docker build -t simplerisk/simplerisk simplerisk/ -# Minimal (build args: php_version=8.1|8.3|8.4) +# Minimal (build args: php_version=8.3|8.4|8.5; default 8.5) docker build -t simplerisk/simplerisk-minimal simplerisk-minimal/ ``` @@ -119,7 +119,7 @@ The entrypoint script handles: ### CI/CD -- **PRs** trigger `container-validation.yml`: builds all 4 variants (jammy, noble, php81, php83), runs Dockle (Dockerfile linter) and Grype (CVE scanner, severity cutoff: critical, only-fixed). +- **PRs** trigger `container-validation.yml`: builds all 5 variants (jammy, noble, php83, php84, php85), runs Dockle (Dockerfile linter) and Grype (CVE scanner, severity cutoff: critical, only-fixed), and runs `generator_checks` — the two `test_generate_dockerfile.sh` harnesses that pin the generators' version/source-mode behaviour. - **Release images are built once, then promoted — never rebuilt.** A push to `testing` runs `publish-testing.yml`, which builds both images from the current testing bundle and publishes immutable tags: `simplerisk-minimal` gets `-php83/-php84/-php85` (multi-arch `linux/amd64,linux/arm64`) and `simplerisk` gets `-jammy/-noble` (amd64). Each image's default variant also takes the bare `` and the floating `:testing`. - **GA is a manual promote, not a build.** After the release merges to `master`, dispatch `promote-latest.yml`. It retags Docker Hub `:latest` to the existing RC digest (`buildx imagetools create`, multi-arch preserved), mirrors the same digests to GHCR cosign-signed, and writes SSM `/simplerisk/customers/image-tag/latest`. Nothing is rebuilt, so the bytes validated in testing are the bytes that ship. A currency guard refuses to promote a version whose digest is not the one `:testing` currently points at. - The reusable workflow files (`*_rw.yml`) are called by the entry-point workflows.