From 483fbaeeb9f7a9e98abf96c5e07eb6c931a552c1 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Wed, 27 May 2026 15:01:24 -0500 Subject: [PATCH 01/10] fix(python): accept any integer dtype for subset candidate ids RankQuant.search_asymmetric_subset and Bitmap.body_overlap_scores_subset declared their candidate/doc-id arrays as PyReadonlyArray1, which rust-numpy matches strictly. The int64 arrays NumPy produces by default (np.arange, np.where()[0], np.array([...]), fancy indexing, np.argpartition) therefore raised an opaque "ndarray cannot be cast as ndarray" TypeError. ordvec's own top_m_candidates* emit uint32, so the happy path worked and the suite never exercised a user-built candidate set. Accept any integer dtype and convert to the core's u32 with checked bounds: negatives and values >= 2^32 raise a clear ValueError rather than silently wrapping (np.asarray(-1, uint32) -> 4294967295, 2**32 -> 0, which would score the wrong document). Already-uint32 contiguous arrays are still borrowed zero-copy; other dtypes are copied once. The >= n bound check and the body_overlap sorted-ids policy are unchanged. Flips the two red-team tests that asserted the old strict-uint32 rejection to the new contract (integer dtypes converted by value, non-integer dtypes still TypeError) and adds dtype-matrix + fail-loud regression tests. 390 pytest pass. Reported via external review: candidate ids naturally arrive as int64. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Nelson Spence --- ordvec-python/src/lib.rs | 160 +++++++++++++++++------ ordvec-python/tests/test_input_guards.py | 122 +++++++++++++++++ ordvec-python/tests/test_redteam_fuzz.py | 41 +++++- 3 files changed, 281 insertions(+), 42 deletions(-) diff --git a/ordvec-python/src/lib.rs b/ordvec-python/src/lib.rs index 922f3a3c..b6e2d881 100644 --- a/ordvec-python/src/lib.rs +++ b/ordvec-python/src/lib.rs @@ -31,7 +31,7 @@ //! usual contract for GIL-releasing numeric extensions (NumPy behaves the same //! way). -use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2}; +use numpy::{IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2}; use pyo3::prelude::*; use pyo3::types::PyType; use pyo3::wrap_pyfunction; @@ -95,6 +95,106 @@ fn check_bits_max7(bits: u8) -> PyResult<()> { Ok(()) } +fn not_contiguous_err() -> PyErr { + pyo3::exceptions::PyValueError::new_err( + "array must be C-contiguous; call np.ascontiguousarray() first", + ) +} + +/// Candidate / doc-id slice obtained from a NumPy array, either borrowed +/// zero-copy (already `uint32` and contiguous) or owned (converted from another +/// integer dtype). The `Borrowed` variant keeps the `PyReadonlyArray` guard +/// alive so its slice stays valid across a GIL-released `py.detach` call. +enum CandidateIds<'py> { + Borrowed(PyReadonlyArray1<'py, u32>), + Owned(Vec), +} + +impl CandidateIds<'_> { + fn as_slice(&self) -> PyResult<&[u32]> { + match self { + CandidateIds::Borrowed(ro) => ro.as_slice().map_err(|_| not_contiguous_err()), + CandidateIds::Owned(v) => Ok(v), + } + } +} + +/// Coerce a NumPy candidate/doc-id array of *any* integer dtype to `u32`. +/// +/// The core takes `&[u32]` doc ids (the corpus is capped at `MAX_VECTORS = 2^26`, +/// well below `u32::MAX`), so the natural binding type is `PyReadonlyArray1`. +/// But rust-numpy matches that dtype *exactly*, while NumPy index arrays are +/// `int64` by default (`np.arange`, `np.where()[0]`, `np.array([...])`, fancy +/// indexing, `np.argpartition`). Requiring `uint32` made the most natural ways to +/// build a candidate set raise an opaque `TypeError`, even though ordvec's own +/// candidate generators (`top_m_candidates*`) already emit `uint32`. +/// +/// We accept any integer dtype and convert with **checked** bounds: a negative id +/// or one exceeding `u32::MAX` is a clean `ValueError`, never a silent wrap — note +/// `np.asarray(x, dtype=uint32)` would wrap `-1 -> 4294967295` and `2**32 -> 0`, +/// which would then score the wrong document. Already-`uint32` contiguous arrays +/// are borrowed zero-copy; every other dtype is copied once (candidate shortlists +/// are small relative to the scan; large-M FFI is tracked in issue #11). The +/// in-range (`< n`) check stays with the caller, which knows the corpus size. +fn coerce_candidate_ids<'py>(arr: &Bound<'py, PyAny>, what: &str) -> PyResult> { + // Fast path: already uint32 and C-contiguous -> borrow, zero-copy. + if let Ok(a) = arr.cast::>() { + let ro = a.readonly(); + if ro.as_slice().is_ok() { + return Ok(CandidateIds::Borrowed(ro)); + } + // Non-contiguous uint32 falls through to the copying path below. + } + + macro_rules! try_int_dtype { + ($t:ty) => { + if let Ok(a) = arr.cast::>() { + let ro = a.readonly(); + let view = ro.as_array(); + let mut out = Vec::with_capacity(view.len()); + for &x in view.iter() { + out.push(u32::try_from(x).map_err(|_| { + pyo3::exceptions::PyValueError::new_err(format!( + "{what} {x} is out of range for a u32 index \ + (must be in 0..=4294967295)" + )) + })?); + } + return Ok(CandidateIds::Owned(out)); + } + }; + } + // u32 first so non-contiguous uint32 (which fell through above) is handled + // before the wider/narrower dtypes; order is otherwise irrelevant since each + // downcast is an exact dtype match. + try_int_dtype!(u32); + try_int_dtype!(i64); + try_int_dtype!(u64); + try_int_dtype!(i32); + try_int_dtype!(i16); + try_int_dtype!(u16); + try_int_dtype!(i8); + try_int_dtype!(u8); + + Err(pyo3::exceptions::PyTypeError::new_err(format!( + "{what} must be a 1-D NumPy array of an integer dtype (e.g. uint32 or int64), got {}", + arr.get_type().name()? + ))) +} + +/// Reject any id `>= n` (out of the corpus) as a typed `IndexError`. The core +/// hard-asserts ids are in range (an AVX-512 path issues a raw gather load), so an +/// out-of-range id would otherwise surface as a `PanicException` that leaks the +/// internal buffer geometry. +fn check_ids_in_range(ids: &[u32], n: usize, what: &str) -> PyResult<()> { + if let Some(&bad) = ids.iter().find(|&&di| (di as usize) >= n) { + return Err(pyo3::exceptions::PyIndexError::new_err(format!( + "{what} {bad} out of range (index holds {n} vectors)" + ))); + } + Ok(()) +} + // ===================================================================== // Rank-mode retrieval bindings: Rank, RankQuant, Bitmap, SignBitmap. // @@ -424,11 +524,17 @@ impl RankQuant { /// indices (mapped from the local candidate slot); slots that could not be /// filled are returned as ``-1``. Uses the same AVX-512 → AVX2 → scalar /// dispatch as ``search_asymmetric``. + /// + /// ``candidates`` may be a 1-D array of any integer dtype — the ``uint32`` + /// emitted by ``top_m_candidates``/``top_m_candidates_batched`` or a plain + /// ``int64`` index array (``np.arange``, ``np.where(...)[0]``, fancy-index + /// results). Ids are converted to ``uint32``; a negative id, one ``>= 2**32``, + /// or one ``>= len(self)`` raises a ``ValueError``/``IndexError``. fn search_asymmetric_subset<'py>( &self, py: Python<'py>, query: PyReadonlyArray1, - candidates: PyReadonlyArray1, + candidates: &Bound<'py, PyAny>, k: usize, ) -> PyResult> { let q = query.as_array(); @@ -439,23 +545,12 @@ impl RankQuant { ) })?; ensure_finite(q_slice)?; - let c = candidates.as_array(); - let c_slice = c.as_slice().ok_or_else(|| { - pyo3::exceptions::PyValueError::new_err( - "array must be C-contiguous; call np.ascontiguousarray() first", - ) - })?; - // Validate every candidate id against the index size *before* calling the - // core. The core gathers `self.packed[di * bpv ..]` for each id and only - // `assert!`s the bound, so an out-of-range id would panic inside Rust and - // surface across pyo3 as a `PanicException` that leaks the internal buffer - // geometry. Reject it here as a typed `IndexError` instead. - let n = self.inner.len(); - if let Some(&bad) = c_slice.iter().find(|&&di| (di as usize) >= n) { - return Err(pyo3::exceptions::PyIndexError::new_err(format!( - "candidate id {bad} out of range (index holds {n} vectors)" - ))); - } + // Accept candidate ids of any integer dtype (NumPy index arrays are int64 + // by default) and convert to the core's u32 with checked bounds, then + // reject any id outside the corpus before dispatch. + let cands = coerce_candidate_ids(candidates, "candidate id")?; + let c_slice = cands.as_slice()?; + check_ids_in_range(c_slice, self.inner.len(), "candidate id")?; let (scores, ids) = py.detach(|| self.inner.search_asymmetric_subset(q_slice, c_slice, k)); Ok((scores.into_pyarray(py), ids.into_pyarray(py))) } @@ -735,8 +830,8 @@ impl Bitmap { /// Compute bitmap-overlap scores for a subset of doc IDs against a pre-built /// query bitmap. `q_bitmap` is a 1-D `uint64` array of `dim / 64` words /// (e.g. from [`Bitmap.build_query_bitmap_fp32`]); `doc_ids` is a 1-D - /// `uint32` array that must be in range. Returns a 1-D `uint32` array of - /// overlap scores aligned to `doc_ids`. + /// integer array of any dtype (converted to `uint32`) whose ids must be in + /// range. Returns a 1-D `uint32` array of overlap scores aligned to `doc_ids`. /// /// `doc_ids` must additionally be sorted ascending. This is a *Python-side /// ergonomic policy*, not a core requirement: the Rust core accepts unsorted @@ -748,7 +843,7 @@ impl Bitmap { &self, py: Python<'py>, q_bitmap: PyReadonlyArray1, - doc_ids: PyReadonlyArray1, + doc_ids: &Bound<'py, PyAny>, ) -> PyResult>> { let qb = q_bitmap.as_array(); let qb_slice = qb.as_slice().ok_or_else(|| { @@ -763,21 +858,12 @@ impl Bitmap { qb_slice.len() ))); } - let ids = doc_ids.as_array(); - let ids_slice = ids.as_slice().ok_or_else(|| { - pyo3::exceptions::PyValueError::new_err( - "array must be C-contiguous; call np.ascontiguousarray() first", - ) - })?; - // Bound-check every id before dispatch: the core hard-asserts ids are in - // range (the AVX-512 path issues a raw load), so an OOB id would surface - // as a PanicException. Reject it as a typed IndexError instead. - let n = self.inner.len(); - if let Some(&bad) = ids_slice.iter().find(|&&di| (di as usize) >= n) { - return Err(pyo3::exceptions::PyIndexError::new_err(format!( - "doc id {bad} out of range (index holds {n} vectors)" - ))); - } + // Accept doc ids of any integer dtype (NumPy index arrays are int64 by + // default) and convert to u32 with checked bounds, then reject any id + // outside the corpus before dispatch. + let doc_ids = coerce_candidate_ids(doc_ids, "doc id")?; + let ids_slice = doc_ids.as_slice()?; + check_ids_in_range(ids_slice, self.inner.len(), "doc id")?; // Python-side ergonomic policy (NOT a core correctness requirement): // the Rust core scores unsorted ids correctly in input order, just with // worse cache locality. The binding requires the sorted, cache-friendly diff --git a/ordvec-python/tests/test_input_guards.py b/ordvec-python/tests/test_input_guards.py index 1a6a5bba..2920366d 100644 --- a/ordvec-python/tests/test_input_guards.py +++ b/ordvec-python/tests/test_input_guards.py @@ -226,6 +226,128 @@ def test_subset_in_range_candidates_still_work(): assert int(ids[0]) == 0 # self-query → self ranks first +# ------------------------------------------------------------------- +# Candidate / doc-id dtype acceptance. The core takes u32 ids, but NumPy +# index arrays are int64 by default (np.arange, np.where()[0], fancy +# indexing, np.argpartition). The binding accepts any integer dtype and +# converts to u32 with checked bounds rather than rejecting non-uint32 +# with an opaque "ndarray cannot be cast as ndarray" TypeError. +# ------------------------------------------------------------------- + + +# Every integer dtype a candidate set might realistically arrive in. +INT_DTYPES = [ + np.uint32, # ordvec's own top_m_candidates output (zero-copy fast path) + np.int64, # NumPy default — np.arange / np.array([...]) / np.where()[0] + np.int32, + np.uint64, + np.int16, + np.uint16, + np.int8, + np.uint8, +] + + +@pytest.mark.parametrize("dtype", INT_DTYPES) +def test_subset_candidate_dtype_accepted_and_equivalent(dtype): + # Any integer dtype is accepted and yields results identical to the + # uint32 reference. (Friend's report: int64/int32/uint64 used to raise + # TypeError.) ids stay small enough for int8 (max 127). + vectors = unit_vectors(50, 128, seed=0) + idx = RankQuant(dim=128, bits=2) + idx.add(vectors) + ref = np.array([0, 7, 13, 25, 41], dtype=np.uint32) + s_ref, id_ref = idx.search_asymmetric_subset(vectors[0], ref, k=4) + + s, ids = idx.search_asymmetric_subset(vectors[0], ref.astype(dtype), k=4) + np.testing.assert_array_equal(ids, id_ref) + np.testing.assert_array_equal(s, s_ref) + + +def test_subset_candidate_natural_numpy_idioms_accepted(): + # The ways a user actually builds a candidate set — all int64. + vectors = unit_vectors(50, 128, seed=0) + idx = RankQuant(dim=128, bits=2) + idx.add(vectors) + for candidates in ( + np.arange(20), + np.where(np.arange(50) % 5 == 0)[0], + np.argpartition(np.arange(50)[::-1], 15)[:15], + ): + assert candidates.dtype == np.int64 # confirm the trap dtype + scores, ids = idx.search_asymmetric_subset(vectors[0], candidates, k=3) + assert scores.shape == (3,) and ids.shape == (3,) + + +def test_subset_noncontiguous_uint32_candidates_accepted(): + # A strided uint32 view (non-contiguous) is copied through the checked + # path rather than rejected — the contiguous fast path is just an + # optimisation, not a requirement, for candidate ids. + vectors = unit_vectors(50, 128, seed=0) + idx = RankQuant(dim=128, bits=2) + idx.add(vectors) + strided = np.arange(0, 48, 2, dtype=np.uint32)[::3] + assert not strided.flags["C_CONTIGUOUS"] + scores, ids = idx.search_asymmetric_subset(vectors[0], strided, k=3) + assert scores.shape == (3,) and ids.shape == (3,) + + +def test_subset_negative_candidate_raises_value_error(): + # Fail-loud: a negative id must NOT silently wrap to a huge u32 + # (np.asarray(-1, uint32) -> 4294967295). Reject with a clear ValueError. + vectors = unit_vectors(50, 128, seed=0) + idx = RankQuant(dim=128, bits=2) + idx.add(vectors) + candidates = np.array([0, -1, 5], dtype=np.int64) + with pytest.raises(ValueError, match="out of range for a u32"): + idx.search_asymmetric_subset(vectors[0], candidates, k=2) + + +def test_subset_overflow_candidate_raises_value_error(): + # Fail-loud: an id >= 2**32 must NOT silently wrap (2**32 + 5 -> 5) and + # score the wrong document. Reject with a clear ValueError. + vectors = unit_vectors(50, 128, seed=0) + idx = RankQuant(dim=128, bits=2) + idx.add(vectors) + candidates = np.array([0, 2**32 + 5], dtype=np.int64) + with pytest.raises(ValueError, match="out of range for a u32"): + idx.search_asymmetric_subset(vectors[0], candidates, k=2) + + +def test_subset_out_of_range_int64_candidate_raises_index_error(): + # The >= len(index) check applies regardless of input dtype. + vectors = unit_vectors(50, 128, seed=0) + idx = RankQuant(dim=128, bits=2) + idx.add(vectors) + candidates = np.array([0, 999], dtype=np.int64) + with pytest.raises(IndexError, match="out of range"): + idx.search_asymmetric_subset(vectors[0], candidates, k=2) + + +def test_subset_float_candidates_raise_type_error(): + # A non-integer dtype is a clear TypeError, not a silent truncation. + vectors = unit_vectors(50, 128, seed=0) + idx = RankQuant(dim=128, bits=2) + idx.add(vectors) + candidates = np.array([0.0, 1.0, 2.0], dtype=np.float32) + with pytest.raises(TypeError, match="integer dtype"): + idx.search_asymmetric_subset(vectors[0], candidates, k=2) + + +def test_body_overlap_doc_ids_int64_accepted(): + # Bitmap.body_overlap_scores_subset shares the same coercion: int64 + # (sorted) doc_ids are accepted; the ascending-order policy still holds. + vectors = unit_vectors(50, 128, seed=0) + bm = Bitmap(dim=128, n_top=32) + bm.add(vectors) + qb = bm.build_query_bitmap_fp32(vectors[0]) + ids_sorted = np.array([2, 4, 8, 16, 32], dtype=np.int64) + out = bm.body_overlap_scores_subset(qb, ids_sorted) + assert out.shape == (5,) + with pytest.raises(ValueError, match="sorted"): + bm.body_overlap_scores_subset(qb, np.array([16, 2, 4], dtype=np.int64)) + + # ------------------------------------------------------------------- # Wrong array width (ncols/len != dim) -> ValueError, not silent # misalignment or a reshape panic. The core derives n = len/dim and only diff --git a/ordvec-python/tests/test_redteam_fuzz.py b/ordvec-python/tests/test_redteam_fuzz.py index a1216c38..4902c3e6 100644 --- a/ordvec-python/tests/test_redteam_fuzz.py +++ b/ordvec-python/tests/test_redteam_fuzz.py @@ -92,6 +92,19 @@ def unit_vectors(n: int, dim: int, seed: int = 0) -> np.ndarray: object, ] +# Non-integer dtypes a candidate / doc-id param must reject. Integer dtypes are +# accepted (converted to u32 with checked bounds — see test_input_guards.py); a +# float / complex / object array is a clear TypeError, never a truncation. +_NON_INTEGER_ID_DTYPES = [ + np.float16, + np.float32, + np.float64, + np.complex64, + np.complex128, + bool, + object, +] + # Integer scalars that must NOT wrap to a giant usize / OOM. PyO3 maps a negative # Python int and anything >= 2**64 to a clean OverflowError on usize conversion. _BAD_INT_SCALARS = [-1, -(2**40), 2**64, 2**70] @@ -277,9 +290,11 @@ def test_rank_add_wrong_dtype_raises_type_error(dt): idx.add(bad) -@pytest.mark.parametrize("dt", [np.int64, np.uint8, np.int32, np.float32, np.uint64, np.int8]) -def test_subset_candidates_wrong_dtype_raises_type_error(dt): - # candidates must be uint32; int64/uint8/etc must not be reinterpreted. +@pytest.mark.parametrize("dt", _NON_INTEGER_ID_DTYPES) +def test_subset_candidates_noninteger_dtype_raises_type_error(dt): + # Candidate ids accept any *integer* dtype (converted to u32 by value, never + # by byte reinterpretation — see test_input_guards.py); a non-integer dtype + # must be a clean TypeError, not a silent truncation. idx = RankQuant(dim=64, bits=2) idx.add(unit_vectors(10, 64)) cand = np.array([0, 1, 2], dtype=dt) @@ -287,6 +302,20 @@ def test_subset_candidates_wrong_dtype_raises_type_error(dt): idx.search_asymmetric_subset(unit_vectors(1, 64, seed=1)[0], cand, k=2) +@pytest.mark.parametrize("dt", [np.uint8, np.int8, np.int64, np.uint64]) +def test_subset_candidates_integer_dtype_converted_by_value(dt): + # Adversarial: a narrow/wide integer dtype is read as logical *values*, not + # reinterpreted bytes. uint8 [1,2,3] -> ids 1,2,3, identical to uint32. + idx = RankQuant(dim=64, bits=2) + idx.add(unit_vectors(10, 64)) + q = unit_vectors(1, 64, seed=1)[0] + ref = np.array([1, 2, 3], dtype=np.uint32) + s_ref, id_ref = idx.search_asymmetric_subset(q, ref, k=3) + s, ids = idx.search_asymmetric_subset(q, ref.astype(dt), k=3) + np.testing.assert_array_equal(ids, id_ref) + np.testing.assert_array_equal(s, s_ref) + + @pytest.mark.parametrize("dt", [np.uint32, np.int64, np.float64, np.uint8]) def test_body_overlap_q_bitmap_wrong_dtype_raises_type_error(dt): # q_bitmap must be uint64; a narrower/float dtype must be a clean TypeError. @@ -297,8 +326,10 @@ def test_body_overlap_q_bitmap_wrong_dtype_raises_type_error(dt): idx.body_overlap_scores_subset(qb, np.array([0, 1], dtype=np.uint32)) -@pytest.mark.parametrize("dt", [np.int64, np.uint8, np.int32, np.uint64]) -def test_body_overlap_doc_ids_wrong_dtype_raises_type_error(dt): +@pytest.mark.parametrize("dt", _NON_INTEGER_ID_DTYPES) +def test_body_overlap_doc_ids_noninteger_dtype_raises_type_error(dt): + # doc_ids accept any integer dtype (converted to u32 with checked bounds); + # a non-integer dtype is a clean TypeError. idx = Bitmap(dim=128, n_top=32) idx.add(unit_vectors(10, 128)) qb = idx.build_query_bitmap_fp32(unit_vectors(1, 128, seed=1)[0]) From 67c69a467fb133bbd4ad4a6b5d4fc2ee76937544 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Wed, 27 May 2026 19:08:48 -0500 Subject: [PATCH 02/10] feat(python): normalize vector input to f32 at the boundary; refine id contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every embedding parameter was declared PyReadonlyArray{1,2}, which rust-numpy matches strictly — so float64 (NumPy's default for np.array([...]) and most API embeddings) and float16 raised an opaque TypeError. The premise of ordvec is 'float vector in -> rank/sign transform', so float32 is the internal working dtype, not a contract the caller must pre-satisfy. Add two choke-point helpers, as_f32_1d / as_f32_2d, that every embedding entry point (18 methods + free functions) routes through, so dtype/layout/finite policy is defined once: - coerce float16/float32/float64 -> float32. The rank/sign transforms are order/sign-only and f64->f32 rounding is monotonic, so coercion is faithful; the asymmetric LUT scores against f32-quantised docs, so sub-f32 query precision is meaningless there too. - REJECT bool + all integer dtypes (TypeError): a {0,1}/narrow-int vector rank-transforms to a degenerate index-tie artefact (silent retrieval garbage) — a deliberate usage-error guard, not an ergonomic gap. - reject complex/object/string (complex would silently drop the imaginary part). - reject wrong ndim (TypeError). - reject non-C-contiguous input (ValueError) BEFORE coercion, so a transposed float64 is never silently laundered into a contiguous float32 (a hidden copy can dominate runtime / poison benchmarks — the copy decision stays with the caller). - all-finite check AFTER coercion (f64 > f32::MAX rounds to +inf — caught here). Candidate IDs are a different boundary (labels, not measurements), so they keep the permissive contract: rename coerce_candidate_ids -> as_u32_ids_1d, accept any range-safe integer dtype (int64 included), reject bool/float/negative/overflow with sharper messages. Coherent split: vectors = float-only/C-contiguous/finite; candidate IDs = integer labels range-safe-coerced to u32. Core, persistence (.tvr/.tvrq/.tvbm/.tvsb store no floats), and the integer primitives are untouched. Inverts the 5 tests that asserted the old strict-f32 rejection (now coerced+faithful); adds test_input_dtype.py covering the full accept/reject matrix across all four index types. 483 pytest pass; fmt + clippy -D warnings clean. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Nelson Spence --- ordvec-python/src/lib.rs | 217 ++++++++++++++++++----- ordvec-python/tests/test_bitmap.py | 16 +- ordvec-python/tests/test_input_dtype.py | 177 ++++++++++++++++++ ordvec-python/tests/test_input_guards.py | 2 +- ordvec-python/tests/test_rank.py | 20 ++- ordvec-python/tests/test_rank_quant.py | 22 ++- ordvec-python/tests/test_redteam_fuzz.py | 8 +- ordvec-python/tests/test_sign_bitmap.py | 18 +- 8 files changed, 410 insertions(+), 70 deletions(-) create mode 100644 ordvec-python/tests/test_input_dtype.py diff --git a/ordvec-python/src/lib.rs b/ordvec-python/src/lib.rs index b6e2d881..3bc2ee8e 100644 --- a/ordvec-python/src/lib.rs +++ b/ordvec-python/src/lib.rs @@ -136,7 +136,7 @@ impl CandidateIds<'_> { /// are borrowed zero-copy; every other dtype is copied once (candidate shortlists /// are small relative to the scan; large-M FFI is tracked in issue #11). The /// in-range (`< n`) check stays with the caller, which knows the corpus size. -fn coerce_candidate_ids<'py>(arr: &Bound<'py, PyAny>, what: &str) -> PyResult> { +fn as_u32_ids_1d<'py>(arr: &Bound<'py, PyAny>, what: &str) -> PyResult> { // Fast path: already uint32 and C-contiguous -> borrow, zero-copy. if let Ok(a) = arr.cast::>() { let ro = a.readonly(); @@ -176,9 +176,13 @@ fn coerce_candidate_ids<'py>(arr: &Bound<'py, PyAny>, what: &str) -> PyResult PyResult<()> { Ok(()) } +fn f32_dtype_error(arr: &Bound<'_, PyAny>) -> PyErr { + let got = arr + .getattr("dtype") + .map(|d| d.to_string()) + .unwrap_or_else(|_| "a non-array object".to_owned()); + pyo3::exceptions::PyTypeError::new_err(format!( + "expected a floating-point NumPy array (float16/float32/float64), got {got}; ordvec \ + rank/sign-transforms real vectors and converts them to float32 at the boundary — \ + boolean, integer, complex, object, and string arrays are rejected (a {{0, 1}} or \ + narrow-integer vector rank-transforms to a degenerate index artefact, not a meaningful \ + ordinal signal; call .astype(np.float32) to opt in deliberately)" + )) +} + +fn not_contiguous_f32_err() -> PyErr { + pyo3::exceptions::PyValueError::new_err( + "expected a C-contiguous NumPy array; got non-contiguous input. Use \ + np.ascontiguousarray(x, dtype=np.float32) if you intend to make a copy.", + ) +} + +/// Reject a non-`float32` input whose dtype isn't a float kind, or whose `ndim` +/// doesn't match. Error types mirror the strict-extraction contract: a bad dtype +/// or rank is a `TypeError`, ordered so the dtype message wins. Layout is checked +/// separately by [`require_c_contiguous`] *after* this (a `ValueError`). +fn gate_float_ndim(arr: &Bound<'_, PyAny>, ndim: usize) -> PyResult<()> { + let kind = arr + .getattr("dtype") + .and_then(|d| d.getattr("kind")) + .and_then(|k| k.extract::()); + if !matches!(kind, Ok('f')) { + return Err(f32_dtype_error(arr)); + } + let nd = arr.getattr("ndim").and_then(|n| n.extract::()); + if !matches!(nd, Ok(n) if n == ndim) { + return Err(pyo3::exceptions::PyTypeError::new_err(format!( + "expected a {ndim}-D float array" + ))); + } + Ok(()) +} + +/// Reject a non-`C`-contiguous original array *before* any dtype coercion, so a +/// transposed/strided float64 can't be silently laundered into a contiguous +/// float32 (that hidden copy can dominate runtime / poison benchmarks — the copy +/// decision stays with the caller). +fn require_c_contiguous(arr: &Bound<'_, PyAny>) -> PyResult<()> { + let contiguous = arr + .getattr("flags") + .and_then(|f| f.getattr("c_contiguous")) + .and_then(|c| c.extract::()) + .unwrap_or(false); + if contiguous { + Ok(()) + } else { + Err(not_contiguous_f32_err()) + } +} + +/// Present an embedding vector as a 1-D `float32` `PyReadonlyArray`, converting at +/// the boundary. The premise of ordvec is *float vector in → rank/sign transform*, +/// so float32 is the internal working dtype, not a contract the caller must +/// pre-satisfy: `float64` (the default for `np.array([...])` and most API +/// embeddings) and `float16` are coerced. The transforms that consume the floats +/// are order-only (rank transform, top-bucket bitmap) or sign-only, and `f64→f32` +/// rounding is *monotonic* — it can never reorder two coordinates, only collapse a +/// near-tie at the f32 floor, strictly less perturbation than the rank/bucket +/// quantisation already applied. The asymmetric-query LUT keeps the floats but +/// scores against f32-quantised docs, so sub-`f32` query precision is meaningless +/// there too. +/// +/// Rejected (matching exception type): non-float dtype — bool / integer / complex / +/// object / string — and wrong `ndim` (`TypeError`); a non-`C`-contiguous original +/// (`ValueError`, checked before coercion). Bool and narrow integers are +/// *deliberately* rejected: a `{0, 1}` or few-valued vector rank-transforms to an +/// index-tie artefact, i.e. silent retrieval garbage. The all-finite check runs on +/// the post-coercion f32 (an `f64 > f32::MAX` rounds to `+inf` — caught here, not +/// silently indexed). Already-`float32` contiguous arrays are borrowed zero-copy. +fn as_f32_1d<'py>(arr: &Bound<'py, PyAny>) -> PyResult> { + let ro = if let Ok(a) = arr.cast::>() { + a.readonly() + } else { + gate_float_ndim(arr, 1)?; + require_c_contiguous(arr)?; + arr.py() + .import("numpy")? + .getattr("ascontiguousarray")? + .call1((arr, "float32"))? + .cast::>() + .map(|a| a.readonly()) + .map_err(|_| pyo3::exceptions::PyTypeError::new_err("expected a 1-D float array"))? + }; + ensure_finite( + ro.as_array() + .as_slice() + .ok_or_else(not_contiguous_f32_err)?, + )?; + Ok(ro) +} + +/// 2-D `(n, dim)` counterpart of [`as_f32_1d`] for the `add` / batched-query paths. +/// Same contract; see [`as_f32_1d`] for the full rationale. +fn as_f32_2d<'py>(arr: &Bound<'py, PyAny>) -> PyResult> { + let ro = if let Ok(a) = arr.cast::>() { + a.readonly() + } else { + gate_float_ndim(arr, 2)?; + require_c_contiguous(arr)?; + arr.py() + .import("numpy")? + .getattr("ascontiguousarray")? + .call1((arr, "float32"))? + .cast::>() + .map(|a| a.readonly()) + .map_err(|_| { + pyo3::exceptions::PyTypeError::new_err( + "expected a 2-D float array of shape (n, dim)", + ) + })? + }; + ensure_finite( + ro.as_array() + .as_slice() + .ok_or_else(not_contiguous_f32_err)?, + )?; + Ok(ro) +} + // ===================================================================== // Rank-mode retrieval bindings: Rank, RankQuant, Bitmap, SignBitmap. // @@ -228,7 +360,8 @@ impl Rank { format!("Rank(dim={}, n={})", self.inner.dim(), self.inner.len()) } - fn add(&mut self, py: Python<'_>, vectors: PyReadonlyArray2) -> PyResult<()> { + fn add<'py>(&mut self, py: Python<'py>, vectors: &Bound<'py, PyAny>) -> PyResult<()> { + let vectors = as_f32_2d(vectors)?; let arr = vectors.as_array(); check_width(arr.ncols(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { @@ -236,7 +369,6 @@ impl Rank { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; // Release the GIL around the parallel rank-transform / pack so other // Python threads run during a bulk add. `slice` (`&[f32]`) and // `&mut self.inner` are both `Ungil`, so no pointer juggling is needed. @@ -256,9 +388,10 @@ impl Rank { fn search<'py>( &self, py: Python<'py>, - queries: PyReadonlyArray2, + queries: &Bound<'py, PyAny>, k: usize, ) -> PyResult> { + let queries = as_f32_2d(queries)?; let arr = queries.as_array(); check_width(arr.ncols(), self.inner.dim())?; let nq = arr.nrows(); @@ -267,7 +400,6 @@ impl Rank { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; let results = py.detach(|| self.inner.search(slice, k)); let scores = numpy::ndarray::Array2::from_shape_vec((nq, results.k), results.scores) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))? @@ -283,9 +415,10 @@ impl Rank { fn search_asymmetric<'py>( &self, py: Python<'py>, - queries: PyReadonlyArray2, + queries: &Bound<'py, PyAny>, k: usize, ) -> PyResult> { + let queries = as_f32_2d(queries)?; let arr = queries.as_array(); check_width(arr.ncols(), self.inner.dim())?; let nq = arr.nrows(); @@ -294,7 +427,6 @@ impl Rank { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; let results = py.detach(|| self.inner.search_asymmetric(slice, k)); let scores = numpy::ndarray::Array2::from_shape_vec((nq, results.k), results.scores) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))? @@ -411,7 +543,8 @@ impl RankQuant { ) } - fn add(&mut self, py: Python<'_>, vectors: PyReadonlyArray2) -> PyResult<()> { + fn add<'py>(&mut self, py: Python<'py>, vectors: &Bound<'py, PyAny>) -> PyResult<()> { + let vectors = as_f32_2d(vectors)?; let arr = vectors.as_array(); check_width(arr.ncols(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { @@ -419,7 +552,6 @@ impl RankQuant { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; // Release the GIL around the parallel rank-transform / pack so other // Python threads run during a bulk add. `slice` (`&[f32]`) and // `&mut self.inner` are both `Ungil`, so no pointer juggling is needed. @@ -437,9 +569,10 @@ impl RankQuant { fn search<'py>( &self, py: Python<'py>, - queries: PyReadonlyArray2, + queries: &Bound<'py, PyAny>, k: usize, ) -> PyResult> { + let queries = as_f32_2d(queries)?; let arr = queries.as_array(); check_width(arr.ncols(), self.inner.dim())?; let nq = arr.nrows(); @@ -448,7 +581,6 @@ impl RankQuant { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; let results = py.detach(|| self.inner.search(slice, k)); let scores = numpy::ndarray::Array2::from_shape_vec((nq, results.k), results.scores) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))? @@ -463,9 +595,10 @@ impl RankQuant { fn search_asymmetric<'py>( &self, py: Python<'py>, - queries: PyReadonlyArray2, + queries: &Bound<'py, PyAny>, k: usize, ) -> PyResult> { + let queries = as_f32_2d(queries)?; let arr = queries.as_array(); check_width(arr.ncols(), self.inner.dim())?; let nq = arr.nrows(); @@ -474,7 +607,6 @@ impl RankQuant { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; let results = py.detach(|| self.inner.search_asymmetric(slice, k)); let scores = numpy::ndarray::Array2::from_shape_vec((nq, results.k), results.scores) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))? @@ -533,10 +665,11 @@ impl RankQuant { fn search_asymmetric_subset<'py>( &self, py: Python<'py>, - query: PyReadonlyArray1, + query: &Bound<'py, PyAny>, candidates: &Bound<'py, PyAny>, k: usize, ) -> PyResult> { + let query = as_f32_1d(query)?; let q = query.as_array(); check_width(q.len(), self.inner.dim())?; let q_slice = q.as_slice().ok_or_else(|| { @@ -544,11 +677,10 @@ impl RankQuant { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(q_slice)?; // Accept candidate ids of any integer dtype (NumPy index arrays are int64 // by default) and convert to the core's u32 with checked bounds, then // reject any id outside the corpus before dispatch. - let cands = coerce_candidate_ids(candidates, "candidate id")?; + let cands = as_u32_ids_1d(candidates, "candidate id")?; let c_slice = cands.as_slice()?; check_ids_in_range(c_slice, self.inner.len(), "candidate id")?; let (scores, ids) = py.detach(|| self.inner.search_asymmetric_subset(q_slice, c_slice, k)); @@ -630,7 +762,8 @@ impl Bitmap { ) } - fn add(&mut self, py: Python<'_>, vectors: PyReadonlyArray2) -> PyResult<()> { + fn add<'py>(&mut self, py: Python<'py>, vectors: &Bound<'py, PyAny>) -> PyResult<()> { + let vectors = as_f32_2d(vectors)?; let arr = vectors.as_array(); check_width(arr.ncols(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { @@ -638,7 +771,6 @@ impl Bitmap { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; // Release the GIL around the parallel rank-transform / pack so other // Python threads run during a bulk add. `slice` (`&[f32]`) and // `&mut self.inner` are both `Ungil`, so no pointer juggling is needed. @@ -658,9 +790,10 @@ impl Bitmap { fn search<'py>( &self, py: Python<'py>, - queries: PyReadonlyArray2, + queries: &Bound<'py, PyAny>, k: usize, ) -> PyResult> { + let queries = as_f32_2d(queries)?; let arr = queries.as_array(); check_width(arr.ncols(), self.inner.dim())?; let nq = arr.nrows(); @@ -669,7 +802,6 @@ impl Bitmap { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; let results = py.detach(|| self.inner.search(slice, k)); let scores = numpy::ndarray::Array2::from_shape_vec((nq, results.k), results.scores) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))? @@ -686,9 +818,10 @@ impl Bitmap { fn top_m_candidates<'py>( &self, py: Python<'py>, - query: PyReadonlyArray1, + query: &Bound<'py, PyAny>, m: usize, ) -> PyResult>> { + let query = as_f32_1d(query)?; let arr = query.as_array(); check_width(arr.len(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { @@ -696,7 +829,6 @@ impl Bitmap { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; let cands = py.detach(|| self.inner.top_m_candidates(slice, m)); Ok(cands.into_pyarray(py)) } @@ -707,8 +839,9 @@ impl Bitmap { fn build_query_bitmap_fp32<'py>( &self, py: Python<'py>, - query: PyReadonlyArray1, + query: &Bound<'py, PyAny>, ) -> PyResult>> { + let query = as_f32_1d(query)?; let arr = query.as_array(); check_width(arr.len(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { @@ -716,7 +849,6 @@ impl Bitmap { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; Ok(self.inner.build_query_bitmap_fp32(slice).into_pyarray(py)) } @@ -728,9 +860,10 @@ impl Bitmap { fn top_m_candidates_batched<'py>( &self, py: Python<'py>, - queries: PyReadonlyArray2, + queries: &Bound<'py, PyAny>, m: usize, ) -> PyResult>> { + let queries = as_f32_2d(queries)?; let arr = queries.as_array(); check_width(arr.ncols(), self.inner.dim())?; let batch = arr.nrows(); @@ -739,7 +872,6 @@ impl Bitmap { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; // Guard the core's internal `batch * n` (scores) and `batch * qpv` // (query bitmaps) allocations BEFORE the call: an overflow there wraps // and then indexes out of bounds (a panic), so convert it to a clean @@ -773,7 +905,7 @@ impl Bitmap { fn top_m_candidates_batched_chunked<'py>( &self, py: Python<'py>, - queries: PyReadonlyArray2, + queries: &Bound<'py, PyAny>, m: usize, batch_size: usize, ) -> PyResult>> { @@ -782,6 +914,7 @@ impl Bitmap { "batch_size must be > 0", )); } + let queries = as_f32_2d(queries)?; let arr = queries.as_array(); check_width(arr.ncols(), self.inner.dim())?; let n_queries = arr.nrows(); @@ -790,7 +923,6 @@ impl Bitmap { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; // Clamp batch_size to the query count so a very large value can't // overflow `batch_size * dim` inside the core (which fails loud with an // overflow panic). A batch larger than the workload is just one chunk, @@ -861,7 +993,7 @@ impl Bitmap { // Accept doc ids of any integer dtype (NumPy index arrays are int64 by // default) and convert to u32 with checked bounds, then reject any id // outside the corpus before dispatch. - let doc_ids = coerce_candidate_ids(doc_ids, "doc id")?; + let doc_ids = as_u32_ids_1d(doc_ids, "doc id")?; let ids_slice = doc_ids.as_slice()?; check_ids_in_range(ids_slice, self.inner.len(), "doc id")?; // Python-side ergonomic policy (NOT a core correctness requirement): @@ -969,7 +1101,8 @@ impl SignBitmap { ) } - fn add(&mut self, py: Python<'_>, vectors: PyReadonlyArray2) -> PyResult<()> { + fn add<'py>(&mut self, py: Python<'py>, vectors: &Bound<'py, PyAny>) -> PyResult<()> { + let vectors = as_f32_2d(vectors)?; let arr = vectors.as_array(); check_width(arr.ncols(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { @@ -977,7 +1110,6 @@ impl SignBitmap { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; // Release the GIL around the parallel rank-transform / pack so other // Python threads run during a bulk add. `slice` (`&[f32]`) and // `&mut self.inner` are both `Ungil`, so no pointer juggling is needed. @@ -998,9 +1130,10 @@ impl SignBitmap { fn top_m_candidates<'py>( &self, py: Python<'py>, - query: PyReadonlyArray1, + query: &Bound<'py, PyAny>, m: usize, ) -> PyResult>> { + let query = as_f32_1d(query)?; let arr = query.as_array(); check_width(arr.len(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { @@ -1008,7 +1141,6 @@ impl SignBitmap { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; let cands = py.detach(|| self.inner.top_m_candidates(slice, m)); Ok(cands.into_pyarray(py)) } @@ -1022,9 +1154,10 @@ impl SignBitmap { fn top_m_candidates_batched<'py>( &self, py: Python<'py>, - queries: PyReadonlyArray2, + queries: &Bound<'py, PyAny>, m: usize, ) -> PyResult>> { + let queries = as_f32_2d(queries)?; let arr = queries.as_array(); check_width(arr.ncols(), self.inner.dim())?; let batch = arr.nrows(); @@ -1033,7 +1166,6 @@ impl SignBitmap { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; // Guard the core's internal `batch * n` (scores) and `batch * qpv` // (query bitmaps) allocations BEFORE the call: an overflow there wraps // and then indexes out of bounds (a panic), so convert it to a clean @@ -1066,8 +1198,9 @@ impl SignBitmap { fn build_query_bitmap<'py>( &self, py: Python<'py>, - query: PyReadonlyArray1, + query: &Bound<'py, PyAny>, ) -> PyResult>> { + let query = as_f32_1d(query)?; let arr = query.as_array(); check_width(arr.len(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { @@ -1075,7 +1208,6 @@ impl SignBitmap { "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; Ok(self.inner.build_query_bitmap(slice).into_pyarray(py)) } @@ -1144,8 +1276,9 @@ impl SignBitmap { #[pyfunction] fn rank_transform<'py>( py: Python<'py>, - v: PyReadonlyArray1, + v: &Bound<'py, PyAny>, ) -> PyResult>> { + let v = as_f32_1d(v)?; let arr = v.as_array(); let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( @@ -1333,7 +1466,7 @@ fn rankquant_norm(d: usize, bits: u8) -> PyResult { fn search_asymmetric_byte_lut<'py>( py: Python<'py>, index: PyRef<'_, RankQuant>, - queries: PyReadonlyArray2, + queries: &Bound<'py, PyAny>, k: usize, ) -> PyResult> { if index.inner.bits() == 1 { @@ -1341,6 +1474,7 @@ fn search_asymmetric_byte_lut<'py>( "search_asymmetric_byte_lut is a benchmark-only helper and does not support bits=1; use RankQuant.search_asymmetric instead", )); } + let queries = as_f32_2d(queries)?; let arr = queries.as_array(); check_width(arr.ncols(), index.inner.dim())?; let nq = arr.nrows(); @@ -1349,7 +1483,6 @@ fn search_asymmetric_byte_lut<'py>( "array must be C-contiguous; call np.ascontiguousarray() first", ) })?; - ensure_finite(slice)?; // Deref the GIL-bound `PyRef` to a plain `&RankQuant` *before* the closure: // capturing `index` (a `PyRef`) directly would make the closure non-`Ungil`, // but a bare `&ordvec_core::RankQuant` is fine to carry across `detach`. diff --git a/ordvec-python/tests/test_bitmap.py b/ordvec-python/tests/test_bitmap.py index 10502136..8cf95a38 100644 --- a/ordvec-python/tests/test_bitmap.py +++ b/ordvec-python/tests/test_bitmap.py @@ -159,11 +159,17 @@ def test_top_m_candidates_deterministic_across_repeated_calls(): assert set(runs[0]) == {int(i) for i in indices[0].tolist()} -def test_add_float64_is_rejected(): - idx = Bitmap(dim=64, n_top=8) - v64 = np.random.default_rng(0).standard_normal((4, 64)).astype(np.float64) - with pytest.raises(TypeError): - idx.add(v64) +def test_add_float64_is_coerced(): + # float64 accepted and coerced to float32 at the boundary; same index as f32. + rng = np.random.default_rng(0) + v32 = rng.standard_normal((20, 64)).astype(np.float32) + a = Bitmap(dim=64, n_top=8) + a.add(v32) + b = Bitmap(dim=64, n_top=8) + b.add(v32.astype(np.float64)) + assert len(a) == len(b) == 20 + q = rng.standard_normal((3, 64)).astype(np.float32) + np.testing.assert_array_equal(a.search(q, k=5)[1], b.search(q, k=5)[1]) def test_dim_above_u16_max_rejected(): diff --git a/ordvec-python/tests/test_input_dtype.py b/ordvec-python/tests/test_input_dtype.py new file mode 100644 index 00000000..4396d9d9 --- /dev/null +++ b/ordvec-python/tests/test_input_dtype.py @@ -0,0 +1,177 @@ +"""Embedding-input dtype/layout boundary contract (``as_f32_1d`` / ``as_f32_2d``). + +ordvec normalises real-valued vector input to float32 at the FFI boundary — the +premise is *float vector in -> rank/sign transform*, so float32 is the internal +working dtype, not a contract the caller must pre-satisfy. The policy is uniform +across the four index types because every embedding entry point routes through +the same two choke-point helpers. + + Accepted: float16 / float32 / float64, C-contiguous, finite after coercion + Rejected: bool, integers, complex, object, string -> TypeError + wrong ndim (scalar / 3-D) -> TypeError + non-contiguous (transpose / stride) -> ValueError + (never silently copied — the copy decision stays with the caller) + non-finite after coercion (NaN / inf / f64 > f32::MAX) -> ValueError + +Why bool/int are rejected rather than coerced: a ``{0.0, 1.0}`` or narrow-integer +vector rank-transforms to an index-tie artefact (silent retrieval garbage), so +those are a deliberate usage-error guard, not an ergonomic gap. Candidate *IDs* +are a different boundary (labels, not measurements) and DO accept int64 — see +test_input_guards.py. +""" +from __future__ import annotations + +import numpy as np +import pytest + +from ordvec import Bitmap, Rank, RankQuant, SignBitmap + +INDEX_CLASSES = [Rank, RankQuant, Bitmap, SignBitmap] + + +def _make(index_cls): + if index_cls is RankQuant: + return RankQuant(dim=64, bits=2) + if index_cls is Bitmap: + return Bitmap(dim=64, n_top=16) + return index_cls(dim=64) + + +def f32(n, dim=64, seed=0): + return np.random.default_rng(seed).standard_normal((n, dim)).astype(np.float32) + + +# ------------------------------------------------------------------- +# Accepted: float dtypes, coerced to f32. +# ------------------------------------------------------------------- + + +@pytest.mark.parametrize("index_cls", INDEX_CLASSES) +@pytest.mark.parametrize("dtype", [np.float16, np.float32, np.float64]) +def test_float_dtypes_accepted(index_cls, dtype): + idx = _make(index_cls) + idx.add(np.ascontiguousarray(f32(8).astype(dtype))) + assert len(idx) == 8 + + +@pytest.mark.parametrize("index_cls", INDEX_CLASSES) +def test_float64_coercion_is_faithful(index_cls): + # f64 and f32 builds of the same values yield the same index — the rank/sign + # transform is order/sign-only, and f64->f32 rounding is monotonic. + v32 = f32(12) + a = _make(index_cls) + a.add(v32) + b = _make(index_cls) + b.add(v32.astype(np.float64)) + assert len(a) == len(b) == 12 + + +# ------------------------------------------------------------------- +# Rejected dtypes -> TypeError (bool/int/complex/object/string). +# ------------------------------------------------------------------- + + +@pytest.mark.parametrize("index_cls", INDEX_CLASSES) +@pytest.mark.parametrize( + "dtype", + [np.int8, np.int32, np.int64, np.uint8, np.uint32, np.uint64, bool, np.complex64, np.complex128, object], +) +def test_nonfloat_dtypes_rejected(index_cls, dtype): + with pytest.raises(TypeError): + _make(index_cls).add(np.ones((8, 64), dtype=dtype)) + + +@pytest.mark.parametrize("index_cls", INDEX_CLASSES) +def test_string_dtype_rejected(index_cls): + with pytest.raises(TypeError): + _make(index_cls).add(np.full((8, 64), "x")) + + +# ------------------------------------------------------------------- +# Rejected ndim -> TypeError (scalar / 3-D). +# ------------------------------------------------------------------- + + +@pytest.mark.parametrize("index_cls", INDEX_CLASSES) +def test_scalar_rejected(index_cls): + with pytest.raises(TypeError): + _make(index_cls).add(np.float32(1.0)) + + +@pytest.mark.parametrize("index_cls", INDEX_CLASSES) +def test_3d_rejected(index_cls): + with pytest.raises(TypeError): + _make(index_cls).add(np.zeros((2, 3, 64), dtype=np.float32)) + + +# ------------------------------------------------------------------- +# Rejected layout -> ValueError, checked BEFORE coercion so a float64 transpose +# is never silently laundered into a contiguous float32 (hidden copy). +# ------------------------------------------------------------------- + + +@pytest.mark.parametrize("index_cls", INDEX_CLASSES) +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) +def test_transpose_rejected_not_silently_copied(index_cls, dtype): + v = np.asfortranarray(f32(8).astype(dtype)) # (8, 64) F-order -> non-C-contiguous + assert not v.flags["C_CONTIGUOUS"] + with pytest.raises(ValueError, match="C-contiguous"): + _make(index_cls).add(v) + + +# ------------------------------------------------------------------- +# Rejected values -> ValueError, finite check AFTER coercion. +# ------------------------------------------------------------------- + + +@pytest.mark.parametrize("index_cls", INDEX_CLASSES) +@pytest.mark.parametrize("bad", [np.nan, np.inf, -np.inf]) +def test_nonfinite_rejected(index_cls, bad): + v = f32(8) + v[2, 5] = bad + with pytest.raises(ValueError, match="finite"): + _make(index_cls).add(v) + + +@pytest.mark.filterwarnings("ignore:overflow encountered in cast") +@pytest.mark.parametrize("index_cls", INDEX_CLASSES) +def test_float64_overflow_to_inf_rejected(index_cls): + # 1e300 is finite in float64 but rounds to +inf in float32 (NumPy's cast emits + # a RuntimeWarning, ignored here) — the finite check runs on the POST-coercion + # f32, so this is caught, not silently indexed. + v = f32(8).astype(np.float64) + v[0, 0] = 1e300 + with pytest.raises(ValueError, match="finite"): + _make(index_cls).add(v) + + +# ------------------------------------------------------------------- +# The 1-D query path (as_f32_1d) shares the contract. +# ------------------------------------------------------------------- + + +def test_query_float64_accepted_and_faithful(): + corpus = f32(30) + a = Bitmap(dim=64, n_top=16) + a.add(corpus) + q32 = f32(1, seed=7)[0] + np.testing.assert_array_equal( + a.top_m_candidates(q32, m=10), + a.top_m_candidates(q32.astype(np.float64), m=10), + ) + + +def test_query_bool_rejected(): + a = SignBitmap(dim=64) + a.add(f32(10)) + with pytest.raises(TypeError): + a.top_m_candidates(np.ones(64, dtype=bool), m=5) + + +def test_query_noncontiguous_rejected(): + a = Bitmap(dim=64, n_top=16) + a.add(f32(10)) + strided = np.ascontiguousarray(f32(1, dim=128)[0])[::2] # len 64, non-contiguous + assert not strided.flags["C_CONTIGUOUS"] + with pytest.raises(ValueError, match="C-contiguous"): + a.top_m_candidates(strided, m=5) diff --git a/ordvec-python/tests/test_input_guards.py b/ordvec-python/tests/test_input_guards.py index 2920366d..b48b535a 100644 --- a/ordvec-python/tests/test_input_guards.py +++ b/ordvec-python/tests/test_input_guards.py @@ -330,7 +330,7 @@ def test_subset_float_candidates_raise_type_error(): idx = RankQuant(dim=128, bits=2) idx.add(vectors) candidates = np.array([0.0, 1.0, 2.0], dtype=np.float32) - with pytest.raises(TypeError, match="integer dtype"): + with pytest.raises(TypeError, match="integer"): idx.search_asymmetric_subset(vectors[0], candidates, k=2) diff --git a/ordvec-python/tests/test_rank.py b/ordvec-python/tests/test_rank.py index a61b24ba..0be221e3 100644 --- a/ordvec-python/tests/test_rank.py +++ b/ordvec-python/tests/test_rank.py @@ -155,10 +155,16 @@ def test_swap_remove_shrinks_length(): assert len(idx) == 9 -def test_add_float64_is_rejected(): - # pyo3 numpy binding is strict on dtype — float64 is not silently - # up/down-converted; the caller must convert. - idx = Rank(dim=64) - v64 = np.random.default_rng(0).standard_normal((4, 64)).astype(np.float64) - with pytest.raises(TypeError): - idx.add(v64) +def test_add_float64_is_coerced(): + # ordvec normalizes real-valued input to float32 at the boundary: float64 + # (NumPy's default) is accepted and coerced, producing the same index as the + # explicitly-f32 array. Rank discards magnitude, so coercion is lossless here. + rng = np.random.default_rng(0) + v32 = rng.standard_normal((9, 64)).astype(np.float32) + a = Rank(dim=64) + a.add(v32) + b = Rank(dim=64) + b.add(v32.astype(np.float64)) + assert len(a) == len(b) == 9 + q = rng.standard_normal((3, 64)).astype(np.float32) + np.testing.assert_array_equal(a.search(q, k=5)[1], b.search(q, k=5)[1]) diff --git a/ordvec-python/tests/test_rank_quant.py b/ordvec-python/tests/test_rank_quant.py index e63ca3ad..1f1023b9 100644 --- a/ordvec-python/tests/test_rank_quant.py +++ b/ordvec-python/tests/test_rank_quant.py @@ -122,13 +122,21 @@ def test_load_rejects_nonexistent_file(): @pytest.mark.parametrize("bits", [1, 2, 4]) -def test_add_float64_is_rejected(bits): - # pyo3 numpy binding is strict on dtype — float64 is not silently - # converted, the caller must convert. - idx = RankQuant(dim=64, bits=bits) - v64 = np.random.default_rng(0).standard_normal((4, 64)).astype(np.float64) - with pytest.raises(TypeError): - idx.add(v64) +def test_add_float64_is_coerced(bits): + # float64 is accepted and coerced to float32 at the boundary. The asymmetric + # LUT keeps the query floats but scores against f32-quantised docs, so f64 + # precision beyond f32 is meaningless — same results as an f32 index. + rng = np.random.default_rng(0) + v32 = rng.standard_normal((9, 64)).astype(np.float32) + a = RankQuant(dim=64, bits=bits) + a.add(v32) + b = RankQuant(dim=64, bits=bits) + b.add(v32.astype(np.float64)) + assert len(a) == len(b) == 9 + q = rng.standard_normal((3, 64)).astype(np.float32) + np.testing.assert_array_equal( + a.search_asymmetric(q, k=5)[1], b.search_asymmetric(q, k=5)[1] + ) @pytest.mark.parametrize("bits", [1, 2, 4]) diff --git a/ordvec-python/tests/test_redteam_fuzz.py b/ordvec-python/tests/test_redteam_fuzz.py index 4902c3e6..436b624e 100644 --- a/ordvec-python/tests/test_redteam_fuzz.py +++ b/ordvec-python/tests/test_redteam_fuzz.py @@ -79,10 +79,12 @@ def unit_vectors(n: int, dim: int, seed: int = 0) -> np.ndarray: _SNAN = float(np.array([0x7FA00000], dtype=np.uint32).view(np.float32)[0]) _NEG_SNAN = float(np.array([0xFFA00000], dtype=np.uint32).view(np.float32)[0]) -# dtypes rust-numpy must reject for an f32 array param (strict, no coercion). +# dtypes a float32 embedding param must reject. float16/float32/float64 are now +# coerced at the boundary (see test_input_dtype.py); integer and bool arrays are +# *deliberately* rejected (a {0,1} or narrow-int vector rank-transforms to a +# degenerate index artefact, not a meaningful ordinal signal), and complex/object +# would be a silent reinterpretation. _WRONG_F32_DTYPES = [ - np.float64, - np.float16, np.int32, np.int64, np.uint8, diff --git a/ordvec-python/tests/test_sign_bitmap.py b/ordvec-python/tests/test_sign_bitmap.py index 8f550c80..69a0552d 100644 --- a/ordvec-python/tests/test_sign_bitmap.py +++ b/ordvec-python/tests/test_sign_bitmap.py @@ -152,11 +152,19 @@ def test_load_rejects_nonexistent_file(): SignBitmap.load("/nonexistent/path/does-not-exist.tvsb") -def test_add_float64_is_rejected(): - idx = SignBitmap(dim=64) - v64 = np.random.default_rng(0).standard_normal((4, 64)).astype(np.float64) - with pytest.raises(TypeError): - idx.add(v64) +def test_add_float64_is_coerced(): + # float64 accepted and coerced to float32 at the boundary; same index as f32. + rng = np.random.default_rng(0) + v32 = rng.standard_normal((20, 64)).astype(np.float32) + a = SignBitmap(dim=64) + a.add(v32) + b = SignBitmap(dim=64) + b.add(v32.astype(np.float64)) + assert len(a) == len(b) == 20 + q = rng.standard_normal(64).astype(np.float32) + np.testing.assert_array_equal( + a.top_m_candidates(q, m=5), b.top_m_candidates(q, m=5) + ) @pytest.mark.parametrize("dim", [64, 128, 256, 1024]) From 4ea6d0bb09c241ca5e0fff10b71ea34adbc8ecab Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Wed, 27 May 2026 19:26:48 -0500 Subject: [PATCH 03/10] fix(python): validate vector width before f32 coercion (codex stop-gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The choke-point helpers coerced non-float32 input to float32 (a full copy) before the caller's width check ran, so a wrong-width float64 array was fully converted only to be rejected — wasteful, and a potential OOM on a large misshapen input. Move the width check into as_f32_1d / as_f32_2d via a cheap shape-metadata read (axis_len), so dtype -> ndim -> width -> contiguity are all validated on the ORIGINAL array before the ascontiguousarray copy. as_f32_2d takes the expected dim; as_f32_1d takes Option (rank_transform has no width constraint). Adds a wrong-width-float64 regression across all four index types. 491 pytest pass; fmt + clippy -D warnings clean. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Nelson Spence --- ordvec-python/src/lib.rs | 90 +++++++++++++------------ ordvec-python/tests/test_input_dtype.py | 12 ++++ 2 files changed, 59 insertions(+), 43 deletions(-) diff --git a/ordvec-python/src/lib.rs b/ordvec-python/src/lib.rs index 3bc2ee8e..a770cb85 100644 --- a/ordvec-python/src/lib.rs +++ b/ordvec-python/src/lib.rs @@ -258,6 +258,13 @@ fn require_c_contiguous(arr: &Bound<'_, PyAny>) -> PyResult<()> { } } +/// Length of `arr`'s axis `axis` from its `shape` tuple, read as cheap metadata so +/// width can be validated *before* any coercion copy — rejecting a wrong-shaped +/// large float64 array must not first allocate its float32 twin. +fn axis_len(arr: &Bound<'_, PyAny>, axis: usize) -> PyResult { + arr.getattr("shape")?.get_item(axis)?.extract::() +} + /// Present an embedding vector as a 1-D `float32` `PyReadonlyArray`, converting at /// the boundary. The premise of ordvec is *float vector in → rank/sign transform*, /// so float32 is the internal working dtype, not a contract the caller must @@ -271,17 +278,29 @@ fn require_c_contiguous(arr: &Bound<'_, PyAny>) -> PyResult<()> { /// there too. /// /// Rejected (matching exception type): non-float dtype — bool / integer / complex / -/// object / string — and wrong `ndim` (`TypeError`); a non-`C`-contiguous original -/// (`ValueError`, checked before coercion). Bool and narrow integers are +/// object / string — and wrong `ndim` (`TypeError`); a width that doesn't match the +/// index dimension, or a non-`C`-contiguous original (`ValueError`) — both checked +/// on the original *before* coercion, so a wrong-shaped large array is never copied +/// just to be rejected. Bool and narrow integers are /// *deliberately* rejected: a `{0, 1}` or few-valued vector rank-transforms to an /// index-tie artefact, i.e. silent retrieval garbage. The all-finite check runs on /// the post-coercion f32 (an `f64 > f32::MAX` rounds to `+inf` — caught here, not /// silently indexed). Already-`float32` contiguous arrays are borrowed zero-copy. -fn as_f32_1d<'py>(arr: &Bound<'py, PyAny>) -> PyResult> { +fn as_f32_1d<'py>( + arr: &Bound<'py, PyAny>, + expected_len: Option, +) -> PyResult> { let ro = if let Ok(a) = arr.cast::>() { - a.readonly() + let ro = a.readonly(); + if let Some(dim) = expected_len { + check_width(ro.as_array().len(), dim)?; + } + ro } else { gate_float_ndim(arr, 1)?; + if let Some(dim) = expected_len { + check_width(axis_len(arr, 0)?, dim)?; + } require_c_contiguous(arr)?; arr.py() .import("numpy")? @@ -301,11 +320,14 @@ fn as_f32_1d<'py>(arr: &Bound<'py, PyAny>) -> PyResult(arr: &Bound<'py, PyAny>) -> PyResult> { +fn as_f32_2d<'py>(arr: &Bound<'py, PyAny>, dim: usize) -> PyResult> { let ro = if let Ok(a) = arr.cast::>() { - a.readonly() + let ro = a.readonly(); + check_width(ro.as_array().ncols(), dim)?; + ro } else { gate_float_ndim(arr, 2)?; + check_width(axis_len(arr, 1)?, dim)?; require_c_contiguous(arr)?; arr.py() .import("numpy")? @@ -361,9 +383,8 @@ impl Rank { } fn add<'py>(&mut self, py: Python<'py>, vectors: &Bound<'py, PyAny>) -> PyResult<()> { - let vectors = as_f32_2d(vectors)?; + let vectors = as_f32_2d(vectors, self.inner.dim())?; let arr = vectors.as_array(); - check_width(arr.ncols(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( "array must be C-contiguous; call np.ascontiguousarray() first", @@ -391,9 +412,8 @@ impl Rank { queries: &Bound<'py, PyAny>, k: usize, ) -> PyResult> { - let queries = as_f32_2d(queries)?; + let queries = as_f32_2d(queries, self.inner.dim())?; let arr = queries.as_array(); - check_width(arr.ncols(), self.inner.dim())?; let nq = arr.nrows(); let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( @@ -418,9 +438,8 @@ impl Rank { queries: &Bound<'py, PyAny>, k: usize, ) -> PyResult> { - let queries = as_f32_2d(queries)?; + let queries = as_f32_2d(queries, self.inner.dim())?; let arr = queries.as_array(); - check_width(arr.ncols(), self.inner.dim())?; let nq = arr.nrows(); let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( @@ -544,9 +563,8 @@ impl RankQuant { } fn add<'py>(&mut self, py: Python<'py>, vectors: &Bound<'py, PyAny>) -> PyResult<()> { - let vectors = as_f32_2d(vectors)?; + let vectors = as_f32_2d(vectors, self.inner.dim())?; let arr = vectors.as_array(); - check_width(arr.ncols(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( "array must be C-contiguous; call np.ascontiguousarray() first", @@ -572,9 +590,8 @@ impl RankQuant { queries: &Bound<'py, PyAny>, k: usize, ) -> PyResult> { - let queries = as_f32_2d(queries)?; + let queries = as_f32_2d(queries, self.inner.dim())?; let arr = queries.as_array(); - check_width(arr.ncols(), self.inner.dim())?; let nq = arr.nrows(); let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( @@ -598,9 +615,8 @@ impl RankQuant { queries: &Bound<'py, PyAny>, k: usize, ) -> PyResult> { - let queries = as_f32_2d(queries)?; + let queries = as_f32_2d(queries, self.inner.dim())?; let arr = queries.as_array(); - check_width(arr.ncols(), self.inner.dim())?; let nq = arr.nrows(); let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( @@ -669,9 +685,8 @@ impl RankQuant { candidates: &Bound<'py, PyAny>, k: usize, ) -> PyResult> { - let query = as_f32_1d(query)?; + let query = as_f32_1d(query, Some(self.inner.dim()))?; let q = query.as_array(); - check_width(q.len(), self.inner.dim())?; let q_slice = q.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( "array must be C-contiguous; call np.ascontiguousarray() first", @@ -763,9 +778,8 @@ impl Bitmap { } fn add<'py>(&mut self, py: Python<'py>, vectors: &Bound<'py, PyAny>) -> PyResult<()> { - let vectors = as_f32_2d(vectors)?; + let vectors = as_f32_2d(vectors, self.inner.dim())?; let arr = vectors.as_array(); - check_width(arr.ncols(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( "array must be C-contiguous; call np.ascontiguousarray() first", @@ -793,9 +807,8 @@ impl Bitmap { queries: &Bound<'py, PyAny>, k: usize, ) -> PyResult> { - let queries = as_f32_2d(queries)?; + let queries = as_f32_2d(queries, self.inner.dim())?; let arr = queries.as_array(); - check_width(arr.ncols(), self.inner.dim())?; let nq = arr.nrows(); let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( @@ -821,9 +834,8 @@ impl Bitmap { query: &Bound<'py, PyAny>, m: usize, ) -> PyResult>> { - let query = as_f32_1d(query)?; + let query = as_f32_1d(query, Some(self.inner.dim()))?; let arr = query.as_array(); - check_width(arr.len(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( "array must be C-contiguous; call np.ascontiguousarray() first", @@ -841,9 +853,8 @@ impl Bitmap { py: Python<'py>, query: &Bound<'py, PyAny>, ) -> PyResult>> { - let query = as_f32_1d(query)?; + let query = as_f32_1d(query, Some(self.inner.dim()))?; let arr = query.as_array(); - check_width(arr.len(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( "array must be C-contiguous; call np.ascontiguousarray() first", @@ -863,9 +874,8 @@ impl Bitmap { queries: &Bound<'py, PyAny>, m: usize, ) -> PyResult>> { - let queries = as_f32_2d(queries)?; + let queries = as_f32_2d(queries, self.inner.dim())?; let arr = queries.as_array(); - check_width(arr.ncols(), self.inner.dim())?; let batch = arr.nrows(); let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( @@ -914,9 +924,8 @@ impl Bitmap { "batch_size must be > 0", )); } - let queries = as_f32_2d(queries)?; + let queries = as_f32_2d(queries, self.inner.dim())?; let arr = queries.as_array(); - check_width(arr.ncols(), self.inner.dim())?; let n_queries = arr.nrows(); let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( @@ -1102,9 +1111,8 @@ impl SignBitmap { } fn add<'py>(&mut self, py: Python<'py>, vectors: &Bound<'py, PyAny>) -> PyResult<()> { - let vectors = as_f32_2d(vectors)?; + let vectors = as_f32_2d(vectors, self.inner.dim())?; let arr = vectors.as_array(); - check_width(arr.ncols(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( "array must be C-contiguous; call np.ascontiguousarray() first", @@ -1133,9 +1141,8 @@ impl SignBitmap { query: &Bound<'py, PyAny>, m: usize, ) -> PyResult>> { - let query = as_f32_1d(query)?; + let query = as_f32_1d(query, Some(self.inner.dim()))?; let arr = query.as_array(); - check_width(arr.len(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( "array must be C-contiguous; call np.ascontiguousarray() first", @@ -1157,9 +1164,8 @@ impl SignBitmap { queries: &Bound<'py, PyAny>, m: usize, ) -> PyResult>> { - let queries = as_f32_2d(queries)?; + let queries = as_f32_2d(queries, self.inner.dim())?; let arr = queries.as_array(); - check_width(arr.ncols(), self.inner.dim())?; let batch = arr.nrows(); let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( @@ -1200,9 +1206,8 @@ impl SignBitmap { py: Python<'py>, query: &Bound<'py, PyAny>, ) -> PyResult>> { - let query = as_f32_1d(query)?; + let query = as_f32_1d(query, Some(self.inner.dim()))?; let arr = query.as_array(); - check_width(arr.len(), self.inner.dim())?; let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( "array must be C-contiguous; call np.ascontiguousarray() first", @@ -1278,7 +1283,7 @@ fn rank_transform<'py>( py: Python<'py>, v: &Bound<'py, PyAny>, ) -> PyResult>> { - let v = as_f32_1d(v)?; + let v = as_f32_1d(v, None)?; let arr = v.as_array(); let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( @@ -1474,9 +1479,8 @@ fn search_asymmetric_byte_lut<'py>( "search_asymmetric_byte_lut is a benchmark-only helper and does not support bits=1; use RankQuant.search_asymmetric instead", )); } - let queries = as_f32_2d(queries)?; + let queries = as_f32_2d(queries, index.inner.dim())?; let arr = queries.as_array(); - check_width(arr.ncols(), index.inner.dim())?; let nq = arr.nrows(); let slice = arr.as_slice().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err( diff --git a/ordvec-python/tests/test_input_dtype.py b/ordvec-python/tests/test_input_dtype.py index 4396d9d9..a42e57c0 100644 --- a/ordvec-python/tests/test_input_dtype.py +++ b/ordvec-python/tests/test_input_dtype.py @@ -104,6 +104,18 @@ def test_3d_rejected(index_cls): _make(index_cls).add(np.zeros((2, 3, 64), dtype=np.float32)) +@pytest.mark.parametrize("index_cls", INDEX_CLASSES) +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) +def test_wrong_width_rejected_before_coercion(index_cls, dtype): + # Width is a cheap metadata check validated on the ORIGINAL array *before* any + # dtype coercion: a wrong-width float64 is rejected with the same ValueError as + # float32, without first allocating its float32 twin (a wrong-shaped large + # array must not be copied just to be rejected). + bad = np.ones((4, 128), dtype=dtype) # width 128 != dim 64 + with pytest.raises(ValueError, match="dimension"): + _make(index_cls).add(bad) + + # ------------------------------------------------------------------- # Rejected layout -> ValueError, checked BEFORE coercion so a float64 transpose # is never silently laundered into a contiguous float32 (hidden copy). From b5af2fa5aa2073d79354225b5ff8eee701fb4844 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Wed, 27 May 2026 21:22:59 -0500 Subject: [PATCH 04/10] ci: unified tag-triggered release pipeline with SLSA provenance on the Release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces changelog.yml + release-crate.yml + release-python.yml with one tag-triggered release.yml. Cutting a stable vX.Y.Z tag now fully automates build (crate + wheels + sdist) -> GitHub artifact attestation -> SLSA Build-L3 provenance -> attach ALL assets (incl. multiple.intoto.jsonl + a .sigstore.json bundle) to the GitHub Release -> un-draft. Only the crates.io + PyPI publishes are gated (Environments with Required Reviewers). Full OIDC, no stored tokens. Fixes OpenSSF Scorecard Signed-Releases = 0: v0.2.0's assets were attached by hand and the attestations dropped. Scorecard's probes read ONLY GitHub release-asset filenames (.intoto.jsonl -> 10, .sigstore.json -> 8; registry/PEP740 ignored by design). The slsa-github-generator multiple.intoto.jsonl gives the provenance probe its 10; the attest-build-provenance .sigstore.json is a backup signing-probe asset + powers 'gh attestation verify'. A single release-asset writer (release-assets) + explicit needs: edges remove the cross-workflow coordination that forced the manual attach. Fail-closed: publishes need attest + provenance. REQUIRES before the first real tag (both fail CLOSED at the gate until done): (1) crates.io + PyPI Trusted-Publisher configs re-pointed to workflow=release.yml (env stays crates-io / pypi); (2) a fork dry-run to validate end-to-end — a fork cannot publish (Trusted Publishing OIDC is bound to this repo). actionlint clean; NOT yet run against a real tag. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Nelson Spence --- .github/workflows/changelog.yml | 90 ----- .github/workflows/release-crate.yml | 158 --------- .github/workflows/release-python.yml | 226 ------------ .github/workflows/release.yml | 491 +++++++++++++++++++++++++++ 4 files changed, 491 insertions(+), 474 deletions(-) delete mode 100644 .github/workflows/changelog.yml delete mode 100644 .github/workflows/release-crate.yml delete mode 100644 .github/workflows/release-python.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml deleted file mode 100644 index 381be389..00000000 --- a/.github/workflows/changelog.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: changelog - -# Cut a GitHub Release with git-cliff-generated notes when a stable -# vMAJOR.MINOR.PATCH tag is pushed. This job does NOT write to `main` — `main` -# is branch-protected (no direct pushes, signed commits, required reviews), so -# the in-repo CHANGELOG.md is curated via the release PR (see CONTRIBUTING.md -# "Releases"). git-cliff authors only the public GitHub Release notes here. -# -# The Release is created as a DRAFT so the generated notes get a final human -# check before they go public (this repo holds a fiction-free, publishable-grade -# bar). Drop `--draft` below once you trust the output. -# -# Tag handling: GitHub Actions tag globs cannot express "digits only" (and `+` -# is unreliable there), so the trigger is a deliberately loose -# `v[0-9]*.[0-9]*.[0-9]*` and the gate step enforces strict SemVer — each of -# MAJOR/MINOR/PATCH is `0` or a non-zero-led integer (no leading zeros), with no -# suffix. A pre-release (v0.3.0-rc.1), leading-zero (v01.2.3), or otherwise -# non-SemVer (v1.2.3.4, vfoo.bar.baz) tag therefore wakes the job but skips -# every step below the gate — no checkout, no git-cliff, no Release. -# -# Injection-safety: the only external value used in a run: step is the tag name, -# bound to the `TAG_NAME` env var from `github.ref_name` and used as the quoted -# "$TAG_NAME" (never interpolated into the run: script), so a crafted tag name -# cannot break out of the command. - -on: - push: - tags: - - "v[0-9]*.[0-9]*.[0-9]*" - -concurrency: - group: changelog-${{ github.ref }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - release-notes: - name: release notes (git-cliff) - runs-on: ubuntu-latest - permissions: - contents: write # create the draft GitHub Release - steps: - - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 - with: - egress-policy: audit - - name: Gate on a stable SemVer tag - id: semver - shell: bash - env: - TAG_NAME: ${{ github.ref_name }} - run: | - # Strict SemVer core: each segment is 0 or [1-9][0-9]* (no leading - # zeros), and there is no pre-release / build suffix. - semver='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' - if [[ "$TAG_NAME" =~ $semver ]]; then - echo "ok=true" >> "$GITHUB_OUTPUT" - else - echo "::notice::$TAG_NAME is not a stable vMAJOR.MINOR.PATCH tag; skipping release notes." - echo "ok=false" >> "$GITHUB_OUTPUT" - fi - # actions/checkout v6.0.2, SHA-pinned — a contents:write job pins ALL - # tooling to immutable refs (no mutable tags), git-cliff-action included. - - if: steps.semver.outputs.ok == 'true' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 # full history + tags for git-cliff - persist-credentials: false - - name: Generate release notes - if: steps.semver.outputs.ok == 'true' - # orhun/git-cliff-action v4.8.0, SHA-pinned (supply-chain hygiene for a - # workflow holding a contents:write token). - uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 - with: - config: cliff.toml - args: --latest --strip header - env: - OUTPUT: RELEASE_NOTES.md - - name: Create draft GitHub Release - if: steps.semver.outputs.ok == 'true' - env: - GH_TOKEN: ${{ github.token }} - TAG_NAME: ${{ github.ref_name }} - run: | - gh release create "$TAG_NAME" \ - --draft \ - --verify-tag \ - --title "$TAG_NAME" \ - --notes-file RELEASE_NOTES.md diff --git a/.github/workflows/release-crate.yml b/.github/workflows/release-crate.yml deleted file mode 100644 index bab21b57..00000000 --- a/.github/workflows/release-crate.yml +++ /dev/null @@ -1,158 +0,0 @@ -# Publishes the `ordvec` core crate to crates.io via Trusted Publishing (OIDC). -# -# PUBLISH-HELD by design: -# * workflow_dispatch only — nothing ships unless a maintainer runs it. -# * the publish job is bound to the `crates-io` GitHub Environment; add a -# Required reviewer there for a human approval gate (enforced once the repo -# is public, or now on a paid plan). -# * build + test + `cargo publish --dry-run` run first; publish only on success. -# -# Rust toolchain via dtolnay/rust-toolchain, SHA-pinned to the stable branch -# (immutable action code; supply-chain hygiene for the publish pipeline — -# `with: toolchain: stable` selects the compiler). MSRV 1.89 stays gated by -# ci.yml's `msrv` job. -# -# One-time setup before the first OIDC publish: -# * crates.io: ordvec > Settings > Trusted Publishing > Add a GitHub publisher -# owner=Fieldnote-Echo repo=ordvec workflow=release-crate.yml env=crates-io -# * GitHub: Settings > Environments > create `crates-io`. -name: release-crate - -on: - workflow_dispatch: - -# Serialize releases; never cancel an in-flight publish. -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - verify: - name: build + test + publish dry-run - runs-on: ubuntu-latest - steps: - - name: Harden the runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 - with: - egress-policy: audit - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (2026-03-27) - with: - toolchain: stable - - name: Test the core crate - run: cargo test -p ordvec --locked - - name: Dry-run the publish (packaging + metadata + verify build) - run: cargo publish -p ordvec --locked --dry-run - - require-ci-green: - # A manual release dispatch must not ship a commit whose CI has not gone - # green. The `verify` job above re-runs tests + the publish dry-run, but it - # does NOT cover lint, the no-default/experimental configs, MSRV 1.89, the - # deps + cargo-deny supply-chain gates, the AVX-512-SDE / wasm-simd128 - # runtime coverage (ci.yml), the cargo-fuzz loader/FastScan smoke (fuzz.yml), - # or the CodeQL static scan of the shipped Rust source (codeql.yml). Rather - # than duplicate (and drift from) those workflows, require that EACH of them - # concluded `success` for this exact SHA on main before publish runs. - # - # Only per-push-to-main workflows are gated, so a run for the release SHA is - # guaranteed to exist: audit.yml is schedule-only (no per-SHA run to assert); - # coverage*/scorecard are advisory or external-service-flaky; zizmor/ - # actionlint are pre-merge CI hygiene, not artifact safety. - name: require full CI green for this commit - runs-on: ubuntu-latest - permissions: - contents: read - actions: read - steps: - - name: Harden the runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 - with: - egress-policy: audit - - name: assert ci.yml, fuzz.yml and codeql.yml are green for this commit - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - SHA: ${{ github.sha }} - run: | - set -euo pipefail - # Require a SUCCESSFUL run for this SHA *on main* for each workflow. - # Filtering on the branch as well as head_sha stops a green run for the - # same commit on an unrelated branch from satisfying the gate. - for wf in ci.yml fuzz.yml codeql.yml; do - ok="$(gh api \ - "repos/${REPO}/actions/workflows/${wf}/runs?head_sha=${SHA}&branch=main&status=success&per_page=20" \ - --jq '[.workflow_runs[] | select(.head_branch == "main" and .conclusion == "success")] | length')" - echo "successful ${wf} runs for ${SHA} on main: ${ok}" - if [ "${ok}" -lt 1 ]; then - echo "::error::no successful ${wf} run for ${SHA} on main. Push to main, let CI pass, then re-run this release." - exit 1 - fi - done - - publish: - name: publish to crates.io - needs: [verify, require-ci-green] - runs-on: ubuntu-latest - environment: crates-io - permissions: - contents: read - id-token: write - # Required by actions/attest-build-provenance to write the SLSA - # provenance attestation for the published .crate. - attestations: write - steps: - - name: Harden the runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 - with: - egress-policy: audit - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (2026-03-27) - with: - toolchain: stable - # Supply-chain attestation + SBOM, run BEFORE the publish so a failed - # attestation fails the release closed (the .crate is never pushed if we - # can't attest it). crates.io does not host SBOMs, so the CycloneDX SBOM - # is uploaded as a build artifact rather than shipped with the crate; the - # attestation below is GitHub-issued SLSA build provenance bound to the - # exact published .crate. - - name: Package the crate (produces the artifact to attest) - # Emits target/package/ordvec-.crate — the precise byte-for-byte - # tarball that `cargo publish` uploads, so the provenance covers the - # published artifact. - run: cargo package -p ordvec --locked - - name: Generate CycloneDX SBOM for the crate - # cargo-cyclonedx writes ordvec.cdx.json (named .cdx.json) at the - # repo root for the core crate. Verified locally: the command rejects - # `-p`; package scoping is via --manifest-path against the root manifest, - # which is the `ordvec` package. - run: | - cargo install cargo-cyclonedx --version 0.5.9 --locked - cargo cyclonedx --manifest-path Cargo.toml --format json - - name: Attest build provenance for the .crate - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 - with: - subject-path: target/package/*.crate - - name: Upload SBOM as a build artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: sbom-crate - path: ordvec.cdx.json - if-no-files-found: error - # Mint the crates.io OIDC credential LAST — only here, immediately before - # publish, so the short-lived token is never live during the - # `cargo install cargo-cyclonedx` step (third-party code) or the - # attestation/upload steps. Minimises the token's exposure window. - - name: Mint a short-lived crates.io credential (OIDC) - id: auth - uses: rust-lang/crates-io-auth-action@bbd81622f20ce9e2dd9622e3218b975523e45bbe # v1.0.4 - - name: cargo publish - run: cargo publish -p ordvec --locked - env: - CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml deleted file mode 100644 index 2d404f7f..00000000 --- a/.github/workflows/release-python.yml +++ /dev/null @@ -1,226 +0,0 @@ -# Publishes the `ordvec` Python bindings (distributed as the `ordvec` wheel) to -# PyPI via Trusted Publishing (OIDC). abi3-py310 => one wheel per platform covers -# CPython 3.10+, so the matrix varies by platform/arch only. -# -# maturin runs with working-directory: ordvec-python (matching python.yml) so the -# binding's own pyproject.toml/Cargo.toml are the unambiguous build context. -# -# PUBLISH-HELD by design: -# * workflow_dispatch only. -# * the publish job is bound to the `pypi` GitHub Environment; add a Required -# reviewer there for a human approval gate (enforced once the repo is public, -# or now on a paid plan). -# -# Trusted publisher (already configured on PyPI): -# project=ordvec owner=Fieldnote-Echo repo=ordvec workflow=release-python.yml env=pypi -name: release-python - -on: - workflow_dispatch: - -# Serialize releases; never cancel an in-flight publish. -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - build-wheels: - name: wheel ${{ matrix.platform.target }} (${{ matrix.platform.runner }}) - runs-on: ${{ matrix.platform.runner }} - strategy: - fail-fast: false - matrix: - platform: - - { runner: ubuntu-latest, target: x86_64, manylinux: auto } - - { runner: ubuntu-latest, target: aarch64, manylinux: auto } - # No macOS x86_64 (Intel) leg — macos-13 hosted runners don't schedule - # for this repo, so the job hangs queued and blocks the build-wheels - # matrix (and thus the release). Intel-mac users install from the - # sdist; a cross-compiled Intel wheel is tracked in issue #29. - - { runner: macos-latest, target: aarch64, manylinux: auto } - - { runner: windows-latest, target: x64, manylinux: auto } - steps: - - name: Harden the runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 - with: - egress-policy: audit - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Build abi3 wheel (covers CPython 3.10+) - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 - with: - working-directory: ordvec-python - target: ${{ matrix.platform.target }} - manylinux: ${{ matrix.platform.manylinux }} - args: --release --out dist - sccache: 'true' - # T2 — prove the freshly-built wheel actually runs before it is uploaded - # for publish: set up Python, install the wheel, run the pytest suite. - # The linux/aarch64 wheel is cross-built here under QEMU and can't execute - # on the x86 host, so it is skipped — its runtime is covered by python.yml's - # native ubuntu-24.04-arm leg and by this job's macOS-arm64 leg. - - name: Set up Python to test the built wheel - if: ${{ !(matrix.platform.runner == 'ubuntu-latest' && matrix.platform.target == 'aarch64') }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.13" - - name: Install the built wheel and run pytest - if: ${{ !(matrix.platform.runner == 'ubuntu-latest' && matrix.platform.target == 'aarch64') }} - shell: bash - run: | - set -euo pipefail - python -m pip install --require-hashes -r ordvec-python/requirements-dev.txt - python -m pip install ordvec-python/dist/*.whl - python -m pytest ordvec-python/tests -q - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: wheels-${{ matrix.platform.runner }}-${{ matrix.platform.target }} - path: ordvec-python/dist - - build-sdist: - name: sdist - runs-on: ubuntu-latest - steps: - - name: Harden the runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 - with: - egress-policy: audit - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Build the sdist - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 - with: - working-directory: ordvec-python - command: sdist - args: --out dist - # T2 — prove the sdist installs *from source* and passes the suite before - # it is uploaded for publish. `pip install ` compiles the Rust - # extension from the vendored core crate via the maturin PEP 517 backend — - # a packaging path the prebuilt wheels never exercise (a missing path-dep - # vendor or workspace mis-resolution would fail here, not in users' - # installs). The runner's preinstalled Rust toolchain builds it. - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (2026-03-27) - with: - toolchain: stable - - name: Set up Python to test the sdist - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.13" - - name: Install from the sdist and run pytest - shell: bash - run: | - set -euo pipefail - python -m pip install --require-hashes -r ordvec-python/requirements-dev.txt - python -m pip install ordvec-python/dist/*.tar.gz - python -m pytest ordvec-python/tests -q - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: sdist - path: ordvec-python/dist - # CycloneDX SBOM — generated ONCE here, not per-wheel. The wheel is the - # compiled Rust extension, so the binding crate's Cargo dependency tree is - # the meaningful, platform-independent bill of materials. cargo-cyclonedx - # walks the manifest and writes `.cdx.json` next to it, i.e. - # `ordvec-python/ordvec-python.cdx.json`. The dtolnay toolchain above - # provides cargo. - - name: Install cargo-cyclonedx - shell: bash - run: cargo install cargo-cyclonedx --version 0.5.9 --locked - - name: Generate CycloneDX SBOM for the binding crate - shell: bash - run: cargo cyclonedx --manifest-path ordvec-python/Cargo.toml --format json - - name: Upload the SBOM - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: sbom-python - path: ordvec-python/ordvec-python.cdx.json - if-no-files-found: error - - require-ci-green: - # The wheel must not ship from a commit whose core (`ci.yml`), binding - # (`python.yml`), fuzz-smoke (`fuzz.yml`), or CodeQL static-scan - # (`codeql.yml`) gates have not gone green. The build-wheels job above - # builds + pytest-checks each platform wheel, but it does not cover the core - # crate's gates, the binding's full OS/Python matrix and lint, the loader/ - # FastScan fuzz smoke, or the CodeQL scan of the shipped Rust + Python - # source. Require each workflow to have concluded `success` for this exact - # SHA on main. - # - # Only per-push-to-main workflows are gated, so a run for the release SHA is - # guaranteed to exist: audit.yml is schedule-only (no per-SHA run to assert); - # coverage*/scorecard are advisory or external-service-flaky; zizmor/ - # actionlint are pre-merge CI hygiene, not artifact safety. - name: require core + binding CI green for this commit - runs-on: ubuntu-latest - permissions: - contents: read - actions: read - steps: - - name: Harden the runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 - with: - egress-policy: audit - - name: assert ci.yml, python.yml, fuzz.yml and codeql.yml are green for this commit - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - SHA: ${{ github.sha }} - run: | - set -euo pipefail - # Require a SUCCESSFUL run for this SHA *on main* for each workflow. - # Filtering on the branch as well as head_sha stops a green run for the - # same commit on an unrelated branch from satisfying the gate. - for wf in ci.yml python.yml fuzz.yml codeql.yml; do - ok="$(gh api \ - "repos/${REPO}/actions/workflows/${wf}/runs?head_sha=${SHA}&branch=main&status=success&per_page=20" \ - --jq '[.workflow_runs[] | select(.head_branch == "main" and .conclusion == "success")] | length')" - echo "successful ${wf} runs for ${SHA} on main: ${ok}" - if [ "${ok}" -lt 1 ]; then - echo "::error::no successful ${wf} run for ${SHA} on main. Push to main, let CI pass, then re-run this release." - exit 1 - fi - done - - publish: - name: publish to PyPI - needs: [build-wheels, build-sdist, require-ci-green] - runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/p/ordvec - permissions: - contents: read - id-token: write - attestations: write - steps: - - name: Harden the runner - uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 - with: - egress-policy: audit - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - path: dist - merge-multiple: true - # GitHub SLSA build provenance for the wheels + sdist, complementary to the - # PyPI-side PEP 740 attestations the pypa publish step emits below. Attest - # runs BEFORE publish so a failed attestation fails the release closed — - # nothing is pushed to PyPI without provenance recorded first. - - name: Attest build provenance for the wheels + sdist - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 - with: - subject-path: | - dist/*.whl - dist/*.tar.gz - # The merge-multiple download above pulls EVERY artifact into dist/, - # including sbom-python (.cdx.json). PyPI/twine accept only wheels + sdists, - # so a stray .cdx.json fails the upload. Drop it here (after attestation, - # which covers only the dists); the SBOM remains the `sbom-python` artifact. - - name: Drop the SBOM from the PyPI upload dir (wheels + sdist only) - run: find dist -name '*.cdx.json' -delete - - name: Publish to PyPI (Trusted Publishing; PEP 740 attestations on by default) - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..0c99c109 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,491 @@ +# Unified, tag-triggered release pipeline for ordvec (crate + Python wheel). +# +# Cutting a stable `vMAJOR.MINOR.PATCH` tag fully automates: build (crate + +# wheels + sdist) -> attest (GitHub artifact attestations) -> SLSA Build-L3 +# provenance -> attach EVERYTHING to a GitHub Release -> un-draft. The two +# registry publishes (crates.io, PyPI) are the ONLY manual gates: each is bound +# to a GitHub Environment with Required Reviewers, so it pauses for a human. +# +# Why one workflow (not the old three): the GitHub Release assets + the single +# un-draft must be coordinated by explicit `needs:` edges. The previous +# `changelog.yml` / `release-crate.yml` / `release-python.yml` triggered +# independently and could not coordinate the asset attach — which is why v0.2.0 +# was assembled by hand and the attestations were dropped. This file replaces +# all three. +# +# Provenance / attestation, soup to nuts (all genuine, nothing faked): +# * SLSA generator -> `*.intoto.jsonl` on the Release (OpenSSF Scorecard +# Signed-Releases provenance probe -> 10/10; SLSA Build L3). +# * actions/attest-build-provenance -> GitHub attestation store + a +# `*.sigstore.json` bundle on the Release (`gh attestation verify`; also the +# Scorecard signing probe -> 8, a backup if the .intoto.jsonl ever regresses). +# * gh-action-pypi-publish -> PEP 740 attestations on PyPI (Integrity API). +# * crates.io / PyPI publish via Trusted Publishing (OIDC) — NO stored tokens. +# +# Fail-closed: `release-assets` and both publishes `needs:` the attest + +# provenance jobs, so nothing is attached or published unless provenance signed. +# +# ONE-TIME SETUP before the first tag on this workflow (publishes fail closed at +# the gate until done, so this is safe to land first): +# * crates.io: ordvec > Settings > Trusted Publishing > edit the GitHub +# publisher -> workflow = `release.yml` (env stays `crates-io`). +# * PyPI: ordvec > publishing > edit the GitHub publisher -> workflow = +# `release.yml` (env stays `pypi`). +# * GitHub: Environments `crates-io` and `pypi` each keep their Required +# reviewer (the human publish gate). +# +# Tag glob is deliberately loose (`v[0-9]*...`); the `guard` job enforces strict +# SemVer (no leading zeros, no pre-release/build suffix). The tag name is only +# ever used as the quoted env "$TAG_NAME" / "$VERSION", never interpolated into a +# run: script (injection-safe). +name: release + +on: + push: + tags: + - "v[0-9]*.[0-9]*.[0-9]*" + +# Serialize releases; never cancel an in-flight publish. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + guard: + name: gate on a stable SemVer tag + runs-on: ubuntu-latest + outputs: + ok: ${{ steps.semver.outputs.ok }} + version: ${{ steps.semver.outputs.version }} + steps: + - name: Harden the runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - name: Enforce strict SemVer + id: semver + env: + TAG_NAME: ${{ github.ref_name }} + run: | + # Each segment is 0 or [1-9][0-9]* (no leading zeros); no suffix. + semver='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + if [[ "$TAG_NAME" =~ $semver ]]; then + echo "ok=true" >> "$GITHUB_OUTPUT" + echo "version=${TAG_NAME#v}" >> "$GITHUB_OUTPUT" + else + echo "::notice::$TAG_NAME is not a stable vMAJOR.MINOR.PATCH tag; skipping release." + echo "ok=false" >> "$GITHUB_OUTPUT" + fi + + require-ci-green: + # A tagged release must not ship a commit whose gates have not gone green. + # The build jobs below rebuild + retest, but do NOT cover the core crate's + # lint / no-default / experimental / MSRV 1.89 / deps + cargo-deny gates + # (ci.yml), the binding's full OS/Python matrix (python.yml), the loader / + # FastScan fuzz smoke (fuzz.yml), or the CodeQL scan (codeql.yml). Require + # each to have concluded `success` for this exact SHA on main. + # + # Only per-push-to-main workflows are gated, so a run for the release SHA is + # guaranteed to exist: audit.yml is schedule-only; coverage*/scorecard are + # advisory/external-flaky; zizmor/actionlint are pre-merge hygiene. + name: require full CI green for this commit + needs: guard + if: needs.guard.outputs.ok == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + steps: + - name: Harden the runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - name: assert ci.yml, python.yml, fuzz.yml and codeql.yml are green for this commit + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + # Require a SUCCESSFUL run for this SHA *on main* for each workflow. + # Filtering on branch as well as head_sha stops a green run for the same + # commit on an unrelated branch from satisfying the gate. + for wf in ci.yml python.yml fuzz.yml codeql.yml; do + ok="$(gh api \ + "repos/${REPO}/actions/workflows/${wf}/runs?head_sha=${SHA}&branch=main&status=success&per_page=20" \ + --jq '[.workflow_runs[] | select(.head_branch == "main" and .conclusion == "success")] | length')" + echo "successful ${wf} runs for ${SHA} on main: ${ok}" + if [ "${ok}" -lt 1 ]; then + echo "::error::no successful ${wf} run for ${SHA} on main. Push to main, let CI pass, then re-tag." + exit 1 + fi + done + + notes: + name: release notes (git-cliff) + draft Release + needs: guard + if: needs.guard.outputs.ok == 'true' + runs-on: ubuntu-latest + permissions: + contents: write # create the draft GitHub Release + steps: + - name: Harden the runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 # full history + tags for git-cliff + persist-credentials: false + - name: Generate release notes + uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # v4.8.0 + with: + config: cliff.toml + args: --latest --strip header + env: + OUTPUT: RELEASE_NOTES.md + - name: Create the draft GitHub Release + # Draft so notes + the auto-attached artifacts/provenance get assembled + # before `release-assets` un-drafts. `--verify-tag` ties it to this tag. + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ github.ref_name }} + run: | + gh release create "$TAG_NAME" \ + --draft \ + --verify-tag \ + --title "$TAG_NAME" \ + --notes-file RELEASE_NOTES.md + + build-crate: + name: build .crate + SBOM + needs: guard + if: needs.guard.outputs.ok == 'true' + runs-on: ubuntu-latest + steps: + - name: Harden the runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (2026-03-27) + with: + toolchain: stable + - name: Package the crate + # Emits target/package/ordvec-.crate — the same content + # `cargo publish` uploads, so the provenance covers the published artifact. + run: cargo package -p ordvec --locked + - name: Generate CycloneDX SBOM for the crate + run: | + cargo install cargo-cyclonedx --version 0.5.9 --locked + cargo cyclonedx --manifest-path Cargo.toml --format json + - name: Upload the .crate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist-crate + path: target/package/*.crate + if-no-files-found: error + - name: Upload the crate SBOM + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sbom-crate + path: ordvec.cdx.json + if-no-files-found: error + + build-wheels: + name: wheel ${{ matrix.platform.target }} (${{ matrix.platform.runner }}) + needs: guard + if: needs.guard.outputs.ok == 'true' + runs-on: ${{ matrix.platform.runner }} + strategy: + fail-fast: false + matrix: + platform: + - { runner: ubuntu-latest, target: x86_64, manylinux: auto } + - { runner: ubuntu-latest, target: aarch64, manylinux: auto } + # No macOS x86_64 (Intel) leg — macos-13 hosted runners don't schedule + # for this repo. Intel-mac users install from the sdist (issue #29). + - { runner: macos-latest, target: aarch64, manylinux: auto } + - { runner: windows-latest, target: x64, manylinux: auto } + steps: + - name: Harden the runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Build abi3 wheel (covers CPython 3.10+) + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + with: + working-directory: ordvec-python + target: ${{ matrix.platform.target }} + manylinux: ${{ matrix.platform.manylinux }} + args: --release --out dist + sccache: 'true' + # Prove the freshly-built wheel runs before it can be published. The + # linux/aarch64 wheel is cross-built under QEMU and can't execute on the x86 + # host, so it's skipped (covered by python.yml's native arm leg + the + # macos-arm64 leg here). + - name: Set up Python to test the built wheel + if: ${{ !(matrix.platform.runner == 'ubuntu-latest' && matrix.platform.target == 'aarch64') }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.13" + - name: Install the built wheel and run pytest + if: ${{ !(matrix.platform.runner == 'ubuntu-latest' && matrix.platform.target == 'aarch64') }} + shell: bash + run: | + set -euo pipefail + python -m pip install --require-hashes -r ordvec-python/requirements-dev.txt + python -m pip install ordvec-python/dist/*.whl + python -m pytest ordvec-python/tests -q + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheels-${{ matrix.platform.runner }}-${{ matrix.platform.target }} + path: ordvec-python/dist/*.whl + if-no-files-found: error + + build-sdist: + name: build sdist + SBOM + needs: guard + if: needs.guard.outputs.ok == 'true' + runs-on: ubuntu-latest + steps: + - name: Harden the runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Build the sdist + uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0 + with: + working-directory: ordvec-python + command: sdist + args: --out dist + # Prove the sdist installs *from source* (compiles the Rust extension via + # the maturin PEP 517 backend — a path the prebuilt wheels never exercise). + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (2026-03-27) + with: + toolchain: stable + - name: Set up Python to test the sdist + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.13" + - name: Install from the sdist and run pytest + shell: bash + run: | + set -euo pipefail + python -m pip install --require-hashes -r ordvec-python/requirements-dev.txt + python -m pip install ordvec-python/dist/*.tar.gz + python -m pytest ordvec-python/tests -q + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sdist + path: ordvec-python/dist/*.tar.gz + if-no-files-found: error + # Binding-crate SBOM — generated once (the wheel is the compiled Rust + # extension, so the binding crate's dependency tree is the meaningful BOM). + - name: Install cargo-cyclonedx + shell: bash + run: cargo install cargo-cyclonedx --version 0.5.9 --locked + - name: Generate CycloneDX SBOM for the binding crate + shell: bash + run: cargo cyclonedx --manifest-path ordvec-python/Cargo.toml --format json + - name: Upload the binding SBOM + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sbom-python + path: ordvec-python/ordvec-python.cdx.json + if-no-files-found: error + + attest: + name: GitHub artifact attestation (+ .sigstore.json bundle) + needs: [guard, build-crate, build-wheels, build-sdist] + if: needs.guard.outputs.ok == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # mint the Sigstore signing cert (OIDC) + attestations: write # persist the attestation to the store + artifact-metadata: write # create the artifact storage record (GA 2026-01-13) + steps: + - name: Harden the runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - name: Collect the distributables + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + merge-multiple: true + # One consolidated attestation referencing every subject (v4 behavior). + - name: Attest build provenance for crate + wheels + sdist + id: attest + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + dist/*.crate + dist/*.whl + dist/*.tar.gz + - name: Stage the Sigstore bundle as a release asset + env: + BUNDLE: ${{ steps.attest.outputs.bundle-path }} + VERSION: ${{ needs.guard.outputs.version }} + run: cp "$BUNDLE" "ordvec-${VERSION}.sigstore.json" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sigstore-bundle + path: ordvec-*.sigstore.json + if-no-files-found: error + + combine-hashes: + name: combine artifact digests for SLSA provenance + needs: [guard, build-crate, build-wheels, build-sdist] + if: needs.guard.outputs.ok == 'true' + runs-on: ubuntu-latest + outputs: + hashes: ${{ steps.hash.outputs.hashes }} + steps: + - name: Harden the runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - name: Collect the distributables + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + merge-multiple: true + - name: Compute one combined base64 sha256sum over all distributables + id: hash + working-directory: dist + run: | + set -euo pipefail + # SLSA generator wants `sha256sum`-format subjects, base64'd, no wrap. + echo "hashes=$(sha256sum *.crate *.whl *.tar.gz | base64 -w0)" >> "$GITHUB_OUTPUT" + + provenance: + name: SLSA Build-L3 provenance (.intoto.jsonl) + needs: [guard, combine-hashes] + if: needs.guard.outputs.ok == 'true' + permissions: + actions: read # detect the GitHub Actions build environment + id-token: write # Sigstore keyless signing of the provenance + contents: write # required by the reusable workflow's interface + # NOTE: SLSA reusable workflows MUST be pinned to a version TAG, never a SHA — + # the generator verifies its own ref to produce non-falsifiable provenance, + # and a SHA-pin breaks that trust model. This is the one deliberate exception + # to the repo's pin-everything-to-SHA rule (see zizmor allowlist). + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 # zizmor: ignore[unpinned-uses] + with: + base64-subjects: ${{ needs.combine-hashes.outputs.hashes }} + # Produce the signed provenance as a workflow artifact ONLY; `release-assets` + # is the single owner of all Release uploads (no concurrent writers). + upload-assets: false + provenance-name: ordvec-${{ needs.guard.outputs.version }}.intoto.jsonl + + release-assets: + name: attach all assets to the Release + un-draft + needs: [guard, notes, attest, provenance, require-ci-green] + if: needs.guard.outputs.ok == 'true' + runs-on: ubuntu-latest + permissions: + contents: write # upload assets + un-draft the Release + steps: + - name: Harden the runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - name: Collect everything (artifacts + attestation + provenance) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + merge-multiple: true + - name: Attach distributables, signature and provenance to the Release + # SOLE Release-asset writer. SBOMs stay build artifacts (registries don't + # host them); the GitHub-native bundle (.sigstore.json) and the SLSA + # provenance (.intoto.jsonl) ship with the artifacts they attest. + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + gh release upload "$TAG_NAME" \ + dist/*.crate \ + dist/*.whl \ + dist/*.tar.gz \ + dist/*.sigstore.json \ + dist/*.intoto.jsonl \ + --clobber + - name: Publish the GitHub Release (un-draft) + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ github.ref_name }} + run: gh release edit "$TAG_NAME" --draft=false + + publish-crate: + name: publish to crates.io + needs: [guard, release-assets] + if: needs.guard.outputs.ok == 'true' + runs-on: ubuntu-latest + environment: crates-io # MANUAL GATE — Required reviewer + permissions: + contents: read + id-token: write # Trusted Publishing (OIDC) + steps: + - name: Harden the runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (2026-03-27) + with: + toolchain: stable + # Mint the short-lived crates.io credential immediately before publish so + # the ephemeral token's exposure window is minimal. No stored secret. + - name: Mint a short-lived crates.io credential (OIDC) + id: auth + uses: rust-lang/crates-io-auth-action@bbd81622f20ce9e2dd9622e3218b975523e45bbe # v1.0.4 + - name: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + run: cargo publish -p ordvec --locked + + publish-pypi: + name: publish to PyPI + needs: [guard, release-assets] + if: needs.guard.outputs.ok == 'true' + runs-on: ubuntu-latest + environment: + name: pypi # MANUAL GATE — Required reviewer + url: https://pypi.org/p/ordvec + permissions: + contents: read + id-token: write # Trusted Publishing (OIDC); PEP 740 attestations on by default + steps: + - name: Harden the runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - name: Collect the wheels + sdist + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + merge-multiple: true + # PyPI accepts ONLY wheels + sdists; drop everything else (SBOMs, the + # Sigstore bundle, the SLSA provenance) so the upload doesn't choke. + - name: Keep only wheels + sdist in the upload dir + run: find dist -type f ! -name '*.whl' ! -name '*.tar.gz' -delete + - name: Publish to PyPI (Trusted Publishing; PEP 740 attestations on by default) + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + packages-dir: dist From c3e7631670b1479a37efa163fa4318c6d9f9b003 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Thu, 28 May 2026 09:03:23 -0500 Subject: [PATCH 05/10] =?UTF-8?q?ci(release):=20fix=20CI=20red=20on=20#91?= =?UTF-8?q?=20=E2=80=94=20qodo=20findings=20+=20actionlint=20SC2035=20+=20?= =?UTF-8?q?zizmor=20cache-poisoning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all three failing checks on PR #91 + all three qodo bugs: * actionlint (SC2035, x3): sha256sum *.crate *.whl *.tar.gz could be tricked by a hostile filename starting with '-'. Use ./*.glob form. * zizmor (HIGH, cache-poisoning): sccache: 'true' on maturin-action in a release workflow allows a poisoned cache to inject code into the shipped wheel. Disable sccache on the release path (python.yml keeps it for the PR/main cadence). * tests/release_publish_invariants.sh: was coupled to the deleted release-python.yml and the explicit '*.cdx.json' delete pattern. Re-point at release.yml, the publish-pypi job, and accept the new keep-only-wheels/tar.gz cleanup form (qodo bug 1). * release.yml concurrency group: 'release-${{ github.ref }}' was per-tag and so allowed multiple tag pipelines to run concurrently — contrary to the 'serialize releases' comment. Use a constant 'release' group for true global serialization (qodo bug 2). * Docs / comments referring to deleted workflows: RELEASING.md (significantly rewritten for the tag-triggered + gated-publish model), CONTRIBUTING.md, THREAT_MODEL.md, cliff.toml, .github/workflows/python.yml. python.yml's stale 'Intel wheel is still built + shipped' claim corrected (no Intel wheel ships — issue #29) (qodo bug 3). Locally: actionlint clean, zizmor clean ('No findings to report'), tests/release_publish_invariants.sh OK. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Nelson Spence --- .github/workflows/python.yml | 15 ++- .github/workflows/release.yml | 17 +++- CONTRIBUTING.md | 13 ++- RELEASING.md | 150 ++++++++++++++++++---------- THREAT_MODEL.md | 23 +++-- cliff.toml | 4 +- tests/release_publish_invariants.sh | 61 ++++++----- 7 files changed, 176 insertions(+), 107 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index b42e5c74..8662701b 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -6,7 +6,7 @@ name: python # the core checks, and so a green run proves the bindings build cold from a # checkout (the paper's reproducibility requirement). on: - # Push runs on EVERY commit to main (no paths filter): release-python.yml's + # Push runs on EVERY commit to main (no paths filter): release.yml's # require-ci-green gate asserts a successful python.yml run exists for the exact # release SHA on main, so a docs-only or workflow-only release tip must still # produce a run here or the wheel gate would be unsatisfiable. Feature branches @@ -56,19 +56,18 @@ jobs: strategy: fail-fast: false matrix: - # release-python.yml builds one wheel per platform; this matrix makes + # release.yml builds one wheel per platform; this matrix makes # sure every one of those targets is *behaviourally* tested (build → # install → pytest), not merely compiled. abi3 means a single wheel # covers all CPython 3.10+, so the python axis only needs its 3.10↔3.13 # floor/ceiling checked once (linux/x86_64); the other targets exercise # the native code on 3.13. The aarch64 runners (ubuntu-24.04-arm, # macos-latest) are what exercise the NEON kernels through the bindings. - # NB: ubuntu-24.04-arm hosted runners are free on public repos / paid - # plans; until this repo is public the ARM leg may queue or bill. - # macOS-Intel (macos-13) is intentionally NOT tested here: that runner - # image is deprecated + scarce, so the jobs sit queued and block PRs. The - # Intel wheel is still built + shipped by release-python.yml, and its - # x86_64 code is covered by the linux-x86_64 legs (logic) + macos-arm64 + # macOS-Intel (macos-13) is intentionally NOT tested here AND not shipped + # by release.yml: the runner image is deprecated + scarce, so the jobs + # sit queued and block PRs. Intel-mac users install from the sdist; + # adding a cross-compiled Intel wheel is tracked in issue #29. The x86_64 + # code is still covered by the linux-x86_64 legs (logic) + macos-arm64 # (Mach-O), so the lost test coverage is marginal. include: - { os: ubuntu-latest, python: "3.10" } # linux x86_64 — abi3 floor diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c99c109..4929da1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,9 +45,13 @@ on: tags: - "v[0-9]*.[0-9]*.[0-9]*" -# Serialize releases; never cancel an in-flight publish. +# Globally serialize releases (one release pipeline at a time across all tags) +# and NEVER cancel an in-flight publish. The group is a constant, not +# `release-${{ github.ref }}`, because per-tag scoping would allow two tag +# pipelines to run concurrently — which would race the Release-asset writes +# and double-book the gated registry pushes. concurrency: - group: release-${{ github.ref }} + group: release cancel-in-progress: false permissions: @@ -227,7 +231,10 @@ jobs: target: ${{ matrix.platform.target }} manylinux: ${{ matrix.platform.manylinux }} args: --release --out dist - sccache: 'true' + # sccache deliberately OFF for release builds. A poisoned sccache entry + # could inject code into the shipped wheel (zizmor cache-poisoning, + # HIGH); the speedup isn't worth that risk on the release path. The CI + # path (`python.yml`) keeps sccache on for the PR/main cadence. # Prove the freshly-built wheel runs before it can be published. The # linux/aarch64 wheel is cross-built under QEMU and can't execute on the x86 # host, so it's skipped (covered by python.yml's native arm leg + the @@ -369,7 +376,9 @@ jobs: run: | set -euo pipefail # SLSA generator wants `sha256sum`-format subjects, base64'd, no wrap. - echo "hashes=$(sha256sum *.crate *.whl *.tar.gz | base64 -w0)" >> "$GITHUB_OUTPUT" + # `./*.glob` form (not bare `*.glob`) so a hostile filename that starts + # with `-` can't be reinterpreted as a sha256sum flag (shellcheck SC2035). + echo "hashes=$(sha256sum ./*.crate ./*.whl ./*.tar.gz | base64 -w0)" >> "$GITHUB_OUTPUT" provenance: name: SLSA Build-L3 provenance (.intoto.jsonl) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 739322c0..598d86bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,10 +86,15 @@ Changelog and release notes are generated with [git-cliff](https://git-cliff.org) from Conventional Commit history (`cliff.toml`). -- **GitHub Release notes are automated.** Pushing a `vMAJOR.MINOR.PATCH` tag - triggers `.github/workflows/changelog.yml`, which runs git-cliff and opens a - **draft** GitHub Release with the generated notes — review, then publish. - Pre-release tags (e.g. `v0.3.0-rc.1`) do not trigger it. +- **The whole release is automated except the two registry publishes.** Pushing + a `vMAJOR.MINOR.PATCH` tag triggers `.github/workflows/release.yml`, which + runs git-cliff for the GitHub Release notes, builds the crate + wheels + + sdist, generates SLSA build provenance (`*.intoto.jsonl`) and a Sigstore + bundle (`*.sigstore.json`), attaches everything to the GitHub Release, and + un-drafts it — all without human intervention. The `crates.io` and `pypi` + publishes wait at GitHub Environments with **Required reviewers** (the + maintainer approves each in the Actions UI). Pre-release tags (e.g. + `v0.3.0-rc.1`) do not trigger it. - **`CHANGELOG.md` is curated by hand** — it is not auto-committed, because `main` is branch-protected. Keep adding entries under `[Unreleased]`; at release time promote that block to `## [X.Y.Z] - YYYY-MM-DD`. To draft the diff --git a/RELEASING.md b/RELEASING.md index 3c4bcca7..b1e356ba 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,54 +1,78 @@ # Releasing `ordvec` -> **Publish is held.** A real `cargo publish` / PyPI publish happens only -> on the maintainer's explicit go. CI never publishes for real — the crate job -> runs `cargo publish -p ordvec --dry-run --locked`, and the PyPI wheel is -> `publish = false` on crates.io and ships separately. +> **Publish is held.** A real `cargo publish` / PyPI publish happens only on +> the maintainer's explicit approval. CI never publishes — the unified release +> pipeline builds, attests, and attaches everything to the GitHub Release +> automatically on a tag push, then **waits at the `crates-io` and `pypi` +> environment gates** for a required-reviewer approval before either registry +> push. `ordvec` (the Rust crate) and `ordvec` on PyPI (the PyO3 wheel built from -`ordvec-python/`) are released by **manually dispatching** the release -workflows. Nothing ships on a tag push or a merge. +`ordvec-python/`) are released by **pushing a `vMAJOR.MINOR.PATCH` tag** to a +commit on `main`. The release workflow handles build, attestation, SLSA +provenance, Release-asset attach, and un-draft automatically; only the two +registry pushes are manual. ## Release pipeline controls -Both `release-crate.yml` and `release-python.yml`: +The unified `release.yml`: -- are **`workflow_dispatch`-only** (no `push` / tag trigger); -- run a **`require-ci-green`** gate confirming the per-commit CI is green for the - target commit on `main` — `ci.yml`, `fuzz.yml`, and `codeql.yml` for the crate, - plus `python.yml` for the wheel (a *successful* run for that exact SHA on `main`); -- publish via **OIDC trusted publishing** (no long-lived crates.io / PyPI +- triggers on **tag push** (`v[0-9]*.[0-9]*.[0-9]*`); a strict-SemVer guard + step rejects pre-release / leading-zero / non-SemVer tags so they wake the + workflow but skip every job below the gate; +- runs a **`require-ci-green`** gate confirming the per-commit CI is green on + `main` for the tagged SHA — `ci.yml`, `python.yml`, `fuzz.yml`, `codeql.yml` + (a *successful* run for that exact SHA on `main`); +- publishes via **OIDC trusted publishing** (no long-lived crates.io / PyPI tokens in the repo); -- emit **SLSA build provenance** (`actions/attest-build-provenance`) **before** - publishing — a failed attestation fails the release closed, so nothing ships - without provenance recorded first; -- pin every third-party action by **commit SHA**, set - `persist-credentials: false`, and default to `permissions: contents: read`. +- emits **GitHub SLSA build provenance** (`actions/attest-build-provenance`) + and a **SLSA-generator `*.intoto.jsonl`** attached to the GitHub Release + **before** the gated publishes — a failed attestation fails the release + closed, so nothing ships without provenance recorded; +- attaches the **`.crate`, wheels, sdist, `*.sigstore.json` bundle, and + `*.intoto.jsonl` provenance** to the GitHub Release and un-drafts it in a + single coordinated job (no manual asset attach — that's what v0.2.0's manual + step missed); +- pins every third-party action by **commit SHA** (the one mandated exception + is the SLSA reusable workflow, tag-pinned per SLSA's trust model), sets + `persist-credentials: false`, and defaults to `permissions: contents: read`. -`release-python.yml` additionally produces **PEP 740** attestations via the PyPI -Trusted Publishing step. +The PyPI publish step additionally produces **PEP 740** attestations via +Trusted Publishing (served from PyPI's Integrity API). ### Environment protection (configured in repo settings, not in code) - **Required reviewer** — each environment (`crates-io`, `pypi`) requires - maintainer (`Fieldnote-Echo`) approval before the publish job runs. -- **Deployment branch** — each environment is restricted to **`main`**, the - only ref a release may be dispatched from. This makes "only `main` can - publish" a configuration invariant rather than a manual check at approval - time. + maintainer (`Fieldnote-Echo`) approval before its publish job runs. +- **Deployment branches and tags** — each environment is restricted so a + release can only deploy from a commit on **`main`**. This makes "only `main` + can publish" a configuration invariant rather than a manual check at + approval time. > These two settings are the supply-chain backstop the workflow code cannot > express on its own (THREAT-SUPPLY-001 in [THREAT_MODEL.md](THREAT_MODEL.md)). +### Trusted-publisher configuration (one-time, in the registries) + +The crates.io and PyPI Trusted Publisher records must point at this workflow +filename. Until either is updated, the corresponding gated publish fails +**closed** at the OIDC exchange (no risk of a bad publish; just a failed run). + +- **crates.io** → `ordvec` → Settings → Trusted Publishing → GitHub publisher: + `workflow = release.yml`, `environment = crates-io`. +- **PyPI** → `ordvec` → Publishing → GitHub publisher: `workflow = release.yml`, + `environment = pypi`. + ### Tag and branch protection -- **Immutable releases** is enabled, so a published release's `v*` tag cannot be - force-moved or deleted and its assets cannot be replaced after publication. - This closes the GitHub-side mutability surface the registries already close on - their end (crates.io is yank-only; PyPI burns a version on delete). +- **Immutable releases** is enabled, so a published release's `v*` tag cannot + be force-moved or deleted and its assets cannot be replaced after + publication. This closes the GitHub-side mutability surface the registries + already close on their end (crates.io is yank-only; PyPI burns a version on + delete). - **`main` is a protected branch** — pull-request review is required and - force-pushes and deletions are blocked, so the branch a release dispatches - from cannot be rewritten (THREAT-SUPPLY-002). + force-pushes and deletions are blocked, so the branch a release tag points + to cannot be rewritten (THREAT-SUPPLY-002). ## Checklist @@ -56,33 +80,51 @@ Trusted Publishing step. sync (`cargo build --locked`). 2. Bump the version (crate `Cargo.toml`, and `ordvec-python` if the wheel changed) and update `CHANGELOG.md`. Commit on `main`. -3. Confirm CI is **green for current `main` HEAD**. A release dispatches from - `main` (the environment refuses any other ref), so `require-ci-green` always - checks `main` HEAD's SHA — which needs a **completed, successful** (not - cancelled, not in-progress) run of `ci.yml`, `fuzz.yml`, `codeql.yml` (and - `python.yml` for the wheel). - - **Do not merge another PR between the release commit and the dispatch.** +3. Confirm CI is **green for current `main` HEAD**. `require-ci-green` checks + `main` HEAD's SHA — which needs a **completed, successful** (not + `cancelled`, not in-progress) run of `ci.yml`, `python.yml`, `fuzz.yml`, and + `codeql.yml`. + - **Do not merge another PR between the release commit and the tag push.** `ci.yml` / `python.yml` use `cancel-in-progress`, so merging again moves - `main` HEAD and cancels the previous commit's in-flight CI. The superseded - commit is no longer the release target: **release from the new HEAD once its - own CI has completed green** — never from, or by re-validating, the older - commit. + `main` HEAD and cancels the previous commit's in-flight CI. The + superseded commit is no longer the release target: **tag from the new + HEAD once its own CI has completed green** — never from, or by + re-validating, the older commit. - If HEAD's *own* run shows `cancelled` (superseded, but you have since - stopped pushing), re-run **that HEAD run** from the Actions UI and wait for - it to finish green before dispatching. The SHA you re-run must be the exact - SHA you publish; do not hand-clear the gate on any other commit. - - Release only from a commit on `main` with a **successful push-to-main run** - of each gated workflow — in practice the tip the merge produced (a squash - commit, a rebased tip, or a merge commit), whatever the merge strategy. An - interior commit that exists in history only from a PR branch has no - push-to-main run (its CI ran as a `pull_request` on the branch) and so is - not releasable. + stopped pushing), re-run **that HEAD run** from the Actions UI and wait + for it to finish green before tagging. The SHA you re-run must be the + exact SHA you publish; do not hand-clear the gate on any other commit. + - Release only from a commit on `main` with a **successful push-to-main + run** of each gated workflow — in practice the tip the merge produced (a + squash commit, a rebased tip, or a merge commit), whatever the merge + strategy. An interior commit that exists in history only from a PR branch + has no push-to-main run (its CI ran as a `pull_request` on the branch) + and so is not releasable. 4. Get the maintainer's explicit go to publish. -5. Dispatch `release-crate.yml` (crate) and/or `release-python.yml` (wheel) - from **`main`**. -6. Approve the environment deployment when prompted (required reviewer). -7. Verify the published artifact (crates.io / docs.rs / PyPI) and its - provenance, and — for a coordinated release — the Zenodo deposit. +5. Push the version tag from `main` (signed): + + ```sh + git tag -s vX.Y.Z -m "vX.Y.Z" + git push origin vX.Y.Z + ``` + + `release.yml` triggers automatically. It builds the `.crate`, wheels, and + sdist; attests them (GitHub attestation store + `*.sigstore.json`); + generates the SLSA `*.intoto.jsonl`; attaches every artifact, the + attestation bundle, and the provenance to the GitHub Release; and un-drafts + the release — all without intervention. +6. **Approve the two publish environments** when they pause in the Actions UI + (one for `crates-io`, one for `pypi`). The required-reviewer approval is + what authorises the registry push. +7. Verify each published artifact and its provenance: + - crates.io / docs.rs; + - PyPI (`pip download ordvec==X.Y.Z` and inspect, plus check the PEP 740 + attestation at `GET https://pypi.org/integrity/ordvec/X.Y.Z//provenance`); + - the GitHub Release page (`.crate`, wheels, sdist, `*.sigstore.json`, + `*.intoto.jsonl` all present); + - `gh attestation verify -R Fieldnote-Echo/ordvec` on a downloaded + artifact; + - for a coordinated release, the Zenodo deposit. ## Coordinated release note diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index f2890fe2..e854da1d 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -239,14 +239,19 @@ applications must validate paths before calling"). ### 5.1 Existing controls (verified) -**Workflow code (all 13 workflows):** third-party actions pinned by commit -SHA; `persist-credentials: false` on every checkout; `permissions: contents: -read` default. **Release workflows** (`release-crate.yml`, `release-python.yml`) -are `workflow_dispatch`-only (no tag/push trigger), run a `require-ci-green` -gate against `main`, publish via **OIDC trusted publishing** (no long-lived -registry tokens), and emit **SLSA build provenance** -(`actions/attest-build-provenance`) **before** publish — a failed attestation -fails the release closed. `release-python` additionally gets **PEP 740** +**Workflow code (all workflows):** third-party actions pinned by commit SHA +(the one mandated exception is the SLSA reusable workflow, which the SLSA +trust model requires be pinned by version *tag*); `persist-credentials: false` +on every checkout; `permissions: contents: read` default. The **release +workflow** (`release.yml`) is tag-triggered with a strict-SemVer guard; build, +GitHub attestation, SLSA provenance, Release-asset attach, and un-draft all +run automatically, while the **`crates.io`** and **`pypi`** publishes are +gated behind GitHub Environments with **Required reviewers** (the only manual +step). It runs a `require-ci-green` gate against `main`, publishes via **OIDC +trusted publishing** (no long-lived registry tokens), and emits **SLSA build +provenance** (`actions/attest-build-provenance` + a `slsa-github-generator` +`*.intoto.jsonl` attached to the GitHub Release) **before** publish — a failed +attestation fails the release closed. PyPI additionally gets **PEP 740** attestations via Trusted Publishing. **Static / supply-chain analysis:** **CodeQL** scans Rust, Python, and Actions @@ -279,7 +284,7 @@ a version on delete (no different artifact may be re-uploaded under the same version). So post-publish "silent replacement" of a version is not possible on either registry, and consumers can verify artifacts against the SLSA / PEP 740 provenance above. The GitHub-side mutability surface is now closed too: -`changelog.yml` cuts tagged GitHub Releases, and **GitHub immutable releases is +`release.yml` cuts tagged GitHub Releases, and **GitHub immutable releases is enabled**, so a published release's `v*` tag cannot be force-moved or deleted and its assets cannot be replaced after publication; the **`main` branch is protected** (pull-request review required, force-pushes and deletions blocked) diff --git a/cliff.toml b/cliff.toml index 8e64b17e..251a5586 100644 --- a/cliff.toml +++ b/cliff.toml @@ -5,8 +5,8 @@ # the no-system-deps gate. # # Used two ways: -# * .github/workflows/changelog.yml runs `git cliff --latest` when a vX.Y.Z -# tag is pushed, to author that release's GitHub Release notes. +# * .github/workflows/release.yml (the `notes` job) runs `git cliff --latest` +# when a vX.Y.Z tag is pushed, to author that release's GitHub Release notes. # * Locally, to draft the next CHANGELOG.md section during release prep: # git cliff --unreleased --tag vX.Y.Z # Output mirrors the repo's Keep a Changelog 1.1.0 + SemVer layout. diff --git a/tests/release_publish_invariants.sh b/tests/release_publish_invariants.sh index 8f8a56b2..7464942f 100755 --- a/tests/release_publish_invariants.sh +++ b/tests/release_publish_invariants.sh @@ -2,15 +2,16 @@ # # Release-publish SBOM invariants — pinned in CI. # -# release-crate.yml / release-python.yml are workflow_dispatch-only, so their -# "generate a CycloneDX SBOM, then publish" flow never runs in push/PR CI. A -# generated *.cdx.json SBOM once broke BOTH publish paths and would only have -# surfaced at a manual release: +# release.yml is the unified tag-triggered release pipeline; its publishes are +# gated behind GitHub Environments (Required reviewers), so the "generate a +# CycloneDX SBOM, then publish" flow runs only on a real release. A generated +# *.cdx.json SBOM once broke BOTH publish paths and would only have surfaced +# at the next release: # * crate — the untracked SBOM dirtied the git tree, so `cargo publish` refused # it (and would otherwise bundle it into the published .crate); # * PyPI — the SBOM artifact was downloaded into dist/, which twine rejects. # This pins the fixes so a regression fails here, on every push/PR, instead of -# silently passing CI and only breaking at manual release time. +# silently passing CI and only breaking at release time. set -euo pipefail fail() { echo "::error::release-publish invariant violated: $*"; exit 1; } @@ -25,28 +26,30 @@ done # (2) In the PyPI publish job the step order must be: # actions/download-artifact (pulls the SBOM into dist/) -# -> delete *.cdx.json from dist/ +# -> delete *.cdx.json from dist/ (either explicit cdx.json delete OR +# a keep-only-wheels/tar.gz find that excludes everything else) # -> pypa/gh-action-pypi-publish upload. # twine rejects a stray .cdx.json in dist/, so the cleanup must run AFTER the # download (otherwise it is a no-op for the downloaded SBOM) and BEFORE the -# upload. The search is scoped to the `publish` job body, so a download step -# in another job cannot satisfy the ordering; the delete is matched only in an -# executing `run:` context (single-line or a `run: |` block), so a step name or -# other non-executing text cannot satisfy it; comment lines are skipped; and the -# publish step keys on the pinned action name (not the bare string `pypi-publish`). -wf=".github/workflows/release-python.yml" +# upload. The search is scoped to the `publish-pypi` job body, so a download +# step in another job cannot satisfy the ordering; the delete is matched only +# in an executing `run:` context (single-line or a `run: |` block), so a step +# name or other non-executing text cannot satisfy it; comment lines are +# skipped; and the publish step keys on the pinned action name (not the bare +# string `pypi-publish`). +wf=".github/workflows/release.yml" [ -f "$wf" ] || fail "$wf: workflow file not found" -# Extract the `publish` job body: from its ` publish:` key to the next -# 2-space-indented job key, or EOF. Scoping here is what makes the ordering -# meaningful — the three steps must live in the SAME (publish) job. -pub_start="$(grep -nE '^ publish:[[:space:]]*$' "$wf" | head -1 | cut -d: -f1)" -[ -n "$pub_start" ] || fail "$wf: no 'publish:' job found" +# Extract the `publish-pypi` job body: from its ` publish-pypi:` key to the +# next 2-space-indented job key, or EOF. Scoping here is what makes the +# ordering meaningful — the three steps must live in the SAME job. +pub_start="$(grep -nE '^ publish-pypi:[[:space:]]*$' "$wf" | head -1 | cut -d: -f1)" +[ -n "$pub_start" ] || fail "$wf: no 'publish-pypi:' job found" pub_end="$(awk -v s="$pub_start" 'NR>s && /^ [A-Za-z0-9_-]+:/ {print NR-1; exit}' "$wf")" [ -n "$pub_end" ] || pub_end="$(awk 'END{print NR}' "$wf")" job="$(sed -n "${pub_start},${pub_end}p" "$wf")" -# First real (non-comment) line WITHIN the publish job matching the regex. +# First real (non-comment) line WITHIN the publish-pypi job matching the regex. in_job() { printf '%s\n' "$job" | grep -nE "$1" | grep -vE '^[0-9]+:[[:space:]]*#' | head -1 | cut -d: -f1; } dl_line="$(in_job 'uses:[[:space:]]*actions/download-artifact' || true)" @@ -54,11 +57,17 @@ dl_line="$(in_job 'uses:[[:space:]]*actions/download-artifact' || true)" # single-line `run: ... -delete` or a line inside that step's `run: |`/`run: >` # block. Matching the command text anywhere would also accept NON-executing text # (a step `name:`, an `env:`/`with:` value, prose), so the delete only counts on -# a `run:` line or within a run block scalar. Still requires a real delete -# (`find ... -delete` or `rm ... *.cdx.json`), not a bare mention. +# a `run:` line or within a run block scalar. Accepts both forms: +# (a) explicit cdx.json delete: `find ... cdx.json ... -delete` / `rm ... *.cdx.json` +# (b) keep-only-wheels/tar.gz delete-everything: `find dist -type f ! -name '*.whl' ! -name '*.tar.gz' -delete` +# Either form removes the SBOM before the upload. clean_line="$(printf '%s\n' "$job" | awk ' function indent(s, i){ i = match(s, /[^ ]/); return (i ? i - 1 : length(s)) } - BEGIN { del = "find.*cdx\\.json.*-delete|rm[[:space:]].*cdx\\.json" } + BEGIN { + del_a = "find.*cdx\\.json.*-delete|rm[[:space:]].*cdx\\.json" + del_b = "find.*-type[[:space:]]+f.*!.*-name.*whl.*!.*-name.*tar\\.gz.*-delete" + del = del_a "|" del_b + } { is_comment = ($0 ~ /^[[:space:]]*#/) } in_block { if ($0 ~ /^[[:space:]]*$/) next # blank line stays in block @@ -73,13 +82,13 @@ clean_line="$(printf '%s\n' "$job" | awk ' ' || true)" pub_line="$(in_job 'uses:[[:space:]]*pypa/gh-action-pypi-publish' || true)" -[ -n "$dl_line" ] || fail "$wf (publish job): no actions/download-artifact step found" -[ -n "$clean_line" ] || fail "$wf (publish job): no step deleting *.cdx.json from dist/ (need 'find ... -delete' or 'rm ... *.cdx.json')" -[ -n "$pub_line" ] || fail "$wf (publish job): no pypa/gh-action-pypi-publish step found" +[ -n "$dl_line" ] || fail "$wf (publish-pypi job): no actions/download-artifact step found" +[ -n "$clean_line" ] || fail "$wf (publish-pypi job): no step deleting *.cdx.json from dist/ (need 'find ... cdx.json ... -delete', 'rm ... *.cdx.json', or 'find ... ! -name *.whl ! -name *.tar.gz -delete')" +[ -n "$pub_line" ] || fail "$wf (publish-pypi job): no pypa/gh-action-pypi-publish step found" [ "$dl_line" -lt "$clean_line" ] \ - || fail "$wf (publish job): the *.cdx.json cleanup must run AFTER actions/download-artifact, else it is a no-op for the downloaded SBOM" + || fail "$wf (publish-pypi job): the *.cdx.json cleanup must run AFTER actions/download-artifact, else it is a no-op for the downloaded SBOM" [ "$clean_line" -lt "$pub_line" ] \ - || fail "$wf (publish job): the *.cdx.json cleanup must run BEFORE the pypa publish" + || fail "$wf (publish-pypi job): the *.cdx.json cleanup must run BEFORE the pypa publish" echo "OK: release-publish SBOM invariants hold." From f4101c3f34be28a52e3cb3ea427d97fac5110b08 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Thu, 28 May 2026 09:08:34 -0500 Subject: [PATCH 06/10] docs(release): fix env-policy deadlock for tag-triggered publishing (codex stop-gate) Codex flagged that the env-policy I'd documented ("Deployment branches and tags = main-only") was inherited from the old workflow_dispatch model and would now DEADLOCK the new tag-triggered publish: the workflow runs on `refs/tags/vX.Y.Z`, not `refs/heads/main`, so a branch-only allowlist refuses every tag-triggered run at the environment gate. Update RELEASING.md, THREAT_MODEL.md, and the release.yml header comment to specify the correct env policy: "Selected branches and tags" with a single TAG pattern `v[0-9]*.[0-9]*.[0-9]*`. The "tag must come from main" guarantee is preserved by `require-ci-green` (queries `?branch=main&status=success` for the SHA, which only returns a hit if the SHA was pushed to main) plus branch protection on `main`. An optional tag ruleset can be added as defence in depth. Doc-only change; actionlint still clean. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Nelson Spence --- .github/workflows/release.yml | 15 +++++++++++---- RELEASING.md | 16 ++++++++++++---- THREAT_MODEL.md | 7 +++++-- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4929da1b..08a9e9a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,14 +25,21 @@ # Fail-closed: `release-assets` and both publishes `needs:` the attest + # provenance jobs, so nothing is attached or published unless provenance signed. # -# ONE-TIME SETUP before the first tag on this workflow (publishes fail closed at -# the gate until done, so this is safe to land first): +# ONE-TIME SETUP before the first tag on this workflow (each fails CLOSED until +# done, so landing this first is safe): # * crates.io: ordvec > Settings > Trusted Publishing > edit the GitHub # publisher -> workflow = `release.yml` (env stays `crates-io`). # * PyPI: ordvec > publishing > edit the GitHub publisher -> workflow = # `release.yml` (env stays `pypi`). -# * GitHub: Environments `crates-io` and `pypi` each keep their Required -# reviewer (the human publish gate). +# * GitHub Environments `crates-io` AND `pypi`: +# - keep "Required reviewers" (the human publish gate); +# - set "Deployment branches and tags" to **Selected branches and tags** +# with a single TAG pattern: `v[0-9]*.[0-9]*.[0-9]*`. The old +# workflow_dispatch setting (branch = `main` only) would now deadlock +# publishing — this workflow runs on `refs/tags/...`, never on +# `refs/heads/main`. The "tag must come from main" guarantee is preserved +# by `require-ci-green` (which queries `?branch=main&status=success` for +# the SHA), plus branch protection on `main`. See RELEASING.md. # # Tag glob is deliberately loose (`v[0-9]*...`); the `guard` job enforces strict # SemVer (no leading zeros, no pre-release/build suffix). The tag name is only diff --git a/RELEASING.md b/RELEASING.md index b1e356ba..ee8826b7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -44,10 +44,18 @@ Trusted Publishing (served from PyPI's Integrity API). - **Required reviewer** — each environment (`crates-io`, `pypi`) requires maintainer (`Fieldnote-Echo`) approval before its publish job runs. -- **Deployment branches and tags** — each environment is restricted so a - release can only deploy from a commit on **`main`**. This makes "only `main` - can publish" a configuration invariant rather than a manual check at - approval time. +- **Deployment branches and tags** — each environment's "Deployment branches + and tags" policy is set to **Selected branches and tags** with a single + **tag pattern**: **`v[0-9]*.[0-9]*.[0-9]*`** (matching the workflow's + trigger glob). The release workflow runs on `refs/tags/vX.Y.Z`, NOT + `refs/heads/main`, so a **branch-only** allowlist (the old setting under the + dispatch model) would deadlock the publish — the environment would refuse + every tag-triggered run. The "tag must point at a commit on `main`" + guarantee is preserved by **`require-ci-green`**, which only passes if a + successful push-event CI run exists for the exact SHA on `main` — a SHA + that exists only via a PR merge to the protected branch. Optionally, a + **tag ruleset** (Settings → Rules → Rulesets → New tag ruleset) can be added + to restrict tag *creation* to refs on `main` as defence in depth. > These two settings are the supply-chain backstop the workflow code cannot > express on its own (THREAT-SUPPLY-001 in [THREAT_MODEL.md](THREAT_MODEL.md)). diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index e854da1d..9f4092bd 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -288,8 +288,11 @@ provenance above. The GitHub-side mutability surface is now closed too: enabled**, so a published release's `v*` tag cannot be force-moved or deleted and its assets cannot be replaced after publication; the **`main` branch is protected** (pull-request review required, force-pushes and deletions blocked) -and is the **only deployment branch** permitted for the `pypi` / `crates-io` -release environments. *Residual:* draft / non-release tags are not covered by +and is the **only branch a release-tag commit can reside on**: each release +environment (`pypi`, `crates-io`) policies "Deployment branches and tags" to +the tag pattern `v[0-9]*.[0-9]*.[0-9]*`, and `require-ci-green` independently +verifies the tag SHA has a successful push-event CI run on `main` — a SHA +that only exists via a PR merge to the protected branch. *Residual:* draft / non-release tags are not covered by release immutability, and — as with the registries — these GitHub controls ultimately trust the single maintainer account; that residual folds into THREAT-SUPPLY-001. From 1b9eaff61cdba407c8307c3912814390413c1714 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Thu, 28 May 2026 09:12:06 -0500 Subject: [PATCH 07/10] docs(release): scrub remaining branch-only / workflow_dispatch refs (codex stop-gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two stale spots survived the env-policy fix: * THREAT_MODEL.md THREAT-SUPPLY-001 still described the env policy as "restrict deployment to the main branch only" — the branch-only policy that would deadlock the new tag-triggered workflow. Rewritten to specify the tag pattern and the require-ci-green + branch-protection chain that preserves the "must come from main" guarantee. Also adjusted the social-engineering residual ("dispatcher and approver" -> "cuts the release tag and approves both publishes") to match the new model. * .github/workflows/ci.yml release-guard preamble still called the release workflows "workflow_dispatch-only." Updated to describe release.yml's tag-triggered + Environment-gated model. Doc-only / comments-only; no YAML semantic change. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Nelson Spence --- .github/workflows/ci.yml | 11 ++++++----- THREAT_MODEL.md | 14 +++++++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b653a2d..68171ae1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,11 +163,12 @@ jobs: run: cargo publish -p ordvec --dry-run --locked # ---------------------------------------------------------------------- - # Pin the release-publish SBOM invariants. release-*.yml are - # workflow_dispatch-only, so their "generate SBOM then publish" flow never runs - # in push/PR CI — a generated *.cdx.json once broke both publish paths and would - # only have surfaced at manual release. This exercises the invariants every - # push/PR (see tests/release_publish_invariants.sh). + # Pin the release-publish SBOM invariants. release.yml is tag-triggered (with + # the two registry publishes gated behind GitHub Environments), so its + # "generate SBOM then publish" flow runs only on a real release — a generated + # *.cdx.json once broke both publish paths and would only have surfaced at + # release time. This exercises the invariants every push/PR (see + # tests/release_publish_invariants.sh). # ---------------------------------------------------------------------- release-guard: name: release-publish invariants diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 9f4092bd..1d2ad72f 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -266,11 +266,15 @@ scoped to the wheel. **THREAT-SUPPLY-001 (mitigated; residual = single-maintainer account compromise): Release configuration and ownership.** The release **environments** -(`pypi`, `crates-io`) now require **approval by the maintainer** and restrict -deployment to the **`main`** branch only — so a release cannot be dispatched -from an unmerged or attacker branch, and no publish runs without an explicit -human approval. The remaining residual is *maintainer-account compromise*: a -single owner is both dispatcher and approver, so account takeover (or social +(`pypi`, `crates-io`) require **approval by the maintainer** and restrict +deployment to the **release-tag pattern `v[0-9]*.[0-9]*.[0-9]*`** (the +tag-triggered workflow runs on `refs/tags/...`, not `refs/heads/main`, so a +branch-only allowlist would deadlock publishing — see RELEASING.md). The +`require-ci-green` gate independently verifies the tag SHA has a successful +push-event CI run on `main`, and `main` itself is branch-protected (PR review, +no force-push) — so a release cannot be cut from an unmerged or attacker +branch, and no publish runs without an explicit human approval. The remaining residual is *maintainer-account compromise*: a +single owner both cuts the release tag and approves both publishes, so account takeover (or social engineering) is not caught by a second human. *Mitigations:* strong 2FA / passkeys on the maintainer account; recruiting a **second owner/maintainer** (also an open OpenSSF Best-Practices item) — which would additionally make a From a4fae78014de49842fed516c0505891cffc1eaea Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Thu, 28 May 2026 09:36:52 -0500 Subject: [PATCH 08/10] ci(release): un-draft only after publishes succeed + byte-identity crate publish (grumpy blockers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both grumpy blockers on PR #91: BLOCKER 1: the GitHub Release was un-drafted as part of the asset-attach step, BEFORE the registry publishes. A failed crates.io / PyPI publish would leave a public GitHub Release pointing at a version the registries refused — exactly the half-published "coordinated release" mode RELEASING.md says to avoid. Split `release-assets` into: * `release-assets-draft` — uploads to the DRAFT release; DOES NOT un-draft. * `publish-github-release` — `needs: [publish-crate, publish-pypi]`; the SOLE un-draft point. If either registry publish fails, the Release stays DRAFT until the failure is investigated / re-run. publish-crate and publish-pypi now `needs: release-assets-draft` (the draft- assets edge transitively carries the attest + provenance fail-closed gate). BLOCKER 2: publish-crate ran `cargo publish` on a fresh checkout — the .crate it uploaded was not proven to match the SLSA-attested artifact `build-crate` produced (toolchain drift / non-determinism could quietly diverge). Add a byte-identity gate to publish-crate: 1. Download the attested `dist-crate` artifact. 2. Re-package with `cargo package -p ordvec --locked`. 3. sha256-compare the repackaged .crate to the attested .crate. 4. Only then mint the crates.io OIDC token and `cargo publish`. A mismatch fails closed BEFORE the token is minted — nothing reaches crates.io. publish-pypi already uploads the exact built wheels/sdist via pypa/gh-action-pypi-publish, so it has byte-identity by construction. NEW: tests/release_signed_release_invariants.sh — Grumpy's "anti-Claude regression guard." Structural lint over release.yml asserting the signed- release graph: release-assets-draft needs attest+provenance+require-ci-green, uploads .crate/.whl/.tar.gz/.sigstore.json/.intoto.jsonl, does NOT un-draft; SLSA generator tag-pinned with upload-assets:false and `*.intoto.jsonl` provenance-name; attest grants id-token+attestations:write; publish-* grant id-token:write and need the draft assets; publish-crate does the byte-identity check (download-artifact dist-crate + cargo package + sha256sum); publish- github-release needs BOTH publishes and is the sole un-draft point. Wired into ci.yml's release-guard so a future commit can't silently dismantle the chain. RELEASING.md flow description updated for the new sequence (stage on DRAFT, gated publishes, un-draft only after both succeed) + the byte-identity check. Locally: actionlint clean, zizmor clean ("No findings to report"), both invariant scripts OK. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Nelson Spence --- .github/workflows/ci.yml | 19 ++- .github/workflows/release.yml | 105 ++++++++++++--- RELEASING.md | 30 ++++- tests/release_signed_release_invariants.sh | 147 +++++++++++++++++++++ 4 files changed, 272 insertions(+), 29 deletions(-) create mode 100755 tests/release_signed_release_invariants.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68171ae1..82a2b75d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,12 +163,17 @@ jobs: run: cargo publish -p ordvec --dry-run --locked # ---------------------------------------------------------------------- - # Pin the release-publish SBOM invariants. release.yml is tag-triggered (with - # the two registry publishes gated behind GitHub Environments), so its - # "generate SBOM then publish" flow runs only on a real release — a generated - # *.cdx.json once broke both publish paths and would only have surfaced at - # release time. This exercises the invariants every push/PR (see - # tests/release_publish_invariants.sh). + # Pin the release-publish invariants. release.yml is tag-triggered (with the + # two registry publishes gated behind GitHub Environments), so its release- + # specific flow runs only on a real release. Two structural lints guard it + # on every push/PR so regressions can't sneak in between releases: + # * release_publish_invariants.sh — the *.cdx.json SBOM never reaches PyPI + # (a generated SBOM once broke both publish paths). + # * release_signed_release_invariants.sh — the signed-release / provenance + # graph stays intact: release-assets-draft stays draft, the SLSA + # generator emits a .intoto.jsonl, both publishes need the draft assets, + # publish-crate proves byte-identity vs the attested .crate, and + # publish-github-release un-drafts ONLY after both publishes succeed. # ---------------------------------------------------------------------- release-guard: name: release-publish invariants @@ -187,6 +192,8 @@ jobs: persist-credentials: false - name: release-publish SBOM invariants run: bash tests/release_publish_invariants.sh + - name: signed-release / provenance invariants + run: bash tests/release_signed_release_invariants.sh # ---------------------------------------------------------------------- # Supply-chain policy gate. The `deps` job's cargo-tree grep is a coarse diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08a9e9a1..90d5b6e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,9 +2,23 @@ # # Cutting a stable `vMAJOR.MINOR.PATCH` tag fully automates: build (crate + # wheels + sdist) -> attest (GitHub artifact attestations) -> SLSA Build-L3 -# provenance -> attach EVERYTHING to a GitHub Release -> un-draft. The two -# registry publishes (crates.io, PyPI) are the ONLY manual gates: each is bound -# to a GitHub Environment with Required Reviewers, so it pauses for a human. +# provenance -> stage EVERYTHING on the DRAFT GitHub Release (`release-assets- +# draft`) -> gated registry publishes -> un-draft ONLY after BOTH publishes +# succeed (`publish-github-release`). The two registry publishes (crates.io, +# PyPI) are the manual gates: each is bound to a GitHub Environment with +# Required Reviewers, so it pauses for a human. +# +# The un-draft-after-publish ordering is deliberate: it prevents a public +# GitHub Release from existing for a version that crates.io / PyPI later +# refused. If a publish fails, the Release stays DRAFT and the maintainer +# investigates / re-runs the failed job, after which `publish-github-release` +# un-drafts. +# +# `publish-crate` additionally proves BYTE-IDENTITY between the .crate +# `cargo publish` uploads and the SLSA-attested artifact: it downloads the +# `dist-crate` artifact, re-packages with `--locked`, and sha256-compares +# before minting the crates.io OIDC token. If the bytes drift (toolchain +# change, deterministic-packaging regression), the publish fails closed. # # Why one workflow (not the old three): the GitHub Release assets + the single # un-draft must be coordinated by explicit `needs:` edges. The previous @@ -22,8 +36,12 @@ # * gh-action-pypi-publish -> PEP 740 attestations on PyPI (Integrity API). # * crates.io / PyPI publish via Trusted Publishing (OIDC) — NO stored tokens. # -# Fail-closed: `release-assets` and both publishes `needs:` the attest + -# provenance jobs, so nothing is attached or published unless provenance signed. +# Fail-closed: `release-assets-draft` and both publishes `needs:` attest + +# provenance, so nothing is attached or published unless provenance signed; and +# `publish-github-release` `needs:` both publishes, so the Release stays DRAFT +# unless both registry pushes succeed. The signed-release graph is pinned in +# `tests/release_signed_release_invariants.sh` (run by ci.yml's release-guard +# on every push/PR) so a future commit can't silently dismantle it. # # ONE-TIME SETUP before the first tag on this workflow (each fails CLOSED until # done, so landing this first is safe): @@ -407,13 +425,13 @@ jobs: upload-assets: false provenance-name: ordvec-${{ needs.guard.outputs.version }}.intoto.jsonl - release-assets: - name: attach all assets to the Release + un-draft + release-assets-draft: + name: stage all assets on the DRAFT Release (does NOT un-draft) needs: [guard, notes, attest, provenance, require-ci-green] if: needs.guard.outputs.ok == 'true' runs-on: ubuntu-latest permissions: - contents: write # upload assets + un-draft the Release + contents: write # upload assets to the draft Release steps: - name: Harden the runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 @@ -424,10 +442,14 @@ jobs: with: path: dist merge-multiple: true - - name: Attach distributables, signature and provenance to the Release + - name: Attach distributables, signature and provenance to the draft Release # SOLE Release-asset writer. SBOMs stay build artifacts (registries don't # host them); the GitHub-native bundle (.sigstore.json) and the SLSA # provenance (.intoto.jsonl) ship with the artifacts they attest. + # The Release is left DRAFT — un-drafting happens in + # `publish-github-release` only after BOTH registry publishes succeed, + # so a partial publish never leaves a "public Release with no + # registry artifact" half-state. env: GH_TOKEN: ${{ github.token }} TAG_NAME: ${{ github.ref_name }} @@ -440,15 +462,10 @@ jobs: dist/*.sigstore.json \ dist/*.intoto.jsonl \ --clobber - - name: Publish the GitHub Release (un-draft) - env: - GH_TOKEN: ${{ github.token }} - TAG_NAME: ${{ github.ref_name }} - run: gh release edit "$TAG_NAME" --draft=false publish-crate: name: publish to crates.io - needs: [guard, release-assets] + needs: [guard, release-assets-draft] if: needs.guard.outputs.ok == 'true' runs-on: ubuntu-latest environment: crates-io # MANUAL GATE — Required reviewer @@ -466,6 +483,38 @@ jobs: - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable (2026-03-27) with: toolchain: stable + # BYTE-IDENTITY GATE. The .crate cargo publish uploads MUST equal the + # .crate the SLSA / attest jobs covered, otherwise the published artifact + # isn't the attested one (toolchain drift, non-deterministic packaging, + # etc. could quietly diverge). Re-package here with `--locked` and + # sha256-compare against the attested artifact produced by `build-crate`. + # If they differ, this job fails closed BEFORE the OIDC token is minted + # and BEFORE `cargo publish` runs — nothing reaches crates.io. + - name: Download the attested .crate (from build-crate) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist-crate + path: attested + - name: Re-package the crate (must be byte-identical to the attested .crate) + run: cargo package -p ordvec --locked + - name: Verify byte-identity vs the attested .crate + env: + VERSION: ${{ needs.guard.outputs.version }} + run: | + set -euo pipefail + ATTESTED="attested/ordvec-${VERSION}.crate" + PACKAGED="target/package/ordvec-${VERSION}.crate" + [ -f "$ATTESTED" ] || { echo "::error::attested .crate not found at $ATTESTED"; exit 1; } + [ -f "$PACKAGED" ] || { echo "::error::packaged .crate not found at $PACKAGED"; exit 1; } + A_SHA=$(sha256sum "$ATTESTED" | cut -d' ' -f1) + P_SHA=$(sha256sum "$PACKAGED" | cut -d' ' -f1) + echo "attested sha256: $A_SHA" + echo "packaged sha256: $P_SHA" + if [ "$A_SHA" != "$P_SHA" ]; then + echo "::error::byte-identity check failed — the .crate this publish job would upload differs from the SLSA-attested artifact. Refusing to publish." + exit 1 + fi + echo "OK: byte-identity verified ($A_SHA)" # Mint the short-lived crates.io credential immediately before publish so # the ephemeral token's exposure window is minimal. No stored secret. - name: Mint a short-lived crates.io credential (OIDC) @@ -478,7 +527,7 @@ jobs: publish-pypi: name: publish to PyPI - needs: [guard, release-assets] + needs: [guard, release-assets-draft] if: needs.guard.outputs.ok == 'true' runs-on: ubuntu-latest environment: @@ -505,3 +554,27 @@ jobs: uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: packages-dir: dist + + publish-github-release: + name: un-draft the GitHub Release (only after BOTH registry publishes succeed) + needs: [guard, publish-crate, publish-pypi] + if: needs.guard.outputs.ok == 'true' + runs-on: ubuntu-latest + permissions: + contents: write # un-draft the Release + steps: + - name: Harden the runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + # Un-drafting ONLY here, after both registry publishes have succeeded, + # is what prevents a public GitHub Release from existing for a version + # that crates.io / PyPI later refused. If either publish job fails or is + # skipped, this job is also skipped and the Release stays DRAFT — the + # maintainer investigates / re-runs the failed publish, after which a + # re-run of this job un-drafts. + - name: Publish the GitHub Release (un-draft) + env: + GH_TOKEN: ${{ github.token }} + TAG_NAME: ${{ github.ref_name }} + run: gh release edit "$TAG_NAME" --draft=false diff --git a/RELEASING.md b/RELEASING.md index ee8826b7..a79321e9 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -29,10 +29,19 @@ The unified `release.yml`: and a **SLSA-generator `*.intoto.jsonl`** attached to the GitHub Release **before** the gated publishes — a failed attestation fails the release closed, so nothing ships without provenance recorded; -- attaches the **`.crate`, wheels, sdist, `*.sigstore.json` bundle, and - `*.intoto.jsonl` provenance** to the GitHub Release and un-drafts it in a - single coordinated job (no manual asset attach — that's what v0.2.0's manual - step missed); +- stages the **`.crate`, wheels, sdist, `*.sigstore.json` bundle, and + `*.intoto.jsonl` provenance** on the GitHub Release while it is still **a + DRAFT** (`release-assets-draft` is the sole Release-asset writer — no manual + attach, which is what v0.2.0's manual step missed); +- enforces **byte-identity** in `publish-crate`: it downloads the SLSA-attested + `.crate` artifact, re-packages with `--locked`, and `sha256`-compares before + minting the crates.io OIDC token. If they differ (toolchain drift, + non-deterministic packaging), the publish fails closed **before** the token + is minted — nothing reaches crates.io; +- **un-drafts the GitHub Release ONLY after BOTH `publish-crate` AND + `publish-pypi` succeed** (`publish-github-release` is the sole un-draft + point). If either publish fails or is skipped, the Release stays DRAFT — no + public Release ever exists for a version the registries refused; - pins every third-party action by **commit SHA** (the one mandated exception is the SLSA reusable workflow, tag-pinned per SLSA's trust model), sets `persist-credentials: false`, and defaults to `permissions: contents: read`. @@ -118,12 +127,19 @@ filename. Until either is updated, the corresponding gated publish fails `release.yml` triggers automatically. It builds the `.crate`, wheels, and sdist; attests them (GitHub attestation store + `*.sigstore.json`); - generates the SLSA `*.intoto.jsonl`; attaches every artifact, the - attestation bundle, and the provenance to the GitHub Release; and un-drafts - the release — all without intervention. + generates the SLSA `*.intoto.jsonl`; and stages every artifact, the + attestation bundle, and the provenance on the GitHub Release — **as a + DRAFT**. It then pauses at the two registry environment gates. 6. **Approve the two publish environments** when they pause in the Actions UI (one for `crates-io`, one for `pypi`). The required-reviewer approval is what authorises the registry push. + - `publish-crate` first sha256-compares its repackaged `.crate` to the + SLSA-attested artifact — if they diverge (toolchain drift, etc.) the job + fails closed BEFORE the OIDC token is minted, so nothing reaches + crates.io. Re-run / investigate. + - Once **both** publishes succeed, `publish-github-release` un-drafts the + GitHub Release automatically. If one publish fails, the Release stays + DRAFT — re-run the failed job, the un-draft then completes. 7. Verify each published artifact and its provenance: - crates.io / docs.rs; - PyPI (`pip download ordvec==X.Y.Z` and inspect, plus check the PEP 740 diff --git a/tests/release_signed_release_invariants.sh b/tests/release_signed_release_invariants.sh new file mode 100755 index 00000000..0249b934 --- /dev/null +++ b/tests/release_signed_release_invariants.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# +# Signed-release / provenance invariants — pinned in CI. +# +# release.yml's signed-release graph is what gets us OpenSSF Scorecard +# Signed-Releases = 10 and keeps the build-attest-publish chain honest: +# +# build-{crate,wheels,sdist} (artifacts) +# | +# +-> attest (id-token + attestations + .sigstore.json) +# +-> provenance (slsa-github-generator @vX.Y.Z, .intoto.jsonl) +# | +# v +# release-assets-draft (uploads .crate/.whl/.tar.gz/.sigstore.json/.intoto.jsonl to DRAFT release) +# | +# +--> publish-crate (byte-identity check vs attested .crate, then cargo publish) +# +--> publish-pypi (Trusted Publishing) +# | +# v +# publish-github-release (un-draft, ONLY after both publishes succeed) +# +# A regression in any of these edges (a future commit drops a needs:, renames +# the provenance file, lets release-assets-draft un-draft itself, forgets the +# byte-identity check, or moves the un-draft before the publishes) silently +# re-creates the v0.2.0 failure mode or weakens the chain. This script pins +# the graph so the regression fails on every push/PR, not at the next real +# release. +# +# It is intentionally a structural lint on release.yml (greps the YAML), not a +# runtime exercise of the pipeline — that's the fork dry-run's job. +set -euo pipefail +fail() { echo "::error::signed-release invariant violated: $*"; exit 1; } + +wf=".github/workflows/release.yml" +[ -f "$wf" ] || fail "$wf: workflow file not found" + +# Extract the body of a job (from ` :` to the next 2-space-indented job key). +job_body() { + local jobname="$1" start end + start="$(grep -nE "^ ${jobname}:[[:space:]]*$" "$wf" | head -1 | cut -d: -f1)" + [ -n "$start" ] || fail "$wf: no '${jobname}:' job found" + end="$(awk -v s="$start" 'NR>s && /^ [A-Za-z0-9_-]+:/ {print NR-1; exit}' "$wf")" + [ -n "$end" ] || end="$(awk 'END{print NR}' "$wf")" + sed -n "${start},${end}p" "$wf" +} + +# Accept both `needs: [a, b, c]` (inline) and `needs:\n - a\n - b` (block) forms. +job_needs() { + local jobname="$1" needed="$2" + job_body "$jobname" | grep -qE "(^[[:space:]]+needs:.*\\b${needed}\\b|^[[:space:]]+-[[:space:]]+${needed}[[:space:]]*$)" +} + +# ---------------------------------------------------------------------- +# (1) release-assets-draft needs attest + provenance + require-ci-green + notes +# ---------------------------------------------------------------------- +for dep in attest provenance require-ci-green notes; do + job_needs release-assets-draft "$dep" \ + || fail "release-assets-draft must \`needs: $dep\` (fail-closed on missing provenance/CI)" +done + +# ---------------------------------------------------------------------- +# (2) release-assets-draft uploads every required asset class to the Release +# ---------------------------------------------------------------------- +body_draft="$(job_body release-assets-draft)" +for ext in '\.crate' '\.whl' '\.tar\.gz' '\.sigstore\.json' '\.intoto\.jsonl'; do + printf '%s\n' "$body_draft" | grep -qE "dist/\*${ext}([^a-zA-Z]|$)" \ + || fail "release-assets-draft must \`gh release upload\` dist/*$(printf '%s' "$ext" | sed 's/\\//g')" +done + +# ---------------------------------------------------------------------- +# (3) release-assets-draft must NOT un-draft (the dedicated un-draft job owns +# that; un-drafting here would re-introduce the public-release-before- +# publish failure mode). +# ---------------------------------------------------------------------- +if printf '%s\n' "$body_draft" | grep -qE 'gh release edit.*--draft=false'; then + fail "release-assets-draft must NOT un-draft the Release (un-drafting belongs in publish-github-release, after both publishes succeed)" +fi + +# ---------------------------------------------------------------------- +# (4) provenance uses slsa-github-generator pinned to a SEMANTIC VERSION TAG +# (NOT a SHA — SLSA trust model requires the tag for its self-verification) +# ---------------------------------------------------------------------- +prov="$(job_body provenance)" +printf '%s\n' "$prov" | grep -qE 'uses:[[:space:]]*slsa-framework/slsa-github-generator/.+/generator_generic_slsa3\.yml@v[0-9]+\.[0-9]+\.[0-9]+' \ + || fail "provenance must \`uses: slsa-framework/slsa-github-generator/.../generator_generic_slsa3.yml@vX.Y.Z\` (tag-pinned per SLSA trust model)" + +# ---------------------------------------------------------------------- +# (5) provenance must have `upload-assets: false` — release-assets-draft is +# the sole Release-asset writer; two concurrent writers would race. +# ---------------------------------------------------------------------- +printf '%s\n' "$prov" | grep -qE '^[[:space:]]+upload-assets:[[:space:]]*false[[:space:]]*$' \ + || fail "provenance must set \`upload-assets: false\` (single Release-asset writer is release-assets-draft; the .intoto.jsonl flows through the workflow-artifact path)" + +# ---------------------------------------------------------------------- +# (6) provenance-name MUST end in `.intoto.jsonl` — Scorecard's provenance +# probe is a pure filename-suffix match. +# ---------------------------------------------------------------------- +printf '%s\n' "$prov" | grep -qE '^[[:space:]]+provenance-name:.*\.intoto\.jsonl[[:space:]]*$' \ + || fail "provenance must set \`provenance-name: .intoto.jsonl\` (Scorecard Signed-Releases provenance probe matches this suffix only)" + +# ---------------------------------------------------------------------- +# (7) attest job grants id-token: write + attestations: write +# ---------------------------------------------------------------------- +att="$(job_body attest)" +printf '%s\n' "$att" | grep -qE '^[[:space:]]+id-token:[[:space:]]*write' \ + || fail "attest job must grant \`id-token: write\` (Sigstore OIDC signing cert)" +printf '%s\n' "$att" | grep -qE '^[[:space:]]+attestations:[[:space:]]*write' \ + || fail "attest job must grant \`attestations: write\` (persist to the GitHub attestation store)" + +# ---------------------------------------------------------------------- +# (8) Both publish jobs grant id-token: write AND need release-assets-draft. +# ---------------------------------------------------------------------- +for pub in publish-crate publish-pypi; do + body="$(job_body "$pub")" + printf '%s\n' "$body" | grep -qE '^[[:space:]]+id-token:[[:space:]]*write' \ + || fail "$pub must grant \`id-token: write\` (Trusted Publishing OIDC)" + job_needs "$pub" release-assets-draft \ + || fail "$pub must \`needs: release-assets-draft\` (gated by attest + provenance via the draft-assets edge)" +done + +# ---------------------------------------------------------------------- +# (9) publish-crate enforces byte-identity vs the attested .crate. The +# uploaded crate must match the SLSA-attested artifact, otherwise the +# published version isn't the one the provenance covers. +# ---------------------------------------------------------------------- +pcb="$(job_body publish-crate)" +printf '%s\n' "$pcb" | grep -qE 'uses:[[:space:]]*actions/download-artifact' \ + || fail "publish-crate must download the attested dist-crate artifact (byte-identity gate)" +printf '%s\n' "$pcb" | grep -qE 'name:[[:space:]]*dist-crate' \ + || fail "publish-crate must download the artifact named \`dist-crate\` (the attested .crate)" +printf '%s\n' "$pcb" | grep -qE 'cargo[[:space:]]+package[[:space:]]+-p[[:space:]]+ordvec[[:space:]]+--locked' \ + || fail "publish-crate must re-run \`cargo package -p ordvec --locked\` so it can sha256-compare to the attested .crate" +printf '%s\n' "$pcb" | grep -qE 'sha256sum' \ + || fail "publish-crate must sha256sum-compare the repackaged .crate vs the attested .crate before publishing" + +# ---------------------------------------------------------------------- +# (10) publish-github-release un-drafts ONLY AFTER both registry publishes succeed. +# ---------------------------------------------------------------------- +for dep in publish-crate publish-pypi; do + job_needs publish-github-release "$dep" \ + || fail "publish-github-release must \`needs: $dep\` (un-draft only after BOTH registry publishes succeed)" +done +unp="$(job_body publish-github-release)" +printf '%s\n' "$unp" | grep -qE 'gh release edit.*--draft=false' \ + || fail "publish-github-release must \`gh release edit --draft=false\` (this is the sole un-draft point)" + +echo "OK: signed-release invariants hold." From 3a75c74ec18f8305b0486d025e47b24c3903a243 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Thu, 28 May 2026 09:44:26 -0500 Subject: [PATCH 09/10] ci(release): post-publish proof that crates.io served bytes == attested (codex stop-gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex correctly flagged that the previous byte-identity check only proved `cargo package`'s output matches the attested .crate — but `cargo publish` runs its OWN internal packaging step before uploading, which the pre-publish gate cannot inspect. Determinism makes those bytes equal in practice, but "must be" is not "is." Add the empirical post-publish proof to publish-crate: after `cargo publish` succeeds, curl the just-published .crate from `https://crates.io/api/v1/crates/ordvec//download` (with a 60s retry window for CDN propagation) and sha256-compare to the attested artifact. * If the bytes crates.io serves equal the SLSA-attested bytes -> the version on crates.io IS the artifact the provenance covers (the byte-identity claim is empirically verified, not just assumed). * If they differ -> publish-crate fails closed. The version is on crates.io (yank-only) but publish-github-release will NEVER un-draft the Release, and the mismatch is loudly audit-logged. The pre-publish gate stays as a fast-fail before the OIDC token is even minted; the post-publish step is the actual proof. Together they cover both sides of `cargo publish`'s internal packaging. Invariants script (release_signed_release_invariants.sh) updated to require the post-publish curl + sha256 step exists in publish-crate; RELEASING.md describes both sides of the proof. release.yml header expanded accordingly. Locally: actionlint clean, zizmor clean, both invariant scripts OK. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Nelson Spence --- .github/workflows/release.yml | 55 ++++++++++++++++++++-- RELEASING.md | 19 ++++++-- tests/release_signed_release_invariants.sh | 16 +++++-- 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90d5b6e7..907ef101 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,11 +14,19 @@ # investigates / re-runs the failed job, after which `publish-github-release` # un-drafts. # -# `publish-crate` additionally proves BYTE-IDENTITY between the .crate -# `cargo publish` uploads and the SLSA-attested artifact: it downloads the -# `dist-crate` artifact, re-packages with `--locked`, and sha256-compares -# before minting the crates.io OIDC token. If the bytes drift (toolchain -# change, deterministic-packaging regression), the publish fails closed. +# `publish-crate` proves BYTE-IDENTITY between the .crate served by crates.io +# and the SLSA-attested artifact on BOTH sides of `cargo publish`: +# * pre-publish: downloads the `dist-crate` artifact, re-packages with +# `--locked`, sha256-compares — fail-closed BEFORE the OIDC token is +# minted (defends against toolchain drift / deterministic-packaging +# regression). +# * post-publish: downloads the just-published .crate from crates.io and +# sha256-compares to the attested artifact — empirical proof that the +# bytes crates.io actually serves equal the SLSA-attested bytes +# (`cargo publish` runs its own internal packaging step the pre-publish +# gate cannot inspect). A mismatch fails closed: `publish-github-release` +# never un-drafts the GitHub Release, and the failure is loudly audit- +# logged even though the version is then yank-only. # # Why one workflow (not the old three): the GitHub Release assets + the single # un-draft must be coordinated by explicit `needs:` edges. The previous @@ -524,6 +532,43 @@ jobs: env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} run: cargo publish -p ordvec --locked + # The pre-publish gate above proves `cargo package`'s output matches the + # SLSA-attested artifact, but `cargo publish` re-runs `cargo package` ONCE + # MORE before uploading. By cargo's deterministic-packaging guarantee the + # bytes must be identical — but "must be" is not "is." Download the + # just-published .crate from crates.io and sha256-compare to the attested + # artifact: this is the empirical proof that the bytes on crates.io equal + # the bytes covered by the SLSA provenance. A mismatch fails this job + # closed, which (a) prevents `publish-github-release` from un-drafting + # the GitHub Release, and (b) leaves an actionable audit-log entry — + # even though the crates.io version is then yank-only, the failure is + # loudly observable rather than silent. + - name: Post-publish byte-identity (download from crates.io == attested) + env: + VERSION: ${{ needs.guard.outputs.version }} + run: | + set -euo pipefail + ATTESTED="attested/ordvec-${VERSION}.crate" + [ -f "$ATTESTED" ] || { echo "::error::attested .crate missing at $ATTESTED"; exit 1; } + A_SHA=$(sha256sum "$ATTESTED" | cut -d' ' -f1) + # crates.io's stable download endpoint (follows redirect to the CDN). + # CDN propagation can take a few seconds after publish — retry briefly. + URL="https://crates.io/api/v1/crates/ordvec/${VERSION}/download" + for i in 1 2 3 4 5 6 7 8 9 10 11 12; do + if curl -fsSL "$URL" -o /tmp/published.crate; then break; fi + echo " waiting for crates.io to serve ordvec ${VERSION} (${i}/12)..." + sleep 5 + done + [ -s /tmp/published.crate ] \ + || { echo "::error::could not download published .crate from $URL after retries"; exit 1; } + P_SHA=$(sha256sum /tmp/published.crate | cut -d' ' -f1) + echo "attested: $A_SHA" + echo "crates.io-served: $P_SHA" + if [ "$A_SHA" != "$P_SHA" ]; then + echo "::error::PUBLISHED .crate on crates.io is NOT byte-identical to the SLSA-attested artifact ($P_SHA != $A_SHA). The version is now on crates.io (yank-only) but the GitHub Release will NOT un-draft. Investigate immediately." + exit 1 + fi + echo "OK: crates.io-served .crate is byte-identical to the SLSA-attested artifact ($A_SHA)." publish-pypi: name: publish to PyPI diff --git a/RELEASING.md b/RELEASING.md index a79321e9..e076d9bd 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -33,11 +33,20 @@ The unified `release.yml`: `*.intoto.jsonl` provenance** on the GitHub Release while it is still **a DRAFT** (`release-assets-draft` is the sole Release-asset writer — no manual attach, which is what v0.2.0's manual step missed); -- enforces **byte-identity** in `publish-crate`: it downloads the SLSA-attested - `.crate` artifact, re-packages with `--locked`, and `sha256`-compares before - minting the crates.io OIDC token. If they differ (toolchain drift, - non-deterministic packaging), the publish fails closed **before** the token - is minted — nothing reaches crates.io; +- proves **byte-identity** in `publish-crate` on both sides of `cargo publish`: + 1. **pre-publish gate** — downloads the SLSA-attested `.crate` artifact, + re-packages with `--locked`, and `sha256`-compares before minting the + crates.io OIDC token. Defends against toolchain drift / deterministic- + packaging regression; if they differ, fails closed **before** the + token is minted (nothing reaches crates.io); + 2. **post-publish empirical proof** — downloads the just-published `.crate` + from `crates.io/api/v1/crates/ordvec//download` and `sha256`-compares + to the attested artifact. `cargo publish` runs its own internal + packaging step the pre-publish gate cannot inspect; this is the only + check that proves the bytes crates.io actually serves equal the SLSA- + attested bytes. A mismatch fails closed, so `publish-github-release` + never un-drafts the Release (the version is then yank-only on + crates.io, but the failure is loudly observable); - **un-drafts the GitHub Release ONLY after BOTH `publish-crate` AND `publish-pypi` succeed** (`publish-github-release` is the sole un-draft point). If either publish fails or is skipped, the Release stays DRAFT — no diff --git a/tests/release_signed_release_invariants.sh b/tests/release_signed_release_invariants.sh index 0249b934..2aa65424 100755 --- a/tests/release_signed_release_invariants.sh +++ b/tests/release_signed_release_invariants.sh @@ -119,9 +119,15 @@ for pub in publish-crate publish-pypi; do done # ---------------------------------------------------------------------- -# (9) publish-crate enforces byte-identity vs the attested .crate. The -# uploaded crate must match the SLSA-attested artifact, otherwise the -# published version isn't the one the provenance covers. +# (9) publish-crate proves byte-identity vs the attested .crate on BOTH +# sides of `cargo publish`: +# (9a) pre-publish: download the attested .crate, re-run `cargo package`, +# sha256-compare. Fail-closed BEFORE the OIDC token is minted. +# (9b) post-publish: download the just-published .crate from crates.io +# and sha256-compare to the attested artifact. cargo publish runs +# its own internal packaging step that the pre-publish gate +# cannot inspect — this is the empirical proof that the bytes +# actually served by crates.io match the SLSA-attested artifact. # ---------------------------------------------------------------------- pcb="$(job_body publish-crate)" printf '%s\n' "$pcb" | grep -qE 'uses:[[:space:]]*actions/download-artifact' \ @@ -129,9 +135,11 @@ printf '%s\n' "$pcb" | grep -qE 'uses:[[:space:]]*actions/download-artifact' \ printf '%s\n' "$pcb" | grep -qE 'name:[[:space:]]*dist-crate' \ || fail "publish-crate must download the artifact named \`dist-crate\` (the attested .crate)" printf '%s\n' "$pcb" | grep -qE 'cargo[[:space:]]+package[[:space:]]+-p[[:space:]]+ordvec[[:space:]]+--locked' \ - || fail "publish-crate must re-run \`cargo package -p ordvec --locked\` so it can sha256-compare to the attested .crate" + || fail "publish-crate must re-run \`cargo package -p ordvec --locked\` so it can sha256-compare to the attested .crate (pre-publish gate)" printf '%s\n' "$pcb" | grep -qE 'sha256sum' \ || fail "publish-crate must sha256sum-compare the repackaged .crate vs the attested .crate before publishing" +printf '%s\n' "$pcb" | grep -qE 'crates\.io/api/v1/crates/ordvec|static\.crates\.io/crates/ordvec' \ + || fail "publish-crate must download the just-published .crate from crates.io after \`cargo publish\` (post-publish byte-identity proof; pre-publish alone cannot inspect cargo publish's internal packaging)" # ---------------------------------------------------------------------- # (10) publish-github-release un-drafts ONLY AFTER both registry publishes succeed. From c63aa688ff8941b2bc64acb78f7ac80359fb18bb Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Thu, 28 May 2026 09:50:03 -0500 Subject: [PATCH 10/10] docs(release): scrub stale `release-assets` comment refs (grumpy round-2) Grumpy's round-2 nits flagged two workflow comments still referencing the old (pre-split) `release-assets` job name + un-draft timing: * `notes` job: "draft so artifacts get assembled before release-assets un-drafts" -> rewritten to describe the new sequence (release-assets-draft stages, publishes run gated, publish-github-release un-drafts only after both succeed). * `provenance` job: "release-assets is the single owner of all Release uploads" -> renamed to release-assets-draft. Pure comment cleanup; no YAML semantic change. actionlint clean, zizmor clean, both invariant scripts OK. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Nelson Spence --- .github/workflows/release.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 907ef101..c9676227 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -186,7 +186,9 @@ jobs: OUTPUT: RELEASE_NOTES.md - name: Create the draft GitHub Release # Draft so notes + the auto-attached artifacts/provenance get assembled - # before `release-assets` un-drafts. `--verify-tag` ties it to this tag. + # by `release-assets-draft`, the publishes then run gated, and + # `publish-github-release` un-drafts ONLY after both publishes succeed. + # `--verify-tag` ties it to this tag. env: GH_TOKEN: ${{ github.token }} TAG_NAME: ${{ github.ref_name }} @@ -428,8 +430,9 @@ jobs: uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 # zizmor: ignore[unpinned-uses] with: base64-subjects: ${{ needs.combine-hashes.outputs.hashes }} - # Produce the signed provenance as a workflow artifact ONLY; `release-assets` - # is the single owner of all Release uploads (no concurrent writers). + # Produce the signed provenance as a workflow artifact ONLY; + # `release-assets-draft` is the single owner of all Release uploads + # (no concurrent writers). upload-assets: false provenance-name: ordvec-${{ needs.guard.outputs.version }}.intoto.jsonl