From 3567484860d844aba55d720bc76b2adf626b125f Mon Sep 17 00:00:00 2001 From: Lucas Ramos <3140800+lucas-d-ramos@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:39:23 +0200 Subject: [PATCH 1/4] docker-smoke: add smoke-command and fail-on-log-pattern, dump logs on any failure smoke-command runs after the Host-header probe with SMOKE_BASE_URL set; a non-zero exit fails the action. It is passed through env and run by an inner bash, not interpolated into the script. fail-on-log-pattern fails the action when the container log matches an extended regex. The last 200 lines of the container log are printed when any step fails, replacing the full dump the probe printed on its own failure. Both inputs default to empty, so existing callers are unaffected. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/actions/docker-smoke/action.yml | 72 +++++++++++++++++++++++-- CLAUDE.md | 2 +- README.md | 27 ++++++++-- 3 files changed, 92 insertions(+), 9 deletions(-) diff --git a/.github/actions/docker-smoke/action.yml b/.github/actions/docker-smoke/action.yml index 5b2c181..db662f4 100644 --- a/.github/actions/docker-smoke/action.yml +++ b/.github/actions/docker-smoke/action.yml @@ -8,9 +8,15 @@ # under its real hostname (e.g. Vite's preview `allowedHosts` rejecting an # unlisted Host with 403) — caught by curling with `Host: `. # -# Used as a STEP inside a job named `ci` so the required-status-check context -# stays exactly `ci` (a reusable workflow would produce `ci / ` and break -# the org ruleset's required-check match). +# Optionally, `smoke-command` runs a deeper check against the running container +# (e.g. a crawl of every page), and `fail-on-log-pattern` fails on server log +# lines such as a missing module that a page may swallow while still answering +# 200. Whenever a step fails, the last 200 lines of the container log are +# printed. +# +# Used as a STEP inside the job whose name is the required status check (`ci` +# under the org ruleset) so the context stays exactly that name (a reusable +# workflow would produce `ci / ` and break the required-check match). name: Docker image smoke test description: Build the Docker image, run it, and assert it serves the production Host with the expected HTTP status. @@ -50,6 +56,22 @@ inputs: the Dockerfile needs) without requiring the container to serve. required: false default: "false" + smoke-command: + description: >- + Shell command run with bash after the Host-header probe succeeds, from the + workspace root, with SMOKE_BASE_URL=http://localhost: in its + environment. A non-zero exit fails the action. Empty (the default) skips + it. Ignored when build-only is "true". + required: false + default: "" + fail-on-log-pattern: + description: >- + Extended regex (grep -E). After the probe and smoke-command, the action + fails if the container log matches it, e.g. + "MODULE_NOT_FOUND|Cannot find module". Empty (the default) skips the + check. Ignored when build-only is "true". + required: false + default: "" runs: using: composite @@ -88,10 +110,50 @@ runs: sleep 3 done echo "::error::Smoke test failed: got HTTP ${code} for Host: ${SMOKE_HOST}${SMOKE_PATH} (expected ${SMOKE_EXPECTED})" - echo "=== container logs ===" - docker logs ci-smoke || true exit 1 + # The command is caller-supplied, so it goes through `env` and is run by an + # inner bash, never spliced into this script body. + - name: Run smoke command + if: ${{ inputs.build-only != 'true' && inputs.smoke-command != '' }} + shell: bash + env: + SMOKE_COMMAND: ${{ inputs.smoke-command }} + SMOKE_BASE_URL: http://localhost:${{ inputs.port }} + run: bash -eo pipefail -c "$SMOKE_COMMAND" + + - name: Check container log + if: ${{ inputs.build-only != 'true' && inputs.fail-on-log-pattern != '' }} + shell: bash + env: + SMOKE_LOG_PATTERN: ${{ inputs.fail-on-log-pattern }} + run: | + set -uo pipefail + if ! logs=$(docker logs ci-smoke 2>&1); then + echo "::error::Could not read the container log" + exit 1 + fi + matches=$(printf '%s\n' "$logs" | grep -E -- "$SMOKE_LOG_PATTERN") + rc=$? + if [ "$rc" -eq 0 ]; then + printf '%s\n' "$matches" + echo "::error::The container log matches fail-on-log-pattern: ${SMOKE_LOG_PATTERN}" + exit 1 + elif [ "$rc" -ne 1 ]; then + echo "::error::grep failed on fail-on-log-pattern: ${SMOKE_LOG_PATTERN}" + exit 1 + fi + echo "✓ No container log line matches: ${SMOKE_LOG_PATTERN}" + + - name: Container logs + if: ${{ failure() && inputs.build-only != 'true' }} + shell: bash + run: | + if docker container inspect ci-smoke >/dev/null 2>&1; then + echo "=== last 200 lines of the container log ===" + docker logs --tail 200 ci-smoke 2>&1 || true + fi + - name: Tear down container if: ${{ always() && inputs.build-only != 'true' }} shell: bash diff --git a/CLAUDE.md b/CLAUDE.md index ecca57e..a1caea3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ uses: WemoveEU/ci-workflows/.github/workflows/docker-build.yml@v14 - **deploy-strapi.yml** — Orchestrates a Strapi deploy by calling `docker-build.yml` twice: backend first (`backend/Dockerfile`, image `/backend`), then frontend (`frontend/Dockerfile`, image `/frontend`). Between them, `wait-for-backend` polls the live backend until ready. - **notify.yml** — Posts a Slack notification (green on success, red on failure) via `slackapi/slack-github-action`. Requires the `slack_webhook_url` secret. Call it as a final step from a caller workflow. - **python-build.yml** — Builds a Python package with `uv` (`astral-sh/setup-uv` + `uv build`). -- **docker-smoke** (`.github/actions/docker-smoke`) — Composite action (not a reusable workflow). Builds the Docker image, runs it, and probes it with the production `Host` header to catch build breaks and hostname-dependent serving failures. Used as a step inside a job named `ci` (so the required-status-check context stays exactly `ci`). Supports `build-only` for apps that can't boot standalone in CI. +- **docker-smoke** (`.github/actions/docker-smoke`) — Composite action (not a reusable workflow). Builds the Docker image, runs it, and probes it with the production `Host` header to catch build breaks and hostname-dependent serving failures. Used as a step inside a job named `ci` (so the required-status-check context stays exactly `ci`). Supports `build-only` for apps that can't boot standalone in CI, and optional `smoke-command` / `fail-on-log-pattern` for deeper checks after the probe. ## Architecture Notes (the non-obvious parts) diff --git a/README.md b/README.md index cb59680..e6d66cf 100644 --- a/README.md +++ b/README.md @@ -232,9 +232,13 @@ image-build breaks (e.g. a base-image bump dropping a tool the Dockerfile needs) runtime serving breaks that only appear under the real hostname (e.g. a preview server's `allowedHosts` rejecting an unlisted Host with 403). -Use it as a **step** inside a job named `ci` so the required-status-check context stays -exactly `ci` (a reusable workflow would produce `ci / ` and break an org ruleset's -required-check match). +Use it as a **step** inside the job whose name is the required status check (`ci` under the +org ruleset) so the context stays exactly that name (a reusable workflow would produce +`ci / ` and break the required-check match). + +With `smoke-command` it also runs a deeper check against the running container, and with +`fail-on-log-pattern` it fails on server log lines that a page can swallow while still +answering 200. Whenever a step fails, the last 200 lines of the container log are printed. ### Inputs @@ -246,6 +250,8 @@ required-check match). - **context**: Docker build context. Default `.`. - **startup-timeout**: Seconds to wait for the container to start responding. Default `90`. - **build-only**: When `"true"`, only build the image and skip running/probing it. Use for apps that can't boot standalone in CI (need runtime secrets, a database, etc.). Default `"false"`. +- **smoke-command**: Shell command run with bash after the probe succeeds, from the workspace root, with `SMOKE_BASE_URL=http://localhost:` in its environment. A non-zero exit fails the action. Default `""` (skipped). +- **fail-on-log-pattern**: Extended regex (`grep -E`). After the probe and `smoke-command`, the action fails if the container log matches it, e.g. `"MODULE_NOT_FOUND|Cannot find module"`. Default `""` (skipped). ### Sample usage @@ -261,6 +267,20 @@ jobs: port: "3000" ``` +With a smoke script and a log check (a Next.js frontend in `frontend/`): + +```yaml + - uses: WeMoveEU/ci-workflows/.github/actions/docker-smoke@v14 + with: + dockerfile: frontend/Dockerfile + context: frontend + host: example.org + port: "3000" + path: /en + smoke-command: node frontend/scripts/smoke-test.mjs "$SMOKE_BASE_URL" + fail-on-log-pattern: "MODULE_NOT_FOUND|Cannot find module" +``` + --- ## Releases @@ -268,6 +288,7 @@ jobs: Version tags `v1`–`v13` predate the current scheme and are frozen point releases. The floating-major convention (see [`RELEASING.md`](RELEASING.md)) starts at **`v14`**. +- **v14.8** — `docker-smoke`: optional `smoke-command` (runs after the probe with `SMOKE_BASE_URL` set; its failure fails the action) and `fail-on-log-pattern` (fails when the container log matches an extended regex). The last 200 lines of the container log are now printed when any step fails, not only the probe. Existing callers are unaffected. - **v14.6** — `pin-override` action: a first pass re-resolves every alerted package inside the ranges the tree already declares (`yarn up -R `, lockfile only, manifest untouched) before any resolution is written. This is what clears a package installed at several versions, which Dependabot cannot fix, and a fix a hand-closed Dependabot PR taught Dependabot to ignore. Measured across the fleet on 2026-09-11: 220 audit findings to 102. New `bumped` output; `changed` is true when either pass changed something. - **v14.5** — `pin-override` composite action: writes Yarn `resolutions` for the transitive advisories Dependabot cannot fix when a parent pins the vulnerable package exactly. - **v14.2** — `dependabot-auto-merge.yml`: arming is now retried (5 attempts) with a direct-merge fallback, so a transient GraphQL error no longer strands a PR and an already-green PR still merges. Consolidates logic that existed only in the inline copies in wemove.eu, youmove, wemove-charity.eu and pubstatic, which this lets them drop. From ae5489d679b43cfab12b8ca11139193e783e7dcb Mon Sep 17 00:00:00 2001 From: Lucas Ramos <3140800+lucas-d-ramos@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:39:23 +0200 Subject: [PATCH 2/4] tmp: exercise docker-smoke inputs (drop before merge) Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/tmp-docker-smoke-test.yml | 75 +++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/tmp-docker-smoke-test.yml diff --git a/.github/workflows/tmp-docker-smoke-test.yml b/.github/workflows/tmp-docker-smoke-test.yml new file mode 100644 index 0000000..fe76f16 --- /dev/null +++ b/.github/workflows/tmp-docker-smoke-test.yml @@ -0,0 +1,75 @@ +name: tmp docker-smoke test + +# Temporary: exercises the docker-smoke inputs on this PR. Removed before merge. +on: + pull_request: + +permissions: + contents: read + +jobs: + smoke: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + case: [defaults, pass, smoke-fails, log-matches] + steps: + - uses: actions/checkout@v7 + - name: Write test image + run: | + mkdir -p /tmp/img + printf 'FROM python:3-alpine\nCMD ["python", "-u", "-m", "http.server", "8080"]\n' > /tmp/img/Dockerfile + + - name: defaults + if: matrix.case == 'defaults' + uses: ./.github/actions/docker-smoke + with: + host: example.org + dockerfile: /tmp/img/Dockerfile + context: /tmp/img + + - name: pass + if: matrix.case == 'pass' + uses: ./.github/actions/docker-smoke + with: + host: example.org + dockerfile: /tmp/img/Dockerfile + context: /tmp/img + smoke-command: | + echo "base=$SMOKE_BASE_URL pwd=$PWD" + curl -fsS "$SMOKE_BASE_URL/" >/dev/null + fail-on-log-pattern: "MODULE_NOT_FOUND|Cannot find module" + + - name: smoke-fails + id: smoke_fails + if: matrix.case == 'smoke-fails' + continue-on-error: true + uses: ./.github/actions/docker-smoke + with: + host: example.org + dockerfile: /tmp/img/Dockerfile + context: /tmp/img + smoke-command: curl -fsS "$SMOKE_BASE_URL/missing" + fail-on-log-pattern: "MODULE_NOT_FOUND" + + - name: log-matches + id: log_matches + if: matrix.case == 'log-matches' + continue-on-error: true + uses: ./.github/actions/docker-smoke + with: + host: example.org + dockerfile: /tmp/img/Dockerfile + context: /tmp/img + smoke-command: curl -s "$SMOKE_BASE_URL/missing" >/dev/null + fail-on-log-pattern: "GET /missing" + + - name: expect failure + if: matrix.case == 'smoke-fails' || matrix.case == 'log-matches' + env: + OUTCOME: ${{ steps.smoke_fails.outcome }}${{ steps.log_matches.outcome }} + run: | + echo "outcome=$OUTCOME" + [ "$OUTCOME" = failure ] + ! docker container inspect ci-smoke >/dev/null 2>&1 From b8259d9ca0387069ea2512b7cb223c7f82e5f52c Mon Sep 17 00:00:00 2001 From: Lucas Ramos <3140800+lucas-d-ramos@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:41:36 +0200 Subject: [PATCH 3/4] docker-smoke: keep the runner's bash -e from ending the log check on no match Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/actions/docker-smoke/action.yml | 5 +++-- .github/workflows/tmp-docker-smoke-test.yml | 21 ++++++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/actions/docker-smoke/action.yml b/.github/actions/docker-smoke/action.yml index db662f4..3f89f64 100644 --- a/.github/actions/docker-smoke/action.yml +++ b/.github/actions/docker-smoke/action.yml @@ -133,8 +133,9 @@ runs: echo "::error::Could not read the container log" exit 1 fi - matches=$(printf '%s\n' "$logs" | grep -E -- "$SMOKE_LOG_PATTERN") - rc=$? + # The runner's bash has -e, so capture grep's status (1 = no match) + # without letting it end the script. + matches=$(printf '%s\n' "$logs" | grep -E -- "$SMOKE_LOG_PATTERN") && rc=0 || rc=$? if [ "$rc" -eq 0 ]; then printf '%s\n' "$matches" echo "::error::The container log matches fail-on-log-pattern: ${SMOKE_LOG_PATTERN}" diff --git a/.github/workflows/tmp-docker-smoke-test.yml b/.github/workflows/tmp-docker-smoke-test.yml index fe76f16..2123a61 100644 --- a/.github/workflows/tmp-docker-smoke-test.yml +++ b/.github/workflows/tmp-docker-smoke-test.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - case: [defaults, pass, smoke-fails, log-matches] + case: [defaults, pass, smoke-fails, log-matches, bad-regex] steps: - uses: actions/checkout@v7 - name: Write test image @@ -37,7 +37,7 @@ jobs: dockerfile: /tmp/img/Dockerfile context: /tmp/img smoke-command: | - echo "base=$SMOKE_BASE_URL pwd=$PWD" + echo "base=$SMOKE_BASE_URL"; pwd curl -fsS "$SMOKE_BASE_URL/" >/dev/null fail-on-log-pattern: "MODULE_NOT_FOUND|Cannot find module" @@ -65,10 +65,25 @@ jobs: smoke-command: curl -s "$SMOKE_BASE_URL/missing" >/dev/null fail-on-log-pattern: "GET /missing" + - name: bad-regex + id: bad_regex + if: matrix.case == 'bad-regex' + continue-on-error: true + uses: ./.github/actions/docker-smoke + with: + host: example.org + dockerfile: /tmp/img/Dockerfile + context: /tmp/img + fail-on-log-pattern: "(unclosed" + + - name: expect bad-regex failure + if: matrix.case == 'bad-regex' + run: '[ "${{ steps.bad_regex.outcome }}" = failure ]' + - name: expect failure if: matrix.case == 'smoke-fails' || matrix.case == 'log-matches' env: - OUTCOME: ${{ steps.smoke_fails.outcome }}${{ steps.log_matches.outcome }} + OUTCOME: ${{ matrix.case == 'smoke-fails' && steps.smoke_fails.outcome || steps.log_matches.outcome }} run: | echo "outcome=$OUTCOME" [ "$OUTCOME" = failure ] From d02795ce91efc24404c374bcb981ad4aacc93537 Mon Sep 17 00:00:00 2001 From: Lucas Ramos <3140800+lucas-d-ramos@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:43:20 +0200 Subject: [PATCH 4/4] Remove the temporary docker-smoke test workflow Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/tmp-docker-smoke-test.yml | 90 --------------------- 1 file changed, 90 deletions(-) delete mode 100644 .github/workflows/tmp-docker-smoke-test.yml diff --git a/.github/workflows/tmp-docker-smoke-test.yml b/.github/workflows/tmp-docker-smoke-test.yml deleted file mode 100644 index 2123a61..0000000 --- a/.github/workflows/tmp-docker-smoke-test.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: tmp docker-smoke test - -# Temporary: exercises the docker-smoke inputs on this PR. Removed before merge. -on: - pull_request: - -permissions: - contents: read - -jobs: - smoke: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - case: [defaults, pass, smoke-fails, log-matches, bad-regex] - steps: - - uses: actions/checkout@v7 - - name: Write test image - run: | - mkdir -p /tmp/img - printf 'FROM python:3-alpine\nCMD ["python", "-u", "-m", "http.server", "8080"]\n' > /tmp/img/Dockerfile - - - name: defaults - if: matrix.case == 'defaults' - uses: ./.github/actions/docker-smoke - with: - host: example.org - dockerfile: /tmp/img/Dockerfile - context: /tmp/img - - - name: pass - if: matrix.case == 'pass' - uses: ./.github/actions/docker-smoke - with: - host: example.org - dockerfile: /tmp/img/Dockerfile - context: /tmp/img - smoke-command: | - echo "base=$SMOKE_BASE_URL"; pwd - curl -fsS "$SMOKE_BASE_URL/" >/dev/null - fail-on-log-pattern: "MODULE_NOT_FOUND|Cannot find module" - - - name: smoke-fails - id: smoke_fails - if: matrix.case == 'smoke-fails' - continue-on-error: true - uses: ./.github/actions/docker-smoke - with: - host: example.org - dockerfile: /tmp/img/Dockerfile - context: /tmp/img - smoke-command: curl -fsS "$SMOKE_BASE_URL/missing" - fail-on-log-pattern: "MODULE_NOT_FOUND" - - - name: log-matches - id: log_matches - if: matrix.case == 'log-matches' - continue-on-error: true - uses: ./.github/actions/docker-smoke - with: - host: example.org - dockerfile: /tmp/img/Dockerfile - context: /tmp/img - smoke-command: curl -s "$SMOKE_BASE_URL/missing" >/dev/null - fail-on-log-pattern: "GET /missing" - - - name: bad-regex - id: bad_regex - if: matrix.case == 'bad-regex' - continue-on-error: true - uses: ./.github/actions/docker-smoke - with: - host: example.org - dockerfile: /tmp/img/Dockerfile - context: /tmp/img - fail-on-log-pattern: "(unclosed" - - - name: expect bad-regex failure - if: matrix.case == 'bad-regex' - run: '[ "${{ steps.bad_regex.outcome }}" = failure ]' - - - name: expect failure - if: matrix.case == 'smoke-fails' || matrix.case == 'log-matches' - env: - OUTCOME: ${{ matrix.case == 'smoke-fails' && steps.smoke_fails.outcome || steps.log_matches.outcome }} - run: | - echo "outcome=$OUTCOME" - [ "$OUTCOME" = failure ] - ! docker container inspect ci-smoke >/dev/null 2>&1