Multi-package npm workspace
This page is for repos that ship multiple npm packages from one workspace — @scope/core + @scope/parser + @scope/runtime, or any family of sibling packages published under one repo. No native code, no per-platform families — just plain npm packages that happen to live together.
putitoutthere orchestrates the publish ordering: cascading a change to @scope/core through every package that depends on it, publishing them in topological order, and cutting a tag per package.
What piot covers
| Responsibility | piot | Your workflow |
|---|---|---|
Decide which packages ship on a given merge (cascade via depends_on) | ✅ | |
| Topologically order the publishes (dependencies first) | ✅ | |
| Compute the next version from a commit trailer | ✅ | |
Rewrite version in each package's package.json | ✅ | |
| OIDC trusted publishing to npm, per package | ✅ | |
npm publish --provenance per package | ✅ | |
| Skip-if-already-published idempotency per package | ✅ | |
Cut a tag per package ({name}-v{version}) | ✅ | |
Update inter-package version pins in package.json (e.g. "@scope/core": "0.4.1") | ⚠️ — see gotchas | |
Build each package (tsc, tsup, etc.) | ✅ | |
| Install Node + your package manager | ✅ (runner prereqs) | |
| Register the trusted-publisher policy on npm per package (one-time) | ✅ |
Package boundaries are declared, not discovered
piot has no workspace auto-detection. You declare one [[package]] per package you want piot to publish. Workspace members not in putitoutthere.toml are ignored — convenient for repos that include private/internal packages (@scope/internal-fixtures, @scope/eslint-config) that should never hit npm.
This also means piot doesn't read your pnpm-workspace.yaml / workspaces field — those govern install-time resolution, not publish-time orchestration. Keep them in sync manually.
Configuration shape
One [[package]] per published package, with depends_on tracing the inter-package dependency graph:
[putitoutthere]
version = 1
[[package]]
name = "scope-core"
kind = "npm"
npm = "@scope/core"
path = "packages/core"
paths = ["packages/core/**"]
[[package]]
name = "scope-parser"
kind = "npm"
npm = "@scope/parser"
path = "packages/parser"
paths = ["packages/parser/**"]
depends_on = ["scope-core"]
[[package]]
name = "scope-runtime"
kind = "npm"
npm = "@scope/runtime"
path = "packages/runtime"
paths = ["packages/runtime/**"]
depends_on = ["scope-core", "scope-parser"]A change inside packages/core/ cascades all three; a change only inside packages/runtime/ ships just that one. piot's name is the internal identifier; the published name lives in npm = "@scope/…" and can carry any scope or scoping convention you like.
Workflow shape
The build job runs once per package on the matrix; the publish job runs once and walks the topological order:
build:
needs: plan
if: fromJSON(needs.plan.outputs.matrix || '[]')[0] != null
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.plan.outputs.matrix) }}
runs-on: ${{ matrix.runs_on }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: '20' }
- name: Install workspace
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm --filter ${{ matrix.name }} build
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: ${{ matrix.artifact_path }}
publish:
needs: [plan, build]
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: https://registry.npmjs.org
- name: Configure git identity
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- uses: actions/download-artifact@v4
with: { path: artifacts }
- uses: thekevinscott/putitoutthere@v0
with:
command: publishThe pnpm --filter <name> step in the build job runs only the planned package's build, not the whole workspace — important when only one package needs to ship.
One-time prerequisites before your first release
- Register a trusted publisher on npm for every published package name. npm trust policies are per-package; a policy on
@scope/coredoesn't cover@scope/parser. Use pending publishers for brand-new package names. - Declare
[package.trust_policy]on each[[package]]sodoctorflags missing or misconfigured policies before the publish runs. - Delete any long-lived
NPM_TOKENrepo secret once OIDC works for every package.
Gotchas specific to this shape
- Inter-package version pins are yours to manage. When piot bumps
@scope/coreto0.4.1, it rewritespackages/core/package.jsonbut does not updatepackages/parser/package.json's"@scope/core": "0.4.0"line. npm publishes succeed regardless, but consumers readingpackage.jsonsee a stale pin. Either use workspace protocol ("@scope/core": "workspace:*") and let your package manager resolve at publish time, or update the pin yourself before the piot step. workspace:*resolution at publish time. pnpm and yarn rewriteworkspace:*to a real version when packing. piot publishes the rewritten tarball, so this works — but the rewrite happens atpnpm pack/pnpm publishtime, notpnpm build. Your build job needs to produce the published tarball (e.g.pnpm pack --pack-destination=dist), not just the compiled JS. Otherwise piot uploads the wrong contents.- Per-package trust policies multiply. With N packages, you'll register N trust policies, N pending-publisher rows in the npm UI. Easy to miss one for a brand-new sibling. piot's
doctorwill flag missing policies, but only after the first attempted publish has failed. - Per-package tags multiply too. Each merge that cascades all N packages produces N tags. If consumers grep your tag list, this can be noisy; that's the cost of per-package versioning.
- Provenance + workspace dependencies. npm's
--provenanceflag inspects the build environment to attest the package's origin. If yourpnpm packstep runs in a job whoseid-token: writepermission isn't set, provenance is silently disabled. Thepermissions:block at the top of the example workflow above sets it; a straypermissions: read-allanywhere up the chain breaks it.
Further reading
- Single-package npm library — if you only ship one package.
- Cascade — how
depends_onandpathsinteract to decide what ships. - npm platform packages — if any of your sibling packages ship native code.
- Configuration reference.
- Runner prerequisites.