A release is a git tag, a GitHub Release, and a package on two registries. Publishing a GitHub Release is the trigger: .github/workflows/release.yml runs the test suite, checks the tag against package.json, and ships the same commit to both.
| Registry | Name | Auth | Install needs a token? |
|---|---|---|---|
| npmjs.com | @open-predicate/open-predicate |
OIDC trusted publishing — no secret | No |
| GitHub Packages | @openpredicate/open-predicate |
GITHUB_TOKEN, minted per run |
Yes |
A third name, unscoped open-predicate, is held as a deprecated placeholder. It is not a release target and the pipeline never touches it.
The two release names differ because GitHub Packages accepts only scoped names and the scope must be the repository owner — the OpenPredicate organisation, hence @openpredicate, lowercased because npm rejects uppercase in a package name. npmjs.com carries the @open-predicate scope, which is the npm organisation. Only the name field differs; the workflow rewrites it in the GitHub Packages job and the tarball is otherwise identical.
npmjs.com is the copy to document. It needs no credential to install. The GitHub Packages copy needs an .npmrc with @openpredicate:registry=https://npm.pkg.github.com and a token even though the package is public.
- Bump
versioninpackage.json(andpackage-lock.json—npm installdoes it). - If the schema's grammar changed, bump the version in the schema's
$idtoo. The$idis what consumers pin, so it is the version that actually matters to them. - Add the
CHANGELOG.mdentry and its compare link at the bottom of the file. - Commit, then tag:
git tag -a v0.6.0 -m "v0.6.0" && git push --follow-tags. - On GitHub, draft a Release against that tag, paste the changelog entry, and Publish release.
To rehearse, run it by hand from Actions → Release → Run workflow. It defaults to a dry run, which exercises packaging and the already-published guard without uploading — but see the note below on what a rehearsal cannot prove.
Each publish job checks whether its version already exists and skips if so, so re-running a partially-failed release is safe when one registry succeeded and the other did not.
npmjs.com authenticates by OIDC: the npmjs job requests id-token: write, and npm exchanges that token for a short-lived registry credential. There is no NPM_TOKEN in this repository and nothing to rotate. Provenance is attached automatically, so the tarball links back to the workflow run and the commit that built it — but only for tarballs this workflow publishes. 0.6.0 has none: the bootstrap publish that claims a trusted publisher has to come from a laptop, because OIDC cannot mint a credential for a package that does not exist yet, and the registry metadata records npm 11.12.1 on Node 25 — nobody's runner image. 0.6.1 is the first release published over OIDC and so the first with provenance. Verify it rather than assuming:
npm view @open-predicate/open-predicate@0.6.1 dist.attestations # empty output means there is noneOIDC cannot perform a package's first publish. npm requires the package to exist before a trusted publisher can be attached to it, so the very first version has to go up under a personal login. That bootstrap is a one-off:
npm login # the account must be a member of the open-predicate org
npm publish --access public # claims @open-predicate/open-predicate
npm install -g npm@12 # npm 11 cannot configure trust; see below
npm trust github --file release.yml --allow-publish # run from the repo root
npm trust list # confirm it stucknpm trust github is the CLI equivalent of npmjs.com → the package → Settings → Trusted Publisher → GitHub Actions. Either way the trust is pinned to the repository and the workflow filename — renaming release.yml breaks publishing until the trusted publisher is updated to match.
Four things about that command, all of which cost a failed attempt to learn:
- Do not pass
--repository. Run from the repo root,npm trusttakes both the package name and theowner/repofrompackage.json, and labels them(from package.json)in its output. Passing the repository by hand is how a truncatedOpenPredicate/open-predicatgets into the request: npm warns about the mismatch, then the POST fails with the same bareE400that a stale client produces, so the two causes are indistinguishable from the error alone. --filetakes the workflow's filename only, not a path under.github/workflows/, and it must end in.ymlor.yaml.- Configuring trust needs npm >= 12, and fails opaquely on npm 11. npm 11.12.1 rejects
--allow-publishas an unknown flag, and without it itsPOST /-/package/<pkg>/trustbody carries nopermissionsmember — which the registry now requires, so it answers a bareE400with no message and npm prints nothing more. Comparinglib/trust-cmd.jsbetween the two versions is what shows it: npm 12 setstrustConfig.permissions = ['createPackage'], npm 11 has no such line. This is separate from the npm version publishing needs. - It requires account-level 2FA and prompts for an OTP, so it cannot be run unattended. Add
--dry-runto check the resolved package, file and repository before committing to it —--dry-runreturns before the POST, so it passes on npm 11 even though the real call cannot.
Afterwards, set the package's publishing access to Require two-factor authentication and disallow tokens. That closes off token auth without affecting OIDC, which is the point of moving to it.
The configuration in force, as npm trust list reports it:
id |
d8641874-290e-4f26-b68b-6af472c8cb44 |
type |
github |
repository |
OpenPredicate/open-predicate |
file |
release.yml |
permissions |
publish, stage publish |
The id is written down here because npm trust revoke --id=<id> is the only way to remove a
configuration, and the only way to read the id back is npm trust list, which needs two-factor
auth. It is an identifier, not a credential — revoking still requires an authenticated owner.
permissions covers stage publish as well as publish even though only --allow-publish was
passed. Nothing here uses npm stage publish; narrow it by revoking and re-creating if that
bothers you.
open-predicate, unscoped, is published as a deprecated placeholder so the name cannot be taken by
something unrelated to the project. It carries no schema and no grammar, is not versioned alongside
releases, and the release pipeline never touches it.
It does carry one executable, and only to fail helpfully. npx open-predicate used to print npm's
could not determine executable to run, which tells someone following a stale instruction nothing;
it now names the scoped package and exits non-zero. Recreate and publish it like this:
d=$(mktemp -d) && cd "$d"
cat > package.json <<'JSON'
{
"name": "open-predicate",
"version": "0.0.2",
"description": "Name reserved. The package is @open-predicate/open-predicate — install that instead.",
"license": "MIT",
"author": "Christos Gkoros",
"repository": { "type": "git", "url": "git+https://github.com/OpenPredicate/open-predicate.git" },
"homepage": "https://github.com/OpenPredicate/open-predicate#readme",
"bugs": { "url": "https://github.com/OpenPredicate/open-predicate/issues" },
"keywords": ["open-predicate", "openpredicate"],
"publishConfig": { "registry": "https://registry.npmjs.org" },
"bin": { "open-predicate": "redirect.js" },
"files": ["README.md", "redirect.js"]
}
JSON
cat > README.md <<'MD'
# open-predicate
**This name is reserved. Nothing is published here.**
OpenPredicate is distributed as a scoped package:
```bash
npm install @open-predicate/open-predicate
```
This placeholder exists only so the unscoped name cannot be taken by something
unrelated to the project. It carries no schema and no code, and it is not
versioned alongside releases.
MIT.
MD
cat > redirect.js <<'JS'
#!/usr/bin/env node
// A reserved name with no implementation. Anyone running `npx open-predicate`
// has followed a stale instruction, so say where to go instead of letting npm
// print "could not determine executable to run". Non-zero, because whatever
// they asked for is not here.
console.error(`open-predicate is a reserved name and carries no code.
OpenPredicate is published as a scoped package:
npm install @open-predicate/open-predicate the schema
npx @open-predicate/open-predicate --help the filter-schema generator
https://openpredicate.tech`);
process.exit(1);
JS
chmod +x redirect.js
npm publish
npm deprecate open-predicate "Moved to @open-predicate/open-predicate — install that instead."The deprecation has to be re-applied after each publish: npm deprecate marks a version, so a new
version arrives undeprecated even though every earlier one is marked.
The npm deprecate is the part that matters: it makes npm install open-predicate print the
redirect rather than silently installing an empty package.
Both commands need two-factor auth, so they cannot be run unattended — npm publish reaches
EOTP and prints its web-auth URL only to a TTY.
An unscoped package is owned by the publishing user rather than by an organisation. Transfer it
with npm owner add, or from npmjs.com → the package → Settings → Transfer, so it does not
depend on one account.
These cost time to work out.
- The trust is per-package, not per-org. A second package under
@open-predicateneeds its ownnpm trust github, and its own bootstrap publish. npm trustneeds account-level 2FA and will not accept a granular token with Bypass 2FA, or legacy basic auth.npm viewcan 404 on a package that is already published. It reads a CDN-cached packument, which lags the publish by minutes.npm dist-tag ls <pkg>andnpm access get status <pkg>hit the registry API directly and are what to trust when checking whether a publish landed.- A rehearsal cannot prove npmjs auth works.
npm publish --dry-rundoes not authenticate, and the OIDC credential is only minted by a real publish — so unlike the old token-based job, there is nonpm whoamithat proves the credential ahead of time. The GitHub Packages job still runs one, because that half is still token-authenticated. - Two different npm version floors. Publishing over OIDC needs npm >= 11.5.1, which is why the
npmjsjob installsnpm@latestrather than trusting the runner image. Configuring the trusted publisher needs npm >= 12, per the bullet above. - npm 12 will not install on an unsupported Node. Its engine range is
^22.22.2 || ^24.15.0 || >=26.0.0, and on Node 25npm install -g npm@12fails outright withEBADENGINE/notsup— npm refuses to replace itself with a version its runtime does not support.npx npm@12 …only warns, so it works for a one-off; otherwise move Node onto a supported line first.brew upgrade nodeto 26 does it, and removes the old kegs on the way. E400andEOTPtell you different things.E400means the registry rejected the payload — on npm 11, the missingpermissions.EOTPmeans the payload was accepted and only two-factor auth is outstanding, so it is the good failure: finish it interactively. The web-auth URL is printed only to a TTY.environment: releaseis decoration until you configure it. Referencing an environment that does not exist does not block the run; GitHub creates it with no protection rules. Add yourself as a required reviewer under Settings → Environments → release to make it a real gate. If you also name that environment in the trusted publisher config, the two must agree or publishing fails.- npm blocks a name from reuse permanently once it has been published and unpublished. The bootstrap claims
@open-predicate/open-predicatefor good. That was the reason publishing stayed off while the name was a working title; the name is settled now, so the trade is worth making.
This is wired up. The $id is now both an identifier and a location:
curl -sI https://openpredicate.tech/schema/v0.4.0/open-predicate-schema.json
# HTTP/2 200 · application/schema+json · access-control-allow-origin: * · immutableServing it is a second repository's job, so a release is not finished when the tag is pushed. The site at OpenPredicate/openpredicate.tech vendors the specification artefacts rather than paraphrasing them, and its build never touches the network — so the copy is refreshed by an explicit step:
# in the openpredicate.tech checkout
node sync.mjs v0.6.0 # pulls SPEC.md, CHANGELOG.md, COMPARISON.md, the examples and the schemasync.mjs reads the grammar version out of the schema's own $id and writes the file to the directory that $id names, so a release that moves the grammar adds a directory rather than overwriting a published one. That is what keeps the versioned URLs immutable, as SPEC.md §9 requires. Cache headers are decided at build time and written to dist/_headers, because Netlify cannot scope headers per deploy context in netlify.toml.
So the order is: tag here → GitHub Release → npm publishes over OIDC → node sync.mjs <tag> there → commit and deploy. Skipping the last step leaves the new grammar version unresolvable while the package already references it.
The schema can also be vendored directly. It is self-contained and has no runtime dependencies:
curl -O https://raw.githubusercontent.com/OpenPredicate/open-predicate/v0.6.0/open-predicate-schema.jsonPin a tag rather than main, as above, if you want a copy that cannot move under you.