From 8c1e2f708984896f20f034c7edff44c29aed8211 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Thu, 6 Aug 2026 23:31:12 -0500 Subject: [PATCH 01/17] 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 6e78066..8e47e35 100644 --- a/simplerisk-minimal/common/entrypoint.sh +++ b/simplerisk-minimal/common/entrypoint.sh @@ -104,6 +104,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 44cd91b7d51c146461c428a2e76966a56b7aa7f6 Mon Sep 17 00:00:00 2001 From: "P." Date: Thu, 30 Jul 2026 07:40:28 -0600 Subject: [PATCH 02/17] 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 d28be23..8b74d26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,7 @@ The entrypoint script handles: |---|---| | `DB_SETUP` | `automatic`, `automatic-only`, `manual`, `delete` | | `DB_SETUP_PASS` | Password used when setting up the DB | +| `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 a8961c1..8484358 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_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 8e47e35..014c178 100644 --- a/simplerisk-minimal/common/entrypoint.sh +++ b/simplerisk-minimal/common/entrypoint.sh @@ -252,13 +252,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: Fri, 7 Aug 2026 10:00:36 -0600 Subject: [PATCH 03/17] simplerisk-minimal: pass Grype critical gate via curl-binary FP ignore; add apt upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI gate is 'grype --fail-on critical --only-fixed'. On feature/db-ssl-enabled it failed on 4 FIXABLE criticals — CVE-2026-11856/-10536/-8927/-8924 — all against a binary Grype labels 'curl 8.4.24'. That string is the PHP interpreter's own version (PHP/8.4.24, embedded in php/libphp.so/curl.so), misclassified as curl. The real curl is the Debian package (8.14.1-2+deb13u4) and is patched. The testing branch already suppresses these exact 4 CVEs in .grype.yaml; this ports that block (scoped to package name=curl type=binary so a genuine curl finding still surfaces). Also add 'apt-get -y upgrade' to the minimal generator (regenerated Dockerfile) so base Debian packages pick up security point-releases at build time, matching testing. No effect on the current scan (the pulled base is already current) but prevents CVE drift between base-image rebuilds. Evidence (amd64): gate FAIL->PASS. Raw criticals unchanged at 60 (all but the 4 FPs are wont-fix/not-fixed base debt, invisible to --only-fixed). PHP stays 8.4.24; all extensions load; container boots. Remaining fixable Highs are app-bundle deps (twig/phpspreadsheet/simplesamlphp/guzzle/symfony/...) fixed by rebuilding the S3 bundle, out of scope for this repo. Co-Authored-By: Claude Opus 4.8 --- .grype.yaml | 22 ++++++++++++++++++++++ simplerisk-minimal/Dockerfile | 4 ++++ simplerisk-minimal/generate_dockerfile.sh | 4 ++++ 3 files changed, 30 insertions(+) diff --git a/.grype.yaml b/.grype.yaml index 1131684..23614b6 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -1,2 +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 (embedded in /usr/local/bin/php, libphp.so, and the bundled extensions such + # as curl.so) as a "curl" binary, 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 diff --git a/simplerisk-minimal/Dockerfile b/simplerisk-minimal/Dockerfile index b62dd3c..fb395b3 100644 --- a/simplerisk-minimal/Dockerfile +++ b/simplerisk-minimal/Dockerfile @@ -24,7 +24,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 433fd1a..357f11c 100755 --- a/simplerisk-minimal/generate_dockerfile.sh +++ b/simplerisk-minimal/generate_dockerfile.sh @@ -48,7 +48,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 74ff76160fcdfcc6cd6650132b6e76011e4dcf6d Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Wed, 12 Aug 2026 15:18:14 -0500 Subject: [PATCH 04/17] 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 b750932..d25fc2b 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 testing` +# code) + the database/master 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. # @@ -81,9 +81,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 59a0c53bf54cb48a3cffb236ce8dba373b1bbc03 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Thu, 13 Aug 2026 10:17:52 -0500 Subject: [PATCH 05/17] 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 014c178..32728d6 100644 --- a/simplerisk-minimal/common/entrypoint.sh +++ b/simplerisk-minimal/common/entrypoint.sh @@ -135,6 +135,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 2cfc27b575eee15785f86a62ad124d0501a565e7 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Thu, 13 Aug 2026 12:35:41 -0500 Subject: [PATCH 06/17] 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 6351391352e1e7a16c9c3717f4447cf0a8477472 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 21 Aug 2026 08:28:51 -0500 Subject: [PATCH 07/17] ci(docker): verified bundle download with a pre-GA fallback on master The image build fetched the app bundle with a bare `curl -sL .../public/bundles/simplerisk-.tgz | tar xz`. Two problems. 1. public/bundles/ is the GA path, written only by propagate_release_bundle at the testing -> master cut. But bump_downstream_versions opens the docker update- PR at the TESTING cut, from base master -- so the version it pins has no prod bundle yet and container-validation fails on every release. Observed on update-20260709-001, update-20260811-001 and update-20260820-001. 2. Without --fail, curl streams the S3 error document into tar, so the failure surfaces as a tar exit code rather than an HTTP status. The real cause is invisible in the log, which is how this got misread as expected behaviour. The fix already exists on the testing branch and is ported here. PR #146 attempted this in July and was correctly closed: it added the ARG, the COPY and the generator changes but never added the script itself, so it would have failed at COPY. common/download_and_verify_bundle.sh (identical in both build contexts, taken verbatim from testing) downloads the prod bundle, resolves its published sha256 (md5 fallback) from the prod updates feed, and verifies before extracting. Fail-closed by default: a missing bundle, a missing feed hash or a mismatch aborts the build, so a swapped S3 object cannot be baked into a published image. PREGA_BUNDLE_FALLBACK=true -- set ONLY by container-validation -- allows a pre-GA build to fall back to bundles-test without verification, warning loudly, because a release has no published hash before GA. Ported surgically rather than copied. The testing branch's versions of these files also carry a PHP default bump (8.4 -> 8.5), a new source_mode generator parameter, an added php85 validation job and a trigger change -- all unrelated, none included here. Verified after patching: php_version is still 8.4, there are no source_mode references, and the container-validation diff is exactly four build_args lines. Patched the GENERATORS and re-ran them rather than editing the Dockerfiles: the Dockerfiles are generated, and make update_version regenerates them on every version bump, so a hand-edit would be silently overwritten. Also fixed the SQL fetch in the full-stack image, which had the same missing --fail: `curl -sL ... > /simplerisk.sql` writes a 404 body to disk, so the image would ship an HTML error page as its database schema. Verified by running the script in the real alpine/curl:8.12.1 downloader image across all three paths: fail-closed on a pre-GA version without the flag (exit 1), pre-GA fallback to bundles-test with the flag (exit 0, warned, extracted), and the verified-prod path against a GA version that exists. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/container-validation.yml | 8 +-- simplerisk-minimal/Dockerfile | 14 ++++- .../common/download_and_verify_bundle.sh | 61 +++++++++++++++++++ simplerisk-minimal/generate_dockerfile.sh | 14 ++++- simplerisk/Dockerfile | 13 +++- .../common/download_and_verify_bundle.sh | 61 +++++++++++++++++++ simplerisk/generate_dockerfile.sh | 13 +++- 7 files changed, 170 insertions(+), 14 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/.github/workflows/container-validation.yml b/.github/workflows/container-validation.yml index 6a015ac..730dba5 100644 --- a/.github/workflows/container-validation.yml +++ b/.github/workflows/container-validation.yml @@ -13,7 +13,7 @@ jobs: context_path: "simplerisk/" dockerfile_path: "simplerisk/Dockerfile" image_tag: "simplerisk/simplerisk:testing" - build_args: "ubuntu_version_code=jammy" + build_args: "ubuntu_version_code=jammy\nPREGA_BUNDLE_FALLBACK=true" simplerisk-noble: name: 'Verify simplerisk/simplerisk image based on Ubuntu 24.04 (Noble)' @@ -22,7 +22,7 @@ jobs: context_path: "simplerisk/" dockerfile_path: "simplerisk/Dockerfile" image_tag: "simplerisk/simplerisk:testing" - build_args: "ubuntu_version_code=noble" + build_args: "ubuntu_version_code=noble\nPREGA_BUNDLE_FALLBACK=true" simplerisk-minimal-php84: name: 'Verify simplerisk/simplerisk-minimal image based on PHP 8.3 with Apache' @@ -31,7 +31,7 @@ jobs: 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: name: 'Verify simplerisk/simplerisk-minimal image based on PHP 8.4 with Apache' @@ -40,4 +40,4 @@ jobs: 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" diff --git a/simplerisk-minimal/Dockerfile b/simplerisk-minimal/Dockerfile index fb395b3..4dc4425 100644 --- a/simplerisk-minimal/Dockerfile +++ b/simplerisk-minimal/Dockerfile @@ -3,10 +3,20 @@ ARG php_version=8.4 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 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 && \ - 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 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 357f11c..8075266 100755 --- a/simplerisk-minimal/generate_dockerfile.sh +++ b/simplerisk-minimal/generate_dockerfile.sh @@ -23,10 +23,20 @@ if [ "$release" != "testing" ]; 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 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 && \\ - 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 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..76aa9e2 100644 --- a/simplerisk/Dockerfile +++ b/simplerisk/Dockerfile @@ -5,11 +5,18 @@ 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 && \ - curl -sL "https://github.com/simplerisk/database/raw/master/simplerisk-$DB_LANG-20260519-001.sql" > /simplerisk.sql +# 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 -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/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..8dd41c8 100755 --- a/simplerisk/generate_dockerfile.sh +++ b/simplerisk/generate_dockerfile.sh @@ -23,11 +23,18 @@ 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 && \\ - curl -sL "https://github.com/simplerisk/database/raw/master/simplerisk-\$DB_LANG-$release.sql" > /simplerisk.sql +# 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 -fsSL "https://github.com/simplerisk/database/raw/master/simplerisk-\$DB_LANG-$release.sql" > /simplerisk.sql EOF fi From 7490298d48c9097c5eac143552fac87dc361e254 Mon Sep 17 00:00:00 2001 From: SimpleRisk Updater Date: Fri, 21 Aug 2026 13:36:17 +0000 Subject: [PATCH 08/17] SimpleRisk 20260820-001 Release --- .github/workflows/push-to-dockerhub.yml | 8 ++++---- .github/workflows/push-to-gh-pkgs.yml | 8 ++++---- simplerisk-minimal/Dockerfile | 4 ++-- simplerisk-minimal/stack.yml | 2 +- simplerisk/Dockerfile | 6 +++--- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/push-to-dockerhub.yml b/.github/workflows/push-to-dockerhub.yml index ef95a78..32cf5f1 100644 --- a/.github/workflows/push-to-dockerhub.yml +++ b/.github/workflows/push-to-dockerhub.yml @@ -20,7 +20,7 @@ jobs: context_path: "simplerisk" dockerfile_path: "simplerisk/Dockerfile" image_name: "simplerisk/simplerisk" - version: "20260519-001" + version: "20260820-001" os_version: "jammy" build_args: "ubuntu_version_code=jammy" secrets: inherit @@ -31,7 +31,7 @@ jobs: context_path: "simplerisk" dockerfile_path: "simplerisk/Dockerfile" image_name: "simplerisk/simplerisk" - version: "20260519-001" + version: "20260820-001" os_version: "noble" main_image: true build_args: "ubuntu_version_code=noble" @@ -43,7 +43,7 @@ jobs: context_path: "simplerisk-minimal" dockerfile_path: "simplerisk-minimal/Dockerfile" image_name: "simplerisk/simplerisk-minimal" - version: "20260519-001" + version: "20260820-001" os_version: "php83" build_args: "php_version=8.3" platforms: linux/amd64,linux/arm64 @@ -55,7 +55,7 @@ jobs: context_path: "simplerisk-minimal" dockerfile_path: "simplerisk-minimal/Dockerfile" image_name: "simplerisk/simplerisk-minimal" - version: "20260519-001" + version: "20260820-001" os_version: "php84" main_image: true build_args: "php_version=8.4" diff --git a/.github/workflows/push-to-gh-pkgs.yml b/.github/workflows/push-to-gh-pkgs.yml index 63e5a7f..ceebbb0 100644 --- a/.github/workflows/push-to-gh-pkgs.yml +++ b/.github/workflows/push-to-gh-pkgs.yml @@ -20,7 +20,7 @@ jobs: context_path: "simplerisk" dockerfile_path: "simplerisk/Dockerfile" image_name: "simplerisk" - version: "20260519-001" + version: "20260820-001" os_version: "jammy" build_args: "ubuntu_version_code=jammy" secrets: inherit @@ -31,7 +31,7 @@ jobs: context_path: "simplerisk" dockerfile_path: "simplerisk/Dockerfile" image_name: "simplerisk" - version: "20260519-001" + version: "20260820-001" os_version: "noble" main_image: true build_args: "ubuntu_version_code=noble" @@ -43,7 +43,7 @@ jobs: context_path: "simplerisk-minimal" dockerfile_path: "simplerisk-minimal/Dockerfile" image_name: "simplerisk-minimal" - version: "20260519-001" + version: "20260820-001" os_version: "php83" build_args: "php_version=8.3" secrets: inherit @@ -54,7 +54,7 @@ jobs: context_path: "simplerisk-minimal" dockerfile_path: "simplerisk-minimal/Dockerfile" image_name: "simplerisk-minimal" - version: "20260519-001" + version: "20260820-001" os_version: "php84" main_image: true build_args: "php_version=8.4" diff --git a/simplerisk-minimal/Dockerfile b/simplerisk-minimal/Dockerfile index 4dc4425..a1e44ca 100644 --- a/simplerisk-minimal/Dockerfile +++ b/simplerisk-minimal/Dockerfile @@ -16,13 +16,13 @@ SHELL [ "/bin/ash", "-eo", "pipefail", "-c" ] # 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 +RUN PREGA_BUNDLE_FALLBACK="$PREGA_BUNDLE_FALLBACK" sh /download_and_verify_bundle.sh 20260820-001 FROM php:${php_version}-apache LABEL maintainer="SimpleRisk " -ENV version=20260519-001 +ENV version=20260820-001 WORKDIR /var/www diff --git a/simplerisk-minimal/stack.yml b/simplerisk-minimal/stack.yml index d3ea61e..765dde6 100644 --- a/simplerisk-minimal/stack.yml +++ b/simplerisk-minimal/stack.yml @@ -8,7 +8,7 @@ services: - DB_SETUP=automatic - DB_SETUP_PASS=simplerisk_setup - SIMPLERISK_DB_HOSTNAME=mysql - image: simplerisk/simplerisk-minimal:20260519-001 + image: simplerisk/simplerisk-minimal:20260820-001 ports: - "80:80" - "443:443" diff --git a/simplerisk/Dockerfile b/simplerisk/Dockerfile index 76aa9e2..6b9b7c0 100644 --- a/simplerisk/Dockerfile +++ b/simplerisk/Dockerfile @@ -15,13 +15,13 @@ SHELL [ "/bin/ash", "-eo", "pipefail", "-c" ] # -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 -fsSL "https://github.com/simplerisk/database/raw/master/simplerisk-$DB_LANG-20260519-001.sql" > /simplerisk.sql +RUN PREGA_BUNDLE_FALLBACK="$PREGA_BUNDLE_FALLBACK" sh /download_and_verify_bundle.sh 20260820-001 && \ + curl -fsSL "https://github.com/simplerisk/database/raw/master/simplerisk-$DB_LANG-20260820-001.sql" > /simplerisk.sql # Using Ubuntu image FROM ubuntu:${ubuntu_version_code} -ENV version=20260519-001 +ENV version=20260820-001 # Maintained by SimpleRisk LABEL maintainer="Simplerisk " From 6dc619c380d2225dc73a7dee7f3df00dd6320ef7 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 21 Aug 2026 11:00:01 -0500 Subject: [PATCH 09/17] ci(promote-latest): promote both images at GA and mirror to GHCR master still ran the pre-redesign promote: SSM-only, auto-fired on a master push, and its own header pointed at push-to-dockerhub to build the image. That is what let today's GA write /image-tag/latest = 20260820-001 while the image build for that version had already failed -- production was promoted onto a tag that the workflow believed did not exist. Bring master onto the build-once-promote model: - manual workflow_dispatch only, so GA is a deliberate gate rather than a side effect of a branch merge; - retag DockerHub :latest to the existing RC digest via buildx imagetools create, for simplerisk-minimal (-php85) and simplerisk (-noble). No rebuild, so the bytes validated in testing are the bytes that ship; - a currency guard per image: refuse to promote a version whose digest is not the one :testing currently points at, so a stale committed Dockerfile version cannot push an old-but-existing release to prod; - mirror the promoted digests into GHCR, cosign-signed, so ghcr and dockerhub are finally the same bytes rather than two builds sharing a name. skip_full_image covers the transition: releases cut before the full-stack RC build landed have no simplerisk/simplerisk RC digest to promote. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/promote-latest.yml | 144 +++++++++++++++++++++++---- 1 file changed, 127 insertions(+), 17 deletions(-) diff --git a/.github/workflows/promote-latest.yml b/.github/workflows/promote-latest.yml index 938eceb..750a6e0 100644 --- a/.github/workflows/promote-latest.yml +++ b/.github/workflows/promote-latest.yml @@ -1,27 +1,36 @@ 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, run MANUALLY (workflow_dispatch) once the release has merged to +# master. Build-once model: both release images were already built by +# publish-testing.yml at the testing cut. GA does NOT rebuild anything -- it +# repoints tags and parameters at those existing digests, so the bytes validated +# in testing are byte-identical to the bytes that reach production. # -# 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. +# 1. Docker Hub :latest -> the existing RC digest, for both +# simplerisk/simplerisk-minimal (-php85) and simplerisk/simplerisk +# (-noble), via `buildx imagetools create` (multi-arch preserved). +# 2. GHCR mirror -- copies the same digests to ghcr.io, cosign-signed, so the +# GHCR and Docker Hub images for a version are the same bytes. GHCR used to +# get its own rebuild from the prod bundle, which meant ghcr and +# dockerhub were different images sharing a name. +# 3. 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. # -# 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: + inputs: + skip_full_image: + description: 'Transitional: skip simplerisk/simplerisk (no RC digest for releases cut before the full-stack RC build landed)' + type: boolean + default: false permissions: contents: read - id-token: write + id-token: write # OIDC: AWS role + cosign/fulcio identity challenge + packages: write # GHCR mirror concurrency: group: promote-latest @@ -30,6 +39,10 @@ concurrency: env: AWS_REGION: us-east-1 SSM_PARAM: /simplerisk/customers/image-tag/latest + MINIMAL_IMAGE: simplerisk/simplerisk-minimal + FULL_IMAGE: simplerisk/simplerisk + GHCR_MINIMAL: ghcr.io/simplerisk/simplerisk-minimal + GHCR_FULL: ghcr.io/simplerisk/simplerisk jobs: promote: @@ -38,6 +51,27 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - name: Install cosign + uses: sigstore/cosign-installer@v3.5.0 + with: + cosign-release: 'v2.4.0' + + - 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: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Read release version from the minimal Dockerfile id: ver run: | @@ -51,18 +85,94 @@ jobs: fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Promote simplerisk-minimal — :latest → -php85 + env: + VERSION: ${{ steps.ver.outputs.version }} + 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 "${MINIMAL_IMAGE}:${VERSION}-php85" >/dev/null 2>&1; then + echo "::error::${MINIMAL_IMAGE}:${VERSION}-php85 not found on Docker Hub — was the RC published?"; 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 "${MINIMAL_IMAGE}:${VERSION}-php85" --format '{{.Manifest.Digest}}') + TESTING_DIGEST=$(docker buildx imagetools inspect "${MINIMAL_IMAGE}:testing" --format '{{.Manifest.Digest}}') + if [ "$SRC_DIGEST" != "$TESTING_DIGEST" ]; then + echo "::error::${MINIMAL_IMAGE}:${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 "${MINIMAL_IMAGE}:latest" \ + "${MINIMAL_IMAGE}:${VERSION}-php85" + echo "retagged ${MINIMAL_IMAGE}:latest -> ${VERSION}-php85 ($SRC_DIGEST)" >> "$GITHUB_STEP_SUMMARY" + + - name: Promote simplerisk (full-stack) — :latest → -noble + if: ${{ !inputs.skip_full_image }} + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + if ! docker buildx imagetools inspect "${FULL_IMAGE}:${VERSION}-noble" >/dev/null 2>&1; then + echo "::error::${FULL_IMAGE}:${VERSION}-noble not found on Docker Hub — was the RC published? (re-run with skip_full_image for a release cut before the full-stack RC build landed)"; exit 1 + fi + SRC_DIGEST=$(docker buildx imagetools inspect "${FULL_IMAGE}:${VERSION}-noble" --format '{{.Manifest.Digest}}') + TESTING_DIGEST=$(docker buildx imagetools inspect "${FULL_IMAGE}:testing" --format '{{.Manifest.Digest}}') + if [ "$SRC_DIGEST" != "$TESTING_DIGEST" ]; then + echo "::error::${FULL_IMAGE}:${VERSION}-noble ($SRC_DIGEST) is not the current testing RC ($TESTING_DIGEST) — refusing to promote a stale version to :latest"; exit 1 + fi + docker buildx imagetools create \ + --tag "${FULL_IMAGE}:latest" \ + "${FULL_IMAGE}:${VERSION}-noble" + echo "retagged ${FULL_IMAGE}:latest -> ${VERSION}-noble ($SRC_DIGEST)" >> "$GITHUB_STEP_SUMMARY" + + - name: Mirror the promoted digests to GHCR (cosign-signed) + env: + VERSION: ${{ steps.ver.outputs.version }} + SKIP_FULL: ${{ inputs.skip_full_image }} + run: | + set -euo pipefail + # imagetools create copies the manifest (and blobs) across registries, + # so GHCR receives the identical digest rather than a rebuild. Each + # source digest is mirrored once, carrying every tag that points at it. + # cosign signs the digest (not the tag), so one signature per call. + mirror() { + local src="$1" dst="$2"; shift 2 + local args=() t digest + for t in "$@"; do args+=(--tag "${dst}:${t}"); done + docker buildx imagetools create "${args[@]}" "$src" + digest=$(docker buildx imagetools inspect "${dst}:${1}" --format '{{.Manifest.Digest}}') + cosign sign --yes "${dst}@${digest}" + echo "mirrored $src -> ${dst} [$*] ($digest)" >> "$GITHUB_STEP_SUMMARY" + } + + mirror "${MINIMAL_IMAGE}:${VERSION}-php83" "${GHCR_MINIMAL}" "${VERSION}-php83" + mirror "${MINIMAL_IMAGE}:${VERSION}-php84" "${GHCR_MINIMAL}" "${VERSION}-php84" + mirror "${MINIMAL_IMAGE}:${VERSION}-php85" "${GHCR_MINIMAL}" "${VERSION}-php85" "${VERSION}" "latest" + + if [ "$SKIP_FULL" != "true" ]; then + mirror "${FULL_IMAGE}:${VERSION}-jammy" "${GHCR_FULL}" "${VERSION}-jammy" + mirror "${FULL_IMAGE}:${VERSION}-noble" "${GHCR_FULL}" "${VERSION}-noble" "${VERSION}" "latest" + else + echo "skip_full_image set — ${GHCR_FULL} not mirrored for ${VERSION}" >> "$GITHUB_STEP_SUMMARY" + fi + - name: Configure AWS credentials (OIDC → customers account) uses: aws-actions/configure-aws-credentials@v4 with: 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: | 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 9d150a9718edb853d7fff1bb2e4b0230b4b373b1 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 21 Aug 2026 11:00:33 -0500 Subject: [PATCH 10/17] ci(push-*): stop rebuilding release images on a master push These are the workflows that failed the 20260820-001 GA. They rebuild from the prod S3 bundle on a master push, but the release PR merges ~17 minutes before the bundle propagation uploads it, so download_and_verify_bundle.sh fail-closed on a bare 403 (S3 returns 403, not 404, for a missing object under an anonymous-list-denied bucket -- it reads as a permissions error). Rebuilding at GA is also what the promote model exists to remove: it shipped bytes to production that were never the bytes validated in testing, and gave GHCR a separate build under the same version tag. Drop the `push: master` trigger and the simplerisk-minimal jobs from both. promote-latest.yml now owns GA for both registries. The jammy/noble jobs stay dispatchable as a transitional escape hatch for releases with no full-stack RC digest; both headers say when they can be deleted. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/push-to-dockerhub.yml | 45 +++++++++---------------- .github/workflows/push-to-gh-pkgs.yml | 39 +++++++-------------- 2 files changed, 28 insertions(+), 56 deletions(-) diff --git a/.github/workflows/push-to-dockerhub.yml b/.github/workflows/push-to-dockerhub.yml index 32cf5f1..a0dd924 100644 --- a/.github/workflows/push-to-dockerhub.yml +++ b/.github/workflows/push-to-dockerhub.yml @@ -1,11 +1,23 @@ name: Push images to DockerHub +# LEGACY REBUILD -- manual dispatch only, retained as a transitional escape hatch. +# +# Release images are no longer built here. Under the build-once-promote model +# (code-development docs/superpowers/specs/2026-07-10-release-image-promotion-design) +# every release image is built ONCE as the RC by publish-testing.yml on the +# `testing` branch, and promote-latest.yml retags that digest at GA. Rebuilding +# from the prod bundle on a master push produced GA bytes that were never the +# tested bytes, and raced the GA bundle upload: the release PR merges here +# minutes before the bundle lands in S3, so the build failed fail-closed with a +# bare 403. +# +# The `push: master` trigger is therefore gone. These jobs remain dispatchable +# so a release cut BEFORE the full-stack RC build landed can still be produced +# by hand. Delete this workflow once the first post-cutover RC has published +# simplerisk/simplerisk -jammy/-noble digests. + on: workflow_dispatch: - push: - branches: [ "master" ] - # Publish semver tags as releases. - #tags: [ '[2022]0701-001' ] # On a job that uses a reusable workflow, it seems you cannot # use env on a with block (https://github.com/actions/runner/issues/1189#issuecomment-1741672276) @@ -36,28 +48,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: "20260820-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: "20260820-001" - os_version: "php84" - main_image: true - build_args: "php_version=8.4" - platforms: linux/amd64,linux/arm64 - secrets: inherit diff --git a/.github/workflows/push-to-gh-pkgs.yml b/.github/workflows/push-to-gh-pkgs.yml index ceebbb0..8152167 100644 --- a/.github/workflows/push-to-gh-pkgs.yml +++ b/.github/workflows/push-to-gh-pkgs.yml @@ -1,11 +1,19 @@ name: Push images to GitHub Packages +# LEGACY REBUILD -- manual dispatch only, retained as a transitional escape hatch. +# +# GHCR release images are no longer built here. promote-latest.yml mirrors the +# promoted Docker Hub digests into ghcr.io (cosign-signed) at GA, so the GHCR +# and Docker Hub images for a version are the same bytes. Rebuilding here meant +# ghcr and dockerhub were different images sharing a name, +# and the rebuild raced the GA bundle upload the same way the Docker Hub one did. +# +# See code-development docs/superpowers/specs/2026-07-10-release-image-promotion-design. +# Delete this workflow once the first post-cutover RC has published +# simplerisk/simplerisk -jammy/-noble digests. + on: workflow_dispatch: - push: - branches: [ "master" ] - # Publish semver tags as releases. - #tags: [ '[2022]0701-001' ] # On a job that uses a reusable workflow, it seems you cannot # use env on a with block (https://github.com/actions/runner/issues/1189#issuecomment-1741672276) @@ -36,26 +44,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-gh-pkgs_rw.yml - with: - context_path: "simplerisk-minimal" - dockerfile_path: "simplerisk-minimal/Dockerfile" - image_name: "simplerisk-minimal" - version: "20260820-001" - os_version: "php83" - build_args: "php_version=8.3" - secrets: inherit - simplerisk-minimal-php85: - name: 'Push simplerisk/simplerisk-minimal image based on PHP 8.4 with Apache' - uses: ./.github/workflows/push-to-gh-pkgs_rw.yml - with: - context_path: "simplerisk-minimal" - dockerfile_path: "simplerisk-minimal/Dockerfile" - image_name: "simplerisk-minimal" - version: "20260820-001" - os_version: "php84" - main_image: true - build_args: "php_version=8.4" - secrets: inherit From 46399973ccb3e6d7c8edda686ca9b58411ea21a3 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 21 Aug 2026 11:00:33 -0500 Subject: [PATCH 11/17] docs(claude): describe the build-once-promote release path The CI/CD section still said pushes publish to Docker Hub and GHCR, which is now wrong in both halves: nothing publishes on a master push, and GHCR is a mirror of the promoted digest rather than its own build. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8b74d26..25a04c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,9 @@ 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). -- **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`. +- **Release images are built once, then promoted — never rebuilt.** A push to `testing` runs `publish-testing.yml`, which builds both images from the current testing bundle and publishes immutable tags: `simplerisk-minimal` gets `-php83/-php84/-php85` (multi-arch `linux/amd64,linux/arm64`) and `simplerisk` gets `-jammy/-noble` (amd64). Each image's default variant also takes the bare `` and the floating `:testing`. +- **GA is a manual promote, not a build.** After the release merges to `master`, dispatch `promote-latest.yml`. It retags Docker Hub `:latest` to the existing RC digest (`buildx imagetools create`, multi-arch preserved), mirrors the same digests to GHCR cosign-signed, and writes SSM `/simplerisk/customers/image-tag/latest`. Nothing is rebuilt, so the bytes validated in testing are the bytes that ship. A currency guard refuses to promote a version whose digest is not the one `:testing` currently points at. +- `push-to-dockerhub.yml` / `push-to-gh-pkgs.yml` are **legacy rebuild workflows, manual dispatch only** — they no longer run on a `master` push. See their headers; they are deletable once the first post-cutover RC has published full-stack RC digests. - The reusable workflow files (`*_rw.yml`) are called by the entry-point workflows. ### Vulnerability ignore list From 96da81e7dd888bbaec22ecb095fb651d295ccd40 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 21 Aug 2026 11:56:23 -0500 Subject: [PATCH 12/17] ci: declare per-workflow permissions and move registry creds to environments Prep so the repo-level default GITHUB_TOKEN can be set to read-only. - Every workflow now declares an explicit `permissions:` block. create_new_tag.yml is the only one that needs contents: write (it tags and pushes tags); the rest are contents: read. - push-to-gh-pkgs.yml grants packages + id-token write at the caller level: a reusable workflow can only downgrade the caller's token, never escalate, so a bare contents:read caller would break the callee's ghcr push and cosign sign. - The jobs that use the Docker Hub credentials now declare an environment, so those credentials become environment-scoped with a deployment-branch policy rather than repo-wide: publish-testing.yml -> environment: testing (branch: testing) promote-latest.yml -> environment: release (branch: master) push-to-dockerhub_rw.yml -> environment: release (branch: master) This matches how the AWS OIDC roles are already scoped by ref. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/container-validation.yml | 3 +++ .github/workflows/create_new_tag.yml | 3 +++ .github/workflows/promote-latest.yml | 1 + .github/workflows/publish-testing.yml | 1 + .github/workflows/push-to-dockerhub.yml | 3 +++ .github/workflows/push-to-dockerhub_rw.yml | 1 + .github/workflows/push-to-gh-pkgs.yml | 5 +++++ .github/workflows/shellcheck.yml | 3 +++ 8 files changed, 20 insertions(+) diff --git a/.github/workflows/container-validation.yml b/.github/workflows/container-validation.yml index 730dba5..78a710e 100644 --- a/.github/workflows/container-validation.yml +++ b/.github/workflows/container-validation.yml @@ -5,6 +5,9 @@ on: pull_request: branches: [ master ] +permissions: + contents: read + jobs: simplerisk-jammy: name: 'Verify simplerisk/simplerisk image based on Ubuntu 22.04 (Jammy)' diff --git a/.github/workflows/create_new_tag.yml b/.github/workflows/create_new_tag.yml index 00a15b4..bbdf2b0 100644 --- a/.github/workflows/create_new_tag.yml +++ b/.github/workflows/create_new_tag.yml @@ -4,6 +4,9 @@ on: push: branches: [ "master" ] +permissions: + contents: write # git tag + git push --tags + jobs: create-release: runs-on: ubuntu-latest diff --git a/.github/workflows/promote-latest.yml b/.github/workflows/promote-latest.yml index 750a6e0..d8af9f9 100644 --- a/.github/workflows/promote-latest.yml +++ b/.github/workflows/promote-latest.yml @@ -46,6 +46,7 @@ env: jobs: promote: + environment: release runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/publish-testing.yml b/.github/workflows/publish-testing.yml index d25fc2b..7a8e6bd 100644 --- a/.github/workflows/publish-testing.yml +++ b/.github/workflows/publish-testing.yml @@ -42,6 +42,7 @@ env: jobs: publish: + environment: testing runs-on: ubuntu-latest timeout-minutes: 40 steps: diff --git a/.github/workflows/push-to-dockerhub.yml b/.github/workflows/push-to-dockerhub.yml index a0dd924..c9c5f02 100644 --- a/.github/workflows/push-to-dockerhub.yml +++ b/.github/workflows/push-to-dockerhub.yml @@ -19,6 +19,9 @@ name: Push images to DockerHub on: workflow_dispatch: +permissions: + contents: read + # On a job that uses a reusable workflow, it seems you cannot # use env on a with block (https://github.com/actions/runner/issues/1189#issuecomment-1741672276) env: diff --git a/.github/workflows/push-to-dockerhub_rw.yml b/.github/workflows/push-to-dockerhub_rw.yml index 16e66b7..c4fe8da 100644 --- a/.github/workflows/push-to-dockerhub_rw.yml +++ b/.github/workflows/push-to-dockerhub_rw.yml @@ -42,6 +42,7 @@ on: jobs: dockerhub: + environment: release runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/.github/workflows/push-to-gh-pkgs.yml b/.github/workflows/push-to-gh-pkgs.yml index 8152167..6c64788 100644 --- a/.github/workflows/push-to-gh-pkgs.yml +++ b/.github/workflows/push-to-gh-pkgs.yml @@ -15,6 +15,11 @@ name: Push images to GitHub Packages on: workflow_dispatch: +permissions: + contents: read + packages: write # callee push-to-gh-pkgs_rw pushes to ghcr.io + id-token: write # callee cosign-signs via sigstore/fulcio + # On a job that uses a reusable workflow, it seems you cannot # use env on a with block (https://github.com/actions/runner/issues/1189#issuecomment-1741672276) env: diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index d468de4..e02f773 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -4,6 +4,9 @@ on: pull_request: branches: [ master ] +permissions: + contents: read + jobs: shellcheck: name: ShellCheck From 03b80c4a2b7d6ad8f726bcb10b5b028313673972 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 21 Aug 2026 14:44:06 -0500 Subject: [PATCH 13/17] ci: delete the legacy rebuild entry-point workflows Their stated deletion condition is met: the first post-cutover RC published simplerisk/simplerisk 20260820-001-jammy/-noble, and GA promoted both images from those digests (run 32507524464). There is no longer a release path that needs a rebuild, so the escape hatch has nothing left to catch. Both were already dispatch-only and unreferenced outside their own files. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/push-to-dockerhub.yml | 53 ------------------------- .github/workflows/push-to-gh-pkgs.yml | 51 ------------------------ 2 files changed, 104 deletions(-) delete mode 100644 .github/workflows/push-to-dockerhub.yml delete mode 100644 .github/workflows/push-to-gh-pkgs.yml diff --git a/.github/workflows/push-to-dockerhub.yml b/.github/workflows/push-to-dockerhub.yml deleted file mode 100644 index c9c5f02..0000000 --- a/.github/workflows/push-to-dockerhub.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Push images to DockerHub - -# LEGACY REBUILD -- manual dispatch only, retained as a transitional escape hatch. -# -# Release images are no longer built here. Under the build-once-promote model -# (code-development docs/superpowers/specs/2026-07-10-release-image-promotion-design) -# every release image is built ONCE as the RC by publish-testing.yml on the -# `testing` branch, and promote-latest.yml retags that digest at GA. Rebuilding -# from the prod bundle on a master push produced GA bytes that were never the -# tested bytes, and raced the GA bundle upload: the release PR merges here -# minutes before the bundle lands in S3, so the build failed fail-closed with a -# bare 403. -# -# The `push: master` trigger is therefore gone. These jobs remain dispatchable -# so a release cut BEFORE the full-stack RC build landed can still be produced -# by hand. Delete this workflow once the first post-cutover RC has published -# simplerisk/simplerisk -jammy/-noble digests. - -on: - workflow_dispatch: - -permissions: - contents: read - -# On a job that uses a reusable workflow, it seems you cannot -# use env on a with block (https://github.com/actions/runner/issues/1189#issuecomment-1741672276) -env: - VERSION: "20240102-001" - -jobs: - simplerisk-jammy: - name: 'Push simplerisk/simplerisk image based on Ubuntu 22.04 (Jammy)' - uses: ./.github/workflows/push-to-dockerhub_rw.yml - with: - context_path: "simplerisk" - dockerfile_path: "simplerisk/Dockerfile" - image_name: "simplerisk/simplerisk" - version: "20260820-001" - os_version: "jammy" - build_args: "ubuntu_version_code=jammy" - secrets: inherit - simplerisk-noble: - name: 'Push simplerisk/simplerisk image based on Ubuntu 24.04 (Noble)' - uses: ./.github/workflows/push-to-dockerhub_rw.yml - with: - context_path: "simplerisk" - dockerfile_path: "simplerisk/Dockerfile" - image_name: "simplerisk/simplerisk" - version: "20260820-001" - os_version: "noble" - main_image: true - build_args: "ubuntu_version_code=noble" - secrets: inherit diff --git a/.github/workflows/push-to-gh-pkgs.yml b/.github/workflows/push-to-gh-pkgs.yml deleted file mode 100644 index 6c64788..0000000 --- a/.github/workflows/push-to-gh-pkgs.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Push images to GitHub Packages - -# LEGACY REBUILD -- manual dispatch only, retained as a transitional escape hatch. -# -# GHCR release images are no longer built here. promote-latest.yml mirrors the -# promoted Docker Hub digests into ghcr.io (cosign-signed) at GA, so the GHCR -# and Docker Hub images for a version are the same bytes. Rebuilding here meant -# ghcr and dockerhub were different images sharing a name, -# and the rebuild raced the GA bundle upload the same way the Docker Hub one did. -# -# See code-development docs/superpowers/specs/2026-07-10-release-image-promotion-design. -# Delete this workflow once the first post-cutover RC has published -# simplerisk/simplerisk -jammy/-noble digests. - -on: - workflow_dispatch: - -permissions: - contents: read - packages: write # callee push-to-gh-pkgs_rw pushes to ghcr.io - id-token: write # callee cosign-signs via sigstore/fulcio - -# On a job that uses a reusable workflow, it seems you cannot -# use env on a with block (https://github.com/actions/runner/issues/1189#issuecomment-1741672276) -env: - VERSION: "20240102-001" - -jobs: - simplerisk-jammy: - name: 'Push simplerisk/simplerisk image based on Ubuntu 22.04 (Jammy)' - uses: ./.github/workflows/push-to-gh-pkgs_rw.yml - with: - context_path: "simplerisk" - dockerfile_path: "simplerisk/Dockerfile" - image_name: "simplerisk" - version: "20260820-001" - os_version: "jammy" - build_args: "ubuntu_version_code=jammy" - secrets: inherit - simplerisk-noble: - name: 'Push simplerisk/simplerisk image based on Ubuntu 24.04 (Noble)' - uses: ./.github/workflows/push-to-gh-pkgs_rw.yml - with: - context_path: "simplerisk" - dockerfile_path: "simplerisk/Dockerfile" - image_name: "simplerisk" - version: "20260820-001" - os_version: "noble" - main_image: true - build_args: "ubuntu_version_code=noble" - secrets: inherit From 159f0910183cd1ad60ad7f82a5f365ef9556782f Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 21 Aug 2026 14:44:06 -0500 Subject: [PATCH 14/17] ci: delete the now-orphaned reusable push workflows push-to-dockerhub_rw.yml and push-to-gh-pkgs_rw.yml had exactly two callers between them -- the entry points removed in the previous commit. Nothing else in the repo references either file, so they are dead code rather than reusable building blocks. Split from the previous commit so it can be dropped independently if you would rather keep the generic build-and-push blocks around. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/push-to-dockerhub_rw.yml | 100 ----------------- .github/workflows/push-to-gh-pkgs_rw.yml | 120 --------------------- 2 files changed, 220 deletions(-) delete mode 100644 .github/workflows/push-to-dockerhub_rw.yml delete mode 100644 .github/workflows/push-to-gh-pkgs_rw.yml diff --git a/.github/workflows/push-to-dockerhub_rw.yml b/.github/workflows/push-to-dockerhub_rw.yml deleted file mode 100644 index c4fe8da..0000000 --- a/.github/workflows/push-to-dockerhub_rw.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: Push resulting image to DockerHub - -on: - workflow_call: - inputs: - context_path: - description: Base path to perform the build - required: true - type: string - dockerfile_path: - description: Path where Dockerfile is located - required: true - type: string - image_name: - description: What would be the name of the image - required: true - type: string - version: - description: SimpleRisk version to upload - required: true - type: string - os_version: - description: Type of base image to put on the tag - required: true - type: string - main_image: - description: Is this the latest image? - default: false - type: boolean - build_args: - description: Arguments to use on image at runtime - type: string - platforms: - description: Target platforms for the build (e.g. linux/amd64,linux/arm64) - default: linux/amd64 - type: string - secrets: - DOCKER_USERNAME: - required: true - DOCKER_TOKEN: - required: true - -jobs: - dockerhub: - environment: release - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - - 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: Extract Docker metadata - id: meta - uses: docker/metadata-action@v6 - with: - images: ${{ inputs.image_name }} - - - name: Build and push main Docker image - if: ${{ inputs.main_image }} - id: build-and-push-main - uses: docker/build-push-action@v7 - with: - context: ${{ inputs.context_path }} - file: ${{ inputs.dockerfile_path }} - push: ${{ github.event_name != 'pull_request' }} - build-args: ${{ inputs.build_args || '' }} - platforms: ${{ inputs.platforms }} - tags: | - ${{ inputs.image_name }} - ${{ inputs.image_name }}:${{ inputs.version }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=${{ inputs.os_version }} - cache-to: type=gha,mode=max,scope=${{ inputs.os_version }} - - - name: Build and push specific Docker image - id: build-and-push-spec - uses: docker/build-push-action@v7 - with: - context: ${{ inputs.context_path }} - file: ${{ inputs.dockerfile_path }} - push: ${{ github.event_name != 'pull_request' }} - build-args: ${{ inputs.build_args || '' }} - platforms: ${{ inputs.platforms }} - tags: | - ${{ inputs.image_name }}:${{ inputs.version }}-${{ inputs.os_version }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=${{ inputs.os_version }} - cache-to: type=gha,mode=max,scope=${{ inputs.os_version }} - diff --git a/.github/workflows/push-to-gh-pkgs_rw.yml b/.github/workflows/push-to-gh-pkgs_rw.yml deleted file mode 100644 index 1e53003..0000000 --- a/.github/workflows/push-to-gh-pkgs_rw.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: Push resulting image to Github Packages - -on: - workflow_call: - inputs: - context_path: - required: true - type: string - dockerfile_path: - required: true - type: string - image_name: - required: true - type: string - version: - required: true - type: string - os_version: - required: true - type: string - main_image: - default: false - type: boolean - build_args: - type: string - -env: - # Use docker.io for Docker Hub if empty - REGISTRY: ghcr.io - IMAGE_NAME: "${{ github.repository_owner }}/simplerisk" - -jobs: - github_packages: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - # This is used to complete the identity challenge - # with sigstore/fulcio when running outside of PRs. - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Install cosign - if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@v3.5.0 - with: - cosign-release: 'v2.4.0' - - # Workaround: https://github.com/docker/build-push-action/issues/461 - - name: Setup Docker buildx - uses: docker/setup-buildx-action@v4 - - - name: Log into registry - if: github.event_name != 'pull_request' - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@v6 - with: - images: "ghcr.io/${{ github.repository_owner }}/${{ inputs.image_name }}" - - - name: Build and push main Docker image - id: build-and-push-main - if: ${{ inputs.main_image }} - uses: docker/build-push-action@v7 - with: - context: ${{ inputs.context_path }} - file: ${{ inputs.dockerfile_path }} - push: ${{ github.event_name != 'pull_request' }} - build-args: ${{ inputs.build_args || '' }} - tags: | - ghcr.io/${{ github.repository_owner }}/${{ inputs.image_name }} - ghcr.io/${{ github.repository_owner }}/${{ inputs.image_name }}:${{ inputs.version }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - # Sign the resulting Docker image digest except on PRs. - # This will only write to the public Rekor transparency log when the Docker - # repository is public to avoid leaking data. If you would like to publish - # transparency data even for private images, pass --yes to cosign below. - # https://github.com/sigstore/cosign - - name: Sign the published Docker image - if: ${{ inputs.main_image && github.event_name != 'pull_request' }} - # This step uses the identity token to provision an ephemeral certificate - # against the sigstore community Fulcio instance. - run: echo "${{ steps.meta.outputs.tags }}" | xargs -I {} cosign sign --yes {}@${{ steps.build-and-push-main.outputs.digest }} - - - name: Build and push specific Docker image - id: build-and-push-spec - uses: docker/build-push-action@v7 - with: - context: ${{ inputs.context_path }} - file: ${{ inputs.dockerfile_path }} - push: ${{ github.event_name != 'pull_request' }} - build-args: ${{ inputs.build_args || '' }} - tags: | - ghcr.io/${{ github.repository_owner }}/${{ inputs.image_name }}:${{ inputs.version }}-${{ inputs.os_version }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - # Sign the resulting Docker image digest except on PRs. - # This will only write to the public Rekor transparency log when the Docker - # repository is public to avoid leaking data. If you would like to publish - # transparency data even for private images, pass --yes to cosign below. - # https://github.com/sigstore/cosign - - name: Sign the published Docker image - if: ${{ github.event_name != 'pull_request' }} - # This step uses the identity token to provision an ephemeral certificate - # against the sigstore community Fulcio instance. - run: echo "${{ steps.meta.outputs.tags }}" | xargs -I {} cosign sign --yes {}@${{ steps.build-and-push-spec.outputs.digest }} From cb1f667b0857a8e5a78fe732e1d861a709ed18a5 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 21 Aug 2026 14:45:02 -0500 Subject: [PATCH 15/17] build: drop update_workflows.sh, now that no workflow carries a version pin Deleting the push workflows would have broken `make update_version`, not just left dead code. update_workflows.sh loops over `.github/workflows/push*`; with no matches bash leaves the literal glob, sed fails on the nonexistent path, and `set -e` exits 1 -- taking down step 1 of the release bump. Verified: the loop exits 1 against the post-deletion tree. The script only ever patched `version:` pins in those four workflows, and despite its name update_stack_and_workflows.sh touches only stack.yml, so nothing else needs it. The version now lives solely in the two generated Dockerfiles (`ENV version=`), which is where promote-latest.yml and create_new_tag.yml already read it. Removed from the Makefile and from gorin.toml -- the latter generates the Makefile, so leaving it would resurrect the call on the next `make generate_makefile`. CLAUDE.md's release-step list updated to match. Verified: `make update_version VERSION=20260820-001` succeeds and is idempotent (no Dockerfile churn). Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 14 ++++++++------ Makefile | 1 - gorin.toml | 3 +-- update_workflows.sh | 12 ------------ 4 files changed, 9 insertions(+), 21 deletions(-) delete mode 100755 update_workflows.sh diff --git a/CLAUDE.md b/CLAUDE.md index 25a04c4..1b287d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,11 +48,14 @@ shellcheck simplerisk/entrypoint.sh simplerisk-minimal/entrypoint.sh make update_version VERSION=YYYYMMDD-NNN ``` -This runs four scripts in sequence: -1. `update_workflows.sh` — patches version in push workflow files -2. `simplerisk/generate_dockerfile.sh` — regenerates the full-stack Dockerfile from a template -3. `simplerisk-minimal/update_stack_and_workflows.sh` — regenerates `stack.yml` (with a fresh random password) and updates workflow files -4. `simplerisk-minimal/generate_dockerfile.sh` — regenerates the minimal Dockerfile +This runs three scripts in sequence: +1. `simplerisk/generate_dockerfile.sh` — regenerates the full-stack Dockerfile from a template +2. `simplerisk-minimal/update_stack_and_workflows.sh` — regenerates `stack.yml` +3. `simplerisk-minimal/generate_dockerfile.sh` — regenerates the minimal Dockerfile + +The version now lives only in the two generated Dockerfiles (`ENV version=`), which is +where `promote-latest.yml` and `create_new_tag.yml` read it from. No workflow carries a +version pin any more. ### Nix dev environment @@ -119,7 +122,6 @@ The entrypoint script handles: - **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). - **Release images are built once, then promoted — never rebuilt.** A push to `testing` runs `publish-testing.yml`, which builds both images from the current testing bundle and publishes immutable tags: `simplerisk-minimal` gets `-php83/-php84/-php85` (multi-arch `linux/amd64,linux/arm64`) and `simplerisk` gets `-jammy/-noble` (amd64). Each image's default variant also takes the bare `` and the floating `:testing`. - **GA is a manual promote, not a build.** After the release merges to `master`, dispatch `promote-latest.yml`. It retags Docker Hub `:latest` to the existing RC digest (`buildx imagetools create`, multi-arch preserved), mirrors the same digests to GHCR cosign-signed, and writes SSM `/simplerisk/customers/image-tag/latest`. Nothing is rebuilt, so the bytes validated in testing are the bytes that ship. A currency guard refuses to promote a version whose digest is not the one `:testing` currently points at. -- `push-to-dockerhub.yml` / `push-to-gh-pkgs.yml` are **legacy rebuild workflows, manual dispatch only** — they no longer run on a `master` push. See their headers; they are deletable once the first post-cutover RC has published full-stack RC digests. - The reusable workflow files (`*_rw.yml`) are called by the entry-point workflows. ### Vulnerability ignore list diff --git a/Makefile b/Makefile index c2f6fac..5eda093 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,6 @@ help: @echo -e "$$HELP_MESSAGE" update_version: - ./update_workflows.sh $(VERSION) ./simplerisk/generate_dockerfile.sh $(VERSION) ./simplerisk-minimal/update_stack_and_workflows.sh $(VERSION) ./simplerisk-minimal/generate_dockerfile.sh $(VERSION) diff --git a/gorin.toml b/gorin.toml index 42b630f..c8cb283 100644 --- a/gorin.toml +++ b/gorin.toml @@ -10,8 +10,7 @@ check_dependencies [options.update_version] description = "Updates the version of Dockerfiles, stacks and others" command = """ -\n\t./update_workflows.sh $(VERSION) -\t./simplerisk/generate_dockerfile.sh $(VERSION) +\n\t./simplerisk/generate_dockerfile.sh $(VERSION) \t./simplerisk-minimal/update_stack_and_workflows.sh $(VERSION) \t./simplerisk-minimal/generate_dockerfile.sh $(VERSION) """ diff --git a/update_workflows.sh b/update_workflows.sh deleted file mode 100755 index 34e3552..0000000 --- a/update_workflows.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_LOCATION="$(dirname "$(readlink -f "$0")")" -readonly SCRIPT_LOCATION - -[ -z "${1:-}" ] && echo "No release version provided. Aborting." && exit 1 || release=$1 - -for workflow in "$SCRIPT_LOCATION"/.github/workflows/push*; do - sed -i -r "s/(version:) \"[0-9]{8,}-[0-9]{3,}\"/\1 \"${release}\"/g" "$workflow" -done From 57b4026f6615089578fe17db653834735fc3a86d Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 21 Aug 2026 15:00:01 -0500 Subject: [PATCH 16/17] build: converge the Dockerfile generators with the testing branch The generator layer had drifted: testing gained the (version, source_mode) split in July, master never did. That is what made generator_checks a testing-only job and left master unable to produce a context-sourced image with a real ENV version. Takes testing's four generator files verbatim -- both generate_dockerfile.sh and both test_generate_dockerfile.sh -- so the layer is now byte-identical across branches rather than approximately similar. The minimal generator's default php moves 8.4 -> 8.5 as part of that, because the decoupling and the default bump landed in the same upstream commit and the minimal harness asserts 8.5. This corrects stale config rather than changing what ships: every released image is the testing-built php85 one, so master's committed 8.4 default only ever affected container-validation and local `docker build`. Committed Dockerfiles regenerated via `make update_version VERSION=20260820-001`. The only substantive change is that php default; the rest is comment punctuation. The -fsSL guard on the schema fetch survives the regeneration (asserted by the full-stack harness). Co-Authored-By: Claude Opus 5 (1M context) --- simplerisk-minimal/Dockerfile | 4 +- simplerisk-minimal/generate_dockerfile.sh | 21 +++++-- .../test_generate_dockerfile.sh | 51 ++++++++++++++++ simplerisk/Dockerfile | 3 +- simplerisk/generate_dockerfile.sh | 23 +++++-- simplerisk/test_generate_dockerfile.sh | 61 +++++++++++++++++++ 6 files changed, 149 insertions(+), 14 deletions(-) create mode 100755 simplerisk-minimal/test_generate_dockerfile.sh create mode 100755 simplerisk/test_generate_dockerfile.sh diff --git a/simplerisk-minimal/Dockerfile b/simplerisk-minimal/Dockerfile index a1e44ca..0554029 100644 --- a/simplerisk-minimal/Dockerfile +++ b/simplerisk-minimal/Dockerfile @@ -1,5 +1,5 @@ # Dockerfile generated by script -ARG php_version=8.4 +ARG php_version=8.5 FROM alpine/curl:8.12.1 AS downloader @@ -35,7 +35,7 @@ ARG TARGETARCH # amd64: mysql-community-client from MySQL's Debian repo # arm64: default-mysql-client from Debian (MySQL's apt repo has no arm64 packages) # apt-get upgrade patches base-image packages (apache2, curl, ...) with Debian -# security updates -- the pinned php:${php_version}-apache base ships them at its +# security updates — the pinned php:${php_version}-apache base ships them at its # own build-time versions, so without this they accumulate fixed CVEs (Grype gate). RUN apt-get update && \ apt-get -y upgrade && \ diff --git a/simplerisk-minimal/generate_dockerfile.sh b/simplerisk-minimal/generate_dockerfile.sh index 8075266..53ba282 100755 --- a/simplerisk-minimal/generate_dockerfile.sh +++ b/simplerisk-minimal/generate_dockerfile.sh @@ -7,19 +7,30 @@ set -euo pipefail SCRIPT_LOCATION="$(dirname "$(readlink -f "$0")")" readonly SCRIPT_LOCATION -if [ $# -eq 1 ]; then +if [ $# -ge 1 ]; then release=$1 else echo "No release version provided. Aborting." && exit 1 fi +# Source mode: `context` = COPY app from build context (no downloader stage); +# `download` = COPY --from=downloader (curl the prod bundle). Default preserves +# back-compat: context when release==testing, else download. +if [ $# -ge 2 ]; then + source_mode=$2 +else + if [ "$release" == "testing" ]; then source_mode="context"; else source_mode="download"; fi +fi +if [ "$source_mode" != "context" ] && [ "$source_mode" != "download" ]; then + echo "Invalid source mode '$source_mode' (expected context|download). Aborting." && exit 1 +fi cat << EOF > "${SCRIPT_LOCATION}/Dockerfile" # Dockerfile generated by script -ARG php_version=8.4 +ARG php_version=8.5 EOF -if [ "$release" != "testing" ]; then +if [ "$source_mode" == "download" ]; then cat << EOF >> "${SCRIPT_LOCATION}/Dockerfile" FROM alpine/curl:8.12.1 AS downloader @@ -59,7 +70,7 @@ ARG TARGETARCH # amd64: mysql-community-client from MySQL's Debian repo # arm64: default-mysql-client from Debian (MySQL's apt repo has no arm64 packages) # apt-get upgrade patches base-image packages (apache2, curl, ...) with Debian -# security updates -- the pinned php:\${php_version}-apache base ships them at its +# security updates — the pinned php:\${php_version}-apache base ships them at its # own build-time versions, so without this they accumulate fixed CVEs (Grype gate). RUN apt-get update && \\ apt-get -y upgrade && \\ @@ -116,7 +127,7 @@ RUN echo "0 0 * * * root /usr/sbin/logrotate /etc/logrotate.d/simplerisk.conf > COPY common/ / EOF # shellcheck disable=SC2015 -if [ "$release" == "testing" ]; then +if [ "$source_mode" == "context" ]; then cat << EOF >> "${SCRIPT_LOCATION}/Dockerfile" COPY simplerisk/ /var/www/simplerisk COPY common/simplerisk.sql /var/www/simplerisk/simplerisk.sql diff --git a/simplerisk-minimal/test_generate_dockerfile.sh b/simplerisk-minimal/test_generate_dockerfile.sh new file mode 100755 index 0000000..545de44 --- /dev/null +++ b/simplerisk-minimal/test_generate_dockerfile.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Regression checks for generate_dockerfile.sh version/source-mode decoupling. +set -euo pipefail +cd "$(dirname "$(readlink -f "$0")")" + +# generate_dockerfile.sh hardcodes its output to the tracked Dockerfile in this +# directory; back it up and restore it on exit (pass or fail) so this checker +# never leaves the committed Dockerfile overwritten. +cp Dockerfile "/tmp/Dockerfile.bak.$$" 2>/dev/null || true +trap 'cp "/tmp/Dockerfile.bak.$$" Dockerfile 2>/dev/null || git checkout -- Dockerfile 2>/dev/null || true; rm -f "/tmp/Dockerfile.bak.$$"' EXIT + +fail=0 +check() { if grep -qF "$2" Dockerfile; then echo "ok: $1"; else echo "FAIL: $1 (missing: $2)"; fail=1; fi; } +absent() { if grep -qF "$2" Dockerfile; then echo "FAIL: $1 (should be absent: $2)"; fail=1; else echo "ok: $1"; fi; } + +# context mode with a real version: no downloader, COPY-from-context, real ENV version, php 8.5 default +./generate_dockerfile.sh 20260709-001 context +check "context: real ENV version" "ENV version=20260709-001" +check "context: COPY app from context" "COPY simplerisk/ /var/www/simplerisk" +absent "context: no downloader stage" "FROM alpine/curl" +check "context: php default 8.5" "ARG php_version=8.5" + +# download mode (explicit): downloader present, COPY-from-downloader, real ENV version +./generate_dockerfile.sh 20260709-001 download +check "download: downloader stage" "FROM alpine/curl" +check "download: COPY from downloader" "COPY --from=downloader /var/www/simplerisk /var/www/simplerisk" +check "download: real ENV version" "ENV version=20260709-001" + +# back-compat: literal "testing" with no mode arg still selects context recipe +./generate_dockerfile.sh testing +absent "testing back-compat: no downloader" "FROM alpine/curl" +check "testing back-compat: COPY context" "COPY simplerisk/ /var/www/simplerisk" + +# invalid source-mode is rejected +if ./generate_dockerfile.sh 20260709-001 bogus >/tmp/bogus-mode.out.$$ 2>&1; then + echo "FAIL: invalid source-mode should be rejected (exited 0)"; fail=1 +else + echo "ok: invalid source-mode rejected" +fi +rm -f "/tmp/bogus-mode.out.$$" + +# idempotence: re-running the same context args does not double anything +./generate_dockerfile.sh 20260709-001 context +copy_count=$(grep -cF "COPY simplerisk/ /var/www/simplerisk" Dockerfile) +if [ "$copy_count" -eq 1 ]; then + echo "ok: idempotence (single COPY simplerisk/ line after re-run)" +else + echo "FAIL: idempotence (expected 1 COPY simplerisk/ line, found $copy_count)"; fail=1 +fi + +exit $fail diff --git a/simplerisk/Dockerfile b/simplerisk/Dockerfile index 6b9b7c0..bcaa9ba 100644 --- a/simplerisk/Dockerfile +++ b/simplerisk/Dockerfile @@ -4,8 +4,7 @@ ARG ubuntu_version_code=noble FROM alpine/curl:8.12.1 AS downloader ARG DB_LANG=en - -# CI-ONLY pre-GA switch (default false) -- see common/download_and_verify_bundle.sh. +# CI-ONLY pre-GA switch (default false) — see common/download_and_verify_bundle.sh. ARG PREGA_BUNDLE_FALLBACK=false SHELL [ "/bin/ash", "-eo", "pipefail", "-c" ] diff --git a/simplerisk/generate_dockerfile.sh b/simplerisk/generate_dockerfile.sh index 8dd41c8..10e347a 100755 --- a/simplerisk/generate_dockerfile.sh +++ b/simplerisk/generate_dockerfile.sh @@ -5,11 +5,25 @@ set -euo pipefail SCRIPT_LOCATION="$(dirname "$(readlink -f "$0")")" readonly SCRIPT_LOCATION -if [ $# -eq 1 ]; then +if [ $# -ge 1 ]; then release=$1 else echo "No release version provided. Aborting." && exit 1 fi +# Source mode: `context` = COPY the app from the build context (no downloader +# stage); `download` = COPY --from=downloader (curl + hash-verify the prod +# bundle). Splitting this out of $release lets the RC build stamp a real +# `ENV version=` while still sourcing bytes from the build context -- +# the build-once half of the promote model. Default preserves back-compat: +# context when release==testing, else download. +if [ $# -ge 2 ]; then + source_mode=$2 +else + if [ "$release" == "testing" ]; then source_mode="context"; else source_mode="download"; fi +fi +if [ "$source_mode" != "context" ] && [ "$source_mode" != "download" ]; then + echo "Invalid source mode '$source_mode' (expected context|download). Aborting." && exit 1 +fi cat << EOF > "${SCRIPT_LOCATION}/Dockerfile" # Dockerfile generated by script @@ -17,13 +31,12 @@ ARG ubuntu_version_code=noble EOF -if [ "$release" != "testing" ]; then +if [ "$source_mode" == "download" ]; then cat << EOF >> "${SCRIPT_LOCATION}/Dockerfile" FROM alpine/curl:8.12.1 AS downloader ARG DB_LANG=en - -# CI-ONLY pre-GA switch (default false) -- see common/download_and_verify_bundle.sh. +# CI-ONLY pre-GA switch (default false) — see common/download_and_verify_bundle.sh. ARG PREGA_BUNDLE_FALLBACK=false SHELL [ "/bin/ash", "-eo", "pipefail", "-c" ] @@ -99,7 +112,7 @@ RUN echo "\$(< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c\${1:-32})" > /password # Install common files COPY common/ / EOF -if [ "$release" == "testing" ]; then +if [ "$source_mode" == "context" ]; then cat << EOF >> "${SCRIPT_LOCATION}/Dockerfile" COPY common/simplerisk.sql /simplerisk.sql COPY ./simplerisk/ /var/www/simplerisk diff --git a/simplerisk/test_generate_dockerfile.sh b/simplerisk/test_generate_dockerfile.sh new file mode 100755 index 0000000..20a5f58 --- /dev/null +++ b/simplerisk/test_generate_dockerfile.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Regression checks for generate_dockerfile.sh version/source-mode decoupling. +set -euo pipefail +cd "$(dirname "$(readlink -f "$0")")" + +# generate_dockerfile.sh hardcodes its output to the tracked Dockerfile in this +# directory; back it up and restore it on exit (pass or fail) so this checker +# never leaves the committed Dockerfile overwritten. +cp Dockerfile "/tmp/Dockerfile.bak.$$" 2>/dev/null || true +trap 'cp "/tmp/Dockerfile.bak.$$" Dockerfile 2>/dev/null || git checkout -- Dockerfile 2>/dev/null || true; rm -f "/tmp/Dockerfile.bak.$$"' EXIT + +fail=0 +check() { if grep -qF "$2" Dockerfile; then echo "ok: $1"; else echo "FAIL: $1 (missing: $2)"; fail=1; fi; } +absent() { if grep -qF "$2" Dockerfile; then echo "FAIL: $1 (should be absent: $2)"; fail=1; else echo "ok: $1"; fi; } + +# context mode with a real version: no downloader, COPY-from-context, real ENV version +./generate_dockerfile.sh 20260709-001 context +check "context: real ENV version" "ENV version=20260709-001" +check "context: COPY app from context" "COPY ./simplerisk/ /var/www/simplerisk" +check "context: COPY schema" "COPY common/simplerisk.sql /simplerisk.sql" +absent "context: no downloader stage" "FROM alpine/curl" +check "context: ubuntu default noble" "ARG ubuntu_version_code=noble" + +# download mode (explicit): downloader present, COPY-from-downloader, real ENV version +./generate_dockerfile.sh 20260709-001 download +check "download: downloader stage" "FROM alpine/curl" +check "download: COPY from downloader" "COPY --from=downloader /var/www/simplerisk /var/www/simplerisk" +check "download: real ENV version" "ENV version=20260709-001" +# --fail on the schema fetch: without it curl writes the 404 body into +# /simplerisk.sql and the image ships an HTML error page as its schema. +# Single-quoted on purpose: $DB_LANG must stay literal, it is a Dockerfile ARG. +# shellcheck disable=SC2016 +check "download: schema fetch uses -fsSL" 'curl -fsSL "https://github.com/simplerisk/database/raw/master/simplerisk-$DB_LANG-20260709-001.sql"' + +# back-compat: literal "testing" with no mode arg still selects the context recipe +./generate_dockerfile.sh testing +absent "testing back-compat: no downloader" "FROM alpine/curl" +check "testing back-compat: COPY context" "COPY ./simplerisk/ /var/www/simplerisk" + +# back-compat: a bare version with no mode arg still selects the download recipe +./generate_dockerfile.sh 20260709-001 +check "version back-compat: downloader stage" "FROM alpine/curl" + +# invalid source-mode is rejected +if ./generate_dockerfile.sh 20260709-001 bogus >/tmp/bogus-mode.out.$$ 2>&1; then + echo "FAIL: invalid source-mode should be rejected (exited 0)"; fail=1 +else + echo "ok: invalid source-mode rejected" +fi +rm -f "/tmp/bogus-mode.out.$$" + +# idempotence: re-running the same context args does not double anything +./generate_dockerfile.sh 20260709-001 context +copy_count=$(grep -cF "COPY ./simplerisk/ /var/www/simplerisk" Dockerfile) +if [ "$copy_count" -eq 1 ]; then + echo "ok: idempotence (single COPY ./simplerisk/ line after re-run)" +else + echo "FAIL: idempotence (expected 1 COPY ./simplerisk/ line, found $copy_count)"; fail=1 +fi + +exit $fail From 59ee1c602f252dcab2d122294d9cee4a6da28a30 Mon Sep 17 00:00:00 2001 From: Josh Sokol Date: Fri, 21 Aug 2026 15:00:01 -0500 Subject: [PATCH 17/17] ci(container-validation): add php 8.5 + generator checks, fix the job-key skew - generator_checks runs both harnesses on PRs and fails if either leaves a committed Dockerfile modified. The generators are load-bearing for the release path but nothing pinned them on master. - Adds the php 8.5 variant, which is the minimal image's default and the one GA actually promotes, yet was unvalidated here. - Fixes a name/key skew that made the matrix misleading: job simplerisk-minimal-php84 built php 8.3 and was labelled "PHP 8.3", and simplerisk-minimal-php85 built 8.4. Keys, labels and build args now agree. CLAUDE.md's CI/CD bullet still claimed jammy/noble/php81/php83; corrected along with the stale php_version build-arg list. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/container-validation.yml | 39 ++++++++++++++++++++-- CLAUDE.md | 4 +-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/workflows/container-validation.yml b/.github/workflows/container-validation.yml index 78a710e..2af75bb 100644 --- a/.github/workflows/container-validation.yml +++ b/.github/workflows/container-validation.yml @@ -27,7 +27,7 @@ jobs: image_tag: "simplerisk/simplerisk:testing" build_args: "ubuntu_version_code=noble\nPREGA_BUNDLE_FALLBACK=true" - simplerisk-minimal-php84: + simplerisk-minimal-php83: name: 'Verify simplerisk/simplerisk-minimal image based on PHP 8.3 with Apache' uses: ./.github/workflows/verify-image_rw.yml with: @@ -36,7 +36,7 @@ jobs: image_tag: "simplerisk/simplerisk-minimal:testing" build_args: "php_version=8.3\nPREGA_BUNDLE_FALLBACK=true" - simplerisk-minimal-php85: + simplerisk-minimal-php84: name: 'Verify simplerisk/simplerisk-minimal image based on PHP 8.4 with Apache' uses: ./.github/workflows/verify-image_rw.yml with: @@ -44,3 +44,38 @@ jobs: dockerfile_path: "simplerisk-minimal/Dockerfile" image_tag: "simplerisk/simplerisk-minimal:testing" build_args: "php_version=8.4\nPREGA_BUNDLE_FALLBACK=true" + + simplerisk-minimal-php85: + name: 'Verify simplerisk/simplerisk-minimal image based on PHP 8.5 with Apache' + uses: ./.github/workflows/verify-image_rw.yml + with: + context_path: "simplerisk-minimal/" + dockerfile_path: "simplerisk-minimal/Dockerfile" + image_tag: "simplerisk/simplerisk-minimal:testing" + build_args: "php_version=8.5\nPREGA_BUNDLE_FALLBACK=true" + + generator_checks: + name: 'Verify the Dockerfile generators (version/source-mode decoupling)' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # Both generators take (version, source_mode). The RC build depends on + # `context` mode stamping a real ENV version while sourcing the app from + # the build context; the release build depends on `download` mode still + # emitting the hash-verifying downloader stage. These harnesses pin both. + - name: simplerisk-minimal generator + run: ./simplerisk-minimal/test_generate_dockerfile.sh + + - name: simplerisk (full-stack) generator + run: ./simplerisk/test_generate_dockerfile.sh + + - name: Fail if a generator left the committed Dockerfile modified + run: | + set -euo pipefail + if ! git diff --quiet -- simplerisk/Dockerfile simplerisk-minimal/Dockerfile; then + echo "::error::a generator harness left a committed Dockerfile modified" + git --no-pager diff -- simplerisk/Dockerfile simplerisk-minimal/Dockerfile + exit 1 + fi diff --git a/CLAUDE.md b/CLAUDE.md index 1b287d8..8f0fc4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ Both images use multi-stage builds (Alpine curl downloader stage → main stage) # Full-stack (build args: ubuntu_version_code=jammy|noble) docker build -t simplerisk/simplerisk simplerisk/ -# Minimal (build args: php_version=8.1|8.3|8.4) +# Minimal (build args: php_version=8.3|8.4|8.5; default 8.5) docker build -t simplerisk/simplerisk-minimal simplerisk-minimal/ ``` @@ -119,7 +119,7 @@ The entrypoint script handles: ### CI/CD -- **PRs** trigger `container-validation.yml`: builds all 4 variants (jammy, noble, php81, php83), runs Dockle (Dockerfile linter) and Grype (CVE scanner, severity cutoff: critical, only-fixed). +- **PRs** trigger `container-validation.yml`: builds all 5 variants (jammy, noble, php83, php84, php85), runs Dockle (Dockerfile linter) and Grype (CVE scanner, severity cutoff: critical, only-fixed), and runs `generator_checks` — the two `test_generate_dockerfile.sh` harnesses that pin the generators' version/source-mode behaviour. - **Release images are built once, then promoted — never rebuilt.** A push to `testing` runs `publish-testing.yml`, which builds both images from the current testing bundle and publishes immutable tags: `simplerisk-minimal` gets `-php83/-php84/-php85` (multi-arch `linux/amd64,linux/arm64`) and `simplerisk` gets `-jammy/-noble` (amd64). Each image's default variant also takes the bare `` and the floating `:testing`. - **GA is a manual promote, not a build.** After the release merges to `master`, dispatch `promote-latest.yml`. It retags Docker Hub `:latest` to the existing RC digest (`buildx imagetools create`, multi-arch preserved), mirrors the same digests to GHCR cosign-signed, and writes SSM `/simplerisk/customers/image-tag/latest`. Nothing is rebuilt, so the bytes validated in testing are the bytes that ship. A currency guard refuses to promote a version whose digest is not the one `:testing` currently points at. - The reusable workflow files (`*_rw.yml`) are called by the entry-point workflows.