Grist Widgets
Contributing

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.md at 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:

  1. Merge a PR with a changesetrelease.yml runs, sees the pending changeset(s), and opens/updates a "Version Packages" PR that bumps packages/core/package.json (and create-grist-widget's, since they're linked) and writes the new entries into packages/core/CHANGELOG.md, consuming the changeset files. No npm publish happens yet.
  2. Merge that Version Packages PRrelease.yml runs again, this time with no pending changesets, so it publishes to npm over OIDC (Trusted Publishing — no stored NPM_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:

  1. On any branch other than main, bump packages/core/package.json's version to a prerelease (0.2.2-rc.0).
  2. Push. The publish-staging job — in the same release.yml file as the real release job, gated to run only on non-main branches — publishes exactly that version to npm tagged next, never latest.
  3. Install it explicitly wherever you want to test it: npm install grist-widget-sdk@next (or the exact version). A plain ^0.2.x consumer 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-staging checks and publishes packages/core alone — it never touches create-grist-widget (or the template it embeds), even though the two are linked and always bump/publish together in the normal release flow above. Bumping packages/core to a -rc.N here does not produce a testable pre-release create-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:

  • dev scaffolds from npm's next dist-tag (falling back to latest if 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/latest is unconditionally force-pushed every run from a fresh @latest-only scaffold, rebuilt off main's current tip each time — a disposable, CI-owned mirror nobody should develop on top of.

⚠️ Promote from canary/latestmain, never from devmain. Since dev tracks next, it can be showing unreleased rc content at any given moment — opening a PR straight from dev risks pushing that into production. canary/latest always 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 touches main itself.

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 what npm 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-pages branch 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 own gh-pages history). Guarded against in templates/grist-widget-template-vite/scripts/deploy.mjs: a version directory only counts as genuinely published if its showcase-meta.json names 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 declared requiredAccess/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.md for the full incident writeup.
  • updateInternalDependencies: "patch" bumps the version field of every workspace package that depends on a bumped one, private: true or not. templates/grist-widget-template-vite and apps/playground both carry a workspace:^ dependency on grist-widget-sdk purely for monorepo DX — neither is published, and the template's version is a load-bearing invariant (scripts/smoke/create-widget.sh asserts a fresh scaffold's own version stays 0.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's package.json to 0.0.2 as a pure side effect, and that version rode straight into the next create-grist-widget publish via build-template.mjs, breaking the smoke test on the next scaffold. Fixed two ways: both packages are listed in .changeset/config.json ignore so Changesets never touches their version again, and build-template.mjs now force-sets the embedded template's version to 0.0.1 at the one choke point that actually matters, regardless of what the source repo's package.json says.

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

On this page