Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions artifacts/requirements.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 48 additions & 8 deletions rivet-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6910,19 +6910,54 @@ fn cmd_release_status(cli: &Cli, version: &str, format: &str) -> Result<bool> {
.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<String> = 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<String>,
std::collections::BTreeSet<String>,
) = 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<String, usize> =
std::collections::BTreeMap::new();
for a in &scoped {
*by_status
.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
Expand Down Expand Up @@ -6953,9 +6988,14 @@ fn cmd_release_status(cli: &Cli, version: &str, format: &str) -> Result<bool> {
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 &not_done {
println!(
" {:<10} {:<12} {}",
Expand Down
1 change: 1 addition & 0 deletions rivet-cli/src/serve/variant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ mod tests {
docs: vec![],
results: None,
commits: None,
release: None,
externals: None,
baselines: None,
docs_check: None,
Expand Down
97 changes: 97 additions & 0 deletions rivet-cli/tests/cli_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> <ver>` re-targets an artifact to a
/// release, logging the old → new transition; idempotent when already scoped.
///
Expand Down
26 changes: 26 additions & 0 deletions rivet-core/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
}

/// Project configuration loaded from `rivet.yaml`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectConfig {
Expand All @@ -996,6 +1019,9 @@ pub struct ProjectConfig {
/// Commit traceability configuration.
#[serde(default)]
pub commits: Option<CommitsConfig>,
/// Release-readiness configuration for `rivet release status` (#612).
#[serde(default)]
pub release: Option<ReleaseConfig>,
/// External project dependencies for cross-repo linking.
#[serde(default)]
pub externals: Option<BTreeMap<String, ExternalProject>>,
Expand Down
Loading