Context
musher-dev/infra is standing up schemas.musher.dev (ADR 0001 follow-up 1, ADR 0006 follow-up 1). Every $id this repo publishes is a dead URL today — the hostname has never resolved, and gh api repos/musher-dev/spec/pages reports cname: null.
The infra side is written and its plan is verified clean; it is blocked only on a Cloudflare token (musher-dev/foundation-bootstrap#129). The origin is Cloudflare Pages, not GitHub Pages, which is a change to ADR 0001 §5 and needs a superseding ADR here.
This issue is deliberately one unit of work rather than four: items 1–3 must land together or the site ships without CORS.
Why the origin moves (for the ADR)
ADR 0001 §5 specified "a GitHub Pages artifact fronted by Cloudflare, which supplies the immutable cache headers Pages cannot set itself." Both halves of that cannot hold at once:
- GitHub cannot renew a custom domain's TLS certificate behind an orange cloud. Issuance and renewal resolve the hostname and require GitHub's own IPs in the answer; proxied, they see Cloudflare anycast.
github/pages-health-check#153 has been open since 2023, and a former GitHub Pages engineer's advice in-thread is to turn proxying off. It fails at ~90 days as a hard 525 on every published $id. But proxying is precisely what the immutable cache headers require.
- The workarounds are plan-gated. Origin Rules' Host-header, SNI and DNS-record overrides are Enterprise-only (only destination-port is not), and the
matches operator needed to separate /v1.2.3/ from /v1/ is Business+. The musher.dev zone is Free.
Cloudflare Pages dissolves both: Cloudflare terminates TLS for the hostname natively, so no third-party certificate exists to expire, and _headers sets everything at the origin. ADR 0006 already anticipated the move — "Revisit when publication outgrows Pages" — and its reasoning against R2 does not apply, since the artifact is still built in-repo by the same gated pipeline and only the upload target changes. The one genuine cost, which the ADR should state plainly: a push to main in a public repo now reaches a Cloudflare credential.
1. Switch the publish step
In .github/workflows/pages.yml, replace actions/upload-pages-artifact + actions/deploy-pages with:
- run: bunx wrangler pages deploy site --project-name=musher-schemas --branch=main
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
Keep everything else exactly as it is — fetch-depth: 0, task check:drift, task check:published, and the pages-deploy concurrency group. Those gates are what guarantee the committed bundles match their sources and that no released version's bytes have moved; none of that changes.
The environment: github-pages block and the pages: write / id-token: write permissions can go.
Secrets will be set by an org owner (foundation-bootstrap#129).
2. Generate _headers from site.ts — the important one
Cloudflare Pages merges all matching rules and comma-joins duplicate header names; a more specific rule does not override a general one. So the obvious shape — a broad pinned-path rule plus per-alias overrides — emits
Cache-Control: public, max-age=31536000, immutable, public, max-age=300, must-revalidate
which is garbage. The rules must be non-overlapping.
site.ts already enumerates every path it writes and already knows which are aliases and which are pinned, so it can emit one exact rule per published path with no new source of truth. That also removes a standing landmine: a future v2 alias would otherwise be served immutable for a year by any pattern-based rule that guessed wrong.
/*
Access-Control-Allow-Origin: *
X-Content-Type-Options: nosniff
/component/v1/component.schema.json
Content-Type: application/schema+json; charset=utf-8
Cache-Control: public, max-age=300, must-revalidate
/component/v1.0.0/component.schema.json
Content-Type: application/schema+json; charset=utf-8
Cache-Control: public, max-age=31536000, immutable
The /* rule is safe to overlap because no other rule sets those two headers.
Two things worth encoding as assertions in site.test.ts:
- CORS is now mandatory, not free. GitHub Pages sends
access-control-allow-origin: * on every response (verified). Cloudflare Pages does not. Without the /* rule, every browser-based validator, Swagger UI and Redoc fetch of these schemas breaks. This is the single most likely thing to be missed in review.
_headers caps at 100 rules. At 3 families that is ~97 pinned versions of headroom — years — but the build should assert the count and fail well before the ceiling rather than silently truncating.
For reference, application/schema+json + public, max-age=31536000, immutable is exactly what json-schema.org serves (verified live), so this matches the canonical registry.
3. Drop CNAME (and .nojekyll) from the published tree
site.ts writes CNAME, but GitHub's docs are explicit that for Actions-published Pages sites "no CNAME file is created, and any existing CNAME file is ignored and is not required." That is why cname is null today despite successful deploys — the file has never done anything.
On Cloudflare Pages it is meaningless and would be served as a static file at https://schemas.musher.dev/CNAME. .nojekyll can go too; Cloudflare Pages does not run Jekyll.
4. Supersede ADR 0001 §5
New ADR recording the two findings above and the credential-exposure consequence. Also closes ADR 0006 follow-up 1 — "Record the Cloudflare cache rule for the versioned path shape in this repository" — better than it asked for: the caching guarantee now lives in this repo's own _headers rather than in a cross-repo edge rule this repository cannot see.
Acceptance
Once infra has applied and this has merged:
curl -sI https://schemas.musher.dev/component/v1/component.schema.json
# content-type: application/schema+json; charset=utf-8
# cache-control: public, max-age=300, must-revalidate
# access-control-allow-origin: *
curl -s https://schemas.musher.dev/component/v1/component.schema.json | jq -r '.["$id"]'
# https://schemas.musher.dev/component/v1/component.schema.json
Pinned-path immutability cannot be checked until a family is tagged — all three read 0.0.0 and release PRs #1–#3 are still open.
Infra side: terraform/platform/cloudflare-zone/schemas.tf and docs/runbooks/schemas-domain.md in musher-dev/infra.
Context
musher-dev/infrais standing upschemas.musher.dev(ADR 0001 follow-up 1, ADR 0006 follow-up 1). Every$idthis repo publishes is a dead URL today — the hostname has never resolved, andgh api repos/musher-dev/spec/pagesreportscname: null.The infra side is written and its plan is verified clean; it is blocked only on a Cloudflare token (musher-dev/foundation-bootstrap#129). The origin is Cloudflare Pages, not GitHub Pages, which is a change to ADR 0001 §5 and needs a superseding ADR here.
This issue is deliberately one unit of work rather than four: items 1–3 must land together or the site ships without CORS.
Why the origin moves (for the ADR)
ADR 0001 §5 specified "a GitHub Pages artifact fronted by Cloudflare, which supplies the immutable cache headers Pages cannot set itself." Both halves of that cannot hold at once:
github/pages-health-check#153has been open since 2023, and a former GitHub Pages engineer's advice in-thread is to turn proxying off. It fails at ~90 days as a hard 525 on every published$id. But proxying is precisely what the immutable cache headers require.matchesoperator needed to separate/v1.2.3/from/v1/is Business+. Themusher.devzone is Free.Cloudflare Pages dissolves both: Cloudflare terminates TLS for the hostname natively, so no third-party certificate exists to expire, and
_headerssets everything at the origin. ADR 0006 already anticipated the move — "Revisit when publication outgrows Pages" — and its reasoning against R2 does not apply, since the artifact is still built in-repo by the same gated pipeline and only the upload target changes. The one genuine cost, which the ADR should state plainly: a push tomainin a public repo now reaches a Cloudflare credential.1. Switch the publish step
In
.github/workflows/pages.yml, replaceactions/upload-pages-artifact+actions/deploy-pageswith:Keep everything else exactly as it is —
fetch-depth: 0,task check:drift,task check:published, and thepages-deployconcurrency group. Those gates are what guarantee the committed bundles match their sources and that no released version's bytes have moved; none of that changes.The
environment: github-pagesblock and thepages: write/id-token: writepermissions can go.Secrets will be set by an org owner (foundation-bootstrap#129).
2. Generate
_headersfromsite.ts— the important oneCloudflare Pages merges all matching rules and comma-joins duplicate header names; a more specific rule does not override a general one. So the obvious shape — a broad pinned-path rule plus per-alias overrides — emits
which is garbage. The rules must be non-overlapping.
site.tsalready enumerates every path it writes and already knows which are aliases and which are pinned, so it can emit one exact rule per published path with no new source of truth. That also removes a standing landmine: a futurev2alias would otherwise be servedimmutablefor a year by any pattern-based rule that guessed wrong.The
/*rule is safe to overlap because no other rule sets those two headers.Two things worth encoding as assertions in
site.test.ts:access-control-allow-origin: *on every response (verified). Cloudflare Pages does not. Without the/*rule, every browser-based validator, Swagger UI and Redoc fetch of these schemas breaks. This is the single most likely thing to be missed in review._headerscaps at 100 rules. At 3 families that is ~97 pinned versions of headroom — years — but the build should assert the count and fail well before the ceiling rather than silently truncating.For reference,
application/schema+json+public, max-age=31536000, immutableis exactly whatjson-schema.orgserves (verified live), so this matches the canonical registry.3. Drop
CNAME(and.nojekyll) from the published treesite.tswritesCNAME, but GitHub's docs are explicit that for Actions-published Pages sites "noCNAMEfile is created, and any existingCNAMEfile is ignored and is not required." That is whycnameisnulltoday despite successful deploys — the file has never done anything.On Cloudflare Pages it is meaningless and would be served as a static file at
https://schemas.musher.dev/CNAME..nojekyllcan go too; Cloudflare Pages does not run Jekyll.4. Supersede ADR 0001 §5
New ADR recording the two findings above and the credential-exposure consequence. Also closes ADR 0006 follow-up 1 — "Record the Cloudflare cache rule for the versioned path shape in this repository" — better than it asked for: the caching guarantee now lives in this repo's own
_headersrather than in a cross-repo edge rule this repository cannot see.Acceptance
Once infra has applied and this has merged:
Pinned-path immutability cannot be checked until a family is tagged — all three read
0.0.0and release PRs #1–#3 are still open.Infra side:
terraform/platform/cloudflare-zone/schemas.tfanddocs/runbooks/schemas-domain.mdinmusher-dev/infra.