From 61e297d572be3de228d047c31aa4fce8d848c84e Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 1 Jul 2026 07:11:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(release):=20release=20status=20readiness?= =?UTF-8?q?=20is=20configurable=20=E2=80=94=20ready-when=20+=20coverage=20?= =?UTF-8?q?(REQ-240,=20#612)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rivet release status` treated only verified/accepted as release-ready, so a V-model/ASPICE project — which verifies via links, not a status flip — could never green the gate. Readiness is now configurable via rivet.yaml's `release:` block: - `ready-when: [...]` extends the release-ready status set (e.g. a project whose lifecycle gates on an `approved` sign-off). - `require: coverage` ALSO counts an artifact ready when its V is closed — every validate coverage rule applicable to its type is satisfied — regardless of the status string. Purely additive: a verified/accepted/ready-when artifact still counts, so switching modes never makes a release LESS cuttable. ReleaseConfig{ready-when, require} on ProjectConfig; cmd_release_status folds both into the readiness predicate, computing coverage V-closure only in coverage mode. Defaults unchanged (status-only). Confirmed end-to-end on a minimal schema: an `approved` widget whose V is closed by a tracing part is not-cuttable by status, cuttable under `require: coverage`, and blocks again when the trace is removed. Regression test release_status_ready_when_and_coverage_modes covers all four states. This completes REQ-240 (the earlier ready-when-only slice was partial). Implements: REQ-240 Verifies: REQ-240 Refs: REQ-007 Co-Authored-By: Claude Opus 4.8 --- artifacts/requirements.yaml | 4 +- rivet-cli/src/main.rs | 56 ++++++++++++++++--- rivet-cli/src/serve/variant.rs | 1 + rivet-cli/tests/cli_commands.rs | 97 +++++++++++++++++++++++++++++++++ rivet-core/src/model.rs | 26 +++++++++ 5 files changed, 174 insertions(+), 10 deletions(-) diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index c91d661e..3e30b93b 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -7590,8 +7590,8 @@ artifacts: - id: REQ-240 type: requirement title: release status cuttability derives from coverage/links, not just status string - status: proposed - description: "rivet release status cuttability is status-only (verified/accepted); V-model/ASPICE projects verify via links (validate coverage rules), so the gate never greens for them. Derive release-ready from validate coverage passing, with a release.ready-when escape hatch. #612, follow-up to REQ-233. Partial: the release.ready-when escape hatch is delivered (ProjectConfig.release.ready-when extends the release-ready status set); deriving cuttability from validate coverage is still open before this can be verified." + status: verified + description: "rivet release status cuttability was status-only (verified/accepted); V-model/ASPICE projects verify via links (validate coverage rules), so the gate never greened for them. Now configurable via rivet.yaml's release: block — `ready-when` extends the release-ready status set, and `require: coverage` derives readiness from validate V-closure (an artifact counts as ready when every coverage rule applicable to its type is satisfied, regardless of status string; purely additive). #612, follow-up to REQ-233." provenance: created-by: ai-assisted model: claude-opus-4-8 diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 08feb3da..5520b276 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -6910,8 +6910,45 @@ fn cmd_release_status(cli: &Cli, version: &str, format: &str) -> Result { .collect(); scoped.sort_by(|a, b| a.id.cmp(&b.id)); - // `verified` and `accepted` are the done states; anything else still blocks. - let is_done = |s: Option<&str>| matches!(s, Some("verified") | Some("accepted")); + // Release-ready statuses: the built-in `verified`/`accepted`, plus any the + // project declares via `release.ready-when` (#612). And, when + // `release.require: coverage` is set, an artifact whose V is closed (every + // validate coverage rule that applies to its type is satisfied) also counts + // as ready regardless of its status string — so V-model / ASPICE projects + // that verify via links, not a status flip, can green the gate. Coverage is + // purely additive: a verified/accepted/ready-when artifact still counts. + let rel = ctx.config.release.as_ref(); + let extra_ready: std::collections::BTreeSet = rel + .map(|r| r.ready_when.iter().cloned().collect()) + .unwrap_or_default(); + let coverage_mode = rel.and_then(|r| r.require.as_deref()) == Some("coverage"); + // In coverage mode, `covered_types` are the artifact types governed by at + // least one traceability rule, and `uncovered_ids` are the artifacts + // strictly missing on some applicable rule. An artifact is V-closed when + // its type is governed AND it is not in `uncovered_ids`. + let (covered_types, uncovered_ids): ( + std::collections::BTreeSet, + std::collections::BTreeSet, + ) = if coverage_mode { + let cov = rivet_core::coverage::compute_coverage(&ctx.store, &ctx.schema, &ctx.graph); + ( + cov.entries.iter().map(|e| e.source_type.clone()).collect(), + cov.entries + .iter() + .flat_map(|e| e.uncovered_ids.iter().cloned()) + .collect(), + ) + } else { + (Default::default(), Default::default()) + }; + let is_ready = |a: &rivet_core::model::Artifact| -> bool { + let s = a.status.as_deref(); + matches!(s, Some("verified") | Some("accepted")) + || s.is_some_and(|x| extra_ready.contains(x)) + || (coverage_mode + && covered_types.contains(&a.artifact_type) + && !uncovered_ids.contains(&a.id)) + }; let mut by_status: std::collections::BTreeMap = std::collections::BTreeMap::new(); for a in &scoped { @@ -6919,10 +6956,8 @@ fn cmd_release_status(cli: &Cli, version: &str, format: &str) -> Result { .entry(a.status.as_deref().unwrap_or("(none)").to_string()) .or_default() += 1; } - let not_done: Vec<&&rivet_core::model::Artifact> = scoped - .iter() - .filter(|a| !is_done(a.status.as_deref())) - .collect(); + let not_done: Vec<&&rivet_core::model::Artifact> = + scoped.iter().filter(|a| !is_ready(a)).collect(); // An EMPTY scope is not cuttable: a release nobody has assigned artifacts // to — or, more commonly, a mistyped version — must not green a // `rivet release status vX.Y.Z || fail` CI gate. "Ship a release @@ -6953,9 +6988,14 @@ fn cmd_release_status(cli: &Cli, version: &str, format: &str) -> Result { println!(" {status:<12} {count}"); } if cuttable { - println!("\n\u{2713} Cuttable — every artifact is verified/accepted."); + let how = if coverage_mode { + "release-ready (verified/accepted, ready-when, or V-closed)" + } else { + "release-ready" + }; + println!("\n\u{2713} Cuttable — every artifact is {how}."); } else { - println!("\nNot yet verified ({}):", not_done.len()); + println!("\nNot yet release-ready ({}):", not_done.len()); for a in ¬_done { println!( " {:<10} {:<12} {}", diff --git a/rivet-cli/src/serve/variant.rs b/rivet-cli/src/serve/variant.rs index a5bd6114..a6b91c09 100644 --- a/rivet-cli/src/serve/variant.rs +++ b/rivet-cli/src/serve/variant.rs @@ -339,6 +339,7 @@ mod tests { docs: vec![], results: None, commits: None, + release: None, externals: None, baselines: None, docs_check: None, diff --git a/rivet-cli/tests/cli_commands.rs b/rivet-cli/tests/cli_commands.rs index f766be33..55875c53 100644 --- a/rivet-cli/tests/cli_commands.rs +++ b/rivet-cli/tests/cli_commands.rs @@ -6513,6 +6513,103 @@ fn release_status_empty_scope_is_not_cuttable() { ); } +/// REQ-240 (#612): `rivet release status` readiness is configurable via +/// `rivet.yaml`'s `release:` block — the built-in verified/accepted set can be +/// extended by `ready-when`, and `require: coverage` derives readiness from +/// validate V-closure so a V-model/ASPICE project (which verifies via links, +/// not a status flip) can green the gate. An approved-but-V-closed artifact is +/// the discriminating case: not ready by status, ready by coverage. +/// +/// rivet: verifies REQ-240 +#[test] +fn release_status_ready_when_and_coverage_modes() { + let tmp = tempfile::tempdir().expect("temp dir"); + let dir = tmp.path(); + let dirs = dir.to_str().unwrap(); + std::fs::create_dir_all(dir.join("schemas")).unwrap(); + std::fs::create_dir_all(dir.join("artifacts")).unwrap(); + // A minimal schema with one traceability rule: a widget must be traced by + // a part. WID-001 is `approved` (not verified) but its V is CLOSED via + // PART-001 — so it discriminates status-readiness from coverage-readiness. + std::fs::write( + dir.join("schemas/mini.yaml"), + "schema:\n name: mini\n version: \"0.1.0\"\n\ + artifact-types:\n \ + - name: widget\n description: W\n link-fields:\n \ + - name: traces\n link-type: traces\n cardinality: zero-or-many\n \ + - name: part\n description: P\n link-fields:\n \ + - name: traces\n link-type: traces\n cardinality: zero-or-many\n\ + traceability-rules:\n \ + - name: widget-traced\n description: every widget must be traced by a part\n \ + source-type: widget\n required-backlink: traces\n from-types: [part]\n \ + severity: warning\n", + ) + .unwrap(); + std::fs::write( + dir.join("artifacts/a.yaml"), + "artifacts:\n \ + - id: WID-001\n type: widget\n title: closed\n status: approved\n release: v1.0.0\n \ + - id: PART-001\n type: part\n title: part\n status: approved\n \ + links:\n - type: traces\n target: WID-001\n", + ) + .unwrap(); + + let write_cfg = |release_block: &str| { + std::fs::write( + dir.join("rivet.yaml"), + format!( + "project:\n name: p\n schemas: [mini]\n\ + sources:\n - path: artifacts\n format: generic-yaml\n{release_block}" + ), + ) + .unwrap(); + }; + let cuttable = || -> bool { + let out = Command::new(rivet_bin()) + .args([ + "--project", + dirs, + "release", + "status", + "v1.0.0", + "--format", + "json", + ]) + .output() + .expect("release status"); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json"); + // exit code and the json flag must agree + assert_eq!(v["cuttable"].as_bool().unwrap(), out.status.success()); + v["cuttable"].as_bool().unwrap() + }; + + // Default (status mode): approved != verified → not cuttable. + write_cfg(""); + assert!(!cuttable(), "status mode: an approved artifact must block"); + + // ready-when: [approved] → approved now counts → cuttable. + write_cfg("release:\n ready-when: [approved]\n"); + assert!(cuttable(), "ready-when must extend the ready set"); + + // require: coverage → WID-001's V is closed → cuttable even though approved. + write_cfg("release:\n require: coverage\n"); + assert!( + cuttable(), + "coverage mode: a V-closed artifact must be ready" + ); + + // require: coverage but V OPEN (drop the tracing part) → not cuttable. + std::fs::write( + dir.join("artifacts/a.yaml"), + "artifacts:\n - id: WID-001\n type: widget\n title: open\n status: approved\n release: v1.0.0\n", + ) + .unwrap(); + assert!( + !cuttable(), + "coverage mode: an artifact with an open V must block" + ); +} + /// REQ-234 (#516): `rivet release move ` re-targets an artifact to a /// release, logging the old → new transition; idempotent when already scoped. /// diff --git a/rivet-core/src/model.rs b/rivet-core/src/model.rs index b2657df6..23094c2e 100644 --- a/rivet-core/src/model.rs +++ b/rivet-core/src/model.rs @@ -978,6 +978,29 @@ impl From<&str> for DocsEntry { } } +/// Release-readiness configuration (`release:` in `rivet.yaml`), consumed by +/// `rivet release status` (#612 / REQ-240). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ReleaseConfig { + /// Extra artifact statuses that count as release-ready, beyond the built-in + /// `verified`/`accepted` — e.g. a project whose lifecycle gates on an + /// `approved` sign-off. The escape hatch for a project's own definition of + /// done. + #[serde(default, rename = "ready-when")] + pub ready_when: Vec, + /// How `rivet release status` decides an artifact is release-ready: + /// - `"status"` (default): the status string (plus `ready-when`). + /// - `"coverage"`: ALSO count an artifact ready when its V is closed — + /// every validate coverage rule that applies to its type is satisfied — + /// regardless of the status string. This lets V-model / ASPICE projects, + /// which verify via links rather than a status flip, green the gate. + /// Purely additive: a `verified`/`accepted`/`ready-when` artifact still + /// counts, so switching to `coverage` never makes a release *less* + /// cuttable. + #[serde(default)] + pub require: Option, +} + /// Project configuration loaded from `rivet.yaml`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectConfig { @@ -996,6 +1019,9 @@ pub struct ProjectConfig { /// Commit traceability configuration. #[serde(default)] pub commits: Option, + /// Release-readiness configuration for `rivet release status` (#612). + #[serde(default)] + pub release: Option, /// External project dependencies for cross-repo linking. #[serde(default)] pub externals: Option>,