Releasing
Every published Pluto package uses the same pipeline: Conventional Commits, release-please, a CI gate, and npm trusted publishing. Nobody edits a version number by hand. Nobody runs npm publish from a laptop, except for the first version of a new package (see A new package).
These files implement the pipeline. They are byte-identical in every repository that has them. If you change one, copy it to all of them.
| File | Where | What it does |
|---|---|---|
.github/workflows/ci.yml | every repository | Lint, typecheck, test, and build, and the duplicate check. See What CI checks. |
.github/workflows/release.yml | the packages | release-please, the verify gate, npm publish, and the fan-out. |
.github/scripts/plutocms-fanout.sh | the packages | Starts the update workflow in every repository, in two waves. See The fan-out. |
.github/workflows/plutocms-update.yml and .github/scripts/plutocms-update.mjs | every repository with @plutocms dependencies | Moves those dependencies to the newest versions. See Dependency updates. |
.github/dependabot.yml | every repository | Grouped updates of the other dependencies. |
Dependency ranges
The packages depend on each other with wide ranges: "@plutocms/core": ">=0.38.0 <1.0.0", not ^0.38.0.
core ─┬─> supabase ─┬─> supabase-shop
│ └─> supabase-blog
└─> docs
Before 1.0, a caret does not accept the next minor version: ^0.38.0 refuses 0.39.0. Then every new minor of core needed a release of every layer, from the bottom up, or an app installed two copies of @plutocms/core. Two copies of a Nuxt layer break the app.
With a wide range, one version satisfies every layer, so the package manager installs one copy, and a layer needs no release when only a dependency moves. A release of core reaches the layers and the apps in one step: the update workflow runs everywhere at once.
The lower bound still matters. When a layer starts to use a newer API of core, raise its lower bound (>=0.39.0 <1.0.0) in a fix: or feat: commit, and release the layer. The upper bound stays <1.0.0: 1.0 is a deliberate step.
How a release happens
- Merge a pull request into
mainwith Conventional Commit messages. - release-please opens a release pull request. It is titled
chore(main): release X.Y.Z. It collects every releasable commit since the last tag, writesCHANGELOG.md, and bumpspackage.jsonand.release-please-manifest.json. - Merge the release pull request when you decide to release. The push to
mainruns the workflow again. This time release-please creates the git tag and the GitHub Release. - The
verifyjob runsci.ymlagain against the tag. - The
publishjob checks out the release commit, sets up Node 24 and the latest npm, and installs withbun install --frozen-lockfile. For each released package, it skips a version that npm already has. Then it runsnpm publish --dry-run, which prints the tarball contents, andnpm publish --access public. - The
fanoutjob waits until npm serves the new version. Then it starts the update workflow in every repository that uses the package (see The fan-out). The layers refresh their lockfiles, and the templates and the docs site move to the new version. Each one merges when its CI is green.
Your manual actions are to merge the feature pull request and the release pull request.
A repository with many packages
The pluto and supabase repositories have many packages. release-please-config.json has one entry for each package folder, and separate-pull-requests: true:
- Each package gets its own release pull request. A commit releases the packages whose folders it changes. A change to the root of the repository (CI, skills) releases nothing.
- The tags of
@plutocms/coreand@plutocms/supabasearevX.Y.Z. The tags of the other packages start with the folder name of the package, for exampledocs-vX.Y.Zandsupabase-shop-vX.Y.Z. - The
publishjob publishes each package that the push released. - When two release pull requests are open, the second one conflicts on
.release-please-manifest.jsonafter the first one merges. Mergemaininto it, and keep both versions in the manifest.
The release script takes the folder of the package as a fourth argument:
scripts/release.sh pluto @plutocms/core 0.39.0 packages/core
scripts/release.sh pluto @plutocms/3d 0.3.0 packages/layers/addons/3d
A new package
npm can add a trusted publisher only to a package that exists. So the first version of a new package is published by hand:
- release-please opens the release pull request of the new package. For a package with no release yet, it uses
initial-versioninrelease-please-config.json(set it to0.1.0). - Publish that version from the branch of the release pull request:
npm publish --access publicin the folder of the package. - On npmjs.com, add the trusted publisher: GitHub Actions, the
plutocmsorganization, the repository, and the workflowrelease.yml. - Merge the release pull request. The
publishjob sees that npm has the version, and skips it.
verify job checks the tag before publish. Its pull_request run can stay action_required.The release App
The workflow uses a token of the plutocms-release GitHub App. The App is installed on all repositories of the organization, with read and write access to Actions, Contents, Issues, and Pull requests. The organization variable PLUTO_RELEASE_APP_ID and the organization secret PLUTO_RELEASE_APP_PRIVATE_KEY hold its ID and its private key.
- release-please uses the App token. A pull request that
github.tokenopens starts no workflow. A pull request that the App opens starts CI. - The
fanoutjob uses an App token for all repositories of the installation.github.tokencannot start a workflow in a different repository.
When the App gives no token (it is not installed, or the key is wrong), release-please uses github.token, and the run shows the warning The plutocms-release App gave no token. The release continues, but the fanout job fails. Then fix the App, and start the fan-out by hand (see below). Without the PLUTO_RELEASE_APP_ID variable, the workflow skips the fanout job.
The fan-out
plutocms-fanout.sh starts plutocms-update.yml in two waves, and waits for each run:
- The layers:
pluto(core, docs and 3D) andsupabase(supabase, supabase-shop and supabase-blog). - The apps:
pluto-admin-template,pluto-blog-template,pluto-supabase-shop-template, andplutocms. Wave 2 starts only when every layer is merged or up to date. A release that breaks a layer stops at the layer.
For each repository, the job log shows merged, up to date, or FAILED with a link to the run.
To start it by hand, run it from a package checkout with a token that can start workflows in the other repositories:
bash .github/scripts/plutocms-fanout.sh # both waves
bash .github/scripts/plutocms-fanout.sh plutocms/plutocms # one wave, these repositories
PLUTO_FANOUT_APPS (a space-separated list of owner/repo) adds apps outside the organization to wave 2. The App token cannot get to them, so use this only with your own token.
Commit messages
release-please reads the commit subjects. Only these prefixes produce a release:
| Prefix | Effect before 1.0 | Effect after 1.0 |
|---|---|---|
fix: | patch | patch |
feat: | minor | minor |
feat!: or a BREAKING CHANGE: footer | minor | major |
All repositories set bump-minor-pre-major: true. While a package is 0.x, a breaking change bumps the minor version. It never jumps to 1.0.0. Going to 1.0 is a deliberate act: add "release-as": "1.0.0" to release-please-config.json, or put Release-As: 1.0.0 in a commit footer.
chore:, docs:, refactor:, style:, test:, and ci: do not trigger a release. A repository can sit for weeks with no release pull request. That does not mean the pipeline is broken.
Cross-layer breaking changes
Some changes break the contract between layers. For example, the move to one Tailwind build for each app.
- Mark each repository change with
feat!:or aBREAKING CHANGE:footer. - Release the upstream package first. In each layer that needs the change, raise the lower bound of the range to the new version in the same pull request as the code change, and release that layer.
- The wide range does not stop an app from installing a new upstream version before a layer catches up. Two things protect the apps:
- The update runs in two waves (see Dependency updates): the layers first, then the apps only when every layer passed. A release that breaks a layer stops at that layer.
- When you know a release breaks a layer, cap that layer below it until it is fixed, for example
">=0.34.0 <0.35.0", and release the layer. An app that moves to the new upstream then gets two copies, the duplicate check fails its update pull request, and the app stays on the old version until the layer catches up.
- Before 1.0 the API changes often, so expect this for most minor releases of core. The layers that need the change release in parallel: one round, not a chain.
create-plutodownloads themainbranch of a template directly. Update a template only when the packages it needs are on npm.
version in package.json by hand. It makes package.json differ from .release-please-manifest.json. release-please trusts the manifest, so the next release computes from the wrong base.What CI checks
ci.yml runs on every pull request and every push to main. The verify job of release.yml and the update workflow call it too. With Bun, it:
- Restores the cache of the Bun packages (
actions/cache, keyed bybun.lock). - Installs with
bun install --frozen-lockfile. - Checks for duplicate Pluto packages. It fails when
bun.lockresolves@plutocms/coreor@plutocms/supabaseto more than one version, and the error names the versions. It also fails whenbun.lockhas@plutocms/pluto, the old name of core. - Runs
lint,typecheck,test, andbuild, in that order. It runs only the scripts that exist in thepackage.jsonof the repository. For example, a template has notest.
A pull request or a push that changes only Markdown files at the root of the repository or files in .claude/ does not run CI.
Dependency updates
The @plutocms packages
The Update @plutocms workflow (plutocms-update.yml) moves the @plutocms dependencies of a repository to the newest versions on npm:
- A wide range (a layer) keeps its text. Only
bun.lockmoves, so the layer tests against the new version. - A caret range or an exact version (a template, the docs site, an app) moves to the new version.
- In a repository with many packages (a Bun workspace), it reads the
package.jsonof each package too. - Then
bun updateof every@plutocmspackage drops an older copy nested under another one, so the lockfile keeps one copy.
Then it pushes the branch deps/plutocms, opens a pull request, runs ci.yml on it, and merges when CI is green. The commit is chore(deps), so a package does not release for it. A pull request that github.token opens starts no workflow, so the update workflow runs ci.yml itself.
The workflow moves only the direct dependencies of a repository. A layer that uses core only through another layer (supabase-shop and supabase-blog, through supabase) lists @plutocms/core as a dev dependency, with a wide range. Then its CI tests the newest core too. Apps do not install dev dependencies, so the published package does not change.
It runs when the fan-out starts it after a release, when you start it (workflow_dispatch), and every 6 hours.
When a layer fails in wave 1, fix it first. Its update pull request stays open, and the next run updates it.
An app outside the Pluto organization can use the same two files. The repository setting Allow GitHub Actions to create and approve pull requests must be on.
Other dependencies
Dependabot opens one pull request a week with the minor and patch updates (groups), and one for each major update. It ignores @plutocms/*, which the update workflow owns.
What gets published
A Nuxt layer ships its source: nuxt.config.ts, app/, server/, shared/, modules/, i18n/, db/, and public/, when the layer has them. There is no build step. Only create-pluto builds before it publishes, with tsdown.
- Layer packages use
.npmignore, a denylist. create-plutousesfiles: ["bin", "dist"], an allowlist.
The publish step runs npm publish --dry-run first. It prints the file list into the job log.
Never publish these: bun.lock, .github/, .vscode/, .agents/, eslint.config.mjs, release-please-config.json, .release-please-manifest.json, test/, shared/types/supabase.ts, and anything that matches .env or .env.* (except .env.example).
shared/types/supabase.ts needs an .npmignore entry, not only a .gitignore entry. The pluto-migrations module can seed a placeholder file in a repository that extends supabase. npm pack does not read .gitignore. A published placeholder breaks the typecheck of a consumer.Set up a new releasable repository
- Copy
ci.yml,release.yml,plutocms-update.yml,.github/scripts/plutocms-update.mjs,.github/scripts/plutocms-fanout.sh,release-please-config.json,.npmignore, and.github/dependabot.ymlfromscripts/shared/in the workspace. Use wide ranges (>=x.y.z <1.0.0) for the@plutocmsdependencies. - Create
.release-please-manifest.jsonwith one entry for each package folder, for example{ "packages/core": "0.42.0" }. Each version must agree with thepackage.jsonof the package. - In
package.json, setname,version,license,description,publishConfig: { "access": "public" }, and arepositoryfield. npm provenance refuses to publish without therepositoryfield. - On npmjs.com, configure trusted publishing for the package. Link the GitHub repository and the workflow file name
release.yml. You need noNPM_TOKEN. Thepublishjob declaresid-token: writefor OIDC. - A new repository in the organization gets the release App automatically, because the App is installed on all repositories. If it has
@plutocmsdependencies, add it toLAYERSorPLUTO_APPSinplutocms-fanout.sh, and copy the script to all the packages. - Optional: protect
main. Require the CI check and require pull requests.
Trusted publishing cannot create a new package. It needs the package to exist on npm. For the first release of a repository, expect to publish once by hand.
Recover from a failed publish
The tag and the GitHub Release exist before npm publish runs. If publishing fails, release-please does not help. Use the manual trigger:
Actions, Release, Run workflow, then enter the existing tag, for example
v0.1.2.
This skips release-please, runs the verify gate against the tag, and publishes. It is safe to run again, because npm rejects a duplicate version.
Troubleshooting
No release pull request appeared. Every commit since the last tag was a non-releasing type. Check git log <last-tag>..main.
The release pull request shows the wrong next version. .release-please-manifest.json disagrees with reality. It is the source of truth. Fix the manifest in a commit on main.
npm publish fails with a repository or provenance error. The package has no repository field, or the field points to another repository.
npm publish fails with an auth error. Trusted publishing is not configured for the package, or it names another workflow file. The file must be release.yml.
bun install --frozen-lockfile fails. bun.lock is behind package.json. Run bun install locally and commit the lockfile.
The duplicate check fails. Two @plutocms ranges do not overlap, so Bun installed two copies. The error names the versions. Make the ranges overlap: a layer uses a wide range, and an app moves to the newest version.
An update pull request stays open. The CI of the update workflow failed: the new version breaks this repository. Fix the repository on the deps/plutocms branch, or raise the lower bound in the layer that needs the change. The next run updates the same pull request.
CI passed on the pull request, but the verify job failed. Something merged into main between the CI run and the release. Fix forward on main. The tag stays unpublished until it is green.
The release pull request changes more than the version. This can happen when you merge several pull requests in quick succession. Before you merge a release pull request, run git diff origin/main origin/<release-branch> -- package.json and check that only version changes. If more changes, close the pull request, delete its branch, and run the Release workflow on main again.
npm publish fails with 404 Not Found on the first release. The package does not exist on npm yet. Publish the version once by hand, then configure trusted publishing. Then run the same tag again through Actions, Release, Run workflow.
Typecheck fails with Cannot find module '../../node_modules/@plutocms/supabase/shared/types/supabase'. A dependency shipped the placeholder in its tarball. Fix it at the source: add the .npmignore entry to the repository and release again.
Known follow-ups
- The packages
@plutocms/supabaseand@plutocms/supabase-shophave nolicensefield. The other packages declare MIT.