Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,15 @@ jobs:
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'

# semantic-release publishes via @semantic-release/npm, which shells out
# to `npm publish`. OIDC trusted publishing requires npm >= 11.5.1, so
# pin a known-good npm even though dependencies are managed by pnpm.
# bulma-ui, create-bestax and bestax-mcp publish via
# @semantic-release/npm, which shells out to `npm publish`. OIDC trusted
# publishing requires npm >= 11.5.1, so pin a known-good npm even though
# dependencies are managed by pnpm.
#
# bestax-migrate is the exception and does not need this: it publishes
# with `pnpm publish` (#436), which carries its own OIDC exchange, because
# `npm publish` does not resolve the `workspace:` protocol it keeps in
# devDependencies (#412). Its release step below is otherwise identical.
- name: Update npm for OIDC trusted publishing
run: npm install -g npm@latest

Expand Down
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ web_modules/
# Output of 'npm pack'
*.tgz
bulma-ui/CLAUDE.md.bak
bestax-migrate/package.json.pack-backup

# Yarn Integrity file
.yarn-integrity
Expand Down Expand Up @@ -169,3 +168,8 @@ test-apps/
test-app-*/
create-bestax/e2e/test-results/
create-bestax/e2e/playwright-report/

# Written only by the pack-manifest resolver deleted in #436. Kept so a stale
# backup left on disk by a pre-#436 checkout cannot be committed by a `git add
# -A` after rebasing. Safe to drop once no working copy predates that change.
bestax-migrate/package.json.pack-backup
15 changes: 12 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,18 @@ Full versioning details (breaking-change footers, tag formats): `VERSIONING.md`.
become permanent (#391). A blocking audit gate plus the cooldown means a fresh advisory can red
every open PR — CONTRIBUTING.md has the runbook.
- Isolated node linker: undeclared (phantom) dependencies fail — declare everything you import.
- Published packages must not ship a `workspace:` or `catalog:` specifier — `npm publish`
(what semantic-release runs) resolves neither, and the tarball becomes uninstallable
(#412). `check:conformance --only=publishable-manifests` enforces this.
- **How a package publishes decides what its manifest may contain.** `npm publish` resolves
no pack-time protocol at all, so a package published that way must not ship one — the
tarball becomes uninstallable (#412). bestax-migrate hands its publish step to
`pnpm publish` instead (#436), which buys it a **narrow** exemption:
`workspace:`/`catalog:` in **devDependencies** only. `jsr:` becomes an aliased
`npm:@jsr/…` specifier and `link:`/`portal:`/`file:` are not rewritten at all, so those
four are a violation in **any** section, exemption or not. `workspace:`/`catalog:` are
additionally a violation in a section consumers resolve, since pnpm resolving them does
not stop every consumer being made to install the dependency. Which packages publish with pnpm
is **declared** in `check:conformance` rather than inferred from their release config —
inferring it meant parsing semantic-release's config format, which was wrong four times,
and every miss granted the exemption.

## Workflow

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ no `npm publish`, no tag, no GitHub release.

> **Safe to run; never publishes:** everything above. The only things that actually publish are
> `pnpm exec semantic-release` **without** `--dry-run` (CI-only, on merge to `main`) and a manual
> `npm publish` — neither of which is in this list.
> `npm publish` / `pnpm publish` — none of which is in this list.

---

Expand Down
9 changes: 6 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,12 @@ Measures active in this repository and its release pipeline:
reviewed lockfile resolves. (The React 18/19 compatibility matrix is the
one deliberate exception: it re-resolves to pin the requested React major
for testing, and never publishes.)
- **npm provenance** — all four published packages set
`publishConfig.provenance`, so every release carries a signed attestation
linking the tarball to the exact commit and CI run that built it.
- **npm provenance** — every release carries a signed attestation linking the
tarball to the exact commit and CI run that built it. Three packages request
it with `publishConfig.provenance`; bestax-migrate publishes with
`pnpm publish`, which does not read that field, so it passes `--provenance`
on the command instead and carries no `publishConfig.provenance` at all
(having one there would imply the flag was redundant).
- **OIDC trusted publishing** — releases authenticate to npm with
short-lived OIDC tokens minted per run; there is no long-lived `NPM_TOKEN`
to steal.
Expand Down
9 changes: 8 additions & 1 deletion VERSIONING.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,16 @@ Each package tags and logs its own releases:
On merge to `main`, CI (`.github/workflows/ci.yml`) runs semantic-release in each package:

1. Each package analyzes the commits since **its own** last tag against its `releaseRules`.
2. If a release is due: version bump, `CHANGELOG.md` update, npm publish (OIDC trusted
2. If a release is due: version bump, `CHANGELOG.md` update, publish to npm (OIDC trusted
publishing — no `NPM_TOKEN`), a signed `chore(release): X.Y.Z [skip ci]` commit, git tag,
and GitHub release.
- Three packages publish via `@semantic-release/npm`, which shells out to `npm publish`.
**bestax-migrate publishes with `pnpm publish`** (`@semantic-release/exec`), because it
keeps a `workspace:` devDependency and `npm publish` ships that protocol verbatim —
which is how 1.0.0 went out uninstallable (#412, #436).
- Note the ordering, because it decides what a failed publish costs: semantic-release runs
**every** `prepare` step — including the release commit and tag — before **any** `publish`
step. A publish that fails leaves the commit and tag behind, and that version is spent.
3. A push may release any subset of the packages — they never bump each other.

`main` is ruleset-protected, so the release commit and tag are pushed by a dedicated
Expand Down
100 changes: 87 additions & 13 deletions bestax-migrate/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,93 @@ each source library registers in `src/sources/registry.ts`; the first is
## Releases

Independent semantic-release, keyed off the `bestax-migrate` commit scope
(`release.config.js`, tag `bestax-migrate@x.y.z`). Publishing goes through
`npm publish`, which — unlike `pnpm publish` — does **not** resolve pnpm's
`workspace:` protocol, so `workspace:^` shipped verbatim in 1.0.0 and made the
package uninstallable (#412). Two rules follow: `@allxsmith/bestax-bulma` stays
a **devDependency** (it is only the typecheck target for the e2e, never
imported at runtime — consumers of a codemod CLI must not be made to install
the component library), and `scripts/pack-manifest.mjs` resolves any remaining
`workspace:` specifier during `prepack`/`postpack`. `pnpm check:conformance
--only=publishable-manifests` enforces the protocol half of both: no
`workspace:`/`catalog:` specifier in the sections consumers resolve, and one
left in `devDependencies` only with the pack hooks present. It does **not**
check which section `@allxsmith/bestax-bulma` sits in — re-adding it as a
plain-semver runtime dependency passes CI, so that one is on review. The skill lives at repo-root
(`release.config.js`, tag `bestax-migrate@x.y.z`).

**This is the one package that publishes with `pnpm publish`, not `npm publish`
(#436).** `npm publish` does not resolve pnpm's `workspace:` protocol, so
`workspace:^` shipped verbatim in 1.0.0 and made the package uninstallable
(#412). That was patched by a `prepack` hook reimplementing pnpm's rewrite, and
the reimplementation was wrong twice in one review — so the publish step now
goes to `@semantic-release/exec` running `pnpm publish`, which resolves every
pnpm specifier shape by construction. `@semantic-release/npm` stays in the chain
with `npmPublish: false` purely for its `prepare` step, which writes the version
`@semantic-release/git` then commits.

Three things about that split are load-bearing, and none of them fails loudly:

- **`--provenance` is required.** pnpm reads `publishConfig.registry` and
`.access` but takes `provenance` from options only. `publishConfig.provenance`
was deliberately REMOVED from this package's manifest rather than left in
place: it does nothing under pnpm, and the most likely reason anyone would
delete the flag is reading `"provenance": true` in package.json and concluding
it is redundant. Drop the flag and #411's provenance quietly stops being
produced.
- **`--embed-readme` is required.** pnpm defaults it to false where npm defaults
it to true; without it the npmjs.com page loses its README.
- **The auth pre-flight is weaker than it was.** `@semantic-release/npm`
exchanged a real OIDC token during `verifyConditions`. With `npmPublish: false`
that is off, and semantic-release finishes every `prepare` step — the release
commit and the tag — before the first `publish` step. So a failed publish
leaves both behind and spends the version.
`scripts/verify-oidc-context.mjs` runs as the exec plugin's
`verifyConditionsCmd` and checks only that an OIDC context exists; it does not
prove npm will accept the token.

**This package must be published with `pnpm publish`, and the likely mistakes
are refused.** The
old `prepack` hook rewrote `workspace:^` for whatever was packing, so it covered a
manual publish as well as the release pipeline. Deleting it left the guarantee
living only in `release.config.js`, which the conformance rule then exempts
precisely because pnpm handles it, so the specifier had no mechanical guard at all
outside CI. The hooks now run repo-root `scripts/require-pnpm-publish.mjs` (not
`bestax-migrate/scripts/`, which still exists for `validate-corpus.mjs`), which
refuses packers it recognises as not being pnpm. Both `npm publish` and `pnpm
publish` run those hooks.

**It keys on `npm_execpath`, not `npm_config_user_agent`, and that is not a
detail to tidy up.** The user agent is inherited: npm relays whatever it finds,
so `pnpm exec npm publish` runs the hook reporting `pnpm/…` while npm assembles
the tarball, and an agent check waves it through. `npm_execpath` is rewritten by
whichever process actually runs the script, so it names the real packer.

**It refuses named packers, and deliberately allows unrecognised ones.** npm,
yarn, bun and friends are refused by name; anything the guard cannot place is
let through. That asymmetry is not laziness, and reversing it would be worse
than the hole it closes: pnpm's own lifecycle runner sets
`npm_execpath = process.argv[1] || process.cwd()`, so a pnpm build where
`argv[1]` is falsy reports the package **directory**. Refusing what we cannot
recognise would kill a genuine release from inside a pack hook, after
semantic-release has pushed the commit and the tag, which is the one direction
this guard must never fail in. So it is a guard against the publisher someone
actually reaches for, not a proof that only pnpm can ever pack this package.

`pnpm check:conformance` reports a violation if either hook is missing, so the
exemption and its compensating guard cannot drift apart. (It reports the missing
hook; it does not retract the exemption, so a manifest with both problems shows
one violation for each.)

The hook is wired to **both `prepack` and `prepublishOnly`**, and both are
required by `check:conformance`. `prepack` is the load-bearing one for `npm
pack`: `npm publish <tarball>` runs no scripts at all, so a tarball packed by
npm would otherwise be publishable with nothing left to refuse it.

It is still a guard against the likely mistake rather than a proof.
`--ignore-scripts` skips both hooks outright (npm and pnpm each gate lifecycle
scripts on it), and a tarball packed before this existed, or packed elsewhere,
carries no guard with it. Check what a manifest will actually ship with
`pnpm -C bestax-migrate pack`.

`@allxsmith/bestax-bulma` still stays a **devDependency** — it is only the
typecheck target for the e2e, never imported at runtime, and consumers of a
codemod CLI must not be made to install the component library. That is a policy
rule, not a protocol one, and the conformance check enforces only part of it.
The pack-time exemption this package gets is narrow: `workspace:`/`catalog:` in
**devDependencies** only. Moving the library to `dependencies` as `workspace:^`
is flagged (consumers would be made to install it), but re-adding it as a
**plain semver range** still passes CI, because that is a policy question rather
than a protocol one. That one is on review.

The skill lives at repo-root
`skills/bestax-migrate/`. It **is** bundled into create-bestax (settled in #385): the
original existing-sites-only policy lost to one uniform bundle, and the skill sits idle
in a fresh scaffold until legacy imports show up. The canonical roster of surfaces that
Expand Down
7 changes: 3 additions & 4 deletions bestax-migrate/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
"format:check": "prettier --check \"src/**/*.{ts,tsx}\" \"e2e/**/*.ts\"",
"clean": "rimraf dist",
"release": "npx semantic-release",
"prepack": "node scripts/pack-manifest.mjs prepack",
"postpack": "node scripts/pack-manifest.mjs postpack"
"prepack": "node ../scripts/require-pnpm-publish.mjs",
"prepublishOnly": "node ../scripts/require-pnpm-publish.mjs"
},
"keywords": [
"bestax",
Expand Down Expand Up @@ -70,7 +70,6 @@
"typescript": "^6.0.3"
},
"publishConfig": {
"access": "public",
"provenance": true
"access": "public"
}
}
Loading
Loading