From 534e8e4378318ffe118a12584ef511baa7fe7d35 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Tue, 6 Oct 2026 13:50:33 -0700 Subject: [PATCH 1/2] feat(netlify): run a baseline check on every site netlify-site-checks.yml now runs netlify/baseline-check.sh against the deploy URL for every caller, before the caller's own check script. It fails unless GET / returns 200 (redirects followed, bounded timeout and retries) with a body of at least 1024 bytes that contains a element. The check-script decision moves into netlify/select-check-script.sh, still run before the 10-minute wait: - default path (scripts/check-deploy-preview.sh) absent: skipped with a notice, so a site with no script of its own (crazy-larry) gets the baseline check alone, with no caller change; - any other path absent: fails with exit 2, to catch a typo; - present but not executable: fails with exit 2, as before. tests/test-baseline-check.sh covers both scripts against a stub curl, and pins the selector's default to the workflow input default. Advances smartwatermelon/dev-env#193 --- .github/workflows/netlify-site-checks.yml | 30 ++-- README.md | 39 ++++- netlify/baseline-check.sh | 54 +++++++ netlify/caller-stub.yml | 2 + netlify/select-check-script.sh | 31 ++++ tests/stub-baseline/curl | 23 +++ tests/test-baseline-check.sh | 166 ++++++++++++++++++++++ 7 files changed, 329 insertions(+), 16 deletions(-) create mode 100755 netlify/baseline-check.sh create mode 100755 netlify/select-check-script.sh create mode 100755 tests/stub-baseline/curl create mode 100755 tests/test-baseline-check.sh diff --git a/.github/workflows/netlify-site-checks.yml b/.github/workflows/netlify-site-checks.yml index bd54697..6ff43af 100644 --- a/.github/workflows/netlify-site-checks.yml +++ b/.github/workflows/netlify-site-checks.yml @@ -14,8 +14,12 @@ on: type: string required: true check-script: - description: Path in the CALLER repo to the check script, invoked as `<script> <base-url>` + description: >- + Path in the CALLER repo to the check script, invoked as + `<script> <base-url>` after the built-in baseline check. Left at the + default and absent, it is skipped; any other absent path fails. type: string + # Must match default_script in netlify/select-check-script.sh. default: scripts/check-deploy-preview.sh timeout-minutes: description: >- @@ -60,7 +64,7 @@ jobs: echo "sha=${JOB_WORKFLOW_SHA}" >> "${GITHUB_OUTPUT}" echo "workflow SHA: ${JOB_WORKFLOW_SHA}" - - name: Checkout wait scripts at the called-workflow SHA + - name: Checkout netlify scripts at the called-workflow SHA uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: smartwatermelon/github-workflows @@ -72,19 +76,15 @@ jobs: netlify # Fail now, not after a 10-minute wait for a check that cannot run. + # A missing script at the default path is skipped, not failed. - name: Verify the check script + id: check_script env: CHECK_SCRIPT: ${{ inputs.check-script }} + SITE_DIR: site run: | set -euo pipefail - if [ ! -f "site/${CHECK_SCRIPT}" ]; then - echo "::error::check-script '${CHECK_SCRIPT}' does not exist in the caller repo" - exit 2 - fi - if [ ! -x "site/${CHECK_SCRIPT}" ]; then - echo "::error::check-script '${CHECK_SCRIPT}' is not executable (git update-index --chmod=+x)" - exit 2 - fi + bash netlify-src/netlify/select-check-script.sh >> "${GITHUB_OUTPUT}" # The token reaches this step only on push; a pull_request run never # holds it. @@ -116,7 +116,17 @@ jobs: esac echo "url=${url}" >> "${GITHUB_OUTPUT}" + # Every site gets this, with or without a check script of its own. + - name: Baseline check + env: + SITE_URL: ${{ steps.deploy.outputs.url }} + run: | + set -euo pipefail + echo "Running the baseline check against ${SITE_URL}" + bash netlify-src/netlify/baseline-check.sh "${SITE_URL}" + - name: Check the served site + if: steps.check_script.outputs.run == 'true' working-directory: site env: CHECK_SCRIPT: ${{ inputs.check-script }} diff --git a/README.md b/README.md index e49d570..9eae932 100644 --- a/README.md +++ b/README.md @@ -537,6 +537,9 @@ The triggering event picks the mode: | `pull_request` | preview | The commit status Netlify posts on the PR head, context `netlify/<netlify-site>/deploy-preview` | its `target_url` | | `push` | production | The Netlify API deploy with `context == "production"` and `commit_ref` equal to the pushed SHA, until `state` is `ready` | the site's `ssl_url` (primary custom domain), else `url` | +Once the deploy is ready, the workflow runs the [baseline check](#baseline-check) +against it, then the caller's check script if there is one. + Any other event fails. Both waits give up after 10 minutes. A production deploy in `error` state fails at once and prints Netlify's `error_message`; a timeout prints every state it saw (`not-yet-present`, `building`, @@ -545,7 +548,22 @@ commit status for production deploys. The wait logic is `netlify/wait-for-preview.sh` and `netlify/wait-for-production.sh`, checked out from this repo at -`job.workflow_sha`, as `standards-check.yml` does with its scripts. +`job.workflow_sha`, as `standards-check.yml` does with its scripts. The +baseline check and the check-script selection (`netlify/baseline-check.sh`, +`netlify/select-check-script.sh`) come from the same checkout. + +### Baseline check + +Every caller gets `netlify/baseline-check.sh <base-url>`, run before the +caller's own script. It fails unless `GET /` returns HTTP 200 (redirects +followed; 30-second timeout, 2 retries) with a body of at least 1024 bytes +that contains a `<title>` element. It catches an empty, truncated, or +non-HTML deploy. It knows nothing about the site, so a site that can say +more about itself should still ship a check script. + +A site with no check script of its own (a static site that never changes, +such as smartwatermelon/crazy-larry) gets the baseline check alone. It needs +no `check-script` input: see the default-path rule in [Setup](#setup). ### How the waits work @@ -614,16 +632,23 @@ jobs: - Pass the secret by name, not with `secrets: inherit`, so the workflow gets only this one secret. It is handed to the wait step only on `push`. - The check script is the site repo's own. It runs from the repo root as - `<script> <base-url>`, must be executable, and must exit nonzero on - failure. The workflow verifies that it exists and is executable before it - starts to wait. + `<script> <base-url>`, after the baseline check, must be executable, and + must exit nonzero on failure. The workflow verifies it before it starts to + wait: + - `check-script` left at its default (`scripts/check-deploy-preview.sh`) + and the file absent: skipped with a `::notice::`; only the baseline + check runs. Passing that default path explicitly behaves the same, since + the workflow cannot tell the two apart. + - `check-script` set to any other path and the file absent: fails at once + (exit 2), to catch a typo. + - The file present but not executable: fails at once (exit 2). ### Inputs | Input | Type | Default | Description | | ----- | ---- | ------- | ----------- | | `netlify-site` | string | (required) | Netlify site name: the `<name>` in `<name>.netlify.app` | -| `check-script` | string | `scripts/check-deploy-preview.sh` | Path in the caller repo, invoked as `<script> <base-url>` | +| `check-script` | string | `scripts/check-deploy-preview.sh` | Path in the caller repo, invoked as `<script> <base-url>` after the baseline check. Skipped if absent at the default path; fails if absent at any other path. | | `timeout-minutes` | number | `20` | Job timeout. The wait alone runs to 10 minutes. | | Secret | Required | Description | @@ -635,7 +660,9 @@ jobs: `tests/test-wait-for-preview.sh` and `tests/test-wait-for-production.sh` run the wait scripts against stub `gh` and `curl` in `tests/stub-netlify/`. `NETLIFY_POLL_INTERVAL` and `NETLIFY_POLL_DEADLINE` (seconds) shorten the -poll for tests. +poll for tests. `tests/test-baseline-check.sh` runs the baseline check +against a stub `curl` in `tests/stub-baseline/`, and covers the check-script +selection, including that its default matches the workflow input's. ### Versioning diff --git a/netlify/baseline-check.sh b/netlify/baseline-check.sh new file mode 100755 index 0000000..f3a053b --- /dev/null +++ b/netlify/baseline-check.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Baseline check every site gets from netlify-site-checks.yml (dev-env#193). +# Usage: baseline-check.sh <base-url> + +# GET / must return 200 (after redirects) with a body of at least 1024 bytes +# that contains a <title> element. + +# Exit 0 = the served site is sane, 1 = it is not, 2 = usage. +set -euo pipefail + +if [[ $# -ne 1 || -z "$1" ]]; then + printf 'usage: %s <base-url>\n' "$(basename "$0")" >&2 + exit 2 +fi +base_url="${1%/}" +min_bytes=1024 + +body="$(mktemp)" +trap 'rm -f "${body}"' EXIT + +# curl exits nonzero on a connection failure but still writes 000 for the +# status, so the status check below reports it. +status="$(curl --silent --show-error --location \ + --connect-timeout 10 --max-time 30 --retry 2 --retry-delay 3 \ + --output "${body}" --write-out '%{http_code}' \ + "${base_url}/")" || true + +if [[ "${status}" != "200" ]]; then + printf 'FAIL: %s/ returned %s (expected 200)\n' "${base_url}" "${status:-000}" >&2 + exit 1 +fi +printf 'ok: %s/ returned 200\n' "${base_url}" + +failures=0 +size="$(wc -c <"${body}")" +size="${size//[[:space:]]/}" +if ((size >= min_bytes)); then + printf 'ok: / body is %s bytes (floor %s)\n' "${size}" "${min_bytes}" +else + printf 'FAIL: / body is %s bytes, below the %s-byte floor (empty or truncated deploy?)\n' \ + "${size}" "${min_bytes}" >&2 + failures=$((failures + 1)) +fi + +if grep -Eqi '<title[^>]*>' "${body}"; then + printf 'ok: / contains a <title> element\n' +else + printf 'FAIL: / has no <title> element (not an HTML page?)\n' >&2 + failures=$((failures + 1)) +fi + +if ((failures > 0)); then + exit 1 +fi diff --git a/netlify/caller-stub.yml b/netlify/caller-stub.yml index bd8ef38..0040d08 100644 --- a/netlify/caller-stub.yml +++ b/netlify/caller-stub.yml @@ -11,6 +11,8 @@ jobs: uses: smartwatermelon/github-workflows/.github/workflows/netlify-site-checks.yml@netlify-site-checks-v1 with: netlify-site: SITE-NAME + # Runs after the built-in baseline check. At this default path, a + # missing script is skipped; any other missing path fails. # check-script: scripts/check-deploy-preview.sh secrets: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} diff --git a/netlify/select-check-script.sh b/netlify/select-check-script.sh new file mode 100755 index 0000000..5972cf4 --- /dev/null +++ b/netlify/select-check-script.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Decide whether netlify-site-checks.yml runs the caller's check script. +# Prints run=true or run=false on stdout, for GITHUB_OUTPUT. + +# Env: CHECK_SCRIPT (path in the caller repo), SITE_DIR (caller checkout). +# Exit 0 = decided, 2 = a script that cannot run. + +# Missing at the default path: skip; the baseline check still runs. +# Missing at any other path: a typo, so fail. +set -euo pipefail + +: "${CHECK_SCRIPT:?CHECK_SCRIPT is required}" +: "${SITE_DIR:?SITE_DIR is required}" +# Must match the check-script input default in netlify-site-checks.yml. +default_script="scripts/check-deploy-preview.sh" +path="${SITE_DIR}/${CHECK_SCRIPT}" + +if [[ ! -f "${path}" ]]; then + if [[ "${CHECK_SCRIPT}" == "${default_script}" ]]; then + echo "::notice::No ${default_script} in the caller repo; only the baseline check runs." >&2 + echo "run=false" + exit 0 + fi + echo "::error::check-script '${CHECK_SCRIPT}' does not exist in the caller repo" >&2 + exit 2 +fi +if [[ ! -x "${path}" ]]; then + echo "::error::check-script '${CHECK_SCRIPT}' is not executable (git update-index --chmod=+x)" >&2 + exit 2 +fi +echo "run=true" diff --git a/tests/stub-baseline/curl b/tests/stub-baseline/curl new file mode 100755 index 0000000..0b72315 --- /dev/null +++ b/tests/stub-baseline/curl @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Stub curl for test-baseline-check.sh. Fixtures in STUB_DIR: status, +# body; a file "down" means connection refused. argv -> calls.log. +set -euo pipefail +: "${STUB_DIR:?}" +printf '%s\n' "$*" >>"${STUB_DIR}/calls.log" +out="" fmt="" +while [[ $# -gt 0 ]]; do + case "$1" in + --output | -o) out="$2"; shift 2 ;; + --write-out | -w) fmt="$2"; shift 2 ;; + --max-time | --connect-timeout | --retry | --retry-delay) shift 2 ;; + *) shift ;; + esac +done +if [[ -f "${STUB_DIR}/down" ]]; then + [[ "${fmt}" == '%{http_code}' ]] && printf '000' + echo "curl: (7) Failed to connect to 127.0.0.1 port 9: Connection refused" >&2 + exit 7 +fi +cat "${STUB_DIR}/body" >"${out:-/dev/stdout}" +[[ "${fmt}" == '%{http_code}' ]] && cat "${STUB_DIR}/status" +exit 0 diff --git a/tests/test-baseline-check.sh b/tests/test-baseline-check.sh new file mode 100755 index 0000000..aa11d69 --- /dev/null +++ b/tests/test-baseline-check.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# Hermetic tests for netlify/baseline-check.sh (stub curl in +# tests/stub-baseline/) and netlify/select-check-script.sh. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +script="${here}/../netlify/baseline-check.sh" +select="${here}/../netlify/select-check-script.sh" +workflow="${here}/../.github/workflows/netlify-site-checks.yml" +# An exported curl function or BASH_ENV would shadow the PATH stub. +unset -f curl 2>/dev/null || true +unset BASH_ENV +pass=0; fail=0 +tmp_dirs=() +trap 'rm -rf "${tmp_dirs[@]}"' EXIT +_ok() { echo " ok $1"; pass=$((pass + 1)); } +_bad() { echo " FAIL $1"; fail=$((fail + 1)); } +_tmp() { local d; d="$(mktemp -d)"; tmp_dirs+=("${d}"); printf '%s' "${d}"; } + +# A page over the 1024-byte floor, with a title. +pad="$(printf '%*s' 1100 '' | tr ' ' 'x')" +good="<!doctype html><html><head><title>Example${pad}" + +# _fixture STATUS BODY +_fixture() { + STUB_DIR="$(_tmp)"; export STUB_DIR + : >"${STUB_DIR}/calls.log" + printf '%s' "$1" >"${STUB_DIR}/status" + printf '%s' "$2" >"${STUB_DIR}/body" +} +run() { + rc=0 + out="$(PATH="${here}/stub-baseline:${PATH}" bash "${script}" "$@" 2>"${STUB_DIR}/err")" || rc=$? + err="$(cat "${STUB_DIR}/err")" +} + +echo "baseline-check.sh" + +# 1. pass: 200, big enough, has a title; requests / and follows redirects +_fixture 200 "${good}" +run "https://example.com/" +call="$(cat "${STUB_DIR}/calls.log")" +if [[ "${rc}" -eq 0 && -z "${err}" ]] && grep -q '^ok: .* returned 200' <<<"${out}" \ + && grep -q '^ok: / contains a ' <<<"${out}"; then + _ok "sane page passes" +else + _bad "pass case: rc=${rc} out='${out}' err='${err}'" +fi +if [[ "${call}" == *"--location"* && "${call}" == *"--max-time"* && "${call}" == *"--retry"* \ + && "${call}" == *" https://example.com/" ]]; then + _ok "requests <base>/ with redirects, timeout, retries" +else + _bad "curl argv: ${call}" +fi + +# 2. non-200 +_fixture 404 "${good}" +run "https://example.com" +if [[ "${rc}" -eq 1 ]] && grep -q '^FAIL: https://example.com/ returned 404' <<<"${err}"; then + _ok "non-200 fails" +else + _bad "non-200 case: rc=${rc} out='${out}' err='${err}'" +fi + +# 3. connection failure reads as status 000, not a crash +_fixture 200 "${good}" +: >"${STUB_DIR}/down" +run "https://example.com" +if [[ "${rc}" -eq 1 ]] && grep -q 'returned 000' <<<"${err}"; then + _ok "connection failure fails as 000" +else + _bad "down case: rc=${rc} err='${err}'" +fi + +# 4. short body +_fixture 200 "<html><head><title>Tiny" +run "https://example.com" +if [[ "${rc}" -eq 1 ]] && grep -q '^FAIL: / body is [0-9]* bytes, below the 1024-byte floor' <<<"${err}"; then + _ok "short body fails" +else + _bad "short-body case: rc=${rc} err='${err}'" +fi + +# 5. missing title +_fixture 200 "${pad}" +run "https://example.com" +if [[ "${rc}" -eq 1 ]] && grep -q '^FAIL: / has no ' <<<"${err}"; then + _ok "missing title fails" +else + _bad "no-title case: rc=${rc} err='${err}'" +fi + +# 6. usage errors: no args, empty arg, two args; no request made +for args in "" "EMPTY" "a b"; do + _fixture 200 "${good}" + case "${args}" in + "") run ;; + EMPTY) run "" ;; + *) run a b ;; + esac + if [[ "${rc}" -eq 2 && ! -s "${STUB_DIR}/calls.log" ]] && grep -q '^usage:' <<<"${err}"; then + _ok "usage error (${args:-no args}) exits 2" + else + _bad "usage case '${args}': rc=${rc} err='${err}'" + fi +done + +echo "select-check-script.sh" + +# sel CHECK_SCRIPT: runs the selector against SITE +sel() { + rc=0 + out="$(CHECK_SCRIPT="$1" SITE_DIR="${SITE}" bash "${select}" 2>"${SITE}.err")" || rc=$? + err="$(cat "${SITE}.err")" +} +SITE="$(_tmp)/site" +mkdir -p "${SITE}/scripts" + +# 7. default path missing: skip with a notice +sel scripts/check-deploy-preview.sh +if [[ "${rc}" -eq 0 && "${out}" == "run=false" ]] && grep -q '^::notice::.*only the baseline check runs' <<<"${err}"; then + _ok "missing default script is skipped with a notice" +else + _bad "default-missing case: rc=${rc} out='${out}' err='${err}'" +fi + +# 8. non-default path missing: exit 2 +sel scripts/chek.sh +if [[ "${rc}" -eq 2 && -z "${out}" ]] && grep -q "^::error::check-script 'scripts/chek.sh' does not exist" <<<"${err}"; then + _ok "missing non-default script fails with exit 2" +else + _bad "typo case: rc=${rc} out='${out}' err='${err}'" +fi + +# 9. exists but not executable: exit 2, for the default path too +printf '#!/bin/sh\nexit 0\n' >"${SITE}/scripts/check-deploy-preview.sh" +chmod -x "${SITE}/scripts/check-deploy-preview.sh" +sel scripts/check-deploy-preview.sh +if [[ "${rc}" -eq 2 && -z "${out}" ]] && grep -q 'is not executable' <<<"${err}"; then + _ok "non-executable script fails with exit 2" +else + _bad "non-exec case: rc=${rc} out='${out}' err='${err}'" +fi + +# 10. exists and executable: run, default and custom paths +chmod +x "${SITE}/scripts/check-deploy-preview.sh" +cp "${SITE}/scripts/check-deploy-preview.sh" "${SITE}/scripts/custom.sh" +for p in scripts/check-deploy-preview.sh scripts/custom.sh; do + sel "${p}" + if [[ "${rc}" -eq 0 && "${out}" == "run=true" && -z "${err}" ]]; then + _ok "executable ${p} runs" + else + _bad "run case ${p}: rc=${rc} out='${out}' err='${err}'" + fi +done + +# 11. the selector's default matches the workflow's check-script default +wf_default="$(grep -A8 '^ check-script:' "${workflow}" | sed -n 's/^ *default: *//p')" +sel_default="$(sed -n 's/^default_script="\(.*\)"$/\1/p' "${select}")" +if [[ -n "${wf_default}" && "${wf_default}" == "${sel_default}" ]]; then + _ok "selector default matches the workflow input default (${wf_default})" +else + _bad "default drift: workflow='${wf_default}' selector='${sel_default}'" +fi + +echo "${pass} passed, ${fail} failed" +[[ "${fail}" -eq 0 ]] From 0ff993dff6337bf5027adcd8a24bc1be5f393e09 Mon Sep 17 00:00:00 2001 From: Claude Code Bot <claude-code@smartwatermelon.github> Date: Tue, 6 Oct 2026 13:51:14 -0700 Subject: [PATCH 2/2] test(netlify): clean up baseline-check temp dirs _tmp ran inside a command substitution, so its append to tmp_dirs was lost and the EXIT trap removed nothing. Fixtures now live under one parent dir that the trap removes. --- tests/test-baseline-check.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test-baseline-check.sh b/tests/test-baseline-check.sh index aa11d69..a3d36af 100755 --- a/tests/test-baseline-check.sh +++ b/tests/test-baseline-check.sh @@ -10,11 +10,12 @@ workflow="${here}/../.github/workflows/netlify-site-checks.yml" unset -f curl 2>/dev/null || true unset BASH_ENV pass=0; fail=0 -tmp_dirs=() -trap 'rm -rf "${tmp_dirs[@]}"' EXIT +# One parent dir: _tmp runs in a $(...) subshell, so it cannot register dirs. +tmp_root="$(mktemp -d)" +trap 'rm -rf "${tmp_root}"' EXIT _ok() { echo " ok $1"; pass=$((pass + 1)); } _bad() { echo " FAIL $1"; fail=$((fail + 1)); } -_tmp() { local d; d="$(mktemp -d)"; tmp_dirs+=("${d}"); printf '%s' "${d}"; } +_tmp() { mktemp -d "${tmp_root}/t.XXXXXX"; } # A page over the 1024-byte floor, with a title. pad="$(printf '%*s' 1100 '' | tr ' ' 'x')"