Skip to content

feat(workspace): link protect-ffi and build it in the jobs that need it - #863

Open
tobyhede wants to merge 2 commits into
feat/protect-ffi-3-vendorfrom
feat/protect-ffi-4-workspace-link
Open

feat(workspace): link protect-ffi and build it in the jobs that need it#863
tobyhede wants to merge 2 commits into
feat/protect-ffi-3-vendorfrom
feat/protect-ffi-4-workspace-link

Conversation

@tobyhede

@tobyhede tobyhede commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Stack 4 of 4 — splitting #858. Base: #862.

PR What it does
1 #860 Consume the published protect-ffi 0.31.0
2 #861 Close three fail-open holes in the no-caching gate
3 #862 Vendor packages/protect-ffi and wire its own CI
4 this one Link consumers to the workspace copy

What

@cipherstash/stack and the two adapters move from the exact pin 0.31.0 to workspace:*, and the CI jobs that reach encryption gain .github/actions/build-ffi-binding. Review found two that did not — see Known gap below.

The same commit also wires lint:ffi-changeset into tests.yml. That guard has existed since #862 and nothing ran it, which is the failure mode the absorption keeps tripping over: a check nothing invokes reads exactly like a check that passes.

Why the two halves cannot be split

The npm tarball ships lib/, index.node and dist/wasm/** as contents. The workspace copy ships them as build outputslib/ is the package main and is generated, and nothing produces index.node without cargo. The moment the dependency line changes, every job that encrypts, decrypts or typechecks against the package needs the binding built first. Split them and the failure is Cannot find module '.../index.node', reported once per test rather than once per job.

minimumReleaseAgeExclude drops its @cipherstash/protect-ffi* entry in the same commit and for the same reason: the cooldown applies to registry dependencies, and as of this line these are not registry dependencies.

Ordering is load-bearing in two places, and neither looks it

  • require-cs-secrets must precede build-ffi-binding. The secrets check costs seconds; a cold binding build costs minutes, more with wasm: 'true'. Build first and a job with a rotated credential pays the whole compile before learning it could never have encrypted anything. Both are self-contained uses: blocks and swapping them changes nothing about whether the job passes — hence ffi-binding-step-order.test.mjs, checking by position across every workflow, discovered rather than listed. Note the direction of the fix if it fails: in wasm-e2e-tests a Build stack step sits between them and consumes dist/wasm, so there the pre-flight moves up, not the build down.
  • Build stack must follow the binding build in wasm-e2e-tests, because it now consumes protect-ffi's dist/wasm from the workspace copy rather than from a tarball.

test:typecheck:wasm returns here with the job that runs it. It needs dist/wasm so it cannot hang off the default test, and lintWiring's exemption list only accepts a carve-out whose name appears in a root workflow — so the script was held back rather than shipped with a promise.

The no-caching gate gains a live citation rather than a fixture: build-ffi-binding restores two Actions caches and its header says publishing workflows must not use it. The test asserts the caching is really there before asserting the gate flags it, so removing the caching fails the test — the prompt to fix the header too, rather than leaving a check that proves nothing.

wasm: 'true' is the minority case

Across the repo three jobs pass it — integration-drizzle, integration-protect-ffi (from #862), and tests.yml's wasm-e2e-tests — because those are the ones that load the real WASM build; it costs a second cargo build against wasm32. 98aee99a's commit message says "exactly two", counting only the jobs that commit touched. AGENTS.md now states the rule rather than a number, since the number drifts and the rule does not.

Known gap

Review found two credentialed jobs in tests.yml that reach live encryption without building the binding:

  • e2e-tests — runs turbo run test:e2e with no file filter, so e2e/vitest.config.ts's tests/**/*.e2e.test.ts picks up prisma-example-readme.e2e.test.ts. That suite is describe.skipIf(!authConfigured) and un-skips whenever CS_CLIENT_ID and CS_CLIENT_KEY are set, which this job sets at job level. It drives the README's pnpm start through newClient, and turbo's ^build reaches protect-ffi's tsc but never index.node.
  • run-tests-bun — runs packages/stack vitest against live suites; continue-on-error: true plus || true means it stays green, so it cannot report the breakage it hits.

Fix in progress, not in this push: a coverage assertion beside the ordering one in ffi-binding-step-order.test.mjs, discovering credentialed jobs by scanning the workflow directory rather than by list — run-tests-bun is only visible that way, since its credentials appear solely inside a run: heredoc.

Verification

The head of this stack is byte-identical to #858 across all 311 files the absorption touched (verified per-file by blob hash). Main has moved 8 commits ahead since #858 branched; those 29 files are disjoint from the absorption's 311, so nothing of main's newer work is reverted.

Scripts suite 295 passing; protect-ffi's JS chain passing; supply-chain e2e 21 passing; biome 0 errors; lockfile in sync.

No changeset: CI wiring, no published surface — and a changeset naming any protect-ffi package would fail lint-no-ffi-changeset.mjs regardless.

@tobyhede
tobyhede requested a review from a team as a code owner August 6, 2026 00:58
@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 98aee99

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c524590-bc9b-4bfb-9949-88f60c94811d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

`@cipherstash/stack` and the two adapters move from the exact pin `0.31.0` to
`workspace:*`, so they resolve the in-tree copy rather than the npm tarball.
This is the step the previous three set up, and it is deliberately atomic.

**Why it cannot be split further.** The tarball ships `lib/`, `index.node` and
`dist/wasm/**` as CONTENTS. The workspace copy ships them as BUILD OUTPUTS —
`lib/` is the package `main` and is generated, and nothing produces
`index.node` without cargo. So the moment this line changes, every CI job that
encrypts, decrypts, or typechecks against the package needs the binding built
first, and the workflow steps below have to land in the same commit. Split
them and the failure is `Cannot find module '.../index.node'`, reported once
per test rather than once per job.

`minimumReleaseAgeExclude` drops its `@cipherstash/protect-ffi*` entry in the
same commit and for the same reason: the cooldown applies to registry
dependencies, and as of this line these are not registry dependencies. Leaving
it would be a stale exemption for packages nothing fetches.

The published surface does not change — the workspace copy is the 0.31.0
release, tree-identical to the tag. The `major` changeset for adopting 0.31.0
is in the first PR of this stack, where the breaking change actually is.
The other half of the workspace link. `.github/actions/build-ffi-binding`
compiles the binding and proves it loads, and every job that reaches
encryption now runs it before its tests: `tests.yml`'s unit, e2e and bun jobs,
the three integration suites, and both Prisma e2e workflows. The action caches
`index.node` on a content hash of the Rust inputs, so a PR touching no Rust
pays a restore rather than a compile.

`wasm: 'true'` is passed by exactly two jobs — the ones that load the real
WASM. stack's `dist/wasm-inline.js` imports protect-ffi's
`dist/wasm/protect_ffi_inline.js` by relative path, and only the three `.d.ts`
under `dist/wasm` are tracked; the `.js` and `.wasm` come from wasm-pack and
nothing else in CI runs it.

**Ordering is load-bearing in two places, and neither looks it.**

`require-cs-secrets` must precede `build-ffi-binding`. The secrets check reads
four inputs and fails in seconds; a cold binding build costs minutes, and more
with `wasm: 'true'`. Put the build first and a job with a rotated credential
pays the whole compile before learning it could never have encrypted anything
— which is the same as having no pre-flight. Both steps are self-contained
`uses:` blocks and swapping them changes nothing about whether the job passes,
so `ffi-binding-step-order.test.mjs` checks it by position across every
workflow, discovered rather than listed. Note the direction of the fix when it
fails: in `wasm-e2e-tests` a `Build stack` step sits between them and consumes
`dist/wasm`, so there the pre-flight moves UP, not the build DOWN.

In `wasm-e2e-tests`, `Build stack` must follow the binding build, because it
now consumes protect-ffi's `dist/wasm` output from the workspace copy rather
than from a tarball.

`test:typecheck:wasm` returns here, with the job that runs it. It needs
`dist/wasm`, so it cannot hang off the default `test`, and `lintWiring`'s
exemption list only accepts a carve-out whose name appears in a root workflow
— which is why the script was held back until this commit rather than shipped
with the package and a promise.

The no-caching gate gains a live citation rather than a fixture:
`build-ffi-binding` restores two GitHub Actions caches, and its header says
publishing workflows must not use it. The test asserts the caching is really
there before asserting the gate flags it, so removing the caching fails the
test — which is the prompt to fix the header too, rather than leaving a check
that passes because it now proves nothing.

No changeset: CI wiring, no published surface. A changeset naming any
protect-ffi package would fail `scripts/lint-no-ffi-changeset.mjs` regardless
— publishing does not move until the trusted-publishing cutover.
@tobyhede
tobyhede force-pushed the feat/protect-ffi-4-workspace-link branch from e0d242e to 98aee99 Compare August 6, 2026 01:06

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: APPROVE

Purely CI-wiring + internal dependency linking (stack 4 of 4). Consumers move @cipherstash/protect-ffi from the 0.31.0 pin to workspace:*, and every job that encrypts/decrypts/typechecks against it gains build-ffi-binding. Reviewed in context against the base branch; ran everything runnable locally.

Verification performed

  • pnpm install --frozen-lockfile — clean, lockfile in sync.
  • pnpm run test:scripts295 passing, incl. new ffi-binding-step-order.test.mjs (9) and the updated lint-no-workflow-caching suite.
  • protect-ffi lintWiring.test.ts12 passing; pnpm run lint:ffi-changeset — passes (no protect-ffi changeset in the PR).
  • pnpm run code:check (biome) — clean for the PR's files.
  • EXPECTED_PAIRED_JOBS matches ground truth exactly (7 files, 8 paired jobs).
  • wasm: flags cross-checked against each job's CS_IT_SUITE: drizzle wasm:'true' (includes integration/wasm/**) ✓, supabase native-only ✓, prisma-next native-only ✓.
  • wasm-e2e-tests reorder verified: require-cs-secretsbuild-ffi-binding(wasm)test:typecheck:wasmBuild stack → Deno. Preflight correctly moves up above the builds.

Blocking

None.

Nits (non-blocking)

  • prisma-example-readme-e2e.yml comment says the binding build "is only ever exercised by the push-to-main run", but the protect-ffi source paths were added to both push and pull_request filters, so a PR touching packages/protect-ffi/** triggers it too. Behaviour is correct/more coverage; prisma-next-e2e.yml's wording ("on push to main and on any PR touching its paths") is the accurate version — consider aligning.
  • EXPECTED_PAIRED_JOBS is a hand-maintained list. Deliberate and well-documented ("guard on the scan"), just flagging that future job renames must touch it.

Highlights

  • ffi-binding-step-order.test.mjs is exemplary — enforces an invisible ordering invariant, discovers paired jobs rather than listing them, guards the scan against vacuous-pass with a mutation-tested floor, and its failure messages teach the correct direction of the fix.
  • The caching-gate test uses a live citation (build-ffi-binding/action.yml) and asserts the caching exists before asserting the gate flags it, so it can't rot into proving nothing.
  • build-ffi-binding verifies the binding actually loads (assertNativeBindingAvailable() + wasm-inline probe), failing at the named artifact instead of as scattered downstream encryption errors.
  • workspace:* resolves to the same 0.31.0 the consumers were pinned to, so the "no published surface / no changeset" reasoning holds. No skills impact — no public API/CLI/workflow surface changed.

Solid, exceptionally well-documented work. Approving.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants