diff --git a/.changeset/brave-hounds-monitor.md b/.changeset/brave-hounds-monitor.md new file mode 100644 index 000000000..ad2f37495 --- /dev/null +++ b/.changeset/brave-hounds-monitor.md @@ -0,0 +1,18 @@ +--- +'stash': patch +--- + +Correct the Dependabot section of the bundled `stash-supply-chain-security` +skill. It described two monitored ecosystems (`npm`, `github-actions`); there +are now three, because the in-tree Rust workspace at `packages/protect-ffi` +brought a `Cargo.lock` that nothing proposed updates for. The skill now names +the `cargo` entry, its non-root `directory`, its monthly cadence, and the +exact-pinned CipherStash crates it ignores. + +Two things the section previously got wrong are also fixed. Major bumps do not +"stay un-grouped — one PR each": every entry ignores +`version-update:semver-major`, so Dependabot proposes no major bumps at all and +they are applied by hand. And `ignore` conditions suppress Dependabot *security* +PRs as well as version updates — the skill now says so, and points at +`osv-scanner.yml` (which scans every lockfile in the tree, `Cargo.lock` +included) as the compensating control. diff --git a/.changeset/config.json b/.changeset/config.json index fb05a4c16..00e1e6a33 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -10,6 +10,15 @@ "@cipherstash/stack-supabase", "@cipherstash/stack-prisma", "@cipherstash/wizard" + ], + [ + "@cipherstash/protect-ffi", + "@cipherstash/protect-ffi-darwin-x64", + "@cipherstash/protect-ffi-darwin-arm64", + "@cipherstash/protect-ffi-win32-x64-msvc", + "@cipherstash/protect-ffi-linux-x64-gnu", + "@cipherstash/protect-ffi-linux-arm64-gnu", + "@cipherstash/protect-ffi-linux-x64-musl" ] ], "linked": [], diff --git a/.changeset/protect-ffi-lazy-load.md.deferred b/.changeset/protect-ffi-lazy-load.md.deferred new file mode 100644 index 000000000..2dd592866 --- /dev/null +++ b/.changeset/protect-ffi-lazy-load.md.deferred @@ -0,0 +1,7 @@ +--- +'@cipherstash/protect-ffi': minor +--- + +The native binding now loads lazily. Importing the package no longer resolves the platform binary: the CJS entry pulls in `./load.cjs` with `import native = require(...)`, which emits a plain `require` and leaves the `@neon-rs/load` proxy untouched, where the previous `import * as native from` compiled to an `__importStar` that enumerated the module and forced resolution at module-evaluation time. `require('@cipherstash/protect-ffi')` therefore succeeds with no binding installed, and the same `MODULE_NOT_FOUND` — identical `code` and `message` — is raised on first use instead of at import. + +Adds `assertNativeBindingAvailable()`: a diagnostic entry point that resolves the platform binary and throws the loader's error unwrapped if it is missing, without constructing a client, reading credentials or touching the network. It exists because laziness removed the implicit probe that importing the package used to be, and there is no consumer-side replacement — `lib/load.cjs` is not an exported path (`ERR_PACKAGE_PATH_NOT_EXPORTED`), reading one of this module's own exports never reaches the proxy, and forcing it through a real wrapper means picking one whose argument validation does not reject first. diff --git a/.changeset/quiet-moons-repair.md b/.changeset/quiet-moons-repair.md new file mode 100644 index 000000000..0687c7be2 --- /dev/null +++ b/.changeset/quiet-moons-repair.md @@ -0,0 +1,5 @@ +--- +'stash': patch +--- + +Document the Dependabot major-version policy in `skills/stash-supply-chain-security`: no entry configures a `semver-major-days` cooldown, because every entry ignores `version-update:semver-major` and cooldown applies to version updates only. The supply-chain e2e suite now pins both halves of that relationship. diff --git a/.github/actions/build-ffi-binding/action.yml b/.github/actions/build-ffi-binding/action.yml new file mode 100644 index 000000000..ea5bc72e8 --- /dev/null +++ b/.github/actions/build-ffi-binding/action.yml @@ -0,0 +1,229 @@ +name: Build the protect-ffi binding +description: >- + Compile `packages/protect-ffi` into the artifacts its JS consumers load at + runtime — `lib/` (tsc), `index.node` (cargo), and optionally `dist/wasm/**` + (wasm-pack) — then prove they load. + + WHY THIS EXISTS: until the monorepo absorption, `@cipherstash/protect-ffi` + was installed from npm and all three arrived prebuilt inside the tarball. As + a workspace package it ships source only, so every job that encrypts, + decrypts, or typechecks against it has to build them first — and nothing else + in CI does. Without this action the credentialed suites fail with + `Cannot find module '.../protect-ffi-linux-x64-gnu/index.node'`, the Deno + smoke test with `Module not found ".../dist/wasm/protect_ffi_inline.js"`, and + stack's type tests with four unresolved-declaration errors. + + DO NOT USE FROM A PUBLISHING WORKFLOW. It restores the GitHub Actions cache, + which `scripts/lint-no-workflow-caching.mjs` forbids anywhere an artifact + gets published. Release builds go through the target-explicit matrix instead, + which compiles every platform from scratch. + +inputs: + wasm: + description: >- + Also build `dist/wasm/**`. Only jobs that exercise + `@cipherstash/stack/wasm-inline` need it — the Deno smoke test and the + `integration/wasm/**` suites — and it costs a wasm-pack install plus a + second cargo build against wasm32, so it is off by default. + required: false + default: 'false' + +runs: + using: composite + steps: + # `index.node` is a pure function of the Rust inputs, so it caches on a + # content hash of them rather than on a lockfile alone — a crates/ edit + # must miss. The path is one gitignored file, so unlike the WASM step + # below there is nothing tracked for a restore to overwrite, and the key + # needs nothing beyond the Rust inputs. Caching the 13MB output, NOT + # cargo's `target/`: a release + # build of the cipherstash-client graph leaves gigabytes there, which is + # slower to save and restore than the compile it saves. + # + # GitHub scopes a PR's cache writes to its own ref, so the payoff arrives + # once this lands on main and PR runs can read main's entry. Until then + # each PR pays one cold build and reuses it across re-runs. + - name: Restore the compiled binding + id: cache-native + uses: actions/cache@v4 + with: + path: packages/protect-ffi/index.node + # package.json and mise.toml are in the key because they are build + # INPUTS, not just metadata: `build:native` is `cargo-build` plus a + # `postcargo-build` hook (`neon dist < cargo.log`), both defined in + # package.json, and mise.toml pins the toolchain that compiles it. + # Edit either and the Rust hash is unchanged, so the cache hit skips + # the build step below and the job proceeds on a stale index.node. + key: >- + ffi-native-${{ runner.os }}-${{ runner.arch }}-${{ + hashFiles('packages/protect-ffi/crates/**', + 'packages/protect-ffi/Cargo.toml', + 'packages/protect-ffi/Cargo.lock', + 'packages/protect-ffi/package.json', + 'packages/protect-ffi/mise.toml') }} + + # Always, hit or miss: `lib/` is `tsc` over `src/`, which is seconds, and + # it tracks a different input set than the cached binary. + - name: Build lib/ (the package `main`, and what tsc consumers resolve) + shell: bash + run: pnpm --filter @cipherstash/protect-ffi run build + + # `build:native` is `tsc && cargo build --release`, then a `postcargo-build` + # hook running `neon dist < cargo.log`. That bare `neon dist` writes + # `index.node` at the PACKAGE ROOT, which is what `src/load.cts` registers + # as its `debug:` fallback — so the loader finds it without any platform + # package being populated. Release packaging needs `neon dist -o` to place + # a binary inside `platforms/
/`; testing deliberately does not.
+ - name: Build index.node (cargo)
+ if: steps.cache-native.outputs.cache-hit != 'true'
+ shell: bash
+ run: pnpm --filter @cipherstash/protect-ffi run build:native
+
+ # Unlike index.node, this path is NOT purely generated. `dist/wasm` holds
+ # wasm-pack's output alongside three declaration files that are tracked in
+ # git — see packages/protect-ffi/.gitignore for why they have to be. A
+ # restore untars over the checkout, so with a key hashed from the Rust
+ # inputs alone, an entry saved before a `.d.ts` edit silently reverts that
+ # edit: no diff, no log line, and the next `tsc` against wasm-inline
+ # typechecks the old declarations.
+ #
+ # So the `.d.ts` are hashed into the key too. Any edit to one now misses,
+ # and any entry that *does* hit was necessarily saved from a checkout whose
+ # declarations were byte-identical — which makes the overwrite a no-op by
+ # construction rather than by luck. The glob is load-bearing: it covers a
+ # fourth declaration file the day someone tracks one, where three literal
+ # paths would quietly stop covering the set they were written for.
+ #
+ # The alternative — keeping them out of the archive with a `!` exclusion —
+ # does not work, and reads as though it does. `actions/cache` resolves
+ # `path:` with `implicitDescendants: false` and hands the result to
+ # `tar --files-from`; a bare directory therefore resolves to one entry and
+ # tar recurses into it itself, so an exclusion aimed at files underneath
+ # never matches anything. Narrowing to explicit file globs would work, but
+ # trades a no-op overwrite for a worse failure: a file wasm-pack starts
+ # emitting is then silently absent on every cache hit.
+ #
+ # No key prefix bump is needed for the changed hash inputs — and none would
+ # be needed had `path:` changed either, since `getCacheVersion` hashes the
+ # path list into the entry's version.
+ - name: Restore the compiled WASM output
+ if: inputs.wasm == 'true'
+ id: cache-wasm
+ uses: actions/cache@v4
+ with:
+ path: packages/protect-ffi/dist/wasm
+ # The last five are the same build-input argument as on the native key,
+ # and `build:wasm` is the longer pipeline of the two: wasm-pack, then
+ # `tsc -p tsconfig.wasm-errors.json`, then a `postbuild:wasm` hook
+ # running scripts/inline-wasm.mjs — which is what emits the
+ # protect_ffi_inline.js that stack's wasm-inline entry imports. An edit
+ # to the inliner with no Rust change is the likely one here, and it is
+ # exactly the edit a Rust-only hash cannot see.
+ #
+ # src/errors.ts is that tsc step's only input (`files:` in the tsconfig
+ # names it alone), and hashing the EMITTED dist/wasm/*.d.ts does not
+ # stand in for it. Rewriting the body of `isProtectErrorCode` leaves
+ # errors.d.ts byte-identical while changing errors.js — so the key hits,
+ # this step is skipped, and the restored errors.js is the old one.
+ # Nothing tracked forces a miss: errors.js is gitignored. It is not
+ # inert output either — scripts/inline-wasm.mjs re-exports it from both
+ # the ./wasm and ./wasm-inline entries, so the WASM integration suites
+ # would run an implementation the release build does not ship.
+ #
+ # It is the one source file here for the same reason the whole of src/**
+ # is NOT in this key: a wasm miss costs a wasm-pack install and a cargo
+ # wasm32 build of the cipherstash-client graph, and 104 of the 122 files
+ # under src/ are the eql-v3-types declarations, which that tsconfig
+ # never reads. ffi-binding-action.test.mjs walks the tsconfig's imports
+ # and fails if this stops being the complete input set.
+ key: >-
+ ffi-wasm-${{ runner.os }}-${{
+ hashFiles('packages/protect-ffi/crates/**',
+ 'packages/protect-ffi/Cargo.toml',
+ 'packages/protect-ffi/Cargo.lock',
+ 'packages/protect-ffi/dist/wasm/*.d.ts',
+ 'packages/protect-ffi/src/errors.ts',
+ 'packages/protect-ffi/package.json',
+ 'packages/protect-ffi/mise.toml',
+ 'packages/protect-ffi/tsconfig.wasm-errors.json',
+ 'packages/protect-ffi/scripts/inline-wasm.mjs') }}
+
+ # mise carries the pinned wasm-pack (see packages/protect-ffi/mise.toml)
+ # and, run from that directory, trusts the nested config — a bare `mise`
+ # call elsewhere refuses it with "Config files are not trusted", which
+ # reads as a toolchain problem rather than a trust one.
+ #
+ # `install_args` narrows this to wasm-pack alone. A bare `mise install`
+ # would also build `cargo:cargo-zigbuild` from source, which this job has
+ # no use for — zigbuild is the release matrix's glibc-pinning path.
+ #
+ # The argument is the full backend id, matching mise.toml. The short name
+ # is not in mise's registry: `mise install wasm-pack` exits non-zero with
+ # "not found in mise tool registry" and a did-you-mean list.
+ #
+ # SHA-pinned, per the convention the deposited upstream workflows already
+ # use for third-party actions. mise-action is a trust dependency this repo
+ # did not have before the absorption, and it runs in jobs holding live
+ # CipherStash credentials — a mutable `@v3` lets the code executing there
+ # change with no commit here. Dependabot moves the pin and the comment
+ # together (.github/dependabot.yml covers github-actions).
+ - name: Install wasm-pack
+ if: inputs.wasm == 'true' && steps.cache-wasm.outputs.cache-hit != 'true'
+ uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3
+ with:
+ install: true
+ install_args: aqua:wasm-bindgen/wasm-pack
+ working_directory: packages/protect-ffi
+
+ # `--all-targets` in the Rust lint means all target KINDS, not platforms;
+ # wasm32 has to be installed explicitly before anything can build for it.
+ - name: Add the wasm32 target
+ if: inputs.wasm == 'true' && steps.cache-wasm.outputs.cache-hit != 'true'
+ shell: bash
+ run: rustup target add wasm32-unknown-unknown
+
+ - name: Build dist/wasm (wasm-pack)
+ if: inputs.wasm == 'true' && steps.cache-wasm.outputs.cache-hit != 'true'
+ shell: bash
+ run: pnpm --filter @cipherstash/protect-ffi run build:wasm
+
+ # Always, and the reason this action is not just three `run:` lines inline.
+ # A cache that restores nothing, or a build whose output lands somewhere
+ # unexpected, otherwise surfaces as dozens of unrelated encryption failures
+ # deep in a credentialed suite. Fail here instead, naming the artifact.
+ - name: Verify the binding loads
+ shell: bash
+ working-directory: packages/protect-ffi
+ env:
+ WANT_WASM: ${{ inputs.wasm }}
+ run: |
+ set -euo pipefail
+
+ test -s index.node || {
+ echo "::error::index.node was neither restored from cache nor built"
+ exit 1
+ }
+
+ # Exercises the real loader, including the platform-package-then-debug
+ # fallback in src/load.cts. `require` alone proves nothing since the
+ # laziness change — the binding resolves on first use, not on import.
+ node -e "require('./lib/index.cjs').assertNativeBindingAvailable()"
+ echo "index.node loads"
+
+ if [ "$WANT_WASM" = "true" ]; then
+ for f in dist/wasm/protect_ffi.js \
+ dist/wasm/protect_ffi_inline.js \
+ dist/wasm/protect_ffi_bg.wasm ; do
+ test -s "$f" || {
+ echo "::error::$f missing — the WASM build did not produce it"
+ exit 1
+ }
+ done
+ node --input-type=module -e "
+ const m = await import('./dist/wasm/protect_ffi_inline.js')
+ if (typeof m.newClient !== 'function') {
+ throw new Error('wasm-inline resolved but exports no newClient')
+ }
+ "
+ echo "dist/wasm loads"
+ fi
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 3bcb587ca..57a53d23c 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -10,9 +10,17 @@ updates:
timezone: Australia/Sydney
# Wait for community discovery before opening PRs
# (lirantal/npm-security-best-practices #2: install with cooldown).
+ #
+ # No `semver-major-days:`, and it is not an oversight. That key delays
+ # major VERSION update PRs, and the `ignore` below stops Dependabot
+ # proposing one at all; nor could it apply on the security path, because
+ # "the cooldown option is only available for version updates, not
+ # security updates" (Dependabot options reference). So it would be
+ # configuration that does nothing — the same reason the cargo entry
+ # below leaves `day:` out of its monthly schedule. Enforced by
+ # e2e/tests/supply-chain.e2e.test.ts, in both directions.
cooldown:
default-days: 7
- semver-major-days: 14
open-pull-requests-limit: 10
labels:
- dependencies
@@ -46,14 +54,6 @@ updates:
# bug). Bump all seven catalog entries together, manually. Lockstep is
# enforced by e2e/tests/supply-chain.e2e.test.ts.
- dependency-name: "@cipherstash/auth-*"
- # Release-managed manually alongside stack releases. Grouped bumps are
- # actively harmful here: Dependabot's "group consistency" once upgraded
- # the (since-removed) `protect` package off its 0.23.0 pin — 0.24+ renamed
- # the exports it imported — while DOWNGRADING the stack packages from
- # 0.29.0 (see #673). The surviving consumers (stack, stack-drizzle,
- # stack-supabase) pin one version between them and must stay in lockstep,
- # so the rule stands.
- - dependency-name: "@cipherstash/protect-ffi"
# 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"
@@ -62,6 +62,93 @@ updates:
update-types:
- version-update:semver-major
+ # ── Cargo (packages/protect-ffi — the only Rust in the repo) ────
+ # Absorbing protect-ffi brought a 494-crate Cargo.lock in-tree. osv-scanner
+ # already sees it — `--recursive ./` walks the tree and extracts every
+ # lockfile it recognises — so known advisories were visible from day one, but
+ # nothing proposed routine version updates. That is the gap this entry
+ # closes, and the only one it closes. Coverage is enforced by
+ # e2e/tests/supply-chain.e2e.test.ts, which derives the required ecosystems
+ # from the lockfiles actually present rather than from a list.
+ - package-ecosystem: cargo
+ # NOT `/`. The Cargo workspace root is the package directory — Cargo.toml
+ # and Cargo.lock live there, the crate itself under crates/protect-ffi.
+ # A misaimed directory fails silently: Dependabot records "no manifest
+ # found" on a log page nobody visits, and the symptom is simply that no PR
+ # ever arrives. The e2e test asserts this path holds a Cargo.toml.
+ directory: /packages/protect-ffi
+ # Monthly, where npm and actions are weekly. The deviation is about
+ # validation cost, not risk appetite: a Cargo.lock bump is checked by
+ # tests-rust.yml — one Blacksmith job, 45-minute timeout, cargo test +
+ # clippy on host AND wasm32 — and the release additionally cross-builds
+ # six platform binaries. Security fixes are not delayed by this: Dependabot
+ # security updates are driven by alerts, not by `schedule`.
+ #
+ # No `day:`, unlike the two weekly entries — that key picks a weekday for
+ # `interval: weekly` and has nothing to pick here. Left out rather than
+ # copied across, so it does not read as configuration that does nothing.
+ schedule:
+ interval: monthly
+ # Same community-discovery window as npm above and pnpm's
+ # minimumReleaseAge. crates.io has no unpublish, but it does have
+ # yanking, and a yank is the signal this window waits for.
+ #
+ # No `semver-major-days:` here either, for the reason recorded on the npm
+ # entry: majors are ignored below, and cooldown never reaches the security
+ # path, so a major window would have nothing to delay.
+ cooldown:
+ default-days: 7
+ # Lower than npm's 10. Each open PR here costs a full native rebuild to
+ # validate, and the grouping below means one PR normally covers everything.
+ open-pull-requests-limit: 3
+ labels:
+ - dependencies
+ - supply-chain
+ commit-message:
+ prefix: "chore"
+ include: scope
+ groups:
+ # One PR for the whole minor/patch sweep, as with actions below. Cargo
+ # resolves the lock as a unit, so per-crate PRs each re-resolve the
+ # others and conflict on every merge.
+ #
+ # Read the diff; do not rubber-stamp it. Dependabot classifies update
+ # types from the version numbers alone, so a 0.2 → 0.3 bump lands in this
+ # group as "minor" even though cargo treats it as breaking — several
+ # direct deps here are 0.x (wasm-bindgen, js-sys, web-sys, getrandom,
+ # serde-wasm-bindgen). The `ignore` on semver-major below does not catch
+ # those. Same trap the @byteslice/result note records on the npm side.
+ cargo-minor-patch:
+ patterns:
+ - "*"
+ update-types:
+ - minor
+ - 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.
+ #
+ # Caveat worth knowing: `ignore` suppresses Dependabot SECURITY PRs too,
+ # not just version updates. osv-scanner is the compensating control —
+ # it scans this lockfile on every PR and on a weekday schedule, and
+ # surfaces crates.io advisories in code scanning.
+ - dependency-name: "cipherstash-client"
+ - dependency-name: "cts-common"
+ - dependency-name: "stack-auth"
+ - dependency-name: "stack-profile"
+ - dependency-name: "eql-bindings"
+ - dependency-name: "vitaminc"
+ # Major bumps are reviewed and applied manually, not by Dependabot.
+ - dependency-name: "*"
+ update-types:
+ - version-update:semver-major
+
# ── GitHub Actions ─────────────────────────────────────────────
- package-ecosystem: github-actions
directory: /
diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml
index 7f642d26e..f4967a09e 100644
--- a/.github/workflows/integration-drizzle.yml
+++ b/.github/workflows/integration-drizzle.yml
@@ -119,7 +119,12 @@ jobs:
#
# Fork PRs have no secrets. Skip cleanly rather than fail on something the
# contributor cannot fix — `tests.yml` still gives them a green signal.
- if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }}
+ # Every OTHER event runs, which is why this gates on "not a fork PR" rather
+ # than listing the event names allowed through: the listing form skipped
+ # the job on `workflow_dispatch` in integration-protect-ffi.yml, making a
+ # declared manual trigger do nothing. Enforced by
+ # scripts/__tests__/workflow-dispatch-job-conditions.test.mjs.
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
strategy:
fail-fast: false
diff --git a/.github/workflows/integration-prisma-next.yml b/.github/workflows/integration-prisma-next.yml
index 35544513c..ddace2c34 100644
--- a/.github/workflows/integration-prisma-next.yml
+++ b/.github/workflows/integration-prisma-next.yml
@@ -65,7 +65,12 @@ jobs:
#
# Fork PRs have no secrets. Skip cleanly rather than fail on something the
# contributor cannot fix — `tests.yml` still gives them a green signal.
- if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }}
+ # Every OTHER event runs, which is why this gates on "not a fork PR" rather
+ # than listing the event names allowed through: the listing form skipped
+ # the job on `workflow_dispatch` in integration-protect-ffi.yml, making a
+ # declared manual trigger do nothing. Enforced by
+ # scripts/__tests__/workflow-dispatch-job-conditions.test.mjs.
+ if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
strategy:
fail-fast: false
diff --git a/.github/workflows/integration-protect-ffi.yml b/.github/workflows/integration-protect-ffi.yml
new file mode 100644
index 000000000..fa9d1cd40
--- /dev/null
+++ b/.github/workflows/integration-protect-ffi.yml
@@ -0,0 +1,316 @@
+name: Integration — protect-ffi (native + WASM)
+
+# The `packages/protect-ffi/integration-tests/` suite: 19 files of live
+# encrypt / decrypt / lock-context / keyset / JSON-SteVec / Postgres /
+# WASM-round-trip coverage against real ZeroKMS and a real Postgres.
+#
+# WHY THIS EXISTS: upstream (`cipherstash/protectjs-ffi`) ran this suite on
+# EVERY pull request from `.github/workflows/test.yml` — `mise setup`, then
+# `mise run test:integration:all`. The absorption copied the suite in intact and
+# left that workflow behind under `packages/protect-ffi/.github/`, where GitHub
+# never looks: it reads workflows from the repository root alone. So from the
+# day protect-ffi landed here, none of it ran, and a suite that never starts
+# reads exactly like a suite that passes.
+# `packages/protect-ffi/src/integrationSuiteCi.test.ts` asserts this file (or a
+# successor) still invokes it.
+#
+# WHY THERE IS npm IN A pnpm REPO. `integration-tests/` is deliberately NOT a
+# pnpm workspace member: it pins `@cipherstash/auth ^0.39.0`, `vitest ^3.1.3`
+# and `@cipherstash/eql 3.0.2` independently of the repo catalog, which is part
+# of what it tests — the suite proves the published FFI surface works for a
+# consumer resolving its own dependency tree, not the one the monorepo happens
+# to hoist. So it keeps its own `package-lock.json` and `mise run setup` installs
+# it with `npm ci`. That is not a supply-chain regression: `npm ci` installs the
+# lockfile exactly (it fails rather than resolving anything new) and verifies
+# every tarball against the `integrity` hash recorded there, and the registry is
+# npmjs. Absorbing the suite into the pnpm workspace is a follow-up that changes
+# those pins, and changed pins can only be validated by a credentialed run.
+#
+# Separate from `tests.yml` on purpose, and separate from `tests-rust.yml`: this
+# needs CipherStash credentials AND Docker, and the suites THROW rather than
+# skip when unconfigured. Keeping them out of the unit job is what lets
+# `pnpm test` stay runnable with neither.
+
+on:
+ push:
+ branches: [main]
+ paths:
+ # The suite itself: tests, fixtures, its compose file, its `tasks.toml`
+ # and its lockfile.
+ - 'packages/protect-ffi/integration-tests/**'
+ # The Rust the suite round-trips every payload through, and the manifests
+ # that change what cargo builds without touching a .rs file. Matching the
+ # filter in tests-rust.yml.
+ - 'packages/protect-ffi/crates/**'
+ - 'packages/protect-ffi/Cargo.toml'
+ - 'packages/protect-ffi/Cargo.lock'
+ # `src/**` is `lib/`, which is the module every test imports, plus
+ # `load.cts` — the loader that finds `index.node`.
+ - 'packages/protect-ffi/src/**'
+ # `scripts/inline-wasm.mjs` emits `dist/wasm/protect_ffi_inline.js`, which
+ # `tests/wasm-round-trip.test.ts` loads at module-graph time. An edit
+ # there breaks that suite while touching no Rust and no `src/`.
+ - 'packages/protect-ffi/scripts/**'
+ # package.json carries the build scripts and the exports map the suite
+ # resolves through; mise.toml carries the `tasks.toml` include, the
+ # toolchain pins, and the PG* connection env.
+ - 'packages/protect-ffi/package.json'
+ - 'packages/protect-ffi/mise.toml'
+ - '.github/workflows/integration-protect-ffi.yml'
+ - '.github/actions/build-ffi-binding/**'
+ - '.github/actions/require-cs-secrets/**'
+ pull_request:
+ branches: ['**']
+ # Repeated verbatim: GitHub Actions does not support YAML anchors/aliases.
+ # A one-sided edit disables the job on pull requests while leaving it green
+ # on `main` — the exact inversion of what you want.
+ paths:
+ - 'packages/protect-ffi/integration-tests/**'
+ - 'packages/protect-ffi/crates/**'
+ - 'packages/protect-ffi/Cargo.toml'
+ - 'packages/protect-ffi/Cargo.lock'
+ - 'packages/protect-ffi/src/**'
+ - 'packages/protect-ffi/scripts/**'
+ - 'packages/protect-ffi/package.json'
+ - 'packages/protect-ffi/mise.toml'
+ - '.github/workflows/integration-protect-ffi.yml'
+ - '.github/actions/build-ffi-binding/**'
+ - '.github/actions/require-cs-secrets/**'
+ workflow_dispatch: {}
+
+permissions:
+ contents: read
+
+defaults:
+ run:
+ shell: bash
+
+# Ref-scoped, so the only thing it cancels is a superseded commit on the same
+# branch — which is what you want for a job that holds live credentials and a
+# database for tens of minutes. This is NOT the cross-workflow job-level group
+# that `.github/actions/integration-db` was written to remove: that one was
+# shared by three workflows, and GitHub cancels a third contender outright
+# rather than queueing it, which dropped whole signals.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ integration:
+ name: protect-ffi integration suite
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ # Generous, because a cold `build-ffi-binding` compiles the cipherstash
+ # client graph from scratch. It is also the backstop for `mise run
+ # start-db`, whose readiness loop retries `pg_isready` forever rather than
+ # giving up.
+ timeout-minutes: 45
+ # Everything runs EXCEPT a fork PR, which has no secrets: skip that cleanly
+ # rather than fail on something the contributor cannot fix — `tests.yml`
+ # still gives them a green signal.
+ #
+ # Stated as "not a fork PR", NOT as a list of the events allowed to run.
+ # The previous form (`github.event_name == 'push' ||