From 1f1d99e3c922bc43ac4151bac4123983aa62dc60 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 10 Jul 2026 13:54:18 -0500 Subject: [PATCH 1/5] build(minimal): decouple image version from source-mode; default php 8.5 --- simplerisk-minimal/Dockerfile | 2 +- simplerisk-minimal/generate_dockerfile.sh | 19 ++++++++++--- .../test_generate_dockerfile.sh | 27 +++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) create mode 100755 simplerisk-minimal/test_generate_dockerfile.sh diff --git a/simplerisk-minimal/Dockerfile b/simplerisk-minimal/Dockerfile index b62dd3c..87a1002 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 diff --git a/simplerisk-minimal/generate_dockerfile.sh b/simplerisk-minimal/generate_dockerfile.sh index 433fd1a..e6a7f2e 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 @@ -102,7 +113,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..1232f9e --- /dev/null +++ b/simplerisk-minimal/test_generate_dockerfile.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Regression checks for generate_dockerfile.sh version/source-mode decoupling. +set -euo pipefail +cd "$(dirname "$(readlink -f "$0")")" +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" + +exit $fail From ec196ced1900d1a32978bcbed45d0bdec3650938 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 10 Jul 2026 13:59:04 -0500 Subject: [PATCH 2/5] ci(container-validation): validate php 8.5; fix minimal job-key/php skew --- .github/workflows/container-validation.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/container-validation.yml b/.github/workflows/container-validation.yml index 6a015ac..12544fb 100644 --- a/.github/workflows/container-validation.yml +++ b/.github/workflows/container-validation.yml @@ -24,20 +24,29 @@ jobs: image_tag: "simplerisk/simplerisk:testing" build_args: "ubuntu_version_code=noble" - 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: context_path: "simplerisk-minimal/" dockerfile_path: "simplerisk-minimal/Dockerfile" image_tag: "simplerisk/simplerisk-minimal:testing" - build_args: "php_version=8.3" + 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: context_path: "simplerisk-minimal/" dockerfile_path: "simplerisk-minimal/Dockerfile" image_tag: "simplerisk/simplerisk-minimal:testing" - build_args: "php_version=8.4" + 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" From bb2972b251def6ca1ab194e9e9a7471812d83891 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 10 Jul 2026 14:06:53 -0500 Subject: [PATCH 3/5] build(minimal): fold PREGA_BUNDLE_FALLBACK into the generator download recipe (supersedes #146) --- simplerisk-minimal/generate_dockerfile.sh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/simplerisk-minimal/generate_dockerfile.sh b/simplerisk-minimal/generate_dockerfile.sh index e6a7f2e..8978e33 100755 --- a/simplerisk-minimal/generate_dockerfile.sh +++ b/simplerisk-minimal/generate_dockerfile.sh @@ -34,10 +34,22 @@ if [ "$source_mode" == "download" ]; then cat << EOF >> "${SCRIPT_LOCATION}/Dockerfile" FROM alpine/curl:8.12.1 AS downloader +# PREGA_BUNDLE_FALLBACK is a CI-ONLY switch, default false. Pre-GA the prod +# bundle for a new version does not exist yet (it lands in public/bundles/ only +# at GA). CI sets this true so a pre-GA build can fall back to the testing +# bundle. A released image is ALWAYS built from the prod bundle and NEVER +# silently falls back to testing bytes (even on a transient prod failure). +ARG PREGA_BUNDLE_FALLBACK=false + SHELL [ "/bin/ash", "-eo", "pipefail", "-c" ] RUN mkdir -p /var/www && \\ - curl -sL https://simplerisk-downloads.s3.amazonaws.com/public/bundles/simplerisk-$release.tgz | tar xz -C /var/www + if curl -fsSL https://simplerisk-downloads.s3.amazonaws.com/public/bundles/simplerisk-$release.tgz -o /tmp/bundle.tgz; then true; \\ + elif [ "\$PREGA_BUNDLE_FALLBACK" = "true" ]; then \\ + echo "prod bundle absent — pre-GA CI fallback to bundles-test"; \\ + curl -fsSL https://bundles-test.simplerisk.com/simplerisk-$release.tgz -o /tmp/bundle.tgz; \\ + else echo "prod bundle simplerisk-$release.tgz not found and PREGA_BUNDLE_FALLBACK=false" && exit 1; fi && \\ + tar xzf /tmp/bundle.tgz -C /var/www && rm -f /tmp/bundle.tgz EOF fi From 82635c898d705129f94546767eb1475249387d81 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 10 Jul 2026 14:11:30 -0500 Subject: [PATCH 4/5] ci(publish-testing): build-once immutable php83/84/85 + bare + :testing; drop -testing; promote php85 --- .github/workflows/publish-testing.yml | 56 ++++++++++++++++++++------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish-testing.yml b/.github/workflows/publish-testing.yml index b750932..e37655b 100644 --- a/.github/workflows/publish-testing.yml +++ b/.github/workflows/publish-testing.yml @@ -12,13 +12,15 @@ name: Publish simplerisk-minimal testing image + promote # (COPYs the app from the context) — the same recipe the code-development # `test_docker_deploy` smoke uses, but pushed multi-arch to Docker Hub. # -# Tags (see design 2026-07-01-testing-image-promote): an IMMUTABLE per-version -# tag `-testing` plus the floating `:testing` alias. The bare `` -# and `:latest` tags are RESERVED for the release build (master) and are NOT -# touched here — the testing and release images are different builds (testing -# bundle vs finalized public bundle), so they must not share the bare version tag. +# 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. # -# Promote: writes SSM /simplerisk/customers/image-tag/testing = -testing +# 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). @@ -94,7 +96,7 @@ jobs: cd simplerisk-minimal # generate_dockerfile.sh testing -> a Dockerfile that COPYs simplerisk/ # (app) + common/simplerisk.sql (schema) from this context. - ./generate_dockerfile.sh testing + ./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; } @@ -111,18 +113,44 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - - name: Build and push (multi-arch) — -testing + :testing + - name: Build and push php83 — -php83 uses: docker/build-push-action@v7 with: context: simplerisk-minimal file: simplerisk-minimal/Dockerfile push: true platforms: linux/amd64,linux/arm64 + build-args: php_version=8.3 + tags: ${{ env.IMAGE_NAME }}:${{ steps.fetch.outputs.version }}-php83 + cache-from: type=gha,scope=minimal-testing-php83 + cache-to: type=gha,mode=max,scope=minimal-testing-php83 + + - name: Build and push php84 — -php84 + uses: docker/build-push-action@v7 + with: + context: simplerisk-minimal + file: simplerisk-minimal/Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + build-args: php_version=8.4 + tags: ${{ env.IMAGE_NAME }}:${{ steps.fetch.outputs.version }}-php84 + cache-from: type=gha,scope=minimal-testing-php84 + cache-to: type=gha,mode=max,scope=minimal-testing-php84 + + - name: Build and push php85 (default) — -php85 + + :testing + uses: docker/build-push-action@v7 + with: + context: simplerisk-minimal + file: simplerisk-minimal/Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + build-args: php_version=8.5 tags: | - ${{ env.IMAGE_NAME }}:${{ steps.fetch.outputs.version }}-testing + ${{ env.IMAGE_NAME }}:${{ steps.fetch.outputs.version }}-php85 + ${{ env.IMAGE_NAME }}:${{ steps.fetch.outputs.version }} ${{ env.IMAGE_NAME }}:testing - cache-from: type=gha,scope=minimal-testing - cache-to: type=gha,mode=max,scope=minimal-testing + cache-from: type=gha,scope=minimal-testing-php85 + cache-to: type=gha,mode=max,scope=minimal-testing-php85 - name: Configure AWS credentials (OIDC → customers account) uses: aws-actions/configure-aws-credentials@v4 @@ -130,12 +158,12 @@ jobs: role-to-assume: ${{ vars.IMAGE_PROMOTER_TESTING_ROLE_ARN }} aws-region: ${{ env.AWS_REGION }} - - name: Promote — SSM /image-tag/testing = -testing + - name: Promote — SSM /image-tag/testing = -php85 env: VERSION: ${{ steps.fetch.outputs.version }} run: | set -euo pipefail aws ssm put-parameter --name "$SSM_PARAM" \ - --value "${VERSION}-testing" --type String --overwrite \ + --value "${VERSION}-php85" --type String --overwrite \ --region "$AWS_REGION" - echo "promoted $SSM_PARAM = ${VERSION}-testing" >> "$GITHUB_STEP_SUMMARY" + echo "promoted $SSM_PARAM = ${VERSION}-php85" >> "$GITHUB_STEP_SUMMARY" From 05b2a0e6496a5aa39e11bb94d11d9f30fdc955f1 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 10 Jul 2026 14:23:28 -0500 Subject: [PATCH 5/5] fix(minimal): sync committed Dockerfile to generator, non-destructive checker, raise publish-testing timeout, doc/comment fixes --- .github/workflows/publish-testing.yml | 15 +++++++----- CLAUDE.md | 4 ++-- simplerisk-minimal/Dockerfile | 14 ++++++++++- .../test_generate_dockerfile.sh | 24 +++++++++++++++++++ 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/.github/workflows/publish-testing.yml b/.github/workflows/publish-testing.yml index e37655b..de4fded 100644 --- a/.github/workflows/publish-testing.yml +++ b/.github/workflows/publish-testing.yml @@ -8,9 +8,10 @@ name: Publish simplerisk-minimal testing image + promote # `sync_docker_testing` workflow), or a manual dispatch. # # Build: the CURRENT testing bundle from bundles-test (the built testing-branch -# code) + the database/testing schema, via `generate_dockerfile.sh testing` -# (COPYs the app from the context) — the same recipe the code-development -# `test_docker_deploy` smoke uses, but pushed multi-arch to Docker Hub. +# code) + the database/testing 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. # # 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 @@ -45,7 +46,8 @@ env: jobs: publish: runs-on: ubuntu-latest - timeout-minutes: 40 + # 3 sequential multi-arch (arm64 = QEMU-emulated, slow) variant builds run here. + timeout-minutes: 120 steps: - name: Checkout (docker@testing) uses: actions/checkout@v6 @@ -94,8 +96,9 @@ jobs: run: | set -euo pipefail cd simplerisk-minimal - # generate_dockerfile.sh testing -> a Dockerfile that COPYs simplerisk/ - # (app) + common/simplerisk.sql (schema) from this context. + # generate_dockerfile.sh "$VERSION" context -> a Dockerfile that COPYs + # simplerisk/ (app) + common/simplerisk.sql (schema) from this context, + # with a real `ENV version=$VERSION` (no literal "testing"). ./generate_dockerfile.sh "$VERSION" context tar xzf /tmp/testing-bundle.tgz -C . cp /tmp/testing.sql common/simplerisk.sql diff --git a/CLAUDE.md b/CLAUDE.md index d28be23..f40f4d3 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) docker build -t simplerisk/simplerisk-minimal simplerisk-minimal/ ``` @@ -115,7 +115,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 4 variants (jammy, noble, php83, php84, php85), runs Dockle (Dockerfile linter) and Grype (CVE scanner, severity cutoff: critical, only-fixed). - **Pushes** trigger separate workflows to publish to Docker Hub and GitHub Container Registry (GHCR). GHCR images are signed with Cosign/sigstore. The `simplerisk-minimal` push builds target both `linux/amd64` and `linux/arm64`. - The reusable workflow files (`*_rw.yml`) are called by the entry-point workflows. diff --git a/simplerisk-minimal/Dockerfile b/simplerisk-minimal/Dockerfile index 87a1002..f1648b2 100644 --- a/simplerisk-minimal/Dockerfile +++ b/simplerisk-minimal/Dockerfile @@ -3,10 +3,22 @@ ARG php_version=8.5 FROM alpine/curl:8.12.1 AS downloader +# PREGA_BUNDLE_FALLBACK is a CI-ONLY switch, default false. Pre-GA the prod +# bundle for a new version does not exist yet (it lands in public/bundles/ only +# at GA). CI sets this true so a pre-GA build can fall back to the testing +# bundle. A released image is ALWAYS built from the prod bundle and NEVER +# silently falls back to testing bytes (even on a transient prod failure). +ARG PREGA_BUNDLE_FALLBACK=false + SHELL [ "/bin/ash", "-eo", "pipefail", "-c" ] RUN mkdir -p /var/www && \ - curl -sL https://simplerisk-downloads.s3.amazonaws.com/public/bundles/simplerisk-20260519-001.tgz | tar xz -C /var/www + if curl -fsSL https://simplerisk-downloads.s3.amazonaws.com/public/bundles/simplerisk-20260519-001.tgz -o /tmp/bundle.tgz; then true; \ + elif [ "$PREGA_BUNDLE_FALLBACK" = "true" ]; then \ + echo "prod bundle absent — pre-GA CI fallback to bundles-test"; \ + curl -fsSL https://bundles-test.simplerisk.com/simplerisk-20260519-001.tgz -o /tmp/bundle.tgz; \ + else echo "prod bundle simplerisk-20260519-001.tgz not found and PREGA_BUNDLE_FALLBACK=false" && exit 1; fi && \ + tar xzf /tmp/bundle.tgz -C /var/www && rm -f /tmp/bundle.tgz FROM php:${php_version}-apache diff --git a/simplerisk-minimal/test_generate_dockerfile.sh b/simplerisk-minimal/test_generate_dockerfile.sh index 1232f9e..545de44 100755 --- a/simplerisk-minimal/test_generate_dockerfile.sh +++ b/simplerisk-minimal/test_generate_dockerfile.sh @@ -2,6 +2,13 @@ # 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; } @@ -24,4 +31,21 @@ check "download: real ENV version" "ENV version=20260709-001" 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