Deploy notes
How this repo's own apps reach production — two independent origins built from one script, base paths, and the Cloudflare Pages projects.
Audience: contributors and LLM agents working in this repository. This is repo maintenance, not widget deployment — for deploying a widget you built, see the CLI reference.
Use this document when deployment behavior or CI build commands are
involved. Deployment configuration for demo.grist-widgets.com
(Cloudflare Pages) is not documented in this repo — it lives in
the Cloudflare dashboard.
Two origins, on purpose
The site is published at two independent origins, each carrying a complete copy — docs and playground:
| Origin | Host | SITE_BASE |
|---|---|---|
https://arthurblanchon.github.io/grist-widget-sdk/ | GitHub Pages | /grist-widget-sdk/ |
https://gristwidgets.com/ | Cloudflare Pages | (unset — domain root) |
Neither redirects to the other. The reason is access, not vanity: some
enterprise networks allowlist github.io while blocking unfamiliar domains,
and others block github.io as user-generated content while permitting
everything else. Two origins on two different providers means a reader
blocked from one can still reach the other — which also rules out hosting both
on GitHub, since an org-wide GitHub block would take out both at once.
gristwidgets.com is the canonical origin: both builds emit
<link rel="canonical"> pointing at it, so search engines index one copy
while humans can browse either.
Why this can't be one deployment
Two things force it.
GitHub redirects. Setting a custom domain on a GitHub Pages site makes
GitHub 301 that site's github.io URL to the custom domain. Not configurable.
So gristwidgets.com must not be a GitHub Pages custom domain, and no
CNAME is written into the artifact.
Base paths are baked in. github.io/grist-widget-sdk/ needs a
/grist-widget-sdk prefix on every asset and link; gristwidgets.com/ needs
none. Next embeds that prefix in emitted hrefs and in JS chunks, so one
artifact cannot be correct at both. DNS can't bridge this either — DNS maps
names to IPs and has no concept of paths, and a proxy that rewrote them would
have to rewrite minified JS at request time.
Hence: same source, same script, two build runs.
Other deploy targets
- template app from
templates/grist-widget-template-vite(Cloudflare Pages) apps/playgroundadditionally deploys standalone todemo.grist-widgets.com(Cloudflare Pages, its own dashboard config)
apps/docs (VitePress) and apps/web (the earlier Vite + shadcn landing
site) have both been removed from the repo. apps/www — Next.js +
fumadocs, static-exported — replaced both: it serves the landing page at the
site root and the full documentation under /docs/*.
The shared build script
Both origins run scripts/build-site.mjs. It builds apps/playground and
apps/www, assembles them into ./site (site/playground/ plus www at the
root), and writes .nojekyll. Keeping one script means the two origins can
only differ in where they're served, never in what they contain.
node scripts/build-site.mjs # domain root -> gristwidgets.com
SITE_BASE=/grist-widget-sdk/ node scripts/build-site.mjs # subpath -> github.ioSITE_BASE is the only base knob, deliberately. It used to be two independent
vars — WWW_BASE and PLAYGROUND_BASE — that had to agree with each other,
and disagreeing is exactly how the first gristwidgets.com deploy broke. The
script derives both from one value, so a mismatch is unrepresentable.
| Consumed as | Value at root | Value on the project site |
|---|---|---|
WWW_BASE (apps/www/next.config.mjs) | "" | /grist-widget-sdk/ |
PLAYGROUND_BASE (apps/playground/vite.config.ts) | /playground/ | /grist-widget-sdk/playground/ |
GitHub Pages origin
.github/workflows/deploy-apps.yml runs the script with
SITE_BASE=/<repo>/ on every push to main touching apps/playground/**,
apps/www/**, or packages/core/** (or workflow_dispatch), then publishes
with actions/upload-pages-artifact + actions/deploy-pages — the modern
Actions-native Pages flow, not a gh-pages branch. Requires this repo's
Settings → Pages → Source to be "GitHub Actions", and its custom domain
field to stay empty (see "Why this can't be one deployment" above).
Cloudflare Pages origin
A Pages project on the same repo, serving gristwidgets.com:
- Build command:
pnpm install --frozen-lockfile && node scripts/build-site.mjs - Build output directory:
site - No
SITE_BASE— the domain root needs no prefix.
The base-path trap
This is the sharp edge, and it has been shipped wrong in both directions:
- Too little prefix. A GitHub Pages project site is served at
https://<user>.github.io/<repo>/, where/<repo>/is a real path prefix. Framework defaults that assume the domain root make every asset absolute against the domain root and 404 everything. Found live the first timeapps/webdeployed end-to-end —index.htmlloaded, every asset 404'd, white screen. - Too much prefix. The inverse, hit on the first
gristwidgets.comdeploy:WWW_BASE=/<repo>/was still set, so assets resolved togristwidgets.com/grist-widget-sdk/_next/…(nonexistent — 404s, broken display) and internal links pointed off-domain.
Local testing catches neither: next start or serving out/ directly runs at
the actual domain root of localhost, where no base path is correct. Verify
against the built output instead:
node scripts/build-site.mjs
grep -o 'href="[^"]*_next[^"]*"' site/index.html | head -2
# expect /_next/… — any /<repo>/ prefix here is the bug
SITE_BASE=/grist-widget-sdk/ node scripts/build-site.mjs
grep -o 'href="[^"]*_next[^"]*"' site/index.html | head -2
# expect /grist-widget-sdk/_next/… — a bare /_next/ here is the bugPlayground base-awareness needs, and already has:
src/main.tsx's<BrowserRouter basename={import.meta.env.BASE_URL}>— without it the router'spath="/"route wouldn't match under/playground/, and every load would bounce through thepath="*"redirect back to the site root.src/data/widget-examples.ts'swidgetHtmlPathForId— prefixeswidget.html?id=…withimport.meta.env.BASE_URLinstead of a hardcoded/. That URL is both the live-preview iframesrcand the documented "prod URL" you paste into a real Grist doc.
Keeping the origins in sync
They deploy on independent triggers, so they can drift for a few minutes after
a push — and "the site is stale" will sometimes mean "one of the two is
stale." Point the Cloudflare project at the same branch (main) to keep the
window small. Neither origin depends on the other at runtime, so a failed
deploy on one leaves the other serving the previous good build.
No release/dev channel for either app — every push to main touching their
paths overwrites the whole deployment. concurrency: { group: "pages", cancel-in-progress: false } is GitHub's documented pattern for
Actions-based Pages deploys: one deployment in flight, none cancelled
mid-flight.
Transient Pages backend flakiness — timeout + auto-retry
Found live, repeatedly, unrelated to anything in this workflow: the
actions/deploy-pages step sometimes sits in deployment_in_progress for
its full internal timeout and then self-aborts (conclusion: cancelled), or
occasionally fails outright — backend GitHub Pages flakiness on this repo,
not a race (confirmed via the Actions API: exactly one deployment in flight,
build already succeeded). Two things address this without a human having to
notice and re-trigger:
- The
deploystep setstimeout: 900000(15 min, up from thedeploy-pagesdefault of 10), giving a genuinely-slow-but-would-have- succeeded deployment more room. .github/workflows/retry-deploy-pages.yml, a separateworkflow_run- triggered workflow, re-runs just the failed/cancelled jobs ofdeploy-apps.yml(via the "re-run failed jobs" API — reuses the already-uploaded artifact, no rebuild) exactly once, gated onrun_attempt. Still failing after that retry? It stops and leaves it alone — two failures in a row stops looking transient.
Why one workflow, not three
This used to be three independent workflows (deploy-docs.yml,
deploy-playground.yml, deploy-web.yml), each pushing its own commit to a
shared gh-pages branch. That worked for the git side, but every successful
push to that branch independently triggered GitHub's hidden native
pages-build-deployment workflow (injected automatically whenever Pages
Source is "Deploy from a branch"). A merge touching multiple apps' trigger
paths at once — routine, e.g. a lockfile change — fired all three, each
pushing a commit, each spawning its own native deployment. GitHub Pages
processes one deployment at a time, so later ones queued behind earlier ones
and occasionally hit actions/deploy-pages's ~10-minute timeout waiting.
One workflow, one artifact, one deploy-pages call removes the race by
construction: structurally only ever one deployment per merge.
This deliberately does not cover templates/grist-widget-template-vite
— see below.
Template showcase (removed)
templates/grist-widget-template-vite used to have a second, independent
GitHub Pages presence inside this monorepo: its own copy deployed to
/template/ on this repo's gh-pages branch
(deploy-template-showcase.yml + scripts/deploy/template-showcase.mjs),
alongside a dev/template-showcase branch for a /template/dev/ channel.
Both the workflow and the script have been deleted, along with their smoke
test.
This does not affect the template's own bundled deploy pipeline —
templates/grist-widget-template-vite/.github/workflows/deploy.yml +
scripts/deploy.mjs — which is embedded verbatim into every scaffolded
widget repo via create-grist-widget and deploys that repo's own separate
GitHub Pages site. That pipeline is untouched and this repo's Pages settings
have no bearing on it.
The template's only remaining live preview is the external
github.com/ArthurBlanchon/grist-widget-template repo, maintained by
template-canary.yml (dev + canary/latest branches, manual PR to promote
to that repo's main — see Releasing).
Cloudflare Pages
One Pages project per target. The safest default is:
- set Root directory to repository root
- use
pnpm --filter …commands with real workspace names - set Build output directory to a path relative to repository root
widgets project
Use this command even if the playground currently builds without importing core directly — it prevents breakage once it does.
- Build command:
pnpm install --frozen-lockfile && pnpm build:playground- Build output directory:
apps/playground/disttemplates project
Template code imports grist-widget-sdk, so core must be built first.
- Build command:
pnpm install --frozen-lockfile && pnpm --filter grist-widget-sdk build && pnpm --filter grist-widget-template-vite build- Build output directory:
templates/grist-widget-template-vite/distThere is no longer a docs Cloudflare project — it built apps/docs, which
has been removed. apps/www is GitHub-Pages-only.
Common Cloudflare errors and fixes
No projects matched the filters: the--filtervalue does not matchpackage.json:name.Output directory ".../.../dist" not found: Root directory + output path are duplicated. If Root is alreadyapps/playground, output should bedist, notapps/playground/dist.Cannot find module 'grist-widget-sdk': buildgrist-widget-sdkfirst in the same command chain.No Wrangler configuration file found/No functions dir at /functions found: expected for static Pages deployments.
Build commands (workspace-safe)
From repository root:
node scripts/build-site.mjs # the deployable tree, exactly as CI builds it
pnpm build # all workspaces (no assembly)
pnpm build:www # apps/www only
pnpm build:playground # apps/playground onlyBuild ordering guarantees
turbo.json uses build.dependsOn: ["^build"] and
test.dependsOn: ["^build"], so dependent apps build against a freshly built
grist-widget-sdk.
Static hosting notes
apps/playground/public/_redirectsenables SPA fallback routing for static hosting.apps/wwwoutputs Next's static export toout/;scripts/build-site.mjscopies it to the root ofsite/.- That script writes
.nojekyllat the assembled site's root so GitHub Pages' default Jekyll processing doesn't drop the_next/static asset folder — Jekyll ignores underscore-prefixed directories, and everything else about the deploy can be correct while this alone 404s every asset. It's harmless on hosts that don't run Jekyll, so it's written unconditionally rather than branching onSITE_BASE. - It deliberately writes no
CNAME— that would re-enable GitHub's redirect and collapse the two origins.
Checklist before touching deploy config
- Confirm workspace/package names still match commands.
- Confirm output paths used by the hosting provider still match build output.
- Confirm no deploy instructions reference removed directories.
- After any base-path or domain change, grep the built output for asset prefixes before merging — that check is what both historical failures needed and neither had.