Deploy serialization and rollback
A static deploy is an upload, and an upload is not atomic. Two of them running at once interleave: the second one's files land on top of the first one's, and the first one's prune list deletes files the second one just uploaded and never knew about. The site that comes out is a mixture of two builds, and neither manifest describes it.
pagedeck does not own the upload — no host is named anywhere in core, and the S3,
CloudFront and GitHub Actions recipes are docs rather than code. What it owns is
the record that makes the race visible: every build says which build it was
based on, every build's manifest is kept for a while, and pagedeck diff refuses a
deploy whose two manifests are not a chain.
What a build records
pagedeck build stamps three things on the manifest it writes:
{
"build": {
"id": "9e1f4a02-7c1e-4f8a-9a6f-1b5c3d2e4f60",
"createdAt": "2026-09-01T10:14:22.301Z",
"parent": "3c77b1de-52a0-4e77-bb0e-8f0a1c2d3e40"
}
}id is this build's identity, minted per run. parent is the id of the newest
manifest the retention store held when this build started — the build this
one is an increment of. It is absent on the first build of a site, where the
store was empty and there was nothing to be based on.
The build records it because nothing downstream can work it out. A deployer
holds two documents and two timestamps, and two builds that started from the
same base and finished a minute apart look exactly like a chain by createdAt
alone. That pair is the whole case worth catching: two CI runs triggered by two
webhooks, each rendering the site as it was before the other one uploaded. The
ordering fact lives in the moment the build read the store, and the only process
present in that moment is the build.
So the deployer compares rather than infers. pagedeck diff <from> <to> refuses when
to's parent is not from's id, which is last-write-wins detection: the second
deploy is told that the first happened. See --force in the CLI
reference for the two messages and the one exemption.
The retention store
pagedeck build writes its manifest twice: into outDir beside the site it
describes, and into a store beside your config.
site/
pagedeck.config.ts
.pagedeck/manifests/3c77b1de-….json
.pagedeck/manifests/9e1f4a02-….json
dist/
manifest.json
index.html
The store is next to the output tree and never inside it. The default output
tree is outDir — a deploy uploads that directory wholesale — and a store
under it would be published with the site to every bucket the deploy touches. It
is also the one part of the tree that grows with the site's history rather
than with its content, so every CDN would end up serving a growing pile of JSON
documents describing files it is already serving.
.pagedeck/ is a dot-directory a .gitignore names once. Add it to yours:
echo '.pagedeck/' >> .gitignoreThe documents are sorted by the createdAt inside them and never by the file's
mtime, because a store restored from a backup or copied between machines has
mtimes that say when the files arrived. The order has to be a property of the
builds.
On CI, the store has to outlive the run. A fresh runner has no .pagedeck/, so
the build finds an empty store, records no parent, and pagedeck diff refuses the
deploy for the second reason in the CLI reference — every time, on a pipeline
with nothing wrong with it. Restore the directory at the start of the job and
save it at the end, the way you would a build cache, or keep it on the machine
that deploys. A chain nothing carries between runs is not a chain.
build.retention.keep
How many manifests the store keeps is a named config, and the default is twenty:
build: {
outDir: "./dist",
retention: { keep: 20 },
// ...
}Newest first by build.createdAt; everything past the count is deleted when a
build writes. Twenty is a guess rather than a measurement — it is meant to cover
the rollback a team actually performs, which is to the build before the one that
broke, with room for a site deploying several times a day to still reach last
week's. Being wrong high costs a few hundred kilobytes of JSON beside the site;
being wrong low costs a rollback target that is gone.
keep: 0 retains nothing, and it turns rollback off. It is a value a site can
write on purpose — a preview environment nobody rolls back is entitled to say
so — and it is why the field is a count rather than a count plus a flag. The
build still writes its document and then prunes it, so lowering the count from
twenty to zero clears the twenty too.
A count of documents rather than an age in days, because a count is the number the store can act on with nothing but the store. An age would make "how far back can I roll?" depend on how often the site deploys, which is the one thing a site cannot see from its own config.
A document this pagedeck cannot read is pruned rather than kept, and the build
says so on stderr. That is what makes a pagedeck upgrade survivable: the store a
previous version filled is aged out by the first build after the upgrade instead
of failing it. If you see that line on every build, two pagedeck versions are
building one site — a CI image that moved while a developer's did not — and
rollback there never reaches further back than the last build.
Rolling back
A rollback re-syncs a previous manifest: it is the same upload the deploy does, with the file lists computed in the other direction.
# 1. Find the build to go back to. The store is named after build ids.
ls .pagedeck/manifests
# 2. Get the upload order that restores it.
pagedeck rollback 3c77b1de-52a0-4e77-bb0e-8f0a1c2d3e40 > rollback.json
# 3. Feed it to whatever performs your deploys.
./deploy.sh rollback.jsonpagedeck rollback compares the manifest in outDir — the build the host is
serving — against the retained one, so the document names what to add back, what
to replace and what to prune. It is the document pagedeck diff writes, in the same
order, so a pipeline that already deploys a diff deploys a rollback unchanged.
Two things follow from the direction:
- There is no stale-parent check and no
--force. A rollback deploys a build that came before what is live, so it is out of order by definition. The refusal lives inpagedeck diff, which is the verb that means "deploy this", and not in the diff itself. - The restored build's own files have to still exist. A rollback adds back
what the newer build pruned, which the deploy can only do from a build output
it still has. Keep the artifact, or re-run
pagedeck buildfrom the commit the retained manifest describes.
The output tree is not what makes the rollback correct — the manifest is. What
pagedeck rollback gives you is the ordering: uploads before prunes, with
--grace-seconds keeping a pruned file reachable while caches turn over.
Recipe: debouncing a webhook storm
One editor pressing publish on eight entries sends eight webhooks. Eight builds of the same site is waste; eight deploys of the same site is the race above.
This is your scheduler's job and not pagedeck's. The debounce window belongs
where the trigger is — a CI concurrency group, a queue, a cron — because that is
the only place that can see a second webhook arrive while the first run is still
going. Core would have to hold state between runs to do it, and pagedeck is a
command that starts, builds and exits.
The shape to reach for is a window before the sync, and one run at a time after it:
concurrency:
group: deploy-${{ github.repository }}
cancel-in-progress: false
on:
repository_dispatch:
types: [cms-publish]
jobs:
deploy:
steps:
- run: sleep 60 # the debounce window
- uses: actions/cache@v4 # the store, carried between runs
with:
path: .pagedeck
key: pagedeck-manifests-${{ github.run_id }}
restore-keys: pagedeck-manifests-
- run: pagedeck sync --incremental
- run: pagedeck build
- run: pagedeck diff "$LIVE_MANIFEST" dist/manifest.json > plan.json
- run: ./deploy.sh plan.json
$LIVE_MANIFEST is the manifest of the build the host is currently serving,
from wherever your pipeline keeps it — the previous run's artifact, or the
newest document in .pagedeck/manifests on a runner with a persistent checkout.
Two properties are what make this work, and they are worth stating separately because only one of them is the sleep:
cancel-in-progress: falsewith a concurrency group serializes the deploys. A second webhook waits for the first run rather than running beside it, so the second build reads a store the first build has already written to, and records that build as its parent.- The window before
pagedeck syncabsorbs the storm. Eight webhooks within a minute collapse into one run that syncs every entry, instead of one run per entry each syncing a site that is still changing.
A window that is too short produces exactly the deploy pagedeck diff refuses.
Two runs starting inside each other's build time both read the store before
either uploads, so both record the same parent, and the second one is a stale
deploy — reported, not silently applied. That is the intended failure: the
detection is what makes a badly tuned window visible instead of leaving a mixed
site behind.
Do not answer that refusal with --force in CI. --force means "I know
another deploy landed and I want mine anyway", which is a judgement a person
makes about one deploy. A pipeline that passes it always has turned the
detection off. The fixes in order are: serialize the runs, lengthen the window,
or re-run pagedeck build so it is based on what is live.
Whether to debounce at all is a property of your CMS's webhook behaviour and
your deploy cost, which is why this is a recipe rather than a setting. What pagedeck
guarantees is that getting it wrong is loud.