Releasing
How a new version of the published packages reaches npm. Maintainers only.
Model
- Published packages: the four libraries
@pidrang/core,@pidrang/supabase,@pidrang/material,@pidrang/tailwind, and the three CLI packages@pidrang/cli,@pidrang/cli-supabaseandpidrang(the bin, Nx projectpidrang-cli). - One version for all seven packages (a fixed release group). Every release bumps and publishes all of them to the same number, so users never have to work out which
@pidrang/materialgoes with which@pidrang/core. The CLI relies on it too:pidrang initinstalls@pidrang/core,@pidrang/supabaseand@pidrang/materialat its own version, so a CLI published without the libraries at the same version would install libraries older than the pages it generates. - Tool: Nx Release, configured under
releaseinnx.json. - Version source: Conventional Commits since the last
vX.Y.Ztag. From 1.0.0 on,featbumps minor,fixbumps patch and a breaking change (!or aBREAKING CHANGE:footer) bumps major. Below 1.0.0 each level shifts down: a breaking change bumps minor, andfeatandfixbump patch. TheReleaseworkflow takes the exact version (0.1.0), because the gate packs and tests the tarballs at that version before anything is tagged; use the local dry run below to see which version the commits call for. - Changelog:
CHANGELOG.mdat the repository root, generated from the same commits, plus a GitHub Release for each tag. It lists only the commits that change a file of a published package (libs/core,libs/supabase,libs/material,libs/tailwind,libs/cli,libs/cli-supabase,apps/pidrang-cli):tools/changelog-renderer.mjsreads each commit's files withgit showand leaves out the ones that only touch the demos,libs/demo-shared,tools/,docs/or root files. A commit that touches a published package and a demo stays in, under its own type and scope.
Semver rules
- Below 1.0.0, minor releases may contain breaking changes and patch releases never do.
- From 1.0.0 on, breaking changes only ship in a major release.
- Adding support for a new Angular major is a minor release. Dropping the previous one is a breaking change.
@pidrang/supabase,@pidrang/materialand@pidrang/tailwinddeclare@pidrang/coreas a peer dependency. In the source tree it isworkspace:*; during a release Nx rewrites it, in the published manifest only, to a caret range of the release version (^0.2.0). Below 1.0.0 a caret range allows patch updates only, so a package pinned to^0.2.0core does not accept core0.3.0. That is intentional: the fixed group guarantees the packages are released together. The same rewrite applies to@pidrang/cli-supabaseandpidrang, which depend on@pidrang/cliand@pidrang/core.
Cutting a release
Releases run from the Release GitHub Actions workflow on main, never from a laptop. It runs four jobs, each only when the previous one passed:
checkrefuses a version that is not exact (0.1.0or0.2.0-rc.1).gateruns the full release gate at that version and uploads the tested tarballs as therelease-gate-<commit>artifact.tagdownloads the artifact, checks it withverify-identities.mjs --version --commit, lints and tests, then runsnx release <version> --skip-publish. That builds the packages, writes the version to eachpackage.json, prepends the new section toCHANGELOG.md, commitschore(release): vX.Y.Z, tagsvX.Y.Z, pushes both and creates the GitHub Release. It publishes nothing.publishruns in thenpmenvironment. It downloads the same artifact and runstools/release-gate/publish.mjs, which checks the artifact again and publishes the seven tested tarballs withnpm publish <tarball> --access public --provenance, in dependency order: core, supabase, material, tailwind, cli, cli-supabase, thenpidranglast. A version with a-goes to thenexttag, any other one tolatest.
The tagged commit differs from the tested commit only by the version bump and the changelog: the gate packed the tarballs from the tested commit with the same nx release version rewrite, so their manifests already hold the release version and the caret ranges.
Steps:
- Make sure
mainis green and contains everything meant for the release. - Open Actions, Release, Run workflow on
mainand enter the version. Leavedry-runchecked first and read the log: the gate result, the version and changelog entry of thetagjob, and thenpm publish --dry-runoutput of each package in thepublishjob. - Run it again with
dry-rununchecked. - Check the tag, the GitHub Release and the npm pages, including the provenance badge on each package page.
For the very first release, set first-release, because no version tag exists yet.
To preview locally, without committing, tagging, pushing or publishing:
bash
pnpm exec nx release --skip-publish --dry-runDo not run nx release without --dry-run on a laptop: it commits, tags, pushes and publishes.
Verifying the artifacts
pnpm run verify:artifacts runs before every release, and every pull request runs the same checks through the build mode of the release gate. It checks what would actually be published, not the source tree:
- builds all seven packages (the four libraries and the three CLI packages) and runs the real
nx release versionstep, so the internalworkspace:*dependencies are rewritten exactly as in a release, - packs each package with
npm pack(tools/pack-packages.mjs), tools/check-tarballs.mjsinspects the tarballs: noworkspace:,file:orlink:dependency, a shared version,READMEandLICENSEpresent, everymain,types,exportsandbintarget present, a shebang on the binary, no test files, environment files, caches or credential-like strings, and source maps only when they embed their sources,tools/check-install.mjsinstalls all the tarballs together in an empty directory, compiles a file importing the four Angular packages through their package exports, imports the libraries at runtime, and runs the installedpidrangbinary (usage and start ofinit).
The script restores nx.json, the package manifests and pnpm-lock.yaml when it finishes, so it is safe to run locally. It needs network access to install the peer dependencies from the registry.
The release gate
tools/release-gate/run.mjs tests the tarballs as an application installs them. It never uses the source tree, tsconfig paths or pnpm workspace links.
--mode build(every pull request):- packs the seven packages at one version (
0.0.0-gate.<commit>by default,--versionto choose) intotmp/release-gate/tarballs, and runs the tarball and install checks above on them; - writes
tmp/release-gate/identities.json: name, version, size and sha256 of each tarball, the source commit and the Node, npm and pnpm versions; - copies
tools/release-gate/consumers/materialandtools/release-gate/consumers/tailwind(two small Angular 22 apps with strict templates) to an empty directory outside the repository, and installs the tarballs there with npm, along with the Angular, Material, Supabase and Tailwind versions of the rootpackage.json; - in the Material app, generates
resources.ts, the CRUD pages and the resource routes with the functions of the installed@pidrang/cli, fromtools/release-gate/generation-plan.json(there is no database to read in this mode); - runs
ng buildon both apps. The Tailwind build must contain the utility classes listed intools/release-gate/tailwind-classes.json, which only exist when the@sourceline reaches the installed@pidrang/tailwind; - records each app's installed versions in
identities.jsonand keeps itspackage-lock.json.
- packs the seven packages at one version (
--mode full(theRelease gateworkflow, on every push tomain, before every release, and on pull requests from branches of this repository) does the same against a local Supabase stack, except that the Material app is generated by the installedpidrangbinary:pidrang init --planwith thesecret-keystrategy and the stack'ssb_secret_key inPIDRANG_SUPABASE_SECRET_KEY, and the tables, actions and hidden fields ofgeneration-plan.json. The gate fails when theresources.tsthe binary writes differs from the one the functions write fromgeneration-plan.json, so the plan of the build mode follows the real schema. It also builds a third app,starter:ng newwith the workspace's Angular CLI, the tarballs installed with npm, thenpidrang init --planwith the same tables and"layout": "sidebar". It fails when init leaves apidrang:comment inapp.ts,app.html,app.spec.ts,app.config.tsorapp.routes.ts, then puts the stack's publishable key in the environment files and runsng testandng build. A fourth app,starter-private, repeats that withgeneration-plan-private.json, which adds thestaff_notestable (readable only by a signed-in user), andconsumer.mjs --reads-need-sign-in, which puts"readsNeedSignIn": truein the init plan so the generated routes carry the sign-in guard. Both plans list the tables in the order the stack introspects them, since the gate comparesresources.tsbyte for byte; that order also decides where a new app opens, so ingeneration-plan-private.jsonstaff_notescomes beforeordersand has the list/redirects to. Then it:- runs Playwright on the four apps and both demos (
tools/release-gate/e2e), each served by the gate on its own port. A test fails if the page sends a request to any host but127.0.0.1,localhostor[::1]. The fixtures are reloaded before each test, and the tests run one at a time; - runs the Supabase provider conformance suite and fails unless every conformance test ran and passed;
- records the pidrang/supabase commit and
gate: { mode: 'full', passed: true }inidentities.json, then checks the tarballs against it again. Each run deletestmp/release-gatebefore its first check, so a run that stops at any step leaves no manifest, or one withpassed: false.
- runs Playwright on the four apps and both demos (
The browser tests cover:
- the list up to row eleven on page two;
- create, show, edit and delete as the fixture writer;
- delete confirmation;
- a failed delete retried;
- a refused delete;
- a write that row-level security refuses;
- the anonymous sign-in prompt and its link;
- the action flags;
- unknown resources next to an app route declared after the resource routes;
- route input binding;
- the Material theme and the Tailwind styles;
- in the
starterapp, the redirect of/to the first list, the layout and its menu labels, and the sign-in link init wrote; - in the
starter-privateapp, the sign-in page that/and a private table both open for a signed-out visitor, and the first list and that table after signing in.
Pull requests from forks and from Dependabot skip the full mode: they cannot read the SUPABASE_FIXTURES_TOKEN secret. GitHub counts a skipped job as passed, so a maintainer runs the full gate locally before merging one that touches what the gate tests. A new push to a pull request cancels its running full gate; runs on main and in a release are never cancelled.
Both workflows (CI in build mode, Release gate in full mode) upload tmp/release-gate (tarballs, manifest, build logs, Playwright report and traces of failed tests) as an artifact, even when it fails.
Running it locally
- Clone pidrang/supabase at the commit in
tools/release-gate/supabase-fixtures.ref. - Start its stack with the CLI version the repository pins:
npx -y "supabase@$(cat supabase/.cli-version)" start. If that clone is linked to the hosted project,supabase/.temppins the hosted service versions, and some of those images are not published; add-x storage-api,imgproxy, the gate does not need them. - In this repository:bash
PIDRANG_SUPABASE_DIR=../supabase node tools/release-gate/run.mjs --mode full
The gate stops early when the pidrang/supabase clone is not at the pinned commit or has local changes, and when the demos' environment.local.ts does not hold the running stack's publishable key, so a stack started by another CLI version fails too. The gate runs every supabase command, including the ones in the fixture scripts, through the CLI version pinned in supabase/.cli-version, whatever is installed globally. It needs ports 4310, 4311, 4320, 4321, 4322 and 4323 free.
A local run on a working tree with uncommitted or untracked files can pass, but it says that it cannot be published: its last check runs verify-identities.mjs --allow-dirty. In CI (CI=true) the same run fails.
Publishing what the gate tested
node tools/release-gate/verify-identities.mjs <directory> fails unless <directory>/identities.json records a passed full gate run on a clean working tree, with one tarball of each of the seven packages, all at the version the gate packed. With --version <version> and --commit <sha> it also fails when the manifest records another version or commit. It then re-hashes the tarballs in <directory>/tarballs and fails when any of them differs from the manifest in name, version, size or content, or when one was not tested.
node tools/release-gate/publish.mjs <directory> --version <version> --commit <sha> [--dry-run] runs that check with both options and publishes nothing when it fails. The Release workflow publishes only through it, so nx release never packs the packages again. Before each package it reads the integrity npm records for that exact version: it publishes when the version is not on npm, skips it when npm holds the same tarball, and stops when npm holds a different one.
The workflow checks out pidrang/supabase with the SUPABASE_FIXTURES_TOKEN secret: a fine-grained token with read-only access to the contents of that repository.
One-time setup
- The
@pidrangorganization exists on npm. Make sure the publishing account is a member with publish rights. An organization does not reserve the unscopedpidrangpackage name used by the CLI: check thatnpm view pidrangreturns 404 before the CLI release. - The
publishjob authenticates to npm with trusted publishing (OIDC), not a token: it grantsid-token: writeand installs npm 11.21.1 (trusted publishing needs npm 11.5.1 or later and Node 22.14 or later). Each of the seven packages has a trusted publisher on npmjs.com, under the package's Settings: publisher GitHub Actions, organizationpidrang, repositorypidrang, workflow filenamerelease.yml, environmentnpm.Allow npm publishis checked, since the job runsnpm publishdirectly;Allow npm dist-tagis not, since the dist-tag is set bynpm publish --tag. A trusted publisher can only be added to a package that already exists on npm, so a new package needs a first publish with a short-lived granular token (NODE_AUTH_TOKENon that step) before it gets one; remove the token afterwards. A trusted publisher cannot be edited: to change it, delete it and create a new one. - The
publishjob runs in thenpmenvironment. GitHub creates it on the first run if it does not exist; add required reviewers to it to approve each publish by hand. - Provenance requires a public repository. Make the repository public before the first publish.
- If
mainis protected, allow the release workflow'sgithub-actions[bot]to push to it, or thechore(release)commit and tag push will be rejected.
If something goes wrong
- Publish failed after the tag was pushed: npm versions cannot be reused once published, but a version that never reached npm can be published again. Use Re-run failed jobs on the same workflow run: only
publishruns again, with the same artifact, and it skips the packages npm already holds with the same content. Do not create a new version. The artifact is kept 30 days. - npm refuses to authenticate the publish (
ENEEDAUTH, or a 404 or 403 onnpm publish): check the trusted publisher of that package on npmjs.com. The organization, repository, workflow filename (release.yml, without its folder) and environment (npm) must match exactly, andAllow npm publishmust be checked. Fix it, then use Re-run failed jobs as above. - The
tagjob failed after pushing the tag (for example while creating the GitHub Release):publishdoes not run, and re-runningtagfails because the tag already exists. The workflow has no automated recovery for this case yet: create the GitHub Release by hand from theCHANGELOG.mdsection and open an issue before publishing that version. - A bad version reached npm: publish a fixed patch version and, if needed,
npm deprecatethe bad one. Avoidnpm unpublish. - Wrong changelog entry: edit
CHANGELOG.mdand the GitHub Release by hand in a follow-up commit.
