Adding a new package
Steps to scaffold a new @rooted/* package and get it publishing to npm. Read package design first to confirm the package belongs.
1. Scaffold
Create the directory under packages/. Use packages/util as the template; it's the smallest complete package. Nothing needs adding to pnpm-workspace.yaml, the root package.json, or any workflow. They all work off globs, so a new directory under packages/ is picked up on its own.
packages/<name>/
src/
_module/
<name>.mts # the public entry
tests/
<name>.test.ts
api/
<name>.api.md # generated, but committed
package.json
tsconfig.json
tsconfig.tests.json
tsdown.config.mts
api-extractor.json
readme.mdThe _module/ folder is rooted's convention for the public entry points. The exports map in package.json points at the dist files generated from this folder. tsdown.config.mts globs src/_module/*.mts, so adding a subpath later means adding a file there plus a key in exports, and nothing else.
Copy tsconfig.json, tsconfig.tests.json, tsdown.config.mts and api-extractor.json from packages/util and change the entry-point filename in api-extractor.json. The six scripts are identical in every package, so copy those verbatim too.
package.json checklist
"name":@rooted/<name>"version":"1.0.0-alpha.0". semantic-release takes over after the first publish."publishConfig":json{ "registry": "https://registry.npmjs.org/", "access": "public", "provenance": true }registryis not optional.pnpm lintruns.repo/config/oxlint/check-packages.mjs, which fails for any non-private package whosepublishConfig.registrydoesn't match the one in.npmrc."files": at minimum"dist"and"readme.md". Add any file you ship unbuilt, such as an ambient.d.ts."sideEffects": false, plusengines,license,repository.directory,homepageandbugs.- An
exportsmap that mirrors the_module/files. Seepackages/util/package.jsonfor the shape.
If the package ships a Vite plugin
It needs a plugins/ folder, its own tsconfig, a second tsdown config and a "source" export condition. See shipping a Vite plugin.
API reports
api-extractor.json is what opts a package into the API report. rooted-pipeline api-diff walks packages/, skips any directory without that file, and fails CI when a report drifts from the committed one. So run pnpm build:dev and commit whatever lands in api/. api/temp/ is gitignored, and an api/.npmignore containing * keeps the folder out of the published tarball.
2. First publish
OIDC trusted publishing has a chicken-and-egg problem: the trusted publisher is configured per package on npmjs, and the package has to exist on npm first. So the very first publish uses a granular access token instead.
2.1 Get a granular access token
- Sign in to npmjs.com and open Access Tokens.
- Generate New Token, choose Granular Access Token.
- Under Packages and scopes, set Read and write, scope to
@rooted. - Copy the token. You don't get to see it again.
2.2 Run the First Publish workflow
In GitHub: Actions, then First Publish, then Run workflow. Inputs:
| Input | Value |
|---|---|
| Branch | main |
| Workspace-relative path to the package | packages/<name> |
| npm automation token | the token from 2.1 |
| Environment | leave blank |
The package name works in that second field too, so @rooted/<name> resolves to packages/<name>. Either way the workflow checks the input against the workspace before it builds, and stops with the list of publishable packages when it doesn't match one.
The branch matters. The workflow publishes whatever it checks out, so the package has to exist on the ref you dispatch from. For a package still sitting in a pull request that means dispatching from that branch rather than main, which is deliberate: the first publish has to happen before OIDC can be configured, and configuring it before the package lands on main keeps the Release workflow from meeting a package it has no trusted publisher for.
The workflow publishes once under the alpha dist-tag, so the new package never claims latest before a real release.
3. Configure OIDC trusted publishing
Once the package exists on npm, switch to OIDC so we don't need a long-lived secret for future releases.
Open
https://www.npmjs.com/package/@rooted/<name>/access.Scroll to Trusted Publishers, then Add a trusted publisher.
Fill in:
Field Value Publisher GitHub Actions Owner Marvin-BrouwerRepository rootedWorkflow release.ymlEnvironment leave blank Save.
From this point on, the Release workflow publishes the new package via OIDC. No NPM_TOKEN secret is needed.
The First Publish workflow also requests
id-token: writeand setsNPM_CONFIG_PROVENANCE: true, so provenance is attached even on the first publish.
4. Verify
https://www.npmjs.com/package/@rooted/<name>exists with thealphatag.- A qualifying commit on
main(feat:,fix:,perf:,docs:) triggers the Release workflow and publishes a newalphapre-release for the package without using the granular token.
5. Update the docs
Five hand-maintained lists. Nothing generates these, so all five need doing:
- Add the package to the table in the root README.md.
- Add it to the table in packages/readme.md.
- Add a brief "what it is, what it depends on" entry in package design, including a line in the layering block at the top.
- If the package has user-facing API, add a guide page under docs/guide/ or docs/advanced/, whichever fits, and list it in docs/guide/readme.md.
- Add that guide to the sidebar in docs/.vitepress/config.mts. This one is easy to miss and there's no check for it. A guide that isn't in the sidebar is unreachable from the published site even though it builds fine, which is exactly what happened to the localization guide.
Then run pnpm docs:build before you push. It isn't part of CI, so a broken docs site gets through everything else. Watch for a double opening brace in an inline code span: VitePress parses it as a Vue interpolation and the build fails on it. Write those as {{ instead.
The new package is not done until it is documented somewhere a user will actually find it.