Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions .github/workflows/container-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -36,11 +36,46 @@ 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:
context_path: "simplerisk-minimal/"
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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
```

Expand Down Expand Up @@ -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 `<VERSION>-php83/-php84/-php85` (multi-arch `linux/amd64,linux/arm64`) and `simplerisk` gets `<VERSION>-jammy/-noble` (amd64). Each image's default variant also takes the bare `<VERSION>` 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.
Expand Down
4 changes: 2 additions & 2 deletions simplerisk-minimal/Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 && \
Expand Down
21 changes: 16 additions & 5 deletions simplerisk-minimal/generate_dockerfile.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 && \\
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions simplerisk-minimal/test_generate_dockerfile.sh
Original file line number Diff line number Diff line change
@@ -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
3 changes: 1 addition & 2 deletions simplerisk/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
Expand Down
23 changes: 18 additions & 5 deletions simplerisk/generate_dockerfile.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,38 @@ 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=<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
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" ]
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions simplerisk/test_generate_dockerfile.sh
Original file line number Diff line number Diff line change
@@ -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