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. 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