diff --git a/.changeset/protect-ffi-lazy-load.md.deferred b/.changeset/protect-ffi-lazy-load.md similarity index 100% rename from .changeset/protect-ffi-lazy-load.md.deferred rename to .changeset/protect-ffi-lazy-load.md diff --git a/.changeset/protect-ffi-repository-url.md.deferred b/.changeset/protect-ffi-repository-url.md similarity index 100% rename from .changeset/protect-ffi-repository-url.md.deferred rename to .changeset/protect-ffi-repository-url.md diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 435c113ef..5d94ad044 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -246,14 +246,6 @@ jobs: - name: Lint — no references to deleted package directories run: pnpm run lint:package-paths - # TEMPORARY — delete with the script in the phase-4 cutover PR. - # The seven protect-ffi packages live here but still publish from - # cipherstash/protectjs-ffi. A changeset naming any of them bumps all - # seven through their fixed group and the next release tries a publish - # npm has not been repointed to trust. - - name: Lint — no protect-ffi changeset before the publishing cutover - run: pnpm run lint:ffi-changeset - - name: Test — lint script self-tests run: pnpm run test:scripts diff --git a/AGENTS.md b/AGENTS.md index c040b78bb..c95d1f04b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,30 +134,34 @@ so that stays true for everyone else. Rust. Everything else under `dist/` stays ignored. The re-inclusion chain spans the root `.gitignore`, the package's own, and a `.gitignore` wasm-pack generates — see the comments in each. -- **Publishing has not moved yet.** All seven packages are still published from - `cipherstash/protectjs-ffi` until npm trusted publishing is repointed, so a - changeset naming any of them fails CI (`scripts/lint-no-ffi-changeset.mjs`). - Change the package freely — but write the changeset and park it as - `.changeset/.md.deferred`, don't skip it. Changesets and the guard both - select on `.endsWith('.md')`, so that extension is inert to - `changeset version`; the cutover PR renames **every** one of them back - (`for f in .changeset/*.md.deferred; do git mv "$f" "${f%.deferred}"; done`). - Check what is parked rather than assuming a single file — `ls - .changeset/*.md.deferred`. Two are waiting today: the lazy native load, and - the manifest repoint to `cipherstash/stack`. -- **The pipeline that will publish them is built and inert.** `release.yml` - asks `scripts/release-gate.mjs` which committed versions are missing from npm; - if any FFI one is, `_build-ffi-artifacts.yml` compiles the six platforms with - an explicit `CARGO_BUILD_TARGET` each, packs all seven tarballs, and +- **Publishing has moved here.** npm trusted publishing for all seven packages + is repointed at this repo, bound to `release.yml`, so write changesets for + them normally. Nothing has actually published from here yet — the first FFI + release is still ahead, and until it lands treat the path as configured rather + than proven. The remaining steps and what is still unverified live in + `docs/plans/2026-08-04-protect-ffi-monorepo-absorption.md`, Phase 4. +- **A `.md.deferred` changeset is now inert, not a CI failure.** The parking + convention and the `lint-no-ffi-changeset` guard that enforced it are both + gone. If you find such a file, it was written on a branch cut before the + cutover: `git mv` it back to `.md`, or the change it describes ships with no + changelog entry. Nothing detects one for you. +- **The pipeline that publishes them.** `release.yml` asks + `scripts/release-gate.mjs` which committed versions are missing from npm; if + any FFI one is, `_build-ffi-artifacts.yml` compiles the six platforms with an + explicit `CARGO_BUILD_TARGET` each, packs all seven tarballs, and `publish-ffi` publishes the six platform packages **before** the wrapper and tags all seven — because `changeset publish` packs from the workspace, where `index.node` does not exist, and tags only what it published itself. Nothing - fires until a version is unpublished, which the changeset guard above - prevents. `ffi-preflight.yml` is the dry run (`changeset publish` has no - `--dry-run`); dispatch it against the Version Packages branch before the - cutover. The seven manifests already name `cipherstash/stack`, which npm - requires of the publishing repository — so a publish attempted from the old - repository would now be rejected, and nothing publishes from there. + fires unless a committed version is absent from the registry, so a push that + bumps nothing is a no-op for all seven. `ffi-preflight.yml` is the dry run + (`changeset publish` has no `--dry-run`); dispatch it against the Version + Packages branch before merging a release that moves an FFI version. +- **Trusted publishing binds to (repository, workflow filename).** Keep + `release.yml` as the single npm entry point; a rename silently invalidates all + seven publisher configurations. Each one must also list `npm publish` under + **Allowed actions** — npm made that field required for configurations created + after 2026-05-20, and a stage-only setting reads as configured while failing + every `npm publish`. Check with `npm trust list `. ### The `integration-tests/` suite diff --git a/docs/plans/2026-08-04-protect-ffi-monorepo-absorption.md b/docs/plans/2026-08-04-protect-ffi-monorepo-absorption.md index 003637198..3aea12a27 100644 --- a/docs/plans/2026-08-04-protect-ffi-monorepo-absorption.md +++ b/docs/plans/2026-08-04-protect-ffi-monorepo-absorption.md @@ -43,15 +43,19 @@ **Working-tree state is not part of this plan's guarantees.** An earlier revision claimed "working tree clean"; that was true when written and false shortly after. A prior rewrite of this document was lost by being left uncommitted across a branch switch — **commit plan edits in the session that makes them.** -**Phase 3 is built; phases 4 and 5 remain.** Phase 4 contains the only -irreversible steps and requires seven manual npmjs.com changes. Phase 5 is -blocked until phase 4 publishes. - -The pipeline is inert until a version is unpublished, and -`scripts/lint-no-ffi-changeset.mjs` is what keeps that from happening early: an -FFI changeset stays parked as `.changeset/.md.deferred` until the cutover -PR renames it. Two are waiting — `protect-ffi-lazy-load.md.deferred` and -`protect-ffi-repository-url.md.deferred`. +**Phases 3 and 5 are built; phase 4 is in flight.** Phase 5 (`stash doctor`) +landed ahead of phase 4 rather than behind it — see #883; the "blocked until +phase 4 publishes" sequencing an earlier revision asserted did not hold, because +the diagnostics subpath probes the binding in the workspace and needs no +release. + +Phase 4 contains the only irreversible steps. Its guard — +`scripts/lint-no-ffi-changeset.mjs`, which held FFI changesets parked as +`.changeset/.md.deferred` — is **deleted** as of the cutover PR, and the +two files it was holding (`protect-ffi-lazy-load`, `protect-ffi-repository-url`) +are renamed back and live. What remains is the release itself: pre-flight +against the versioned ref, merge Version Packages, verify, archive the old +repository. ### Phase 3 progress @@ -149,7 +153,7 @@ error The package "@cipherstash/stack" depends on the skipped package Please add "@cipherstash/stack" to the `ignore` option. ``` -An ignored package's dependents must also be ignored, cascading through the Stack fixed group to a total release freeze — the alternative this plan rejected. Replaced by `scripts/lint-no-ffi-changeset.mjs`. All seven packages are already on npm at `0.31.0`, and `changeset publish` only publishes versions absent from the registry, so a release is *already* a no-op for them. Full analysis: `.work/2026-08-04-protect-ffi-changesets-ignore-analysis.md`. +An ignored package's dependents must also be ignored, cascading through the Stack fixed group to a total release freeze — the alternative this plan rejected. Replaced for the duration of the cutover window by `scripts/lint-no-ffi-changeset.mjs`, deleted in Phase 4. All seven packages were already on npm at `0.31.0`, and `changeset publish` only publishes versions absent from the registry, so a release was *already* a no-op for them. Full analysis: `.work/2026-08-04-protect-ffi-changesets-ignore-analysis.md`. ### `optionalDependencies` were never tracked @@ -1861,26 +1865,43 @@ git commit -m "ci: run the Rust checks from a root path-filtered workflow" The only irreversible steps. -- [ ] Merge a cutover PR that deletes `scripts/lint-no-ffi-changeset.mjs`, its self-test, its fixtures, the `lint:ffi-changeset` script and the `tests.yml` step; **and** activates the deferred `@cipherstash/protect-ffi` **minor** changeset for the laziness change and `assertNativeBindingAvailable()` — it is already written and parked, so this half is a rename, not composition: +- [x] Merge a cutover PR that deletes `scripts/lint-no-ffi-changeset.mjs`, its self-test, its fixtures, the `lint:ffi-changeset` script and the `tests.yml` step; **and** activates the deferred `@cipherstash/protect-ffi` **minor** changeset for the laziness change and `assertNativeBindingAvailable()` — it is already written and parked, so this half is a rename, not composition: ```bash for f in .changeset/*.md.deferred; do git mv "$f" "${f%.deferred}"; done ``` Both halves in one PR — the guard exists to stop that changeset landing early. The `.md.deferred` extension is what makes parking safe: `@changesets/read` and the guard both select on `.endsWith('.md')`, so the file is inert to `changeset version`/`publish` until renamed. Check for more than one parked file — any protect-ffi change landing during the window parks its changeset the same way. -- [ ] Let the Version Packages job create the release PR. Verify it bumps all seven FFI packages to `0.32.0`, rewrites the wrapper's six `optionalDependencies`, and patch-bumps the six Stack packages (expected — see "Release lines are coupled by pinning"). + + Done in the PR that ticks this box. Two parked files were renamed, not one: `protect-ffi-lazy-load.md` (minor) and `protect-ffi-repository-url.md` (patch). + + Six fixtures under `scripts/__tests__/fixtures/lint-no-ffi-changeset/` were deleted with the self-test. An earlier revision of this line claimed there were none, on the strength of a check against `scripts/fixtures/` — the wrong directory. The self-test used a tmpdir for two cases only (CRLF, and the parked-file case); the rest loaded these on-disk fixtures through ``resolve(…, `../fixtures/lint-no-ffi-changeset/${name}`)``. Deleting the test without them leaves six files no referent points at, and `pnpm run test:scripts` stays green either way — which is exactly why it needed checking rather than inferring. + + **A third changeset is parked on the branch of #905** (the `jsonwebtoken` CVE bump); once this lands, nothing reads or warns about that extension, so #905 must rename its own file before merging or the fix ships with an empty changelog. +- [ ] Let the Version Packages job create the release PR. Verify it bumps all seven FFI packages to `0.32.0` and rewrites the wrapper's six `optionalDependencies`. + + **Do not expect a Stack patch.** This criterion originally read "patch-bumps the six Stack packages", which follows from the pinning analysis and is the right prediction for an FFI bump in isolation. It is not what will happen: `.changeset/prisma-next-0-17.md` carries `'@cipherstash/stack-prisma': major`, which propagates through the Stack fixed group, so `changeset status` reports all six Stack packages at **major** — and does so on `origin/main` too, with no FFI changeset in play. The Stack major is unrelated to this phase and must not be read as evidence the FFI bump misbehaved. - [ ] Run `ffi-preflight.yml` against that **versioned release-PR ref**. -- [ ] **Repoint npm trusted publishing for all seven packages**: `cipherstash/protectjs-ffi` → `cipherstash/stack`, workflow `release.yml`. For each publisher, **explicitly select `npm publish` under "Allowed actions"** — npm made that field required for configurations created after 2026-05-20, and these are new configurations. Confirm `repository.url` already reads `cipherstash/stack` (Task 2) or the publish is rejected. Only after the versioned pre-flight is green. +- [x] **Repoint npm trusted publishing for all seven packages**: `cipherstash/protectjs-ffi` → `cipherstash/stack`, workflow `release.yml`. For each publisher, **explicitly select `npm publish` under "Allowed actions"** — npm made that field required for configurations created after 2026-05-20, and these are new configurations. Confirm `repository.url` already reads `cipherstash/stack` (Task 2) or the publish is rejected. Only after the versioned pre-flight is green. + + Done ahead of the pre-flight rather than after it, which inverts the "only after the versioned pre-flight is green" sequencing above. That ordering was about not repointing until the pipeline was known good; the pipeline is built and its jobs have run, so the residual risk is a misconfigured publisher rather than a broken workflow. + + **The Allowed-actions setting is not verified.** npm accepts a publisher scoped to `npm stage publish` only — a configuration that reads as enabled in the UI and fails every `npm publish`. Check all seven before the release, six platform packages included, since `publish-ffi` publishes those first and a stage-only setting on one of six fails a release halfway through: + + ```bash + npm trust list @cipherstash/protect-ffi --json + # …and the six @cipherstash/protect-ffi- packages + ``` - [ ] Merge the Version Packages PR. The gate returns `ffi=true js=true`; artifacts build, six platform packages then the wrapper publish, tags and the GitHub release are created, and `changeset publish` skips the seven and publishes the JS packages. - [ ] Verify npm provenance on all seven, the seven git tags, the `protect-ffi-v0.32.0` release, and the Stack tags from changesets. Smoke-test a fresh install. - [ ] Archive `cipherstash/protectjs-ffi`. ## Phase 5 — wire `stash doctor` -- [ ] Add a `@cipherstash/stack/diagnostics` subpath (both `import` and `require`). It must import protect-ffi **without** importing `@cipherstash/auth`, be pure, and let the loader error propagate unwrapped. -- [ ] Rework `packages/cli/src/commands/doctor/index.ts` to probe it, keeping the separate `@cipherstash/auth` probe. This fixes a pre-existing bug: `dist/index.js` statically imports `@cipherstash/auth`, which is eager on two counts (top-level `require`, plus `module.exports = { ...native }` — a spread forces any loader), so today's stack probe silently duplicates the auth probe while rendering two green rows. -- [ ] Add the missing-binary e2e fixture. -- [ ] Add changesets for the new Stack subpath and the CLI diagnostic behaviour. +- [x] Add a `@cipherstash/stack/diagnostics` subpath (both `import` and `require`). It must import protect-ffi **without** importing `@cipherstash/auth`, be pure, and let the loader error propagate unwrapped. +- [x] Rework `packages/cli/src/commands/doctor/index.ts` to probe it, keeping the separate `@cipherstash/auth` probe. This fixes a pre-existing bug: `dist/index.js` statically imports `@cipherstash/auth`, which is eager on two counts (top-level `require`, plus `module.exports = { ...native }` — a spread forces any loader), so today's stack probe silently duplicates the auth probe while rendering two green rows. +- [x] Add the missing-binary e2e fixture. +- [x] Add changesets for the new Stack subpath and the CLI diagnostic behaviour. --- diff --git a/package.json b/package.json index 214dde32f..f6de0a5ef 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "clean": "rimraf --glob **/.next **/.turbo **/dist **/node_modules", "code:fix": "biome check --write", "code:check": "biome check", - "lint:ffi-changeset": "node scripts/lint-no-ffi-changeset.mjs", "lint:package-paths": "node scripts/lint-no-dead-package-paths.mjs", "lint:runners": "node scripts/lint-no-hardcoded-runners.mjs", "lint:typecheck-scope": "node scripts/lint-typecheck-scope.mjs", diff --git a/packages/protect-ffi/README.md b/packages/protect-ffi/README.md index 021f4f8b4..8cbad5960 100644 --- a/packages/protect-ffi/README.md +++ b/packages/protect-ffi/README.md @@ -379,18 +379,13 @@ packages. The changeset body becomes the `CHANGELOG.md` entry — Changesets own that file now, so the `[Unreleased]` heading and the `version` npm lifecycle hook that used to promote it are gone. -**During the publishing cutover, a protect-ffi changeset is blocked.** All seven -packages are already on npm at the version this workspace carries, and npm -trusted publishing still names `cipherstash/protectjs-ffi` as the publisher. A -changeset naming any of them would bump all seven and send a publish npm rejects -or misattributes, so `scripts/lint-no-ffi-changeset.mjs` fails CI on one. Change -this package freely — only the changeset waits. - -Write it anyway, and park it as `.changeset/.md.deferred`. Changesets -selects changesets by `.endsWith('.md')`, so that extension is invisible to -`changeset version` and `changeset publish` (and to the guard), and the cutover -PR that repoints trusted publishing renames it back rather than reconstructing -it from the git log. +npm trusted publishing for all seven packages names `cipherstash/stack` and the +repository-root `release.yml`, so a changeset here releases like any other +package. The temporary guard that blocked one during the cutover window, and the +`.md.deferred` convention for parking one behind it, are both gone. + +No FFI release has run from this repository yet — 0.31.0 was published from the +old one. Until the first one lands, the path is configured rather than proven. The previous repository's GitHub Actions workflows were deposited under `.github/` in this directory by the subtree import and kept as the reference for diff --git a/scripts/__tests__/fixtures/lint-no-ffi-changeset/clean/README.md b/scripts/__tests__/fixtures/lint-no-ffi-changeset/clean/README.md deleted file mode 100644 index 4326a3ba9..000000000 --- a/scripts/__tests__/fixtures/lint-no-ffi-changeset/clean/README.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@cipherstash/protect-ffi': minor ---- - -# Changesets - -This README must never be parsed as a changeset. The frontmatter above is bait: -`.changeset/README.md` ships with changesets itself, and a guard that read it -would fail every repo that has one. If the skip regresses, this fixture turns -the `clean` directory red and names README in the output. diff --git a/scripts/__tests__/fixtures/lint-no-ffi-changeset/clean/happy-otter-sing.md b/scripts/__tests__/fixtures/lint-no-ffi-changeset/clean/happy-otter-sing.md deleted file mode 100644 index d3cfa3cde..000000000 --- a/scripts/__tests__/fixtures/lint-no-ffi-changeset/clean/happy-otter-sing.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@cipherstash/stack': patch -'stash': minor ---- - -An ordinary Stack changeset. Names no FFI package. diff --git a/scripts/__tests__/fixtures/lint-no-ffi-changeset/offending/brave-lion-jump.md b/scripts/__tests__/fixtures/lint-no-ffi-changeset/offending/brave-lion-jump.md deleted file mode 100644 index 783c74f76..000000000 --- a/scripts/__tests__/fixtures/lint-no-ffi-changeset/offending/brave-lion-jump.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@cipherstash/protect-ffi': minor ---- - -Make the native binding load lazily. diff --git a/scripts/__tests__/fixtures/lint-no-ffi-changeset/offending/quiet-moth-wait.md b/scripts/__tests__/fixtures/lint-no-ffi-changeset/offending/quiet-moth-wait.md deleted file mode 100644 index 6d0c0e7a0..000000000 --- a/scripts/__tests__/fixtures/lint-no-ffi-changeset/offending/quiet-moth-wait.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@cipherstash/protect-ffi-linux-x64-musl": patch ---- - -A platform package named directly, with double quotes and no leading quote style match. diff --git a/scripts/__tests__/fixtures/lint-no-ffi-changeset/offending/wise-crane-list.md b/scripts/__tests__/fixtures/lint-no-ffi-changeset/offending/wise-crane-list.md deleted file mode 100644 index 07a2bb65f..000000000 --- a/scripts/__tests__/fixtures/lint-no-ffi-changeset/offending/wise-crane-list.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@cipherstash/stack': patch -'@cipherstash/protect-ffi-darwin-arm64': patch ---- - -Two packages in one block — what `pnpm changeset` writes when you select more -than one. The FFI package is deliberately NOT the first line: the package you -set out to change gets picked first, so this is the ordinary shape of the -mistake this guard exists to catch, not an exotic one. diff --git a/scripts/__tests__/fixtures/lint-no-ffi-changeset/prose-mention/gentle-fox-run.md b/scripts/__tests__/fixtures/lint-no-ffi-changeset/prose-mention/gentle-fox-run.md deleted file mode 100644 index cbda784b5..000000000 --- a/scripts/__tests__/fixtures/lint-no-ffi-changeset/prose-mention/gentle-fox-run.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@cipherstash/stack': patch ---- - -Adopt @cipherstash/protect-ffi 0.31.0. The body mentions the package by name, -and even shows frontmatter-looking text: - ---- -'@cipherstash/protect-ffi': major ---- - -That must not count — only the first fenced block is frontmatter. diff --git a/scripts/__tests__/lint-no-ffi-changeset.test.mjs b/scripts/__tests__/lint-no-ffi-changeset.test.mjs deleted file mode 100644 index 149d0d89b..000000000 --- a/scripts/__tests__/lint-no-ffi-changeset.test.mjs +++ /dev/null @@ -1,235 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterAll, describe, expect, it } from 'vitest' -import { REPO_ROOT } from './lib/repo-root.mjs' - -const SCRIPT = resolve( - fileURLToPath(import.meta.url), - '../../lint-no-ffi-changeset.mjs', -) -function run(dir) { - try { - const stdout = execFileSync('node', dir ? [SCRIPT, dir] : [SCRIPT], { - encoding: 'utf8', - }) - return { exitCode: 0, output: stdout } - } catch (err) { - return { - exitCode: err.status, - output: String(err.stdout) + String(err.stderr), - } - } -} - -const fx = (name) => - resolve( - fileURLToPath(import.meta.url), - `../fixtures/lint-no-ffi-changeset/${name}`, - ) - -const tempDirs = [] -afterAll(() => { - for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }) -}) - -describe('lint-no-ffi-changeset', () => { - it('passes against the real .changeset directory', () => { - // The whole point of the guard: until the phase-4 cutover, no pending - // changeset may name an FFI package. If this fails on `main`, the window - // invariant has already been broken. - expect(run().exitCode).toBe(0) - }) - - it('passes on changesets that name no FFI package', () => { - expect(run(fx('clean')).exitCode).toBe(0) - }) - - it('does not parse README.md as a changeset', () => { - // `.changeset/README.md` ships with changesets itself and is not a - // changeset; parsing it would be a false positive waiting to happen. - // - // The fixture README carries guarded frontmatter deliberately. Without it - // this assertion held whether or not the skip existed — a README with no - // frontmatter yields no package names either way, so the test passed by - // describing the fixture rather than the behaviour. - const { exitCode, output } = run(fx('clean')) - expect(exitCode).toBe(0) - expect(output).not.toMatch(/README/) - }) - - it('fails when a changeset names the wrapper', () => { - const { exitCode, output } = run(fx('offending')) - expect(exitCode).toBe(1) - expect(output).toMatch('@cipherstash/protect-ffi') - expect(output).toMatch('brave-lion-jump.md') - }) - - it('fails when a changeset names a platform package', () => { - // Named directly rather than via the wrapper — the fixed group means one - // is as publishing as the other. - expect(run(fx('offending')).output).toMatch( - '@cipherstash/protect-ffi-linux-x64-musl', - ) - }) - - it('reports every offending file, not just the first', () => { - const { output } = run(fx('offending')) - expect(output).toMatch('brave-lion-jump.md') - expect(output).toMatch('quiet-moth-wait.md') - }) - - it('catches an FFI package named on any frontmatter line, not just the first', () => { - // The likeliest real offender by some distance: one `pnpm changeset` run - // that selects the package you changed AND protect-ffi, which writes both - // into a single block. Every other fixture here names exactly one package - // on line one, so a parser that read only the first line of frontmatter - // passed this whole suite — mutation-tested by appending `.slice(0, 1)` - // to the frontmatter split: 10/10 still green. `darwin-arm64` appears in - // no other fixture, so matching it proves the second line was read. - const { exitCode, output } = run(fx('offending')) - expect(exitCode).toBe(1) - expect(output).toMatch('@cipherstash/protect-ffi-darwin-arm64') - expect(output).toMatch('wise-crane-list.md') - }) - - it('parses a changeset checked out with CRLF line endings', () => { - // `packagesIn` spells its line breaks `\r?\n` in both the frontmatter - // regex and the split — deliberate, because a Windows checkout with - // `core.autocrlf=true` yields CRLF, and this repo has no `.gitattributes` - // forcing otherwise. Nothing exercised it: dropping both `\r?` left the - // suite green, and the guard would then wave through every changeset - // written on Windows. - // - // Generated rather than committed for the same reason it needs testing — - // a CRLF fixture in git is one `autocrlf=true` commit away from being - // silently normalised to LF, which would disarm this test without a diff. - const dir = mkdtempSync(join(tmpdir(), 'ffi-changeset-crlf-')) - tempDirs.push(dir) - writeFileSync( - join(dir, 'tidy-vole-climb.md'), - "---\r\n'@cipherstash/stack': patch\r\n'@cipherstash/protect-ffi-linux-arm64-gnu': patch\r\n---\r\n\r\nWritten on Windows.\r\n", - ) - - const { exitCode, output } = run(dir) - expect(exitCode).toBe(1) - expect(output).toMatch('@cipherstash/protect-ffi-linux-arm64-gnu') - }) - - it('ignores an FFI package named only in the prose body', () => { - // A Stack changeset describing the 0.31 adoption necessarily mentions - // protect-ffi in its text, and may quote frontmatter to show a shape. - // Only the first fenced block is frontmatter. - expect(run(fx('prose-mention')).exitCode).toBe(0) - }) - - it('explains that the changeset should wait, not the change', () => { - // The failure a reader hits is "I changed Rust and CI went red". The - // message has to distinguish those two things or it reads as a ban on - // touching the package. - const { output } = run(fx('offending')) - expect(output).toMatch(/cutover PR/) - expect(output).toMatch(/Change protect-ffi freely/) - }) - - it('skips a parked `.md.deferred` changeset naming an FFI package', () => { - // The window has to leave protect-ffi's changeset somewhere, or the prose - // is reconstructed from the git log at cutover time. `.md.deferred` is - // that parking spot, and this is the behaviour that makes it one. - // - // Generated rather than committed so the assertion is about the SKIP. Run - // against the real .changeset directory it would also pass if the parked - // file simply named nothing guarded. - const dir = mkdtempSync(join(tmpdir(), 'ffi-changeset-deferred-')) - tempDirs.push(dir) - writeFileSync( - join(dir, 'parked.md.deferred'), - "---\n'@cipherstash/protect-ffi': minor\n---\n\nWaiting for the cutover.\n", - ) - - expect(run(dir).exitCode).toBe(0) - }) - - it('has the deferred laziness changeset already written and parked', () => { - // Why the guard can be deleted safely at cutover: the changeset it defers - // is not a thing someone has to remember to write, it is a `git mv`. The - // phase-4 checklist says so; this is what stops the file being deleted, - // emptied or renamed out from under that instruction in the meantime. - const changesetDir = resolve(REPO_ROOT, '.changeset') - const parked = readdirSync(changesetDir).filter((f) => - f.endsWith('.md.deferred'), - ) - expect( - parked, - 'no parked changeset in .changeset/ — the phase-2 laziness changeset must be written before the cutover, not during it', - ).not.toHaveLength(0) - - // `@changesets/read` selects changeset files with - // !file.startsWith('.') && file.endsWith('.md') && !/^README\.md$/i - // (@changesets/read/dist/changesets-read.esm.js). Reproduced rather than - // imported — it is a transitive dependency, not a declared one — because - // the failure worth catching is a well-meaning rename to - // `.deferred.md`, which changesets WOULD read, publishing all seven - // FFI packages into a trusted-publishing configuration that still names - // the old repository. Suffix order is the whole safety property. - for (const file of parked) { - expect( - !file.startsWith('.') && - file.endsWith('.md') && - !/^README\.md$/i.test(file), - `${file} would be read by changesets as a live changeset`, - ).toBe(false) - } - - const parkedPackages = parked.flatMap((file) => - [ - ...readFileSync(join(changesetDir, file), 'utf8').matchAll( - /^\s*['"]?(@?[^'":\n]+?)['"]?\s*:\s*(?:major|minor|patch)\s*$/gm, - ), - ].map(([, name]) => name), - ) - expect( - parkedPackages, - 'a parked changeset must name @cipherstash/protect-ffi — that is the only reason to park one', - ).toContain('@cipherstash/protect-ffi') - }) - - it('names its own removal condition in the source', () => { - // A temporary guard with no stated expiry becomes permanent. The cutover - // PR must be able to find this file from the plan and delete it. - const source = readFileSync(SCRIPT, 'utf8') - expect(source).toMatch(/TEMPORARY/) - expect(source).toMatch(/trusted publishing/) - }) - - it('guards exactly the seven packages in the FFI fixed group', () => { - // Drift between the guard list and the changesets fixed group would let a - // platform package through while the group still bumps it. - const config = JSON.parse( - readFileSync(resolve(REPO_ROOT, '.changeset/config.json'), 'utf8'), - ) - const ffiGroup = config.fixed.find((group) => - group.includes('@cipherstash/protect-ffi'), - ) - // Both directions. Asserting only that each configured name appears in the - // script catches a package dropped from the guard, but not one dropped - // from the fixed group or added to only one of the two — and it is the - // guard falling behind a NEW platform package that publishes something. - const guardedPackages = [ - ...readFileSync(SCRIPT, 'utf8').matchAll( - /'(@cipherstash\/protect-ffi(?:-[a-z0-9-]+)?)'/g, - ), - ].map(([, name]) => name) - - expect(ffiGroup).toHaveLength(7) - expect([...new Set(guardedPackages)].sort()).toEqual([...ffiGroup].sort()) - }) -}) diff --git a/scripts/lint-no-ffi-changeset.mjs b/scripts/lint-no-ffi-changeset.mjs deleted file mode 100644 index e8ba9938b..000000000 --- a/scripts/lint-no-ffi-changeset.mjs +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Fail if any pending changeset names one of the seven `@cipherstash/protect-ffi` - * packages. - * - * TEMPORARY. Delete this script, its self-test, its fixtures, and its - * `lint:ffi-changeset` entry in the phase-4 cutover PR, at the same moment npm - * trusted publishing is repointed from `cipherstash/protectjs-ffi` to - * `cipherstash/stack`. In the same PR, `git mv` every - * `.changeset/*.md.deferred` to `*.md` — see "Where a deferred changeset - * waits" below. - * - * ## Why this exists rather than the changesets `ignore` list - * - * The absorption plan specified `ignore` as the publication guard. Changesets - * rejects that configuration outright: - * - * The package "@cipherstash/stack" depends on the skipped package - * "@cipherstash/protect-ffi", but "@cipherstash/stack" is not being - * skipped. Please add "@cipherstash/stack" to the `ignore` option. - * - * An ignored package's dependents must themselves be ignored, and all three - * consumers now depend on the wrapper at `workspace:*`. Honouring that would - * mean ignoring `@cipherstash/stack`, `stack-drizzle` and `stack-supabase` — - * and, through the Stack fixed group, `stash`, `stack-prisma` and `wizard` - * with them. That is a total release freeze, which the plan considered and - * rejected for exactly the right reason: this repo releases actively and a - * freeze of unknown duration is a real operational cost. - * - * ## What actually guards the window - * - * Two things, and neither is configuration: - * - * 1. All seven packages are already on npm at 0.31.0 — the same version the - * workspace carries. `changeset publish` publishes only versions absent - * from the registry, so with no changeset naming them, a release is a - * no-op for all seven no matter how often `main` publishes. - * 2. This check, which stops the one thing that would change that. - * - * The hazard is narrow and specific: a changeset naming any FFI package makes - * `changeset version` bump all seven to 0.32.0 (they share a fixed group), and - * the next release then tries to publish seven packages whose npm trusted - * publisher still names `cipherstash/protectjs-ffi`. That is a failed or - * misattributed publish of the encryption core. - * - * This is *not* a rule against changing protect-ffi. Rust and TypeScript - * changes are fine and land normally; the constraint is only that their - * changeset waits for the cutover PR. - * - * ## Where a deferred changeset waits - * - * `.changeset/.md.deferred`. Write the changeset now, under that - * extension, and the cutover PR is a `git mv` instead of an act of memory — - * which matters most for the phase-2 laziness change and - * `assertNativeBindingAvailable()`, already parked as - * `protect-ffi-lazy-load.md.deferred`. Reconstructing that prose from the git - * log months later is how a user-visible behaviour change ships with an empty - * changelog. - * - * The suffix is load-bearing, and it is `.md.deferred` rather than - * `.deferred.md` for a reason: `@changesets/read` selects changesets with - * `!file.startsWith('.') && file.endsWith('.md') && !/^README\.md$/i`, and the - * loop below filters on `.endsWith('.md')` too. A name ending in `.md` - * publishes; a name ending in `.deferred` is invisible to both. Nothing else - * in changesets touches it either — `getOldChangesets` only descends into - * DIRECTORIES, `removeEmptyFolders` swallows the `ENOTDIR` a file raises, and - * `applyReleasePlan` deletes strictly `${changeset.id}.md` for ids in the - * release plan. Verified against the installed 2.31.0, and by - * `changeset status` reporting no FFI package with the file in place. - * - * The self-test pins all of this: that a parked file exists, that it names a - * guarded package, and that its name would NOT be read as a changeset. - */ -import { readdirSync, readFileSync } from 'node:fs' -import { join, relative, resolve } from 'node:path' - -const REPO_ROOT = resolve(import.meta.dirname, '..') - -const GUARDED = new Set([ - '@cipherstash/protect-ffi', - '@cipherstash/protect-ffi-darwin-x64', - '@cipherstash/protect-ffi-darwin-arm64', - '@cipherstash/protect-ffi-win32-x64-msvc', - '@cipherstash/protect-ffi-linux-x64-gnu', - '@cipherstash/protect-ffi-linux-arm64-gnu', - '@cipherstash/protect-ffi-linux-x64-musl', -]) - -const changesetDir = process.argv[2] - ? resolve(process.argv[2]) - : join(REPO_ROOT, '.changeset') - -/** - * Pull the package names out of a changeset's frontmatter. - * - * A changeset is `---\n'pkg': minor\n---\n\nprose`. Only the first fenced - * block counts: prose below it may quote a package name in a sentence, and - * `--- ` rules inside markdown would otherwise reopen the block. - */ -function packagesIn(source) { - const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source) - if (!match) return [] - return match[1] - .split(/\r?\n/) - .map((line) => /^\s*['"]?(@?[^'":]+?)['"]?\s*:/.exec(line)) - .filter(Boolean) - .map((m) => m[1].trim()) -} - -const offenders = [] -for (const entry of readdirSync(changesetDir)) { - if (!entry.endsWith('.md') || entry === 'README.md') continue - const named = packagesIn(readFileSync(join(changesetDir, entry), 'utf8')) - const guarded = named.filter((name) => GUARDED.has(name)) - if (guarded.length) offenders.push({ file: entry, packages: guarded }) -} - -if (offenders.length === 0) { - console.log('No pending changeset names a protect-ffi package.') - process.exit(0) -} - -console.error( - '\nA pending changeset names a @cipherstash/protect-ffi package:\n', -) -for (const { file, packages } of offenders) { - // Relative to the repo root, not a literal `.changeset/` — the directory is - // overridable by argv for the self-tests, and a hardcoded prefix prints a - // path that does not exist whenever it is. - console.error(` ${relative(REPO_ROOT, join(changesetDir, file))}`) - for (const name of packages) console.error(` ${name}`) -} -console.error( - '\nThese seven packages live in this repo but are still PUBLISHED from\n' + - 'cipherstash/protectjs-ffi — npm trusted publishing has not been\n' + - 'repointed yet. A changeset naming any one of them bumps all seven to\n' + - '0.32.0 through their fixed group, and the next release then attempts a\n' + - 'publish npm will reject or misattribute.\n\n' + - 'Change protect-ffi freely — only its changeset has to wait, and it has\n' + - 'somewhere to wait. Rename the file to end in `.md.deferred`:\n\n' + - ' git mv .changeset/.md .changeset/.md.deferred\n\n' + - 'Changesets does not read that extension and neither does this check, so\n' + - 'the prose is written now and the cutover PR — the one that repoints\n' + - 'trusted publishing and deletes this script — renames it back.\n', -) -process.exit(1)