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" diff --git a/.github/workflows/publish-testing.yml b/.github/workflows/publish-testing.yml index b750932..de4fded 100644 --- a/.github/workflows/publish-testing.yml +++ b/.github/workflows/publish-testing.yml @@ -8,17 +8,20 @@ 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 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). @@ -43,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 @@ -92,9 +96,10 @@ 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 testing + # 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 test -d simplerisk || { echo "::error::bundle did not extract a simplerisk/ dir"; exit 1; } @@ -111,18 +116,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 +161,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" 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 b62dd3c..f1648b2 100644 --- a/simplerisk-minimal/Dockerfile +++ b/simplerisk-minimal/Dockerfile @@ -1,12 +1,24 @@ # Dockerfile generated by script -ARG php_version=8.4 +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/generate_dockerfile.sh b/simplerisk-minimal/generate_dockerfile.sh index 433fd1a..8978e33 100755 --- a/simplerisk-minimal/generate_dockerfile.sh +++ b/simplerisk-minimal/generate_dockerfile.sh @@ -7,26 +7,49 @@ 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 +# 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 @@ -102,7 +125,7 @@ RUN echo "0 0 * * * root /usr/sbin/logrotate /etc/logrotate.d/simplerisk.conf > COPY common/ / EOF # shellcheck disable=SC2015 -if [ "$release" == "testing" ]; then +if [ "$source_mode" == "context" ]; then cat << EOF >> "${SCRIPT_LOCATION}/Dockerfile" COPY simplerisk/ /var/www/simplerisk COPY common/simplerisk.sql /var/www/simplerisk/simplerisk.sql diff --git a/simplerisk-minimal/test_generate_dockerfile.sh b/simplerisk-minimal/test_generate_dockerfile.sh new file mode 100755 index 0000000..545de44 --- /dev/null +++ b/simplerisk-minimal/test_generate_dockerfile.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Regression checks for generate_dockerfile.sh version/source-mode decoupling. +set -euo pipefail +cd "$(dirname "$(readlink -f "$0")")" + +# generate_dockerfile.sh hardcodes its output to the tracked Dockerfile in this +# directory; back it up and restore it on exit (pass or fail) so this checker +# never leaves the committed Dockerfile overwritten. +cp Dockerfile "/tmp/Dockerfile.bak.$$" 2>/dev/null || true +trap 'cp "/tmp/Dockerfile.bak.$$" Dockerfile 2>/dev/null || git checkout -- Dockerfile 2>/dev/null || true; rm -f "/tmp/Dockerfile.bak.$$"' EXIT + +fail=0 +check() { if grep -qF "$2" Dockerfile; then echo "ok: $1"; else echo "FAIL: $1 (missing: $2)"; fail=1; fi; } +absent() { if grep -qF "$2" Dockerfile; then echo "FAIL: $1 (should be absent: $2)"; fail=1; else echo "ok: $1"; fi; } + +# context mode with a real version: no downloader, COPY-from-context, real ENV version, php 8.5 default +./generate_dockerfile.sh 20260709-001 context +check "context: real ENV version" "ENV version=20260709-001" +check "context: COPY app from context" "COPY simplerisk/ /var/www/simplerisk" +absent "context: no downloader stage" "FROM alpine/curl" +check "context: php default 8.5" "ARG php_version=8.5" + +# download mode (explicit): downloader present, COPY-from-downloader, real ENV version +./generate_dockerfile.sh 20260709-001 download +check "download: downloader stage" "FROM alpine/curl" +check "download: COPY from downloader" "COPY --from=downloader /var/www/simplerisk /var/www/simplerisk" +check "download: real ENV version" "ENV version=20260709-001" + +# back-compat: literal "testing" with no mode arg still selects context recipe +./generate_dockerfile.sh testing +absent "testing back-compat: no downloader" "FROM alpine/curl" +check "testing back-compat: COPY context" "COPY simplerisk/ /var/www/simplerisk" + +# invalid source-mode is rejected +if ./generate_dockerfile.sh 20260709-001 bogus >/tmp/bogus-mode.out.$$ 2>&1; then + echo "FAIL: invalid source-mode should be rejected (exited 0)"; fail=1 +else + echo "ok: invalid source-mode rejected" +fi +rm -f "/tmp/bogus-mode.out.$$" + +# idempotence: re-running the same context args does not double anything +./generate_dockerfile.sh 20260709-001 context +copy_count=$(grep -cF "COPY simplerisk/ /var/www/simplerisk" Dockerfile) +if [ "$copy_count" -eq 1 ]; then + echo "ok: idempotence (single COPY simplerisk/ line after re-run)" +else + echo "FAIL: idempotence (expected 1 COPY simplerisk/ line, found $copy_count)"; fail=1 +fi + +exit $fail