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
99 changes: 91 additions & 8 deletions .github/actions/deploy-worker/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ inputs:
cache_prefix:
description: Prefix for the build-artefact cache key.
required: true
access_gated:
description: >-
Set to 'true' when the host sits behind Cloudflare Access. Only then may
the smoke downgrade an unreachable host to a warning; a public host always
fails hard.
required: false
default: 'false'
access_client_id:
description: >-
Cloudflare Access service token client id, for smoking a host that sits
behind Access. The token's Access policy must use the Service Auth
decision. Leave empty for a public host.
required: false
default: ''
access_client_secret:
description: Cloudflare Access service token client secret.
required: false
default: ''

runs:
using: composite
Expand Down Expand Up @@ -88,26 +106,91 @@ runs:
run: bun run ${{ inputs.deploy_script }}

# A deploy succeeds even when the Worker serves errors on every request, so
# the auth surface is probed before the job reports green. Retries cover
# propagation of a fresh version.
# the live deployment is probed before the job reports green. This covers
# what the pre-merge workerd smoke cannot: the real hostname, the real
# bindings, and the real secrets. A host behind Cloudflare Access needs the
# service-token headers to reach the Worker at all; without them the edge
# answers for every unauthenticated probe, so an Access-gated caller
# downgrades to a warning rather than failing on a response the Worker
# never saw. A public caller never takes that path.
- name: Smoke the deployed Worker
shell: bash
env:
BASE_URL: ${{ inputs.better_auth_url }}
ACCESS_GATED: ${{ inputs.access_gated }}
ACCESS_CLIENT_ID: ${{ inputs.access_client_id }}
ACCESS_CLIENT_SECRET: ${{ inputs.access_client_secret }}
run: |
# A successful Access handshake returns a live CF_Authorization JWT in
# the header dump, which is not registered with the log masker.
trap 'rm -f /tmp/smoke.json /tmp/smoke-headers /tmp/smoke-mcp.json' EXIT

if { [ -n "$ACCESS_CLIENT_ID" ] && [ -z "$ACCESS_CLIENT_SECRET" ]; } ||
{ [ -z "$ACCESS_CLIENT_ID" ] && [ -n "$ACCESS_CLIENT_SECRET" ]; }; then
echo "::error::Set both access_client_id and access_client_secret, or neither."
exit 1
fi

# --max-redirs 0 pins the no-replay invariant: curl strips credentials
# across a cross-host redirect but replays arbitrary -H headers, so a
# later -L would hand the service token to the redirect target.
curl_args=(-sS --max-time 15 --max-redirs 0)
if [ -n "$ACCESS_CLIENT_ID" ]; then
curl_args+=(-H "CF-Access-Client-Id: $ACCESS_CLIENT_ID"
-H "CF-Access-Client-Secret: $ACCESS_CLIENT_SECRET")
fi

# Access answers ahead of the Worker, so these markers mean the probe
# never reached the deployment. `cf-mitigated` is deliberately absent:
# it marks a WAF or bot challenge, which must stay a hard failure.
access_gate() {
grep -qiE 'cloudflare-access|cloudflareaccess\.com' /tmp/smoke-headers 2>/dev/null
}

code=000
for attempt in 1 2 3 4 5 6; do
code=$(curl -sS -o /tmp/smoke.json -w '%{http_code}' --max-time 15 \
code=$(curl "${curl_args[@]}" -o /tmp/smoke.json -D /tmp/smoke-headers \
-w '%{http_code}' \
"$BASE_URL/api/auth/.well-known/oauth-authorization-server" || echo 000)
[ "$code" = "200" ] && break
sleep 5
# Retries cover propagation of a fresh version. A 5xx is the Worker
# executing and failing, and an Access gate never clears on retry;
# neither gets better with another attempt.
if [ "$code" = 200 ] || [ "$code" -ge 500 ] || access_gate; then break; fi
[ "$attempt" = 6 ] || sleep 5
done

if [ "$code" != "200" ]; then
if [ "$ACCESS_GATED" = "true" ] && access_gate; then
if [ -z "$ACCESS_CLIENT_ID" ]; then
echo "::warning::$BASE_URL is gated by Cloudflare Access and no smoke service token is configured, so the Worker could not be probed. Set CF_ACCESS_SMOKE_CLIENT_ID/_SECRET to restore this check."
exit 0
fi
echo "::error::Cloudflare Access rejected the smoke service token ($code). Check the token has not expired and its Access policy uses the Service Auth decision."
exit 1
fi
echo "::error::Auth metadata returned $code after deploy; the Worker is serving errors."
head -c 500 /tmp/smoke.json
exit 1
fi
grep -q '"issuer"' /tmp/smoke.json || {
echo "::error::Auth metadata body is missing \"issuer\"."

# The value, not just the key: a wrong BETTER_AUTH_URL binding still
# serves well-formed metadata and breaks OAuth for every MCP client.
issuer=$(jq -r '.issuer // empty' /tmp/smoke.json)
if [ "$issuer" != "$BASE_URL/api/auth" ]; then
echo "::error::Auth metadata issuer is \"$issuer\", expected \"$BASE_URL/api/auth\"; BETTER_AUTH_URL is bound wrong."
exit 1
}
fi

# The product surface. A 401 proves the MCP module loaded and the
# bearer path ran; a 5xx here is a bundle that deployed and cannot
# serve the one endpoint agents call.
mcp_code=$(curl "${curl_args[@]}" -o /tmp/smoke-mcp.json -X POST \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
--data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
-w '%{http_code}' "$BASE_URL/api/mcp" || echo 000)
if [ "$mcp_code" != "401" ]; then
echo "::error::Unauthenticated POST /api/mcp returned $mcp_code, expected 401."
head -c 500 /tmp/smoke-mcp.json
exit 1
fi
3 changes: 3 additions & 0 deletions .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ jobs:
better_auth_url: https://dev.app.piyaz.ai
deploy_script: deploy:cf:dev
cache_prefix: deploy-dev
access_gated: 'true'
access_client_id: ${{ secrets.CF_ACCESS_SMOKE_CLIENT_ID }}
access_client_secret: ${{ secrets.CF_ACCESS_SMOKE_CLIENT_SECRET }}
34 changes: 34 additions & 0 deletions .github/workflows/workers-smoke-not-required.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Workers Smoke

# Required-check twin of workers-smoke.yml. Branch protection requires the
# "Serve the built Worker on workerd" context, and a path-filtered workflow
# that never starts leaves that context Expected forever, blocking any PR
# that touches none of the smoke's paths (e.g. a .github-only change). This
# job reports the context green for exactly the PRs the real workflow skips.
# The paths-ignore list MUST mirror workers-smoke.yml's paths list; on a PR
# touching both kinds of files both workflows run, this one passes instantly
# and the real smoke still gates.
on:
pull_request:
branches: [main]
paths-ignore:
- 'app/**'
- 'lib/**'
- 'components/**'
- 'middleware.ts'
- 'worker-cf.ts'
- 'next.config.ts'
- 'open-next.config.ts'
- 'wrangler.jsonc'
- 'scripts/smoke-workers.ts'
- 'package.json'
- 'bun.lock'

jobs:
smoke:
name: Serve the built Worker on workerd
runs-on: ubuntu-latest
timeout-minutes: 2
permissions: {}
steps:
- run: echo "No Worker-affecting path changed; the workerd smoke does not apply to this PR."