Deploy recipe
How the dogfood site (packages/site) is deployed: the verbs in order, what the
one secret is, what a dry run does, how to roll back, and what the grace period
protects. Issue #57. The landing page and the docs site are deployed to
Cloudflare as Workers Static Assets with wrangler, not through this CLI. Their
runbooks are the last two sections, "The landing page on Cloudflare" and "The
docs site on Cloudflare".
What has been proven, and what has not
Nothing here has been run against a real distribution, and nothing here has touched a cloud account. No CDN, no bucket, no DNS record and no credential exists in this repository. What has run is the presigned half of the pipeline against an S3 implementation, locally, with a credential minted for one run and thrown away. Read this as a pipeline whose uploads are proven and whose serving is not:
The deploy CLI against a presigned origin, and the snapshot transport, are what
pnpm test:origin-harness(packages/site/src/origin.harness.ts, #302) proves against a real S3 origin: SeaweedFS 4.47's S3 gateway (chrislusf/seaweedfs:4.47, pinned by digest). It first passed on 2026-09-28, and first passed through the spawned CLI on 2026-10-02 (#652). The harness starts the origin in Docker, over TLS, at a fixed address on an internal bridge network it creates. It signs real SigV4 URLs and runs the shippeddeploy.bin.jsas a child process, through both passes of "Deploying to a presigned origin" below.pagedeck store pushandpullrun throughrunCli. A passing run asserts that:- every uploaded object reads back byte-identical to the file the build wrote;
- a second deploy reads the live
manifest.jsonoff the origin with a signed GET, and puts only the files whose hash changed, the build's history copy, its deploy instant, the history index and the manifest; - a rollback reads the live manifest and the retained one off the origin's deploy history with signed GETs, and leaves the origin serving the first build's manifest and bytes;
- no access key, secret key or signed query string appears in the CLI's argv, stdout or stderr, on every run above and on the failures below;
- an origin error is the origin's real 403, to a PUT and to a GET, and the run exits 1;
- an
http:URL and a URL on127.0.0.1are refused with exit 2 before any request, and the origin still serves what it served before; - a presigned
DELETEtakes an object from 200 to 404, throughpresignedTargetdirectly; - a PUT URL signed by
presignRequestsfor one type, one cache policy and one MD5 refuses anotherContent-TypeorCache-Control(403SignatureDoesNotMatch), other bytes under the signedContent-MD5(400BadDigest), other bytes under their own MD5 or with no MD5 (403), and the object keeps its type, cache policy and bytes (#60); - three builds deployed to a bucket and to a directory origin, then pruned
with
--pruneon each, lose the same files: a page the second build dropped, and a passthrough file it dropped once its grace was backdated past. A file the third build dropped stays inside its grace on both, every document in the deploy history stays, and the live build reads back byte-identical (#659). The prune reads the history index and each document it names with signed GETs, and sends each DELETE to the URL signed for its key; - with the history index removed, the same
--prunerun deletes nothing, says why, and the apply writes a new index; - an index naming builds beside no
/manifest.jsonis refused as a damaged origin, exit 2, before anything is planned; pagedeck store pushthenpullround-trips the store byte-identical, and the target it prints carries no query string;- the loopback refusal still fires, with its full message, on the same URL
pointed at
127.0.0.1.
It skips with a stated reason where there is no Docker daemon, and
pnpm testdoes not run it.AGENTS.md, "The origin harness", says how to run it. One S3 implementation passing is not every S3 origin passing: neither AWS S3 nor Cloudflare R2 has been sent a request.It is not a CI service container, and cannot be one. A service container is reached on
localhost, which is the case the snapshot host refusal blocks with no override (CONTEXT.md), so the deploy half would pass andpagedeck store pushwould be refused. The harness creates, fills and starts a SeaweedFS container itself (docker create,docker cp,docker start) on a user-defined network with a private address instead, which the refusal does not name.The planner, the uploader seam and the rollback are exercised end to end, locally:
packages/site/src/deploy.build.test.tsbuilds a fixture site twice with one page's content edited between the builds, deploys into a directory standing in for the origin, serves it back over anode:httpserver bound to127.0.0.1, and rolls back to the retained build through the same seam.The
--edgecompile produces text and is asserted as text. A spawned dry run in the build test names every out-of-band artifact and writes none of them.The edge artifacts are asserted as compiled text and never as a live distribution — the standing decision that edge artifacts are emitted, not provisioned. SeaweedFS is not CloudFront: the edge function, cache behaviours, invalidation and propagation are unproven, and
--edge cloudfront-functioncompiles text that nothing here executes.--edge cloudflare-workeris the exception: itsworker.jsis run innode:vmagainst a stand-in for its R2 binding, bypnpm testand by the origin harness over what a deploy put into SeaweedFS. The Workers runtime has not run it. Nothing in this repository creates a CDN or cloud resource, except thatdeploy-landing.ymlanddeploy-docs.ymlpublish the landing page and the docs site withwrangler deploy(below)..github/workflows/deploy.ymlhas never deployed anything, and cannot until a maintainer adds the secret below and passesapply: true. It isworkflow_dispatchandrepository_dispatchonly; it is neveron: push. It still passes--origina directory. Moving it to a presigned origin needs a signing step that holds the credentials, and this repository has none..github/workflows/deploy-landing.ymlhas never deployed anything either. It publishes only withapply: trueand the twoCLOUDFLARE_*secrets set, and has run only aswrangler deploy --dry-run, locally, which sends nothing to Cloudflare. It does not usedeploy.bin.jsor the signing step,presign.bin.js, which the origin harness proves in regionauto..github/workflows/deploy-docs.ymlhas never deployed anything, on the same terms:apply: trueand the two secrets, and a localwrangler deploy --dry-runonly.The deferred prune reads the history back off a directory origin by listing it, and off a presigned origin through the history index (#659, below).
packages/site/src/deploy.test.tspins the arithmetic — three builds, a fresh runner each time, and the third deploy really deleting what the first left behind — anddeploy.build.test.tspins that the runnable files a real build's own document at the origin and that the retention store's reader opens it there. The origin harness runs the prune end to end against a real S3 origin, and compares what it deletes with a directory origin given the same history. Nothing lists the bucket.Spec §11's "publish in under 60 s" is a figure about a staging distribution. The build test measures the local publish and prints it rather than asserting on it, and so does the origin harness. Its figures are a round trip over a local bridge, not publish-to-live, so they do not settle #295.
The secret
One secret, PAGEDECK_SNAPSHOT_URL, and it is a presigned https: URL — not an
AWS access key, not a role to assume, not a credential chain. The transport
uses no SDK: a presigned S3 URL is an HTTPS GET and PUT, so an SDK would buy
nothing but a dependency and a set of credentials on the runner. Whoever holds
the real credentials signs a URL and stores the URL.
Two consequences worth knowing before storing one:
- Never name it on a command line.
pagedeck store pullandpagedeck store pushreadPAGEDECK_SNAPSHOT_URLwhen the command line names no URL, and that is the whole protection: an argument is in/proc/<pid>/cmdline, which is world-readable on a shared runner, and in therun:line CI echoes. Writingpagedeck store pull "$PAGEDECK_SNAPSHOT_URL"gets no benefit at all — the shell expands it beforepagedeckstarts. Giving both the variable and an argument is refused rather than ranked. - A presigned URL carries its credential in the query string. No message in the deploy path prints one, and CI secret masking will not save you if one is composed at run time rather than stored.
Loopback and link-local targets are refused with no override, so a job that runs
its object store as a service container on localhost cannot push.
In the workflow the secret is declared on three steps and nowhere else — the
gate, the snapshot pull and the snapshot push. A job-level env: is exported to
every step of the job, which would have handed a write credential for the
content store to actions/checkout, pnpm/action-setup, actions/setup-node,
actions/upload-artifact and pnpm install --frozen-lockfile — that is, to
every dependency lifecycle script in the workspace. That exposure is what
CONTEXT.md's Third-party actions are pinned to commit SHAs guards, and
pinning an action does not help against a package.
The pipeline, verb by verb
Run from packages/site, where pagedeck.config.ts is:
# 1. the content store, from the snapshot the secret points at
node ../core/dist/bin.js store pull
# 2. render, bundle and write the whole site into ./site
node ../core/dist/bin.js build
# 3. the plan: what would be uploaded, in what order, and what would be pruned
# <live-manifest.json> is <origin>/manifest.json, which the last deploy published
node ../core/dist/bin.js diff <live-manifest.json> site/manifest.json
# 4. the same plan, carried out — see "Dry run and --apply" below.
# No --from: the runnable reads <origin>/manifest.json for itself
node dist/deploy.bin.js --origin <dir> --staging <dir> \
--edge cloudfront-function
# 5. the store, back to where it came from, so the next run starts here
node ../core/dist/bin.js store pushStep 3 and step 4 read the same function: deploy.bin.js calls diffManifests
and recomputes nothing, so the document pagedeck diff printed and the plan the
deploy carries out are the same rows in the same order. Step 3 exists because a
person should be able to read the plan before anyone authorizes step 4.
--edge <target> is what compiles the routing document, and omitting it is
what leaves the plan with no edge group at all. It is a flag rather than a
config field because the host is not a property of the site: build.routing is
one declaration and an edge adapter compiles it for whichever host is serving it.
The targets are the names of the four adapters the deploy depends on,
cloudfront-function, netlify, nginx and cloudflare-worker; an unknown
one is refused with the list in the message.
Which build is live is read off the origin. applyPlan publishes
manifest.json with the site, last of all the keys it puts, so
<origin>/manifest.json is the previous build's document and deploy.bin.js
reads it back. Omit --from and that is what the plan diffs against; an origin
holding no manifest and no deploy history is a first deploy, which is what an
origin nothing has deployed to actually is.
An origin with a deploy history and no manifest is refused (#561). Every
document under <origin>/.pagedeck/manifests/ is a build an apply published before
it put manifest.json, so a history with no manifest beside it, or with a
dangling link in its place, is an origin that lost its manifest. Planned as a
first deploy, the prune would not know which build was live and could delete
the files it serves with no grace, so the run exits 2 before it plans, dry run
included, and uploads, deletes and prunes nothing. The refusal names the build
to put back: the one whose <build id>.deployed-at holds the newest instant,
since every apply writes one, rollbacks included. Copy its
<origin>/.pagedeck/manifests/<build id>.json to <origin>/manifest.json (the bytes
are the same), or pass --from that file for this run. When an instant there
cannot be read, the refusal names no build; copy the history file the last
apply's Filed this build into the origin's deploy history at … line names
instead. The next
apply publishes manifest.json again either way.
That is what makes the webhook path incremental. repository_dispatch carries
no inputs, so while the previous manifest could only arrive as an operator
input, every run on the trigger content editing actually uses planned a first
deploy — every file. --from is now an override rather than the only source,
for the cases the origin cannot answer: an origin this process cannot read, or a
deliberate re-deploy over an older build.
--from twice, or --from beside --rollback, is refused rather than
ranked. A repeated option used to take the last value in silence and a
--from beside a rollback was dropped in silence; both now name both values and
exit 2. A rollback's left-hand side is the live build read from --out, so
there is no second source for that side to rank against.
Which build is live is also what pagedeck diff refuses on. A build that was not
based on the build it is deploying over is a raced deploy. pagedeck diff reports it
and exits 2 rather than printing a document a pipeline would act on, and
deploy.bin.js now does the same on the writing path: the plan carries the
report, a dry run prints it as a RACED: line, and --apply refuses to upload
unless --force is passed beside it. pagedeck rollback has no such flag and neither
does a rollback plan, because a rollback is out of order by definition.
A forced pagedeck diff says so on the document it writes. pagedeck diff --force
prints "forced": true as the document's second key, straight after version;
every other pagedeck diff, and every pagedeck rollback, omits the key rather than
writing false. Nothing else in the document moves — the plan a forced run
prints is the plan the same pair of manifests would have produced anyway, and
DIFF_VERSION stays at 1, so a recipe reading from, to, trees and stats
reads exactly the keys it always did. It is there for the pipeline that archives
its diffs: the two manifests say which builds were compared and nothing at all
about which flags the run was given, so after an incident this key is the only
record that the raced-deploy check was skipped rather than passed.
Reading an archive that predates this release: absence means "unforced or
older than this". Every diff written before the key existed omits it, forced
or not, and there is nothing in the document that separates the two — the
version did not move, for the reason above. So a pipeline that has archived
diffs across the upgrade should read a missing forced as "no override
recorded" rather than as "no override happened", and date the boundary from the
release it upgraded on.
What pagedeck build writes outside the output tree
Step 2 above writes the site into ./site, and that directory is what a deploy
publishes. Two things a build produces are deliberately not in it, and both
are anchored at the site directory — the one holding pagedeck.config.ts — rather
than at outDir:
.pagedeck/manifests/— the retention store (#32), read back bypagedeck rollback. Its size grows withbuild.retention.keep. A deploy does not publish it: what reaches the origin is one document per apply, the manifest of the build that apply published, with a one-line file beside it holding the instant it was deployed. The origin's copy is bounded by nothing (#287, #403, and "The grace period" below)..pagedeck/budget-report.json— the per-page size report (#21, #336), written by any build that declaresbuild.budgetorbuild.criticalCss, including a build that fails on a breach.
The placement is about the second kind of deploy, not the first. The
pipeline above is manifest-driven: pagedeck diff and deploy.bin.js upload the rows
in Manifest.files, plus manifest.json, a second copy of it under
.pagedeck/manifests/ and the instant that copy was deployed, so a file outside
those lists is never sent no matter where it sits on disk. Nothing reads this
site's .pagedeck/ directory to decide what to upload — the second copy is the
document the deploy already holds, filed under a second key — so the budget
report beside it is not on the list and cannot be. A directory-sync host —
Netlify or Vercel with a publish directory, or
aws s3 sync site/ — reads no manifest and copies the tree, so for that host
the filesystem is the upload list. The budget report names the build id, the
build time, every budgeted page's limit and spend, and every emitted chunk's
path and size; inside outDir it answered GET /budget-report.json for anyone.
Under .pagedeck/ there is nothing for either deploy to exclude — for the layout step
2 above sets up, and for every site in this repo: outDir inside the site
directory.
That premise is the whole of the guarantee, so it is worth stating rather than
implying. build.outDir is yours to point anywhere; the build checks only that
it is a string. A site that aims it at the site directory itself, or at an
ancestor of it, puts .pagedeck/ back inside the tree a directory-sync host copies.
Nothing in the framework refuses that layout — a site publishing its own source
directory is already publishing pagedeck.config.ts — so read the placement as what
it is: the report is outside the directory you publish, not outside every
directory you could name.
A full build removes what the previous build wrote and this one did not
(#515), which is what lets a directory-sync host drop a retracted page. Such a
host publishes whatever is in the tree, so a post set to draft: true or
deleted since the last build would stay at its old URL for as long as its file
stayed in outDir. pagedeck build reads the manifest.json already in outDir,
writes its files and its own manifest, and then deletes every file the old
manifest named and the new one does not: the same prune
pagedeck build --incremental makes. A file you put in outDir by hand is in no
manifest and is never deleted, and a directory the prune empties is left in
place. The host still has to delete on its side: aws s3 sync removes a file
from the bucket only when you pass --delete.
A manifest-driven deploy retracts a page only when it runs with --prune
(#555). It never uploads the page again, but the copy the previous deploy put
at the origin stays there until a prune deletes it. With --prune, that
happens in the same run, because a page has no grace period ("The grace
period" below). Without it, the page is served at its old URL until a later
deploy passes --prune. A CDN in front of the origin can serve its cached copy
for as long as its own TTL after that.
The prune needs a manifest this pagedeck can read and trust. After an upgrade that
changed the manifest version, or over a manifest.json edited by hand, the
build still succeeds but deletes nothing, and writes a warning that starts
Output "<outDir>":. Before a directory-sync deploy of that tree, delete the
stale pages yourself, or point build.outDir at a new, empty directory and
build again. Do not empty outDir blindly: nothing stops it being the site
directory or one above it. A manifest-driven deploy has nothing to do, because
it never uploads a file the manifest does not name.
.pagedeck/ is one directory a site's .gitignore names once, which is why both live
under it rather than taking a top-level entry each. Neither is derived from the
other and neither is site content, so a CI job that wants to keep them keeps
them as build artifacts.
Dry run and --apply
A dry run is what you get. deploy.bin.js prints the plan and writes
nothing unless --apply is on the command line, and there is no config field
and no environment variable that changes that. The reasoning is the snapshot
host refusal's: a switch a CI file can set by accident is a switch that deploys
by accident.
node dist/deploy.bin.js --origin ../../.origin # prints the plan
node dist/deploy.bin.js --origin ../../.origin --apply # writes it
node dist/deploy.bin.js --origin ../../.origin --apply --prune # and deletes
node dist/deploy.bin.js --origin ../../.origin --apply --force # over a race--prune and --force are not second ways out of the dry run. Neither
moves a byte without --apply beside it: --prune asks for the delete pass
that spec §11 puts after the grace period, and --force only lifts the
raced-deploy refusal. The count of switches that can deploy by accident stays at
zero.
The exit code comes from the class (docs/error-messages.md rule 7). A bad
command line, an unreadable manifest, an unsupported --edge target and a
refused raced deploy are ConfigError and exit 2, which promises CI that
retrying will not help. Everything else — an I/O failure reading the build's
bytes, an HTTP failure from a presigned target — exits 1, where a retry may.
@pagedeck/site depends on @pagedeck/core, so both the class and EXIT_CODES are
imported rather than spelled as numbers.
The runnable is node dist/deploy.bin.js and nothing else. There is no
pnpm deploy: no packages/*/package.json in this workspace carries a
scripts field — the root holds every runnable — and the name would collide
with pnpm's own pnpm deploy besides. The workflow and this recipe invoke the
file directly.
The plan names every file rather than counting them, because the question it answers is "is that the file I changed" — and it leads with the counts and the byte totals, so a deploy that is unexpectedly the whole site is visible in the first line.
--origin is a directory. A presigned origin is not a second spelling of
--origin: it is PAGEDECK_DEPLOY_URLS, a file of presigned URLs, described in
the next section. Giving both is refused rather than ranked, and exits 2.
What the workflow adds is two locks, not a third mode.
.github/workflows/deploy.yml guards every writing step on apply: true and
on PAGEDECK_SNAPSHOT_URL being set. With no secret configured — this repository's
state — a run pulls nothing, syncs the site's own entries instead, builds, prints
the plan, uploads it as a build artifact and finishes green. A repository_dispatch
run (the CMS webhook) never reaches the writing steps at all, whatever its
payload says: inputs is empty on that event, and the guards test inputs.
Deploying to a presigned origin
deploy.bin.js deploys to a bucket through presigned URLs, one per object and
per method, minted by the operator's own signing step (#652). It holds no
credential, uses no SDK and knows no provider. Any host that answers an S3-style
presigned GET and PUT is a target, and a prune also sends a presigned DELETE.
The URLs arrive in a file, and the file's path in PAGEDECK_DEPLOY_URLS. Not
on the command line: an argument is in /proc/<pid>/cmdline and in the run:
line CI echoes, the reason pagedeck store reads PAGEDECK_SNAPSHOT_URL (see "The
secret"). Not the JSON itself in the variable: a site of a few hundred files
passes Linux's 128 KB limit on one environment string. The file is keyed by
deploy key:
{
"get": { "/manifest.json": "https://…", "/.pagedeck/deploy-history.json": "https://…" },
"put": { "/index.html": "https://…", "/manifest.json": "https://…" },
"delete": { "/old/index.html": "https://…" }
}delete is there only for a run with --prune ("Pruning a presigned origin"
below).
Signing takes two passes, because the keys are not known until the build. A content-hashed asset is named by its bytes, and which keys a deploy writes depends on what is live.
- Sign a GET for
/manifest.jsonand one for/.pagedeck/deploy-history.json, the two keys every run reads, and write them to the file. A rollback also reads/.pagedeck/manifests/<build-id>.json, so sign a GET for that too. - Run the dry run with
--requests <file>. It plans against the live manifest it read and writes every request an apply would send, in the same shape: each GET key, each PUT key with thecontentTypeandcacheControlthe PUT will carry and the base64contentMd5of its body, and with--pruneeach DELETE key. The deploy instant has nocontentMd5: its bytes are the time of the apply. "What a signed PUT binds" below says what the signer does with them. - Sign every request in that file, write the URLs over the same keys, and run
--applywithPAGEDECK_DEPLOY_URLSpointing at the result.
PAGEDECK_DEPLOY_URLS=reads.json node dist/deploy.bin.js --requests requests.json
# sign every key in requests.json into signed.json
PAGEDECK_DEPLOY_URLS=signed.json node dist/deploy.bin.js --applyIf the live manifest changes between the passes, the apply plans different keys, finds no URL for some of them, and is refused before it sends anything. Run both passes again.
What is refused, before any request. The file is read and checked whole before the first GET, and every fault is listed at once, by method and key, never by URL:
- a URL that is not
https:; - a URL with a user name or password before its host, which
fetchwould refuse with a message quoting the URL whole; - a URL on a loopback or link-local host, the snapshot host refusal's named
cases (
localAddressKind,packages/core/src/snapshot.ts). A private address passes, as it does for a snapshot; - a URL whose decoded path does not end in the key it is listed under;
- a URL whose host, or whose path before the key, differs from the other URLs
in the file. A URL signed for
/en/index.htmlalso ends in/index.html, and this is what tells the two apart; - a field other than
get,putanddelete, a key not starting with/, a key with a.,..or empty segment, a backslash or a control character, and a value that is not a string.readManifestrefuses a manifest whose file row spells such a key, and so does the signer, so no deploy or prune, on either kind of origin, acts on a key that resolves to another one; - a
deleteURL for/manifest.json, or for/.pagedeckor any key under it. The prune deletes only files a build served, never a key the deploy writes for itself.
An apply then plans, and is refused before the first PUT if any key it writes, or any key its prune deletes, has no URL. Each PUT and each DELETE goes to the URL listed for its key and to no other. A malformed file is refused without the JSON parser's message, because that message quotes the file.
--requests is for a dry run against a presigned origin only. Beside --apply, or
with --origin, it is refused, and so is a --requests naming the file
PAGEDECK_DEPLOY_URLS names, by its path or through a link, which the dry run
would write over.
Which build is live is read with a signed GET. A 404 on /manifest.json is
a first deploy, unless the history index names a build: then the origin lost
its manifest, and the run is refused before it plans, as #561 refuses a
directory origin. Put the document of the build the origin last served back at
/manifest.json, or pass --from a copy of it. That build is the one whose
.deployed-at holds the newest instant. Any other failure is an error, exit 1,
and names the key. --from still overrides the read.
No URL is in any message. A presigned URL carries its
credential in the query string, so every message names the deploy key, and the
success lines say presigned https target. A key or field the file holds is
quoted through redactTarget, so a map written backwards does not print the
URL standing where a key belongs. A request that fails keeps its cause, with
the URL and its query string cut from every message in the chain.
Pruning a presigned origin
A presigned origin cannot be listed, so the deploy keeps its own list (#659).
Per-object presigned URLs cannot list a prefix, and Cloudflare R2 presigns
GET, HEAD, PUT and DELETE only, with no list operation. So every apply, to any
origin and rollbacks included, also puts the history index,
/.pagedeck/deploy-history.json:
{"builds":["<build id>","<build id>"]}It names every build in the origin's deploy history and the build the apply
deploys, sorted. It goes after the build's deploy instant and before
manifest.json, so it never names a build whose document is not up yet. When
each build was deployed is not in it: that is the build's .deployed-at file
("The grace period" below), and the prune reads that file, as a directory prune
does. On a directory origin the index is written from the directory's listing.
On a presigned origin it is the index the run read, plus the build read off
/manifest.json, plus the build deployed.
An index is refused, naming the cap, when it names more than 100,000 builds,
ten deploys a day for 27 years; when an id is longer than 243 bytes, the most a
<build id>.deployed-at file name can hold; or when its body is longer than
the 24,600,014 bytes those two caps allow, which the read stops at. A refused
index stops every deploy to the origin until it is fixed, so the caps sit
beyond any history an origin reaches.
A prune against a presigned origin reads the index, never a listing. With
--prune, the run reads the index, then each document it names and each
document's deploy instant, all with signed GETs. Then it plans with the same
retained prune a directory origin runs, with the same grace periods. A document
that is absent or does not parse is left out, as the directory's reader leaves
it out. The DELETE keys go into the --requests file for the signer.
That takes one more signing pass than a deploy, because the documents to read are known only once the index is read:
# GETs signed for /manifest.json and /.pagedeck/deploy-history.json
PAGEDECK_DEPLOY_URLS=reads.json node dist/deploy.bin.js --prune --requests requests.json
# sign every key in requests.json into history.json: it now lists the history's GETs
PAGEDECK_DEPLOY_URLS=history.json node dist/deploy.bin.js --prune --requests requests.json
# sign every key in requests.json into signed.json: it now lists the DELETEs
PAGEDECK_DEPLOY_URLS=signed.json node dist/deploy.bin.js --prune --applyThe first dry run says Prune: deletes nothing — the prune reads <n> keys of the origin's deploy history that PAGEDECK_DEPLOY_URLS holds no GET URL for … and
lists those keys as GETs. The second plans the prune and lists its DELETEs. If
a key's grace runs out between the second dry run and the apply, the apply
finds no DELETE URL for it and is refused before it sends anything. Run the
passes again.
An origin with no index prunes nothing, and says so. An origin last deployed
by a pagedeck older than the index has none. The run does not guess a history:
it prints Pruned nothing: the origin holds no history index at …, and its
apply writes an index naming the build it read off /manifest.json and the
build it deployed. Prune on a run after that one.
A file no document in the index names is never deleted, because without a
listing the deploy cannot know it exists. That covers files only builds
deployed before the origin's first index named, and files put at the origin by
hand. Remove them by hand if the bytes matter: list the bucket, and delete what
no document under /.pagedeck/manifests/ names and manifest.json does not
serve.
The index is a read-modify-write, and a deploy that loses the race leaks.
A DeployTarget has no compare-and-swap, so two applies that read the same
index each write it with only their own build added, and one entry is lost. The
workflows serialize applies with a concurrency group. A lost build's document
stays in the history, and the next deploy over that build adds it back from
/manifest.json when it is the build being served. Until then, a key only that
build named is not found, which leaks rather than deletes. A key it shared with
an older build can be blamed on a build stamped between the two, and get that
build's earlier deadline. That is the early-deadline fault "The grace period"
already describes for builds deployed out of stamp order.
The prune never deletes a key the deploy writes for itself. A DELETE for
/manifest.json or under /.pagedeck/ is refused three times: as an entry in
the URL file, by the run before any request when its plan names one (only a
document written by hand can), and by presign.bin.js as an entry in the
requests file. The deploy history is never pruned, on any origin ("The grace
period" below).
Types and cache policy
Every object the deploy writes carries a Content-Type and a
Cache-Control that the deploy decides (#560). The storage host does not
guess them. Without a type, the harness origin served JavaScript and CSS as
text/plain, which a browser refuses as a module script or a stylesheet. AWS S3
stores an untyped object as binary/octet-stream, and that includes HTML.
Where they come from. packages/site/src/deploy-metadata.ts maps the
manifest row of each file. DeployTarget.put receives the result, and
presignedTarget sends it as the Content-Type and Cache-Control request
headers. The filesystem target has no place to keep them and ignores them.
The type comes from the row's kind first and its extension second:
| Row | Content-Type |
|---|---|
kind html |
text/html; charset=utf-8 |
kind js |
text/javascript; charset=utf-8 |
kind css |
text/css; charset=utf-8 |
.json (manifest.json and .pagedeck/manifests/*.json too) |
application/json; charset=utf-8 |
.xml |
application/xml; charset=utf-8 |
.txt, *.deployed-at, _headers, _redirects |
text/plain; charset=utf-8 |
.woff2 |
font/woff2 |
.png, .webp, .avif |
image/png, image/webp, image/avif |
.jpg, .jpeg |
image/jpeg |
.svg |
image/svg+xml |
.ico |
image/x-icon |
| any other extension | application/octet-stream |
An asset row gets its type from its extension. A file with an extension the
table does not know is sent as application/octet-stream. After the upload,
the run prints a Deploy: sent N files as application/octet-stream line and
names each file. A browser downloads such a file and does not display it, so
rename the file, or add its extension to the table.
The cache policy is the ruling on #560:
public, max-age=31536000, immutablefor a file whose name carries its content hash. When the bytes change, the name changes too.no-cachefor everything else: every page,manifest.json, the history documents under.pagedeck/manifests/, the edge tree files, and every asset whose name is not hashed.
How "hashed" is decided. The build decides it, and the deploy does not
read file names. When the build names a file from its bytes, it records
"hashed": true on that file's manifest row (ManifestFile.hashed, #560):
- every chunk the bundler names through its
[name]-[hash]pattern, and every stylesheet and asset (an image, a font) it names that way; - every file named by
shortHash: font subsets, the font stylesheets and social images.
A page, a passthrough file and an asset a plugin emits under a name of its own
never carry the column. The deploy gives immutable to a row with
"hashed": true and no-cache to every other row, a page always. A manifest
written before the column existed has no such rows, so it gets no-cache
throughout. That costs a conditional request per file, and it never serves a
stale file. The column did not move MANIFEST_VERSION (#560): a reader that
misses the key gives the row no-cache whichever reading of the absence is
true, and that is correct for both, while a bump would make every retained
document a refusal at readManifest and so refuse a rollback over a key it can
do without.
For whoever signs the URLs. PresignedUrls.put(key, metadata) receives the
two values before the PUT is sent, and the CLI's --requests file lists them
for each key, with the body's MD5. The PUT also sends Content-MD5.
presign.bin.js signs content-type and cache-control into every PUT URL,
and content-md5 into every PUT URL whose request carries an MD5: every file
but the deploy instant ("What a signed PUT binds" below). A URL signed over
host alone, like most of the harness's, still works, and S3 stores the headers with the object all the
same, but such a URL writes any bytes with any type and cache policy for as
long as it lives.
What a signed PUT binds
A presigned PUT URL that signs only host lets whoever holds it write any
bytes with any Content-Type to its key until it expires (#60). The
cloudflare-worker Worker serves the stored type, so a leaked URL is a stored
XSS. A URL that leaves Cache-Control free can store a long immutable on a
page, so a later retraction of it never reaches a cache that kept it (#555).
presign.bin.js therefore signs the headers of each PUT from the requests file,
and the deploy sends each with exactly that value:
content-type. Cloudflare's R2 presigned URL page: "Specify the allowedContent-Typein your SDK's parameters. The signature will include this header, so uploads will fail with a403/SignatureDoesNotMatcherror if the client sends a differentContent-Typefor an upload request." (https://developers.cloudflare.com/r2/api/s3/presigned-urls/). AWS's upload guide says the same of S3: "Make sure the content type in your upload request matches the content type specified when generating the URL" (https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html).cache-control. No R2 or S3 page speaks to signingCache-Controlin particular. It is a signed header like any other: SigV4 puts each signed header's value in the canonical request, so a request that sends another value does not match the signature. The harness proves the refusal on SeaweedFS only.content-md5, the base64 MD5 of the body, in every PUT URL whose request carries one: every file but the deploy instant. A signed header must arrive with the value signed, so the URL takes only that MD5, and the host checks the body against it. AWS: "After uploading the object, Amazon S3 calculates the MD5 digest of the object and compares it to the value that you provided. The request succeeds only if the two digests match." (https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity-upload.html). R2's S3 API compatibility table listsContent-MD5as implemented forPutObject(https://developers.cloudflare.com/r2/api/s3/api/).
Why not a SHA-256. R2's table lists no x-amz-checksum-* header for
PutObject, and lists SHA-256 for composite (multipart) checksums only, so
x-amz-checksum-sha256, which S3 does verify on a single-part upload, is not
documented to bind anything on R2. A hex x-amz-content-sha256 in place of
UNSIGNED-PAYLOAD is documented by neither for a presigned URL, and SeaweedFS
4.47, the harness's origin, stored a different body under one when #60 tried
it. MD5 is broken for collisions, not for
second preimages, and binding a URL to a known body needs the second.
What stays unbound. The deploy instant
/.pagedeck/manifests/<id>.deployed-at gets a signed type and cache policy and
no MD5, because its bytes are the clock of the apply, which the dry run cannot
know. presign.bin.js refuses any other PUT in the requests file that has no
contentMd5, and names each one: run the dry run again on the same build and
sign the file it writes. GET and DELETE URLs sign host alone.
A rebuild between the passes is refused. The MD5s come from the bytes the
dry run read. If the apply reads other bytes for a key, the host answers 403,
and the deploy stops with the host answered 403 to PUT. Run both passes
again on the same build. The history index is planned from the history the dry
run read, so a deploy that lands between the passes makes the apply's index
differ, and it is refused the same way.
These are the headers the object is stored with. The headers an edge adds at
serve time (_headers, the CloudFront function) are routing's, and this does
not change them.
Webhook and debounce
The webhook a CMS calls is repository_dispatch with type content-published.
The debounce is a concurrency group, and the group carries whether the run
can write:
- A run that cannot write joins
deploy-dogfood-site-dry-runwithcancel-in-progress: true, so a burst of publish events collapses to one run of the latest. That is correct for a dry run because of what it is derived from — the store snapshot and the two manifests, both read at the start of a run, neither accumulated across runs — and because it uploaded nothing, so there is nothing half-done to inherit. - A run that can write joins
deploy-dogfood-site-applyand is never cancelled. "A cancelled run uploaded nothing" is false for an applying run:applyPlanwalks the plan in spec §11's order — assets, then HTML, then the build's manifest, filed once into the origin's deploy history and once asmanifest.json, with the deploy instant between the two — so a cancel landing between two phases leaves pages live pointing at chunks that were never uploaded, which is exactly the half-published state the ordering exists to prevent. A webhook dry run could have delivered that cancel while the two shared one group.
A second applying run therefore queues behind the first rather than interleaving its uploads into one origin, and the raced-deploy refusal is underneath it either way.
The grace period
A deploy never deletes on its own. applyPlan uploads and stops; the prune
is a separate call, and it deletes nothing before a deadline. For a hashed
js, css or asset file, that deadline is the instant a build was deployed
plus graceSeconds, and the default is 604800 seconds — seven days. For a
page it is that instant with nothing added. Which build's is the whole of what #287
settled, and the answer is further down this section.
--prune is what asks for that call, and it is behind --apply like every
other write. It runs after every upload has succeeded, which is spec §11's third
phase and not a tail of the second. The workflow passes it on the apply step.
What it deletes is named in the plan the run prints, before the write and not
only after it: a due line per key going now and a hold line per key still
inside its window, each carrying the build that dropped it and the deadline that
build set. A dry run prints the same lines and deletes nothing, so the deletion
half of a deploy is as readable in advance as the upload half.
What it protects is a visitor holding a page from a minute ago whose browser is still asking for the hashed chunk this build replaced. Delete on the way out and that reader gets a 404 for a script the page they are looking at needs.
A page has no grace period (#555). A manifest row of kind html that the
new build no longer emits is not a chunk any page asks for, and when it was
dropped on purpose, as a retracted post is, the author wants it gone. So the
deploy that drops a page deletes it in the same run when it passes --prune,
after every upload has succeeded, and prints it on a due line. The hashed
chunks the same build replaced get their hold lines and their seven days. A
deploy without --prune still deletes nothing, page or chunk, so a retraction
needs --prune. Such a run says so in its last line, for example
Prune 1 page on the next --prune run, and 3 files no earlier than <deadline>. The origin is not the last copy: a CDN in front of it keeps
serving what it cached for as long as the CDN decides. The deploy writes every
page with Cache-Control: no-cache (#560, "Types and cache policy" below), so a
CDN that honours the origin's header asks the origin before it serves a page
again; one configured to override it keeps its own TTL.
Before a row's deadline the prune deletes nothing and answers with nothing, and the run says which of the two it was. That is a result and not an error: a pipeline that runs the prune on every deploy and finds every window still open has done the right thing, and refusing would force the pipeline to compute the date for itself.
A file's deadline belongs to the build that stopped serving it, not to the build running the prune. That is what #287 changed, and until it landed the prune in a workflow run was a no-op on every run: the only list a deploy could compute was the difference between the live build and this one, whose window this very deploy opens, so with the seven-day default the run holding the list was always inside it. Nothing stale was ever deleted, because the run that could delete it was the run that had just replaced the manifest describing it.
So the deploy files each build it publishes into a history at the origin, and
the prune reads that history back. applyPlan puts the build's own
manifest.json at <origin>/.pagedeck/manifests/<build id>.json as well — the same
bytes, under the same relative path a site's retention store has, immediately
before it publishes manifest.json itself. Every run reads that directory back
before it plans, through listRetainedManifests, the same door pagedeck rollback
reads a local store through. It then walks the history newest first: the first
build that no longer emits a key is the build that stopped serving it, so the
seven-day window on that key opened when that build was deployed. A key
dropped ten days ago is deleted today; a key this deploy dropped is held. There
is no state a CI runner has to keep between jobs, which is what makes this work
on a fresh checkout that holds one manifest of its own.
When a build was deployed is on record beside its document, not in it
(#403). Each apply also puts <origin>/.pagedeck/manifests/<build id>.deployed-at,
one line holding the ISO-8601 instant of that deploy, after the document and
before manifest.json. A build deployed again overwrites it. It is a separate
file because the document has to stay byte-identical to the manifest.json it
was deployed as, and it is one file per build rather than a field of the
history index because a DeployTarget has no compare-and-swap: the index is a
read-modify-write two deploys can each lose half of, and an instant lost that
way would shorten a grace period. The index only names builds, and what losing
one costs is in "Pruning a presigned origin" above. The name does not
end in .json, so the retention store's listing never opens it. The window
opens at that instant, and not at the build's createdAt, because a build waits
between being stamped and being deployed: an approval gate, a paused
pipeline, an old artifact put back. Timed from the stamp, b2 stamped on
1 January and deployed on 1 February gave the files it dropped a deadline of
8 January, already past when they stopped being served.
The build being deployed is the exception. Its instant is written by the apply that is about to run, so when it goes over a different live build the plan times it from the moment the deploy runs, whatever the origin holds for it. That covers an old artifact deployed here for the first time: re-deploying a build stamped on 1 December over a November build and a January one would otherwise time the November build's files from 1 December and delete them on the spot.
A file there that holds no instant is reported with the plan, on a
Deploy history: line naming it, and its build falls back to the stamp.
Rewrite it as the ISO-8601 UTC instant the build was deployed, with or without
milliseconds, or delete it to accept the stamp.
The instant does not fix the order, and the order can err early. The
history is still walked by createdAt, and an instant says when a build went
up, not which build it followed. When builds were deployed in a different order
from the one they were stamped in, and neither is the build being deployed or
the one it replaces, a file can be blamed on the wrong build: b2 stamped in
November but deployed on 15 December, after a build stamped on 1 December and
deployed on the 2nd, has its files timed from 2 December although b2 served
them until the deploy after it. The deadline is then early by the gap between
the two. A rollback is the same fault in the late direction, described below.
One document per apply, and it is the document being deployed. That is the whole of what makes the walk above sound, and it was got wrong the first time: the deploy published the runner's retention store, which is which builds this runner built. Two builds made on a developer's machine between two deploys then sat in the history having served nothing, and the older of them was blamed for a drop that happened a month later — so a chunk the origin was serving that second was deleted with no grace at all. Filing the bytes the apply is already publishing removes the question rather than answering it: there is no way to enter a build into this history without deploying it.
Four things about it are worth knowing before changing any of them:
- The build being deployed is stated, never inferred from the order. A
document can be stamped ahead of what the origin actually serves — a skewed
runner, or a build
pagedeck buildretained and never deployed — and by age alone that document would be the newest, making every file the live site serves read as superseded. The runnable passes the manifest it is deploying, and its keys come off the answer whatever the history says. - So is the build that was being served, and for a sharper reason: a rollback
breaks the order.
createdAtsays when a build was stamped, and restoringb2overb3leaves the clock still callingb3the newer — sob3reads as the build that supersededb2, when in truthb2outlived it by weeks, and every file the restored site serves is attributed to a deadline long past. The runnable passes<origin>/manifest.jsonas the build that was live, and a key whose newest holder is that build is named as dropped by this deploy, with its window opening at the moment the deploy runs — not at the deployed build's own clock reading, which is when the artifact was stamped and can be a month earlier if you are re-deploying an old one. That is the whole grace period ahead of it, measured from now. What that cannot fix is the build the rollback un-served: nothing anywhere records when a rollback happened, so its files are attributed to the next build by clock reading, which is later than the truth and therefore the safe side. The cost is one deploy's delay and not a leak —deploy.test.tsruns the deploy after and asserts the files then go. - The origin accumulates these documents and nothing bounds it — not
build.retention.keep, which governs only the copy beside the site. ADeployTargethas no list operation, and deleting the oldest documents is the one edit that would break the prune: a key only those builds held would go invisible to it and stay at the origin for ever. Never leaking an asset is what the growth buys, and the growth is real rather than negligible. Measured on this repository: one manifest is 6110 B for the four-page dogfood site and 363546 B for the fifty-page docs site — 6.1 kB and 364 kB, SI units, as every figure in this section is. Two things set the rate and only one of them is the document: the size of each scales with the pages and files a build emits, and how many of them you accumulate scales with how often you deploy. Multiply the two — a site the size of the docs site, deploying ten times a day, files about 3.6 MB a day. Budget it against both, and read it as the standing cost of a prune that works. - They are kept off the edge, and so is
manifest.json(#556). Each document names every file its build served, andbuild.parentleads from the live manifest to the one before it, so served, the history would hand anyone every address the site has ever had — a post set to draft since included — with the time each build went up. Nothing in a browser reads either file: the deploy reads the origin directly, through the file system or a signed GET, and never through the edge. So every edge adapter's output answers/manifest.jsonand everything under/.pagedeck/with the site's 404, the response a missing page gets, or a bare 404 on a site with none. That is a framework default, in every tree, and no rule a site writes can undo it: the planner refuses a page or a redirect at these paths, and a header rule there sets headers on the 404 and serves nothing. What the edge cannot close is the origin itself: an origin anyone can read without going through the edge serves these keys like any other file, and keeping the origin private is set up where the origin is, not in anything the framework emits.
Two gaps remain on an origin that was deployed to before these changes, and both are bounded.
- Builds deployed before the deploy instant was recorded fall back to their
stamp. Their documents have no
.deployed-atbeside them, so the files they dropped are timed fromcreatedAtas before, and a build that waited between stamp and deploy still hands those files less than seven days of grace. That lasts only while such a build is the one that dropped a file the prune has not yet deleted: once those files are gone, the build no longer decides any deadline. From now on an apply writes the instant of the build it deploys once that build's files and document are up, re-deploys and rollbacks included, overwriting any earlier one; an apply stopped before that point leaves the build without one. None of this needs a migration. - Files dropped by builds older than the first deploy after #287 appear in no
document and are never pruned. Before #287 the origin held only
manifest.json, so its history starts at one document, and a file that document already did not list is invisible to the walk above. This leaks bytes rather than deleting live ones, and it does not grow: it is fixed at adoption. Nothing in the framework can find those files, because aDeployTargethas put and delete and no list. Remove them by hand if the bytes matter: list the origin, and delete what no document under.pagedeck/manifests/names andmanifest.jsondoes not serve.
This overrides a decision #32 made, and the override is the maintainer's
rather than this document's. #32 put the retention store beside the output
tree and never inside it, and one of its reasons is precisely this: a store
under outDir would mean every CDN bucket accumulates twenty copies of a
document describing files it is serving. The reasoning holds — a deploy that
swept outDir would
publish twenty documents on the first apply and twenty again on the next, for a
store the origin had no use for. #287's ruling decided the origin has a use for
it after all, and the shape above is what makes the two compatible rather than
merely overruled: the origin accumulates one document per apply and not
twenty, it is a copy of a document the deploy was publishing anyway rather than
a directory swept off disk, and the site's own store is still outside outDir
and still unpublished. The count #32 warned about is the count this
arrangement does not have.
A rollback keeps the old prune and hands over no history, deliberately: that
computation is written on "the newest build in the history is what the origin
serves", and a restore makes it false, so a rollback with --prune would delete
the site it just restored. The workflow passes --prune on the deploy step and
not on the rollback step. That old prune has one deadline for the whole plan,
and a page ignores it the same way: a rollback with --prune deletes the pages
the rolled-back build added in the same run, and holds its chunks until the
deadline. The run ends with a Held 1 file or Held <n> files line naming
that deadline whenever it deleted some files and not others.
pagedeck diff and pagedeck rollback take --grace-seconds <n> to set it.
deploy.bin.js does not, and plans under the default — so if you change the
window, change it in both places, or the document a person read and the plan
that ran will disagree about when a file may go. The workflow passes it to
neither, which is why the two agree today.
There is no --due-only flag anywhere in this pipeline. Scheduled publishing's
build-side pass is #281's, and deploy.bin.ts deliberately does not call it.
Rolling back
# the restore, as a document
node ../core/dist/bin.js rollback <build-id>
# the restore, carried out through the same uploader seam
node dist/deploy.bin.js --rollback <build-id> --origin <dir> # dry run
node dist/deploy.bin.js --rollback <build-id> --origin <dir> --applyThe build id is the build.id in the manifest that build wrote. The plan is
{ from: what is live, to: the retained build }, never the other way round:
the build being restored is the one the host must end up serving, so its files
are the uploads and the live build's leftovers are the prunes. Swapped, a
rollback deletes the site it was asked to restore.
Against a presigned origin the rollback reads both manifests off the origin:
# GETs signed for /manifest.json, /.pagedeck/deploy-history.json
# and /.pagedeck/manifests/<build-id>.json
PAGEDECK_DEPLOY_URLS=reads.json node dist/deploy.bin.js --rollback <build-id> \
--out <tree build-id wrote> --requests requests.json
PAGEDECK_DEPLOY_URLS=signed.json node dist/deploy.bin.js --rollback <build-id> \
--out <tree build-id wrote> --applyWhat is live is /manifest.json, and the build to restore is its document in
the origin's deploy history, so the origin has to have served it. The bytes come
from --out, which must be the tree that build wrote: a run whose --out holds
another build is refused before any request.
Two things a rollback needs that a fresh runner does not have.
- The retention store.
pagedeck buildretains manifests in.pagedeck/manifestsbeside the output tree, and a rollback reads the one it is named. A CI job that checked out and built once holds exactly one manifest — its own — so a real rollback needs that store restored into the workspace first. - The retained build's bytes.
pagedeck buildretains the manifest, not the output tree, and a rollback uploads files. Either keep the build artifact or rebuild that commit. The build test keeps build 1's tree on disk, which is the same fact with no build in it.
The edge artifacts
An edge adapter compiles the site's build.routing into a host's files, and a deploy
treats them as two groups because a host does:
tree-file—/_redirectsand/_headerson Netlify, for instance. Uploaded with the site, into the output tree, after every file the build emitted for that tree. That order is what makes it impossible for a redirect to be live before the page it points at.- everything else — a CloudFront Function, its config fragment, a KeyValueStore
dataset, an nginx include, a Cloudflare Worker. Published out of band.
applyPlanwrites them into the--stagingdirectory and stops; the workflow uploads that directory as a build artifact, and an operator applies it.
A domain tree's artifacts are staged in a directory named for its tree key
(#669). Every tree compiles to the same file names, so with one flat directory
the last tree's file would overwrite the others. The default tree's stay at the
root of the staging directory, as its files do in the output directory. The
plan names the tree on each domain tree's stage line:
edge artifacts, compiled for cloudflare-worker
stage worker.js (edge-module) — applied out of band by CI
stage shop.example/worker.js (edge-module, tree shop.example) — applied out of band by CI
An applying run lists each file it staged with its tree, the default tree's
as tree (default). Apply each file to the distribution or Worker that serves
its tree. A site whose only tree is a domain tree stages under that tree's key
too, so its files do not move when a second tree is added. A tree key that
would put a file outside the staging directory is refused before anything is
uploaded.
Every tree gets edge artifacts, even a tree whose site declares no routing at
all (#556). The deny for the reserved deploy keys — /manifest.json and
/.pagedeck/, see "The grace period" above — is compiled into every tree, so a site
that wrote nothing still has something to install:
- On
cloudfront-function, a viewer-request function (routing.request.js). It has to be associated with the distribution for the deny to take effect. CloudFront allows one function per event type on a cache behaviour, so a distribution that already runs a viewer-request function of its own has to compose the two into one: this one's stages first, then its own. - On
netlify, and on any deploy that uploads the tree files to its origin, a/_redirectswhose first three rows are the deny. - On
nginx, arouting.conffragment whose first two lines are the deny. It still has to beincluded in theserverblock. - On
cloudflare-worker, aworker.jsthat refuses the keys before it reads the bucket. It has to be published with the bucket bound to it, and routed in front of the site, for the deny to take effect.
A dry run stages nothing at all — it names each artifact in the plan and creates
no directory — so --edge is safe to leave in a workflow that has not been
authorized to deploy. The workflow's edge_target input defaults to
cloudfront-function because that is the target whose artifacts are entirely
out of band, and so the only one that exercises the staging half; it is a
statement about what the run compiles, not about where this site is served.
none skips the compile.
The headers on the 404 page and on a redirect rest on host facts nobody has
checked (#559). Every target is compiled to send the 404 page with the set
of the header rule the 404 page's own path matches, and a redirect with the set
its from matches. nginx does that by construction: the lines sit in the 404
page's location and in each redirect's. The other two need a host fact this
repository has not observed, and the equivalence check assumes it:
- Netlify, two facts: that
_headersis matched against a404row's target, the 404 page, and not against the path requested; and that it applies to a redirect row at all, matched against the row'sfrom. If the first is wrong, the 404 page gets the requested path's set, which is the same set on a site with one rule over/. If the second is wrong, redirects go out with no header from the routing document. - CloudFront: that the viewer-response function runs over a custom error response. If it does not, the 404 page goes out with no header from the routing document. A redirect does not depend on it: the viewer-request function writes the set into the redirect it returns.
- Cloudflare Pages:
@pagedeck/adapter-cloudflare-pageshas no rewrite with a status other than 200, so a reserved deploy key (/manifest.json,/.pagedeck/) is proxied (200, in place) to the tree's 404 page instead, and_headersis assumed to match the original request's path rather than the proxied page's — the opposite assumption from Netlify's and CloudFront's above. If Cloudflare matches the proxied page's path instead, a reserved key carries the 404 page's own header set rather than whatever (if anything) matches the key itself. Whether a redirect response carries_headersat all is also unconfirmed. A missing page that is not a reserved deploy key is outside this adapter's files entirely: Cloudflare's own nearest-404.htmllookup serves it (not_redirectsor_headers), and whether that response carries the 404 page's header set or none is a third open question. - Vercel, two facts: that a
routesentry ahead of{"handle": "filesystem"}still wins over a real file at that path, and that one declared after it is reached only where no real file answers. If the first is wrong, a reserved deploy key is served as the real file it masks rather than the 404 page. If the second is wrong, a miss anywhere else in the tree gets no header from the routing document, or masks a page that does exist. A third fact, outside this check: that Vercel readsvercel.jsonfrom the Output Directory, which is wherepagedeck buildwrites it. The how-to's "Vercel" section names the Output Directory setting that assumes it.
Check all of these on a staging deploy before relying on them, by requesting a missing path, a redirect source and a reserved deploy key, and reading the response headers and status.
Nothing in this repository applies the second group, and that is the standing decision "edge artifacts are emitted, not provisioned" rather than an unfinished step: a compiler emits the files a host needs and records what the host must be given, and what it emits is inert until somebody installs it. The failure that decision is written against is concrete — a CloudFront Function uploaded into the bucket beside the site's JavaScript succeeds, publishes nothing, fires no redirect, and reports green.
On Netlify, a page's other trailing-slash spelling is Netlify's own answer
(#35). Every other target redirects the other spelling of a redirect's target
to the target. Netlify matches a _redirects rule with or without a trailing
slash, and
its docs say "you cannot use a redirect rule to add or remove a trailing
slash": a row such as /new /new/ 301! sends /new/ to itself. So netlify()
writes no row whose two paths differ only by a trailing slash, refuses a
configured redirect of that shape, and the conformance cases do not claim that
redirect for Netlify. It keeps the row from a redirect source's other spelling
to the target: that row is forced (!), so it wins over a file at that path.
Because Netlify ignores the trailing slash, the same forced row also catches
the source's own spelling, which in effect forces the configured rule too.
The dogfood site declares two redirects and two response headers
(packages/site/src/site.ts), and
packages/site/src/site.build.test.ts compiles the real build's routing document
for two hosts and reads both back out of the artifacts. That is the only claim
made about them: they are text, compiled from the site's own config.
The landing page on Cloudflare
The landing page (packages/landing) is deployed as Workers Static
Assets: one wrangler deploy uploads the built tree and publishes a Worker
that serves it, with no Worker code of the site's own and no bucket (#52). It
does not go through deploy.bin.js, so the incremental deploy, the deploy
history, the grace period and the rollback above do not apply to it: wrangler
uploads only the files Cloudflare does not hold yet, and Cloudflare keeps the
Worker's versions. The cloudflare-worker adapter, presign.bin.js and the
origin harness stay as they are, as the R2 path for other sites.
wrangler is packages/landing's exact-version dev dependency, so every command
below runs as pnpm exec wrangler in packages/landing, never as npx wrangler, which would take whatever version is latest.
What the build writes for it. The site's build.adapter is
cloudflarePages(). Workers Static Assets reads the same _headers and
_redirects formats as Cloudflare Pages, so pagedeck build writes into
site/:
_headers: a/*rule with the three security headers, theContent-Security-Policyfromsrc/csp.ts, the same value the routing header rule and the other adapters carry, and thePermissions-PolicyandCross-Origin-Opener-Policyfrom the same file (#62). Then one rule each for/assets/*,/fonts/*and/social/*, which hold only content-hashed names, addingCache-Control: public, max-age=31536000, immutableand detaching the policy (#55). Those three rules set the three security headers too, because a path carries the set of the prefix it matches and no other, and the adapter writes them once, in/*./images/holds unhashed names and has no rule. Every other file, and every page, gets Cloudflare's defaultpublic, max-age=0, must-revalidate, observed on the live landing page (#55)._redirects: three rows that proxy/manifest.json,/.pagedeckand/.pagedeck/*to/404.html. The site declares no redirects of its own.404.html: the adapter's bare fallback, written because the site declares no 404 page..assetsignore, frompublic/, through passthrough. It names/manifest.json,/.pagedeckand/404.html. wrangler leaves those out of the upload, and itself leaves out.assetsignore,_headersand_redirects, which it reads as configuration.
So manifest.json and the fallback page are never uploaded, and the build
writes .pagedeck/ beside site/, not in it. A request for /manifest.json
or under /.pagedeck/ matches a proxy row whose target was not uploaded, and
the asset worker answers a proxied path with no asset 404. Uploaded, the
fallback page would be served for those keys with status 200, which is why
it is left out. Any other path with no asset is answered by
not_found_handling: "none", a bare 404.
packages/landing/wrangler.jsonc declares the Worker: the name
pagedeck-landing, assets.directory ./site, html_handling
auto-trailing-slash (/features answers 307 to /features/),
not_found_handling none, no main, workers_dev on, and no route, because
the domain does not exist yet. The Worker serves on its workers.dev address.
What is proven, and what is not. wrangler deploy --dry-run against a
local landing build succeeds: it reads the 45 entries of site/, ignores
.assetsignore, 404.html, _headers, _redirects and manifest.json, and
sends nothing to Cloudflare. site.build.test.ts holds the written _headers
to the landing page's header set, each hashed file to immutable with
nosniff, every other file to no Cache-Control, and .assetsignore to its
three lines.
Cloudflare has not been sent a request. These are the host facts the first
deploy checks:
- Workers Static Assets applies the
_headersrule to every page, so each response carries the CSP, the three security headers,Permissions-PolicyandCross-Origin-Opener-Policy(#62), and a hashed file answersimmutablewithnosniff. - A proxy row whose target is not uploaded answers
404. This is read from the asset worker's source bundled in wrangler 4.148.0's Miniflare (workers-shared), not observed on Cloudflare, and whether that404carries the_headersset is not documented. - An API token with Account → Workers Scripts → Edit can upload the assets and publish the Worker. Cloudflare's permissions reference describes that permission as write access to Workers scripts and names nothing for static assets; that nothing more is needed is not checked.
Setting up
Once, in the Cloudflare account that will hold the domain:
wrangler on your machine.
wrangler loginopens a browser and authorizes wrangler for your own account. It is for the commands you run by hand; the workflow uses the token below.cd packages/landing pnpm exec wrangler loginAn API token for the workflow, in the dashboard under My Profile → API Tokens → Create Token → Custom token, with one permission: Account → Workers Scripts → Edit, on that one account. If the first publish is refused with an authorization error, add only the permission the error names.
The Worker. Nothing creates it by hand: the first
wrangler deploycreatespagedeck-landingwith its assets and itsworkers.devaddress.
The secrets
Two repository secrets, under Settings → Secrets and variables → Actions:
| Secret | Value |
|---|---|
CLOUDFLARE_API_TOKEN |
the API token above |
CLOUDFLARE_ACCOUNT_ID |
the account's ID, from the dashboard's Workers & Pages overview |
gh secret set <name> reads the value from standard input when you paste it,
so the value is not on a command line. The workflow passes both to the publish
step and to no other step. A publishing run with either one unset fails, names
it, and publishes nothing.
Deploying
gh workflow run deploy-landing.yml # build, then wrangler deploy --dry-run
gh workflow run deploy-landing.yml -f apply=true # build, then wrangler deployBoth build the page from the checked-out commit. A run without apply uses no
credential. Each run uploads the landing-deploy artifact: what wrangler
printed, the built site/ and .pagedeck/budget-report.json.
The repository is public, so anyone can read a run's log and its artifacts.
wrangler's stdout and stderr pass through packages/landing/dist/redact.bin.js
before the log or wrangler.txt gets them. It replaces each email with
<email> and each 32-character hex ID, such as the account ID, with <id>
(#59). packages/landing/src/redact.bin.test.ts fails on a workflow line
that runs wrangler without it.
To deploy from your machine instead, from packages/landing after pnpm build
at the repository root:
node ../core/dist/bin.js sync
node ../core/dist/bin.js build
pnpm exec wrangler deploy --dry-run # lists the upload; sends nothing
pnpm exec wrangler deployThe first time:
- Set up as above and set the two secrets.
- Run with
apply=true. wrangler creates the Worker and prints itsworkers.devaddress. - Open that address.
/answers the page,/featuresanswers307to/features/,/manifest.jsonand/.pagedeck/deploy-history.jsonanswer404, and a page carries the CSP, the three security headers, aPermissions-PolicyandCross-Origin-Opener-Policy: same-origin(#62). A file under/assets/answersCache-Control: public, max-age=31536000, immutablewithX-Content-Type-Options: nosniff, and/answers noimmutable. Then add the domain (below).
Rolling back
Each wrangler deploy makes a new version of the Worker, with its assets, and
Cloudflare keeps them. From packages/landing, with wrangler login done:
pnpm exec wrangler deployments list # what was deployed, newest first
pnpm exec wrangler rollback # back to the version before the live one
pnpm exec wrangler rollback <version id> # back to a named versionA rollback publishes an earlier version as it was. The next applying run
deploys the checked-out commit again, so revert the change on main before
the next run if it should stay rolled back.
Adding the domain
When the domain is ready, declare it in wrangler.jsonc, not in the
dashboard, and turn workers_dev off so the page has one address. wrangler deploy applies the config's workers_dev on every publish.
"workers_dev": false,
"routes": [{ "pattern": "<domain>", "custom_domain": true }]Before that, write down the DNS records the domain has: a custom domain
creates its own record. Merge and run with apply=true. Then:
- Check
/over HTTPS on the domain, as in step 3 above. - Declare HSTS. It is left out until then because it is a promise about a
domain (
CONTEXT.md, "The framework emits no header a site did not write"). Add{ name: "Strict-Transport-Security", value: "max-age=31536000" }to the/rule inpackages/landing/src/site.ts, where a comment says why it is absent, and toSERVED_HEADERSinpackages/landing/src/site.build.test.ts. Leave outincludeSubDomainsunless every host under the domain serves HTTPS. Merge, run withapply=true, and check one response for it.
To take the page off the domain, remove the custom domain from the Worker,
which deletes the record Cloudflare created for it, and recreate the records
you wrote down. Then take the route out of wrangler.jsonc and set
workers_dev back to true, so the next publish does not add the route
again.
The docs site on Cloudflare
The docs site (packages/docs) deploys the way the landing page does, as its
own Workers Static Assets Worker, pagedeck-docs (#6). Everything in "The
landing page on Cloudflare" holds for it, with packages/docs for
packages/landing and deploy-docs.yml for deploy-landing.yml: the same
token and the same two secrets, wrangler pinned to the same version in this
package's dev dependencies, the same rollback and the same steps to add a
domain. Both workflows call .github/workflows/deploy-worker.yml, which builds
the one site it is given and runs wrangler on it.
What the build writes for it. build.adapter is cloudflarePages(), so
pagedeck build writes the same four files into site/: _headers, a /*
rule with the three security headers and the docs site's own
Content-Security-Policy from src/csp.ts, then a rule for /assets/*, which
holds only content-hashed names, adding Cache-Control: public, max-age=31536000, immutable and detaching the policy (#86); _redirects, the
three proxy rows for /manifest.json, /.pagedeck and /.pagedeck/*; the
fallback 404.html; and .assetsignore, from public/. The site declares no
404 page, so not_found_handling is none, and the fallback stays out of the
upload. /search/ is the one page with JavaScript, and its index under
/search/en/ is uploaded with the pages. The index files and favicon.ico
have unhashed names and no rule: like every page, they get Cloudflare's
default public, max-age=0, must-revalidate, as observed on the landing page
(#55).
If the site ever declares a 404 page, uploading it is not enough. With
/404.html uploaded, the asset worker bundled in wrangler 4.148.0 answers a
proxy row's target through html_handling: /manifest.json gets 307 to
/404, and /404 serves the page with 200. site.build.test.ts fails when
a 404 page is declared while .assetsignore still names /404.html.
packages/docs/wrangler.jsonc declares the Worker: assets.directory
./site, html_handling auto-trailing-slash, because the site's
trailingSlash is always (/reference/cli answers 307 to
/reference/cli/), not_found_handling none, no main, workers_dev on,
and no route. The Worker serves on
https://pagedeck-docs.pedrodsousa.workers.dev until a domain is chosen (#54).
The READMEs link it. The root README, each package README and the
README that create-pagedeck --host writes link the deployed pages at that
address. site.build.test.ts requires each linked route to be a page the
build emits, and each #fragment a heading on it. When the domain changes,
change DOCS_ORIGIN there and every link with it.
What is proven. wrangler deploy --dry-run against a local docs build
reads site/, ignores .assetsignore, 404.html, _headers, _redirects
and manifest.json, and sends nothing. site.build.test.ts holds each hashed
file to immutable with the three security headers, and every other file to
the full set with no Cache-Control. Cloudflare has not been sent a request.
gh workflow run deploy-docs.yml # build, then wrangler deploy --dry-run
gh workflow run deploy-docs.yml -f apply=true # build, then wrangler deployEach run uploads the docs-deploy artifact: what wrangler printed, redacted
by the landing package's redact.bin.js as for the landing page, and the
built site/. The site declares no budget, so there is no budget report.
After the first publish, open the address. / and /search/ answer their
pages, /reference/cli answers 307 to /reference/cli/, /manifest.json
and /.pagedeck/deploy-history.json answer 404, a page carries the CSP and
the three security headers, a file under /assets/ answers Cache-Control: public, max-age=31536000, immutable with X-Content-Type-Options: nosniff,
/ answers no immutable, and a search on /search/ returns results.