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
5 changes: 5 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,8 @@ paths:
# Scoped to this one message in this one file: any other expression
# error, in this or any other workflow, still fires.
- 'property "workflow_sha" is not defined in object type'
.github/workflows/netlify-site-checks.yml:
ignore:
# Same gap as standards-check.yml above: it checks out netlify/ at
# job.workflow_sha. Remove with the entry above.
- 'property "workflow_sha" is not defined in object type'
127 changes: 127 additions & 0 deletions .github/workflows/netlify-site-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
name: Netlify Site Checks

# Acceptance checks on the deploy Netlify published: preview on pull_request,
# production on push (dev-env#193). README.md has the details.

# Caller stub: netlify/caller-stub.yml. The caller grants contents: read and
# statuses: read, and passes NETLIFY_AUTH_TOKEN by name.

on:
workflow_call:
inputs:
netlify-site:
description: Netlify site name (the <name> in <name>.netlify.app), e.g. ameliabooneracing
type: string
required: true
check-script:
description: Path in the CALLER repo to the check script, invoked as `<script> <base-url>`
type: string
default: scripts/check-deploy-preview.sh
timeout-minutes:
description: >-
Job timeout. The wait alone runs to 10 minutes, so leave room for
the check script on top of that.
type: number
default: 20
secrets:
NETLIFY_AUTH_TOKEN:
description: Netlify personal access token. Required for push (production) runs only.
required: false

permissions: {}

jobs:
site-check:
runs-on: ubuntu-latest
permissions:
contents: read
statuses: read
timeout-minutes: ${{ inputs.timeout-minutes }}
steps:
- name: Checkout caller repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 1
path: site

# An empty ref makes checkout take the default branch, so fail instead.
# Same step as standards-check.yml.
- name: Resolve the workflow SHA
id: workflow_sha
env:
JOB_WORKFLOW_SHA: ${{ job.workflow_sha }}
run: |
set -euo pipefail
if [ -z "${JOB_WORKFLOW_SHA}" ]; then
echo "::error::job.workflow_sha is empty; refusing to check out an ambiguous ref"
exit 2
fi
echo "sha=${JOB_WORKFLOW_SHA}" >> "${GITHUB_OUTPUT}"
echo "workflow SHA: ${JOB_WORKFLOW_SHA}"

- name: Checkout wait scripts at the called-workflow SHA
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: smartwatermelon/github-workflows
ref: ${{ steps.workflow_sha.outputs.sha }}
persist-credentials: false
fetch-depth: 1
path: netlify-src
sparse-checkout: |
netlify

# Fail now, not after a 10-minute wait for a check that cannot run.
- name: Verify the check script
env:
CHECK_SCRIPT: ${{ inputs.check-script }}
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

# The token reaches this step only on push; a pull_request run never
# holds it.
- name: Wait for the Netlify deploy
id: deploy
env:
EVENT_NAME: ${{ github.event_name }}
NETLIFY_SITE: ${{ inputs.netlify-site }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PUSH_SHA: ${{ github.sha }}
GH_TOKEN: ${{ github.token }}
NETLIFY_AUTH_TOKEN: ${{ github.event_name == 'push' && secrets.NETLIFY_AUTH_TOKEN || '' }}
run: |
set -euo pipefail
case "${EVENT_NAME}" in
pull_request)
echo "Preview mode: PR head ${PR_HEAD_SHA}"
url="$(COMMIT_SHA="${PR_HEAD_SHA}" bash netlify-src/netlify/wait-for-preview.sh)"
;;
push)
echo "Production mode: pushed ${PUSH_SHA}"
url="$(COMMIT_SHA="${PUSH_SHA}" bash netlify-src/netlify/wait-for-production.sh)"
;;
*)
echo "::error::netlify-site-checks runs on pull_request (preview) or push" \
"(production), not '${EVENT_NAME}'"
exit 2
;;
esac
echo "url=${url}" >> "${GITHUB_OUTPUT}"

- name: Check the served site
working-directory: site
env:
CHECK_SCRIPT: ${{ inputs.check-script }}
SITE_URL: ${{ steps.deploy.outputs.url }}
run: |
set -euo pipefail
echo "Running ${CHECK_SCRIPT} against ${SITE_URL}"
"./${CHECK_SCRIPT}" "${SITE_URL}"
127 changes: 127 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,133 @@ existing `claude-blocking-review`/`claude-assistant` `v1` line.

---

## `netlify-site-checks.yml`

Reusable acceptance check for a Netlify site, run against the deploy Netlify
actually published
([dev-env#193](https://github.com/smartwatermelon/dev-env/issues/193)). It
replaces the hand-copied `verify-deploy-preview.yml` files in the site repos,
and adds a check of the production deploy after every merge, which nothing
ran before.

The triggering event picks the mode:

| Event | Mode | Waits for | Then checks |
| ----- | ---- | --------- | ----------- |
| `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` |

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`,
`request-failed`, ...). Production mode exists because Netlify posts no
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.

### How the waits work

**Preview** is a port of the poll in nightowlstudiollc/amelia-boone's
`verify-deploy-preview.yml`. It reads the commit status rather than building
the preview URL by hand: that tells "still building" from "deploy failed",
and the checks run against the deploy Netlify published for this commit. A
`success` with no `target_url` fails rather than checking nothing.

It calls the **singular** `/commits/{sha}/status`, which keeps only the
latest status per context. The `head -1` after each `jq` is defensive and
does nothing on that endpoint. It is there because the plural
`/commits/{sha}/statuses` is one character away and returns full history:
every amelia-boone PR checked had two rows for the context. Fed that, the
script would read `success\npending`, match no branch, and poll to the
deadline on a deploy that succeeded (amelia-boone#50). Keep `head -1` if the
endpoint changes. `tests/test-wait-for-preview.sh` covers the two-row case
and refuses the plural endpoint.

**Production** reads the Netlify API with `NETLIFY_AUTH_TOKEN`. The token is
written to curl as a config on stdin (`curl -K -`) by `printf`, a bash
builtin, so it is never in any process's argv and never in a file on disk.
`-H "Authorization: Bearer $TOKEN"` would put it in argv. A failed request
counts as `request-failed` and the wait continues, so a bad token or a wrong
site name shows up in the timeout message.

API facts it relies on, checked on 2026-10-06 against the OpenAPI spec at
<https://open-api.netlify.com/swagger.json> (`site` and `deploy`
definitions) and against live responses for `ameliabooneracing`:

- `{site_id}` accepts `<name>.netlify.app` in place of the site UUID.
- A deploy has `context` (`production`, `deploy-preview`, ...), `state`,
`commit_ref` (the full SHA), and `error_message`.
- `GET /sites/{site_id}/deploys` lists newest first (descending
`created_at`). The script takes the first match, so a retried deploy wins
over an earlier `error` for the same SHA.
- `site.ssl_url` is the primary custom domain over https
(`https://ameliabooneracing.com`). `site.url` is the fallback.

### Setup

`.github/workflows/netlify-site-checks.yml` in the site repo. The canonical
copy is `netlify/caller-stub.yml`:

```yaml
name: Netlify Site Checks
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
statuses: read
jobs:
netlify-site-checks:
uses: smartwatermelon/github-workflows/.github/workflows/netlify-site-checks.yml@netlify-site-checks-v1
with:
netlify-site: ameliabooneracing
secrets:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
```

- With the caller job named `netlify-site-checks`, the status check is
`netlify-site-checks / site-check` (caller job / inner job).
- `statuses: read` is for preview mode, which reads commit statuses.
- 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.

### 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>` |
| `timeout-minutes` | number | `20` | Job timeout. The wait alone runs to 10 minutes. |

| Secret | Required | Description |
| ------ | -------- | ----------- |
| `NETLIFY_AUTH_TOKEN` | production only | Netlify personal access token. A `push` run without it fails with a message that says so. |

### Tests

`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.

### Versioning

| Tag | Meaning |
| ----- | --------- |
| `netlify-site-checks-v1` | Floating major for callers, moved manually and human-authorized, like `standards-check-v1`. |

Prefixed for the same reason as `standards-check-v*`: tags in this repo are
repo-scoped, not per-file.

---

## `claude-assistant`

Reusable workflow that invokes Claude Code Action. The caller handles triggers
Expand Down
16 changes: 16 additions & 0 deletions netlify/caller-stub.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: Netlify Site Checks
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
statuses: read
jobs:
netlify-site-checks:
uses: smartwatermelon/github-workflows/.github/workflows/netlify-site-checks.yml@netlify-site-checks-v1
with:
netlify-site: SITE-NAME
# check-script: scripts/check-deploy-preview.sh
secrets:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
66 changes: 66 additions & 0 deletions netlify/wait-for-preview.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Wait for the Netlify deploy preview of one commit; print its URL on stdout.
# Used by netlify-site-checks.yml. See README.md for details.

# Env: GITHUB_REPOSITORY, COMMIT_SHA, NETLIFY_SITE, GH_TOKEN (required).
# NETLIFY_POLL_INTERVAL / NETLIFY_POLL_DEADLINE: seconds (15 / 600).

# Exit 0 ready, 1 failed / no target_url / timeout, 2 bad input.
set -euo pipefail

: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
: "${COMMIT_SHA:?COMMIT_SHA is required}"
: "${NETLIFY_SITE:?NETLIFY_SITE is required}"
interval="${NETLIFY_POLL_INTERVAL:-15}"
deadline_secs="${NETLIFY_POLL_DEADLINE:-600}"

# The site name goes into a --jq string (no --arg there), so allow only
# Netlify's name charset.
if [[ ! "${NETLIFY_SITE}" =~ ^[a-z0-9-]+$ ]]; then
echo "::error::netlify-site '${NETLIFY_SITE}' is not a Netlify site name ([a-z0-9-]+)" >&2
exit 2
fi
if [[ ! "${COMMIT_SHA}" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::COMMIT_SHA '${COMMIT_SHA}' is not a 40-hex commit SHA" >&2
exit 2
fi

# The exact context Netlify posts, keyed by SITE name (not repo name).
status_context="netlify/${NETLIFY_SITE}/deploy-preview"

deadline=$((SECONDS + deadline_secs))

while ((SECONDS < deadline)); do
status_json="$(gh api \
"repos/${GITHUB_REPOSITORY}/commits/${COMMIT_SHA}/status" \
--jq ".statuses[] | select(.context == \"${status_context}\")" ||
true)"

# Defensive, not load-bearing on singular /status. Plural /statuses returns
# history, so keep head -1. amelia-boone#50; tested.
state="$(printf '%s' "${status_json}" | jq -r '.state // empty' | head -1)"
target="$(printf '%s' "${status_json}" | jq -r '.target_url // empty' | head -1)"

case "${state}" in
success)
if [[ -z "${target}" ]]; then
echo "::error::Deploy preview succeeded but reported no target_url." >&2
exit 1
fi
echo "Deploy preview ready: ${target}" >&2
printf '%s\n' "${target}"
exit 0
;;
failure | error)
echo "::error::Netlify deploy preview failed (state=${state})." >&2
exit 1
;;
*)
echo "Waiting for deploy preview (state=${state:-none})..." >&2
sleep "${interval}"
;;
esac
done

echo "::error::Timed out after ${deadline_secs}s waiting for the deploy preview (context ${status_context})." >&2
exit 1
Loading
Loading