From 9022e16e42c8f911bed88e3930a934be60875645 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:16:04 +0000 Subject: [PATCH 01/20] build(deps-dev): bump js-yaml from 4.3.0 to 4.3.1 Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.0 to 4.3.1. - [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.1/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.3.0...4.3.1) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.3.1 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- package.json | 2 +- pnpm-lock.yaml | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index a8d183446..845226440 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "devDependencies": { "@changesets/cli": "^2.31.0", "@types/node": "^22.13.14", - "js-yaml": "^4.1.0", + "js-yaml": "^4.3.1", "tsup": "^8.5.0", "typescript": "^5.8.3", "vitest": "^3.2.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6bb3277c..f3c8fb98a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^22.13.14 version: 22.20.0 js-yaml: - specifier: ^4.1.0 - version: 4.3.0 + specifier: ^4.3.1 + version: 4.3.1 tsup: specifier: ^8.5.0 version: 8.5.1(postcss@8.5.16)(typescript@5.9.3) @@ -876,12 +876,12 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@3.15.0: - resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + js-yaml@3.15.1: + resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} hasBin: true - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsonfile@4.0.0: @@ -1401,7 +1401,7 @@ snapshots: '@changesets/parse@0.4.3': dependencies: '@changesets/types': 6.1.0 - js-yaml: 4.3.0 + js-yaml: 4.3.1 '@changesets/pre@2.0.2': dependencies: @@ -2010,12 +2010,12 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@3.15.0: + js-yaml@3.15.1: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -2138,7 +2138,7 @@ snapshots: read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 - js-yaml: 3.15.0 + js-yaml: 3.15.1 pify: 4.0.1 strip-bom: 3.0.0 From 142f41d88319b5dff7155ca57db7259f012f29b9 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Mon, 17 Aug 2026 10:33:43 +1000 Subject: [PATCH 02/20] fix(json): restore deprecated ste_vec_contains aliases --- .changeset/rename-ste-vec-contains.md | 10 +++--- src/v3/json/functions.sql | 32 +++++++++++++++++++ tasks/docs/doxygen-filter.sh | 22 ++++++++++--- tasks/test/docs_v3_grep.sh | 13 +++++++- .../sqlx/snapshots/eql_v3_public_surface.txt | 2 ++ tests/sqlx/tests/v3_jsonb_tests.rs | 23 +++++++++++++ 6 files changed, 93 insertions(+), 9 deletions(-) diff --git a/.changeset/rename-ste-vec-contains.md b/.changeset/rename-ste-vec-contains.md index 4585af313..1b1b25c53 100644 --- a/.changeset/rename-ste-vec-contains.md +++ b/.changeset/rename-ste-vec-contains.md @@ -1,10 +1,12 @@ --- -'@cipherstash/eql': major +'@cipherstash/eql': patch --- **`eql_v3.ste_vec_contains` is renamed to `eql_v3.jsonb_document_contains`.** This consolidates the last `ste_vec_*`-named public object into the `jsonb_*` family, matching the earlier renames of the SteVec entry/query surface (`jsonb_entry`, -`jsonb_query`). The function backs the `json` `@>` / `<@` containment operators; -its behaviour is unchanged. Callers that invoke the function by name (Supabase / -PostgREST, which call functions rather than operators) must update the name. +`jsonb_query`). The old name remains available as a deprecated compatibility +alias, so existing direct callers continue to work; new code should use +`jsonb_document_contains`. The `json` `@>` / `<@` containment operators and the +raw-jsonb function-form entry points, `eql_v3.jsonb_contains(jsonb, jsonb)` and +`eql_v3.jsonb_contained_by(jsonb, jsonb)`, are unchanged. diff --git a/src/v3/json/functions.sql b/src/v3/json/functions.sql index e5770dde7..fa8acdc2f 100644 --- a/src/v3/json/functions.sql +++ b/src/v3/json/functions.sql @@ -349,6 +349,38 @@ AS $$ @> eql_v3.to_ste_vec_query(b)::jsonb $$; +--! @cond deprecated_compatibility +--! @brief Deprecated compatibility alias for jsonb_document_contains(jsonb[], jsonb). +--! @deprecated Use eql_v3.jsonb_document_contains(jsonb[], jsonb). +--! @param a jsonb[] sv array to search within. +--! @param b jsonb sv element to search for. +--! @return boolean True if b's selector is present in any element of a. +CREATE FUNCTION eql_v3.ste_vec_contains(a jsonb[], b jsonb) + RETURNS boolean + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ + SELECT eql_v3.jsonb_document_contains(a, b) +$$; + +COMMENT ON FUNCTION eql_v3.ste_vec_contains(jsonb[], jsonb) IS + 'DEPRECATED: use eql_v3.jsonb_document_contains(jsonb[], jsonb)'; + +--! @brief Deprecated compatibility alias for jsonb_document_contains(json_search, json_search). +--! @deprecated Use eql_v3.jsonb_document_contains(public.eql_v3_json_search, public.eql_v3_json_search). +--! @param a public.eql_v3_json_search Container. +--! @param b public.eql_v3_json_search Elements to find. +--! @return boolean True if all elements of b are contained in a. +CREATE FUNCTION eql_v3.ste_vec_contains(a public.eql_v3_json_search, b public.eql_v3_json_search) + RETURNS boolean + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ + SELECT eql_v3.jsonb_document_contains(a, b) +$$; + +COMMENT ON FUNCTION eql_v3.ste_vec_contains(public.eql_v3_json_search, public.eql_v3_json_search) IS + 'DEPRECATED: use eql_v3.jsonb_document_contains(json_search, json_search)'; +--! @endcond + ------------------------------------------------------------------------------ -- Path queries (text selector only) ------------------------------------------------------------------------------ diff --git a/tasks/docs/doxygen-filter.sh b/tasks/docs/doxygen-filter.sh index 0b5313bdb..fe00c19ab 100755 --- a/tasks/docs/doxygen-filter.sh +++ b/tasks/docs/doxygen-filter.sh @@ -10,10 +10,13 @@ if [ "$#" -ne 1 ]; then exit 2 fi -# Prepares SQL for Doxygen's C++ parser. Three transforms: +# Prepares SQL for Doxygen's C++ parser. Four transforms: # -# 1. `--!` doc comments -> `//!` so Doxygen sees them. -# 2. Strip dollar-quoted function bodies (`$$ ... $$`), leaving just the +# 1. Omit blocks marked `@cond deprecated_compatibility`. These functions +# remain callable for backwards compatibility but must not appear in the +# generated function reference or source browser. +# 2. `--!` doc comments -> `//!` so Doxygen sees them. +# 3. Strip dollar-quoted function bodies (`$$ ... $$`), leaving just the # declaration and its trailing clauses. Doxygen parses SQL heuristically as # C++, and body SQL derails it: a `::type` cast reads as C++ scope # resolution and drops the whole enclosing CREATE FUNCTION memberdef (this @@ -23,7 +26,7 @@ fi # documentation, so removing them is lossless for the generated reference # and leaves Doxygen only clean `CREATE FUNCTION name(args) RETURNS ...` # declarations to read. Only bare `$$` quoting is used in this codebase. -# 3. Strip CREATE AGGREGATE definition bodies, for the same reason and with the +# 4. Strip CREATE AGGREGATE definition bodies, for the same reason and with the # same losslessness: `sfunc`/`stype`/`combinefunc`/`parallel` carry no # documentation. The trailing `( ... )` is a SECOND parenthesised group # after the signature, and C++ has no such form, so Doxygen misreads the @@ -39,6 +42,17 @@ fi # Reducing each to a single `CREATE AGGREGATE name(argtype);` declaration # recovers the name in both cases and leaves a clean argument list. awk ' + /^--![[:space:]]+@cond[[:space:]]+deprecated_compatibility[[:space:]]*$/ { + inhidden = 1 + print "" + next + } + inhidden && /^--![[:space:]]+@endcond[[:space:]]*$/ { + inhidden = 0 + print "" + next + } + inhidden { print ""; next } /^--!/ { print "//!" substr($0, 4); next } # Emit the signature up to its balanced closing paren, then skip the body. !inagg && /^[[:space:]]*CREATE[[:space:]]+AGGREGATE/ { diff --git a/tasks/test/docs_v3_grep.sh b/tasks/test/docs_v3_grep.sh index 8eeff97a3..fdd6ba0a0 100755 --- a/tasks/test/docs_v3_grep.sh +++ b/tasks/test/docs_v3_grep.sh @@ -62,8 +62,19 @@ for f in "${DOC_FILES[@]}"; do fi done +# Deprecated SQL compatibility aliases remain installed, but the explicitly +# hidden block must never reach Doxygen — including its generated source browser. +ste_vec_alias_count=$(grep -c '^CREATE FUNCTION eql_v3\.ste_vec_contains' src/v3/json/functions.sql || true) +if [ "$ste_vec_alias_count" -ne 2 ]; then + echo "FAIL: expected both deprecated ste_vec_contains overloads in src/v3/json/functions.sql" >&2 + status=1 +elif tasks/docs/doxygen-filter.sh src/v3/json/functions.sql | grep -q 'ste_vec_contains'; then + echo "FAIL: deprecated ste_vec_contains aliases leaked through the Doxygen input filter" >&2 + status=1 +fi + if [ "$status" -eq 0 ]; then - echo "OK: no user-facing doc references eql_v2 (${#DOC_FILES[@]} files scanned)." + echo "OK: user-facing docs contain neither eql_v2 nor the hidden ste_vec_contains alias (${#DOC_FILES[@]} files scanned)." else echo >&2 echo "The eql_v2 surface was removed in 3.0.0; user-facing docs must teach only eql_v3." >&2 diff --git a/tests/sqlx/snapshots/eql_v3_public_surface.txt b/tests/sqlx/snapshots/eql_v3_public_surface.txt index 1f7ace6ed..85c9bfaf6 100644 --- a/tests/sqlx/snapshots/eql_v3_public_surface.txt +++ b/tests/sqlx/snapshots/eql_v3_public_surface.txt @@ -1272,6 +1272,8 @@ function eql_v3.ord_term_ore(a public.eql_v3_timestamp_ord_ore) function eql_v3.selector(entry public.eql_v3_json_entry) function eql_v3.selector(val jsonb) function eql_v3.ste_vec(val jsonb) +function eql_v3.ste_vec_contains(a jsonb[], b jsonb) +function eql_v3.ste_vec_contains(a public.eql_v3_json_search, b public.eql_v3_json_search) function eql_v3.to_ste_vec_query(e eql_v3.query_json) function eql_v3.to_ste_vec_query(e public.eql_v3_json_search) function eql_v3.version() diff --git a/tests/sqlx/tests/v3_jsonb_tests.rs b/tests/sqlx/tests/v3_jsonb_tests.rs index 97741b4e2..2eab32842 100644 --- a/tests/sqlx/tests/v3_jsonb_tests.rs +++ b/tests/sqlx/tests/v3_jsonb_tests.rs @@ -577,6 +577,29 @@ async fn v3_jsonb_raw_helpers_contains_and_contained_by(pool: PgPool) -> anyhow: "jsonb_contains must agree with the typed @> operator" ); + let legacy_raw: bool = sqlx::query_scalar(&format!( + "SELECT eql_v3.ste_vec_contains(\ + eql_v3.jsonb_array('{full}'::jsonb), \ + (eql_v3.jsonb_array('{subset}'::jsonb))[1])" + )) + .fetch_one(&pool) + .await?; + let legacy_typed: bool = sqlx::query_scalar(&format!( + "SELECT eql_v3.ste_vec_contains(\ + '{full}'::public.eql_v3_json_search, \ + '{subset}'::public.eql_v3_json_search)" + )) + .fetch_one(&pool) + .await?; + assert!( + legacy_raw, + "legacy raw ste_vec_contains alias must remain callable" + ); + assert_eq!( + legacy_typed, typed, + "legacy typed ste_vec_contains alias must agree with jsonb containment" + ); + // Ordering terms are not equality terms. All containment entry points // normalize to selector-only matching, so two entries with the same value // selector match even when one carries a different `op`. From 600a9b2d6daa8a7493c8723dfb22dbe2cf97f285 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Mon, 17 Aug 2026 10:48:37 +1000 Subject: [PATCH 03/20] test(json): use encrypted fixture for legacy aliases --- tests/sqlx/tests/v3_jsonb_tests.rs | 53 +++++++++++++++++------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/tests/sqlx/tests/v3_jsonb_tests.rs b/tests/sqlx/tests/v3_jsonb_tests.rs index 2eab32842..de24ca55d 100644 --- a/tests/sqlx/tests/v3_jsonb_tests.rs +++ b/tests/sqlx/tests/v3_jsonb_tests.rs @@ -577,29 +577,6 @@ async fn v3_jsonb_raw_helpers_contains_and_contained_by(pool: PgPool) -> anyhow: "jsonb_contains must agree with the typed @> operator" ); - let legacy_raw: bool = sqlx::query_scalar(&format!( - "SELECT eql_v3.ste_vec_contains(\ - eql_v3.jsonb_array('{full}'::jsonb), \ - (eql_v3.jsonb_array('{subset}'::jsonb))[1])" - )) - .fetch_one(&pool) - .await?; - let legacy_typed: bool = sqlx::query_scalar(&format!( - "SELECT eql_v3.ste_vec_contains(\ - '{full}'::public.eql_v3_json_search, \ - '{subset}'::public.eql_v3_json_search)" - )) - .fetch_one(&pool) - .await?; - assert!( - legacy_raw, - "legacy raw ste_vec_contains alias must remain callable" - ); - assert_eq!( - legacy_typed, typed, - "legacy typed ste_vec_contains alias must agree with jsonb containment" - ); - // Ordering terms are not equality terms. All containment entry points // normalize to selector-only matching, so two entries with the same value // selector match even when one carries a different `op`. @@ -625,6 +602,36 @@ async fn v3_jsonb_raw_helpers_contains_and_contained_by(pool: PgPool) -> anyhow: Ok(()) } +/// Both deprecated `ste_vec_contains` overloads remain callable over a real +/// CipherStash-generated SteVec payload and agree with their replacement. +#[sqlx::test(fixtures(path = "../fixtures", scripts("v3_ste_vec")))] +async fn v3_jsonb_legacy_ste_vec_contains_aliases(pool: PgPool) -> anyhow::Result<()> { + let (legacy_raw, current_raw, legacy_typed, current_typed): (bool, bool, bool, bool) = + sqlx::query_as( + "SELECT \ + eql_v3.ste_vec_contains(\ + eql_v3.ste_vec(payload::jsonb), \ + (eql_v3.ste_vec(payload::jsonb))[1]), \ + eql_v3.jsonb_document_contains(\ + eql_v3.ste_vec(payload::jsonb), \ + (eql_v3.ste_vec(payload::jsonb))[1]), \ + eql_v3.ste_vec_contains(payload, payload), \ + eql_v3.jsonb_document_contains(payload, payload) \ + FROM fixtures.v3_ste_vec \ + ORDER BY id \ + LIMIT 1", + ) + .fetch_one(&pool) + .await?; + + assert!(legacy_raw, "legacy raw alias must find a real sv entry"); + assert_eq!(legacy_raw, current_raw); + assert!(legacy_typed, "legacy typed alias must contain itself"); + assert_eq!(legacy_typed, current_typed); + + Ok(()) +} + /// `eql_v3.ord_term(jsonb_entry)` has no `has_*` companion: absence of an /// `op` term is signalled by the extractor returning SQL NULL (which a /// functional btree index stores and comparisons skip). Dedicated From 35bce9e55fca77f51a73095962f483f4321732d6 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Mon, 17 Aug 2026 12:09:24 +1000 Subject: [PATCH 04/20] fix(ci): register restored alias coverage --- tasks/test/splinter.sh | 1 + tests/sqlx/snapshots/v3_jsonb_tests.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/tasks/test/splinter.sh b/tasks/test/splinter.sh index af7c7e98a..058689892 100755 --- a/tasks/test/splinter.sh +++ b/tasks/test/splinter.sh @@ -103,6 +103,7 @@ function_search_path_mutable eql_v3 selector function STE-vec entry selector ext function_search_path_mutable eql_v3 ope_term function STE-vec ordered-term extractor for public.eql_v3_jsonb_entry: inlinable so `eql_v3.ope_term(col -> 'sel')` folds into the calling range query and matches the functional btree index built on the same expression. SET search_path would disable SQL function inlining. The deprecated eq_term(json_entry) compatibility alias is pinned and does not surface. function_search_path_mutable eql_v3 to_ste_vec_query function Encrypted-JSONB query-document constructor (CAST WITH FUNCTION for eql_v3.query_jsonb): inlinable SQL over a public.eql_v3_json domain arg, structural domain-arg skip. Builds the ste_vec query value the @>/<@ wrappers compare against; must inline to fold into the calling query. function_search_path_mutable eql_v3 jsonb_document_contains function Typed encrypted-JSONB document containment engine backing the public.eql_v3_json_search @>/<@ operators: the inlinable SQL (public.eql_v3_json_search) overload folds to native jsonb containment over eql_v3.to_ste_vec_query(a/b)::jsonb, so the planner can match the functional GIN index on eql_v3.to_ste_vec_query(col)::jsonb. Structural domain-arg skip leaves this overload unpinned; the raw jsonb[] plpgsql overload has a fixed search_path and does not surface. +function_search_path_mutable eql_v3 ste_vec_contains function Deprecated compatibility alias for the typed eql_v3.jsonb_document_contains overload. It must remain inlinable so existing function-form queries retain the same functional-GIN-index matching as before the rename. The public.eql_v3_json_search domain arguments leave this overload intentionally unpinned; the raw jsonb[] alias is pinned by tasks/pin_search_path_v3.sql and does not surface. function_search_path_mutable eql_v3 jsonb_array function ste_vec deterministic-field array extractor on the eql_v3 encrypted-JSONB surface: public inlinable SQL (raw jsonb arg) behind the documented functional GIN index expression eql_v3.jsonb_array(col). Takes bare jsonb, so it carries the documented `eql-inline-critical` COMMENT marker that pin_search_path_v3.sql honours rather than the structural skip. function_search_path_mutable eql_v3 jsonb_contains function Public GIN-inlining containment helper (function-form of @> over raw jsonb): unfolds to eql_v3.jsonb_array(a) @> eql_v3.jsonb_array(b). Carries the `eql-inline-critical` COMMENT marker. function_search_path_mutable eql_v3 jsonb_contained_by function Public GIN-inlining reverse-containment helper (function-form of <@ over raw jsonb): same as eql_v3.jsonb_contains. diff --git a/tests/sqlx/snapshots/v3_jsonb_tests.txt b/tests/sqlx/snapshots/v3_jsonb_tests.txt index 3296365fa..729859b65 100644 --- a/tests/sqlx/snapshots/v3_jsonb_tests.txt +++ b/tests/sqlx/snapshots/v3_jsonb_tests.txt @@ -33,6 +33,7 @@ v3_jsonb_generator_envelope_shape_accepted v3_jsonb_index_ord_ope_btree_engages v3_jsonb_index_to_ste_vec_query_gin_engages v3_jsonb_json_payload_check +v3_jsonb_legacy_ste_vec_contains_aliases v3_jsonb_minus_array_blocker v3_jsonb_minus_int_blocker v3_jsonb_minus_text_blocker From c1057af0748266bd12a5f096f87ad24eeebae3ee Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 18 Aug 2026 09:10:46 +1000 Subject: [PATCH 05/20] fix(deps): patch postcss to 8.5.26 Signed-off-by: Toby Hede --- pnpm-lock.yaml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3c8fb98a..9f66fea6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 4.3.1 tsup: specifier: ^8.5.0 - version: 8.5.1(postcss@8.5.16)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.26)(typescript@5.9.3) typescript: specifier: ^5.8.3 version: 5.9.3 @@ -49,7 +49,7 @@ importers: version: 22.20.0 tsup: specifier: 'catalog:' - version: 8.5.1(postcss@8.5.16)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.26)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -932,8 +932,8 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1026,8 +1026,8 @@ packages: yaml: optional: true - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} prettier@2.8.8: @@ -2065,7 +2065,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.15: {} + nanoid@3.3.18: {} object-assign@4.1.1: {} @@ -2117,15 +2117,15 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - postcss-load-config@6.0.1(postcss@8.5.16): + postcss-load-config@6.0.1(postcss@8.5.26): dependencies: lilconfig: 3.1.3 optionalDependencies: - postcss: 8.5.16 + postcss: 8.5.26 - postcss@8.5.16: + postcss@8.5.26: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -2267,7 +2267,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.5.1(postcss@8.5.16)(typescript@5.9.3): + tsup@8.5.1(postcss@8.5.26)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -2278,7 +2278,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.16) + postcss-load-config: 6.0.1(postcss@8.5.26) resolve-from: 5.0.0 rollup: 4.62.2 source-map: 0.7.6 @@ -2287,7 +2287,7 @@ snapshots: tinyglobby: 0.2.17 tree-kill: 1.2.2 optionalDependencies: - postcss: 8.5.16 + postcss: 8.5.26 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -2329,7 +2329,7 @@ snapshots: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.16 + postcss: 8.5.26 rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: From 4b504830bd5aadda1de932d6752d0555dbad72b4 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 18 Aug 2026 14:19:25 +1000 Subject: [PATCH 06/20] fix(deps): bump jsonwebtoken to 10.4.0 via stack-auth 0.42.2 Signed-off-by: Toby Hede --- Cargo.lock | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cc569499e..0bdbfe72f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -612,9 +612,9 @@ dependencies = [ [[package]] name = "cipherstash-config" -version = "0.42.0" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56cde3aaa5e2916a40530932f142c37ad6202835a6d221ce6c46fc14bfec8fc5" +checksum = "b94efb31c4b6cc951f2ed2c2a953393ba34136c37c5ddd022a8da732f174e532" dependencies = [ "bitflags", "serde", @@ -872,9 +872,9 @@ dependencies = [ [[package]] name = "cts-common" -version = "0.42.0" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe2acdda527057d48061433ace5378d8452f10d6787f78e0e69b1cddacc57082" +checksum = "576c82618990e693abe4dfbd06dada0bcb45884a4514405851eec6d82818f52c" dependencies = [ "arrayvec", "base32", @@ -2127,17 +2127,20 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "9.3.1" +version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ + "aws-lc-rs", "base64", + "getrandom 0.2.16", "js-sys", "pem", - "ring", "serde", "serde_json", + "signature", "simple_asn1", + "zeroize", ] [[package]] @@ -3855,9 +3858,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stack-auth" -version = "0.42.0" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1c9c640571eba8fa5a705ccebe5c5e50aa27d4d51f4ec1614ffd1841b8a13a" +checksum = "f1a9ac43060af7605899754daa3de2e26302c2ab4d9249a8bc16157bc53dad65" dependencies = [ "aquamarine", "base64", @@ -3883,9 +3886,9 @@ dependencies = [ [[package]] name = "stack-profile" -version = "0.42.0" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "192a90bfa46efe194c2b8beae5523f6214714574c7e8a210c78021259f100e79" +checksum = "90ce7ca95d8e688a35e86e0682293a2085d9b1f26e7ca5b489009c6ee0c6967a" dependencies = [ "dirs", "gethostname", @@ -4882,7 +4885,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -5415,9 +5418,9 @@ dependencies = [ [[package]] name = "zerokms-protocol" -version = "0.12.26" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f731f2de99e66396928faef44b02b39288dc9a93f77a4a3e1dcdc33c1adad0" +checksum = "29486723fdd2bdb0c234174dc242051a46ac0c82935ccaa39e9781dbf7bcc6d2" dependencies = [ "base64", "cipherstash-config", From 6ba124fbf54e27f672e03730eaea212cb5945643 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 19 Aug 2026 08:28:39 +1000 Subject: [PATCH 07/20] fix(deps): keep winapi-util on windows-sys 0.61.2 The jsonwebtoken 10.4.0 bump re-resolved winapi-util's windows-sys edge from 0.61.2 down to 0.48.0. winapi-util declares a permissive range (>=0.48.0, <=0.61.*), so both satisfy it and cargo is free to pick either; pin the lockfile back to 0.61.2. Inert in practice - the edge is cfg(windows)-gated and CI is Ubuntu only - but there is no reason to build against three-generation-older bindings. Signed-off-by: Toby Hede --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 0bdbfe72f..a7619f5f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4885,7 +4885,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] From 4b2d8596c96a707b04a941aba6fcb251e658a7c7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:26:01 +0000 Subject: [PATCH 08/20] Version Packages --- .changeset/rename-ste-vec-contains.md | 12 ------ Cargo.lock | 2 +- crates/eql-bindings/Cargo.toml | 2 +- .../eql-bindings/sql/cipherstash-encrypt.sql | 38 +++++++++++++++++-- crates/eql-bindings/sql/release-manifest.json | 4 +- packages/eql/CHANGELOG.md | 13 +++++++ packages/eql/package.json | 2 +- packages/eql/sql/cipherstash-encrypt.sql | 38 +++++++++++++++++-- packages/eql/sql/release-manifest.json | 4 +- .../eql/src/generated/release-manifest.ts | 4 +- 10 files changed, 92 insertions(+), 27 deletions(-) delete mode 100644 .changeset/rename-ste-vec-contains.md diff --git a/.changeset/rename-ste-vec-contains.md b/.changeset/rename-ste-vec-contains.md deleted file mode 100644 index 1b1b25c53..000000000 --- a/.changeset/rename-ste-vec-contains.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@cipherstash/eql': patch ---- - -**`eql_v3.ste_vec_contains` is renamed to `eql_v3.jsonb_document_contains`.** This -consolidates the last `ste_vec_*`-named public object into the `jsonb_*` family, -matching the earlier renames of the SteVec entry/query surface (`jsonb_entry`, -`jsonb_query`). The old name remains available as a deprecated compatibility -alias, so existing direct callers continue to work; new code should use -`jsonb_document_contains`. The `json` `@>` / `<@` containment operators and the -raw-jsonb function-form entry points, `eql_v3.jsonb_contains(jsonb, jsonb)` and -`eql_v3.jsonb_contained_by(jsonb, jsonb)`, are unchanged. diff --git a/Cargo.lock b/Cargo.lock index a7619f5f8..e76325bbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1131,7 +1131,7 @@ dependencies = [ [[package]] name = "eql-bindings" -version = "3.0.4" +version = "3.0.5" dependencies = [ "eql-domains", "schemars", diff --git a/crates/eql-bindings/Cargo.toml b/crates/eql-bindings/Cargo.toml index 83cf589fd..962a1f37c 100644 --- a/crates/eql-bindings/Cargo.toml +++ b/crates/eql-bindings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "eql-bindings" -version = "3.0.4" +version = "3.0.5" edition = "2021" description = "Canonical wire types for EQL payloads — single source of truth for Rust, TypeScript (ts-rs), and JSON Schema (schemars)." # crates.io metadata. `license` is REQUIRED by crates.io — publish fails without diff --git a/crates/eql-bindings/sql/cipherstash-encrypt.sql b/crates/eql-bindings/sql/cipherstash-encrypt.sql index dcd10521f..88c460300 100644 --- a/crates/eql-bindings/sql/cipherstash-encrypt.sql +++ b/crates/eql-bindings/sql/cipherstash-encrypt.sql @@ -23444,6 +23444,38 @@ AS $$ @> eql_v3.to_ste_vec_query(b)::jsonb $$; +--! @cond deprecated_compatibility +--! @brief Deprecated compatibility alias for jsonb_document_contains(jsonb[], jsonb). +--! @deprecated Use eql_v3.jsonb_document_contains(jsonb[], jsonb). +--! @param a jsonb[] sv array to search within. +--! @param b jsonb sv element to search for. +--! @return boolean True if b's selector is present in any element of a. +CREATE FUNCTION eql_v3.ste_vec_contains(a jsonb[], b jsonb) + RETURNS boolean + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ + SELECT eql_v3.jsonb_document_contains(a, b) +$$; + +COMMENT ON FUNCTION eql_v3.ste_vec_contains(jsonb[], jsonb) IS + 'DEPRECATED: use eql_v3.jsonb_document_contains(jsonb[], jsonb)'; + +--! @brief Deprecated compatibility alias for jsonb_document_contains(json_search, json_search). +--! @deprecated Use eql_v3.jsonb_document_contains(public.eql_v3_json_search, public.eql_v3_json_search). +--! @param a public.eql_v3_json_search Container. +--! @param b public.eql_v3_json_search Elements to find. +--! @return boolean True if all elements of b are contained in a. +CREATE FUNCTION eql_v3.ste_vec_contains(a public.eql_v3_json_search, b public.eql_v3_json_search) + RETURNS boolean + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ + SELECT eql_v3.jsonb_document_contains(a, b) +$$; + +COMMENT ON FUNCTION eql_v3.ste_vec_contains(public.eql_v3_json_search, public.eql_v3_json_search) IS + 'DEPRECATED: use eql_v3.jsonb_document_contains(json_search, json_search)'; +--! @endcond + ------------------------------------------------------------------------------ -- Path queries (text selector only) ------------------------------------------------------------------------------ @@ -59437,7 +59469,7 @@ DROP FUNCTION IF EXISTS eql_v3.version(); --! @brief EQL version reporting (self-contained eql_v3 surface) --! --! This file is auto-generated from src/v3/version.template during build. ---! The 3.0.4 placeholder is replaced with the actual release +--! The 3.0.5 placeholder is replaced with the actual release --! version (bare semver, e.g. "3.0.0") supplied via `mise run build --version`, --! or "DEV" for development builds. @@ -59456,14 +59488,14 @@ CREATE FUNCTION eql_v3.version() RETURNS text IMMUTABLE STRICT PARALLEL SAFE AS $$ - SELECT '3.0.4'; + SELECT '3.0.5'; $$ LANGUAGE SQL; --! @brief Schema-level version marker for obj_description() discoverability --! --! Mirrors eql_v3.version() as a comment on the schema so the installed --! version can also be read via obj_description('eql_v3'::regnamespace). -COMMENT ON SCHEMA eql_v3 IS '3.0.4'; +COMMENT ON SCHEMA eql_v3 IS '3.0.5'; --! @file pin_search_path_v3.sql --! @brief Post-install: pin search_path on every eql_v3.* function. --! diff --git a/crates/eql-bindings/sql/release-manifest.json b/crates/eql-bindings/sql/release-manifest.json index 4cd5728ed..f0203fcc7 100644 --- a/crates/eql-bindings/sql/release-manifest.json +++ b/crates/eql-bindings/sql/release-manifest.json @@ -1,6 +1,6 @@ { - "eqlVersion": "3.0.4", + "eqlVersion": "3.0.5", "schemaVersion": 3, - "installSqlSha256": "a92cc04132512a43bf18b148c61fc0c205c42627709f1f641292519dc410873c", + "installSqlSha256": "accde0030b8f356af616175640635f67661d51aa900624b7fb0fb059e8115048", "uninstallSqlSha256": "b1b5131b8175c5d04da9ada108d25c81c5772b15fad79a6c419ebb32d18c60a9" } diff --git a/packages/eql/CHANGELOG.md b/packages/eql/CHANGELOG.md index d78abe309..10e5a4c61 100644 --- a/packages/eql/CHANGELOG.md +++ b/packages/eql/CHANGELOG.md @@ -1,5 +1,18 @@ # @cipherstash/eql +## 3.0.5 + +### Patch Changes + +- 4c2bb92: **`eql_v3.ste_vec_contains` is renamed to `eql_v3.jsonb_document_contains`.** This + consolidates the last `ste_vec_*`-named public object into the `jsonb_*` family, + matching the earlier renames of the SteVec entry/query surface (`jsonb_entry`, + `jsonb_query`). The old name remains available as a deprecated compatibility + alias, so existing direct callers continue to work; new code should use + `jsonb_document_contains`. The `json` `@>` / `<@` containment operators and the + raw-jsonb function-form entry points, `eql_v3.jsonb_contains(jsonb, jsonb)` and + `eql_v3.jsonb_contained_by(jsonb, jsonb)`, are unchanged. + ## 3.0.4 ### Patch Changes diff --git a/packages/eql/package.json b/packages/eql/package.json index 07eed4762..125e5efc7 100644 --- a/packages/eql/package.json +++ b/packages/eql/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/eql", - "version": "3.0.4", + "version": "3.0.5", "description": "Canonical EQL v3 wire types, JSON schemas, and SQL bundle.", "keywords": [ "eql", diff --git a/packages/eql/sql/cipherstash-encrypt.sql b/packages/eql/sql/cipherstash-encrypt.sql index dcd10521f..88c460300 100644 --- a/packages/eql/sql/cipherstash-encrypt.sql +++ b/packages/eql/sql/cipherstash-encrypt.sql @@ -23444,6 +23444,38 @@ AS $$ @> eql_v3.to_ste_vec_query(b)::jsonb $$; +--! @cond deprecated_compatibility +--! @brief Deprecated compatibility alias for jsonb_document_contains(jsonb[], jsonb). +--! @deprecated Use eql_v3.jsonb_document_contains(jsonb[], jsonb). +--! @param a jsonb[] sv array to search within. +--! @param b jsonb sv element to search for. +--! @return boolean True if b's selector is present in any element of a. +CREATE FUNCTION eql_v3.ste_vec_contains(a jsonb[], b jsonb) + RETURNS boolean + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ + SELECT eql_v3.jsonb_document_contains(a, b) +$$; + +COMMENT ON FUNCTION eql_v3.ste_vec_contains(jsonb[], jsonb) IS + 'DEPRECATED: use eql_v3.jsonb_document_contains(jsonb[], jsonb)'; + +--! @brief Deprecated compatibility alias for jsonb_document_contains(json_search, json_search). +--! @deprecated Use eql_v3.jsonb_document_contains(public.eql_v3_json_search, public.eql_v3_json_search). +--! @param a public.eql_v3_json_search Container. +--! @param b public.eql_v3_json_search Elements to find. +--! @return boolean True if all elements of b are contained in a. +CREATE FUNCTION eql_v3.ste_vec_contains(a public.eql_v3_json_search, b public.eql_v3_json_search) + RETURNS boolean + LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE +AS $$ + SELECT eql_v3.jsonb_document_contains(a, b) +$$; + +COMMENT ON FUNCTION eql_v3.ste_vec_contains(public.eql_v3_json_search, public.eql_v3_json_search) IS + 'DEPRECATED: use eql_v3.jsonb_document_contains(json_search, json_search)'; +--! @endcond + ------------------------------------------------------------------------------ -- Path queries (text selector only) ------------------------------------------------------------------------------ @@ -59437,7 +59469,7 @@ DROP FUNCTION IF EXISTS eql_v3.version(); --! @brief EQL version reporting (self-contained eql_v3 surface) --! --! This file is auto-generated from src/v3/version.template during build. ---! The 3.0.4 placeholder is replaced with the actual release +--! The 3.0.5 placeholder is replaced with the actual release --! version (bare semver, e.g. "3.0.0") supplied via `mise run build --version`, --! or "DEV" for development builds. @@ -59456,14 +59488,14 @@ CREATE FUNCTION eql_v3.version() RETURNS text IMMUTABLE STRICT PARALLEL SAFE AS $$ - SELECT '3.0.4'; + SELECT '3.0.5'; $$ LANGUAGE SQL; --! @brief Schema-level version marker for obj_description() discoverability --! --! Mirrors eql_v3.version() as a comment on the schema so the installed --! version can also be read via obj_description('eql_v3'::regnamespace). -COMMENT ON SCHEMA eql_v3 IS '3.0.4'; +COMMENT ON SCHEMA eql_v3 IS '3.0.5'; --! @file pin_search_path_v3.sql --! @brief Post-install: pin search_path on every eql_v3.* function. --! diff --git a/packages/eql/sql/release-manifest.json b/packages/eql/sql/release-manifest.json index 4cd5728ed..f0203fcc7 100644 --- a/packages/eql/sql/release-manifest.json +++ b/packages/eql/sql/release-manifest.json @@ -1,6 +1,6 @@ { - "eqlVersion": "3.0.4", + "eqlVersion": "3.0.5", "schemaVersion": 3, - "installSqlSha256": "a92cc04132512a43bf18b148c61fc0c205c42627709f1f641292519dc410873c", + "installSqlSha256": "accde0030b8f356af616175640635f67661d51aa900624b7fb0fb059e8115048", "uninstallSqlSha256": "b1b5131b8175c5d04da9ada108d25c81c5772b15fad79a6c419ebb32d18c60a9" } diff --git a/packages/eql/src/generated/release-manifest.ts b/packages/eql/src/generated/release-manifest.ts index b0f19bf12..21c403c1c 100644 --- a/packages/eql/src/generated/release-manifest.ts +++ b/packages/eql/src/generated/release-manifest.ts @@ -1,6 +1,6 @@ export const releaseManifest = { - eqlVersion: '3.0.4', + eqlVersion: '3.0.5', schemaVersion: 3, - installSqlSha256: 'a92cc04132512a43bf18b148c61fc0c205c42627709f1f641292519dc410873c', + installSqlSha256: 'accde0030b8f356af616175640635f67661d51aa900624b7fb0fb059e8115048', uninstallSqlSha256: 'b1b5131b8175c5d04da9ada108d25c81c5772b15fad79a6c419ebb32d18c60a9', } as const From 4098a1f244f69b4608e8e74af519958873a9d897 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 19 Aug 2026 12:25:48 +1000 Subject: [PATCH 09/20] fix(release): make the version hook runnable, and refuse to half-publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects that only fire after a merge, found in PR review. THE VERSION HOOK COULD NOT RUN. `release.yml` passes `version: pnpm run version`, which chains `scripts/sync-lockstep-versions.mjs` -> `mise run release:prepare_bindings_assets` -> `cargo run -p eql-codegen`. The `release` job installed pnpm, Node and node-gyp and nothing else; mise is not on GitHub's ubuntu images. `.changeset/` already holds live changesets, so changesets/action takes its version branch on the next push to main regardless of this PR — `pnpm run version` would have died with ENOENT and no Version Packages PR would have been opened at all. `release-version-hook.test.mjs` asserted the wiring existed; nothing asserted the job could execute it. `workflow-mise-setup.test.mjs` now resolves command chains — action input -> npm script -> spawned binary — and requires a mise setup step at a lower index in any job that reaches one. `add_shims_to_path: false` is load-bearing and would not survive a copy-paste from test-eql.yml: mise's shim dir is prepended to PATH for every later step, so mise's pinned Node 22 would shadow setup-node's and `changeset publish` would shell out to its bundled npm 10.x, below the 11.5.1 that OIDC trusted publishing requires. `changeset publish` HAS NO IDEA WHAT IT IS DOING. It publishes every public workspace package whose version is absent from npm — changeset or no changeset — with `Promise.all` and no dependency ordering, and `publishAPackage` returns a failure rather than throwing it. So a package that cannot publish does not stop the siblings that depend on it: they ship pointing at a version nobody can install. `publishBlockers` in release-gate.mjs answers that question before anything publishes and exits non-zero, which fails `gate` and skips `release` entirely. `FROZEN_PUBLISHERS` has to be written down rather than derived: npm exposes no way to ask "would a publish from here be accepted?", and provenance on the last release is evidence about the past, not permission for the next. Its FFI half turns lint-no-ffi-changeset.mjs's unstated assumption — that all seven are already on npm at the workspace version — into a checked one, which is precisely what the hand-applied EQL 3.0.5 bump showed it needed. The third verdict in the range check is NOT SAFE, MERELY NOT WORSE: a dependency being published in the same release is allowed, because that is every ordinary release here, and changesets' lack of ordering leaves a window nothing at this layer can close. What it can do is separate that transient window from a permanent hole. Also: `changeset:version` was a bare `changeset version` that skipped the lockstep sync while AGENTS.md documented it as the release flow, and `changeset:publish` skipped the build the same way. Both now route through the wrappers, and the test states the rule over the scripts rather than over the two names. packages/protect-ffi/Cargo.lock was stale (`cargo metadata --locked` exit 101): it records eql-bindings, whose version sync-lockstep-versions.mjs moves, and nothing refreshed it — `packages/eql`'s lock is refreshed as a side effect of the SQL build, that one by nothing at all. The script now discovers every lock recording the crate from a path and refreshes each. --- .github/workflows/release.yml | 61 +++ .github/workflows/tests.yml | 24 + package.json | 4 +- packages/protect-ffi/Cargo.lock | 10 +- .../__tests__/cargo-lock-freshness.test.mjs | 192 +++++++ scripts/__tests__/release-gate.test.mjs | 473 +++++++++++++++++- .../__tests__/release-version-hook.test.mjs | 66 +++ .../__tests__/sync-lockstep-versions.test.mjs | 85 +++- .../__tests__/workflow-mise-setup.test.mjs | 235 +++++++++ scripts/release-gate.mjs | 446 ++++++++++++++++- scripts/sync-lockstep-versions.mjs | 135 ++++- 11 files changed, 1713 insertions(+), 18 deletions(-) create mode 100644 scripts/__tests__/cargo-lock-freshness.test.mjs create mode 100644 scripts/__tests__/workflow-mise-setup.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c4245dcc..6c58a4491 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -273,6 +273,67 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # REQUIRED BY `version:` BELOW, and nothing in this file says so without + # this comment — which is why there is also a test. `pnpm run version` is + # `changeset version && node scripts/sync-lockstep-versions.mjs`, and that + # script ends in `execFileSync('mise', ['run', + # 'release:prepare_bindings_assets', …])`, which reaches + # `packages/eql/tasks/build.sh` and two `cargo run -p eql-codegen` calls. + # mise is NOT preinstalled on GitHub's ubuntu images, so without this step + # the hook dies with ENOENT — AFTER `changeset version` has already + # rewritten every manifest and changelog, in a job holding + # `contents: write`. Asserted by + # scripts/__tests__/workflow-mise-setup.test.mjs. + # + # It only fires on the branch where `.changeset/` is non-empty (the + # Version Packages branch), which is why a publish rehearsal never + # exercised it. + # + # `working_directory: packages/eql` for the reason test-eql.yml records at + # length: mise reads config from the current directory and its PARENTS, so + # an action running at the repo root never sees `packages/eql/mise.toml`. + # It would install nothing and leave the config untrusted, and the first + # `mise run` fails with "Config files … are not trusted" — which reads as + # a toolchain problem rather than a path one. That file is also where the + # Rust toolchain comes from (`[tools] rust`), so this step is the cargo + # setup as well; there is deliberately no second one. + # + # `cache: false` IS NOT THE DEFAULT — jdx/mise-action caches by default, + # and scripts/lint-no-workflow-caching.mjs forbids a GitHub Actions cache + # restore anywhere an artifact gets published. A poisoned entry here would + # execute in the job that holds the npm publishing credential. + # + # `add_shims_to_path: false` IS LOAD-BEARING, and it is the input a + # copy-paste from test-eql.yml would not carry. `packages/eql/mise.toml` + # pins `node = "22"` under `[tools]`, and mise's shim directory is + # PREPENDED to PATH for every later step — so with the default `true`, + # mise's own Node would shadow the one `actions/setup-node` installed, and + # `changeset publish` would shell out to that Node's bundled npm 10.x + # instead of the `npm@^11.5.1` installed above. OIDC trusted publishing + # requires >= 11.5.1 and fails with E404 below it, which is the exact + # failure the two comments above this step exist to prevent. `mise run` + # resolves its own toolchain internally, so nothing here needs the shims. + # + # `env: false` for a smaller version of the same argument: the default + # exports that file's `[env]` block — `DATABASE_URL`, `POSTGRES_PASSWORD` + # and friends, all pointed at a Postgres this job does not have — into + # GITHUB_ENV for every subsequent step. + # + # SHA-pinned, matching .github/actions/build-ffi-binding/action.yml: + # mise-action executes third-party code in the job that publishes, so a + # mutable `@v4` would let that code change with no commit here. The pin is + # the same v4 commit test-eql.yml uses. Dependabot moves the pin and the + # trailing comment together (.github/dependabot.yml covers github-actions). + - name: Install mise (the lockstep version hook shells out to it) + uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + cache: false + working_directory: packages/eql + add_shims_to_path: false + env: false + - name: Publish to npm id: changesets uses: changesets/action@v1.9.0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fcc766b6b..8db055cc7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -271,6 +271,30 @@ jobs: - name: Lint — no protect-ffi changeset before the publishing cutover run: pnpm run lint:ffi-changeset + # 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: `@cipherstash/eql` was hand-bumped to 3.0.5 while npm's + # newest is 3.0.4 and its trusted publisher still names + # cipherstash/encrypt-query-language, and `packages/cli` and + # `packages/stack-prisma` carry it in RUNTIME dependencies, so both would + # pack `^3.0.5` — a range no published version satisfies. + # + # 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 diff --git a/package.json b/package.json index c9035a76f..a279d3109 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,8 @@ "build": "turbo build --filter './packages/*'", "build:js": "turbo build --filter './packages/nextjs'", "changeset": "changeset", - "changeset:version": "changeset version", - "changeset:publish": "changeset publish", + "changeset:version": "pnpm run version", + "changeset:publish": "pnpm run release", "dev": "turbo dev --filter './packages/*'", "clean": "rimraf --glob **/.next **/.turbo **/dist **/node_modules", "code:fix": "biome check --write", diff --git a/packages/protect-ffi/Cargo.lock b/packages/protect-ffi/Cargo.lock index 845d99445..c9f921fa0 100644 --- a/packages/protect-ffi/Cargo.lock +++ b/packages/protect-ffi/Cargo.lock @@ -1058,7 +1058,7 @@ dependencies = [ [[package]] name = "eql-bindings" -version = "3.0.4" +version = "3.0.5" dependencies = [ "schemars", "serde", @@ -1079,7 +1079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3021,7 +3021,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3357,7 +3357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4388,7 +4388,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/scripts/__tests__/cargo-lock-freshness.test.mjs b/scripts/__tests__/cargo-lock-freshness.test.mjs new file mode 100644 index 000000000..9106cf5e0 --- /dev/null +++ b/scripts/__tests__/cargo-lock-freshness.test.mjs @@ -0,0 +1,192 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' + +/** + * Every crate this repo owns must be recorded in every `Cargo.lock` at the + * version its own `Cargo.toml` declares. + * + * ## The drift this catches, and why nothing else does + * + * `scripts/sync-lockstep-versions.mjs` rewrites + * `packages/eql/crates/eql-bindings/Cargo.toml` on every lockstep bump — that + * is its job. `packages/protect-ffi` depends on that crate BY PATH + * (`crates/protect-ffi/Cargo.toml`), so its `Cargo.lock` records the version + * too, and nothing was updating it. After the 3.0.5 bump the lock still said + * `eql-bindings 3.0.4` and `cargo metadata --locked` exited 101. + * + * Nothing failed. Not one command in this repo passes `--locked`: + * `build:native` is a plain `cargo build --release`, so every CI job that + * touches Rust silently regenerated the lock, used the regenerated one, and + * threw it away with the runner. The committed file drifted further from the + * tree on each lockstep bump while every job stayed green. + * + * ## Why a version comparison rather than `cargo metadata --locked` + * + * `--locked` is the exact check and it needs a Rust toolchain. `AGENTS.md` is + * explicit that the default `test` and `build` scripts must never invoke cargo + * — root `pnpm test` reaches `packages/protect-ffi`, so a cargo call on that + * path is a Rust toolchain on every contributor's machine — and `pnpm run + * test:scripts` has the same reach, since everyone runs it. + * + * So this asserts the property that actually broke, with node builtins: a + * source-less `[[package]]` entry in a lock is a crate resolved from this + * tree, and its version is knowable by reading the crate's manifest. That is + * narrower than `--locked` — a lock stale because a crate gained a NEW + * dependency still passes here — but it is the whole of the lockstep failure + * mode, it runs everywhere, and it costs nothing. Pairing it with a `--locked` + * invocation on `packages/protect-ffi`'s `test:cargo` path would close the + * remainder; that script is owned elsewhere. + */ + +/** Same skip set, and the same reasons, as `lint-no-eql-registry-pins.mjs`. */ +const SKIP_DIRS = new Set([ + '.git', + 'node_modules', + 'target', + 'dist', + '.turbo', + '.next', +]) + +/** Every file named `name` under the repo, repo-relative and POSIX-spelled. */ +function findFiles(name) { + const found = [] + const walk = (abs) => { + for (const entry of readdirSync(abs, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) walk(join(abs, entry.name)) + } else if (entry.name === name) { + found.push(join(abs, entry.name)) + } + } + } + walk(REPO_ROOT) + return found + .map((abs) => relative(REPO_ROOT, abs).split(sep).join('/')) + .sort() +} + +/** + * `[package] name` and `version` from one `Cargo.toml`. + * + * Anchored to the `[package]` table for the reason `bumpCargoPackageVersion` + * is: a `[dependencies.foo]` long-form table also carries a column-0 + * `version = "…"` line, and reading "the first version in the file" would pick + * up whichever came first. + */ +function crateManifest(source) { + const section = source.match(/^\[package\]\n(?:(?!^\[).*\n)*/m) + if (!section) return null + const name = /^name = "([^"]*)"$/m.exec(section[0]) + const version = /^version = "([^"]*)"$/m.exec(section[0]) + return name && version ? { name: name[1], version: version[1] } : null +} + +/** + * The `[[package]]` entries of a `Cargo.lock` that resolve from THIS tree. + * + * A registry or git dependency carries a `source = "…"` key; a path dependency + * and a workspace member carry none. That absence is the whole test for + * "cargo resolved this from a directory", and it is how cargo itself + * distinguishes them. + */ +export function localLockEntries(source) { + return source + .split(/^\[\[package\]\]$/m) + .slice(1) + .map((block) => { + const name = /^name = "([^"]*)"$/m.exec(block) + const version = /^version = "([^"]*)"$/m.exec(block) + const source_ = /^source = "/m.test(block) + return name && version && !source_ + ? { name: name[1], version: version[1] } + : null + }) + .filter(Boolean) +} + +/** name -> { version, file } for every crate manifest in the tree. */ +const CRATES = new Map() +const AMBIGUOUS = [] +for (const file of findFiles('Cargo.toml')) { + const crate = crateManifest(readFileSync(join(REPO_ROOT, file), 'utf8')) + if (!crate) continue // a virtual manifest: `[workspace]` with no `[package]` + const existing = CRATES.get(crate.name) + if (existing && existing.version !== crate.version) { + AMBIGUOUS.push(`${crate.name}: ${existing.file} vs ${file}`) + } + CRATES.set(crate.name, { ...crate, file }) +} + +const LOCKS = findFiles('Cargo.lock') + +/** Every (lock, local crate) pair, with the version each side records. */ +const PAIRS = LOCKS.flatMap((lock) => + localLockEntries(readFileSync(join(REPO_ROOT, lock), 'utf8')).map( + (entry) => ({ + lock, + name: entry.name, + locked: entry.version, + onDisk: CRATES.get(entry.name)?.version ?? null, + }), + ), +) + +describe('Cargo.lock records this tree’s crates at their real versions', () => { + it('finds locks and crates to compare', () => { + // The floor. Discovery over the tree means a walk that stops matching — + // a new SKIP_DIRS entry, a rename — turns this whole file green while + // checking nothing. + expect(LOCKS.length).toBeGreaterThan(0) + expect(PAIRS.length).toBeGreaterThan(0) + }) + + it('covers eql-bindings, the crate the lockstep bump rewrites', () => { + // Named specifically because it is the one with a mechanism actively + // pushing it out of sync: `scripts/sync-lockstep-versions.mjs` writes its + // `Cargo.toml` on every release. If this crate ever drops out of the pair + // set, the check that matters most has silently stopped running. + expect(PAIRS.filter(({ name }) => name === 'eql-bindings').length).toBe( + LOCKS.length, + ) + }) + + it('resolves every locked local crate to a manifest on disk', () => { + // A source-less entry naming a crate this tree does not contain means the + // scan lost its subject — a path dependency pointing outside the repo, or + // a walk that no longer reaches the crate. Either way the comparison below + // is not being made, which must not read as a pass. + expect( + PAIRS.filter(({ onDisk }) => onDisk === null).map( + ({ lock, name }) => `${lock} :: ${name}`, + ), + ).toEqual([]) + }) + + it('has one version per crate name across the tree', () => { + // The name -> version map is keyed on the crate name alone, so two crates + // sharing a name at different versions would make the comparison depend on + // walk order. Fail rather than pick. + expect(AMBIGUOUS).toEqual([]) + }) + + it('locks each crate at the version its Cargo.toml declares', () => { + // THE DEFECT. `packages/protect-ffi/Cargo.lock` said `eql-bindings 3.0.4` + // while the crate said 3.0.5, because the lockstep sync rewrote the + // manifest and no command in this repo passes `--locked`. + const offenders = PAIRS.filter( + ({ locked, onDisk }) => locked !== onDisk, + ).map( + ({ lock, name, locked, onDisk }) => + `${lock}: ${name} locked at ${locked}, ${CRATES.get(name).file} declares ${onDisk}`, + ) + expect( + offenders, + 'A committed Cargo.lock disagrees with a crate manifest in this tree. Refresh it with ' + + '`cargo update --offline --package ` in the workspace holding the lock — no network ' + + 'needed, because the crate resolves from a path.', + ).toEqual([]) + }) +}) diff --git a/scripts/__tests__/release-gate.test.mjs b/scripts/__tests__/release-gate.test.mjs index 992ea11cc..a2ae18255 100644 --- a/scripts/__tests__/release-gate.test.mjs +++ b/scripts/__tests__/release-gate.test.mjs @@ -1,13 +1,26 @@ -import { readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' import { join } from 'node:path' import yaml from 'js-yaml' import { describe, expect, it } from 'vitest' import { classify, + packedRange, + publishBlockers, + satisfies, unpublished, + workspaceManifests, workspacePackagePatterns, } from '../release-gate.mjs' import { REPO_ROOT } from './lib/repo-root.mjs' +import { readWorkflow } from './lib/workflows.mjs' /** * The gate decides what a push to `main` still has to publish, and it is @@ -174,3 +187,461 @@ describe('classify', () => { expect(classify([])).toEqual({ ffi: false, js: false }) }) }) + +/** + * WHAT ARMS A RELEASE THAT CANNOT SUCCEED. + * + * `changeset publish` publishes every public workspace package whose committed + * version is absent from npm — changeset or no changeset. In the installed + * 2.31.0 it does that with no dependency ordering (`Promise.all`) and + * `publishAPackage` RETURNS a result rather than throwing, so one package's + * failed publish does not stop its siblings. A package that cannot publish + * therefore does not abort the release; it just does not arrive, and everything + * that depends on it ships pointing at a version nobody can install. + * + * That is not hypothetical here. `@cipherstash/eql` was hand-bumped to 3.0.5 in + * the workspace while npm's newest is 3.0.4 and its trusted publisher still + * names `cipherstash/encrypt-query-language` — so its publish from this + * repository is rejected, while `packages/cli` and `packages/stack-prisma` + * carry `"@cipherstash/eql": "workspace:^"` in their RUNTIME dependencies, + * which pnpm rewrites to `^3.0.5` at pack time. A range no published version + * satisfies, in a tarball that publishes fine. + */ + +const EQL = '@cipherstash/eql' + +describe('packedRange', () => { + it('resolves the three bare protocol forms the way pnpm does', () => { + // `workspace:*` is an EXACT pin, not a wildcard — the distinction the whole + // check turns on, since it makes the range unsatisfiable by anything but + // the one version. + expect(packedRange('workspace:*', '1.5.0')).toBe('1.5.0') + expect(packedRange('workspace:^', '1.5.0')).toBe('^1.5.0') + expect(packedRange('workspace:~', '1.5.0')).toBe('~1.5.0') + }) + + it('passes an explicit range through verbatim', () => { + expect(packedRange('workspace:^1.0.0', '1.5.0')).toBe('^1.0.0') + expect(packedRange('workspace:1.2.3', '1.5.0')).toBe('1.2.3') + }) + + it('ignores a specifier that is not the workspace protocol', () => { + // Registry ranges are somebody else's problem: npm resolves them itself. + expect(packedRange('^1.0.0', '1.5.0')).toBeNull() + expect(packedRange('catalog:repo', '1.5.0')).toBeNull() + }) +}) + +describe('satisfies', () => { + it('reads an exact pin', () => { + expect(satisfies('1.5.0', '1.5.0')).toBe(true) + expect(satisfies('1.5.1', '1.5.0')).toBe(false) + }) + + it('reads caret, including the 0.x and 0.0.x narrowings', () => { + expect(satisfies('1.9.9', '^1.5.0')).toBe(true) + expect(satisfies('2.0.0', '^1.5.0')).toBe(false) + expect(satisfies('1.4.9', '^1.5.0')).toBe(false) + // ^0.5.0 does NOT admit 0.6.0 — the case that decides whether + // `@cipherstash/protect-ffi@0.31.0`'s siblings are read correctly. + expect(satisfies('0.5.9', '^0.5.0')).toBe(true) + expect(satisfies('0.6.0', '^0.5.0')).toBe(false) + expect(satisfies('0.0.4', '^0.0.3')).toBe(false) + }) + + it('reads tilde', () => { + expect(satisfies('1.5.9', '~1.5.0')).toBe(true) + expect(satisfies('1.6.0', '~1.5.0')).toBe(false) + }) + + it('does not let a prerelease satisfy a stable range', () => { + // THE ONE THAT DECIDES THIS REPO'S ANSWER. npm carries + // `@cipherstash/eql@3.0.0-alpha.2` and friends, and a naive comparison puts + // some of them inside `^3.0.5`'s window — which would report the range as + // satisfiable and wave the whole defect through. + expect(satisfies('3.1.0-alpha.1', '^3.0.5')).toBe(false) + expect(satisfies('3.0.0-alpha.2', '^3.0.0')).toBe(false) + // …while a prerelease of the range's OWN tuple still counts, per semver. + expect(satisfies('3.0.5-rc.2', '^3.0.5-rc.1')).toBe(true) + }) + + it('throws on a range it cannot read, rather than guessing', () => { + // Every failure mode in this file fails loudly — see the script header. A + // range read as "unsatisfiable" would freeze a release that is fine; one + // read as "satisfiable" would publish the broken tarball. + expect(() => satisfies('1.0.0', '>=1.0.0 <2.0.0')).toThrow(/range/i) + expect(() => satisfies('1.0.0', '1.x')).toThrow(/range/i) + }) +}) + +describe('publishBlockers', () => { + /** A registry where nothing has ever been published. */ + const empty = () => null + + it('blocks a frozen package whose committed version is not on npm', () => { + // CHECK A. `changeset publish` will attempt this package because its + // version is absent, and the attempt is rejected — npm trusted publishing + // is bound to a repository, and this one is not it. + const blockers = publishBlockers({ + manifests: [ + { name: EQL, version: '3.0.5', private: false, workspaceDeps: [] }, + ], + lookup: () => ['3.0.4'], + frozen: new Map([[EQL, 'publisher not repointed yet']]), + }) + expect(blockers.map((b) => b.kind)).toEqual(['frozen-publisher']) + expect(blockers[0].package).toBe(EQL) + }) + + it('lets a frozen package through once its committed version IS on npm', () => { + // The state the seven protect-ffi packages are in, and the unstated + // assumption `scripts/lint-no-ffi-changeset.mjs` rests on: at the published + // version, `changeset publish` skips them and the frozen publisher never + // matters. This is that assumption, checked. + expect( + publishBlockers({ + manifests: [ + { name: EQL, version: '3.0.4', private: false, workspaceDeps: [] }, + ], + lookup: () => ['3.0.4'], + frozen: new Map([[EQL, 'publisher not repointed yet']]), + }), + ).toEqual([]) + }) + + it('blocks a runtime range that only a frozen package could satisfy', () => { + // CHECK B, and the blast radius. `stash` publishes fine; it just ships a + // dependency on a version that will never exist. + const blockers = publishBlockers({ + manifests: [ + { name: EQL, version: '3.0.5', private: false, workspaceDeps: [] }, + { + name: 'stash', + version: '1.0.1', + private: false, + workspaceDeps: [ + { table: 'dependencies', name: EQL, spec: 'workspace:^' }, + ], + }, + ], + lookup: (name) => (name === EQL ? ['3.0.4'] : ['1.0.0']), + frozen: new Map([[EQL, 'publisher not repointed yet']]), + }) + const range = blockers.find((b) => b.kind === 'frozen-dependency') + expect(range).toBeDefined() + expect(range.package).toBe('stash') + expect(range.dependency).toBe(EQL) + expect(range.range).toBe('^3.0.5') + }) + + it('allows a range only this same release will satisfy, when the dep can publish', () => { + // THE FALSE POSITIVE THAT WOULD MAKE THIS GATE UNUSABLE. Every ordinary + // release of this repo moves the six-package fixed group together, so at + // gate time `@cipherstash/stack-drizzle@1.0.1` depends on + // `@cipherstash/stack@1.0.1` and NEITHER is on npm yet. Failing that would + // freeze the repo permanently rather than catch anything. + expect( + publishBlockers({ + manifests: [ + { + name: 'stack', + version: '1.0.1', + private: false, + workspaceDeps: [], + }, + { + name: 'drizzle', + version: '1.0.1', + private: false, + workspaceDeps: [ + { table: 'dependencies', name: 'stack', spec: 'workspace:*' }, + ], + }, + ], + lookup: () => ['1.0.0'], + frozen: new Map(), + }), + ).toEqual([]) + }) + + it('blocks a runtime dependency on a package that is never published', () => { + // `@cipherstash/test-kit` is `private: true`. A runtime `workspace:*` on it + // packs a range for a package with no registry entry at all — the same + // broken install, arrived at a different way. + const blockers = publishBlockers({ + manifests: [ + { + name: 'test-kit', + version: '0.0.1', + private: true, + workspaceDeps: [], + }, + { + name: 'stack', + version: '1.0.0', + private: false, + workspaceDeps: [ + { table: 'dependencies', name: 'test-kit', spec: 'workspace:*' }, + ], + }, + ], + lookup: empty, + frozen: new Map(), + }) + expect(blockers.map((b) => b.kind)).toEqual(['private-dependency']) + }) + + it('blocks a hand-written range the workspace version cannot satisfy', () => { + // `workspace:^2.0.0` against a 1.x member. pnpm writes it out verbatim, so + // nothing downstream notices; it is simply a manifest that cannot resolve. + const blockers = publishBlockers({ + manifests: [ + { name: 'stack', version: '1.0.0', private: false, workspaceDeps: [] }, + { + name: 'drizzle', + version: '1.0.0', + private: false, + workspaceDeps: [ + { table: 'dependencies', name: 'stack', spec: 'workspace:^2.0.0' }, + ], + }, + ], + lookup: () => ['1.0.0'], + frozen: new Map(), + }) + expect(blockers.map((b) => b.kind)).toEqual(['unsatisfiable-range']) + }) + + it('ignores devDependencies', () => { + // A published tarball keeps its devDependencies in the manifest, but no + // consumer installs them, so an unsatisfiable one breaks nothing. This is + // why `packages/stack`'s `@cipherstash/eql: workspace:^` is not a finding + // while `packages/cli`'s identical line is. + expect( + publishBlockers({ + manifests: [ + { name: EQL, version: '3.0.5', private: false, workspaceDeps: [] }, + { + name: 'stack', + version: '1.0.0', + private: false, + workspaceDeps: [ + { table: 'devDependencies', name: EQL, spec: 'workspace:^' }, + ], + }, + ], + lookup: (name) => (name === EQL ? ['3.0.4'] : ['1.0.0']), + frozen: new Map(), + }), + ).toEqual([]) + }) + + it('does not check a private package’s own dependencies', () => { + // `examples/*`, `e2e` and `packages/bench` are never packed, so their + // `workspace:*` lines reach no consumer. + expect( + publishBlockers({ + manifests: [ + { + name: 'test-kit', + version: '0.0.1', + private: true, + workspaceDeps: [], + }, + { + name: 'bench', + version: '0.0.5', + private: true, + workspaceDeps: [ + { table: 'dependencies', name: 'test-kit', spec: 'workspace:*' }, + ], + }, + ], + lookup: empty, + frozen: new Map(), + }), + ).toEqual([]) + }) + + it('propagates a lookup error instead of reporting "nothing blocks"', () => { + // The load-bearing direction, same as `unpublished`'s. A network, auth or + // rate-limit failure must stop the gate — swallowed, it reads as "this + // frozen package is already on npm", which is the one answer that lets the + // broken release through. + // + // The manifest carries a FROZEN package deliberately: that is what makes + // the registry answer load-bearing here. A package nothing asks about is + // not looked up at all, and `[]` is then the right answer whatever the + // registry is doing — so asserting the throw on that shape would pin an + // eager round trip rather than the property. + expect(() => + publishBlockers({ + manifests: [ + { name: EQL, version: '3.0.5', private: false, workspaceDeps: [] }, + ], + lookup: () => { + throw new Error('npm view failed: ETIMEDOUT') + }, + frozen: new Map([[EQL, 'publisher not repointed yet']]), + }), + ).toThrow(/ETIMEDOUT/) + }) +}) + +describe('the gate over this repo’s real manifests', () => { + // A SYNTHETIC REGISTRY over the REAL tree: every workspace package is + // published at exactly the version the tree carries, EXCEPT `@cipherstash/eql` + // — stuck at 3.0.4, which is npm's actual state. So the only thing this can + // report is the consequence of the hand-applied 3.0.5 bump, and it reports it + // from the manifests themselves rather than from a fixture that could drift + // away from them. + const manifests = workspaceManifests() + const lookup = (name) => { + if (name === EQL) return ['3.0.3', '3.0.4'] + const found = manifests.find((m) => m.name === name) + return found ? [found.version] : null + } + const blockers = publishBlockers({ manifests, lookup }) + + it('names the frozen package that cannot publish', () => { + expect( + blockers + .filter((b) => b.kind === 'frozen-publisher') + .map((b) => `${b.package}@${b.version}`), + ).toEqual([`${EQL}@3.0.5`]) + }) + + it('names every published package that would ship the unsatisfiable range', () => { + // THE REGRESSION. `packages/cli` (`stash`) and `packages/stack-prisma` both + // carry `"@cipherstash/eql": "workspace:^"` under `dependencies`, so both + // pack `^3.0.5`. `packages/stack` carries the same line under + // `devDependencies` and must NOT appear. + expect( + blockers + .filter((b) => b.kind === 'frozen-dependency') + .map((b) => `${b.package} -> ${b.dependency}@${b.range}`) + .sort(), + ).toEqual([ + `@cipherstash/stack-prisma -> ${EQL}@^3.0.5`, + `stash -> ${EQL}@^3.0.5`, + ]) + }) + + it('reports nothing once the frozen package is on npm at its committed version', () => { + // The exit condition, stated as a test: complete the Phase-5 repoint and + // publish 3.0.5, and this gate goes quiet on its own. Nothing else has to + // change — which is what makes the freeze a fact about the registry rather + // than a policy encoded here. + expect( + publishBlockers({ + manifests, + lookup: (name) => (name === EQL ? ['3.0.4', '3.0.5'] : lookup(name)), + }), + ).toEqual([]) + }) +}) + +describe('the gate actually blocks the publish', () => { + const workflow = readWorkflow('.github/workflows/release.yml') + + it('skips the release job unless the gate job succeeded', () => { + // A gate that exits non-zero and stops nothing is a slower way of printing + // a warning. `release` is the job that runs `changeset publish`, and its + // condition is `always()` — needed, because `publish-ffi` is legitimately + // skipped — so "gate failed" has to be excluded EXPLICITLY or `always()` + // runs the publish straight through the failure. + const release = workflow.jobs.release + expect(release.needs).toContain('gate') + expect( + String(release.if), + "release.yml's `release` job must require `needs.gate.result == 'success'`. Under " + + '`always()` a failed gate would otherwise still reach `changeset publish`.', + ).toMatch(/needs\.gate\.result\s*==\s*'success'/) + }) +}) + +/** + * The gate as a PROCESS, driven against a fake registry. + * + * Every test above calls `publishBlockers` and reads what it returns. That + * proves the analysis and nothing about the release: a `main()` that computed + * the same list and exited 0 would pass all of them while publishing the broken + * tarballs. The exit code is the entire mechanism — `release.yml`'s `release` + * job is conditioned on `needs.gate.result == 'success'` — so it gets a test + * that actually runs the script. + * + * `npm` is shimmed on PATH rather than the module being imported, because + * `npmVersions` shells out to it. That also keeps this offline and + * deterministic: the real registry would make the assertion below depend on + * whether Phase 5 has happened yet. + */ +describe('the gate exits non-zero when a blocker is found', () => { + const manifests = workspaceManifests() + + /** A PATH entry whose `npm view versions --json` answers from `map`. */ + const fakeRegistry = (map) => { + const dir = mkdtempSync(join(tmpdir(), 'release-gate-')) + const versions = join(dir, 'versions.json') + writeFileSync(versions, JSON.stringify(map)) + const shim = join(dir, 'npm') + writeFileSync( + shim, + '#!/usr/bin/env node\n' + + "const map = JSON.parse(require('node:fs').readFileSync(process.env.FAKE_NPM_VERSIONS, 'utf8'))\n" + + 'const found = map[process.argv[3]]\n' + + "if (!found) { process.stderr.write('npm error code E404\\n'); process.exit(1) }\n" + + 'process.stdout.write(JSON.stringify(found))\n', + ) + chmodSync(shim, 0o755) + return { dir, versions } + } + + /** Every workspace package published at exactly its committed version. */ + const allPublished = Object.fromEntries( + manifests.map(({ name, version }) => [name, [version]]), + ) + + const runGate = (map) => { + const { dir, versions } = fakeRegistry(map) + const result = spawnSync(process.execPath, ['scripts/release-gate.mjs'], { + cwd: REPO_ROOT, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + FAKE_NPM_VERSIONS: versions, + // The real one would be written for the whole vitest run. + GITHUB_OUTPUT: join(dir, 'github-output.txt'), + }, + }) + rmSync(dir, { recursive: true, force: true }) + return result + } + + it('fails the job, and says how to clear it', () => { + // TODAY'S TREE against TODAY'S REGISTRY: @cipherstash/eql pinned to 3.0.4, + // everything else current. Exit 1 is what skips the `release` job. + const result = runGate({ ...allPublished, [EQL]: ['3.0.3', '3.0.4'] }) + expect(result.status).toBe(1) + expect(result.stderr).toContain('cannot be installed') + expect(result.stderr).toContain(`${EQL}@3.0.5`) + // The message has to name the way out, or a blocked release is a puzzle. + expect(result.stderr).toContain('Phase 5') + }) + + it('still reports the publish set on the run it blocks', () => { + // The `ffi`/`js` outputs are diagnostic and are written BEFORE the exit, so + // the job log on a blocked run still says what was missing. Losing that + // would make the blocked run less informative than a passing one. + const result = runGate({ ...allPublished, [EQL]: ['3.0.3', '3.0.4'] }) + expect(result.stdout).toContain(`unpublished: ${EQL}`) + }) + + it('exits 0 once the frozen package is published', () => { + // The other half of the mutation check: this must not be a gate that always + // fails. Publish 3.0.5 and the same tree passes untouched. + const result = runGate({ ...allPublished, [EQL]: ['3.0.4', '3.0.5'] }) + expect(result.stderr).toBe('') + expect(result.status).toBe(0) + }) +}) diff --git a/scripts/__tests__/release-version-hook.test.mjs b/scripts/__tests__/release-version-hook.test.mjs index fe6a8cbd4..9ec564197 100644 --- a/scripts/__tests__/release-version-hook.test.mjs +++ b/scripts/__tests__/release-version-hook.test.mjs @@ -104,3 +104,69 @@ describe('the root `version` script chains the propagation', () => { ).toBeLessThan(script.indexOf(VERSION_SCRIPT)) }) }) + +/** + * The same hook, reached the way a human reaches it. + * + * `release.yml` is not the only caller. `AGENTS.md` documents the release flow + * as `pnpm changeset:version`, and that alias was a bare `changeset version` — + * so a maintainer following this repo's own documentation reproduced exactly + * the skew the hook exists to prevent: npm bumped, the crate and the bundled + * SQL left behind. The workflow test above passes throughout, because the + * workflow is not what ran. + * + * The rule is stated over the SCRIPTS rather than over the one name, so a + * second convenience alias added later is held to it without anyone + * remembering this file exists. Two changeset commands are wrapped by this + * repo, and both wrappers are load-bearing: + * + * * `changeset version` must chain `sync-lockstep-versions.mjs`, or the + * lockstep bump reaches npm and nothing else. + * * `changeset publish` must build first, or it packs whatever `dist/` the + * working tree happens to hold — which for a clean clone is nothing. + * + * A script that wraps neither (`pnpm run version`, `pnpm run release`) is not + * matched and needs no exemption: it does not contain the bare command. + */ +describe('no root script invokes a bare changesets command', () => { + const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')) + const scripts = Object.entries(pkg.scripts ?? {}) + + /** Scripts whose body runs `changeset `, other than via `pnpm run`. */ + const invoking = (command) => + scripts.filter(([, body]) => + new RegExp(`(^|[\\s;&|(])changeset\\s+${command}\\b`).test(body), + ) + + it('finds the wrappers it is checking', () => { + // The floor. Both rules below are "for every script that runs X…", and a + // repo where nothing matches passes them having checked nothing — which is + // also what a rename of the changesets CLI would produce. + expect(invoking('version').length).toBeGreaterThan(0) + expect(invoking('publish').length).toBeGreaterThan(0) + }) + + it('chains the lockstep sync onto every `changeset version`', () => { + const offenders = invoking('version') + .filter(([, body]) => !body.includes(VERSION_SCRIPT)) + .map(([name, body]) => `${name}: ${body}`) + expect( + offenders, + `A root script runs \`changeset version\` without ${VERSION_SCRIPT}. It bumps the npm ` + + 'package and leaves packages/eql/crates/eql-bindings/Cargo.toml, every Cargo.lock that ' + + 'records it, and the bundled SQL at the previous version. Route the alias through ' + + '`pnpm run version` instead of calling the changesets CLI directly.', + ).toEqual([]) + }) + + it('builds before every `changeset publish`', () => { + const offenders = invoking('publish') + .filter(([, body]) => !/\brun build\b/.test(body)) + .map(([name, body]) => `${name}: ${body}`) + expect( + offenders, + 'A root script runs `changeset publish` without building first, so it packs whatever ' + + '`dist/` the working tree happens to hold. Route the alias through `pnpm run release`.', + ).toEqual([]) + }) +}) diff --git a/scripts/__tests__/sync-lockstep-versions.test.mjs b/scripts/__tests__/sync-lockstep-versions.test.mjs index 2d4192c61..edd6fbb4d 100644 --- a/scripts/__tests__/sync-lockstep-versions.test.mjs +++ b/scripts/__tests__/sync-lockstep-versions.test.mjs @@ -1,5 +1,11 @@ import { describe, expect, test } from 'vitest' -import { bumpCargoPackageVersion } from '../sync-lockstep-versions.mjs' +import { + bumpCargoPackageVersion, + cargoLockWorkspaces, + LOCKED_CRATE, + refreshCargoLock, +} from '../sync-lockstep-versions.mjs' +import { REPO_ROOT } from './lib/repo-root.mjs' const CARGO = `[package] name = "eql-bindings" @@ -56,3 +62,80 @@ serde_json = "1" expect(out).toContain('serde_json = "1"') }) }) + +/** + * The second thing the lockstep bump has to carry, and the one it did not. + * + * Rewriting `packages/eql/crates/eql-bindings/Cargo.toml` moves the crate's + * version. Every `Cargo.lock` that resolves that crate FROM A PATH records the + * old one, and nothing in this repo passes `--locked`, so the stale lock is + * regenerated on every CI run, used, and discarded — green the whole way. + * `cargo metadata --locked` in `packages/protect-ffi` exited 101 for exactly + * this reason after the 3.0.5 bump. + * + * `scripts/__tests__/cargo-lock-freshness.test.mjs` is the guard that fails a + * PR carrying a stale lock. These are the tests for the half that stops it + * going stale in the first place. + */ +describe('lockstep Cargo.lock refresh', () => { + test('discovers every lock that resolves the crate from this tree', () => { + const found = cargoLockWorkspaces(REPO_ROOT) + + // The floor. Discovery over a hardcoded list means a walk that stops + // matching refreshes nothing and reports success — so an empty result is a + // failure, not a no-op. `packages/protect-ffi` is named because it is the + // consumer that broke: it path-depends on the crate from a SEPARATE cargo + // workspace, so no eql-side build ever touches its lock. + expect(found.length).toBeGreaterThan(0) + expect(found).toContain('packages/protect-ffi') + }) + + test('refreshes offline, through mise, against the named workspace', () => { + const calls = [] + refreshCargoLock({ + root: '/repo', + eqlRoot: '/repo/packages/eql', + workspace: 'packages/protect-ffi', + run: (...args) => calls.push(args), + }) + + expect(calls).toHaveLength(1) + const [command, args, options] = calls[0] + + // THROUGH MISE, not a bare `cargo`. release.yml installs mise with + // `add_shims_to_path: false` — deliberately, so mise's own Node cannot + // shadow the one `changeset publish` needs — which means cargo is NOT on + // PATH in the release job. `mise exec` resolves it from + // packages/eql/mise.toml's `[tools] rust`, which is the same toolchain the + // SQL build below uses. + expect(command).toBe('mise') + expect(args.slice(0, 3)).toEqual(['exec', '--', 'cargo']) + expect(options.cwd).toBe('/repo/packages/eql') + + // OFFLINE. This runs inside `changeset version`, between the manifest + // rewrite and the commit; a registry fetch there is a network dependency + // on the release path buying nothing, because the crate resolves from a + // path. Verified byte-identical to the networked resolution. + expect(args).toContain('--offline') + expect(args).toContain('--package') + expect(args).toContain(LOCKED_CRATE) + expect(args).toContain('--manifest-path') + expect(args).toContain('/repo/packages/protect-ffi/Cargo.toml') + }) + + test('fails loudly rather than leaving a lock stale', () => { + // A refresh that cannot run must stop the release. The alternative is the + // state this whole pair of tests exists to end: a bumped manifest, a lock + // that still names the old version, and a green log. + expect(() => + refreshCargoLock({ + root: '/repo', + eqlRoot: '/repo/packages/eql', + workspace: 'packages/protect-ffi', + run: () => { + throw new Error('spawn mise ENOENT') + }, + }), + ).toThrow(/ENOENT/) + }) +}) diff --git a/scripts/__tests__/workflow-mise-setup.test.mjs b/scripts/__tests__/workflow-mise-setup.test.mjs new file mode 100644 index 000000000..e8489691f --- /dev/null +++ b/scripts/__tests__/workflow-mise-setup.test.mjs @@ -0,0 +1,235 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { readWorkflow, workflowFiles } from './lib/workflows.mjs' + +/** + * A job whose command chain reaches `mise` must install mise, first. + * + * ## Why this fails open, and silently, without a test + * + * `mise` is NOT preinstalled on GitHub's ubuntu runner images. A job that needs + * it and does not install it fails with `ENOENT: spawn mise` — which is loud + * enough, once it runs. The problem is *when* it runs. + * + * `release.yml`'s `release` job passes `version: pnpm run version` to + * `changesets/action`. That input is a shell command, and the chain behind it + * is three hops deep and entirely invisible in the workflow file: + * + * version: pnpm run version (release.yml) + * -> "changeset version && node scripts/sync-lockstep-versions.mjs" + * (root package.json) + * -> execFileSync('mise', ['run', 'release:prepare_bindings_assets'…]) + * (that script) + * + * Nothing in `release.yml` says the word `mise`. The job installed pnpm, Node, + * node-gyp and npm and looked complete. And the branch that runs the hook — + * changesets taking its VERSION branch, which it does only when `.changeset/` + * holds unconsumed changesets — is not the branch a release rehearsal + * exercises, so the gap survived review of both the workflow and the script. + * + * The cost of finding out at run time is what makes this a test rather than a + * comment: `changeset version` runs FIRST and rewrites every manifest and + * changelog in the tree. The ENOENT lands after that, so the failure is a + * half-applied version bump in a job holding `contents: write`, and the repair + * is manual. + * + * ## What is checked + * + * The chain is RESOLVED, not pattern-matched, because the whole defect is that + * the requirement is not visible at the call site. `pnpm run