From 6a5d8929b6d91cb80d06ba7d9bced491cfa34c76 Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Tue, 28 Jul 2026 16:57:58 +0200 Subject: [PATCH 1/3] ci: smoke access-gated hosts with a service token --- .github/actions/deploy-worker/action.yml | 31 ++++++++++++++++++++++-- .github/workflows/deploy-dev.yml | 2 ++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/actions/deploy-worker/action.yml b/.github/actions/deploy-worker/action.yml index cfb7075b..09099290 100644 --- a/.github/actions/deploy-worker/action.yml +++ b/.github/actions/deploy-worker/action.yml @@ -24,6 +24,17 @@ inputs: cache_prefix: description: Prefix for the build-artefact cache key. required: true + 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 @@ -89,20 +100,36 @@ runs: # 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. + # propagation of a fresh version. 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 the check is skipped with a + # warning rather than failing on a response the Worker never saw. - name: Smoke the deployed Worker shell: bash env: BASE_URL: ${{ inputs.better_auth_url }} + ACCESS_CLIENT_ID: ${{ inputs.access_client_id }} + ACCESS_CLIENT_SECRET: ${{ inputs.access_client_secret }} run: | + auth_args=() + if [ -n "$ACCESS_CLIENT_ID" ]; then + auth_args=(-H "CF-Access-Client-Id: $ACCESS_CLIENT_ID" \ + -H "CF-Access-Client-Secret: $ACCESS_CLIENT_SECRET") + fi 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 -sS -o /tmp/smoke.json -D /tmp/smoke-headers "${auth_args[@]}" \ + -w '%{http_code}' --max-time 15 \ "$BASE_URL/api/auth/.well-known/oauth-authorization-server" || echo 000) [ "$code" = "200" ] && break sleep 5 done if [ "$code" != "200" ]; then + if [ -z "$ACCESS_CLIENT_ID" ] && \ + grep -qiE 'cloudflare-access|cloudflareaccess\.com|cf-mitigated' /tmp/smoke-headers; 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::Auth metadata returned $code after deploy; the Worker is serving errors." head -c 500 /tmp/smoke.json exit 1 diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 1b3f05ce..2e317f38 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -43,3 +43,5 @@ jobs: better_auth_url: https://dev.app.piyaz.ai deploy_script: deploy:cf:dev cache_prefix: deploy-dev + access_client_id: ${{ secrets.CF_ACCESS_SMOKE_CLIENT_ID }} + access_client_secret: ${{ secrets.CF_ACCESS_SMOKE_CLIENT_SECRET }} From d878560d32386bc465cbaa2b81f8fc98ef06c29f Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Tue, 28 Jul 2026 17:53:20 +0200 Subject: [PATCH 2/3] ci: gate the access smoke skip and widen probe coverage --- .github/actions/deploy-worker/action.yml | 92 +++++++++++++++++++----- .github/workflows/deploy-dev.yml | 1 + 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/.github/actions/deploy-worker/action.yml b/.github/actions/deploy-worker/action.yml index 09099290..f29c46ba 100644 --- a/.github/actions/deploy-worker/action.yml +++ b/.github/actions/deploy-worker/action.yml @@ -24,6 +24,13 @@ 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 @@ -99,42 +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. A host behind Cloudflare Access needs the + # 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 the check is skipped with a - # warning rather than failing on a response the Worker never saw. + # 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: | - auth_args=() + # 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 - auth_args=(-H "CF-Access-Client-Id: $ACCESS_CLIENT_ID" \ - -H "CF-Access-Client-Secret: $ACCESS_CLIENT_SECRET") + 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 -D /tmp/smoke-headers "${auth_args[@]}" \ - -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 [ -z "$ACCESS_CLIENT_ID" ] && \ - grep -qiE 'cloudflare-access|cloudflareaccess\.com|cf-mitigated' /tmp/smoke-headers; 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 + 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 diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 2e317f38..af0f4fbd 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -43,5 +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 }} From d6bd4af29549c8d0012e15ab17b944e302fb410c Mon Sep 17 00:00:00 2001 From: Furkan Akbulutlar Date: Tue, 28 Jul 2026 19:16:29 +0200 Subject: [PATCH 3/3] ci: report the workerd smoke green when its paths are untouched --- .../workflows/workers-smoke-not-required.yml | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/workers-smoke-not-required.yml diff --git a/.github/workflows/workers-smoke-not-required.yml b/.github/workflows/workers-smoke-not-required.yml new file mode 100644 index 00000000..473adb62 --- /dev/null +++ b/.github/workflows/workers-smoke-not-required.yml @@ -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."