Contributing

Releasing

How Pluto packages are released with release-please and npm trusted publishing, and how the update workflow brings a release to every repository.

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.

FileWhereWhat it does
.github/workflows/ci.ymlevery repositoryLint, typecheck, test, and build, and the duplicate check. See What CI checks.
.github/workflows/release.ymlthe packagesrelease-please, the verify gate, npm publish, and the fan-out.
.github/scripts/plutocms-fanout.shthe packagesStarts the update workflow in every repository, in two waves. See The fan-out.
.github/workflows/plutocms-update.yml and .github/scripts/plutocms-update.mjsevery repository with @plutocms dependenciesMoves those dependencies to the newest versions. See Dependency updates.
.github/dependabot.ymlevery repositoryGrouped 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

  1. Merge a pull request into main with Conventional Commit messages.
  2. 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, writes CHANGELOG.md, and bumps package.json and .release-please-manifest.json.
  3. Merge the release pull request when you decide to release. The push to main runs the workflow again. This time release-please creates the git tag and the GitHub Release.
  4. The verify job runs ci.yml again against the tag.
  5. The publish job checks out the release commit, sets up Node 24 and the latest npm, and installs with bun install --frozen-lockfile. For each released package, it skips a version that npm already has. Then it runs npm publish --dry-run, which prints the tarball contents, and npm publish --access public.
  6. The fanout job 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/core and @plutocms/supabase are vX.Y.Z. The tags of the other packages start with the folder name of the package, for example docs-vX.Y.Z and supabase-shop-vX.Y.Z.
  • The publish job publishes each package that the push released.
  • When two release pull requests are open, the second one conflicts on .release-please-manifest.json after the first one merges. Merge main into it, and keep both versions in the manifest.

The release script takes the folder of the package as a fourth argument:

Terminal
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:

  1. release-please opens the release pull request of the new package. For a package with no release yet, it uses initial-version in release-please-config.json (set it to 0.1.0).
  2. Publish that version from the branch of the release pull request: npm publish --access public in the folder of the package.
  3. On npmjs.com, add the trusted publisher: GitHub Actions, the plutocms organization, the repository, and the workflow release.yml.
  4. Merge the release pull request. The publish job sees that npm has the version, and skips it.
You do not need to wait for CI on the release pull request. It changes only the version and the changelog, and the 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.token opens starts no workflow. A pull request that the App opens starts CI.
  • The fanout job uses an App token for all repositories of the installation. github.token cannot 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:

  1. The layers: pluto (core, docs and 3D) and supabase (supabase, supabase-shop and supabase-blog).
  2. The apps: pluto-admin-template, pluto-blog-template, pluto-supabase-shop-template, and plutocms. 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:

Terminal
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:

PrefixEffect before 1.0Effect after 1.0
fix:patchpatch
feat:minorminor
feat!: or a BREAKING CHANGE: footerminormajor

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 a BREAKING 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-pluto downloads the main branch of a template directly. Update a template only when the packages it needs are on npm.
Never edit 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:

  1. Restores the cache of the Bun packages (actions/cache, keyed by bun.lock).
  2. Installs with bun install --frozen-lockfile.
  3. Checks for duplicate Pluto packages. It fails when bun.lock resolves @plutocms/core or @plutocms/supabase to more than one version, and the error names the versions. It also fails when bun.lock has @plutocms/pluto, the old name of core.
  4. Runs lint, typecheck, test, and build, in that order. It runs only the scripts that exist in the package.json of the repository. For example, a template has no test.

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.lock moves, 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.json of each package too.
  • Then bun update of every @plutocms package 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-pluto uses files: ["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

  1. 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.yml from scripts/shared/ in the workspace. Use wide ranges (>=x.y.z <1.0.0) for the @plutocms dependencies.
  2. Create .release-please-manifest.json with one entry for each package folder, for example { "packages/core": "0.42.0" }. Each version must agree with the package.json of the package.
  3. In package.json, set name, version, license, description, publishConfig: { "access": "public" }, and a repository field. npm provenance refuses to publish without the repository field.
  4. On npmjs.com, configure trusted publishing for the package. Link the GitHub repository and the workflow file name release.yml. You need no NPM_TOKEN. The publish job declares id-token: write for OIDC.
  5. A new repository in the organization gets the release App automatically, because the App is installed on all repositories. If it has @plutocms dependencies, add it to LAYERS or PLUTO_APPS in plutocms-fanout.sh, and copy the script to all the packages.
  6. 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/supabase and @plutocms/supabase-shop have no license field. The other packages declare MIT.