Releasing & publishing
How the two npm packages ship — the Changesets release train, staging/rc pre-releases, and the template canary that verifies a release after it lands.
Audience: contributors and LLM agents working in this repository. Companion doc:
RELEASING.mdat the repo root — exact commands, secrets, one-time setup. Read that when you need to do something; read this when you need to understand why it works this way.
Use this page to orient before touching anything under .github/workflows/release.yml,
.github/workflows/require-changeset.yml, .github/workflows/template-canary.yml,
or .changeset/.
Two npm packages are published from this repo — grist-widget-sdk
(packages/core) and create-grist-widget (packages/create-grist-widget)
— linked via Changesets (.changeset/config.json linked), so a release
touching either bumps and republishes both to the same version number.
Everything else in the workspace is private.
Three moving pieces
1. Changesets — the real release train
Every change that touches published-package source needs a changeset
(pnpm changeset), which writes a .changeset/*.md file describing the
bump type and a one-line summary. This is enforced by CI:
require-changeset.yml fails a PR that touches packages/core/src/,
packages/create-grist-widget/{src,scripts}/, or
templates/grist-widget-template-vite/ (the template embedded into
create-grist-widget at publish time) without one — this project already
hit the failure mode it prevents once (a PR shipped published-package
source with no changeset, so the merge quietly published nothing).
The actual publish is a two-merge process, not one:
- Merge a PR with a changeset →
release.ymlruns, sees the pending changeset(s), and opens/updates a "Version Packages" PR that bumpspackages/core/package.json(andcreate-grist-widget's, since they're linked) and writes the new entries intopackages/core/CHANGELOG.md, consuming the changeset files. No npm publish happens yet. - Merge that Version Packages PR →
release.ymlruns again, this time with no pending changesets, so it publishes to npm over OIDC (Trusted Publishing — no storedNPM_TOKEN) and creates the git tag + GitHub Release.
So a normal release is: merge feature PRs (each with a changeset) → merge the Version Packages PR. No local publish, no tokens, no OTP.
2. Staging / rc — test a real install before it's real
Before a change becomes a real release, you can publish an installable,
real-codebase-testable build without touching latest or the normal
Version Packages flow:
- On any branch other than
main, bumppackages/core/package.json'sversionto a prerelease (0.2.2-rc.0). - Push. The
publish-stagingjob — in the samerelease.ymlfile as the real release job, gated to run only on non-mainbranches — publishes exactly that version to npm taggednext, neverlatest. - Install it explicitly wherever you want to test it:
npm install grist-widget-sdk@next(or the exact version). A plain^0.2.xconsumer never resolves it — semver excludes prereleases from a caret range — so this is invisible to everyone except whoever explicitly asks for it.
Nothing is committed back to git and main is never touched, so there's
no "staging mode" to remember to turn off — each push is a self-contained,
one-shot publish.
⚠️ SDK-only.
publish-stagingchecks and publishespackages/corealone — it never touchescreate-grist-widget(or the template it embeds), even though the two are linked and always bump/publish together in the normal release flow above. Bumpingpackages/coreto a-rc.Nhere does not produce a testable pre-releasecreate-grist-widget— there's no equivalent staging path for it yet.
3. Template canary — post-release verification + a live preview
.github/workflows/template-canary.yml runs after every release.yml
completion (including ones that only updated the Version Packages PR
without actually publishing — a harmless no-op re-check in that case). It
scaffolds from the actual published create-grist-widget package (not
a workspace-linked build) — catching exactly the class of bug a green
pnpm test can't: a package that publishes fine but doesn't actually
produce a working, deployable widget.
It maintains two branches on the external reference repo
grist-widget-template:
devscaffolds from npm'snextdist-tag (falling back tolatestif no pre-release has ever been published) — a genuine live preview that can regularly sit ahead of what's fully released, pushed on top of its own real git history so GitHub's compare/PR view keeps working.canary/latestis unconditionally force-pushed every run from a fresh@latest-only scaffold, rebuilt offmain's current tip each time — a disposable, CI-owned mirror nobody should develop on top of.
⚠️ Promote from
canary/latest→main, never fromdev→main. Sincedevtracksnext, it can be showing unreleased rc content at any given moment — opening a PR straight fromdevrisks pushing that into production.canary/latestalways reflects exactly what's genuinely published as@latest. Opening/merging the promote PR is a fully manual step; the workflow only ever pushes branches, it never touchesmainitself.
Needs TEMPLATE_CANARY_DEPLOY_KEY: an SSH deploy key scoped to only that
one reference repo (deliberately not a PAT — a leaked deploy key can't
reach anything else, and structurally can't call the GitHub API either,
which is exactly why promotion stays a manual PR rather than something
this workflow opens itself).
Two changelogs, two audiences
packages/core/CHANGELOG.md— the npm package's own changelog, fully generated by Changesets from your one-line changeset summary. Ships in the tarball; this is whatnpm view/ the npm page shows. Never hand-edited./CHANGELOG.md(repo root) — the broader, hand-curated project history (widgets, docs, tooling, process, incidents found live). Maintained manually.
Pitfalls found live (already fixed, good context for future changes)
- Merging without bumping the version publishes nothing. The release build is idempotent — it skips whenever a version's directory already exists. CI reports success either way, so this is easy to miss without checking the actual diff.
- A
gh-pagesbranch manually seeded from another repo's export could permanently squat on a version path (e.g. via GitHub's "Use this template" → "Include all branches", which legitimately copies the source template's owngh-pageshistory). Guarded against intemplates/grist-widget-template-vite/scripts/deploy.mjs: a version directory only counts as genuinely published if itsshowcase-meta.jsonnames the current repo, and a repo with no genuine releases yet safely clears any foreign content on its first real release. - A widget mounting both
<GristWidgetProvider>and a bare<GristStatusChip>/<GristHandshakeProvider>could silently override its own declaredrequiredAccess/columns on Grist's side — unrelated to the release pipeline itself, but found live on a freshly-published scaffold, so worth knowing this class of bug exists. See Handshake state machine and/CHANGELOG.mdfor the full incident writeup. updateInternalDependencies: "patch"bumps theversionfield of every workspace package that depends on a bumped one,private: trueor not.templates/grist-widget-template-viteandapps/playgroundboth carry aworkspace:^dependency ongrist-widget-sdkpurely for monorepo DX — neither is published, and the template'sversionis a load-bearing invariant (scripts/smoke/create-widget.shasserts a fresh scaffold's own version stays0.0.1, since it's the widget author's version to own from scaffold time on, not this repo's). A routine SDK release bumped the template'spackage.jsonto0.0.2as a pure side effect, and that version rode straight into the nextcreate-grist-widgetpublish viabuild-template.mjs, breaking the smoke test on the next scaffold. Fixed two ways: both packages are listed in.changeset/config.jsonignoreso Changesets never touches theirversionagain, andbuild-template.mjsnow force-sets the embedded template'sversionto0.0.1at the one choke point that actually matters, regardless of what the source repo'spackage.jsonsays.
Validation checklist
pnpm --filter grist-widget-sdk test
bash scripts/smoke/template-deploy.sh # local dry-run against a fake gh-pages, no network
npm view grist-widget-sdk version
npm view create-grist-widget version