You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
.github/workflows/purge-cdn.yml (added in #1189) purges the Cloudflare cache
after a site deploy, and gates the purge on a heuristic: it polls GET /__webjs/version on the Railway origin until the reported uptime is
lower than the time elapsed since the run began, inferring "the process
restarted for THIS push".
That heuristic is wrong, and it failed on its very first live run. Run 30532402978 on merge commit 93883bbc failed at the wait step, and the purge
never ran. The cause: that commit touched only .github/workflows/purge-cdn.yml
and framework-dev.md, and Railway recorded the deployment as SKIPPED
(deployment 6603bade-6d78-4965-aeee-84050e7a1fa5). No deploy meant no restart,
so the wait loop ran its full 15 minute budget and then failed by design.
The refusal-to-purge logic behaved correctly (purging while the origin still
serves old bytes would repopulate the edge with them). The DEFECT is the
assumption that every push to main produces a Railway deploy. It does not, so
the workflow now red-Xes on every docs-only or CI-only commit. A CI job that
cries wolf on routine commits is worse than no job, because the real failures
stop being noticed.
Meanwhile the original staleness is still live: webjs-lockup-on-dark.svg and webjs-lockup-on-light.svg are still the pre-#1185 masked versions at the edge,
so the mobile Safari logo bug still reaches visitors.
Design / approach
Stop inferring the deploy and ask Railway directly. Railway's public GraphQL
API exposes, per deployment, both a status and the originating commitHash,
which is precisely the signal needed. Confirmed against the live API (via the
Railway MCP list_deployments against this exact service):
6603bade | SKIPPED | 2026-07-30 09:52:06 UTC | 93883bbc <- the failing case
5ae88e19 | SUCCESS | 2026-07-30 09:19:08 UTC | 086b3c75
So the wait step becomes: poll for the deployment whose commitHash equals github.sha, then branch on its terminal status.
SUCCESS -> new bytes are live, purge.
SKIPPED -> Railway deliberately did not deploy this commit, so the origin
is unchanged and there is nothing stale to evict. Exit 0 with a notice. This
is the case that currently fails.
FAILED / CRASHED -> no new content reached the origin, so a purge would
only cost a cold cache for no benefit. Exit 0 with a warning.
No deployment record at all within the budget -> exit 0 with a warning rather
than failing, for the same reason.
This removes the uptime heuristic entirely, removes the false failures, and
makes the "did the content actually change" question exact instead of inferred.
workflow_dispatch keeps skipping the wait and purging immediately, which is
the manual escape hatch.
Implementation notes (for the implementing agent)
Where to edit: .github/workflows/purge-cdn.yml, the step named
"Wait for the new build to be live on the origin". Replace its body. The
"Purge the Cloudflare cache" and "Confirm the edge now matches the origin"
steps are unchanged, but the purge step must become conditional on the wait
step's outcome (use a step id plus an output such as should_purge=true|false, and gate the purge with if: steps.<id>.outputs.should_purge == 'true' || github.event_name == 'workflow_dispatch').
API details, all verified live:
Endpoint: https://backboard.railway.com/graphql/v2, HTTP POST, JSON body.
Auth: Authorization: Bearer <token> for an account token. A PROJECT token
(the narrower, preferred CI credential) authenticates with the Project-Access-Token: <token> header INSTEAD of Authorization. The
implementation should send the project-token header and fall back to
Bearer, because which one applies depends on the token type the owner
created, and this was not verifiable ahead of time (the local Railway CLI
credential is not valid for the public API, it returns "Not Authorized").
with variables {"first": 20, "input": {"projectId": ..., "environmentId": ..., "serviceId": ...}}.
The commit sha is on the node's meta object (meta.commitHash).
IDs for the website service (not secrets, they identify resources):
New secret required: RAILWAY_TOKEN. Document it in framework-dev.md
beside the existing CLOUDFLARE_API_TOKEN note, including which token type
and header. If the secret is absent, the step must fail with an explicit
message rather than silently skipping the purge.
Landmines:
Railway's API sits behind Cloudflare and returns error code: 1010 to
clients whose user agent looks automated (Python urllib is blocked; curl is fine). Use curl in the workflow.
A GraphQL error is returned with HTTP 200 and an errors array, so
checking the HTTP status is NOT sufficient. Assert on .errors == null
and on the parsed data, the same lesson as the --fail-with-body note
already in this workflow for the Cloudflare call.
Deployment status is not immediately present: the record for a brand new
push can take a few seconds to appear, so "no record yet" must keep
polling rather than concluding SKIPPED.
Keep the existing hardening from ci: purge the Cloudflare cache after each site deploy #1189: || true on every jq extraction
(a set -e abort on a transient non-JSON body was a real bug), the
deadline-driven loop rather than an iteration count, and permissions: {}.
Invariants: none affected (CI only, no runtime or public API surface).
Tests + docs: no unit test layer applies to a workflow. Validate the YAML
parses and every run block passes bash -n, and exercise the status
branching against recorded API responses (a SUCCESS commit and the known
SKIPPED commit 93883bbc) before merging. Update the framework-dev.md
"CDN cache" section, which currently describes the uptime heuristic and will
be stale.
Acceptance criteria
The wait step resolves the Railway deployment for github.sha and
branches on its real status
Problem
.github/workflows/purge-cdn.yml(added in #1189) purges the Cloudflare cacheafter a site deploy, and gates the purge on a heuristic: it polls
GET /__webjs/versionon the Railway origin until the reporteduptimeislower than the time elapsed since the run began, inferring "the process
restarted for THIS push".
That heuristic is wrong, and it failed on its very first live run. Run
30532402978on merge commit93883bbcfailed at the wait step, and the purgenever ran. The cause: that commit touched only
.github/workflows/purge-cdn.ymland
framework-dev.md, and Railway recorded the deployment as SKIPPED(deployment
6603bade-6d78-4965-aeee-84050e7a1fa5). No deploy meant no restart,so the wait loop ran its full 15 minute budget and then failed by design.
The refusal-to-purge logic behaved correctly (purging while the origin still
serves old bytes would repopulate the edge with them). The DEFECT is the
assumption that every push to
mainproduces a Railway deploy. It does not, sothe workflow now red-Xes on every docs-only or CI-only commit. A CI job that
cries wolf on routine commits is worse than no job, because the real failures
stop being noticed.
Meanwhile the original staleness is still live:
webjs-lockup-on-dark.svgandwebjs-lockup-on-light.svgare still the pre-#1185 masked versions at the edge,so the mobile Safari logo bug still reaches visitors.
Design / approach
Stop inferring the deploy and ask Railway directly. Railway's public GraphQL
API exposes, per deployment, both a
statusand the originatingcommitHash,which is precisely the signal needed. Confirmed against the live API (via the
Railway MCP
list_deploymentsagainst this exact service):So the wait step becomes: poll for the deployment whose
commitHashequalsgithub.sha, then branch on its terminal status.SUCCESS-> new bytes are live, purge.SKIPPED-> Railway deliberately did not deploy this commit, so the originis unchanged and there is nothing stale to evict. Exit 0 with a notice. This
is the case that currently fails.
FAILED/CRASHED-> no new content reached the origin, so a purge wouldonly cost a cold cache for no benefit. Exit 0 with a warning.
than failing, for the same reason.
This removes the
uptimeheuristic entirely, removes the false failures, andmakes the "did the content actually change" question exact instead of inferred.
workflow_dispatchkeeps skipping the wait and purging immediately, which isthe manual escape hatch.
Implementation notes (for the implementing agent)
.github/workflows/purge-cdn.yml, the step named"Wait for the new build to be live on the origin". Replace its body. The
"Purge the Cloudflare cache" and "Confirm the edge now matches the origin"
steps are unchanged, but the purge step must become conditional on the wait
step's outcome (use a step
idplus an output such asshould_purge=true|false, and gate the purge withif: steps.<id>.outputs.should_purge == 'true' || github.event_name == 'workflow_dispatch').Endpoint:
https://backboard.railway.com/graphql/v2, HTTP POST, JSON body.Auth:
Authorization: Bearer <token>for an account token. A PROJECT token(the narrower, preferred CI credential) authenticates with the
Project-Access-Token: <token>header INSTEAD ofAuthorization. Theimplementation should send the project-token header and fall back to
Bearer, because which one applies depends on the token type the owner
created, and this was not verifiable ahead of time (the local Railway CLI
credential is not valid for the public API, it returns "Not Authorized").
Query shape (Relay pagination,
edges { node { ... } }):with variables
{"first": 20, "input": {"projectId": ..., "environmentId": ..., "serviceId": ...}}.The commit sha is on the node's
metaobject (meta.commitHash).IDs for the website service (not secrets, they identify resources):
1d05ab75-b64e-4920-94ef-1ca7d04778fc2e30b2f6-d166-48d3-9ae7-17176fb0942f2eb044b7-8698-43d3-959b-37c8925eb653RAILWAY_TOKEN. Document it inframework-dev.mdbeside the existing
CLOUDFLARE_API_TOKENnote, including which token typeand header. If the secret is absent, the step must fail with an explicit
message rather than silently skipping the purge.
error code: 1010toclients whose user agent looks automated (Python
urllibis blocked;curlis fine). Usecurlin the workflow.errorsarray, sochecking the HTTP status is NOT sufficient. Assert on
.errors == nulland on the parsed data, the same lesson as the
--fail-with-bodynotealready in this workflow for the Cloudflare call.
push can take a few seconds to appear, so "no record yet" must keep
polling rather than concluding SKIPPED.
|| trueon every jq extraction(a
set -eabort on a transient non-JSON body was a real bug), thedeadline-driven loop rather than an iteration count, and
permissions: {}.parses and every
runblock passesbash -n, and exercise the statusbranching against recorded API responses (a SUCCESS commit and the known
SKIPPED commit
93883bbc) before merging. Update theframework-dev.md"CDN cache" section, which currently describes the uptime heuristic and will
be stale.
Acceptance criteria
github.shaandbranches on its real status
NOT fail the run (the ci: purge the Cloudflare cache after each site deploy #1189 regression)
RAILWAY_TOKENfails loudly rather than skipping silentlyworkflow_dispatchstill purges immediately with no waitframework-dev.mdupdated: the uptime heuristic description replaced,and
RAILWAY_TOKENdocumented with its token type and headerskips