From 664378572978ea78dce00bfc8315480bb3c25cc4 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 2 Oct 2026 14:52:41 +1000 Subject: [PATCH 1/5] feat(release): stop freezing the @cipherstash/auth packages Delete the seven @cipherstash/auth entries from FROZEN_PUBLISHERS and FROZEN_ARTEFACT_DIGESTS. This arms release.yml's auth-artifacts and publish-auth jobs, which run when the gate reports auth=true, and release-plz.yml's release-crates job, whose switch keys on the @cipherstash/auth entry. It assumes npm and crates.io trusted publishing for the seven packages and the two crates now name cipherstash/stack. Remove the temporary lint-no-auth-changeset guard, its test, its lint:auth-changeset script and its tests.yml step, as its header asked. Its check that the changesets fixed group is exactly the seven auth workspace packages moves to auth-build-artifacts.test.mjs, so the lockstep stays covered. The gate keeps its `files` and `noTreeBytes` entry shapes. Their tests use the entries the auth packages carried as fixtures. New tests assert that no auth package is frozen, that an unpublished auth version passes the gate with auth=true, and that the crates line is armed. frozen-publisher-docs.test.mjs keeps its auth rows, with the wording each instruction was written for, so an assertion of absence can still fail. AGENTS.md, SECURITY.md, CONTRIBUTING.md and the workflow comments no longer describe the freeze, and docs/npm-releases.md is marked as the suite's history. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .github/workflows/release-plz.yml | 10 +- .github/workflows/release.yml | 6 +- .github/workflows/tests.yml | 7 - AGENTS.md | 47 +++--- CONTRIBUTING.md | 16 +- SECURITY.md | 27 ++-- docs/npm-releases.md | 5 + package.json | 1 - .../__tests__/auth-build-artifacts.test.mjs | 22 +++ scripts/__tests__/eql-pipeline-armed.test.mjs | 9 +- .../__tests__/frozen-publisher-docs.test.mjs | 9 +- .../__tests__/lint-no-auth-changeset.test.mjs | 150 ------------------ scripts/__tests__/release-gate.test.mjs | 146 ++++++++++------- scripts/eql-pipeline-armed.mjs | 8 +- scripts/lint-no-auth-changeset.mjs | 78 --------- scripts/release-gate.mjs | 98 +++--------- 16 files changed, 208 insertions(+), 431 deletions(-) delete mode 100644 scripts/__tests__/lint-no-auth-changeset.test.mjs delete mode 100644 scripts/lint-no-auth-changeset.mjs diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 134d43f9d..f319edfc3 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -177,11 +177,11 @@ jobs: # Its own job, not a step in `release`: a step there would inherit the EQL # gate, and the two lines arm at different times. # - # INERT until the arming PR of the stack-* crates import (PR E). No crate is - # in FROZEN_PUBLISHERS (an npm map), so the switch keys on the - # `@cipherstash/auth` entry, which PR E deletes when it repoints crates.io and - # npm trusted publishing together. crates.io also needs a Trusted Publishing - # entry for both crates naming cipherstash/stack + release-plz.yml. + # ARMED by the arming PR of the stack-* crates import. No crate is in + # FROZEN_PUBLISHERS (an npm map), so the switch keys on the + # `@cipherstash/auth` entry, which that PR deleted when crates.io and npm + # trusted publishing moved here together. crates.io needs a Trusted + # Publishing entry for both crates naming cipherstash/stack + release-plz.yml. # # Publish-only: nothing here opens a release PR, so a version bump of the two # crates is a hand-made pull request. `release-plz update` cannot version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ea8a3a2c..d4bebad20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,9 +22,9 @@ name: Release JS # binds to a repository AND a workflow filename, so every npm publish in this # repository has to happen here. # -# The auth jobs are INERT while the seven auth packages are in -# FROZEN_PUBLISHERS: the gate reports `auth=true` only for an unpublished auth -# version, and for a frozen package that is a blocker, so `gate` fails first. +# The auth jobs run when the gate reports `auth=true`: an auth version in the +# tree that npm does not carry. Re-freezing the seven auth packages in +# FROZEN_PUBLISHERS makes that version a blocker, so `gate` fails first. # # `workflow_dispatch` came with the EQL port (its prerelease path is dispatched # against a release branch). A dispatch reaches every job in the file, so diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 29707432a..4cceb6f78 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -295,13 +295,6 @@ jobs: - name: Lint — no references to deleted package directories run: pnpm run lint:package-paths - # TEMPORARY — delete with the script in the arming PR (PR E) of the - # stack-* crates import. The seven @cipherstash/auth packages live here - # but still publish from cipherstash/cipherstash-suite, so a changeset - # naming one would bump a frozen package and block every release. - - name: Lint — no @cipherstash/auth changeset before the publishing cutover - run: pnpm run lint:auth-changeset - # `eql-bindings` emits EQL payloads; `@cipherstash/eql` carries the SQL # that stores them. Both live here now and release at one lockstep # version. A registry pin on either lets them drift apart — it compiles, diff --git a/AGENTS.md b/AGENTS.md index 982979ffc..273cc0912 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l `cipherstash/encrypt-query-language` repository. Old upstream issue and PR links are provenance only. - `packages/stack-auth`, `packages/stack-profile`, `packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`, `packages/stack-guest-abi`: The Rust crates imported from `cipherstash/cipherstash-suite` with their history — `stack-auth` and `stack-profile` (published to crates.io), and `stack-kms`, `stack-encrypt`, `stack-encrypt-derive` and `stack-guest-abi` (`publish = false`). They are the members of the **root Cargo workspace**, with the three node binding crates below. See "Working on the Rust crates". -- `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm, **frozen here** until publishing moves from the suite (see `FROZEN_PUBLISHERS` below). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. +- `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. - `languages/golang`: The Go module (`stackencrypt`, `stackauth`, `internal`), a wazero host with no cgo. Its two WASI guests (`*/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; the `.wasm` files they embed are gitignored. There is no Go release process yet. - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `languages/typescript/examples/*`: Working apps (basic, prisma, supabase-worker) @@ -557,34 +557,31 @@ monorepo, which is where the silent failures are. has been repointed, is likewise configuration — check the registry, do not read it here. This bullet used to narrate that state and was wrong twice. - **EQL left the map in its Phase-5 cutover, and an empty map is a legitimate - state.** A package absorbed before its publisher moves goes in, with its - artefact in `FROZEN_ARTEFACT_DIGESTS`, and both entries are deleted in the - PR that repoints its publisher — not afterwards. + **The map is empty, and empty is a legitimate state.** EQL left it in its + Phase-5 cutover, and the `@cipherstash/auth` packages in the arming PR of + the stack-* crates import. A package absorbed before its publisher moves + goes back in, with its artefact in `FROZEN_ARTEFACT_DIGESTS`, and both + entries are deleted in the PR that repoints its publisher — not afterwards. `scripts/__tests__/frozen-publisher-docs.test.mjs` holds this file, the EQL plan and `SECURITY.md`'s "Note on publishing" to the map — the last being the one file that tells a reporter which pipeline built the artefact they - are reporting on. `release-gate.test.mjs` asserts the map carries neither - EQL nor any FFI name: the seven protect-ffi packages were left in it after + are reporting on. `release-gate.test.mjs` asserts the map carries no EQL, + auth or FFI name: the seven protect-ffi packages were left in it after their own cutover, which armed the gate against the first release that - cutover had just enabled. The tests drive the `field` mechanism with EQL's - old entry as an injected fixture. - - **Delete the `@cipherstash/auth*` entries in the arming PR of the stack-* - crates import.** The wrapper and its six platform packages, imported from - cipherstash-suite, are frozen the same way until npm trusted publishing is - repointed here. The wrapper has no release manifest, so its check-3 entry is - a `files` list: the gate hashes each of the 15 tracked files it publishes, - in the tree and in the tarball, and names the one that differs. That is why - `biome.json` excludes those files: a reformat is a skew, and the gate - refuses it. The platform packages publish only a binary built in CI, so their entries - declare `noTreeBytes` and check 3 skips them; checks 1 and 2 still apply. - `scripts/lint-no-auth-changeset.mjs` refuses a changeset naming any of the - seven, and goes in the same PR. Deleting the entries also arms two - pipelines: `release.yml`'s `auth-artifacts` and `publish-auth`, which run - once the gate reports `auth=true`, and `release-plz.yml`'s `release-crates` - for `stack-auth` and `stack-profile`, whose switch is - `node scripts/eql-pipeline-armed.mjs crates`. + cutover had just enabled. With nothing frozen, the tests drive the mechanism + with EQL's old entry as an injected fixture. + + The seven `@cipherstash/auth*` packages were frozen the same way from the + stack-* crates import until its arming PR, which moved npm trusted + publishing here and deleted them from both maps. That also armed + `release.yml`'s `auth-artifacts` and `publish-auth`, which run when the gate + reports `auth=true`, and `release-plz.yml`'s `release-crates` for + `stack-auth` and `stack-profile`, whose switch is + `node scripts/eql-pipeline-armed.mjs crates`. Re-freezing `@cipherstash/auth` + stops both lines. The gate's `files` and `noTreeBytes` entry shapes date from + that freeze and have no entry today. `biome.json` still excludes the 15 + tracked files the wrapper publishes, so a reformat does not ride along with + an auth release. **Check 3 is the one worth understanding before you touch a frozen package.** For a package this repo publishes, in-tree bytes differing from npm is an diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b35dd5c2e..cd9ba1b51 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -181,16 +181,18 @@ packages are a `fixed` group in [`.changeset/config.json`](./.changeset/config.j they always version together, so a bump to any one of them bumps all six. `@cipherstash/auth` and its six `@cipherstash/auth-*` platform packages are -developed here but still published from `cipherstash/cipherstash-suite`. Until -publishing moves here, do not add a changeset for them: `pnpm run -lint:auth-changeset` fails on one, and `release:gate` blocks any version npm -does not have. They are already their own `fixed` group in -`.changeset/config.json`, so once publishing moves the seven release together. +their own `fixed` group in `.changeset/config.json`, so the seven release +together. `release.yml` builds the six platform binaries and publishes the +seven before Changesets publishes the rest. Two Rust crates, `stack-auth` and `stack-profile`, are released to crates.io, in one version group of their own, by release-plz from the root Cargo -workspace — not by Changesets. Until that pipeline is armed they, too, keep -releasing from the suite. +workspace — not by Changesets. Nothing opens a release PR for them yet. To +release them, bump both in a pull request of your own: the two `[package]` +versions, their two entries in the root `[workspace.dependencies]`, the root +`Cargo.lock` and the five detached locks (the two Go guests and the three fuzz +crates), and both `CHANGELOG.md` files. `release-plz.yml` publishes them when +that pull request reaches `main`. ## Pre-release process diff --git a/SECURITY.md b/SECURITY.md index 87bfec41c..f4a118eaf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,19 +35,20 @@ It also carries the source of two Rust crates published to crates.io, crates), which has no release yet. All three are in scope for security reports on the same terms as the npm packages above. -> **Note on publishing.** `@cipherstash/auth` and its six platform packages, -> and the `stack-auth` and `stack-profile` crates, are developed here but are -> *published* from `cipherstash/cipherstash-suite` until the arming PR of the -> stack-* crates import repoints them. Every other package in the table above, -> including all seven `@cipherstash/protect-ffi*` packages and -> `@cipherstash/eql`, is published from this repository by -> `.github/workflows/release.yml`; the `eql-bindings` crate is published from -> here by `.github/workflows/release-plz.yml`. EQL moved here at the Phase 5 -> cutover in `docs/plans/2026-08-13-eql-monorepo-absorption.md`. Releases made -> before it, `@cipherstash/eql@3.0.5` and earlier, were built by -> `cipherstash/encrypt-query-language`. **Source, issues, and security reports -> for all of them belong here regardless** — that part does not depend on which -> pipeline built the artefact. +> **Note on publishing.** Every package in the table above, including all +> seven `@cipherstash/protect-ffi*` packages, all seven `@cipherstash/auth*` +> packages and `@cipherstash/eql`, is published from this repository by +> `.github/workflows/release.yml`; the `eql-bindings`, `stack-auth` and +> `stack-profile` crates are published from here by +> `.github/workflows/release-plz.yml`. EQL moved here at the Phase 5 cutover in +> `docs/plans/2026-08-13-eql-monorepo-absorption.md`, and the auth packages and +> the two crates at the arming PR of the stack-* crates import. Releases made +> before those moves were built elsewhere: `@cipherstash/eql@3.0.5` and earlier +> by `cipherstash/encrypt-query-language`, and `@cipherstash/auth@0.44.0` and +> the crates' `0.42.3` and earlier by `cipherstash/cipherstash-suite`. +> **Source, issues, and security reports for all of them belong here +> regardless** — that part does not depend on which pipeline built the +> artefact. > > **Do not trust this note for which repository published a given release.** > Registry configuration changes without touching this file, and this note has diff --git a/docs/npm-releases.md b/docs/npm-releases.md index 1c7fd5342..50f59550f 100644 --- a/docs/npm-releases.md +++ b/docs/npm-releases.md @@ -1,5 +1,10 @@ # npm releases (changesets) +> **Historical.** This records how `@cipherstash/auth` released from +> cipherstash-suite before the stack-* crates import. The workflows it names are +> not in this repository. Here, `@cipherstash/auth` versions through this +> repository's Changesets and publishes from `release.yml`; see CONTRIBUTING.md. + > Adopts [changesets](https://github.com/changesets/changesets) for the `@cipherstash` > npm products while release-plz keeps owning the Rust crates. Tracking issue: > [CIP-3278](https://linear.app/cipherstash/issue/CIP-3278). Covers the full diff --git a/package.json b/package.json index 1f1f9a4d6..6f22b42a0 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:auth-changeset": "node scripts/lint-no-auth-changeset.mjs", "lint:eql-pins": "node scripts/lint-no-eql-registry-pins.mjs", "lint:package-paths": "node scripts/lint-no-dead-package-paths.mjs", "lint:runners": "node scripts/lint-no-hardcoded-runners.mjs", diff --git a/scripts/__tests__/auth-build-artifacts.test.mjs b/scripts/__tests__/auth-build-artifacts.test.mjs index fdaee354d..a0fb3c2ea 100644 --- a/scripts/__tests__/auth-build-artifacts.test.mjs +++ b/scripts/__tests__/auth-build-artifacts.test.mjs @@ -1,6 +1,7 @@ import { readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { workspaceManifests } from '../release-gate.mjs' import { REPO_ROOT } from './lib/repo-root.mjs' import { readWorkflow } from './lib/workflows.mjs' @@ -79,3 +80,24 @@ describe('_build-auth-artifacts.yml', () => { expect(packs.some((run) => /\bnpm pack\b/.test(run))).toBe(false) }) }) + +describe('the seven @cipherstash/auth packages', () => { + it('version together as one changesets fixed group', () => { + // The `wrapper` job refuses a tarball whose six peers differ from its own + // version. A package left out of the group drifts on its first bump, and + // that shows up mid-release rather than here. + const isAuth = (name) => + name === '@cipherstash/auth' || name.startsWith('@cipherstash/auth-') + const workspace = workspaceManifests() + .map((manifest) => manifest.name) + .filter(isAuth) + .sort() + const { fixed } = JSON.parse( + readFileSync(join(REPO_ROOT, '.changeset/config.json'), 'utf8'), + ) + const group = fixed.find((names) => names.includes('@cipherstash/auth')) + + expect(workspace).toHaveLength(7) + expect([...(group ?? [])].sort()).toEqual(workspace) + }) +}) diff --git a/scripts/__tests__/eql-pipeline-armed.test.mjs b/scripts/__tests__/eql-pipeline-armed.test.mjs index 101c9125e..0351ae0af 100644 --- a/scripts/__tests__/eql-pipeline-armed.test.mjs +++ b/scripts/__tests__/eql-pipeline-armed.test.mjs @@ -182,10 +182,17 @@ describe('the stack-* crates line', () => { ) }) - it('matches the live map, whichever state that is in', () => { + it('matches the live map', () => { expect(pipelineArmed('crates')).toBe(!FROZEN_PUBLISHERS.has(CRATES_PACKAGE)) }) + it('is armed, because the arming PR moved auth and crates publishing here', () => { + // Re-freezing @cipherstash/auth also stops the stack-auth and + // stack-profile crates.io line, so it has to be a deliberate edit here. + expect(pipelineArmed('crates')).toBe(true) + expect(lineFrozenReason('crates')).toBeNull() + }) + it('refuses a line it does not know', () => { expect(() => pipelineArmed('bogus')).toThrow(/unknown release line/) }) diff --git a/scripts/__tests__/frozen-publisher-docs.test.mjs b/scripts/__tests__/frozen-publisher-docs.test.mjs index 0867ae307..bdda6a204 100644 --- a/scripts/__tests__/frozen-publisher-docs.test.mjs +++ b/scripts/__tests__/frozen-publisher-docs.test.mjs @@ -53,10 +53,11 @@ const AUTH = '@cipherstash/auth' * Where each freeze is explained, and the instruction each file carries. * * `pkg` is the map key the instruction is about. The @cipherstash/auth freeze - * (the wrapper and its six platform packages, keyed here by the wrapper) is - * deleted by the arming PR of the stack-* crates import, and its prose goes - * with it. `historical` is the wording each instruction was written for, - * which keeps an assertion of absence able to fail once a freeze is deleted. + * (the wrapper and its six platform packages, keyed here by the wrapper) was + * deleted by the arming PR of the stack-* crates import. Its rows stay, so + * neither document can describe it again; `historical` is the wording each + * instruction was written for, which keeps an assertion of absence able to + * fail. */ const DOCS = [ { diff --git a/scripts/__tests__/lint-no-auth-changeset.test.mjs b/scripts/__tests__/lint-no-auth-changeset.test.mjs deleted file mode 100644 index ea8b4bb39..000000000 --- a/scripts/__tests__/lint-no-auth-changeset.test.mjs +++ /dev/null @@ -1,150 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { mkdtempSync, 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 { FROZEN_PUBLISHERS, workspaceManifests } from '../release-gate.mjs' -import { REPO_ROOT } from './lib/repo-root.mjs' - -const SCRIPT = resolve( - fileURLToPath(import.meta.url), - '../../lint-no-auth-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), - } - } -} - -// Generated rather than committed: a committed CRLF fixture is one -// `autocrlf=true` checkout away from being normalised to LF. -const tempDirs = [] -function changesets(files) { - const dir = mkdtempSync(join(tmpdir(), 'auth-changeset-')) - tempDirs.push(dir) - for (const [name, body] of Object.entries(files)) { - writeFileSync(join(dir, name), body) - } - return dir -} -afterAll(() => { - for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }) -}) - -describe('lint-no-auth-changeset', () => { - it('passes against the real .changeset directory', () => { - expect(run().exitCode).toBe(0) - }) - - it('passes on changesets that name no auth package', () => { - const dir = changesets({ - 'happy-otter-sing.md': - "---\n'@cipherstash/stack': patch\n---\n\nA fix.\n", - }) - expect(run(dir).exitCode).toBe(0) - }) - - it('does not parse README.md as a changeset', () => { - // Guarded frontmatter in the README, so this passes only because of the skip. - const dir = changesets({ - 'README.md': "---\n'@cipherstash/auth': minor\n---\n\nNot a changeset.\n", - }) - const { exitCode, output } = run(dir) - expect(exitCode).toBe(0) - expect(output).not.toMatch(/README/) - }) - - it('fails when a changeset names the wrapper, and reports every file', () => { - const dir = changesets({ - 'brave-lion-jump.md': - "---\n'@cipherstash/auth': minor\n---\n\nNew API.\n", - 'quiet-moth-wait.md': - "---\n'@cipherstash/auth-linux-x64-musl': patch\n---\n\nRebuild.\n", - }) - const { exitCode, output } = run(dir) - expect(exitCode).toBe(1) - expect(output).toMatch('brave-lion-jump.md') - expect(output).toMatch('quiet-moth-wait.md') - expect(output).toMatch('@cipherstash/auth-linux-x64-musl') - }) - - it('catches an auth package on any frontmatter line, not just the first', () => { - const dir = changesets({ - 'wise-crane-list.md': - "---\n'@cipherstash/stack': patch\n'@cipherstash/auth-darwin-arm64': patch\n---\n\nBoth.\n", - }) - const { exitCode, output } = run(dir) - expect(exitCode).toBe(1) - expect(output).toMatch('@cipherstash/auth-darwin-arm64') - }) - - it('parses a changeset checked out with CRLF line endings', () => { - const dir = changesets({ - 'tidy-vole-climb.md': - "---\r\n'@cipherstash/stack': patch\r\n'@cipherstash/auth-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/auth-linux-arm64-gnu') - }) - - it('ignores an auth package named only in the prose body', () => { - const dir = changesets({ - 'gentle-fox-run.md': - "---\n'@cipherstash/stack': patch\n---\n\nUses `'@cipherstash/auth': minor` internally.\n", - }) - expect(run(dir).exitCode).toBe(0) - }) - - it('does not guard a name that only starts with auth', () => { - const dir = changesets({ - 'odd-name.md': - "---\n'@cipherstash/authority': patch\n---\n\nUnrelated.\n", - }) - expect(run(dir).exitCode).toBe(0) - }) - - it('names its own removal condition in the source', () => { - const source = readFileSync(SCRIPT, 'utf8') - expect(source).toMatch(/TEMPORARY/) - expect(source).toMatch(/trusted\s+publishing/) - }) - - it('guards exactly the frozen auth packages, which are the auth workspace packages', () => { - // Four lists name the same seven packages until PR E: this guard, the - // release gate's freeze, the workspace, and the changesets `fixed` group. - // Drift between any two lets a platform package through while the others - // still treat it as frozen, or version it apart from the wrapper. - const guarded = [ - ...readFileSync(SCRIPT, 'utf8').matchAll( - /'(@cipherstash\/auth(?:-[a-z0-9-]+)?)'/g, - ), - ].map(([, name]) => name) - const isAuth = (name) => - name === '@cipherstash/auth' || name.startsWith('@cipherstash/auth-') - const frozen = [...FROZEN_PUBLISHERS.keys()].filter(isAuth) - const workspace = workspaceManifests() - .map((manifest) => manifest.name) - .filter(isAuth) - - expect([...new Set(guarded)].sort()).toHaveLength(7) - expect([...new Set(guarded)].sort()).toEqual([...frozen].sort()) - expect([...new Set(guarded)].sort()).toEqual([...workspace].sort()) - - const { fixed } = JSON.parse( - readFileSync(join(REPO_ROOT, '.changeset/config.json'), 'utf8'), - ) - const group = fixed.find((names) => names.includes('@cipherstash/auth')) - expect([...(group ?? [])].sort()).toEqual([...new Set(guarded)].sort()) - }) -}) diff --git a/scripts/__tests__/release-gate.test.mjs b/scripts/__tests__/release-gate.test.mjs index 764df3f12..104bd1717 100644 --- a/scripts/__tests__/release-gate.test.mjs +++ b/scripts/__tests__/release-gate.test.mjs @@ -614,7 +614,8 @@ describe('publishBlockers', () => { * * So the map gets a test that names what is NOT in it. `@cipherstash/eql` is * held to the same rule since its Phase-5 cutover, when npm and crates.io - * trusted publishing moved here. + * trusted publishing moved here, and the seven `@cipherstash/auth` packages + * since the arming PR of the stack-* crates import. */ describe('FROZEN_PUBLISHERS', () => { it('does not freeze the protect-ffi packages, whose publisher has moved here', () => { @@ -636,6 +637,43 @@ describe('FROZEN_PUBLISHERS', () => { expect(FROZEN_ARTEFACT_DIGESTS.has(EQL)).toBe(false) }) + it('does not freeze the @cipherstash/auth packages, whose publisher has moved here', () => { + // The same defect, one line along. The arming PR of the stack-* crates + // import repointed npm trusted publishing for the wrapper and its six + // platform packages at this repository and deleted their entries, in both + // maps. + expect( + [...FROZEN_PUBLISHERS.keys()].filter((name) => name.startsWith(AUTH)), + 'npm trusted publishing for all seven @cipherstash/auth packages is ' + + 'bound to this repository and `release.yml`. A frozen entry here ' + + 'blocks the first release that bumps one of them.', + ).toEqual([]) + expect( + [...FROZEN_ARTEFACT_DIGESTS.keys()].filter((name) => + name.startsWith(AUTH), + ), + ).toEqual([]) + }) + + it('does not block the first auth release published from this repository', () => { + // Through the REAL map, for the reason the FFI case below gives. Every auth + // version on npm was published from cipherstash-suite, so the first bump + // made here is absent from npm by definition. + const blockers = publishBlockers({ + manifests: [ + { name: AUTH, version: '0.44.1', private: false, workspaceDeps: [] }, + { + name: AUTH_PLATFORM, + version: '0.44.1', + private: false, + workspaceDeps: [], + }, + ], + lookup: () => ['0.44.0'], + }) + expect(blockers).toEqual([]) + }) + it('does not block the first FFI or EQL release published from this repository', () => { // Driven through the REAL map, not a fixture: the defect is in the map's // contents, so a fixture would prove the mechanism and miss it entirely. @@ -816,12 +854,11 @@ describe('the gate actually blocks the publish', () => { * `npmVersions` shells out to it. That also keeps this offline and * deterministic. * - * THE REAL MAPS DO NOT FREEZE EQL, so the EQL blocking path needs a frozen - * package, and most of these run `main()` with the EQL fixture injected - * through its parameters — a separate process importing the module, never a - * flag the real script reads. Two run the script itself: one holds the real - * maps to "EQL is not frozen", and one drives the `@cipherstash/auth` freeze - * through them. + * WITH THE REAL MAPS EMPTY, the blocking path needs a frozen package, so most + * of these run `main()` with the EQL fixture injected through its parameters — + * a separate process importing the module, never a flag the real script reads. + * Two run the script itself, to hold the real maps to "EQL is not frozen" and + * to "`@cipherstash/auth` is not frozen". * * THE SHIM ANSWERS `pack` AS WELL AS `view`, and that is not tidying. It used * to answer `view` only, so `publishedArtefactDigest`'s `npm pack` got a @@ -861,9 +898,9 @@ describe('the gate exits non-zero when a blocker is found', () => { " process.stderr.write('npm error code ETARGET\\n'); process.exit(1)\n" + ' }\n' + " const dest = process.argv[process.argv.indexOf('--pack-destination') + 1]\n" + - // A `files` artefact (@cipherstash/auth): the tarball carries the - // tree's own bytes for every listed file, so CHECK C compares them for - // real and passes. + // A `files` artefact (none in the real map since the auth packages + // were unfrozen): the tarball carries the tree's own bytes for every + // listed file, so CHECK C compares them for real and passes. ' const files = JSON.parse(process.env.FAKE_NPM_FILES)[name]\n' + ' if (files) {\n' + ' for (const [published, source] of Object.entries(files)) {\n' + @@ -1036,18 +1073,22 @@ describe('the gate exits non-zero when a blocker is found', () => { expect(result.stdout).toContain(`unpublished: ${EQL}`) }) - it('blocks a stray @cipherstash/auth bump while the auth packages are frozen', () => { - // The auth freeze, end to end, through the real script and the real maps: - // npm carries the committed 0.44.0 and not the bump, so CHECK A names the - // bumped version and the release stops. + it('hands an unpublished @cipherstash/auth version to publish-auth now that this repository publishes it', () => { + // The arming PR's end state, end to end: npm does not carry the tree's + // auth version, and the gate passes and reports `auth=true`, which runs + // `auth-artifacts` and `publish-auth`, instead of refusing the release as + // a frozen publisher. Through the real script, because the real maps are + // what the arming PR changed. const result = runGate( { ...allPublished, [AUTH]: ['0.43.0'] }, IN_TREE_DIGEST, REAL_SCRIPT, ) - expect(result.status).toBe(1) - expect(result.stderr).toContain(`${AUTH}@0.44.0 is not on npm`) - expect(result.stderr).toContain('cipherstash/cipherstash-suite') + expect(result.stderr).toBe('') + expect(result.status).toBe(0) + expect(result.stdout).toContain(`unpublished: ${AUTH}`) + expect(result.stdout).toContain('ffi=false auth=true js=false') + expect(result.githubOutput).toBe('ffi=false\nauth=true\njs=false\n') }) }) @@ -1460,38 +1501,33 @@ describe('reportBlockers, for a bytes skew', () => { }) /** - * The `files` artefact shape, which `@cipherstash/auth` needs because it has no - * release manifest to read a digest from. The gate hashes each listed file on - * both sides; EQL's `field` entry keeps working unchanged (every EQL test - * above). + * The `files` artefact shape, for a frozen package with no release manifest to + * read a digest from. No entry in the real map uses it since the auth packages + * were unfrozen, so the fixture is the list their entry carried, read from the + * wrapper's `files`: the mechanism stays exercised against real bytes. */ describe('a `files` artefact', () => { const AUTH_DIR = 'languages/typescript/packages/auth' - const artefact = FROZEN_ARTEFACT_DIGESTS.get(AUTH) + const artefact = { + label: 'wrapper sources and type declarations', + files: JSON.parse( + readFileSync(join(REPO_ROOT, AUTH_DIR, 'package.json'), 'utf8'), + ) + .files.filter((entry) => !entry.endsWith('/')) + .map((file) => ({ + inTree: `${AUTH_DIR}/${file}`, + published: `package/${file}`, + })), + } it('hashes every listed file in the tree, one line per file', () => { const lines = inTreeArtefactDigest(AUTH, artefact).split('\n') + expect(artefact.files).toHaveLength(15) expect(lines).toHaveLength(artefact.files.length) for (const line of lines) expect(line).toMatch(/^package\/\S+ [0-9a-f]{64}$/) }) - it('lists every tracked file the wrapper publishes, except package.json', () => { - // A file the wrapper publishes and the list leaves out is a file whose - // bytes nobody compares. `files` in package.json is what npm packs, and - // `wasm/` is a build output with nothing tracked behind it. - const manifest = JSON.parse( - readFileSync(join(REPO_ROOT, AUTH_DIR, 'package.json'), 'utf8'), - ) - const published = manifest.files.filter((entry) => !entry.endsWith('/')) - expect(artefact.files.map((file) => file.inTree).sort()).toEqual( - published.map((file) => `${AUTH_DIR}/${file}`).sort(), - ) - expect(manifest.files.filter((entry) => entry.endsWith('/'))).toEqual([ - 'wasm/', - ]) - }) - it('throws, naming the file, when a listed file is missing from the tree', () => { expect(() => inTreeArtefactDigest(AUTH, { @@ -1563,22 +1599,19 @@ describe('a `files` artefact', () => { }) /** - * The `noTreeBytes` shape: the six @cipherstash/auth platform packages publish - * only a binary built in CI, so CHECK C has nothing to compare and skips them. - * They are frozen all the same, so CHECK A blocks a stray version. + * The `noTreeBytes` shape: a platform package whose tarball holds only a + * binary built in CI, so CHECK C skips it and only CHECK A holds it. The six + * @cipherstash/auth platform packages were frozen in this shape; their entry + * is the fixture. */ describe('a `noTreeBytes` artefact', () => { const platforms = workspaceManifests() .map((manifest) => manifest.name) .filter((name) => name.startsWith(`${AUTH}-`)) - - it('covers every @cipherstash/auth platform package in the workspace', () => { - expect(platforms).toHaveLength(6) - for (const name of platforms) { - expect(FROZEN_PUBLISHERS.has(name), name).toBe(true) - expect(FROZEN_ARTEFACT_DIGESTS.get(name).noTreeBytes, name).toMatch(/\S/) - } - }) + const artefact = { + label: 'platform binary', + noTreeBytes: 'the published tarball holds only a binary built in CI', + } it('is skipped by CHECK C without asking the registry', () => { const name = platforms[0] @@ -1586,7 +1619,7 @@ describe('a `noTreeBytes` artefact', () => { frozenBytesSkew({ manifests: [{ name, version: '0.44.0', private: false }], frozen: new Map([[name, 'frozen']]), - artefacts: new Map([[name, FROZEN_ARTEFACT_DIGESTS.get(name)]]), + artefacts: new Map([[name, artefact]]), inTreeDigest: () => { throw new Error('must not read the tree') }, @@ -1612,15 +1645,20 @@ describe('a `noTreeBytes` artefact', () => { ).toThrow(/noTreeBytes/) }) - it('still blocks a platform version npm does not carry (CHECK A)', () => { + it('blocks a platform version npm does not carry only while it is frozen (CHECK A)', () => { const name = platforms[0] - expect( + const blockers = (frozen) => publishBlockers({ manifests: [{ name, version: '0.44.1', private: false }], lookup: () => ['0.44.0'], + frozen, }).map( (blocker) => `${blocker.kind} ${blocker.package}@${blocker.version}`, - ), - ).toEqual([`frozen-publisher ${name}@0.44.1`]) + ) + expect(blockers(new Map([[name, 'frozen']]))).toEqual([ + `frozen-publisher ${name}@0.44.1`, + ]) + // The real map: armed, so a bump is a release, not a blocker. + expect(blockers(FROZEN_PUBLISHERS)).toEqual([]) }) }) diff --git a/scripts/eql-pipeline-armed.mjs b/scripts/eql-pipeline-armed.mjs index e917afdd6..d7639cc35 100644 --- a/scripts/eql-pipeline-armed.mjs +++ b/scripts/eql-pipeline-armed.mjs @@ -21,11 +21,11 @@ * ## A second line: the stack-* crates * * `release-plz.yml` also publishes `stack-auth` and `stack-profile` from the - * root Cargo workspace. They are imported from cipherstash-suite with - * `@cipherstash/auth`, and all three lines move here together in the arming PR - * of that import (PR E), which repoints crates.io and npm trusted publishing in + * root Cargo workspace. They were imported from cipherstash-suite with + * `@cipherstash/auth`, and all three lines moved here together in the arming + * PR of that import, which repointed crates.io and npm trusted publishing in * one step. No crate is in `FROZEN_PUBLISHERS` — it is an npm map — so the - * crates line keys on `@cipherstash/auth`, whose entries that PR deletes. + * crates line keys on `@cipherstash/auth`, whose entries that PR deleted. * `node scripts/eql-pipeline-armed.mjs crates` answers for it. The file keeps * its EQL name because the EQL workflows and tests name it. */ diff --git a/scripts/lint-no-auth-changeset.mjs b/scripts/lint-no-auth-changeset.mjs deleted file mode 100644 index 93de4124e..000000000 --- a/scripts/lint-no-auth-changeset.mjs +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Fail if any pending changeset names one of the seven `@cipherstash/auth` - * packages. - * - * TEMPORARY. Delete this script, its self-test and its `lint:auth-changeset` - * entry in the arming PR of the stack-* crates import (PR E), when npm trusted - * publishing for the seven packages moves from `cipherstash/cipherstash-suite` - * to `cipherstash/stack` and their `FROZEN_PUBLISHERS` entries are deleted. - * - * The release gate's CHECK A already refuses to publish a frozen package at a - * version npm does not carry, but it fires on `main` after the Version - * Packages PR merges, and then it blocks every release until the bump is - * reverted. This stops the changeset on the pull request instead. - * - * There is nowhere to park the changeset: the `.md.deferred` convention the - * retired protect-ffi guard used is forbidden by `no-parked-changesets`. - */ -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/auth', - '@cipherstash/auth-darwin-arm64', - '@cipherstash/auth-darwin-x64', - '@cipherstash/auth-linux-arm64-gnu', - '@cipherstash/auth-linux-x64-gnu', - '@cipherstash/auth-linux-x64-musl', - '@cipherstash/auth-win32-x64-msvc', -]) - -const changesetDir = process.argv[2] - ? resolve(process.argv[2]) - : join(REPO_ROOT, '.changeset') - -// Only the first fenced block is frontmatter: prose below it may quote a -// package name, and `---` rules in 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 an @cipherstash/auth package.') - process.exit(0) -} - -console.error('\nA pending changeset names an @cipherstash/auth package:\n') -for (const { file, packages } of offenders) { - 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/cipherstash-suite — npm trusted publishing has not been\n' + - 'repointed yet, so `release.yml` here cannot publish them. Releasing a\n' + - 'bumped version from here is blocked by `release:gate`, and that block\n' + - 'stops every other release with it.\n\n' + - 'Remove the changeset. Merges that touch the auth packages are paused\n' + - 'until the arming PR (PR E of the stack-* crates import), which repoints\n' + - 'trusted publishing, deletes this script and writes the changesets. If a\n' + - 'change must land before then, put its release note in the pull request.\n', -) -process.exit(1) diff --git a/scripts/release-gate.mjs b/scripts/release-gate.mjs index c6862e1d2..6fc96c706 100644 --- a/scripts/release-gate.mjs +++ b/scripts/release-gate.mjs @@ -144,11 +144,11 @@ const INSTALLED_TABLES = new Set([ * * Each entry is DELETED by the cutover that repoints its publisher. * `@cipherstash/eql`'s Phase-5 release cutover repointed npm and crates.io - * trusted publishing at this repository and deleted its entry here. The - * `@cipherstash/auth` packages below are in the same position until the - * arming PR of the stack-* crates import. An empty map is a legitimate state, - * not a retired mechanism — the next package that lives here before its - * publisher moves goes in, with its artefact below. + * trusted publishing at this repository and deleted its entry here, and the + * arming PR of the stack-* crates import did the same for the seven + * `@cipherstash/auth` packages, which is why the map is empty. Empty is a + * legitimate state, not a retired mechanism — the next package that lives + * here before its publisher moves goes back in, with its artefact below. * * DELETE IT IN THAT PR, not afterwards. An entry left behind does not fail on * the day it goes wrong, it fails on the next release: while the package sits @@ -160,28 +160,7 @@ const INSTALLED_TABLES = new Set([ * `release-gate.test.mjs` now asserts their absence, so the map has a test for * what is NOT in it as well as what is. */ -export const FROZEN_PUBLISHERS = new Map([ - // The @cipherstash/auth wrapper and its six platform packages, imported - // from cipherstash-suite with the stack-* crates. They keep publishing from - // there until the arming PR of that import repoints npm trusted publishing - // at this repository and deletes all seven entries here, in both maps. - ...[ - '@cipherstash/auth', - '@cipherstash/auth-darwin-arm64', - '@cipherstash/auth-darwin-x64', - '@cipherstash/auth-linux-arm64-gnu', - '@cipherstash/auth-linux-x64-gnu', - '@cipherstash/auth-linux-x64-musl', - '@cipherstash/auth-win32-x64-msvc', - ].map((name) => [ - name, - 'Still published from cipherstash/cipherstash-suite — npm trusted publishing ' + - 'for the seven @cipherstash/auth packages names that repository, not this ' + - 'one. `release.yml` here builds and publishes them (`publish-auth`), but only ' + - 'once these entries are gone. Repointing is the arming PR (PR E) of the ' + - 'stack-* crates import.', - ]), -]) +export const FROZEN_PUBLISHERS = new Map([]) /** * For each frozen package, the artefact whose bytes must equal the published @@ -224,58 +203,20 @@ export const FROZEN_PUBLISHERS = new Map([ * `release-gate.test.mjs`). * * `files` — for a package with no such manifest. The gate hashes each * listed file with sha256, in the tree and in the published tarball, and - * a mismatch names the file. `@cipherstash/auth` is this shape: the list - * is every tracked file the wrapper publishes except `package.json`, which - * publishing rewrites. `wasm/` is a build output and is not listed. The - * list cannot see the Rust source, because the compiled binary is not in - * the tree — the freeze covers the JavaScript and type surface only. + * a mismatch names the file. `@cipherstash/auth` was frozen in this shape + * from the stack-* crates import until its arming PR: the list was every + * tracked file the wrapper publishes except `package.json`, which + * publishing rewrites. Such a list cannot see a compiled binary's source. * * `noTreeBytes` — a package whose tarball holds nothing the tree has, only * a binary built in CI. CHECK C skips it, and the string says why. The * entry still exists so the key-equality test holds, and CHECK A still - * blocks a version npm does not carry. + * blocks a version npm does not carry. The six `@cipherstash/auth-*` + * platform packages were frozen in this shape. + * + * No entry uses any of the three shapes today. They stay, with their tests, + * for the next package that lives here before its publisher moves. */ -export const FROZEN_ARTEFACT_DIGESTS = new Map([ - [ - '@cipherstash/auth', - { - label: 'wrapper sources and type declarations', - files: [ - 'index.js', - 'stack-auth-node.js', - 'wasm-inline.mjs', - 'cookies.mjs', - 'base64url.mjs', - 'next.mjs', - 'index.d.ts', - 'native.d.ts', - 'wasm-types.d.ts', - 'wasm-inline.d.ts', - 'cookies.d.ts', - 'base64url.d.ts', - 'next.d.ts', - 'README.md', - 'LICENSE', - ].map((file) => ({ - inTree: `languages/typescript/packages/auth/${file}`, - published: `package/${file}`, - })), - }, - ], - ...[ - '@cipherstash/auth-darwin-arm64', - '@cipherstash/auth-darwin-x64', - '@cipherstash/auth-linux-arm64-gnu', - '@cipherstash/auth-linux-x64-gnu', - '@cipherstash/auth-linux-x64-musl', - '@cipherstash/auth-win32-x64-msvc', - ].map((name) => [ - name, - { - label: 'platform binary', - noTreeBytes: 'the published tarball holds only a binary built in CI', - }, - ]), -]) +export const FROZEN_ARTEFACT_DIGESTS = new Map([]) /** * The range pnpm writes into the packed `package.json` for a `workspace:` @@ -979,10 +920,9 @@ export function reportBlockers(blockers) { " repository's to do.\n" : target ? ' 1. Publish the frozen package. For @cipherstash/eql that is the Phase 5\n' + - ' cutover in docs/plans/2026-08-13-eql-monorepo-absorption.md; for the\n' + - ' @cipherstash/auth packages it is the arming PR of the stack-* crates\n' + - ' import. Either way: repoint npm trusted publishing to cipherstash/stack\n' + - ` and release the version above — ${target}.\n` + + ' cutover in docs/plans/2026-08-13-eql-monorepo-absorption.md: repoint\n' + + ' npm trusted publishing to cipherstash/stack and release the version\n' + + ` above — ${target}.\n` + ' Every finding then clears on its own, with no further change here.\n' : ' 1. Publish the frozen package. Nothing above is frozen, so this way out\n' + ' is not available: the findings are manifests to fix, not a release to\n' + From 0efb59e38511c5d0917e0bb6e801991cc42cbe31 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 2 Oct 2026 14:58:19 +1000 Subject: [PATCH 2/5] ci: require an @cipherstash/auth changeset for changes it ships Port require-auth-npm-changeset.yml from cipherstash-suite, with its paths renamed: packages/stack-auth's manifest and src/, and the auth and stack-auth-wasm binding folders. The job diffs the pull request against its base and passes the added or modified changesets to scripts/check-auth-npm-changeset.mjs, which fails unless one releases @cipherstash/auth with a patch, minor or major bump. The script imports @changesets/parse, which pnpm does not expose to the root as a dependency of @changesets/cli, so the root declares it at the 0.4.3 already in the lock and the job installs only the root. The script now skips .changeset/README.md, which the job's pathspec matches, and names `pnpm changeset`. The suite's release-plz exemption is dropped: nothing here opens a release-plz pull request. check-auth-npm-changeset.test.mjs drives the script and holds the job's pathspec and its paths filter to the same set. The workflow has only a pull_request trigger, which workflow-paths-filter-parity now records. AGENTS.md and CONTRIBUTING.md describe the rule, and note that a crates release trips it: stack-auth sends its own version in its user-agent. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .../workflows/require-auth-npm-changeset.yml | 102 +++++++++++ AGENTS.md | 2 +- CONTRIBUTING.md | 13 +- package.json | 1 + pnpm-lock.yaml | 3 + .../check-auth-npm-changeset.test.mjs | 167 ++++++++++++++++++ .../workflow-paths-filter-parity.test.mjs | 6 + scripts/check-auth-npm-changeset.mjs | 14 +- 8 files changed, 302 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/require-auth-npm-changeset.yml create mode 100644 scripts/__tests__/check-auth-npm-changeset.test.mjs diff --git a/.github/workflows/require-auth-npm-changeset.yml b/.github/workflows/require-auth-npm-changeset.yml new file mode 100644 index 000000000..06186afb9 --- /dev/null +++ b/.github/workflows/require-auth-npm-changeset.yml @@ -0,0 +1,102 @@ +name: "Require @cipherstash/auth changeset" + +# Ported from cipherstash-suite with the stack-* crates. A pull request that +# changes what `@cipherstash/auth` ships must carry a changeset with a patch, +# minor or major entry for it, even when the change is inside the Rust crate +# and the npm API does not move: the npm binary is built from that crate, and +# nothing else would release it. +# +# The paths below and the job's `git diff` pathspec are held to the same set +# by scripts/__tests__/check-auth-npm-changeset.test.mjs. There is no `push:` +# trigger: the check compares a pull request with its base. + +on: + pull_request: + paths: + - packages/stack-auth/Cargo.toml + - packages/stack-auth/src/** + - languages/typescript/packages/auth/** + - languages/typescript/packages/stack-auth-wasm/** + - scripts/check-auth-npm-changeset.mjs + - .github/workflows/require-auth-npm-changeset.yml + +permissions: + contents: read + +defaults: + run: + shell: bash + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + require-npm-changeset: + name: Require @cipherstash/auth changeset + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + # The job diffs the pull request head against its base. + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - uses: pnpm/action-setup@v6.1.0 + name: Install pnpm + with: + run_install: false + cache: false + + - name: Install Node.js + uses: actions/setup-node@v6.5.0 + with: + node-version: 22 + package-manager-cache: false + + # The root alone declares `@changesets/parse`, and nothing here builds. + - name: Install the Changesets parser + run: pnpm install --frozen-lockfile --ignore-scripts --filter @cipherstash/stack-monorepo + + - name: Check for an @cipherstash/auth changeset + env: + ACTOR: ${{ github.actor }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_REF: ${{ github.head_ref }} + run: | + set -euo pipefail + + # The Version Packages PR consumes the changesets it releases. + if [[ "$ACTOR" == "github-actions[bot]" ]] && \ + [[ "$HEAD_REF" == "changeset-release/main" ]]; then + echo "Automated Version Packages PR; release intent was already consumed" + exit 0 + fi + + if ! SHIPPED_CHANGES="$( + git diff --name-only "${BASE_SHA}...HEAD" -- \ + packages/stack-auth/Cargo.toml \ + packages/stack-auth/src \ + languages/typescript/packages/auth \ + languages/typescript/packages/stack-auth-wasm + )"; then + echo "::error::Failed to determine release-relevant stack-auth changes" + exit 1 + fi + + if [[ -z "$SHIPPED_CHANGES" ]]; then + echo "No release-relevant stack-auth changes found" + exit 0 + fi + + if ! CHANGESETS="$( + git diff --diff-filter=AM --name-only "${BASE_SHA}...HEAD" -- '.changeset/*.md' + )"; then + echo "::error::Failed to determine added or modified changesets" + exit 1 + fi + + mapfile -t CHANGESET_FILES <<< "$CHANGESETS" + node scripts/check-auth-npm-changeset.mjs "${CHANGESET_FILES[@]}" diff --git a/AGENTS.md b/AGENTS.md index 273cc0912..b9cb92844 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l `cipherstash/encrypt-query-language` repository. Old upstream issue and PR links are provenance only. - `packages/stack-auth`, `packages/stack-profile`, `packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`, `packages/stack-guest-abi`: The Rust crates imported from `cipherstash/cipherstash-suite` with their history — `stack-auth` and `stack-profile` (published to crates.io), and `stack-kms`, `stack-encrypt`, `stack-encrypt-derive` and `stack-guest-abi` (`publish = false`). They are the members of the **root Cargo workspace**, with the three node binding crates below. See "Working on the Rust crates". -- `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. +- `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`); a change to what it ships, the `stack-auth` crate included, needs an `@cipherstash/auth` changeset (`require-auth-npm-changeset.yml`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. - `languages/golang`: The Go module (`stackencrypt`, `stackauth`, `internal`), a wazero host with no cgo. Its two WASI guests (`*/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; the `.wasm` files they embed are gitignored. There is no Go release process yet. - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `languages/typescript/examples/*`: Working apps (basic, prisma, supabase-worker) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd9ba1b51..ec9690394 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -183,7 +183,13 @@ they always version together, so a bump to any one of them bumps all six. `@cipherstash/auth` and its six `@cipherstash/auth-*` platform packages are their own `fixed` group in `.changeset/config.json`, so the seven release together. `release.yml` builds the six platform binaries and publishes the -seven before Changesets publishes the rest. +seven before Changesets publishes the rest. A pull request that changes what +`@cipherstash/auth` ships must add a changeset for `@cipherstash/auth`, even +for an internal Rust change, because the npm binary is built from the crate: +`require-auth-npm-changeset.yml` fails without one. Those paths are +`packages/stack-auth/Cargo.toml`, `packages/stack-auth/src/`, and everything +under `languages/typescript/packages/auth` and +`languages/typescript/packages/stack-auth-wasm`. Two Rust crates, `stack-auth` and `stack-profile`, are released to crates.io, in one version group of their own, by release-plz from the root Cargo @@ -192,7 +198,10 @@ release them, bump both in a pull request of your own: the two `[package]` versions, their two entries in the root `[workspace.dependencies]`, the root `Cargo.lock` and the five detached locks (the two Go guests and the three fuzz crates), and both `CHANGELOG.md` files. `release-plz.yml` publishes them when -that pull request reaches `main`. +that pull request reaches `main`. The bump edits +`packages/stack-auth/Cargo.toml`, so the `@cipherstash/auth` changeset rule +above applies to it too: the binding sends `stack-auth`'s version in its +`user-agent`. ## Pre-release process diff --git a/package.json b/package.json index 6f22b42a0..1989ad5fe 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "devDependencies": { "@biomejs/biome": "^2.5.9", "@changesets/cli": "^2.31.1", + "@changesets/parse": "0.4.3", "@types/node": "^22.20.1", "js-yaml": "^4.3.1", "rimraf": "^6.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6c7e370d..207386056 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ importers: '@changesets/cli': specifier: ^2.31.1 version: 2.31.1(@types/node@22.20.1) + '@changesets/parse': + specifier: 0.4.3 + version: 0.4.3 '@types/node': specifier: ^22.20.1 version: 22.20.1 diff --git a/scripts/__tests__/check-auth-npm-changeset.test.mjs b/scripts/__tests__/check-auth-npm-changeset.test.mjs new file mode 100644 index 000000000..9c5fd9321 --- /dev/null +++ b/scripts/__tests__/check-auth-npm-changeset.test.mjs @@ -0,0 +1,167 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { readWorkflow } from './lib/workflows.mjs' + +/** + * `require-auth-npm-changeset.yml` and the script it runs, ported from + * cipherstash-suite with the stack-* crates. + * + * The workflow decides WHEN the check applies (a pull request that changes + * what `@cipherstash/auth` ships) and the script decides WHETHER the + * changesets it is handed carry a release for it. Both halves fail open: a + * path the workflow does not diff is a change that never needs a changeset, + * and a script that accepts anything is a check that passes. + */ + +const SCRIPT = 'scripts/check-auth-npm-changeset.mjs' +const WORKFLOW = '.github/workflows/require-auth-npm-changeset.yml' + +const dir = mkdtempSync(join(tmpdir(), 'check-auth-npm-changeset-')) +afterAll(() => rmSync(dir, { recursive: true, force: true })) + +let count = 0 +const changeset = (body, name = `c${++count}.md`) => { + const file = join(dir, name) + writeFileSync(file, body) + return file +} + +const run = (...files) => + spawnSync(process.execPath, [SCRIPT, ...files], { + cwd: REPO_ROOT, + encoding: 'utf8', + }) + +describe('check-auth-npm-changeset.mjs', () => { + it('fails with no changeset', () => { + const result = run() + expect(result.status).toBe(1) + expect(result.stderr).toContain('require an @cipherstash/auth changeset') + }) + + it.each(['patch', 'minor', 'major'])( + 'passes a %s release of @cipherstash/auth', + (type) => { + const result = run( + changeset(`---\n"@cipherstash/auth": ${type}\n---\n\nA change.\n`), + ) + expect(result.stderr).toBe('') + expect(result.status).toBe(0) + }, + ) + + it('fails a changeset that releases only other packages', () => { + const result = run( + changeset('---\n"@cipherstash/stack": patch\n---\n\nA change.\n'), + ) + expect(result.status).toBe(1) + expect(result.stderr).toContain('with a patch, minor, or major bump') + }) + + it('fails a `none` release of @cipherstash/auth', () => { + const result = run( + changeset('---\n"@cipherstash/auth": none\n---\n\nA change.\n'), + ) + expect(result.status).toBe(1) + }) + + it('fails a changeset with an empty summary', () => { + const result = run(changeset('---\n"@cipherstash/auth": patch\n---\n\n')) + expect(result.status).toBe(1) + expect(result.stderr).toContain('summary must not be empty') + }) + + it('ignores .changeset/README.md, which the workflow pathspec matches', () => { + const readme = changeset('# Changesets\n', 'README.md') + expect(run(readme).status).toBe(1) + expect( + run( + readme, + changeset('---\n"@cipherstash/auth": patch\n---\n\nA change.\n'), + ).status, + ).toBe(0) + }) + + it('tells a contributor to run the pnpm changeset command', () => { + expect(run().stderr).toContain("'pnpm changeset'") + }) +}) + +describe('require-auth-npm-changeset.yml', () => { + const workflow = readWorkflow(WORKFLOW) + const [job] = Object.values(workflow?.jobs ?? {}) + const check = job?.steps?.find((step) => step.run?.includes(`node ${SCRIPT}`)) + + /** The pathspec of the job's first `git diff`: what counts as shipped. */ + const shipped = ( + /git diff --name-only[^\n]*-- \\\n([\s\S]*?)\n\s*\)"/.exec( + check?.run ?? '', + )?.[1] ?? '' + ) + .split(/\\?\n/) + .map((line) => line.trim().replace(/\s*\\$/, '')) + .filter(Boolean) + + const filters = workflow?.on?.pull_request?.paths ?? [] + + it('diffs the crate and both binding folders', () => { + expect(shipped).toEqual([ + 'packages/stack-auth/Cargo.toml', + 'packages/stack-auth/src', + 'languages/typescript/packages/auth', + 'languages/typescript/packages/stack-auth-wasm', + ]) + }) + + it('runs on a pull request touching any path it diffs', () => { + // A path diffed but not filtered never boots the job. + for (const path of shipped) { + expect( + filters.some((filter) => filter === path || filter === `${path}/**`), + `${path} is diffed by the job but missing from its paths filter`, + ).toBe(true) + } + }) + + it('diffs every path that boots it, except its own inputs', () => { + // A path filtered but not diffed boots the job to report no change. + const own = [SCRIPT, WORKFLOW] + for (const filter of filters.filter((f) => !own.includes(f))) { + expect( + shipped.includes(filter.replace(/\/\*\*$/, '')), + `${filter} boots the job but the job does not diff it`, + ).toBe(true) + } + }) + + it('names folders that exist', () => { + for (const path of shipped) { + expect( + spawnSync('git', ['ls-files', '--error-unmatch', path], { + cwd: REPO_ROOT, + }).status, + path, + ).toBe(0) + } + }) + + it('installs a root that declares the parser the script imports', () => { + // pnpm does not expose `@changesets/cli`'s own dependencies to the root, + // so without this declaration the script dies on its first import. + const { devDependencies } = JSON.parse( + readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'), + ) + expect(devDependencies['@changesets/parse']).toBeDefined() + expect( + job?.steps?.some((step) => + /pnpm install --frozen-lockfile\b.*--filter @cipherstash\/stack-monorepo/.test( + step.run ?? '', + ), + ), + ).toBe(true) + }) +}) diff --git a/scripts/__tests__/workflow-paths-filter-parity.test.mjs b/scripts/__tests__/workflow-paths-filter-parity.test.mjs index 9f1f54191..c478626ca 100644 --- a/scripts/__tests__/workflow-paths-filter-parity.test.mjs +++ b/scripts/__tests__/workflow-paths-filter-parity.test.mjs @@ -132,6 +132,12 @@ const EXPECTED_ASYMMETRIES = new Map([ // release machinery never runs the check) is what the single list IS. 'pull_request is the only trigger; a post-merge copy would report a release-blocking finding too late to act on', ], + [ + '.github/workflows/require-auth-npm-changeset.yml', + // Its list is held to the job's own `git diff` pathspec by + // check-auth-npm-changeset.test.mjs, which is the second copy here. + 'pull_request is the only trigger; the check diffs a pull request against its base, which a push does not have', + ], [ '.github/workflows/test-eql.yml', // `pull_request` is deliberately UNFILTERED and `push: branches: [main]` diff --git a/scripts/check-auth-npm-changeset.mjs b/scripts/check-auth-npm-changeset.mjs index 97a87e699..70abed2bc 100644 --- a/scripts/check-auth-npm-changeset.mjs +++ b/scripts/check-auth-npm-changeset.mjs @@ -1,11 +1,19 @@ +// Run by .github/workflows/require-auth-npm-changeset.yml, which decides when +// a pull request changes what @cipherstash/auth ships. import fs from 'node:fs' +import { basename } from 'node:path' import parseChangeset from '@changesets/parse' -const changesetFiles = process.argv.slice(2).filter(Boolean) +// The workflow's `.changeset/*.md` pathspec matches the README, which has no +// frontmatter. +const changesetFiles = process.argv + .slice(2) + .filter(Boolean) + .filter((file) => basename(file) !== 'README.md') if (changesetFiles.length === 0) { console.error( - "::error::Release-relevant stack-auth changes require an @cipherstash/auth changeset. Run 'npx changeset' and commit the generated file.", + "::error::Release-relevant stack-auth changes require an @cipherstash/auth changeset. Run 'pnpm changeset' and commit the generated file.", ) process.exit(1) } @@ -62,7 +70,7 @@ for (const changesetFile of changesetFiles) { if (!hasAuthRelease) { console.error( - "::error::Release-relevant stack-auth changes require an @cipherstash/auth changeset with a patch, minor, or major bump. Run 'npx changeset' and commit the generated file.", + "::error::Release-relevant stack-auth changes require an @cipherstash/auth changeset with a patch, minor, or major bump. Run 'pnpm changeset' and commit the generated file.", ) process.exit(1) } From e766c9a830d77d93aca34bc5d92747000fa72eb9 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 2 Oct 2026 14:59:41 +1000 Subject: [PATCH 3/5] chore(changeset): carry the six pending @cipherstash/auth changesets Copy the six changesets pending on cipherstash-suite main (f161a447f, the commit the import exported) into .changeset/, byte for byte. Each is a patch for @cipherstash/auth; the fixed group takes the six platform packages with it, to 0.44.1. The suite's last release PR closed without merging and the export left .changeset/ out, so the first auth release from this repository carries them. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .changeset/auth-credential-rejection-classifier.md | 8 ++++++++ .changeset/auth-go-credential-guest.md | 5 +++++ .changeset/auth-http-feature-split.md | 11 +++++++++++ .changeset/auth-http-transport-trait.md | 9 +++++++++ .changeset/auth-mutation-regressions.md | 5 +++++ .changeset/auth-reexport-crn.md | 8 ++++++++ 6 files changed, 46 insertions(+) create mode 100644 .changeset/auth-credential-rejection-classifier.md create mode 100644 .changeset/auth-go-credential-guest.md create mode 100644 .changeset/auth-http-feature-split.md create mode 100644 .changeset/auth-http-transport-trait.md create mode 100644 .changeset/auth-mutation-regressions.md create mode 100644 .changeset/auth-reexport-crn.md diff --git a/.changeset/auth-credential-rejection-classifier.md b/.changeset/auth-credential-rejection-classifier.md new file mode 100644 index 000000000..87cf8722b --- /dev/null +++ b/.changeset/auth-credential-rejection-classifier.md @@ -0,0 +1,8 @@ +--- +"@cipherstash/auth": patch +--- + +Internal addition to the underlying Rust crate: `AuthError` gains an +`is_credential_rejection()` classifier used by FFI front-ends to decide +whether refreshing the credential and retrying is sensible. No API or +behaviour change for `@cipherstash/auth` consumers. diff --git a/.changeset/auth-go-credential-guest.md b/.changeset/auth-go-credential-guest.md new file mode 100644 index 000000000..faaf64b17 --- /dev/null +++ b/.changeset/auth-go-credential-guest.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/auth": patch +--- + +Device-session refresh now reports failed profile saves so callers do not silently reuse a consumed refresh token. diff --git a/.changeset/auth-http-feature-split.md b/.changeset/auth-http-feature-split.md new file mode 100644 index 000000000..611456844 --- /dev/null +++ b/.changeset/auth-http-feature-split.md @@ -0,0 +1,11 @@ +--- +"@cipherstash/auth": patch +--- + +Internal restructuring of the underlying Rust crate: HTTP transport (reqwest +and the bundled access-key, device-session, OIDC-federation and auto +strategies) now sits behind an `http` cargo feature, on by default and always +enabled in the npm builds — no API change for `@cipherstash/auth` consumers. +The only observable difference is the wording of transport-failure error +messages, which now read "Request to the auth server failed: …" instead of +"HTTP request failed: …". diff --git a/.changeset/auth-http-transport-trait.md b/.changeset/auth-http-transport-trait.md new file mode 100644 index 000000000..2668ca0c1 --- /dev/null +++ b/.changeset/auth-http-transport-trait.md @@ -0,0 +1,9 @@ +--- +"@cipherstash/auth": patch +--- + +Internal restructuring of the underlying Rust crate: every strategy now sends +its requests through an `HttpTransport` trait, with the bundled reqwest client +as the default implementation, so the same strategies can run over a host's own +HTTP client (the Go binding's WASI guest). The npm builds always use the bundled +client — no API or behaviour change for `@cipherstash/auth` consumers. diff --git a/.changeset/auth-mutation-regressions.md b/.changeset/auth-mutation-regressions.md new file mode 100644 index 000000000..51eaccfe6 --- /dev/null +++ b/.changeset/auth-mutation-regressions.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/auth": patch +--- + +Internal test-only change to the underlying Rust crate: adds regression coverage for token expiry, credential rejection, device metadata, and browser-launch results. No API or behaviour change for `@cipherstash/auth` consumers; this entry exists because CI requires a changeset for changes under the crate. diff --git a/.changeset/auth-reexport-crn.md b/.changeset/auth-reexport-crn.md new file mode 100644 index 000000000..af140b2e9 --- /dev/null +++ b/.changeset/auth-reexport-crn.md @@ -0,0 +1,8 @@ +--- +"@cipherstash/auth": patch +--- + +Internal addition to the underlying Rust crate: `stack_auth` now re-exports +`Crn` (the workspace CRN every strategy is bound to) so a caller that builds +a strategy by hand needs nothing else from `cts-common`. No API or behaviour +change for `@cipherstash/auth` consumers. From 931f359d105ca4e12f4890ed93c59871fa3ab48d Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 2 Oct 2026 15:13:18 +1000 Subject: [PATCH 4/5] build: take @cipherstash/auth from the workspace, not the registry stack, stash, @cipherstash/wizard and the protect-ffi integration suite move the wrapper and the six platform packages from `catalog:repo` to `workspace:*`. The seven catalog entries, the two minimumReleaseAgeExclude entries and the two npm Dependabot ignores are dead config and go. The lock changes only the auth importers and entries, and `pnpm pack` still writes each range as the exact version. A workspace @cipherstash/auth ships source only, and its index.js loads the napi module on import, so every CI job that imports the SDK or runs the CLI now builds it: without a build, the stack, stash and wizard suites fail 26, 16 and 2 files with `Failed to load native binding`. .github/actions/build-auth-binding runs build:debug, and build:wasm with `wasm: 'true'`, then checks both load. It runs after each of the ten build-ffi-binding calls, with wasm where that call has it, and in tests-bench.yml, whose unit checks import the SDK and whose globalSetup runs `stash`. In tests.yml's run-tests it replaces the auth half of the binding build step. auth-binding-step-order.test.mjs holds the pairing and the filters. Every workflow that uses the action filters on it. The three integration workflows that saw auth bumps through pnpm-workspace.yaml now filter on the paths require-auth-npm-changeset.yml treats as what @cipherstash/auth ships. supply-chain.e2e.test.ts keeps the lockstep invariant in its new shape: no auth catalog entry, and `workspace:*` in every consumer. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .github/actions/build-auth-binding/action.yml | 73 +++++++ .github/dependabot.yml | 24 +-- .github/workflows/integration-drizzle.yml | 39 +++- .github/workflows/integration-prisma-next.yml | 5 + .github/workflows/integration-protect-ffi.yml | 27 ++- .github/workflows/integration-supabase.yml | 39 +++- .../workflows/prisma-example-readme-e2e.yml | 5 + .github/workflows/prisma-next-e2e.yml | 5 + .github/workflows/tests-bench.yml | 8 + .github/workflows/tests.yml | 36 +++- e2e/tests/supply-chain.e2e.test.ts | 59 +++--- .../typescript/packages/cli/package.json | 16 +- .../integration-tests/package.json | 2 +- .../typescript/packages/stack/package.json | 16 +- .../typescript/packages/wizard/package.json | 16 +- pnpm-lock.yaml | 194 ++++-------------- pnpm-workspace.yaml | 32 +-- .../auth-binding-step-order.test.mjs | 138 +++++++++++++ 18 files changed, 459 insertions(+), 275 deletions(-) create mode 100644 .github/actions/build-auth-binding/action.yml create mode 100644 scripts/__tests__/auth-binding-step-order.test.mjs diff --git a/.github/actions/build-auth-binding/action.yml b/.github/actions/build-auth-binding/action.yml new file mode 100644 index 000000000..9c14b280a --- /dev/null +++ b/.github/actions/build-auth-binding/action.yml @@ -0,0 +1,73 @@ +name: Build the @cipherstash/auth binding +description: >- + Compile `languages/typescript/packages/auth` into what its JS consumers load + at runtime — the napi module (cargo, debug) and optionally `wasm/` + (wasm-pack) — then prove they load. + + WHY THIS EXISTS: `@cipherstash/stack`, `stash`, `@cipherstash/wizard` and the + protect-ffi integration suite take `@cipherstash/auth` from the workspace. + It ships source only there, and its `index.js` loads the napi module on + import, so every job that imports the SDK or runs the CLI builds it first. + From npm, the platform package brought a prebuilt `.node`. Without this, those + jobs fail with `Failed to load native binding for linux-x64`. It is the auth + half of `.github/actions/build-ffi-binding`, and runs after it; + scripts/__tests__/auth-binding-step-order.test.mjs holds the jobs to that. + + DO NOT USE FROM A PUBLISHING WORKFLOW: release builds go through + `_build-auth-artifacts.yml`, which compiles every platform from scratch. + +inputs: + wasm: + description: >- + Also build `wasm/`, which `@cipherstash/auth/wasm-inline` imports and + `@cipherstash/stack/wasm-inline` re-exports. Pass it where the job passes + `wasm: true` to build-ffi-binding. + required: false + default: 'false' + +runs: + using: composite + steps: + # The runner's cargo, as build-ffi-binding uses for `index.node`. Writes + # the typings to the committed `native.d.ts`, so the tree stays clean. + - name: Build the napi module (cargo) + shell: bash + run: pnpm --filter @cipherstash/auth run build:debug + + # The root mise.toml pins wasm-pack for this build. `install_args` narrows + # the install to it: the root also pins Rust, Go and golangci-lint, which + # this needs none of. Same action and pin as build-ffi-binding. + - name: Install wasm-pack + if: inputs.wasm == 'true' + uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3 + with: + install: true + install_args: aqua:wasm-bindgen/wasm-pack + working_directory: . + + - name: Add the wasm32 target + if: inputs.wasm == 'true' + shell: bash + run: rustup target add wasm32-unknown-unknown + + - name: Build wasm/ (wasm-pack) + if: inputs.wasm == 'true' + shell: bash + run: pnpm --filter @cipherstash/auth run build:wasm + + - name: Verify the binding loads + shell: bash + working-directory: languages/typescript/packages/auth + env: + WANT_WASM: ${{ inputs.wasm }} + run: | + set -euo pipefail + + # index.js loads the napi module when it is required. + node -e "require('./index.js')" + echo "the napi module loads" + + if [ "$WANT_WASM" = "true" ]; then + node --input-type=module -e "await import('./wasm-inline.mjs')" + echo "wasm/ loads" + fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8c6073551..2796fff27 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -43,17 +43,6 @@ updates: patterns: - "@types/*" ignore: - # Catalog-managed; bump manually via pnpm-workspace.yaml + changeset. - - dependency-name: "@cipherstash/auth" - # The platform bindings MUST move in lockstep with @cipherstash/auth: - # auth pins them as exact-version optional peer deps, so a skewed set - # makes npm nest per-consumer binding copies the hoisted auth package - # cannot resolve, and every project-local install of the CLI/SDK dies - # with "Failed to load native binding". Dependabot once bumped these - # six to 0.42.0 while the ignored auth stayed 0.41.0 (the rc.2 B1 - # bug). Bump all seven catalog entries together, manually. Lockstep is - # enforced by e2e/tests/supply-chain.e2e.test.ts. - - dependency-name: "@cipherstash/auth-*" # 0.x bumps ship breaking type changes (e.g. 0.2 → 0.3 tightened the # FailureOption constraint). Review and apply manually. - dependency-name: "@byteslice/result" @@ -126,13 +115,12 @@ updates: - patch ignore: # The CipherStash crates are pinned with EXACT `=` requirements in - # crates/protect-ffi/Cargo.toml and are the same release train as the - # @cipherstash/auth catalog entries above — cipherstash-client, - # cts-common, stack-auth and stack-profile all sit at =0.42.0, matching - # the catalog's 0.42.0. Dependabot bumping a subset is the Rust version - # of the rc.2 B1 bug recorded above: it would rewrite one `=` pin and - # leave the rest, and the crates do not tolerate skew. Bump them - # together, manually, in step with the npm catalog. + # crates/protect-ffi/Cargo.toml and are one release train — + # cipherstash-client, cts-common, stack-auth and stack-profile all sit at + # the same `=` version. Dependabot bumping a subset would rewrite one `=` + # pin and leave the rest, and the crates do not tolerate skew (a skewed + # @cipherstash/auth platform set was the npm form of this, the rc.2 B1 + # bug). Bump them together, manually. # # Caveat worth knowing: `ignore` suppresses Dependabot SECURITY PRs too, # not just version updates. osv-scanner is the compensating control — diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml index aa51eeba0..ddbf73c2c 100644 --- a/.github/workflows/integration-drizzle.yml +++ b/.github/workflows/integration-drizzle.yml @@ -41,14 +41,14 @@ on: # directory, so without these two entries the only suites that would catch # it never start. They are the files a bump actually edits: exact pins # (`protect-ffi`, `@cipherstash/eql`) live in the package manifest, - # `catalog:` ones (`@cipherstash/auth`, which moves in lockstep with - # protect-ffi for the WASM entry) in the workspace catalog. + # `catalog:` ones in the workspace catalog. # # `pnpm-lock.yaml` is deliberately NOT listed. It changes on roughly every # dependency bump in the monorepo — far more often than either file here — # and these are credentialed, database-backed jobs. Nothing is lost: a - # protect-ffi or auth version change cannot reach the lockfile without - # editing one of the two manifests below first. + # protect-ffi version change cannot reach the lockfile without editing + # one of the two manifests below first, and an auth one edits its own + # manifest, under the auth paths below. - 'languages/typescript/packages/stack/package.json' - 'pnpm-workspace.yaml' - 'languages/typescript/packages/test-kit/**' @@ -60,6 +60,15 @@ on: - '.github/actions/integration-setup/**' - '.github/actions/integration-db/**' - '.github/actions/build-ffi-binding/**' + - '.github/actions/build-auth-binding/**' + # `@cipherstash/auth` is a workspace package, and the SDK loads its napi + # module (and, for `wasm-inline`, its wasm) on import, so a change to what + # it ships arrives here rather than in `pnpm-workspace.yaml`. The paths of + # require-auth-npm-changeset.yml. + - 'packages/stack-auth/Cargo.toml' + - 'packages/stack-auth/src/**' + - 'languages/typescript/packages/auth/**' + - 'languages/typescript/packages/stack-auth-wasm/**' # The Rust that produces every EQL payload these suites round-trip. # Absorbing protect-ffi put it in-tree, so a crate change can now # break them in a PR that touches no TypeScript at all. @@ -126,14 +135,14 @@ on: # directory, so without these two entries the only suites that would catch # it never start. They are the files a bump actually edits: exact pins # (`protect-ffi`, `@cipherstash/eql`) live in the package manifest, - # `catalog:` ones (`@cipherstash/auth`, which moves in lockstep with - # protect-ffi for the WASM entry) in the workspace catalog. + # `catalog:` ones in the workspace catalog. # # `pnpm-lock.yaml` is deliberately NOT listed. It changes on roughly every # dependency bump in the monorepo — far more often than either file here — # and these are credentialed, database-backed jobs. Nothing is lost: a - # protect-ffi or auth version change cannot reach the lockfile without - # editing one of the two manifests below first. + # protect-ffi version change cannot reach the lockfile without editing + # one of the two manifests below first, and an auth one edits its own + # manifest, under the auth paths below. - 'languages/typescript/packages/stack/package.json' - 'pnpm-workspace.yaml' - 'languages/typescript/packages/test-kit/**' @@ -145,6 +154,15 @@ on: - '.github/actions/integration-setup/**' - '.github/actions/integration-db/**' - '.github/actions/build-ffi-binding/**' + - '.github/actions/build-auth-binding/**' + # `@cipherstash/auth` is a workspace package, and the SDK loads its napi + # module (and, for `wasm-inline`, its wasm) on import, so a change to what + # it ships arrives here rather than in `pnpm-workspace.yaml`. The paths of + # require-auth-npm-changeset.yml. + - 'packages/stack-auth/Cargo.toml' + - 'packages/stack-auth/src/**' + - 'languages/typescript/packages/auth/**' + - 'languages/typescript/packages/stack-auth-wasm/**' # The Rust that produces every EQL payload these suites round-trip. # Absorbing protect-ffi put it in-tree, so a crate change can now # break them in a PR that touches no TypeScript at all. @@ -276,6 +294,11 @@ jobs: with: wasm: 'true' + - name: Build the @cipherstash/auth binding + uses: ./.github/actions/build-auth-binding + with: + wasm: 'true' + # No pre-`up` cleanup step any more: the project name is unique per job, so # a container leaked by a hard-killed prior run cannot hold this job's # name or its (ephemeral) port. Blanket-pruning would now be actively diff --git a/.github/workflows/integration-prisma-next.yml b/.github/workflows/integration-prisma-next.yml index 2a63c86d5..ed74631f2 100644 --- a/.github/workflows/integration-prisma-next.yml +++ b/.github/workflows/integration-prisma-next.yml @@ -34,6 +34,7 @@ on: - '.github/actions/integration-setup/**' - '.github/actions/integration-db/**' - '.github/actions/build-ffi-binding/**' + - '.github/actions/build-auth-binding/**' # The Rust that produces every EQL payload these suites round-trip. # Absorbing protect-ffi put it in-tree, so a crate change can now # break them in a PR that touches no TypeScript at all. @@ -81,6 +82,7 @@ on: - '.github/actions/integration-setup/**' - '.github/actions/integration-db/**' - '.github/actions/build-ffi-binding/**' + - '.github/actions/build-auth-binding/**' # The Rust that produces every EQL payload these suites round-trip. # Absorbing protect-ffi put it in-tree, so a crate change can now # break them in a PR that touches no TypeScript at all. @@ -166,6 +168,9 @@ jobs: - name: Build the protect-ffi binding uses: ./.github/actions/build-ffi-binding + - name: Build the @cipherstash/auth binding + uses: ./.github/actions/build-auth-binding + # No pre-`up` cleanup step any more: the project name is unique per job, so # a container leaked by a hard-killed prior run cannot hold this job's # name or its (ephemeral) port. Blanket-pruning would now be actively diff --git a/.github/workflows/integration-protect-ffi.yml b/.github/workflows/integration-protect-ffi.yml index 38fbf73f4..0972f4ada 100644 --- a/.github/workflows/integration-protect-ffi.yml +++ b/.github/workflows/integration-protect-ffi.yml @@ -23,7 +23,7 @@ name: Integration — protect-ffi (native + WASM) # queried SQL installed from the PUBLISHED EQL bundle while the payloads under # test were emitted by the in-tree `eql-bindings`, so the two halves of EQL could # disagree — and would have disagreed in a database, not in CI. `@cipherstash/eql` -# now resolves `workspace:^`, and `@cipherstash/auth` and `vitest` take +# now resolves `workspace:^`, `@cipherstash/auth` `workspace:*`, and `vitest` # `catalog:repo`. # # Separate from `tests.yml` on purpose, and separate from `tests-rust.yml`: this @@ -39,7 +39,7 @@ on: # and its manifest. - 'languages/typescript/packages/protect-ffi/integration-tests/**' # The suite's dependency versions, now that it is a pnpm workspace member: - # `@cipherstash/auth`, `vitest` and `typescript` reach it through + # `vitest` and `typescript` reach it through # `catalog:repo`, so a catalog bump changes what this job runs while # editing no file under the suite. Same entry, same reason, as the other # integration workflows — and like them, `pnpm-lock.yaml` is deliberately @@ -89,6 +89,15 @@ on: - 'packages/eql/Cargo.toml' - '.github/workflows/integration-protect-ffi.yml' - '.github/actions/build-ffi-binding/**' + - '.github/actions/build-auth-binding/**' + # `@cipherstash/auth` is a workspace package, and the SDK loads its napi + # module (and, for `wasm-inline`, its wasm) on import, so a change to what + # it ships arrives here rather than in `pnpm-workspace.yaml`. The paths of + # require-auth-npm-changeset.yml. + - 'packages/stack-auth/Cargo.toml' + - 'packages/stack-auth/src/**' + - 'languages/typescript/packages/auth/**' + - 'languages/typescript/packages/stack-auth-wasm/**' - '.github/actions/require-cs-secrets/**' pull_request: branches: ['**'] @@ -119,6 +128,15 @@ on: - 'packages/eql/Cargo.toml' - '.github/workflows/integration-protect-ffi.yml' - '.github/actions/build-ffi-binding/**' + - '.github/actions/build-auth-binding/**' + # `@cipherstash/auth` is a workspace package, and the SDK loads its napi + # module (and, for `wasm-inline`, its wasm) on import, so a change to what + # it ships arrives here rather than in `pnpm-workspace.yaml`. The paths of + # require-auth-npm-changeset.yml. + - 'packages/stack-auth/Cargo.toml' + - 'packages/stack-auth/src/**' + - 'languages/typescript/packages/auth/**' + - 'languages/typescript/packages/stack-auth-wasm/**' - '.github/actions/require-cs-secrets/**' workflow_dispatch: {} @@ -292,6 +310,11 @@ jobs: with: wasm: 'true' + - name: Build the @cipherstash/auth binding + uses: ./.github/actions/build-auth-binding + with: + wasm: 'true' + # `working_directory` is load-bearing, not tidiness. mise reads config # from the current directory and its PARENTS, so an action running at the # repo root never sees languages/typescript/packages/protect-ffi/mise.toml — it would install diff --git a/.github/workflows/integration-supabase.yml b/.github/workflows/integration-supabase.yml index 6da214867..16d4da21e 100644 --- a/.github/workflows/integration-supabase.yml +++ b/.github/workflows/integration-supabase.yml @@ -34,14 +34,15 @@ on: # payload deserialization and it touches NO source directory, so without # these two entries the only suites that would catch it never start. They # are the files a bump actually edits: exact pins (`protect-ffi`, - # `@cipherstash/eql`) live in the package manifest, `catalog:` ones - # (`@cipherstash/auth`) in the workspace catalog. + # `@cipherstash/eql`) live in the package manifest, `catalog:` ones in + # the workspace catalog. # # `pnpm-lock.yaml` is deliberately NOT listed. It changes on roughly every # dependency bump in the monorepo — far more often than either file here — # and these are credentialed, database-backed jobs. Nothing is lost: a - # protect-ffi or auth version change cannot reach the lockfile without - # editing one of the two manifests below first. + # protect-ffi version change cannot reach the lockfile without editing + # one of the two manifests below first, and an auth one edits its own + # manifest, under the auth paths below. - 'languages/typescript/packages/stack/package.json' - 'pnpm-workspace.yaml' - 'languages/typescript/packages/test-kit/**' @@ -52,6 +53,15 @@ on: - '.github/actions/integration-setup/**' - '.github/actions/integration-db/**' - '.github/actions/build-ffi-binding/**' + - '.github/actions/build-auth-binding/**' + # `@cipherstash/auth` is a workspace package, and the SDK loads its napi + # module (and, for `wasm-inline`, its wasm) on import, so a change to what + # it ships arrives here rather than in `pnpm-workspace.yaml`. The paths of + # require-auth-npm-changeset.yml. + - 'packages/stack-auth/Cargo.toml' + - 'packages/stack-auth/src/**' + - 'languages/typescript/packages/auth/**' + - 'languages/typescript/packages/stack-auth-wasm/**' # The Rust that produces every EQL payload these suites round-trip. # Absorbing protect-ffi put it in-tree, so a crate change can now # break them in a PR that touches no TypeScript at all. @@ -104,14 +114,15 @@ on: # payload deserialization and it touches NO source directory, so without # these two entries the only suites that would catch it never start. They # are the files a bump actually edits: exact pins (`protect-ffi`, - # `@cipherstash/eql`) live in the package manifest, `catalog:` ones - # (`@cipherstash/auth`) in the workspace catalog. + # `@cipherstash/eql`) live in the package manifest, `catalog:` ones in + # the workspace catalog. # # `pnpm-lock.yaml` is deliberately NOT listed. It changes on roughly every # dependency bump in the monorepo — far more often than either file here — # and these are credentialed, database-backed jobs. Nothing is lost: a - # protect-ffi or auth version change cannot reach the lockfile without - # editing one of the two manifests below first. + # protect-ffi version change cannot reach the lockfile without editing + # one of the two manifests below first, and an auth one edits its own + # manifest, under the auth paths below. - 'languages/typescript/packages/stack/package.json' - 'pnpm-workspace.yaml' - 'languages/typescript/packages/test-kit/**' @@ -122,6 +133,15 @@ on: - '.github/actions/integration-setup/**' - '.github/actions/integration-db/**' - '.github/actions/build-ffi-binding/**' + - '.github/actions/build-auth-binding/**' + # `@cipherstash/auth` is a workspace package, and the SDK loads its napi + # module (and, for `wasm-inline`, its wasm) on import, so a change to what + # it ships arrives here rather than in `pnpm-workspace.yaml`. The paths of + # require-auth-npm-changeset.yml. + - 'packages/stack-auth/Cargo.toml' + - 'packages/stack-auth/src/**' + - 'languages/typescript/packages/auth/**' + - 'languages/typescript/packages/stack-auth-wasm/**' # The Rust that produces every EQL payload these suites round-trip. # Absorbing protect-ffi put it in-tree, so a crate change can now # break them in a PR that touches no TypeScript at all. @@ -227,6 +247,9 @@ jobs: - name: Build the protect-ffi binding uses: ./.github/actions/build-ffi-binding + - name: Build the @cipherstash/auth binding + uses: ./.github/actions/build-auth-binding + # No pre-`up` cleanup step any more: the project name is unique per job, so # a container leaked by a hard-killed prior run cannot hold this job's # name or its (ephemeral) port. Blanket-pruning would now be actively diff --git a/.github/workflows/prisma-example-readme-e2e.yml b/.github/workflows/prisma-example-readme-e2e.yml index e19b2e4af..9869d8470 100644 --- a/.github/workflows/prisma-example-readme-e2e.yml +++ b/.github/workflows/prisma-example-readme-e2e.yml @@ -26,6 +26,7 @@ on: # report — a PR touching any of these runs the walkthrough before the # change lands, not after. - '.github/actions/build-ffi-binding/**' + - '.github/actions/build-auth-binding/**' - 'languages/typescript/packages/protect-ffi/crates/**' - 'languages/typescript/packages/protect-ffi/src/**' - 'languages/typescript/packages/protect-ffi/Cargo.toml' @@ -58,6 +59,7 @@ on: # scripts/__tests__/workflow-paths-filter-parity.test.mjs compares the # two copies.) - '.github/actions/build-ffi-binding/**' + - '.github/actions/build-auth-binding/**' - 'languages/typescript/packages/protect-ffi/crates/**' - 'languages/typescript/packages/protect-ffi/src/**' - 'languages/typescript/packages/protect-ffi/Cargo.toml' @@ -145,6 +147,9 @@ jobs: - name: Build the protect-ffi binding uses: ./.github/actions/build-ffi-binding + - name: Build the @cipherstash/auth binding + uses: ./.github/actions/build-auth-binding + # Build via turbo so `^build` on `@cipherstash/stack-prisma` and # its `@cipherstash/stack` peer is honoured. The test's # `pnpm install` subprocess inside `languages/typescript/examples/prisma/` is a no-op diff --git a/.github/workflows/prisma-next-e2e.yml b/.github/workflows/prisma-next-e2e.yml index bb34df723..01c68f928 100644 --- a/.github/workflows/prisma-next-e2e.yml +++ b/.github/workflows/prisma-next-e2e.yml @@ -25,6 +25,7 @@ on: # what makes that a gate rather than a report — a PR touching any of these # runs the suite before the change lands, not after. - '.github/actions/build-ffi-binding/**' + - '.github/actions/build-auth-binding/**' - 'languages/typescript/packages/protect-ffi/crates/**' - 'languages/typescript/packages/protect-ffi/src/**' - 'languages/typescript/packages/protect-ffi/Cargo.toml' @@ -58,6 +59,7 @@ on: # scripts/__tests__/workflow-paths-filter-parity.test.mjs compares the # two copies.) - '.github/actions/build-ffi-binding/**' + - '.github/actions/build-auth-binding/**' - 'languages/typescript/packages/protect-ffi/crates/**' - 'languages/typescript/packages/protect-ffi/src/**' - 'languages/typescript/packages/protect-ffi/Cargo.toml' @@ -147,6 +149,9 @@ jobs: - name: Build the protect-ffi binding uses: ./.github/actions/build-ffi-binding + - name: Build the @cipherstash/auth binding + uses: ./.github/actions/build-auth-binding + # Write the CS_* credentials and the harness DATABASE_URL into the # example app's .env so the runtime + the `prisma-next migration # apply` invocation in global-setup both pick them up. The harness diff --git a/.github/workflows/tests-bench.yml b/.github/workflows/tests-bench.yml index c373c5788..1e97e64cc 100644 --- a/.github/workflows/tests-bench.yml +++ b/.github/workflows/tests-bench.yml @@ -23,6 +23,7 @@ on: - 'local/**' - '.github/workflows/tests-bench.yml' - '.github/actions/integration-setup/**' + - '.github/actions/build-auth-binding/**' pull_request: branches: - "**" @@ -40,6 +41,7 @@ on: - 'local/**' - '.github/workflows/tests-bench.yml' - '.github/actions/integration-setup/**' + - '.github/actions/build-auth-binding/**' jobs: tests-bench: @@ -59,6 +61,12 @@ jobs: # integration suites. node-version: 22 + # The bench unit checks import @cipherstash/stack, and the `stash eql + # install` in the bench `globalSetup` runs the CLI; both load + # @cipherstash/auth's napi module. + - name: Build the @cipherstash/auth binding + uses: ./.github/actions/build-auth-binding + # `@cipherstash/stack` ships dist/-based `exports`; bench imports # from `@cipherstash/stack` and `@cipherstash/stack-drizzle` (the Drizzle # adapter split out into its own package), so both must be built before diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4cceb6f78..ee628009b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -136,15 +136,20 @@ jobs: - name: Build the protect-ffi binding uses: ./.github/actions/build-ffi-binding - # `pnpm run test` below also runs the @cipherstash/auth and - # @cipherstash/profile vitest suites, which load the napi module. Their - # `test` scripts do not build it, so cargo stays off the default `test` - # path; this step builds it, as the protect-ffi step above does for - # `index.node`. Each `build:debug` writes its typings to the committed - # `native.d.ts`, so the build leaves the tree clean; tests-crates.yml - # holds it to that, and runs the same suites against the pinned Rust. - - name: Build the auth and profile node bindings - run: pnpm --filter @cipherstash/auth --filter @cipherstash/profile run build:debug + # The SDK, stash and wizard suites below load @cipherstash/auth's napi + # module, and so does its own vitest suite. + - name: Build the @cipherstash/auth binding + uses: ./.github/actions/build-auth-binding + + # `pnpm run test` below also runs the @cipherstash/profile vitest suite, + # which loads the napi module. Its `test` script does not build it, so + # cargo stays off the default `test` path; this step builds it, as the + # step above does for @cipherstash/auth. `build:debug` writes its typings + # to the committed `native.d.ts`, so the build leaves the tree clean; + # tests-crates.yml holds it to that, and runs the same suites against the + # pinned Rust. + - name: Build the profile node binding + run: pnpm --filter @cipherstash/profile run build:debug - name: Type tests (stack) run: pnpm exec turbo run test:types --filter @cipherstash/stack @@ -440,6 +445,9 @@ jobs: - name: Build the protect-ffi binding uses: ./.github/actions/build-ffi-binding + - name: Build the @cipherstash/auth binding + uses: ./.github/actions/build-auth-binding + # Run the standalone `e2e/` workspace via turbo so the `^build` # dep on the `test:e2e` task builds cli + wizard first. CLI's own # E2E (`languages/typescript/packages/cli/tests/e2e/**`) is covered by the `run-tests` @@ -529,6 +537,13 @@ jobs: with: wasm: 'true' + # stack's wasm-inline entry imports @cipherstash/auth/wasm-inline, which + # loads auth's own wasm/. + - name: Build the @cipherstash/auth binding + uses: ./.github/actions/build-auth-binding + with: + wasm: 'true' + # The only job that can run this: the type tests read the GENERATED # dist/wasm/*.d.ts (wasm-bindgen emits them from the `typescript_type` # attributes in crates/protect-ffi/src/wasm.rs) and those declarations @@ -658,6 +673,9 @@ jobs: - name: Build the protect-ffi binding uses: ./.github/actions/build-ffi-binding + - name: Build the @cipherstash/auth binding + uses: ./.github/actions/build-auth-binding + - name: Create .env file in ./languages/typescript/packages/stack/ run: | touch ./languages/typescript/packages/stack/.env diff --git a/e2e/tests/supply-chain.e2e.test.ts b/e2e/tests/supply-chain.e2e.test.ts index af9ec5c25..9fe55dc36 100644 --- a/e2e/tests/supply-chain.e2e.test.ts +++ b/e2e/tests/supply-chain.e2e.test.ts @@ -96,44 +96,47 @@ describe('supply chain — pnpm configuration', () => { } }) - it('@cipherstash/auth and its six platform bindings are catalog-pinned in lockstep', () => { + it('@cipherstash/auth and its six platform bindings resolve from the workspace in lockstep', () => { // Not tidiness — a load-bearing invariant. @cipherstash/auth pins its // bindings as EXACT-version optional peerDependencies, while stash / // stack / wizard declare the bindings in their own optionalDependencies - // (pnpm doesn't auto-install optional peer deps). If the seven catalog - // entries skew, npm nests per-consumer binding copies that the hoisted - // auth package cannot resolve, and every project-local install of the - // CLI/SDK dies at startup with "Failed to load native binding". That is - // exactly what happened in 1.0.0-rc.2: Dependabot bumped the six - // bindings to 0.42.0 while the ignored @cipherstash/auth stayed 0.41.0. - // Dependabot now ignores all seven names; this test catches every other - // way the set can drift. + // (pnpm doesn't auto-install optional peer deps). If the seven skew, npm + // nests per-consumer binding copies that the hoisted auth package cannot + // resolve, and every project-local install of the CLI/SDK dies at startup + // with "Failed to load native binding" — 1.0.0-rc.2, when they were + // registry pins. As workspace packages in one changesets `fixed` group + // (auth-build-artifacts.test.mjs), `workspace:*` packs each range as the + // same exact version, and a catalog entry would be a pin that can drift. + const isAuth = (name: string) => + name === '@cipherstash/auth' || name.startsWith('@cipherstash/auth-') const ws = readYaml('pnpm-workspace.yaml') as { catalogs?: Record> } - const repo = ws.catalogs?.repo ?? {} - const authEntries = Object.entries(repo).filter( - ([name]) => - name === '@cipherstash/auth' || name.startsWith('@cipherstash/auth-'), - ) - // The wrapper + the six platform bindings. A count change means a - // binding was added/removed upstream — update the consumers' package - // JSONs and this expectation together. - expect(authEntries.length).toBe(7) - const versions = new Set(authEntries.map(([, v]) => v)) + expect(Object.keys(ws.catalogs?.repo ?? {}).filter(isAuth)).toEqual([]) + + const specifiers = globSync('languages/typescript/**/package.json', { + cwd: REPO_ROOT, + exclude: (path) => path.includes('node_modules'), + }).flatMap((file) => { + const manifest = readJson(file) as Record + return [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + ].flatMap((table) => + Object.entries((manifest[table] ?? {}) as Record) + .filter(([name]) => isAuth(name)) + .map(([name, range]) => `${file} ${name}@${range}`), + ) + }) + // stack, stash and wizard declare all seven; the protect-ffi suite one. + expect(specifiers.length).toBeGreaterThanOrEqual(22) expect( - versions.size, - `@cipherstash/auth* catalog entries have skewed versions: ${authEntries - .map(([n, v]) => `${n}@${v}`) - .join(', ')}`, - ).toBe(1) + specifiers.filter((entry) => !entry.endsWith('@workspace:*')), + ).toEqual([]) }) it('vitest and @vitest/coverage-v8 are catalog-pinned in lockstep', () => { - // The same shape as the auth set above, one dependency along, and it needs - // its own assertion because that one filters on the `@cipherstash/auth` - // prefix and cannot see this pair. - // // `@vitest/coverage-v8` is versioned against the runner, not // independently: vitest refuses to start against a mismatched provider // ("Vitest failed to load @vitest/coverage-v8"). Both are in the diff --git a/languages/typescript/packages/cli/package.json b/languages/typescript/packages/cli/package.json index 57d4ece66..e1510cd33 100644 --- a/languages/typescript/packages/cli/package.json +++ b/languages/typescript/packages/cli/package.json @@ -46,7 +46,7 @@ "lint": "biome check ." }, "dependencies": { - "@cipherstash/auth": "catalog:repo", + "@cipherstash/auth": "workspace:*", "@cipherstash/eql": "workspace:*", "@cipherstash/migrate": "workspace:*", "@clack/prompts": "1.7.0", @@ -57,14 +57,14 @@ "posthog-node": "^5.49.1", "zod": "^3.25.76" }, - "//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies — pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. All seven names share a single catalog entry to keep them in lockstep.", + "//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies — pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. All seven are workspace packages in one changesets `fixed` group, so `workspace:*` packs the same exact version for each.", "optionalDependencies": { - "@cipherstash/auth-darwin-arm64": "catalog:repo", - "@cipherstash/auth-darwin-x64": "catalog:repo", - "@cipherstash/auth-linux-arm64-gnu": "catalog:repo", - "@cipherstash/auth-linux-x64-gnu": "catalog:repo", - "@cipherstash/auth-linux-x64-musl": "catalog:repo", - "@cipherstash/auth-win32-x64-msvc": "catalog:repo" + "@cipherstash/auth-darwin-arm64": "workspace:*", + "@cipherstash/auth-darwin-x64": "workspace:*", + "@cipherstash/auth-linux-arm64-gnu": "workspace:*", + "@cipherstash/auth-linux-x64-gnu": "workspace:*", + "@cipherstash/auth-linux-x64-musl": "workspace:*", + "@cipherstash/auth-win32-x64-msvc": "workspace:*" }, "peerDependencies": { "@cipherstash/stack": ">=1.0.0-rc.0" diff --git a/languages/typescript/packages/protect-ffi/integration-tests/package.json b/languages/typescript/packages/protect-ffi/integration-tests/package.json index 97620d469..1ce282b26 100644 --- a/languages/typescript/packages/protect-ffi/integration-tests/package.json +++ b/languages/typescript/packages/protect-ffi/integration-tests/package.json @@ -10,7 +10,7 @@ "vitest:live:coverage": "vitest run --coverage" }, "dependencies": { - "@cipherstash/auth": "catalog:repo", + "@cipherstash/auth": "workspace:*", "@cipherstash/protect-ffi": "workspace:*", "pg": "8.23.0" }, diff --git a/languages/typescript/packages/stack/package.json b/languages/typescript/packages/stack/package.json index 36e581c4c..7a1d074e8 100644 --- a/languages/typescript/packages/stack/package.json +++ b/languages/typescript/packages/stack/package.json @@ -229,20 +229,20 @@ }, "dependencies": { "@byteslice/result": "0.2.0", - "@cipherstash/auth": "catalog:repo", + "@cipherstash/auth": "workspace:*", "@cipherstash/protect-ffi": "workspace:*", "evlog": "1.11.0", "uuid": "14.0.1", "zod": "3.25.76" }, - "//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies — pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. Required because @cipherstash/stack re-exports the Node auth strategies (OidcFederationStrategy, AccessKeyStrategy, …). All seven names share a single catalog entry to keep them in lockstep.", + "//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies — pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. Required because @cipherstash/stack re-exports the Node auth strategies (OidcFederationStrategy, AccessKeyStrategy, …). All seven are workspace packages in one changesets `fixed` group, so `workspace:*` packs the same exact version for each.", "optionalDependencies": { - "@cipherstash/auth-darwin-arm64": "catalog:repo", - "@cipherstash/auth-darwin-x64": "catalog:repo", - "@cipherstash/auth-linux-arm64-gnu": "catalog:repo", - "@cipherstash/auth-linux-x64-gnu": "catalog:repo", - "@cipherstash/auth-linux-x64-musl": "catalog:repo", - "@cipherstash/auth-win32-x64-msvc": "catalog:repo" + "@cipherstash/auth-darwin-arm64": "workspace:*", + "@cipherstash/auth-darwin-x64": "workspace:*", + "@cipherstash/auth-linux-arm64-gnu": "workspace:*", + "@cipherstash/auth-linux-x64-gnu": "workspace:*", + "@cipherstash/auth-linux-x64-musl": "workspace:*", + "@cipherstash/auth-win32-x64-msvc": "workspace:*" }, "engines": { "node": ">=22" diff --git a/languages/typescript/packages/wizard/package.json b/languages/typescript/packages/wizard/package.json index 439f54d75..9b4f70591 100644 --- a/languages/typescript/packages/wizard/package.json +++ b/languages/typescript/packages/wizard/package.json @@ -32,7 +32,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.234", "@anthropic-ai/sdk": "^0.117.1", - "@cipherstash/auth": "catalog:repo", + "@cipherstash/auth": "workspace:*", "@clack/prompts": "1.7.0", "dotenv": "17.4.2", "pg": "8.23.0", @@ -40,14 +40,14 @@ "posthog-node": "^5.49.1", "zod": "^3.25.76" }, - "//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies — pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. All seven names share a single catalog entry to keep them in lockstep.", + "//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies — pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. All seven are workspace packages in one changesets `fixed` group, so `workspace:*` packs the same exact version for each.", "optionalDependencies": { - "@cipherstash/auth-darwin-arm64": "catalog:repo", - "@cipherstash/auth-darwin-x64": "catalog:repo", - "@cipherstash/auth-linux-arm64-gnu": "catalog:repo", - "@cipherstash/auth-linux-x64-gnu": "catalog:repo", - "@cipherstash/auth-linux-x64-musl": "catalog:repo", - "@cipherstash/auth-win32-x64-msvc": "catalog:repo" + "@cipherstash/auth-darwin-arm64": "workspace:*", + "@cipherstash/auth-darwin-x64": "workspace:*", + "@cipherstash/auth-linux-arm64-gnu": "workspace:*", + "@cipherstash/auth-linux-x64-gnu": "workspace:*", + "@cipherstash/auth-linux-x64-musl": "workspace:*", + "@cipherstash/auth-win32-x64-msvc": "workspace:*" }, "devDependencies": { "@types/pg": "^8.23.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 207386056..7f9712390 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,27 +6,6 @@ settings: catalogs: repo: - '@cipherstash/auth': - specifier: 0.44.0 - version: 0.44.0 - '@cipherstash/auth-darwin-arm64': - specifier: 0.44.0 - version: 0.44.0 - '@cipherstash/auth-darwin-x64': - specifier: 0.44.0 - version: 0.44.0 - '@cipherstash/auth-linux-arm64-gnu': - specifier: 0.44.0 - version: 0.44.0 - '@cipherstash/auth-linux-x64-gnu': - specifier: 0.44.0 - version: 0.44.0 - '@cipherstash/auth-linux-x64-musl': - specifier: 0.44.0 - version: 0.44.0 - '@cipherstash/auth-win32-x64-msvc': - specifier: 0.44.0 - version: 0.44.0 '@types/node': specifier: 22.20.1 version: 22.20.1 @@ -267,8 +246,8 @@ importers: languages/typescript/packages/cli: dependencies: '@cipherstash/auth': - specifier: catalog:repo - version: 0.44.0(@cipherstash/auth-darwin-arm64@0.44.0)(@cipherstash/auth-darwin-x64@0.44.0)(@cipherstash/auth-linux-arm64-gnu@0.44.0)(@cipherstash/auth-linux-x64-gnu@0.44.0)(@cipherstash/auth-linux-x64-musl@0.44.0)(@cipherstash/auth-win32-x64-msvc@0.44.0) + specifier: workspace:* + version: link:../auth '@cipherstash/eql': specifier: workspace:* version: link:../../../../packages/eql/packages/eql @@ -329,23 +308,23 @@ importers: version: 4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) optionalDependencies: '@cipherstash/auth-darwin-arm64': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/darwin-arm64 '@cipherstash/auth-darwin-x64': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/darwin-x64 '@cipherstash/auth-linux-arm64-gnu': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/linux-arm64-gnu '@cipherstash/auth-linux-x64-gnu': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/linux-x64-gnu '@cipherstash/auth-linux-x64-musl': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/linux-x64-musl '@cipherstash/auth-win32-x64-msvc': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/win32-x64-msvc languages/typescript/packages/migrate: dependencies: @@ -494,8 +473,8 @@ importers: languages/typescript/packages/protect-ffi/integration-tests: dependencies: '@cipherstash/auth': - specifier: catalog:repo - version: 0.44.0(@cipherstash/auth-darwin-arm64@0.44.0)(@cipherstash/auth-darwin-x64@0.44.0)(@cipherstash/auth-linux-arm64-gnu@0.44.0)(@cipherstash/auth-linux-x64-gnu@0.44.0)(@cipherstash/auth-linux-x64-musl@0.44.0)(@cipherstash/auth-win32-x64-msvc@0.44.0) + specifier: workspace:* + version: link:../../auth '@cipherstash/protect-ffi': specifier: workspace:* version: link:.. @@ -540,8 +519,8 @@ importers: specifier: 0.2.0 version: 0.2.0 '@cipherstash/auth': - specifier: catalog:repo - version: 0.44.0(@cipherstash/auth-darwin-arm64@0.44.0)(@cipherstash/auth-darwin-x64@0.44.0)(@cipherstash/auth-linux-arm64-gnu@0.44.0)(@cipherstash/auth-linux-x64-gnu@0.44.0)(@cipherstash/auth-linux-x64-musl@0.44.0)(@cipherstash/auth-win32-x64-msvc@0.44.0) + specifier: workspace:* + version: link:../auth '@cipherstash/protect-ffi': specifier: workspace:* version: link:../protect-ffi @@ -614,23 +593,23 @@ importers: version: 4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) optionalDependencies: '@cipherstash/auth-darwin-arm64': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/darwin-arm64 '@cipherstash/auth-darwin-x64': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/darwin-x64 '@cipherstash/auth-linux-arm64-gnu': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/linux-arm64-gnu '@cipherstash/auth-linux-x64-gnu': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/linux-x64-gnu '@cipherstash/auth-linux-x64-musl': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/linux-x64-musl '@cipherstash/auth-win32-x64-msvc': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/win32-x64-msvc languages/typescript/packages/stack-auth-wasm: {} @@ -791,8 +770,8 @@ importers: specifier: ^0.117.1 version: 0.117.1(zod@3.25.76) '@cipherstash/auth': - specifier: catalog:repo - version: 0.44.0(@cipherstash/auth-darwin-arm64@0.44.0)(@cipherstash/auth-darwin-x64@0.44.0)(@cipherstash/auth-linux-arm64-gnu@0.44.0)(@cipherstash/auth-linux-x64-gnu@0.44.0)(@cipherstash/auth-linux-x64-musl@0.44.0)(@cipherstash/auth-win32-x64-msvc@0.44.0) + specifier: workspace:* + version: link:../auth '@clack/prompts': specifier: 1.7.0 version: 1.7.0 @@ -829,23 +808,23 @@ importers: version: 4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) optionalDependencies: '@cipherstash/auth-darwin-arm64': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/darwin-arm64 '@cipherstash/auth-darwin-x64': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/darwin-x64 '@cipherstash/auth-linux-arm64-gnu': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/linux-arm64-gnu '@cipherstash/auth-linux-x64-gnu': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/linux-x64-gnu '@cipherstash/auth-linux-x64-musl': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/linux-x64-musl '@cipherstash/auth-win32-x64-msvc': - specifier: catalog:repo - version: 0.44.0 + specifier: workspace:* + version: link:../auth/platforms/win32-x64-msvc packages/eql/packages/eql: devDependencies: @@ -1078,62 +1057,6 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cipherstash/auth-darwin-arm64@0.44.0': - resolution: {integrity: sha512-5BJ1r5LXZ814oPT22PKDoEPugPaWQ8Y1TWdjQwhJ+N/XsPaPDXeV+Fy0g4CYxnp60+JiA8AJGXd4tujbHipHSw==} - cpu: [arm64] - os: [darwin] - - '@cipherstash/auth-darwin-x64@0.44.0': - resolution: {integrity: sha512-LeC8TBheva3u5UndhyjGc9IweYSkuY9MZKw7zAPZnWrPiIczsefPfkCXoEu6LLGYUZZNee8Nl1LwpPtkZE5xoA==} - cpu: [x64] - os: [darwin] - - '@cipherstash/auth-linux-arm64-gnu@0.44.0': - resolution: {integrity: sha512-jJLydQhKX98j+6yAqC9DtgdL/XHRWU5w2PSZnr/fJMAnXYfE/sw9tocvEm8K3qkreFuVkDXiJjttYW6s0EWdtQ==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@cipherstash/auth-linux-x64-gnu@0.44.0': - resolution: {integrity: sha512-vatcPWcJZtaezhHG+sEm18BojCL2vpME4AVIDtsKywN2DyJgxuScX02Hso4XW7MM/IjeH0AZhE3kEGWkWUhkGQ==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@cipherstash/auth-linux-x64-musl@0.44.0': - resolution: {integrity: sha512-rA08b++pIukcHTQhJ9fWw5fzlgDgQ0CtLbtrkuW+0fkVY1VrdKO5GjhxMS7BY4aXkR+62Crjl61rQvbQOjOBHg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@cipherstash/auth-win32-x64-msvc@0.44.0': - resolution: {integrity: sha512-Yhviqyrwl8DVt7EDdA/MJw0A8sNGRT6iH/xiGm85Wu5wQWFOeTYEry7DMWJ5dLB31qXxeKymjb6SQoFPK3hbQQ==} - cpu: [x64] - os: [win32] - - '@cipherstash/auth@0.44.0': - resolution: {integrity: sha512-NwMWM7nPsdPAFeGCMpXkmsjo+2MaQ/W2sHwD14eGqahC2SG8Jg3Zr4agPruKYhPO4Z1CUwLcDBbSg9Lglsy+QA==} - peerDependencies: - '@cipherstash/auth-darwin-arm64': 0.44.0 - '@cipherstash/auth-darwin-x64': 0.44.0 - '@cipherstash/auth-linux-arm64-gnu': 0.44.0 - '@cipherstash/auth-linux-x64-gnu': 0.44.0 - '@cipherstash/auth-linux-x64-musl': 0.44.0 - '@cipherstash/auth-win32-x64-msvc': 0.44.0 - peerDependenciesMeta: - '@cipherstash/auth-darwin-arm64': - optional: true - '@cipherstash/auth-darwin-x64': - optional: true - '@cipherstash/auth-linux-arm64-gnu': - optional: true - '@cipherstash/auth-linux-x64-gnu': - optional: true - '@cipherstash/auth-linux-x64-musl': - optional: true - '@cipherstash/auth-win32-x64-msvc': - optional: true - '@cipherstash/eql@3.0.2': resolution: {integrity: sha512-E85o0aoOqgCW6RReLtJ0YLh/ExRlmDJo7LlJGpWPoMTVaw+CW8o11DJ4oJIF1vFtuxSVxNULuPzzBuVmpTvvcA==} @@ -4041,35 +3964,6 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 - '@cipherstash/auth-darwin-arm64@0.44.0': - optional: true - - '@cipherstash/auth-darwin-x64@0.44.0': - optional: true - - '@cipherstash/auth-linux-arm64-gnu@0.44.0': - optional: true - - '@cipherstash/auth-linux-x64-gnu@0.44.0': - optional: true - - '@cipherstash/auth-linux-x64-musl@0.44.0': - optional: true - - '@cipherstash/auth-win32-x64-msvc@0.44.0': - optional: true - - '@cipherstash/auth@0.44.0(@cipherstash/auth-darwin-arm64@0.44.0)(@cipherstash/auth-darwin-x64@0.44.0)(@cipherstash/auth-linux-arm64-gnu@0.44.0)(@cipherstash/auth-linux-x64-gnu@0.44.0)(@cipherstash/auth-linux-x64-musl@0.44.0)(@cipherstash/auth-win32-x64-msvc@0.44.0)': - dependencies: - '@byteslice/result': 0.3.0 - optionalDependencies: - '@cipherstash/auth-darwin-arm64': 0.44.0 - '@cipherstash/auth-darwin-x64': 0.44.0 - '@cipherstash/auth-linux-arm64-gnu': 0.44.0 - '@cipherstash/auth-linux-x64-gnu': 0.44.0 - '@cipherstash/auth-linux-x64-musl': 0.44.0 - '@cipherstash/auth-win32-x64-msvc': 0.44.0 - '@cipherstash/eql@3.0.2': {} '@clack/core@1.4.3': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c33a93f3c..97e4c4695 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -38,25 +38,6 @@ packages: catalogs: repo: - # @cipherstash/auth ships per-platform native bindings as optional - # peerDependencies. pnpm does not auto-install platform-matched - # optional peer deps, so the consuming packages (stack, cli, wizard) - # declare them as `optionalDependencies` via `catalog:repo`. - # - # LOCKSTEP IS LOAD-BEARING, not tidiness: auth pins its bindings as - # EXACT-version optional peer deps, so a skewed set makes npm nest - # per-consumer binding copies that the hoisted auth package cannot - # resolve — every project-local install of the CLI/SDK then dies with - # "Failed to load native binding" (the 1.0.0-rc.2 B1 bug). Enforced by - # e2e/tests/supply-chain.e2e.test.ts; Dependabot ignores all seven - # names — bump them together, manually. - '@cipherstash/auth': 0.44.0 - '@cipherstash/auth-darwin-arm64': 0.44.0 - '@cipherstash/auth-darwin-x64': 0.44.0 - '@cipherstash/auth-linux-arm64-gnu': 0.44.0 - '@cipherstash/auth-linux-x64-gnu': 0.44.0 - '@cipherstash/auth-linux-x64-musl': 0.44.0 - '@cipherstash/auth-win32-x64-msvc': 0.44.0 '@types/node': 22.20.1 tsup: 8.5.1 tsx: 4.23.12 @@ -64,9 +45,8 @@ catalogs: vitest: 4.1.11 # Must track `vitest` exactly: the coverage provider is versioned in # lockstep with the runner, and vitest refuses to start against a mismatched - # one ("Vitest failed to load @vitest/coverage-v8"). Catalogued for the same - # reason the auth bindings are, and enforced the same way — by a test rather - # than by this comment: see `e2e/tests/supply-chain.e2e.test.ts`. Dependabot + # one ("Vitest failed to load @vitest/coverage-v8"). Enforced by a test + # rather than by this comment: see `e2e/tests/supply-chain.e2e.test.ts`. Dependabot # groups both under `dev-dependencies` and will otherwise move one alone. '@vitest/coverage-v8': 4.1.11 security: @@ -191,9 +171,9 @@ blockExoticSubdeps: true # retired @prisma-next/* scope is kept for # historical installs while anything still pins # 0.16) -# - @cipherstash/auth* CipherStash-published auth strategies (NAPI + -# WASM-inline variant); also tracked in lockstep -# with protect-ffi for the WASM path. +# +# @cipherstash/auth* was listed here until the stack-* crates import made the +# seven packages workspace packages, for the same reason as the EQL entry below. # # @cipherstash/eql was listed here until the encrypt-query-language subtree # landed. It is now a workspace package, so no pnpm install ever resolves it @@ -206,5 +186,3 @@ minimumReleaseAgeExclude: - '@prisma-next/*' - '@prisma/orm-*' - 'prisma-next' - - '@cipherstash/auth' - - '@cipherstash/auth-*' diff --git a/scripts/__tests__/auth-binding-step-order.test.mjs b/scripts/__tests__/auth-binding-step-order.test.mjs new file mode 100644 index 000000000..694db4f17 --- /dev/null +++ b/scripts/__tests__/auth-binding-step-order.test.mjs @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { readWorkflow, workflowFiles } from './lib/workflows.mjs' + +/** + * Every CI job that loads `@cipherstash/stack` or runs `stash` builds the + * `@cipherstash/auth` binding first. + * + * The consumers take `@cipherstash/auth` from the workspace, which ships + * source only, and its `index.js` loads the napi module on import. Without a + * build, `@cipherstash/stack` and `stash` die with "Failed to load native + * binding" — on the registry, the platform package brought a prebuilt `.node`. + * The jobs that need it are the ones that already build the protect-ffi + * binding for the same reason, plus those listed in `AUTH_ONLY_JOBS`. + * `@cipherstash/stack/wasm-inline` imports `@cipherstash/auth/wasm-inline`, so + * a job that builds protect-ffi's wasm builds auth's too. + */ + +const BUILD_FFI = './.github/actions/build-ffi-binding' +const BUILD_AUTH = './.github/actions/build-auth-binding' + +/** Jobs that run `stash` or import the SDK without the protect-ffi binding. */ +const AUTH_ONLY_JOBS = new Map([ + [ + '.github/workflows/tests-bench.yml / tests-bench', + 'the bench unit checks import @cipherstash/stack, and its globalSetup runs `stash eql install`', + ], +]) + +const stepUses = (step) => + typeof step?.uses === 'string' ? step.uses.trim() : null +const wantsWasm = (step) => String(step?.with?.wasm ?? 'false') === 'true' + +const JOBS = workflowFiles().flatMap((relPath) => + Object.entries(readWorkflow(relPath)?.jobs ?? {}).map(([jobName, job]) => { + const steps = Array.isArray(job?.steps) ? job.steps : [] + const index = (uses) => steps.findIndex((step) => stepUses(step) === uses) + return { + id: `${relPath} / ${jobName}`, + ffi: steps[index(BUILD_FFI)], + auth: steps[index(BUILD_AUTH)], + ffiIndex: index(BUILD_FFI), + authIndex: index(BUILD_AUTH), + } + }), +) + +const FFI_JOBS = JOBS.filter((job) => job.ffi) + +describe('the @cipherstash/auth binding is built wherever the SDK runs', () => { + it('finds the jobs that build the protect-ffi binding', () => { + // A floor: a renamed action path would otherwise make every check below + // pass over nothing. + expect(FFI_JOBS.length).toBeGreaterThanOrEqual(10) + }) + + it.each(FFI_JOBS.map((job) => [job.id, job]))( + '%s builds it after the protect-ffi binding', + (_id, job) => { + expect(job.auth, `${job.id} does not use ${BUILD_AUTH}`).toBeDefined() + expect(job.authIndex).toBeGreaterThan(job.ffiIndex) + }, + ) + + it.each(FFI_JOBS.map((job) => [job.id, job]))( + '%s builds the auth wasm exactly when it builds the protect-ffi wasm', + (_id, job) => { + expect(wantsWasm(job.auth)).toBe(wantsWasm(job.ffi)) + }, + ) + + it.each([...AUTH_ONLY_JOBS])('%s builds it (%s)', (id) => { + const job = JOBS.find((entry) => entry.id === id) + expect(job, `${id} no longer exists`).toBeDefined() + expect(job?.auth, `${id} does not use ${BUILD_AUTH}`).toBeDefined() + }) + + it('lists no job twice', () => { + for (const id of AUTH_ONLY_JOBS.keys()) { + expect(FFI_JOBS.map((job) => job.id)).not.toContain(id) + } + }) +}) + +/** The `paths:` lists of a workflow's filtered events. */ +const filters = (relPath) => { + const wf = readWorkflow(relPath) + const on = wf?.on ?? wf?.[true] + return ['push', 'pull_request'] + .map((event) => on?.[event]?.paths) + .filter(Array.isArray) +} + +/** + * The integration workflows that saw an auth bump through + * `pnpm-workspace.yaml` while auth was a catalog pin. As a workspace package, + * a change to it edits its own sources instead. + */ +const AUTH_SOURCE_WORKFLOWS = [ + '.github/workflows/integration-drizzle.yml', + '.github/workflows/integration-protect-ffi.yml', + '.github/workflows/integration-supabase.yml', +] + +describe('a change to the auth binding starts the jobs that load it', () => { + const users = [ + ...new Set( + JOBS.filter((job) => job.auth).map((job) => job.id.split(' / ')[0]), + ), + ] + + it.each(users)('%s filters on the action', (relPath) => { + for (const paths of filters(relPath)) { + expect(paths).toContain('.github/actions/build-auth-binding/**') + } + }) + + // What `@cipherstash/auth` ships, from the changeset check's own filter. + const CHANGESET_WORKFLOW = '.github/workflows/require-auth-npm-changeset.yml' + const shipped = filters(CHANGESET_WORKFLOW) + .flat() + .filter( + (path) => + path !== CHANGESET_WORKFLOW && + path !== 'scripts/check-auth-npm-changeset.mjs', + ) + + it.each(AUTH_SOURCE_WORKFLOWS)( + '%s filters on the auth sources', + (relPath) => { + expect(shipped.length).toBeGreaterThanOrEqual(4) + const lists = filters(relPath) + expect(lists.length).toBe(2) + for (const paths of lists) { + expect(shipped.filter((path) => !paths.includes(path))).toEqual([]) + } + }, + ) +}) From ed14d047b721e6babe7543b730d8d49ffb585ab8 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Sat, 3 Oct 2026 06:42:21 +1000 Subject: [PATCH 5/5] chore(changeset): tell Alpine users that @cipherstash/auth loads on musl again @cipherstash/auth-linux-x64-musl 0.44.0 linked glibc, so the package did not load on musl systems such as Alpine Linux. #1018 fixes the build. None of the six carried changesets says so, and AGENTS.md asks for a changeset for a bug fix to a published package. It lives here because main's lint:auth-changeset refuses any @cipherstash/auth changeset until this PR removes the freeze. freshtonic raised it in review of #1018. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .changeset/auth-linux-x64-musl-links-musl.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/auth-linux-x64-musl-links-musl.md diff --git a/.changeset/auth-linux-x64-musl-links-musl.md b/.changeset/auth-linux-x64-musl-links-musl.md new file mode 100644 index 000000000..66bb6f752 --- /dev/null +++ b/.changeset/auth-linux-x64-musl-links-musl.md @@ -0,0 +1,6 @@ +--- +"@cipherstash/auth": patch +--- + +The `linux-x64-musl` binary now links musl. In 0.44.0 it linked glibc, so +`@cipherstash/auth` did not load on musl systems such as Alpine Linux.