From b5e1fc57f0b5f02e6ff761fa24a7f2da7ce38bc3 Mon Sep 17 00:00:00 2001 From: SimpleRisk Updater Date: Thu, 9 Jul 2026 23:28:52 +0000 Subject: [PATCH 01/25] Testing image trigger: version 20260709-001 (code-development @ 51416c0e) --- simplerisk-minimal/.testing-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 simplerisk-minimal/.testing-version diff --git a/simplerisk-minimal/.testing-version b/simplerisk-minimal/.testing-version new file mode 100644 index 0000000..ce636be --- /dev/null +++ b/simplerisk-minimal/.testing-version @@ -0,0 +1 @@ +20260709-001 From 1f1d99e3c922bc43ac4151bac4123983aa62dc60 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 10 Jul 2026 13:54:18 -0500 Subject: [PATCH 02/25] 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 03/25] 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 04/25] 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 05/25] 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 06/25] 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 From 9896f2f9a85946f5f7c505a19bfeb23160b065cd Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 10 Jul 2026 14:32:25 -0500 Subject: [PATCH 07/25] ci(promote-latest): GA retags :latest to -php85 digest (no rebuild); manual dispatch; php85 SSM --- .github/workflows/promote-latest.yml | 53 +++++++++++++++++++--------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/.github/workflows/promote-latest.yml b/.github/workflows/promote-latest.yml index 938eceb..02d5b9e 100644 --- a/.github/workflows/promote-latest.yml +++ b/.github/workflows/promote-latest.yml @@ -1,22 +1,17 @@ name: Promote latest image tag (release) -# On a release (push to master that bumps the version), promote the customers-cdk -# `latest` channel: write SSM /simplerisk/customers/image-tag/latest = in -# the customers account via OIDC, so the image-updater Lambda rolls tier=latest -# (production) services onto the just-published release image. +# GA promotion for simplerisk-minimal, run MANUALLY (workflow_dispatch) once the +# release has merged to master. Build-once model: the RC image was already built +# by publish-testing.yml at the testing cut (tags -php83/84/85 + bare +# ). GA does NOT rebuild — it (1) retags DockerHub :latest to the +# existing -php85 digest via `buildx imagetools create` (multi-arch +# preserved), and (2) writes SSM /simplerisk/customers/image-tag/latest = +# -php85 in the customers account (OIDC), so the image-updater Lambda +# rolls tier=latest (production) services onto the promoted digest. # -# The release IMAGE itself is built + pushed (:latest + :) by the existing -# push-to-dockerhub workflow on the same master push — this workflow ONLY does the -# cross-account SSM promote (the missing automation link). Path-filtered to the -# minimal Dockerfile so a docs-only master push does not roll production. -# -# See design docs/superpowers/specs/2026-07-01-testing-image-promote (customers-cdk). +# See design code-development docs/superpowers/specs/2026-07-10-release-image-promotion-design. on: - push: - branches: [master] - paths: - - simplerisk-minimal/Dockerfile workflow_dispatch: permissions: @@ -38,6 +33,15 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - 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: Read release version from the minimal Dockerfile id: ver run: | @@ -51,6 +55,23 @@ jobs: fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Verify the RC image exists, then retag :latest → -php85 + env: + VERSION: ${{ steps.ver.outputs.version }} + IMAGE_NAME: simplerisk/simplerisk-minimal + run: | + set -euo pipefail + # The php85 immutable tag MUST already exist (built by publish-testing + # at the RC cut). Fail loudly rather than silently promoting nothing. + if ! docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}-php85" >/dev/null 2>&1; then + echo "::error::${IMAGE_NAME}:${VERSION}-php85 not found on Docker Hub — was the RC published (Plan 1)?"; exit 1 + fi + # Retag (no rebuild): create :latest from the existing multi-arch digest. + docker buildx imagetools create \ + --tag "${IMAGE_NAME}:latest" \ + "${IMAGE_NAME}:${VERSION}-php85" + echo "retagged ${IMAGE_NAME}:latest -> ${VERSION}-php85 digest" >> "$GITHUB_STEP_SUMMARY" + - name: Configure AWS credentials (OIDC → customers account) uses: aws-actions/configure-aws-credentials@v4 with: @@ -63,6 +84,6 @@ jobs: run: | set -euo pipefail aws ssm put-parameter --name "$SSM_PARAM" \ - --value "$VERSION" --type String --overwrite \ + --value "${VERSION}-php85" --type String --overwrite \ --region "$AWS_REGION" - echo "promoted $SSM_PARAM = $VERSION" >> "$GITHUB_STEP_SUMMARY" + echo "promoted $SSM_PARAM = ${VERSION}-php85" >> "$GITHUB_STEP_SUMMARY" From 74a45c90b9ba1aabbecca52abd11889225d47750 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 10 Jul 2026 14:36:06 -0500 Subject: [PATCH 08/25] ci(push-to-dockerhub): stop rebuilding minimal on master (GA promotes the RC digest instead) --- .github/workflows/push-to-dockerhub.yml | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/.github/workflows/push-to-dockerhub.yml b/.github/workflows/push-to-dockerhub.yml index ef95a78..82474bc 100644 --- a/.github/workflows/push-to-dockerhub.yml +++ b/.github/workflows/push-to-dockerhub.yml @@ -36,28 +36,3 @@ jobs: main_image: true build_args: "ubuntu_version_code=noble" secrets: inherit - simplerisk-minimal-php84: - name: 'Push simplerisk/simplerisk-minimal image based on PHP 8.3 with Apache' - uses: ./.github/workflows/push-to-dockerhub_rw.yml - with: - context_path: "simplerisk-minimal" - dockerfile_path: "simplerisk-minimal/Dockerfile" - image_name: "simplerisk/simplerisk-minimal" - version: "20260519-001" - os_version: "php83" - build_args: "php_version=8.3" - platforms: linux/amd64,linux/arm64 - secrets: inherit - simplerisk-minimal-php85: - name: 'Push simplerisk/simplerisk-minimal image based on PHP 8.4 with Apache' - uses: ./.github/workflows/push-to-dockerhub_rw.yml - with: - context_path: "simplerisk-minimal" - dockerfile_path: "simplerisk-minimal/Dockerfile" - image_name: "simplerisk/simplerisk-minimal" - version: "20260519-001" - os_version: "php84" - main_image: true - build_args: "php_version=8.4" - platforms: linux/amd64,linux/arm64 - secrets: inherit From 0b86fa1b9a856541f99e9d0d20a597d20f422d46 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 10 Jul 2026 14:42:55 -0500 Subject: [PATCH 09/25] ci(promote-latest): guard GA against promoting a stale (non-current-RC) version; fix step name --- .github/workflows/promote-latest.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/promote-latest.yml b/.github/workflows/promote-latest.yml index 02d5b9e..1270782 100644 --- a/.github/workflows/promote-latest.yml +++ b/.github/workflows/promote-latest.yml @@ -66,6 +66,16 @@ jobs: if ! docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}-php85" >/dev/null 2>&1; then echo "::error::${IMAGE_NAME}:${VERSION}-php85 not found on Docker Hub — was the RC published (Plan 1)?"; exit 1 fi + # Currency guard: only promote the version that is CURRENTLY in testing. + # :testing floats to the current RC (publish-testing tags -php85 and + # :testing on the same build), so the digests match iff VERSION is the + # current RC. Prevents a stale committed Dockerfile version (or a stale + # dispatch ref) from promoting an old-but-existing release to prod :latest. + SRC_DIGEST=$(docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}-php85" --format '{{.Manifest.Digest}}') + TESTING_DIGEST=$(docker buildx imagetools inspect "${IMAGE_NAME}:testing" --format '{{.Manifest.Digest}}') + if [ "$SRC_DIGEST" != "$TESTING_DIGEST" ]; then + echo "::error::${IMAGE_NAME}:${VERSION}-php85 ($SRC_DIGEST) is not the current testing RC ($TESTING_DIGEST) — refusing to promote a stale version to :latest"; exit 1 + fi # Retag (no rebuild): create :latest from the existing multi-arch digest. docker buildx imagetools create \ --tag "${IMAGE_NAME}:latest" \ @@ -78,7 +88,7 @@ jobs: role-to-assume: ${{ vars.IMAGE_PROMOTER_LATEST_ROLE_ARN }} aws-region: ${{ env.AWS_REGION }} - - name: Promote — SSM /image-tag/latest = + - name: Promote — SSM /image-tag/latest = -php85 env: VERSION: ${{ steps.ver.outputs.version }} run: | From 7b23b8c3a42ef9d030773af2df111d7e3fa21f9b Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Sun, 12 Jul 2026 10:57:47 -0500 Subject: [PATCH 10/25] simplerisk-minimal: add headless DB_UPGRADE mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a DB_UPGRADE entrypoint mode (automatic / automatic-only) that runs SimpleRisk's core release-by-release schema upgrade (run_database_upgrade_structured in includes/upgrade.php) headlessly via /db-upgrade.php against an already-installed database, using the app DB user (SIMPLERISK_DB_*) — no privileged user, no HTTP context, no credentials beyond the DB connection. Emits the structured per-release JSON result to the log; automatic-only exits with the upgrade status so a one-shot Job fails on a failed upgrade. The upgrade logic is core-only (run_database_upgrade_structured + the 97 per-release functions live in includes/upgrade.php; upgrade.php requires only core includes/), so the mode needs only the base image + the app DB env. No Dockerfile change: common/ is already COPYed to / (db-upgrade.php -> /). Twin of the DB_SETUP mode. DB_UPGRADE documented in simplerisk-minimal/README.md and CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 ++ simplerisk-minimal/README.md | 1 + simplerisk-minimal/common/db-upgrade.php | 23 +++++++++++++++ simplerisk-minimal/common/entrypoint.sh | 36 ++++++++++++++++++++++++ 4 files changed, 62 insertions(+) create mode 100644 simplerisk-minimal/common/db-upgrade.php diff --git a/CLAUDE.md b/CLAUDE.md index f40f4d3..772454d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,7 @@ If `TARGETARCH` is unset (plain `docker build` without buildx), the amd64 path r The entrypoint script handles: - Writing `config.php` by substituting env vars via `sed` - Automatic database provisioning (`DB_SETUP=automatic|automatic-only|manual|delete`) +- Headless schema upgrade of an already-installed database (`DB_UPGRADE=automatic|automatic-only`) — runs SimpleRisk's core release-by-release upgrade (`run_database_upgrade_structured`) as the app DB user via `/db-upgrade.php`, emitting the structured per-release JSON to the log; `automatic-only` exits with the upgrade status (used by the EKS release upgrade Job) - SSL certificate generation (minimal image generates a CA + signed cert; full-stack generates a self-signed cert) - Cron setup (`SIMPLERISK_CRON_SETUP` in minimal; always-on in full-stack) - Supervisor start (full-stack) or `apache2-foreground` (minimal) @@ -91,6 +92,7 @@ The entrypoint script handles: |---|---| | `DB_SETUP` | `automatic`, `automatic-only`, `manual`, `delete` | | `DB_SETUP_PASS` | Password used when setting up the DB | +| `DB_UPGRADE` | `automatic`, `automatic-only` — headless release-by-release **schema upgrade** of an existing DB (uses the `SIMPLERISK_DB_*` app creds, no privileged user; JSON result to the log; non-zero exit on failure) | | `SIMPLERISK_DB_HOSTNAME` | External DB host | | `SIMPLERISK_DB_USERNAME/PASSWORD/DATABASE` | DB credentials | | `SIMPLERISK_CRON_SETUP` | Enable/disable PHP cron (default: enabled) | diff --git a/simplerisk-minimal/README.md b/simplerisk-minimal/README.md index a8961c1..1ad84b4 100644 --- a/simplerisk-minimal/README.md +++ b/simplerisk-minimal/README.md @@ -64,6 +64,7 @@ docker run -d --name simplerisk -e SIMPLERISK_DB_PASSWORD=pass -e SIMPLERISK_DB_ | `DB_SETUP_USER` | `root` | Used when `DB_SETUP=automatic\|automatic-only\|delete`. User name of database privileged user to install SimpleRisk schema and other components | | `DB_SETUP_PASS` | `root` (the bundled `stack.yml` ships `simplerisk_setup`) | Used when `DB_SETUP=automatic\|automatic-only\|delete`. Password of the privileged MySQL user used **only** to install the SimpleRisk schema and create the app DB user. In `stack.yml` it is also the bundled MySQL root password; since that MySQL is not exposed outside the stack network, a documented default is used for the zero-config trial. Override it (and `MYSQL_ROOT_PASSWORD` in `stack.yml`) for any non-trial deployment. | | `DB_SETUP_WAIT` | 20 | Used when `DB_SETUP=automatic\|automatic-only`. Time, in seconds, the application is going to wait to set up the database. Useful if you are deploying the database and SimpleRisk at the same time | +| `DB_UPGRADE` | `null` (Accepts any value) | Run SimpleRisk's release-by-release database **schema upgrade** against an already-installed database (as opposed to `DB_SETUP`, which installs a fresh one), then continue running (`automatic`) or stop the container (`automatic-only`, for a one-shot upgrade job). Uses the `SIMPLERISK_DB_*` application credentials — no privileged user. The structured per-release JSON result is written to the container log, and the container exits non-zero if the upgrade fails. Idempotent — a no-op when the schema is already current | | `SIMPLERISK_DB_HOSTNAME` | `localhost` | Hostname of the database server | | `SIMPLERISK_DB_PORT` | 3306 | Port to contact the database | | `SIMPLERISK_DB_USERNAME` |`simplerisk` | User name to be used to access the SimpleRisk database | diff --git a/simplerisk-minimal/common/db-upgrade.php b/simplerisk-minimal/common/db-upgrade.php new file mode 100644 index 0000000..5f238fe --- /dev/null +++ b/simplerisk-minimal/common/db-upgrade.php @@ -0,0 +1,23 @@ + Date: Sun, 12 Jul 2026 17:26:15 -0500 Subject: [PATCH 11/25] fix(minimal): apt-get upgrade to patch base-image CVEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned php:${php_version}-apache base ships apache2/curl at its own build-time versions, and the Dockerfile only `install`s specific packages — so base packages accumulate upstream-fixed CVEs. Grype (--fail-on critical --only-fixed) flags criticals on every minimal build: curl (CVE-2026-11856/10536/8927/8924, fixed 8.21.0) and apache2 (CVE-2026-29167/42535, fixed 2.4.68). Add `apt-get -y upgrade` after `apt-get update` so the Debian security fixes are pulled; regenerate the Dockerfile from the generator. Targets testing (features land here, then promote to master). Full/Ubuntu image currently passes Grype and is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- simplerisk-minimal/Dockerfile | 4 ++++ simplerisk-minimal/generate_dockerfile.sh | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/simplerisk-minimal/Dockerfile b/simplerisk-minimal/Dockerfile index f1648b2..ca2cba1 100644 --- a/simplerisk-minimal/Dockerfile +++ b/simplerisk-minimal/Dockerfile @@ -36,7 +36,11 @@ ARG TARGETARCH # NOTE: The MySQL key was taken from https://dev.mysql.com/doc/refman/8.4/en/checking-gpg-signature.html # 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 +# own build-time versions, so without this they accumulate fixed CVEs (Grype gate). RUN apt-get update && \ + apt-get -y upgrade && \ apt-get install -y --no-install-recommends \ libldap2-dev \ libicu-dev \ diff --git a/simplerisk-minimal/generate_dockerfile.sh b/simplerisk-minimal/generate_dockerfile.sh index 8978e33..1171271 100755 --- a/simplerisk-minimal/generate_dockerfile.sh +++ b/simplerisk-minimal/generate_dockerfile.sh @@ -71,7 +71,11 @@ ARG TARGETARCH # NOTE: The MySQL key was taken from https://dev.mysql.com/doc/refman/8.4/en/checking-gpg-signature.html # 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 +# own build-time versions, so without this they accumulate fixed CVEs (Grype gate). RUN apt-get update && \\ + apt-get -y upgrade && \\ apt-get install -y --no-install-recommends \\ libldap2-dev \\ libicu-dev \\ From be61fa5f89af0834477358e9aa236c7ddd127783 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Sun, 12 Jul 2026 17:36:24 -0500 Subject: [PATCH 12/25] grype: ignore binary-classified curl (PHP-version false positive) Grype's binary classifier reads the PHP interpreter's version string (PHP 8.3.32, embedded in /usr/local/bin/php, libphp.so, and extensions) as a "curl" binary at 8.3.32 and flags curl CVEs (CVE-2026-11856/10536/8927/8924, fixed 8.21.0). The real curl is the Debian package, patched to 8.14.1-2+deb13u4 and correctly not flagged. Scope the ignore to binary-type curl so the deb curl stays scanned. With the apt-get upgrade (apache2 -> 2.4.68) this clears the minimal Grype gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .grype.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.grype.yaml b/.grype.yaml index 1131684..ac697c4 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -1,2 +1,11 @@ ignore: - vulnerability: CVE-2025-27558 # Not able to fix it at the moment + # False positive: Grype's binary classifier reads the PHP interpreter's own + # version string (PHP 8.3.32 — embedded in /usr/local/bin/php, libphp.so, and the + # bundled extensions) as a "curl" binary at 8.3.32, then flags curl CVEs + # (CVE-2026-11856/10536/8927/8924, fixed 8.21.0). The REAL curl in the image is + # the Debian package, patched (8.14.1-2+deb13u4) and correctly NOT flagged. Ignore + # binary-classified curl so this FP drops while the deb-packaged curl stays scanned. + - package: + name: curl + type: binary From 5591ab067f3787b08f3379d81359f76de8c7b49e Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Sun, 12 Jul 2026 17:40:49 -0500 Subject: [PATCH 13/25] grype: scope the binary-curl ignore per-CVE (not the whole package) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review flagged the previous `package: {name: curl, type: binary}` rule as over-broad — it would silently suppress any future binary-curl finding. Narrow it to the four specific PHP-version-misidentified CVEs (CVE-2026-11856/10536/8927/ 8924) so a genuine curl vulnerability still surfaces. Co-Authored-By: Claude Opus 4.8 (1M context) --- .grype.yaml | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/.grype.yaml b/.grype.yaml index ac697c4..684e38e 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -1,11 +1,24 @@ ignore: - vulnerability: CVE-2025-27558 # Not able to fix it at the moment - # False positive: Grype's binary classifier reads the PHP interpreter's own - # version string (PHP 8.3.32 — embedded in /usr/local/bin/php, libphp.so, and the - # bundled extensions) as a "curl" binary at 8.3.32, then flags curl CVEs - # (CVE-2026-11856/10536/8927/8924, fixed 8.21.0). The REAL curl in the image is - # the Debian package, patched (8.14.1-2+deb13u4) and correctly NOT flagged. Ignore - # binary-classified curl so this FP drops while the deb-packaged curl stays scanned. - - package: + # False positive: Grype's binary classifier reads the PHP interpreter's own version + # string (PHP 8.3.32 — embedded in /usr/local/bin/php, libphp.so, and the bundled + # extensions) as a "curl" binary at 8.3.32, then flags these curl CVEs (all fixed in + # curl 8.21.0). The REAL curl is the Debian package, patched (8.14.1-2+deb13u4) and + # correctly not flagged. Scoped per-CVE to binary-classified curl so a genuine future + # curl finding still surfaces instead of being blanket-ignored. + - vulnerability: CVE-2026-11856 + package: + name: curl + type: binary + - vulnerability: CVE-2026-10536 + package: + name: curl + type: binary + - vulnerability: CVE-2026-8927 + package: + name: curl + type: binary + - vulnerability: CVE-2026-8924 + package: name: curl type: binary From 5b8dae1923fd368ae03cee7fac21e904b09969ad Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Sun, 12 Jul 2026 17:48:21 -0500 Subject: [PATCH 14/25] ci: run container-validation + shellcheck on testing PRs too Both gates only triggered on pull_request -> master, so feature PRs to testing (where features land before promotion) were never validated. Add testing to the branch filter so the gates cover the branch features actually target. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/container-validation.yml | 2 +- .github/workflows/shellcheck.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/container-validation.yml b/.github/workflows/container-validation.yml index 12544fb..887c8ff 100644 --- a/.github/workflows/container-validation.yml +++ b/.github/workflows/container-validation.yml @@ -3,7 +3,7 @@ name: Full checkup for SimpleRisk Docker images on: workflow_dispatch: pull_request: - branches: [ master ] + branches: [ master, testing ] jobs: simplerisk-jammy: diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index d468de4..d6f9e53 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -2,7 +2,7 @@ name: Shell script checkup with ShellCheck on: pull_request: - branches: [ master ] + branches: [ master, testing ] jobs: shellcheck: From c622c2ebd7e843d71f4e9ff5ec6211f718c8760c Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Sun, 12 Jul 2026 18:13:21 -0500 Subject: [PATCH 15/25] feat: verify the release bundle hash before extracting (both images) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconciles the bundle-integrity feature (#141) onto testing. A single common/download_and_verify_bundle.sh downloads the prod bundle, resolves its bundle_sha256 (md5 fallback) from the updates feed, verifies fail-closed, then extracts — so a swapped S3 object aborts the build instead of being baked into a published image. Both image generators COPY + run it. Reconciled with testing's PREGA_BUNDLE_FALLBACK: a RELEASED build is always verified against the prod bundle; the pre-GA CI fallback to bundles-test skips verification (the release has no published hash yet) and warns loudly — never for a released image. The full image keeps its separate release-SQL download. Co-Authored-By: Claude Opus 4.8 (1M context) --- simplerisk-minimal/Dockerfile | 22 +++---- .../common/download_and_verify_bundle.sh | 61 +++++++++++++++++++ simplerisk-minimal/generate_dockerfile.sh | 22 +++---- simplerisk/Dockerfile | 8 ++- .../common/download_and_verify_bundle.sh | 61 +++++++++++++++++++ simplerisk/generate_dockerfile.sh | 8 ++- 6 files changed, 154 insertions(+), 28 deletions(-) create mode 100755 simplerisk-minimal/common/download_and_verify_bundle.sh create mode 100755 simplerisk/common/download_and_verify_bundle.sh diff --git a/simplerisk-minimal/Dockerfile b/simplerisk-minimal/Dockerfile index ca2cba1..0a2aaea 100644 --- a/simplerisk-minimal/Dockerfile +++ b/simplerisk-minimal/Dockerfile @@ -3,22 +3,20 @@ 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). +# 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 WITHOUT hash +# verification (the release has no published hash yet). A released image is ALWAYS +# built from the VERIFIED prod bundle and NEVER from unverified testing bytes. ARG PREGA_BUNDLE_FALLBACK=false SHELL [ "/bin/ash", "-eo", "pipefail", "-c" ] -RUN mkdir -p /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 +# Download the prod bundle, verify its published sha256 (md5 fallback) from the +# updates feed, then extract -- fail-closed unless PREGA_BUNDLE_FALLBACK allows the +# pre-GA path. See common/download_and_verify_bundle.sh. +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 FROM php:${php_version}-apache diff --git a/simplerisk-minimal/common/download_and_verify_bundle.sh b/simplerisk-minimal/common/download_and_verify_bundle.sh new file mode 100755 index 0000000..9a9691d --- /dev/null +++ b/simplerisk-minimal/common/download_and_verify_bundle.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# Download the released SimpleRisk bundle for the given version, verify it against +# the sha256 (md5 fallback) published in the production updates feed, then extract +# it into /var/www. Run from the alpine/curl downloader stage of the generated +# Dockerfile. +# +# Fail-closed by default: a RELEASED image (PREGA_BUNDLE_FALLBACK unset/false) is +# built ONLY from the prod bundle and ONLY if it matches its published hash -- a +# missing prod bundle, a missing feed hash, or a mismatch aborts the build, so a +# swapped S3 object can never be baked into a published image. The bundle (S3 +# public/bundles) and its hash (served updates feed) are stored independently. +# +# PRE-GA CI ONLY: before GA the prod bundle/hash for a new version do not exist +# yet (they land at GA). When PREGA_BUNDLE_FALLBACK=true AND the prod bundle is +# absent, fall back to the testing bundle WITHOUT verification (there is no +# published hash to check yet) and warn loudly. This path is never taken for a +# released image. +set -eu + +VERSION="${1:?usage: download_and_verify_bundle.sh }" +case "$VERSION" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9]) : ;; + *) echo "ERROR: bad version format: $VERSION" >&2; exit 1 ;; +esac + +FEED="https://updates.simplerisk.com/releases.xml" +PROD_URL="https://simplerisk-downloads.s3.amazonaws.com/public/bundles/simplerisk-${VERSION}.tgz" +TEST_URL="https://bundles-test.simplerisk.com/simplerisk-${VERSION}.tgz" +TGZ="/tmp/simplerisk-${VERSION}.tgz" + +if curl -fsSL -o "$TGZ" "$PROD_URL"; then + echo "Downloaded prod bundle for ${VERSION}; resolving published hash from ${FEED} ..." + ENTRY="$(curl -fsSL "$FEED" | sed -n "//,/<\/release>/p")" + EXPECTED="$(printf '%s\n' "$ENTRY" | grep -oE '[0-9a-f]{64}' | grep -oE '[0-9a-f]{64}' | head -1 || true)" + ALGO=sha256 + if [ -z "$EXPECTED" ]; then + EXPECTED="$(printf '%s\n' "$ENTRY" | grep -oE '[0-9a-f]{32}' | grep -oE '[0-9a-f]{32}' | head -1 || true)" + ALGO=md5 + fi + if [ -z "$EXPECTED" ]; then + echo "ERROR: no bundle_sha256 or bundle_md5 for ${VERSION} in ${FEED} -- refusing to extract an unverifiable prod bundle" >&2 + exit 1 + fi + ACTUAL="$(${ALGO}sum "$TGZ" | cut -d' ' -f1)" + if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "ERROR: bundle ${ALGO} mismatch for ${VERSION} -- expected ${EXPECTED}, got ${ACTUAL}" >&2 + exit 1 + fi + echo "Bundle ${ALGO} verified (${ACTUAL})." +elif [ "${PREGA_BUNDLE_FALLBACK:-false}" = "true" ]; then + echo "WARNING: prod bundle simplerisk-${VERSION}.tgz absent -- PRE-GA CI fallback to bundles-test (UNVERIFIED: the release has no published hash yet)." >&2 + curl -fsSL -o "$TGZ" "$TEST_URL" +else + echo "ERROR: prod bundle simplerisk-${VERSION}.tgz not found and PREGA_BUNDLE_FALLBACK != true -- refusing to build a release from unverified bytes" >&2 + exit 1 +fi + +mkdir -p /var/www +tar xzf "$TGZ" -C /var/www +rm -f "$TGZ" +echo "Extracted bundle to /var/www." diff --git a/simplerisk-minimal/generate_dockerfile.sh b/simplerisk-minimal/generate_dockerfile.sh index 1171271..53ba282 100755 --- a/simplerisk-minimal/generate_dockerfile.sh +++ b/simplerisk-minimal/generate_dockerfile.sh @@ -34,22 +34,20 @@ 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). +# 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 WITHOUT hash +# verification (the release has no published hash yet). A released image is ALWAYS +# built from the VERIFIED prod bundle and NEVER from unverified testing bytes. ARG PREGA_BUNDLE_FALLBACK=false SHELL [ "/bin/ash", "-eo", "pipefail", "-c" ] -RUN mkdir -p /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 +# Download the prod bundle, verify its published sha256 (md5 fallback) from the +# updates feed, then extract -- fail-closed unless PREGA_BUNDLE_FALLBACK allows the +# pre-GA path. See common/download_and_verify_bundle.sh. +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 EOF fi diff --git a/simplerisk/Dockerfile b/simplerisk/Dockerfile index 300327a..33b774b 100644 --- a/simplerisk/Dockerfile +++ b/simplerisk/Dockerfile @@ -4,11 +4,15 @@ 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. +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 && \ +# Download the prod bundle, verify its published sha256 (md5 fallback) from the +# updates feed, then extract (fail-closed) -- then fetch the release SQL 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 # Using Ubuntu image diff --git a/simplerisk/common/download_and_verify_bundle.sh b/simplerisk/common/download_and_verify_bundle.sh new file mode 100755 index 0000000..9a9691d --- /dev/null +++ b/simplerisk/common/download_and_verify_bundle.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# Download the released SimpleRisk bundle for the given version, verify it against +# the sha256 (md5 fallback) published in the production updates feed, then extract +# it into /var/www. Run from the alpine/curl downloader stage of the generated +# Dockerfile. +# +# Fail-closed by default: a RELEASED image (PREGA_BUNDLE_FALLBACK unset/false) is +# built ONLY from the prod bundle and ONLY if it matches its published hash -- a +# missing prod bundle, a missing feed hash, or a mismatch aborts the build, so a +# swapped S3 object can never be baked into a published image. The bundle (S3 +# public/bundles) and its hash (served updates feed) are stored independently. +# +# PRE-GA CI ONLY: before GA the prod bundle/hash for a new version do not exist +# yet (they land at GA). When PREGA_BUNDLE_FALLBACK=true AND the prod bundle is +# absent, fall back to the testing bundle WITHOUT verification (there is no +# published hash to check yet) and warn loudly. This path is never taken for a +# released image. +set -eu + +VERSION="${1:?usage: download_and_verify_bundle.sh }" +case "$VERSION" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9]) : ;; + *) echo "ERROR: bad version format: $VERSION" >&2; exit 1 ;; +esac + +FEED="https://updates.simplerisk.com/releases.xml" +PROD_URL="https://simplerisk-downloads.s3.amazonaws.com/public/bundles/simplerisk-${VERSION}.tgz" +TEST_URL="https://bundles-test.simplerisk.com/simplerisk-${VERSION}.tgz" +TGZ="/tmp/simplerisk-${VERSION}.tgz" + +if curl -fsSL -o "$TGZ" "$PROD_URL"; then + echo "Downloaded prod bundle for ${VERSION}; resolving published hash from ${FEED} ..." + ENTRY="$(curl -fsSL "$FEED" | sed -n "//,/<\/release>/p")" + EXPECTED="$(printf '%s\n' "$ENTRY" | grep -oE '[0-9a-f]{64}' | grep -oE '[0-9a-f]{64}' | head -1 || true)" + ALGO=sha256 + if [ -z "$EXPECTED" ]; then + EXPECTED="$(printf '%s\n' "$ENTRY" | grep -oE '[0-9a-f]{32}' | grep -oE '[0-9a-f]{32}' | head -1 || true)" + ALGO=md5 + fi + if [ -z "$EXPECTED" ]; then + echo "ERROR: no bundle_sha256 or bundle_md5 for ${VERSION} in ${FEED} -- refusing to extract an unverifiable prod bundle" >&2 + exit 1 + fi + ACTUAL="$(${ALGO}sum "$TGZ" | cut -d' ' -f1)" + if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "ERROR: bundle ${ALGO} mismatch for ${VERSION} -- expected ${EXPECTED}, got ${ACTUAL}" >&2 + exit 1 + fi + echo "Bundle ${ALGO} verified (${ACTUAL})." +elif [ "${PREGA_BUNDLE_FALLBACK:-false}" = "true" ]; then + echo "WARNING: prod bundle simplerisk-${VERSION}.tgz absent -- PRE-GA CI fallback to bundles-test (UNVERIFIED: the release has no published hash yet)." >&2 + curl -fsSL -o "$TGZ" "$TEST_URL" +else + echo "ERROR: prod bundle simplerisk-${VERSION}.tgz not found and PREGA_BUNDLE_FALLBACK != true -- refusing to build a release from unverified bytes" >&2 + exit 1 +fi + +mkdir -p /var/www +tar xzf "$TGZ" -C /var/www +rm -f "$TGZ" +echo "Extracted bundle to /var/www." diff --git a/simplerisk/generate_dockerfile.sh b/simplerisk/generate_dockerfile.sh index 7716e87..6ee759d 100755 --- a/simplerisk/generate_dockerfile.sh +++ b/simplerisk/generate_dockerfile.sh @@ -22,11 +22,15 @@ if [ "$release" != "testing" ]; then 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. +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 && \\ +# Download the prod bundle, verify its published sha256 (md5 fallback) from the +# updates feed, then extract (fail-closed) -- then fetch the release SQL 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 EOF From 0396371b0adf42ddbf77283df2cc714615bf2362 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Thu, 6 Aug 2026 23:31:12 -0500 Subject: [PATCH 16/25] Add SIMPLERISK_DEMO_MODE to the minimal image's config generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a public demo instance restrict uploads, password change and language change through configuration rather than by patching core files. That matters because the hosted platform runs ONE shared image for every customer — an instance with patched files cannot run on it, which is why demo.simplerisk.com is currently off on its own. Deliberately NOT a placeholder in config.sample.php. This is a hosting-platform concern, not a self-hosted option, so the define is APPENDED only when asked for and stays invisible to everyone else. config.sample.php has no closing "?>", so an appended define lands inside PHP. ONLY an explicitly true value activates it (true/1/yes/on, case-insensitive). This is deliberate, not defensive noise: PHP treats the STRING 'false' as truthy, so writing define('DEMO_MODE', 'false') would put EVERY customer into demo mode. The constant's PRESENCE is the signal — core checks defined('DEMO_MODE'), not its value — and anything not clearly true is logged and ignored. The grep guard is for the fallback path in set_config: when config.sample.php is absent the existing config.php is reused rather than regenerated, so an unguarded append would redefine the constant on every boot. Verified across two boots: exactly one define for true/TRUE/1/yes/on, none for unset/false/0/no/arbitrary strings, and no duplication when config.php already carries it. Minimal image only. The non-minimal image's set_config is an older shape (hardcoded values, run-once guard) and the hosted platform uses minimal. Core gating — the defined('DEMO_MODE') checks — is a separate change owned by the core repo. No config.sample.php change is needed there. Co-Authored-By: Claude Opus 5 (1M context) --- simplerisk-minimal/common/entrypoint.sh | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/simplerisk-minimal/common/entrypoint.sh b/simplerisk-minimal/common/entrypoint.sh index b0759a1..23b31b3 100644 --- a/simplerisk-minimal/common/entrypoint.sh +++ b/simplerisk-minimal/common/entrypoint.sh @@ -117,6 +117,37 @@ set_config(){ escaped_ssl_path=$(sed_escape "$SIMPLERISK_DB_SSL_CERT_PATH") sed -i "s|^[[:space:]]*\(//[[:space:]]*\)\{0,1\}define('DB_SSL_CERTIFICATE_PATH', '[^']*');|define('DB_SSL_CERTIFICATE_PATH', '${escaped_ssl_path}');|" "$CONFIG_PATH" fi + + + # DEMO_MODE — restricts a public demo instance (uploads, password change, + # language change). Deliberately NOT a placeholder in config.sample.php: this + # is a hosting-platform concern, not a self-hosted option, so it is APPENDED + # only when asked for and is invisible to everyone else. config.sample.php has + # no closing "?>", so an appended define lands inside PHP. + # + # ONLY an explicitly true value activates it. PHP treats the STRING 'false' as + # truthy, so writing define('DEMO_MODE', 'false') would put EVERY customer into + # demo mode. The constant's PRESENCE is the signal — core checks + # defined('DEMO_MODE'), not its value. + # + # The grep guard matters for the fallback path above: when config.sample.php is + # absent the existing config.php is reused rather than regenerated, so an + # unguarded append would redefine the constant on every boot. + case "$(printf '%s' "${SIMPLERISK_DEMO_MODE:-}" | tr '[:upper:]' '[:lower:]')" in + true|1|yes|on) + if grep -q "define('DEMO_MODE'" "$CONFIG_PATH"; then + print_log "initial_setup:info" "DEMO_MODE already present in $CONFIG_PATH; leaving it alone." + else + printf "\n// Set by the hosting platform when demo_mode is enabled for this\n// instance. Not a self-hosted configuration option.\ndefine('DEMO_MODE', 'true');\n" >> "$CONFIG_PATH" + print_log "initial_setup:info" "DEMO_MODE enabled." + fi + ;; + "") + ;; + *) + print_log "initial_setup:info" "SIMPLERISK_DEMO_MODE is set to a non-true value; demo mode NOT enabled." + ;; + esac } set_csrf_secret(){ From b85ccfdd0d0922df5c350166fedf34637a31090a Mon Sep 17 00:00:00 2001 From: "P." Date: Thu, 30 Jul 2026 07:40:28 -0600 Subject: [PATCH 17/25] simplerisk-minimal: add opt-in DB_SSL_ENABLED for TLS-required DB setup connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The privileged setup/delete MySQL client in the simplerisk-minimal entrypoint now honors DB_SSL_ENABLED. Set to exactly "true", it appends --ssl-mode=REQUIRED --enable-cleartext-plugin to the db_setup()/delete_db() mysql calls — for databases that require the cleartext auth plugin to be sent over TLS (e.g. when DB_SETUP_PASS is a short-lived token rather than a static password). Defaults off (fail-closed): any other value or unset preserves today's plaintext-capable connection, so published images are unaffected for existing consumers. Documented in README.md and CLAUDE.md. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 1 + simplerisk-minimal/README.md | 1 + simplerisk-minimal/common/entrypoint.sh | 20 +++++++++++++++++--- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 772454d..0754506 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,7 @@ The entrypoint script handles: | `DB_SETUP` | `automatic`, `automatic-only`, `manual`, `delete` | | `DB_SETUP_PASS` | Password used when setting up the DB | | `DB_UPGRADE` | `automatic`, `automatic-only` — headless release-by-release **schema upgrade** of an existing DB (uses the `SIMPLERISK_DB_*` app creds, no privileged user; JSON result to the log; non-zero exit on failure) | +| `DB_SSL_ENABLED` | Opt-in, **default off**. Set to exactly `true` to add `--ssl-mode=REQUIRED --enable-cleartext-plugin` to the privileged setup/delete MySQL client (for databases that require the cleartext auth plugin to be sent over TLS). Any other value / unset ⇒ unchanged plaintext-capable connection. | | `SIMPLERISK_DB_HOSTNAME` | External DB host | | `SIMPLERISK_DB_USERNAME/PASSWORD/DATABASE` | DB credentials | | `SIMPLERISK_CRON_SETUP` | Enable/disable PHP cron (default: enabled) | diff --git a/simplerisk-minimal/README.md b/simplerisk-minimal/README.md index 1ad84b4..e8c6931 100644 --- a/simplerisk-minimal/README.md +++ b/simplerisk-minimal/README.md @@ -65,6 +65,7 @@ docker run -d --name simplerisk -e SIMPLERISK_DB_PASSWORD=pass -e SIMPLERISK_DB_ | `DB_SETUP_PASS` | `root` (the bundled `stack.yml` ships `simplerisk_setup`) | Used when `DB_SETUP=automatic\|automatic-only\|delete`. Password of the privileged MySQL user used **only** to install the SimpleRisk schema and create the app DB user. In `stack.yml` it is also the bundled MySQL root password; since that MySQL is not exposed outside the stack network, a documented default is used for the zero-config trial. Override it (and `MYSQL_ROOT_PASSWORD` in `stack.yml`) for any non-trial deployment. | | `DB_SETUP_WAIT` | 20 | Used when `DB_SETUP=automatic\|automatic-only`. Time, in seconds, the application is going to wait to set up the database. Useful if you are deploying the database and SimpleRisk at the same time | | `DB_UPGRADE` | `null` (Accepts any value) | Run SimpleRisk's release-by-release database **schema upgrade** against an already-installed database (as opposed to `DB_SETUP`, which installs a fresh one), then continue running (`automatic`) or stop the container (`automatic-only`, for a one-shot upgrade job). Uses the `SIMPLERISK_DB_*` application credentials — no privileged user. The structured per-release JSON result is written to the container log, and the container exits non-zero if the upgrade fails. Idempotent — a no-op when the schema is already current | +| `DB_SSL_ENABLED` | `false` (off) | Opt-in, used when `DB_SETUP=automatic\|automatic-only\|delete`. Set to exactly `true` to require TLS on the privileged setup/delete MySQL client connection (adds `--ssl-mode=REQUIRED --enable-cleartext-plugin`). Any other value, or unset, leaves the connection unchanged (plaintext-capable) | | `SIMPLERISK_DB_HOSTNAME` | `localhost` | Hostname of the database server | | `SIMPLERISK_DB_PORT` | 3306 | Port to contact the database | | `SIMPLERISK_DB_USERNAME` |`simplerisk` | User name to be used to access the SimpleRisk database | diff --git a/simplerisk-minimal/common/entrypoint.sh b/simplerisk-minimal/common/entrypoint.sh index 23b31b3..f2191c8 100644 --- a/simplerisk-minimal/common/entrypoint.sh +++ b/simplerisk-minimal/common/entrypoint.sh @@ -265,13 +265,24 @@ set_mail_settings(){ [ -n "${MAIL_PASSWORD:-}" ] && apply_mail_setting phpmailer_password "$MAIL_PASSWORD" || true } +set_db_ssl_flags(){ + # Some databases require the privileged setup/delete mysql client to send + # its credential via the cleartext auth plugin, which the server only + # accepts over TLS (e.g. when DB_SETUP_PASS is a short-lived auth token + # rather than a static password). Set DB_SSL_ENABLED=true to opt in. + DB_SSL_FLAGS="" + if [ "${DB_SSL_ENABLED:-}" = "true" ]; then + DB_SSL_FLAGS="--ssl-mode=REQUIRED --enable-cleartext-plugin" + fi +} + delete_db(){ print_log "db_deletion: prepare" "Performing database deletion" # Pass password via env var to avoid shell interpretation of special characters in the value export MYSQL_PWD="$DB_SETUP_PASS" # Needed to separate the GRANT statement from the rest because it was providing a syntax error - exec_cmd "mysql -u $DB_SETUP_USER -h$SIMPLERISK_DB_HOSTNAME -P$SIMPLERISK_DB_PORT < Date: Wed, 12 Aug 2026 19:21:02 +0000 Subject: [PATCH 18/25] Testing image trigger: version 20260811-001 (code-development @ f4497601) --- simplerisk-minimal/.testing-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplerisk-minimal/.testing-version b/simplerisk-minimal/.testing-version index ce636be..622b6a4 100644 --- a/simplerisk-minimal/.testing-version +++ b/simplerisk-minimal/.testing-version @@ -1 +1 @@ -20260709-001 +20260811-001 From 8df482a3da8405c1fd61f79aeb086300e80991c3 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Wed, 12 Aug 2026 15:18:38 -0500 Subject: [PATCH 19/25] fix(ci): fetch the release schema from database/master, not database/testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code-development be81d416f9 ("write DB schemas to database/master, drop the database/testing branch") made master the single source for generated installer SQL. This workflow was last touched 2026-07-01, ten days earlier, and still fetched database/testing. The 20260709-001 RC was cut before that change, so database/testing still held its SQL and this step passed. 20260811-001 is the first RC cut after it, and the step failed with a 404 on database/testing/simplerisk-en-20260811-001.sql — blocking the testing image publish and the tier=testing SSM promotion. The bundle fetch and its sha256 verification were unaffected. Also retitles the error so a future failure names the branch it actually searched rather than saying "testing schema". Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish-testing.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-testing.yml b/.github/workflows/publish-testing.yml index de4fded..113c290 100644 --- a/.github/workflows/publish-testing.yml +++ b/.github/workflows/publish-testing.yml @@ -8,7 +8,7 @@ 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 "$VERSION" +# 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. @@ -85,9 +85,9 @@ jobs: 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/testing/simplerisk-en-${VERSION}.sql" + SQL_URL="https://raw.githubusercontent.com/simplerisk/database/master/simplerisk-en-${VERSION}.sql" curl -fsSL -o /tmp/testing.sql "$SQL_URL" \ - || { echo "::error::testing schema not found: $SQL_URL"; exit 1; } + || { echo "::error::release schema not found on database/master: $SQL_URL"; exit 1; } echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Assemble the testing build context From dc69f6b2e075cd65d625cb8df6b6246ef1dbd27e Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Thu, 13 Aug 2026 10:17:52 -0500 Subject: [PATCH 20/25] Allow the service endpoint URLs to be overridden per instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An instance on a pre-release image compares itself against the production updates endpoint, which only advertises the current GA release — so a canary running 20260811-001 reports a phantom version problem against an updates service still publishing 20260519-001. The same applies to registration: a rebuilt canary would register itself into the production licensing database. Adds optional SIMPLERISK_{SERVICES,UPDATES,PING,BUNDLES,LICENSING}_URL, appended to config.php only when supplied. Same shape as DEMO_MODE: no placeholder in config.sample.php, so an instance that says nothing keeps the code's built-in production defaults, and self-hosted users see nothing new. All five are supported deliberately, not for completeness. LICENSING_URL superseded SERVICES_URL and PING_URL when registration and ping merged into the licensing service in the July 2026 release — but the platform runs different images per release channel, and the `latest` channel currently points at 20260519-001, which reads the legacy pair. Writing whichever are supplied keeps one customer config correct across both. Every consumer is defined()-guarded with a production fallback, so a constant an image does not know about is simply ignored. Values are validated rather than trusted: http(s) only, and no quote or backslash, since they are interpolated into a single-quoted PHP string. Rejections are logged rather than silent. The grep guard makes it idempotent for the persisted-volume path where config.php is reused rather than regenerated. Verified: valid URLs written once, a non-URL and a quote-injection attempt both refused, second pass a no-op. Co-Authored-By: Claude Opus 5 (1M context) --- simplerisk-minimal/common/entrypoint.sh | 45 +++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/simplerisk-minimal/common/entrypoint.sh b/simplerisk-minimal/common/entrypoint.sh index f2191c8..ee6c210 100644 --- a/simplerisk-minimal/common/entrypoint.sh +++ b/simplerisk-minimal/common/entrypoint.sh @@ -148,6 +148,51 @@ set_config(){ print_log "initial_setup:info" "SIMPLERISK_DEMO_MODE is set to a non-true value; demo mode NOT enabled." ;; esac + + # Optional service endpoint overrides, for instances that must talk to the + # test estate rather than production. Same shape as DEMO_MODE above: no + # placeholder in config.sample.php, appended only when supplied, so an + # instance that says nothing keeps the code's built-in production defaults. + # + # All five are written when supplied, on purpose. LICENSING_URL superseded + # SERVICES_URL and PING_URL when registration and ping were merged into the + # licensing service (July 2026 release) — but the platform runs different + # images per release channel, and an instance on an older image still reads + # the legacy pair. Writing whichever ones are supplied keeps one config + # correct across both. + # + # Every consumer is defined()-guarded with a production fallback, so a + # constant that a given image does not know about is simply ignored. + for _url_const in SERVICES_URL UPDATES_URL PING_URL BUNDLES_URL LICENSING_URL; do + _url_env="SIMPLERISK_${_url_const}" + _url_val="${!_url_env:-}" + + [ -z "$_url_val" ] && continue + + # Only http(s), and no quote or backslash — the value is interpolated into + # a single-quoted PHP string, and this is config we generate, not input we + # need to be clever about. + case "$_url_val" in + https://*|http://*) ;; + *) + print_log "initial_setup:info" "$_url_env is not an http(s) URL; ignoring." + continue + ;; + esac + case "$_url_val" in + *\'*|*\\*) + print_log "initial_setup:info" "$_url_env contains a quote or backslash; ignoring." + continue + ;; + esac + + if grep -q "define('${_url_const}'" "$CONFIG_PATH"; then + print_log "initial_setup:info" "${_url_const} already present in $CONFIG_PATH; leaving it alone." + else + printf "\ndefine('%s', '%s');\n" "$_url_const" "$_url_val" >> "$CONFIG_PATH" + print_log "initial_setup:info" "${_url_const} set to ${_url_val}." + fi + done } set_csrf_secret(){ From 6b146dc310f42f0a49c8df68619e09f542974285 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Thu, 13 Aug 2026 12:35:41 -0500 Subject: [PATCH 21/25] Restore HTTP access logging on the minimal image's :443 vhost An Apache CustomLog inside a vhost REPLACES the one inherited from the server config. default-ssl.conf declared only ssl_request_log, so conf-enabled/other-vhosts-access-log.conf never applied to :443 -- and since the ALB speaks only to :443, the :80 vhost's access.log stayed empty too. The result was no HTTP access logging anywhere for a dedicated-hosting customer. ssl_request_log was the only record of a request, and it is a poor one: its %h is the load balancer's private address rather than the caller, and its format carries no status code, referer, or user agent. Nothing shipped it off the container either -- it is a real file on the ephemeral /var/log volume, so it died with the task. Found while verifying demo's migration: a probe request could not be located in CloudWatch at all. The log-tailer sidecar appears to cover this (it tails /var/log/apache2/access.log) but cannot -- that path is a symlink to /dev/stdout in the php:apache base image, and /dev/stdout is a write end, so the tail reads nothing. The sidecar entry looked like coverage while providing none. Adds a combined-format CustomLog to access.log, which reaches the container's stdout and therefore the awslogs driver. Client identity comes from X-Forwarded-For, since %h is the load balancer. The whole header is logged deliberately: the ALB appends the true peer as the last element, so only the last one is trustworthy, and keeping the chain visible beats hiding a forged prefix behind a single value. ssl_request_log is kept for the TLS protocol/cipher detail the combined format does not carry. Verified on a built image: `apache2ctl -t` reports Syntax OK, and requests through the vhost emit, on stdout, 203.0.113.9 ... "GET /?abuse-probe HTTP/1.1" 200 4031 "-" "curl/8.7.1" 198.51.100.4 ... "GET /nonexistent-page HTTP/1.1" 404 298 "-" "curl/8.7.1" including the 404 the previous configuration could not record. --- .../apache2/sites-enabled/default-ssl.conf | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/simplerisk-minimal/common/etc/apache2/sites-enabled/default-ssl.conf b/simplerisk-minimal/common/etc/apache2/sites-enabled/default-ssl.conf index 08e69a8..0b55c84 100644 --- a/simplerisk-minimal/common/etc/apache2/sites-enabled/default-ssl.conf +++ b/simplerisk-minimal/common/etc/apache2/sites-enabled/default-ssl.conf @@ -16,6 +16,30 @@ SSLStrictSNIVHostCheck Off SSLCertificateKeyFile /etc/apache2/ssl/simplerisk/simplerisk.key SSLProtocol -all +TLSv1.2 +TLSv1.3 SetEnvIf User-Agent ".*MSIE.*" nokeepalive ssl-unclean-shutdown + + # A CustomLog inside a vhost REPLACES the one inherited from the server + # config, so declaring only ssl_request_log below silently disabled normal + # access logging for every request: conf-enabled/other-vhosts-access-log.conf + # never applied to this vhost, and since the ALB speaks only to :443, the + # :80 vhost's access.log stayed empty too. Nothing recorded status codes, + # and nothing reached CloudWatch. + # + # access.log is a symlink to /dev/stdout in the php:apache base image, so + # writing here reaches the container's stdout and the awslogs driver picks + # it up. That is the Docker-native path -- no log-tailer entry is needed, + # and one tailing this file would read nothing, because /dev/stdout is a + # write end. + # + # %h is the load balancer, not the caller, so it is useless for "who hit + # this". X-Forwarded-For carries the real client. The ALB APPENDS the true + # peer address as the last element, so trust the last one: anything before + # it was supplied by the client and can be forged. Logging the whole header + # keeps that chain visible rather than hiding a spoof behind a single value. + LogFormat "%{X-Forwarded-For}i %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" simplerisk_alb + CustomLog /var/log/apache2/access.log simplerisk_alb + + # Kept for TLS diagnostics (protocol and cipher per request), which the + # combined format above does not carry. CustomLog /var/log/apache2/ssl_request_log "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b" Header always set Strict-Transport-Security "max-age=63072000; includeSubdomains; preload" From ca733b0181930c0038923d4d5e8fd2df365a57f9 Mon Sep 17 00:00:00 2001 From: SimpleRisk Updater Date: Fri, 21 Aug 2026 02:35:41 +0000 Subject: [PATCH 22/25] Testing image trigger: version 20260820-001 (code-development @ 2debd69a) --- simplerisk-minimal/.testing-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplerisk-minimal/.testing-version b/simplerisk-minimal/.testing-version index 622b6a4..4313955 100644 --- a/simplerisk-minimal/.testing-version +++ b/simplerisk-minimal/.testing-version @@ -1 +1 @@ -20260811-001 +20260820-001 From db3edd07f194b6572f13c3f17e59206a68dc8dd9 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 21 Aug 2026 10:55:56 -0500 Subject: [PATCH 23/25] build(full): decouple image version from source-mode in the generator simplerisk/generate_dockerfile.sh branched its recipe on the literal string "testing", so a context-sourced build could only ever stamp `ENV version=testing`. That is the one thing standing between the full-stack image and the build-once-promote model: the RC has to carry a real version to be promotable at GA. Add the same (version, source_mode) split simplerisk-minimal already has: `context` COPYs the app from the build context, `download` emits the hash-verifying downloader stage. The default preserves back-compat exactly -- a bare "testing" still selects context, a bare version still selects download. Also restore --fail on the schema fetch (present on master, lost on testing). Without it curl writes the 404 body into /simplerisk.sql and the image ships an HTML error page as its schema. Adds test_generate_dockerfile.sh mirroring the minimal harness: 14 checks over both modes, both back-compat paths, invalid-mode rejection and idempotence. Co-Authored-By: Claude Opus 5 (1M context) --- simplerisk/Dockerfile | 4 +- simplerisk/generate_dockerfile.sh | 24 ++++++++-- simplerisk/test_generate_dockerfile.sh | 61 ++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 5 deletions(-) create mode 100755 simplerisk/test_generate_dockerfile.sh 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 From a68ac50b73769beaee508c1dce9725e6ad8b4bbd Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 21 Aug 2026 10:55:56 -0500 Subject: [PATCH 24/25] ci(publish-testing): build the full-stack RC image once, alongside minimal GA promotes digests rather than rebuilding, so every image it promotes has to exist as an RC. simplerisk/simplerisk had no RC build at all -- it was only ever built on master from the prod bundle, which is exactly the rebuild the promote model removes. Add a publish-full job tagging -jammy/-noble, bare (= noble, the default) and :testing, amd64-only to match what the release build published before. No SSM tier: the full-stack image is not part of the managed fleet. Hoist version resolution into a shared resolve job so both publish jobs stamp the same release even if the testing channel rotates mid-run, and have each fetch that exact bundle by name rather than re-listing the channel. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/publish-testing.yml | 167 +++++++++++++++++++++----- 1 file changed, 139 insertions(+), 28 deletions(-) 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 From 136cb863eb6571c2f3025ef10c461f15949a8e45 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 21 Aug 2026 10:55:56 -0500 Subject: [PATCH 25/25] ci(container-validation): run both generator harnesses on PRs The generators are now load-bearing for the release path -- context mode has to stamp a real ENV version, download mode has to keep the hash-verifying downloader stage -- but neither harness was wired into CI. Run both on PRs, and fail if either leaves a committed Dockerfile modified. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/container-validation.yml | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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