diff --git a/.github/actions/build-ffi-binding/action.yml b/.github/actions/build-ffi-binding/action.yml index ba7bca9e2..19f76247a 100644 --- a/.github/actions/build-ffi-binding/action.yml +++ b/.github/actions/build-ffi-binding/action.yml @@ -55,7 +55,7 @@ runs: # 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. # - # The last two are NOT in this package. `crates/protect-ffi/Cargo.toml` + # The EQL entries are NOT in this package. `crates/protect-ffi/Cargo.toml` # carries `eql-bindings = { path = "../../../../../../packages/eql/crates/eql-bindings" }` # — an in-tree path dependency that compiles into `index.node` and into # the wasm build below. A path dep has no registry checksum, so a @@ -67,14 +67,24 @@ runs: # observed, not theoretical: the crate went 3.0.4 -> 4.0.0 -> 3.0.5 # across three commits and neither key here changed by a byte.) # - # `crates/**` rather than the one crate, deliberately. `eql-bindings` - # has no path deps of its own TODAY, so the narrower glob would be exact - # — and would silently stop being exact the moment it gains one on a - # sibling in that workspace, which is the same hole one level down. The - # cost of the wider glob is a spurious miss when an unrelated EQL crate - # changes; the cost of the narrower one is a stale binary. Guarded by - # `scripts/__tests__/ffi-binding-action.test.mjs`, which discovers the - # path deps from the manifest rather than trusting this comment. + # The files cargo compiles from that crate, not the crate directory and + # not `packages/eql/crates/**`. protect-ffi builds eql-bindings with no + # features, so its `v4` and `encryption` modules (feature + # `stack-encrypt`) never reach the binding, and its tests, docs and + # generated `bindings/` / `schema/` are not compile inputs at all. The + # wider glob hashed all of that, plus eql-codegen and the other crates + # beside it: each such edit missed this cache, and every workflow whose + # `paths:` copies the glob started its suites over a change that cannot + # reach them. An EQL v4 pull request ran every TypeScript integration + # suite that way. + # + # The list is derived, not remembered. + # `scripts/__tests__/ffi-binding-action.test.mjs` walks the path + # dependencies from the manifest, follows each crate's modules from + # `src/lib.rs` under the features it is built with, and fails both + # ways: a compiled file this key misses (a new module, a new path dep, + # a feature turned on), and a file beside those crates that it hashes + # and cargo does not read. # # The EQL workspace root manifest comes with it: cargo reads it for the # crate's workspace context, so a `.workspace = true` added to @@ -87,7 +97,12 @@ runs: 'languages/typescript/packages/protect-ffi/Cargo.lock', 'languages/typescript/packages/protect-ffi/package.json', 'languages/typescript/packages/protect-ffi/mise.toml', - 'packages/eql/crates/**', + 'packages/eql/crates/eql-bindings/Cargo.toml', + 'packages/eql/crates/eql-bindings/src/lib.rs', + 'packages/eql/crates/eql-bindings/src/sql.rs', + 'packages/eql/crates/eql-bindings/src/from_v2/**', + 'packages/eql/crates/eql-bindings/src/v3/**', + 'packages/eql/crates/eql-bindings/sql/**', 'packages/eql/Cargo.toml') }} # Always, hit or miss: `lib/` is `tsc` over `src/`, which is seconds, and @@ -165,7 +180,7 @@ runs: # never reads. ffi-binding-action.test.mjs walks the tsconfig's imports # and fails if this stops being the complete input set. # - # `packages/eql/**` is here for the reason spelled out on the native key + # The EQL entries are here for the reason spelled out on the native key # above, and it is NOT redundant with it: wasm-pack compiles the same # `eql-bindings` path dependency for wasm32 into # `protect_ffi_inline.js`, which is the bundle @@ -176,7 +191,12 @@ runs: hashFiles('languages/typescript/packages/protect-ffi/crates/**', 'languages/typescript/packages/protect-ffi/Cargo.toml', 'languages/typescript/packages/protect-ffi/Cargo.lock', - 'packages/eql/crates/**', + 'packages/eql/crates/eql-bindings/Cargo.toml', + 'packages/eql/crates/eql-bindings/src/lib.rs', + 'packages/eql/crates/eql-bindings/src/sql.rs', + 'packages/eql/crates/eql-bindings/src/from_v2/**', + 'packages/eql/crates/eql-bindings/src/v3/**', + 'packages/eql/crates/eql-bindings/sql/**', 'packages/eql/Cargo.toml', 'languages/typescript/packages/protect-ffi/dist/wasm/*.d.ts', 'languages/typescript/packages/protect-ffi/src/errors.ts', diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml index ddbf73c2c..f5a3254ce 100644 --- a/.github/workflows/integration-drizzle.yml +++ b/.github/workflows/integration-drizzle.yml @@ -104,8 +104,22 @@ on: # `.github/actions/build-ffi-binding` hash them, so an edit here misses # that cache and rebuilds the bundle the `integration/wasm/**` suites # load. Same argument as the two entries above, same guard. - - 'packages/eql/crates/**' + # Only the eql-bindings files cargo compiles into the binding, as + # the cache key lists them: not its tests, docs or generated + # bindings, not the `v4` / `encryption` modules behind a feature + # protect-ffi never enables, and not the other EQL crates. + # ffi-binding-action.test.mjs derives the list from the manifests. + - 'packages/eql/crates/eql-bindings/Cargo.toml' + - 'packages/eql/crates/eql-bindings/src/lib.rs' + - 'packages/eql/crates/eql-bindings/src/sql.rs' + - 'packages/eql/crates/eql-bindings/src/from_v2/**' + - 'packages/eql/crates/eql-bindings/src/v3/**' + - 'packages/eql/crates/eql-bindings/sql/**' - 'packages/eql/Cargo.toml' + # The SQL this suite installs. `stash eql install` and the Prisma + # Next baseline migration read the bundle from `@cipherstash/eql`, a + # workspace package, so an EQL release bump edits only this directory. + - 'packages/eql/packages/eql/**' pull_request: branches: ['**'] # Repeated verbatim: GitHub Actions does not support YAML anchors/aliases. @@ -198,8 +212,22 @@ on: # `.github/actions/build-ffi-binding` hash them, so an edit here misses # that cache and rebuilds the bundle the `integration/wasm/**` suites # load. Same argument as the two entries above, same guard. - - 'packages/eql/crates/**' + # Only the eql-bindings files cargo compiles into the binding, as + # the cache key lists them: not its tests, docs or generated + # bindings, not the `v4` / `encryption` modules behind a feature + # protect-ffi never enables, and not the other EQL crates. + # ffi-binding-action.test.mjs derives the list from the manifests. + - 'packages/eql/crates/eql-bindings/Cargo.toml' + - 'packages/eql/crates/eql-bindings/src/lib.rs' + - 'packages/eql/crates/eql-bindings/src/sql.rs' + - 'packages/eql/crates/eql-bindings/src/from_v2/**' + - 'packages/eql/crates/eql-bindings/src/v3/**' + - 'packages/eql/crates/eql-bindings/sql/**' - 'packages/eql/Cargo.toml' + # The SQL this suite installs. `stash eql install` and the Prisma + # Next baseline migration read the bundle from `@cipherstash/eql`, a + # workspace package, so an EQL release bump edits only this directory. + - 'packages/eql/packages/eql/**' jobs: integration: diff --git a/.github/workflows/integration-prisma-next.yml b/.github/workflows/integration-prisma-next.yml index ed74631f2..a92adb003 100644 --- a/.github/workflows/integration-prisma-next.yml +++ b/.github/workflows/integration-prisma-next.yml @@ -56,12 +56,26 @@ on: # EQL wire types are linked into the `index.node` this job encrypts # through. The native cache key in `.github/actions/build-ffi-binding` # hashes both, so an edit here misses that cache and the binding gets - # rebuilt — correctly. Without these two entries the miss never happens, + # rebuilt — correctly. Without these entries the miss never happens, # because the workflow does not trigger at all: a payload-encoding change # under packages/eql/crates merges with this suite green by absence. # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'packages/eql/crates/**' + # Only the eql-bindings files cargo compiles into the binding, as + # the cache key lists them: not its tests, docs or generated + # bindings, not the `v4` / `encryption` modules behind a feature + # protect-ffi never enables, and not the other EQL crates. + # ffi-binding-action.test.mjs derives the list from the manifests. + - 'packages/eql/crates/eql-bindings/Cargo.toml' + - 'packages/eql/crates/eql-bindings/src/lib.rs' + - 'packages/eql/crates/eql-bindings/src/sql.rs' + - 'packages/eql/crates/eql-bindings/src/from_v2/**' + - 'packages/eql/crates/eql-bindings/src/v3/**' + - 'packages/eql/crates/eql-bindings/sql/**' - 'packages/eql/Cargo.toml' + # The SQL this suite installs. `stash eql install` and the Prisma + # Next baseline migration read the bundle from `@cipherstash/eql`, a + # workspace package, so an EQL release bump edits only this directory. + - 'packages/eql/packages/eql/**' pull_request: branches: ['**'] # Repeated verbatim: GitHub Actions does not support YAML anchors/aliases. @@ -104,12 +118,26 @@ on: # EQL wire types are linked into the `index.node` this job encrypts # through. The native cache key in `.github/actions/build-ffi-binding` # hashes both, so an edit here misses that cache and the binding gets - # rebuilt — correctly. Without these two entries the miss never happens, + # rebuilt — correctly. Without these entries the miss never happens, # because the workflow does not trigger at all: a payload-encoding change # under packages/eql/crates merges with this suite green by absence. # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'packages/eql/crates/**' + # Only the eql-bindings files cargo compiles into the binding, as + # the cache key lists them: not its tests, docs or generated + # bindings, not the `v4` / `encryption` modules behind a feature + # protect-ffi never enables, and not the other EQL crates. + # ffi-binding-action.test.mjs derives the list from the manifests. + - 'packages/eql/crates/eql-bindings/Cargo.toml' + - 'packages/eql/crates/eql-bindings/src/lib.rs' + - 'packages/eql/crates/eql-bindings/src/sql.rs' + - 'packages/eql/crates/eql-bindings/src/from_v2/**' + - 'packages/eql/crates/eql-bindings/src/v3/**' + - 'packages/eql/crates/eql-bindings/sql/**' - 'packages/eql/Cargo.toml' + # The SQL this suite installs. `stash eql install` and the Prisma + # Next baseline migration read the bundle from `@cipherstash/eql`, a + # workspace package, so an EQL release bump edits only this directory. + - 'packages/eql/packages/eql/**' jobs: integration: diff --git a/.github/workflows/integration-protect-ffi.yml b/.github/workflows/integration-protect-ffi.yml index 8b8f4ff03..909f5bca8 100644 --- a/.github/workflows/integration-protect-ffi.yml +++ b/.github/workflows/integration-protect-ffi.yml @@ -85,7 +85,17 @@ on: # cache and rebuilds the binding — without these entries it would be # rebuilt and no suite that loads it would start. Pinned by # scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'packages/eql/crates/**' + # Only the eql-bindings files cargo compiles into the binding, as + # the cache key lists them: not its tests, docs or generated + # bindings, not the `v4` / `encryption` modules behind a feature + # protect-ffi never enables, and not the other EQL crates. + # ffi-binding-action.test.mjs derives the list from the manifests. + - 'packages/eql/crates/eql-bindings/Cargo.toml' + - 'packages/eql/crates/eql-bindings/src/lib.rs' + - 'packages/eql/crates/eql-bindings/src/sql.rs' + - 'packages/eql/crates/eql-bindings/src/from_v2/**' + - 'packages/eql/crates/eql-bindings/src/v3/**' + - 'packages/eql/crates/eql-bindings/sql/**' - 'packages/eql/Cargo.toml' - '.github/workflows/integration-protect-ffi.yml' - '.github/actions/build-ffi-binding/**' @@ -124,7 +134,17 @@ on: # cache and rebuilds the binding — without these entries it would be # rebuilt and no suite that loads it would start. Pinned by # scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'packages/eql/crates/**' + # Only the eql-bindings files cargo compiles into the binding, as + # the cache key lists them: not its tests, docs or generated + # bindings, not the `v4` / `encryption` modules behind a feature + # protect-ffi never enables, and not the other EQL crates. + # ffi-binding-action.test.mjs derives the list from the manifests. + - 'packages/eql/crates/eql-bindings/Cargo.toml' + - 'packages/eql/crates/eql-bindings/src/lib.rs' + - 'packages/eql/crates/eql-bindings/src/sql.rs' + - 'packages/eql/crates/eql-bindings/src/from_v2/**' + - 'packages/eql/crates/eql-bindings/src/v3/**' + - 'packages/eql/crates/eql-bindings/sql/**' - 'packages/eql/Cargo.toml' - '.github/workflows/integration-protect-ffi.yml' - '.github/actions/build-ffi-binding/**' diff --git a/.github/workflows/integration-supabase.yml b/.github/workflows/integration-supabase.yml index 16d4da21e..90917bd8b 100644 --- a/.github/workflows/integration-supabase.yml +++ b/.github/workflows/integration-supabase.yml @@ -83,12 +83,26 @@ on: # EQL wire types are linked into the `index.node` this job encrypts # through. The native cache key in `.github/actions/build-ffi-binding` # hashes both, so an edit here misses that cache and the binding gets - # rebuilt — correctly. Without these two entries the miss never happens, + # rebuilt — correctly. Without these entries the miss never happens, # because the workflow does not trigger at all: a payload-encoding change # under packages/eql/crates merges with this suite green by absence. # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'packages/eql/crates/**' + # Only the eql-bindings files cargo compiles into the binding, as + # the cache key lists them: not its tests, docs or generated + # bindings, not the `v4` / `encryption` modules behind a feature + # protect-ffi never enables, and not the other EQL crates. + # ffi-binding-action.test.mjs derives the list from the manifests. + - 'packages/eql/crates/eql-bindings/Cargo.toml' + - 'packages/eql/crates/eql-bindings/src/lib.rs' + - 'packages/eql/crates/eql-bindings/src/sql.rs' + - 'packages/eql/crates/eql-bindings/src/from_v2/**' + - 'packages/eql/crates/eql-bindings/src/v3/**' + - 'packages/eql/crates/eql-bindings/sql/**' - 'packages/eql/Cargo.toml' + # The SQL this suite installs. `stash eql install` and the Prisma + # Next baseline migration read the bundle from `@cipherstash/eql`, a + # workspace package, so an EQL release bump edits only this directory. + - 'packages/eql/packages/eql/**' pull_request: branches: ['**'] paths: @@ -163,12 +177,26 @@ on: # EQL wire types are linked into the `index.node` this job encrypts # through. The native cache key in `.github/actions/build-ffi-binding` # hashes both, so an edit here misses that cache and the binding gets - # rebuilt — correctly. Without these two entries the miss never happens, + # rebuilt — correctly. Without these entries the miss never happens, # because the workflow does not trigger at all: a payload-encoding change # under packages/eql/crates merges with this suite green by absence. # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'packages/eql/crates/**' + # Only the eql-bindings files cargo compiles into the binding, as + # the cache key lists them: not its tests, docs or generated + # bindings, not the `v4` / `encryption` modules behind a feature + # protect-ffi never enables, and not the other EQL crates. + # ffi-binding-action.test.mjs derives the list from the manifests. + - 'packages/eql/crates/eql-bindings/Cargo.toml' + - 'packages/eql/crates/eql-bindings/src/lib.rs' + - 'packages/eql/crates/eql-bindings/src/sql.rs' + - 'packages/eql/crates/eql-bindings/src/from_v2/**' + - 'packages/eql/crates/eql-bindings/src/v3/**' + - 'packages/eql/crates/eql-bindings/sql/**' - 'packages/eql/Cargo.toml' + # The SQL this suite installs. `stash eql install` and the Prisma + # Next baseline migration read the bundle from `@cipherstash/eql`, a + # workspace package, so an EQL release bump edits only this directory. + - 'packages/eql/packages/eql/**' jobs: integration: diff --git a/.github/workflows/prisma-example-readme-e2e.yml b/.github/workflows/prisma-example-readme-e2e.yml index 9869d8470..badeab957 100644 --- a/.github/workflows/prisma-example-readme-e2e.yml +++ b/.github/workflows/prisma-example-readme-e2e.yml @@ -39,12 +39,26 @@ on: # EQL wire types are linked into the `index.node` this job encrypts # through. The native cache key in `.github/actions/build-ffi-binding` # hashes both, so an edit here misses that cache and the binding gets - # rebuilt — correctly. Without these two entries the miss never happens, + # rebuilt — correctly. Without these entries the miss never happens, # because the workflow does not trigger at all: a payload-encoding change # under packages/eql/crates merges with this walkthrough green by absence. # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'packages/eql/crates/**' + # Only the eql-bindings files cargo compiles into the binding, as + # the cache key lists them: not its tests, docs or generated + # bindings, not the `v4` / `encryption` modules behind a feature + # protect-ffi never enables, and not the other EQL crates. + # ffi-binding-action.test.mjs derives the list from the manifests. + - 'packages/eql/crates/eql-bindings/Cargo.toml' + - 'packages/eql/crates/eql-bindings/src/lib.rs' + - 'packages/eql/crates/eql-bindings/src/sql.rs' + - 'packages/eql/crates/eql-bindings/src/from_v2/**' + - 'packages/eql/crates/eql-bindings/src/v3/**' + - 'packages/eql/crates/eql-bindings/sql/**' - 'packages/eql/Cargo.toml' + # The SQL this suite installs. `stash eql install` and the Prisma + # Next baseline migration read the bundle from `@cipherstash/eql`, a + # workspace package, so an EQL release bump edits only this directory. + - 'packages/eql/packages/eql/**' pull_request: branches: - '**' @@ -72,12 +86,26 @@ on: # EQL wire types are linked into the `index.node` this job encrypts # through. The native cache key in `.github/actions/build-ffi-binding` # hashes both, so an edit here misses that cache and the binding gets - # rebuilt — correctly. Without these two entries the miss never happens, + # rebuilt — correctly. Without these entries the miss never happens, # because the workflow does not trigger at all: a payload-encoding change # under packages/eql/crates merges with this walkthrough green by absence. # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'packages/eql/crates/**' + # Only the eql-bindings files cargo compiles into the binding, as + # the cache key lists them: not its tests, docs or generated + # bindings, not the `v4` / `encryption` modules behind a feature + # protect-ffi never enables, and not the other EQL crates. + # ffi-binding-action.test.mjs derives the list from the manifests. + - 'packages/eql/crates/eql-bindings/Cargo.toml' + - 'packages/eql/crates/eql-bindings/src/lib.rs' + - 'packages/eql/crates/eql-bindings/src/sql.rs' + - 'packages/eql/crates/eql-bindings/src/from_v2/**' + - 'packages/eql/crates/eql-bindings/src/v3/**' + - 'packages/eql/crates/eql-bindings/sql/**' - 'packages/eql/Cargo.toml' + # The SQL this suite installs. `stash eql install` and the Prisma + # Next baseline migration read the bundle from `@cipherstash/eql`, a + # workspace package, so an EQL release bump edits only this directory. + - 'packages/eql/packages/eql/**' jobs: walkthrough: diff --git a/.github/workflows/prisma-next-e2e.yml b/.github/workflows/prisma-next-e2e.yml index 01c68f928..2d0e3daec 100644 --- a/.github/workflows/prisma-next-e2e.yml +++ b/.github/workflows/prisma-next-e2e.yml @@ -38,12 +38,26 @@ on: # EQL wire types are linked into the `index.node` this job encrypts # through. The native cache key in `.github/actions/build-ffi-binding` # hashes both, so an edit here misses that cache and the binding gets - # rebuilt — correctly. Without these two entries the miss never happens, + # rebuilt — correctly. Without these entries the miss never happens, # because the workflow does not trigger at all: a payload-encoding change # under packages/eql/crates merges with this suite green by absence. # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'packages/eql/crates/**' + # Only the eql-bindings files cargo compiles into the binding, as + # the cache key lists them: not its tests, docs or generated + # bindings, not the `v4` / `encryption` modules behind a feature + # protect-ffi never enables, and not the other EQL crates. + # ffi-binding-action.test.mjs derives the list from the manifests. + - 'packages/eql/crates/eql-bindings/Cargo.toml' + - 'packages/eql/crates/eql-bindings/src/lib.rs' + - 'packages/eql/crates/eql-bindings/src/sql.rs' + - 'packages/eql/crates/eql-bindings/src/from_v2/**' + - 'packages/eql/crates/eql-bindings/src/v3/**' + - 'packages/eql/crates/eql-bindings/sql/**' - 'packages/eql/Cargo.toml' + # The SQL this suite installs. `stash eql install` and the Prisma + # Next baseline migration read the bundle from `@cipherstash/eql`, a + # workspace package, so an EQL release bump edits only this directory. + - 'packages/eql/packages/eql/**' pull_request: branches: - '**' @@ -72,12 +86,26 @@ on: # EQL wire types are linked into the `index.node` this job encrypts # through. The native cache key in `.github/actions/build-ffi-binding` # hashes both, so an edit here misses that cache and the binding gets - # rebuilt — correctly. Without these two entries the miss never happens, + # rebuilt — correctly. Without these entries the miss never happens, # because the workflow does not trigger at all: a payload-encoding change # under packages/eql/crates merges with this suite green by absence. # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'packages/eql/crates/**' + # Only the eql-bindings files cargo compiles into the binding, as + # the cache key lists them: not its tests, docs or generated + # bindings, not the `v4` / `encryption` modules behind a feature + # protect-ffi never enables, and not the other EQL crates. + # ffi-binding-action.test.mjs derives the list from the manifests. + - 'packages/eql/crates/eql-bindings/Cargo.toml' + - 'packages/eql/crates/eql-bindings/src/lib.rs' + - 'packages/eql/crates/eql-bindings/src/sql.rs' + - 'packages/eql/crates/eql-bindings/src/from_v2/**' + - 'packages/eql/crates/eql-bindings/src/v3/**' + - 'packages/eql/crates/eql-bindings/sql/**' - 'packages/eql/Cargo.toml' + # The SQL this suite installs. `stash eql install` and the Prisma + # Next baseline migration read the bundle from `@cipherstash/eql`, a + # workspace package, so an EQL release bump edits only this directory. + - 'packages/eql/packages/eql/**' jobs: e2e: diff --git a/.github/workflows/tests-bench.yml b/.github/workflows/tests-bench.yml index 1e97e64cc..dec0e785c 100644 --- a/.github/workflows/tests-bench.yml +++ b/.github/workflows/tests-bench.yml @@ -20,6 +20,9 @@ on: - 'languages/typescript/packages/stack-drizzle/**' - 'languages/typescript/packages/test-kit/**' - 'languages/typescript/packages/cli/src/installer/**' + # The SQL the installer applies comes from `@cipherstash/eql`, a + # workspace package; an EQL release bump edits only this directory. + - 'packages/eql/packages/eql/**' - 'local/**' - '.github/workflows/tests-bench.yml' - '.github/actions/integration-setup/**' @@ -38,6 +41,9 @@ on: - 'languages/typescript/packages/stack-drizzle/**' - 'languages/typescript/packages/test-kit/**' - 'languages/typescript/packages/cli/src/installer/**' + # The SQL the installer applies comes from `@cipherstash/eql`, a + # workspace package; an EQL release bump edits only this directory. + - 'packages/eql/packages/eql/**' - 'local/**' - '.github/workflows/tests-bench.yml' - '.github/actions/integration-setup/**' diff --git a/.github/workflows/tests-rust.yml b/.github/workflows/tests-rust.yml index 1a1bdc388..eac65a42c 100644 --- a/.github/workflows/tests-rust.yml +++ b/.github/workflows/tests-rust.yml @@ -29,7 +29,7 @@ on: # languages/typescript/packages/protect-ffi at all. The workspace root manifest comes with it: # cargo reads it for the crate's workspace context, so a # `.workspace = true` added to eql-bindings becomes a compile input - # with no other trace. Same two entries as the native cache key in + # with no other trace. The same entries as the native cache key in # `.github/actions/build-ffi-binding`, which is where the list is derived # from — pinned by # scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. @@ -37,7 +37,17 @@ on: # This is the only copy: `push:` below is `branches: [main]` with no # `paths:`, so main runs a superset of what PRs run (recorded as a # deliberate asymmetry in workflow-paths-filter-parity.test.mjs). - - 'packages/eql/crates/**' + # Only the eql-bindings files cargo compiles into the binding, as + # the cache key lists them: not its tests, docs or generated + # bindings, not the `v4` / `encryption` modules behind a feature + # protect-ffi never enables, and not the other EQL crates. + # ffi-binding-action.test.mjs derives the list from the manifests. + - 'packages/eql/crates/eql-bindings/Cargo.toml' + - 'packages/eql/crates/eql-bindings/src/lib.rs' + - 'packages/eql/crates/eql-bindings/src/sql.rs' + - 'packages/eql/crates/eql-bindings/src/from_v2/**' + - 'packages/eql/crates/eql-bindings/src/v3/**' + - 'packages/eql/crates/eql-bindings/sql/**' - 'packages/eql/Cargo.toml' - '.github/workflows/tests-rust.yml' push: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b596f07b9..f38b208a5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,8 +51,224 @@ jobs: - name: Biome check (format + lint) run: pnpm run code:check + # Which of the jobs below this pull request can affect. Runs on every event + # and must always succeed: the live jobs `needs:` it, and `tests-required` + # reads its result. + # + # The live jobs build two native bindings, start Postgres, and spend + # CipherStash credentials; until this job existed they ran on every pull + # request, including ones that changed only Go, the Stack Encrypt crates or + # EQL's SQL. The question asked here is the reverse of a `paths:` allowlist: + # the jobs run UNLESS every changed file is one they provably cannot read. + # An allowlist that forgets a directory stops a suite from running on the + # change that breaks it; this list, if it forgets one, only runs the suite + # when it did not need to. + # + # dorny/paths-filter cannot re-include a path after excluding it in one list + # (`predicate-quantifier: every` makes a file match only if it matches every + # pattern), so the question is split in two: + # `rest` — any changed file outside the excluded trees, and + # `direct` — any changed file inside them that the jobs do read. + # The jobs run when either is true. `tests-workflow-gate.test.mjs` holds + # both lists: every exclusion must be a path no gated job reads, and every + # build input of the FFI binding must be selected by one or the other. + changes: + name: Detect JS-relevant changes + runs-on: blacksmith-4vcpu-ubuntu-2404 + outputs: + js: ${{ steps.r.outputs.js }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + # Only a pull request has a base to diff against. Every other event + # takes the default below and runs everything. + - id: rest + if: github.event_name == 'pull_request' + uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3 + with: + predicate-quantifier: every + filters: | + js: + - '**' + # Plans and ADRs; plan-docs-unread-by-tests.test.mjs holds the + # plans half for the `paths:` filter above. + - '!docs/**' + # The Go module, and the Rust crates no binding these jobs build + # depends on. The auth and profile bindings compile stack-auth and + # stack-profile, which stay in. + - '!languages/golang/**' + - '!packages/stack-encrypt/**' + - '!packages/stack-encrypt-derive/**' + - '!packages/stack-kms/**' + - '!packages/stack-guest-abi/**' + - '!packages/stack-auth/fuzz/**' + # EQL's SQL sources, test suites, codegen and tasks. What the + # jobs do read from here is selected by `direct` below. + - '!packages/eql/**' + # Changeset prose. `.changeset/config.json` is read by the CLI's + # release-train test, so only the entries are excluded. + - '!.changeset/*.md' + # Other workflows. Everything that reads them runs in + # `repo-checks`, which is not gated. + - '!.github/workflows/**' + # Agent instructions, and config for Rust tools these jobs never run. + - '!AGENTS.md' + - '!CLAUDE.md' + - '!CONTEXT-MAP.md' + - '!mise.test.toml' + - '!.cargo/mutants.toml' + - '!.cargo-crap.toml' + - '!.config/nextest.toml' + - '!release-plz.toml' + - '!cliff.toml' + + - id: direct + if: github.event_name == 'pull_request' + uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3 + with: + filters: | + js: + - '.github/workflows/tests.yml' + # The eql-bindings files cargo compiles into the protect-ffi + # binding, as `.github/actions/build-ffi-binding` hashes them, + # and the EQL workspace manifest cargo reads for them. + - 'packages/eql/crates/eql-bindings/Cargo.toml' + - 'packages/eql/crates/eql-bindings/src/lib.rs' + - 'packages/eql/crates/eql-bindings/src/sql.rs' + - 'packages/eql/crates/eql-bindings/src/from_v2/**' + - 'packages/eql/crates/eql-bindings/src/v3/**' + - 'packages/eql/crates/eql-bindings/sql/**' + - 'packages/eql/Cargo.toml' + # `@cipherstash/eql`, the SQL bundle the CLI installs and the + # package `pnpm test` runs. + - 'packages/eql/packages/eql/**' + + # The event arrives through `env:`, not an interpolated `${{ }}`, so + # tests-workflow-gate.test.mjs can run this script once per event. + - id: r + env: + EVENT_NAME: ${{ github.event_name }} + REST: ${{ steps.rest.outputs.js }} + DIRECT: ${{ steps.direct.outputs.js }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != "pull_request" ]; then + echo "js=true" >> "$GITHUB_OUTPUT" + elif [ "$REST" = "true" ] || [ "$DIRECT" = "true" ]; then + echo "js=true" >> "$GITHUB_OUTPUT" + else + echo "js=false" >> "$GITHUB_OUTPUT" + fi + + # Checks of the repository itself: lint scripts, the release gate, and the + # suites that read workflow files and repo config. None needs a binding, a + # database or credentials, and between them they read nearly every file in + # the tree, so this job is never gated. They ran in `run-tests` once per Node + # leg until the live jobs were gated; a pull request changing only a + # workflow would then have run none of the guards on it. + repo-checks: + name: Repo checks + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout Repo + uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6.1.0 + name: Install pnpm + with: + run_install: false + + - name: Install Node.js + uses: actions/setup-node@v6.5.0 + with: + node-version: 22 + cache: 'pnpm' + + # node-pty's install hook falls back to `node-gyp rebuild` when no + # linux-x64 prebuild matches; pnpm/action-setup v6 no longer ships it. + - name: Install node-gyp + run: npm install -g node-gyp + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint — no hardcoded package-manager runners + run: pnpm run lint:runners + + # The typecheck gates in `run-tests` only mean something if they compile + # source. A tsconfig + # with no `include` globs `**/*`, which sweeps the package's own `dist/` + # into the program — and whether `dist/` exists during a gate depends on + # what an earlier step built transitively, so the gate compiles a + # different program in CI than it does locally. + - name: Lint — typecheck gates compile source, not build output + run: pnpm run lint:typecheck-scope + + # Deleting or renaming a package leaves `packages/` references + # dangling in docs and CI config. Nothing else catches it (#760 review). + - name: Lint — no references to deleted package directories + run: pnpm run lint:package-paths + + # `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, + # it passes every suite, and it fails in a customer's database. + - name: Lint — EQL resolves in-tree, not from a registry + run: pnpm run lint:eql-pins + + # THE SAME GATE release.yml RUNS, one merge earlier. + # + # `changeset publish` publishes every public workspace package whose + # version is absent from npm — with no dependency ordering, and a failed + # publish returns a result rather than throwing, so it does not stop the + # siblings that depend on it. A merge can therefore ARM a release that + # cannot succeed, and this repository has done it. `@cipherstash/eql` is + # published from cipherstash/encrypt-query-language, so a version bumped + # in this workspace cannot be published from here — while `languages/typescript/packages/cli` + # and `languages/typescript/packages/stack-prisma` carry it in RUNTIME dependencies, so pnpm + # packs whatever version this tree holds into both tarballs. Bump it past + # what the registry carries and both ship a range no published version + # satisfies, in tarballs that publish fine. That is what the hand-applied + # 3.0.5 bump did; upstream has since released 3.0.5 and it cleared on its + # own. The CONDITION is what this step is for — the verdict is whatever it + # prints on the day. + # + # The gate in release.yml blocks the publish, which is the load-bearing + # half. This step is the half that says so BEFORE the merge rather than + # after it, which is the difference between a red pull request and a red + # main. Same script, same verdict, no second copy to drift. + # + # It talks to the registry, in a job that already installs the whole + # workspace from it — so this adds a class of dependency the job does not + # already have, only a few more requests. A lookup that fails for any + # reason other than a 404 throws rather than being read as "published". + - name: Lint — this tree can be published, and installed + run: pnpm run release:gate + + + - name: Test — lint script self-tests + run: pnpm run test:scripts + + # protect-ffi's JS suite reads the root workflows (lintWiring, + # integrationSuiteCi) and passes with no binding built, which is a + # property it asserts. `run-tests` runs it too, through `pnpm test`; + # it runs here as well so that a change to a workflow alone, which the + # live jobs skip, still meets it. + - name: Test — protect-ffi workflow wiring + run: pnpm exec turbo run test --filter @cipherstash/protect-ffi + + # The e2e suites that read only files: workflow hardening and the skill + # map. The rest of `e2e/` builds and runs packages, in `e2e-tests`. + - name: Test — supply-chain and skill-map checks + working-directory: e2e + run: pnpm exec vitest run tests/supply-chain.e2e.test.ts tests/skill-map-parity.e2e.test.ts + run-tests: name: Run Tests (Node ${{ matrix.node-version }}) + needs: changes + if: needs.changes.outputs.js == 'true' runs-on: blacksmith-4vcpu-ubuntu-2404 strategy: matrix: @@ -289,61 +505,6 @@ jobs: - name: Typecheck (stash init's scaffolded client) run: pnpm exec turbo run typecheck:scaffold --filter stash - - name: Lint — no hardcoded package-manager runners - run: pnpm run lint:runners - - # The gates above only mean something if they compile source. A tsconfig - # with no `include` globs `**/*`, which sweeps the package's own `dist/` - # into the program — and whether `dist/` exists during a gate depends on - # what an earlier step built transitively, so the gate compiles a - # different program in CI than it does locally. - - name: Lint — typecheck gates compile source, not build output - run: pnpm run lint:typecheck-scope - - # Deleting or renaming a package leaves `packages/` references - # dangling in docs and CI config. Nothing else catches it (#760 review). - - name: Lint — no references to deleted package directories - run: pnpm run lint:package-paths - - # `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, - # it passes every suite, and it fails in a customer's database. - - name: Lint — EQL resolves in-tree, not from a registry - run: pnpm run lint:eql-pins - - # THE SAME GATE release.yml RUNS, one merge earlier. - # - # `changeset publish` publishes every public workspace package whose - # version is absent from npm — with no dependency ordering, and a failed - # publish returns a result rather than throwing, so it does not stop the - # siblings that depend on it. A merge can therefore ARM a release that - # cannot succeed, and this repository has done it. `@cipherstash/eql` is - # published from cipherstash/encrypt-query-language, so a version bumped - # in this workspace cannot be published from here — while `languages/typescript/packages/cli` - # and `languages/typescript/packages/stack-prisma` carry it in RUNTIME dependencies, so pnpm - # packs whatever version this tree holds into both tarballs. Bump it past - # what the registry carries and both ship a range no published version - # satisfies, in tarballs that publish fine. That is what the hand-applied - # 3.0.5 bump did; upstream has since released 3.0.5 and it cleared on its - # own. The CONDITION is what this step is for — the verdict is whatever it - # prints on the day. - # - # The gate in release.yml blocks the publish, which is the load-bearing - # half. This step is the half that says so BEFORE the merge rather than - # after it, which is the difference between a red pull request and a red - # main. Same script, same verdict, no second copy to drift. - # - # It talks to the registry, in a job that already installs the whole - # workspace from it — so this adds a class of dependency the job does not - # already have, only a few more requests. A lookup that fails for any - # reason other than a 404 throws rather than being read as "published". - - name: Lint — this tree can be published, and installed - run: pnpm run release:gate - - - name: Test — lint script self-tests - run: pnpm run test:scripts - - name: Create .env file in ./languages/typescript/packages/stack/ run: | touch ./languages/typescript/packages/stack/.env @@ -386,6 +547,8 @@ jobs: e2e-tests: name: Run E2E Tests + needs: changes + if: needs.changes.outputs.js == 'true' runs-on: blacksmith-4vcpu-ubuntu-2404 # Auth-dependent suites in `e2e/` skip themselves unless these env vars @@ -467,6 +630,8 @@ jobs: # a silent fallback to the NAPI module is impossible. wasm-e2e-tests: name: Run WASM E2E Tests (Deno) + needs: changes + if: needs.changes.outputs.js == 'true' runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: @@ -610,6 +775,8 @@ jobs: run-tests-bun: name: Run Tests (Bun) + needs: changes + if: needs.changes.outputs.js == 'true' runs-on: blacksmith-4vcpu-ubuntu-2404 continue-on-error: true @@ -702,3 +869,26 @@ jobs: (cd "$dir" && bunx --bun vitest run) || true fi done + + # One check that summarises the run, for a branch rule to require. A gated + # job reports `skipped` on a pull request that cannot affect it, which a + # rule requiring that job by name would wait on forever; this job reads + # `skipped` as a pass and anything but `success` otherwise as a failure. + tests-required: + name: tests-required + needs: [lint, changes, repo-checks, run-tests, e2e-tests, wasm-e2e-tests, run-tests-bun] + if: always() + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Assert every needed job passed or was skipped + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + set -euo pipefail + echo "needed results: $RESULTS" + for r in $RESULTS; do + case "$r" in + success|skipped) ;; + *) echo "a needed job reported '$r'"; exit 1 ;; + esac + done diff --git a/AGENTS.md b/AGENTS.md index 70e6870a5..338df5c22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -252,7 +252,8 @@ EQL versions installed** in the database. rather than listing it, so a task added there tomorrow is covered. `typecheck` is the deliberate exception: it *should* run under `turbo run - typecheck`, and does, from `tests.yml`, on every PR. It needs no credentials + typecheck`, and does, from `tests.yml`, on every PR that can reach the JS + side (see "`tests.yml` gates its live jobs" below). It needs no credentials and no database, and its tsconfig sets `checkJs` so the suite's two `.cjs` fixtures are compiled too — one of them is a real `AccessKeyStrategy` call site, and a `tests/**/*.ts` scope would leave it checked by nothing but the @@ -931,6 +932,16 @@ pnpm changeset:publish `languages/typescript/packages/cli/tests/e2e/**` run via `pnpm --filter stash test:e2e` (requires a build). See `languages/typescript/packages/cli/AGENTS.md` for when to add or update them. +- **`tests.yml` gates its live jobs on what a pull request changes.** Its + `changes` job skips `run-tests`, `e2e-tests`, `wasm-e2e-tests` and + `run-tests-bun` when every changed file sits in a tree they cannot read + (the Go module, the Stack Encrypt crates, EQL's SQL and Rust outside the + compiled `eql-bindings` files, docs, other workflows). A test that reads a + path in one of those trees would be skipped on the change that breaks it: + `scripts/__tests__/tests-workflow-gate.test.mjs` scans for that and fails. + Checks that read the whole tree (the lint scripts, `test:scripts`, the + release gate, the suites that read workflow files) run in the ungated + `repo-checks` job instead. ## Bundling and Deployment Notes diff --git a/scripts/__tests__/ffi-binding-action.test.mjs b/scripts/__tests__/ffi-binding-action.test.mjs index da4f15b61..dfea543d6 100644 --- a/scripts/__tests__/ffi-binding-action.test.mjs +++ b/scripts/__tests__/ffi-binding-action.test.mjs @@ -43,6 +43,7 @@ import yaml from 'js-yaml' import { describe, expect, it } from 'vitest' import { readJsonc } from './lib/read-jsonc.mjs' import { REPO_ROOT } from './lib/repo-root.mjs' +import { compiledCrateFiles } from './lib/rust-compiled-files.mjs' const ACTION = '.github/actions/build-ffi-binding/action.yml' const RUST_WORKFLOW = '.github/workflows/tests-rust.yml' @@ -567,7 +568,7 @@ function enabledByFeatures(features, requested, defaults) { for (const [dep, depFeature] of weak) { if (deps.has(dep)) want(dep, depFeature) } - return { deps, depFeatures } + return { deps, depFeatures, features: seen } } /** @@ -590,8 +591,10 @@ function alreadyWalked(prior, wanted, defaultFeatures) { /** * Every crate `cargo build` compiles into the artifact, as repo-relative * directories, starting from the cdylib crate and following path deps - * transitively. The starting crate itself is excluded: it lives under - * `languages/typescript/packages/protect-ffi/crates/**`, which both keys already hash. + * transitively, each with the files cargo reads from it under the features it + * is built with (`compiledCrateFiles`). The starting crate itself is excluded: + * it lives under `languages/typescript/packages/protect-ffi/crates/**`, which + * both keys already hash. */ function compiledPathDependencies(rootManifestRel) { const crates = new Map() @@ -639,10 +642,22 @@ function compiledPathDependencies(rootManifestRel) { queue.push(depManifest) } } - return { - crates: [...crates.entries()].map(([dir, name]) => ({ dir, name })), - unresolved, - } + // The files of each crate cargo reads, under the union of what every + // dependent asked of it. A module behind a feature nobody enables is not + // among them, which is what lets the keys name less than the whole crate. + const withFiles = [...crates.entries()].map(([dir, name]) => { + const manifest = `${dir}/Cargo.toml` + const asked = requested.get(manifest) + const { features } = enabledByFeatures( + parseFeatures(readFileSync(resolve(REPO_ROOT, manifest), 'utf8')), + [...asked.features], + asked.defaults, + ) + const walk = compiledCrateFiles(REPO_ROOT, dir, features) + unresolved.push(...walk.unresolved) + return { dir, name, features, files: walk.files } + }) + return { crates: withFiles, unresolved } } /** @@ -794,7 +809,7 @@ describe('build-ffi-binding — the keys cover every crate the build compiles', it('found the path dependencies it means to check', () => { expect( COMPILED.unresolved, - `These manifests are named by a \`path = \` dependency and do not exist. A path dep cargo cannot resolve does not build at all, so this is a broken manifest rather than a broken guard.`, + `The walk could not follow these. A manifest named by a \`path = \` dependency that does not exist is a broken manifest, since cargo cannot build it either. Anything else is a module or include \`compiledCrateFiles\` (./lib/rust-compiled-files.mjs) cannot resolve: a \`mod\` with no file, an \`include_str!\` with a non-literal argument, an inline module declaring file modules. Teach the walk the form rather than widening the key, or the keys stop being derived from what cargo compiles.`, ).toEqual([]) expect( @@ -804,23 +819,84 @@ describe('build-ffi-binding — the keys cover every crate the build compiles', for (const crate of COMPILED.crates) { expect( - trackedUnder(crate.dir).length, - `${crate.dir} (\`${crate.name}\`) has no git-tracked files, so the coverage check below has nothing to compare against.`, + crate.files.filter((file) => file.endsWith('.rs')).length, + `The walk found no Rust source in ${crate.dir} (\`${crate.name}\`), so the coverage check below has nothing to compare against.`, ).toBeGreaterThan(0) } }) + it('the compiled-file walk drops only what a feature gates off', () => { + // The narrowing below rests on this: `eql-bindings` is compiled with no + // features, so its `stack-encrypt` modules are not compiled into the + // binding, and the keys may leave them out. If protect-ffi ever turns + // that feature on, the walk includes them and the coverage check fails + // until the keys name them, which is the direction this should fail in. + const eqlBindings = COMPILED.crates.find( + (crate) => crate.name === 'eql-bindings', + ) + expect( + eqlBindings, + 'eql-bindings is no longer a compiled path dependency of the binding. Rewrite this case around what replaced it.', + ).toBeTruthy() + const all = compiledCrateFiles( + REPO_ROOT, + eqlBindings.dir, + new Set(['stack-encrypt', 'targets']), + ) + expect(all.unresolved).toEqual([]) + const gated = all.files.filter((file) => !eqlBindings.files.includes(file)) + expect( + eqlBindings.features.has('stack-encrypt') ? [] : gated.length, + 'With every feature on, the walk should find the `v4` and `encryption` modules on top of what the binding compiles. If it finds nothing more, the feature gate is no longer being read and every module counts as compiled.', + ).not.toBe(0) + expect( + gated.every((file) => /\/src\/(v4|encryption)(\/|\.rs$)/.test(file)), + `The walk dropped files outside the modules eql-bindings gates behind \`stack-encrypt\`:\n${gated.join('\n')}`, + ).toBe(true) + }) + for (const { step, name } of RUST_CACHE_STEPS) { it(`"${name}" hashes every crate the build compiles`, () => { const patterns = hashFilesPatterns(step?.with?.key) const uncovered = COMPILED.crates.flatMap((crate) => - trackedUnder(crate.dir).filter( + crate.files.filter( (file) => !patterns.some((pattern) => globMatches(pattern, file)), ), ) expect( uncovered, - `These files are compiled into the artifact this step caches and are absent from its key, so an edit to one leaves the key unchanged, the restore hits, and the build step is skipped over a stale binary. A path dependency carries no registry checksum and does not move \`Cargo.lock\` on a source-only edit, so nothing else in the key covers it either.\nAdd a glob for each crate to the \`hashFiles(...)\` list on the "${name}" step.`, + `These files are compiled into the artifact this step caches and are absent from its key, so an edit to one leaves the key unchanged, the restore hits, and the build step is skipped over a stale binary. A path dependency carries no registry checksum and does not move \`Cargo.lock\` on a source-only edit, so nothing else in the key covers it either.\nAdd a glob covering each to the \`hashFiles(...)\` list on the "${name}" step, and the same entry to the \`paths:\` filters that wasm-build-inputs-paths-filter.test.mjs holds to this key.`, + ).toEqual([]) + }) + + it(`"${name}" hashes nothing beside those crates that cargo does not read`, () => { + // The other direction. A key naming a whole crate directory, or every + // crate beside it, hashes tests, docs, generated bindings and modules + // behind features the binding never enables; each edit to one misses the + // cache, and every path-filtered workflow listing the same glob starts + // its suites over a change that cannot reach them. That was + // \`packages/eql/crates/**\`, and an EQL v4 pull request ran every + // TypeScript integration suite. Scoped to the directories holding the + // compiled crates, and their workspace manifests. + const patterns = hashFilesPatterns(step?.with?.key) + const compiled = new Set(COMPILED.crates.flatMap((crate) => crate.files)) + const roots = new Set( + COMPILED.crates.map((crate) => workspaceRootOf(crate.dir)), + ) + const parents = [ + ...new Set(COMPILED.crates.map((crate) => dirname(crate.dir))), + ] + const extra = parents.flatMap((parent) => + trackedUnder(parent).filter( + (file) => + !compiled.has(file) && + !roots.has(file) && + patterns.some((pattern) => globMatches(pattern, file)), + ), + ) + expect( + extra, + `The "${name}" key hashes these, and cargo reads none of them when it builds the binding. Narrow the glob to the compiled files (the walk in ./lib/rust-compiled-files.mjs lists them), and narrow the \`paths:\` filters that copy it.`, ).toEqual([]) }) diff --git a/scripts/__tests__/lib/rust-compiled-files.mjs b/scripts/__tests__/lib/rust-compiled-files.mjs new file mode 100644 index 000000000..cf733d17c --- /dev/null +++ b/scripts/__tests__/lib/rust-compiled-files.mjs @@ -0,0 +1,236 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' + +/** + * The files of one library crate that `cargo build` reads when a dependent + * compiles it with a given feature set: the manifest, `build.rs`, every module + * file reached from `src/lib.rs`, and every file pulled in by `include_str!`, + * `include_bytes!` or `include!`. + * + * Why this exists: a cache key or a `paths:` filter that names a whole crate + * directory also names its tests, its docs, its generated bindings and every + * module behind a feature the dependent never turns on. `eql-bindings` is the + * case that made this matter. protect-ffi compiles it with no features, so its + * `v4` and `encryption` modules (feature `stack-encrypt`) never reach + * `index.node`, yet a pull request touching only those modules started every + * TypeScript integration suite. This walk is what lets the key and the filters + * name the compiled files instead, while a guard still fails the day a new + * module is added that they do not cover. + * + * It errs towards including. Only one attribute removes a module: a + * `#[cfg(feature = "NAME")]` directly above `mod NAME;`, for a feature not in + * `enabled`. Every other `cfg` (`test`, `not(...)`, `any(...)`, target cfgs) + * leaves the module in, and a module that is in but never compiled costs a + * spurious cache miss, not a stale binary. What it cannot follow it reports in + * `unresolved` rather than skipping, so a guard built on it fails instead of + * passing having checked less: a `mod` with no file behind it, an + * `include_str!` whose argument is not a string literal, a file it cannot read. + * + * String literals are kept as written, since attribute and include arguments + * live in them, so a `mod x;` spelled inside a string reads as a declaration. + * That errs the same way: an extra file, or an unresolved entry. + * + * `#[path = "…"]` is honoured. A module declared inline (`mod name { … }`) + * whose body declares further file modules is reported as unresolved, since + * their files resolve under a directory this walk does not track. + * + * Paths are repo-relative, with forward slashes. + */ +export function compiledCrateFiles(repoRoot, crateDir, enabled) { + const files = new Set() + const unresolved = [] + const rel = (abs) => relative(repoRoot, abs).replaceAll('\\', '/') + const crateAbs = resolve(repoRoot, crateDir) + + const add = (abs) => { + if (!existsSync(abs)) { + unresolved.push(`${rel(abs)} (does not exist)`) + return false + } + files.add(rel(abs)) + return true + } + + add(join(crateAbs, 'Cargo.toml')) + if (existsSync(join(crateAbs, 'build.rs'))) add(join(crateAbs, 'build.rs')) + + const libPath = libPathOf(readFileSync(join(crateAbs, 'Cargo.toml'), 'utf8')) + const queue = [{ abs: join(crateAbs, libPath), owner: true }] + while (queue.length > 0) { + const { abs, owner } = queue.shift() + if (files.has(rel(abs))) continue + if (!add(abs)) continue + const text = readFileSync(abs, 'utf8') + + for (const include of includes(text)) { + if (include.literal === undefined) { + unresolved.push( + `${rel(abs)}: ${include.macro}! with a non-literal argument`, + ) + continue + } + add(resolve(dirname(abs), include.literal)) + } + + // `owner`: the file owns a directory for its children — `lib.rs`, + // `main.rs` and `mod.rs` own their own directory; `foo.rs` owns `foo/`. + const childDir = owner ? dirname(abs) : abs.replace(/\.rs$/, '') + for (const decl of moduleDeclarations(text)) { + if (decl.inlineWithFileModules) { + unresolved.push( + `${rel(abs)}: inline \`mod ${decl.name} { … }\` declares file modules`, + ) + continue + } + if (decl.inline) continue + if (decl.feature !== undefined && !enabled.has(decl.feature)) continue + if (decl.path !== undefined) { + queue.push({ abs: resolve(dirname(abs), decl.path), owner: true }) + continue + } + const flat = join(childDir, `${decl.name}.rs`) + const nested = join(childDir, decl.name, 'mod.rs') + if (existsSync(flat)) queue.push({ abs: flat, owner: false }) + else if (existsSync(nested)) queue.push({ abs: nested, owner: true }) + else + unresolved.push( + `${rel(abs)}: \`mod ${decl.name};\` has no ${rel(flat)} or ${rel(nested)}`, + ) + } + } + return { files: [...files].sort(), unresolved } +} + +/** `[lib] path`, or the default `src/lib.rs`. */ +function libPathOf(manifest) { + let inLib = false + for (const raw of manifest.split('\n')) { + const line = raw.replace(/#.*$/, '').trim() + const header = /^\[([^\]]+)\]$/.exec(line) + if (header) { + inLib = header[1] === 'lib' + continue + } + const path = inLib && /^path\s*=\s*"([^"]+)"$/.exec(line) + if (path) return path[1] + } + return 'src/lib.rs' +} + +/** + * Rust source with line and block comments blanked (newlines kept), and + * string, raw-string and char literals kept verbatim, so a `//` inside a + * string is not read as a comment and a `"` char literal does not open one. + */ +export function stripComments(text) { + const rawAt = /b?r(#*)"/y + const charAt = /b?'(?:\\.[^']*|[^\\'])'/y + const at = (pattern, i) => { + pattern.lastIndex = i + return pattern.exec(text) + } + let out = '' + let i = 0 + while (i < text.length) { + const raw = at(rawAt, i) + const char = at(charAt, i) + if (text.startsWith('//', i)) { + while (i < text.length && text[i] !== '\n') i += 1 + } else if (text.startsWith('/*', i)) { + let depth = 1 + i += 2 + while (i < text.length && depth > 0) { + if (text.startsWith('/*', i)) { + depth += 1 + i += 2 + } else if (text.startsWith('*/', i)) { + depth -= 1 + i += 2 + } else { + if (text[i] === '\n') out += '\n' + i += 1 + } + } + } else if (raw && (i === 0 || !/[A-Za-z0-9_]/.test(text[i - 1]))) { + const close = `"${raw[1]}` + const end = text.indexOf(close, i + raw[0].length) + const stop = end === -1 ? text.length : end + close.length + out += text.slice(i, stop) + i = stop + } else if (char) { + out += char[0] + i += char[0].length + } else if (text[i] === '"') { + const start = i + i += 1 + while (i < text.length && text[i] !== '"') i += text[i] === '\\' ? 2 : 1 + i += 1 + out += text.slice(start, i) + } else { + out += text[i] + i += 1 + } + } + return out +} + +/** + * Every `mod NAME;` and `mod NAME { … }` in a file, with the attributes + * written directly above it. A declaration nested inside an inline module is + * not returned: its file would resolve under a directory named after the + * inline module, which this walk does not track, so the inline parent carries + * `inlineWithFileModules` instead and the caller reports it. + */ +export function moduleDeclarations(text) { + const source = stripComments(text) + const found = [] + const pattern = + /((?:#\[[^\]]*\]\s*)*)(?:pub(?:\s*\([^)]*\))?\s+)?\bmod\s+([A-Za-z_][A-Za-z0-9_]*)\s*([;{])/g + for (const match of source.matchAll(pattern)) { + const [whole, attrs, name, end] = match + const feature = /#\[\s*cfg\s*\(\s*feature\s*=\s*"([^"]+)"\s*\)\s*\]/.exec( + attrs, + )?.[1] + const path = /#\[\s*path\s*=\s*"([^"]+)"\s*\]/.exec(attrs)?.[1] + const decl = { name, feature, path, inline: end === '{', at: match.index } + if (decl.inline) { + const from = match.index + whole.length + const body = blockBody(source, from) + decl.span = [from, from + body.length] + decl.inlineWithFileModules = /\bmod\s+[A-Za-z_][A-Za-z0-9_]*\s*;/.test( + body, + ) + } + found.push(decl) + } + const spans = found.filter((decl) => decl.inline).map((decl) => decl.span) + return found + .filter( + (decl) => !spans.some(([from, to]) => decl.at >= from && decl.at < to), + ) + .map(({ at, span, ...decl }) => decl) +} + +function blockBody(source, from) { + let depth = 1 + let i = from + while (i < source.length && depth > 0) { + if (source[i] === '{') depth += 1 + else if (source[i] === '}') depth -= 1 + i += 1 + } + return source.slice(from, i - 1) +} + +/** `include_str!` / `include_bytes!` / `include!` calls and their argument. */ +export function includes(text) { + const source = stripComments(text) + return [ + ...source.matchAll( + /\b(include_str|include_bytes|include)!\s*\(\s*([^)]*)\)/g, + ), + ].map(([, macro, arg]) => ({ + macro, + literal: /^"([^"\\]*)"$/.exec(arg.trim())?.[1], + })) +} diff --git a/scripts/__tests__/rust-compiled-files.test.mjs b/scripts/__tests__/rust-compiled-files.test.mjs new file mode 100644 index 000000000..424de5083 --- /dev/null +++ b/scripts/__tests__/rust-compiled-files.test.mjs @@ -0,0 +1,153 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { + compiledCrateFiles, + includes, + moduleDeclarations, + stripComments, +} from './lib/rust-compiled-files.mjs' + +/** + * `compiledCrateFiles` decides which files of a path dependency the FFI cache + * keys and the workflow filters may leave out. Against the real tree it is + * checked by ffi-binding-action.test.mjs (and was checked once by hand against + * cargo's own dep-info for eql-bindings: the same 26 source files). These + * cases pin the forms that tree does not happen to contain today. + */ + +const root = mkdtempSync(join(tmpdir(), 'rust-compiled-files-')) +afterAll(() => rmSync(root, { recursive: true, force: true })) + +function crate(name, files) { + for (const [path, text] of Object.entries(files)) { + const full = join(root, name, path) + mkdirSync(dirname(full), { recursive: true }) + writeFileSync(full, text) + } + return name +} + +describe('compiledCrateFiles', () => { + const dir = crate('gated', { + 'Cargo.toml': '[package]\nname = "gated"\n', + 'src/lib.rs': [ + 'pub mod always;', + '#[cfg(feature = "extra")]', + 'pub mod extra;', + '#[cfg(test)]', + 'mod tests;', + '#[path = "elsewhere/renamed.rs"]', + 'mod moved;', + 'pub const SQL: &str = include_str!("../sql/bundle.sql");', + ].join('\n'), + 'src/always.rs': 'mod child;\n', + 'src/always/child.rs': '', + 'src/extra/mod.rs': 'mod deeper;\n', + 'src/extra/deeper.rs': '', + 'src/tests.rs': '', + 'src/elsewhere/renamed.rs': '', + 'sql/bundle.sql': '', + 'README.md': '', + 'tests/integration.rs': '', + }) + + it('drops a module gated on a feature that is off', () => { + const { files, unresolved } = compiledCrateFiles(root, dir, new Set()) + expect(unresolved).toEqual([]) + expect(files).toEqual( + [ + 'Cargo.toml', + 'sql/bundle.sql', + 'src/always.rs', + 'src/always/child.rs', + 'src/elsewhere/renamed.rs', + 'src/lib.rs', + 'src/tests.rs', + ].map((file) => `${dir}/${file}`), + ) + }) + + it('follows it, and its children, when the feature is on', () => { + const { files } = compiledCrateFiles(root, dir, new Set(['extra'])) + expect(files).toContain(`${dir}/src/extra/mod.rs`) + expect(files).toContain(`${dir}/src/extra/deeper.rs`) + }) + + it('reports what it cannot follow instead of skipping it', () => { + const broken = crate('broken', { + 'Cargo.toml': '[package]\nname = "broken"\n', + 'src/lib.rs': [ + 'mod missing;', + 'const X: &str = include_str!(concat!(env!("OUT_DIR"), "/x"));', + 'mod inline { mod nested; }', + ].join('\n'), + }) + const { unresolved } = compiledCrateFiles(root, broken, new Set()) + expect(unresolved).toHaveLength(3) + }) + + it('reads `[lib] path`', () => { + const custom = crate('custom', { + 'Cargo.toml': + '[package]\nname = "custom"\n\n[lib]\npath = "lib/root.rs"\n', + 'lib/root.rs': 'mod a;\n', + 'lib/a.rs': '', + }) + expect(compiledCrateFiles(root, custom, new Set()).files).toEqual([ + `${custom}/Cargo.toml`, + `${custom}/lib/a.rs`, + `${custom}/lib/root.rs`, + ]) + }) +}) + +describe('the source readers', () => { + it('ignores declarations in comments and keeps strings intact', () => { + const text = [ + '// mod commented;', + '/* mod blocked; /* nested */ still; */', + 'const S: &str = "// not a comment";', + "const C: char = '\"';", + 'const R: &str = r#"// "quoted" "#;', + 'mod real;', + ].join('\n') + const stripped = stripComments(text) + expect(stripped).toContain('"// not a comment"') + expect(stripped).toContain('r#"// "quoted" "#') + expect(moduleDeclarations(text).map((decl) => decl.name)).toEqual(['real']) + }) + + it('reads only the attributes written directly above a declaration', () => { + const decls = moduleDeclarations( + [ + '#[cfg(feature = "a")]', + 'fn f() {}', + 'mod plain;', + '#[doc(hidden)]', + '#[cfg(feature = "b")]', + 'pub(crate) mod gated;', + '#[cfg(not(feature = "c"))]', + 'mod negated;', + ].join('\n'), + ) + expect(decls.map(({ name, feature }) => ({ name, feature }))).toEqual([ + { name: 'plain', feature: undefined }, + { name: 'gated', feature: 'b' }, + { name: 'negated', feature: undefined }, + ]) + }) + + it('reads include macro arguments only when they are plain literals', () => { + expect( + includes( + 'include_str!("a.sql"); include_bytes!( "b.bin" ); include!(x);', + ), + ).toEqual([ + { macro: 'include_str', literal: 'a.sql' }, + { macro: 'include_bytes', literal: 'b.bin' }, + { macro: 'include', literal: undefined }, + ]) + }) +}) diff --git a/scripts/__tests__/tests-workflow-gate.test.mjs b/scripts/__tests__/tests-workflow-gate.test.mjs new file mode 100644 index 000000000..4526c4b8b --- /dev/null +++ b/scripts/__tests__/tests-workflow-gate.test.mjs @@ -0,0 +1,381 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, posix } from 'node:path' +import yaml from 'js-yaml' +import { describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { readWorkflow } from './lib/workflows.mjs' + +/** + * `tests.yml` runs its live jobs (two native bindings, Postgres, CipherStash + * credentials) only when a pull request changes a file they can read. The + * `changes` job decides that with two dorny/paths-filter lists: + * + * `rest` — `**` minus excluded trees, `predicate-quantifier: every`, so a + * file selects it only when it is outside every exclusion; + * `direct` — paths inside those trees that the live jobs do read. + * + * As one GitHub-style ordered list that is `[...rest, ...direct]`: later + * entries win, so a `direct` entry re-includes what a `rest` negation + * excluded. This file holds the two lists to the jobs they gate. + * + * Getting an exclusion wrong is the failure that matters. A path the live jobs + * read, excluded here, is a change that merges with every suite that would + * have caught it skipped, and a skipped job reads as green. + */ + +const WORKFLOW = '.github/workflows/tests.yml' +const wf = readWorkflow(WORKFLOW) +const changes = wf.jobs.changes +const step = (id) => changes.steps.find((candidate) => candidate.id === id) +const filterOf = (id) => yaml.load(step(id)?.with?.filters ?? '')?.js ?? [] +const REST = filterOf('rest') +const DIRECT = filterOf('direct') +const EFFECTIVE = [...REST, ...DIRECT] + +/** GitHub / picomatch-style glob, `dot: true`: `*` stops at `/`, `**` does not. */ +function globRegExp(glob) { + let source = '' + for (const token of glob.split(/(\*\*\/|\*\*|\*|\?)/)) { + if (token === '**/') source += '(?:.*/)?' + else if (token === '**') source += '.*' + else if (token === '*') source += '[^/]*' + else if (token === '?') source += '[^/]' + else source += token.replace(/[.+^${}()|[\]\\]/g, '\\$&') + } + return new RegExp(`^${source}$`) +} + +const COMPILED = EFFECTIVE.map((entry) => ({ + negated: entry.startsWith('!'), + re: globRegExp(entry.replace(/^!/, '')), +})) + +/** Whether a change to `file` alone leaves the live jobs skipped. */ +function skips(file) { + let selected = false + for (const { negated, re } of COMPILED) { + if (re.test(file)) selected = !negated + } + return !selected +} + +/** A path, or anything beneath it if it names a directory. */ +const skipsPath = (path) => + skips(path) || skips(`${path.replace(/\/$/, '')}/__probe__`) + +const GATED = Object.entries(wf.jobs).filter( + ([, job]) => job.if === "needs.changes.outputs.js == 'true'", +) +const UNGATED = ['lint', 'changes', 'repo-checks', 'tests-required'] + +describe('tests.yml — the gate itself', () => { + it('splits the question into the two lists the comments describe', () => { + expect(step('rest')?.with?.['predicate-quantifier']).toBe('every') + expect(REST[0]).toBe('**') + expect( + REST.slice(1).filter((entry) => !entry.startsWith('!')), + 'Under `predicate-quantifier: every` a positive entry after `**` intersects rather than re-includes. A path the live jobs read belongs in `direct`.', + ).toEqual([]) + expect(step('direct')?.with?.['predicate-quantifier']).toBeUndefined() + expect(DIRECT.filter((entry) => entry.startsWith('!'))).toEqual([]) + expect(DIRECT.length).toBeGreaterThan(0) + for (const id of ['rest', 'direct']) { + expect(step(id).if).toBe("github.event_name == 'pull_request'") + } + }) + + it('gates every job but the ones that read the whole tree', () => { + const jobs = Object.keys(wf.jobs) + expect( + jobs.filter((id) => !UNGATED.includes(id)).sort(), + 'Every job outside UNGATED must carry the gate. A new live job without it runs on every pull request again; a new job WITH no live dependency belongs in `repo-checks`.', + ).toEqual(GATED.map(([id]) => id).sort()) + for (const [id, job] of GATED) { + expect([job.needs].flat(), `${id} must need \`changes\``).toContain( + 'changes', + ) + } + expect(GATED.map(([id]) => id)).toContain('run-tests') + }) + + it('has `tests-required` summarise every other job', () => { + const summary = wf.jobs['tests-required'] + expect(summary.if).toBe('always()') + expect([...summary.needs].sort()).toEqual( + Object.keys(wf.jobs) + .filter((id) => id !== 'tests-required') + .sort(), + ) + }) + + describe('the decision script', () => { + const run = (env) => { + const dir = mkdtempSync(join(tmpdir(), 'tests-gate-')) + const output = join(dir, 'out') + try { + execFileSync('bash', ['-c', ': > "$GITHUB_OUTPUT"'], { + env: { PATH: process.env.PATH, GITHUB_OUTPUT: output }, + }) + execFileSync('bash', ['-c', String(step('r').run)], { + env: { PATH: process.env.PATH, GITHUB_OUTPUT: output, ...env }, + }) + return readFileSync(output, 'utf8').trim() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + } + const pr = { EVENT_NAME: 'pull_request' } + it('runs everything on any event but a pull request', () => { + expect(run({ EVENT_NAME: 'push', REST: '', DIRECT: '' })).toBe('js=true') + }) + it('runs when either list matched', () => { + expect(run({ ...pr, REST: 'true', DIRECT: 'false' })).toBe('js=true') + expect(run({ ...pr, REST: 'false', DIRECT: 'true' })).toBe('js=true') + }) + it('skips only when neither did', () => { + expect(run({ ...pr, REST: 'false', DIRECT: 'false' })).toBe('js=false') + }) + }) +}) + +/** + * What the live jobs run, as the trees their code lives in. `pnpm test` walks + * every package under `languages/typescript/packages/` and `packages/eql/`'s + * npm package; the e2e and WASM jobs run `e2e/`. + */ +const LIVE_ROOTS = ['languages/typescript', 'e2e', 'packages/eql/packages/eql'] + +/** + * Files under LIVE_ROOTS that read an excluded path, and why that is not a + * hole. Each must name a reason; the stale check fails an entry that no longer + * matches anything. + */ +const READS_EXCLUDED = new Map([ + [ + 'e2e/tests/supply-chain.e2e.test.ts', + 'Reads `.github/workflows/`. Run in `repo-checks`, which is not gated.', + ], + [ + 'languages/typescript/packages/protect-ffi/src/lintWiring.test.ts', + 'Reads `.github/workflows/`. protect-ffi’s suite also runs in `repo-checks`.', + ], + [ + 'languages/typescript/packages/protect-ffi/src/integrationSuiteCi.test.ts', + 'Reads `.github/workflows/`. protect-ffi’s suite also runs in `repo-checks`.', + ], + [ + 'packages/eql/packages/eql/scripts/sync-generated.mjs', + 'Reads `crates/eql-bindings/bindings` and `schema`. Run by `check:generated` and the release, never by `pnpm test`.', + ], +]) + +/** + * The directories a relative literal in `file` may be resolved from: its own, + * and each one above it up to its package root (the nearest `package.json`). + * Code joins from either; the protect-ffi suites join + * `../../../../.github/workflows` onto the package root. Not above it, where + * `./dist/…` in a package manifest would resolve to nonsense. + */ +function basesOf(file) { + const bases = [] + for (let dir = posix.dirname(file); ; dir = posix.dirname(dir)) { + bases.push(dir) + if (dir === '.' || PACKAGE_ROOTS.has(dir)) break + } + return bases +} + +const PACKAGE_ROOTS = new Set( + execFileSync( + 'git', + [ + 'ls-files', + '--', + ...LIVE_ROOTS.map((root) => `${root}/**/package.json`), + ...LIVE_ROOTS.map((root) => `${root}/package.json`), + ], + { + cwd: REPO_ROOT, + encoding: 'utf8', + }, + ) + .split('\n') + .filter(Boolean) + .map((manifest) => posix.dirname(manifest)), +) + +/** Source files the live jobs may execute, as opposed to prose and manifests. */ +const CODE = /\.(?:[cm]?[jt]sx?|json|ya?ml)$/ + +/** + * Every file under LIVE_ROOTS that names a path the gate skips, with the line. + * + * Text, not execution, so it sees a path written out rather than built at run + * time. A literal counts when it is relative (`../…`) and resolves to an + * excluded path from the file's directory or any directory above it (code + * often joins from its package root, not its own directory), or when it is + * repo-relative on a line that mentions a repo root. + */ +function readsOfExcludedPaths() { + const files = execFileSync('git', ['ls-files', '-z', '--', ...LIVE_ROOTS], { + cwd: REPO_ROOT, + encoding: 'utf8', + }) + .split('\0') + .filter((file) => CODE.test(file)) + const literal = /(['"`])((?:\.\.?\/)*[A-Za-z0-9_.@-][^'"`\s$]*)\1/g + const found = [] + for (const file of files) { + const text = readFileSync(join(REPO_ROOT, file), 'utf8') + const lines = text.split('\n') + lines.forEach((line, idx) => { + if (line.length > 1000) return + const rooted = /\b(?:repo|workspace|monorepo)_?root\b/i.test(line) + for (const [, , value] of line.matchAll(literal)) { + const targets = [] + if (/^\.\.?\//.test(value)) { + for (const base of basesOf(file)) { + targets.push(posix.normalize(posix.join(base, value))) + } + } else if (rooted) { + targets.push(value) + } + const hit = targets.find( + (target) => + !target.startsWith('..') && target !== '.' && skipsPath(target), + ) + if (hit) found.push({ file, line: idx + 1, value, hit }) + } + }) + } + return found +} + +describe('tests.yml — what the gate skips, no live job reads', () => { + const reads = readsOfExcludedPaths() + + it('finds no live code reading an excluded path', () => { + const unexplained = reads + .filter(({ file }) => !READS_EXCLUDED.has(file)) + .map(({ file, line, value, hit }) => `${file}:${line} ${value} (${hit})`) + expect( + unexplained, + 'These files run in a gated job and name a path the gate excludes, so a pull request changing only that path skips the job that reads it. Either take the path out of the `rest` exclusions (or add it to `direct`), or, if the read happens somewhere ungated, record where in READS_EXCLUDED.', + ).toEqual([]) + }) + + it('keeps no stale READS_EXCLUDED entries', () => { + const live = new Set(reads.map(({ file }) => file)) + expect( + [...READS_EXCLUDED.keys()].filter((file) => !live.has(file)), + 'These entries excuse nothing the scan finds any more. Delete them.', + ).toEqual([]) + }) + + it('the scan sees a read when there is one', () => { + // The protect-ffi suites join `../../../../.github/workflows` onto their + // package root, not their own directory. A scan resolving only from the + // file would miss both, and every check above would pass having seen + // nothing. + expect(reads.map(({ file }) => file)).toEqual( + expect.arrayContaining([ + 'languages/typescript/packages/protect-ffi/src/lintWiring.test.ts', + 'e2e/tests/supply-chain.e2e.test.ts', + ]), + ) + }) + + it('the readers it excuses as ungated do run in repo-checks', () => { + const runs = wf.jobs['repo-checks'].steps + .map((candidate) => String(candidate.run ?? '')) + .join('\n') + expect(runs).toMatch( + /turbo run test --filter @cipherstash\/protect-ffi(\s|$)/, + ) + expect(runs).toContain('tests/supply-chain.e2e.test.ts') + expect(runs).toContain('pnpm run test:scripts') + }) +}) + +describe('tests.yml — the bindings the live jobs build stay selected', () => { + /** + * The Rust crates behind `@cipherstash/auth` and `@cipherstash/profile`, + * which every live job compiles, found by following `path =` dependencies + * from the binding crates. Optional ones are followed too: over-reading + * here only makes the check stricter. + */ + function pathDependencyClosure(manifests) { + // `name = { workspace = true }` inherits the root's `[workspace.dependencies]` + // entry, which is where the stack-* path dependencies are written. + const inherited = new Map() + let inTable = false + for (const raw of readFileSync(join(REPO_ROOT, 'Cargo.toml'), 'utf8').split( + '\n', + )) { + const header = /^\s*\[([^\]]+)\]/.exec(raw) + if (header) inTable = header[1] === 'workspace.dependencies' + const entry = + inTable && + /^([A-Za-z0-9_-]+)\s*=\s*\{.*\bpath\s*=\s*"([^"]+)"/.exec(raw) + if (entry) inherited.set(entry[1], entry[2]) + } + const seen = new Set() + const queue = [...manifests] + while (queue.length > 0) { + const manifest = queue.shift() + const dir = posix.dirname(manifest) + if (seen.has(dir)) continue + seen.add(dir) + const text = readFileSync(join(REPO_ROOT, manifest), 'utf8') + for (const [, path] of text.matchAll(/\bpath\s*=\s*"([^"]+)"/g)) { + const target = posix.normalize(posix.join(dir, path)) + if (target.endsWith('.rs')) continue + queue.push(`${target}/Cargo.toml`) + } + for (const [, name] of text.matchAll( + /^([A-Za-z0-9_-]+)\s*=\s*\{[^}\n]*\bworkspace\s*=\s*true/gm, + )) { + const path = inherited.get(name) + if (path) queue.push(`${posix.normalize(path)}/Cargo.toml`) + } + } + return [...seen] + } + + const crates = pathDependencyClosure([ + 'languages/typescript/packages/auth/Cargo.toml', + 'languages/typescript/packages/profile/Cargo.toml', + ]) + + it('finds the crates the auth and profile bindings compile', () => { + expect(crates).toEqual( + expect.arrayContaining(['packages/stack-auth', 'packages/stack-profile']), + ) + }) + + it('excludes none of them', () => { + expect( + crates.filter((dir) => skips(`${dir}/src/lib.rs`)), + 'The live jobs compile these crates into the auth and profile bindings, and the gate would skip a change to them.', + ).toEqual([]) + }) + + it('keeps the root Cargo manifests and toolchain pins', () => { + for (const file of [ + 'Cargo.toml', + 'Cargo.lock', + '.cargo/config.toml', + 'mise.toml', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'package.json', + 'turbo.json', + '.github/workflows/tests.yml', + '.github/actions/build-ffi-binding/action.yml', + ]) { + expect(skips(file), `${file} must start the live jobs`).toBe(false) + } + }) +}) diff --git a/scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs b/scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs index 28d96ad94..457d59ef0 100644 --- a/scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs +++ b/scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs @@ -327,13 +327,39 @@ function compilesFfiRust(relPath) { * one. `tests.yml` and `tests-rust.yml` are both half-cases: `push` is * `branches: [main]` with no filter, so only their `pull_request` lists are * here. `tests.yml`'s is `'**'` with one negation, which `filterCovers` reads. + * + * `tests.yml` also gates its live jobs inside the run, through its `changes` + * job (see `dornyGate`), and that list is the one that decides whether the + * binding's suites start on a pull request. It is checked here too, as the + * `pull_request (changes job)` event. */ function filteredEvents(relPath) { const wf = readWorkflow(relPath) const on = wf?.on ?? wf?.[true] - return FILTERED_EVENTS.filter((event) => + const events = FILTERED_EVENTS.filter((event) => Array.isArray(on?.[event]?.paths), ).map((event) => ({ relPath, event, paths: on[event].paths })) + const gate = dornyGate(wf) + if (gate) + events.push({ relPath, event: 'pull_request (changes job)', paths: gate }) + return events +} + +/** + * A `changes` job splitting its question into a `rest` list (`**` minus + * exclusions, `predicate-quantifier: every`) and a `direct` list re-including + * paths inside them, as one ordered `paths:`-style list: later entries win, so + * appending `direct` re-includes exactly what it names. + * tests-workflow-gate.test.mjs holds the shape this relies on. + */ +function dornyGate(wf) { + const steps = wf?.jobs?.changes?.steps ?? [] + const list = (id) => + yaml.load(steps.find((step) => step.id === id)?.with?.filters ?? '')?.js + const rest = list('rest') + const direct = list('direct') + if (!Array.isArray(rest) || !Array.isArray(direct)) return null + return [...rest, ...direct] } const ALL_WORKFLOWS = workflowFiles() @@ -513,6 +539,37 @@ describe('the FFI cache keys and the filters that trigger them agree', () => { }) } + // The other direction, for the one directory where it has bitten. Every + // check above asks whether a filter covers the key; none asks whether it + // covers much more. `packages/eql/crates/**` sat in these filters, matching + // eql-codegen, the EQL v4 modules eql-bindings gates behind a feature + // protect-ffi never enables, and the crate's tests and docs, so an EQL v4 + // pull request started every suite here over files the binding does not + // compile. The key now names the compiled files (held to them by + // ffi-binding-action.test.mjs); a filter entry under the EQL crates must be + // one of its entries, not a wider glob. + const EQL_CRATES = 'packages/eql/crates' + const nativeInputs = new Set(buildInputs(NATIVE_CACHE_STEP)) + for (const { relPath, event, paths } of KEYS[0].workflows.flatMap( + filteredEvents, + )) { + it(`${relPath} (${event}) names no EQL crate path the binding does not compile`, () => { + const wider = paths.filter( + (entry) => + !entry.startsWith('!') && + (literalPrefix(entry) === EQL_CRATES || + literalPrefix(entry).startsWith(`${EQL_CRATES}/`) || + EQL_CRATES.startsWith(`${literalPrefix(entry)}/`)) && + literalPrefix(entry) !== '' && + !nativeInputs.has(entry), + ) + expect( + wider, + `These \`${event}\` filter entries in ${relPath} reach into ${EQL_CRATES}/ beyond what the native cache key hashes, so an edit there starts this workflow over code the binding does not compile. Copy the key's eql-bindings entries instead.`, + ).toEqual([]) + }) + } + it('keeps no stale known-gap entries', () => { // The mirror of the checks above, and the one that matters when a gap is // FIXED: an entry that no longer describes an uncovered input sits there