From 138d94c28f91ccb8dd97de87e514035a7eb416e8 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 19 Oct 2026 08:43:06 +0200 Subject: [PATCH 01/17] feat(rollup): pure selection, bisection and close-outcome core for the rollup bot (Refs #5394) Agent: aprender-w5404 Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 4 + Cargo.toml | 1 + crates/aprender-rollup/Cargo.toml | 14 +++ crates/aprender-rollup/src/lib.rs | 137 ++++++++++++++++++++++++++++ crates/aprender-rollup/src/tests.rs | 115 +++++++++++++++++++++++ 5 files changed, 271 insertions(+) create mode 100644 crates/aprender-rollup/Cargo.toml create mode 100644 crates/aprender-rollup/src/lib.rs create mode 100644 crates/aprender-rollup/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 8f724f9c91..c090ca97c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1388,6 +1388,10 @@ dependencies = [ "zstd", ] +[[package]] +name = "aprender-rollup" +version = "0.71.3" + [[package]] name = "aprender-serve" version = "0.71.3" diff --git a/Cargo.toml b/Cargo.toml index 7a2a0fd787..0050cf07ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ members = [ "crates/aprender-compute-xtask", # --- PY-PORT (C301): CI helpers ported from scripts/**/*.py, publish = false --- "crates/aprender-ci-tools", + "crates/aprender-rollup", # --- APR-MONO Phase 2b: was provable-contracts --- "crates/aprender-contracts", "crates/aprender-contracts-macros", diff --git a/crates/aprender-rollup/Cargo.toml b/crates/aprender-rollup/Cargo.toml new file mode 100644 index 0000000000..4e3417dcb7 --- /dev/null +++ b/crates/aprender-rollup/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "aprender-rollup" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Rollup bot core: picks small green PRs to land as one merge-queue entry (T-rollup, #5394)" +license = "MIT OR Apache-2.0" +repository = "https://github.com/paiml/aprender" +authors = ["PAIML Engineering"] +# Build tooling, never installed or published. +publish = false + +[lints] +workspace = true diff --git a/crates/aprender-rollup/src/lib.rs b/crates/aprender-rollup/src/lib.rs new file mode 100644 index 0000000000..5c1cd632e0 --- /dev/null +++ b/crates/aprender-rollup/src/lib.rs @@ -0,0 +1,137 @@ +//! Rollup bot core (T-rollup, refs #5394): pure selection and bisection, no I/O. +//! +//! The `gh` layer feeds [`Candidate`]s in; [`select`] picks a rollup; on a red +//! merge-group run [`split`] halves it. The bot closes a constituent only after the +//! rollup commit is on main, with the SHA posted; never on red (see [`Outcome`]). + +/// Largest PR (additions + deletions, from `gh`) that may join a rollup. +pub const MAX_LINES: u32 = 200; +/// Most constituents in one rollup. +pub const MAX_ENTRIES: usize = 10; +/// Label a PR owner sets to opt in. +pub const OPT_IN_LABEL: &str = "rollup-ok"; + +/// One open PR as read from GitHub. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + pub number: u64, + pub additions: u32, + pub deletions: u32, + pub green: bool, + pub approved: bool, + pub labels: Vec, + pub files: Vec, +} + +/// Why a PR stays out of a rollup; posted as the ejection reason. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Reject { + NotGreen, + NotApproved, + NoOptInLabel, + TooLarge(u32), + ForbiddenFile(String), + FileOverlap { with: u64, file: String }, +} + +/// Files a rollup never carries: CI workflows, contracts, the lockfile. +#[must_use] +pub fn is_forbidden(path: &str) -> bool { + path.starts_with(".github/workflows/") || path.starts_with("contracts/") || path == "Cargo.lock" +} + +/// First reason `c` is ineligible on its own, if any. +#[must_use] +pub fn check(c: &Candidate) -> Option { + if !c.green { + return Some(Reject::NotGreen); + } + if !c.approved { + return Some(Reject::NotApproved); + } + if !c.labels.iter().any(|l| l == OPT_IN_LABEL) { + return Some(Reject::NoOptInLabel); + } + let lines = c.additions.saturating_add(c.deletions); + if lines > MAX_LINES { + return Some(Reject::TooLarge(lines)); + } + c.files + .iter() + .find(|f| is_forbidden(f)) + .map(|f| Reject::ForbiddenFile(f.clone())) +} + +/// A chosen rollup plus every PR left out and why. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Selection { + pub picked: Vec, + pub rejected: Vec<(u64, Reject)>, +} + +/// Pick up to [`MAX_ENTRIES`] eligible PRs, lowest number first, no shared file. +#[must_use] +pub fn select(cands: &[Candidate]) -> Selection { + let mut sorted: Vec<&Candidate> = cands.iter().collect(); + sorted.sort_by_key(|c| c.number); + let mut sel = Selection::default(); + let mut taken: Vec<(&Candidate, u64)> = Vec::new(); + for c in sorted { + if let Some(r) = check(c) { + sel.rejected.push((c.number, r)); + continue; + } + let clash = taken.iter().find_map(|(t, n)| { + c.files + .iter() + .find(|f| t.files.contains(f)) + .map(|f| (*n, f.clone())) + }); + if let Some((with, file)) = clash { + sel.rejected + .push((c.number, Reject::FileOverlap { with, file })); + } else if taken.len() < MAX_ENTRIES { + sel.picked.push(c.number); + taken.push((c, c.number)); + } + } + sel +} + +/// Halve a red rollup for bisection. A single entry cannot be split. +#[must_use] +pub fn split(picked: &[u64]) -> Option<(Vec, Vec)> { + if picked.len() < 2 { + return None; + } + let (a, b) = picked.split_at(picked.len() / 2); + Some((a.to_vec(), b.to_vec())) +} + +/// What the bot may do with one constituent after a merge-group run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outcome { + /// Rollup commit is on main: close with the SHA posted. + Close { sha: String }, + /// Red or not yet on main: leave open and say why. + KeepOpen { reason: String }, +} + +/// Close only when the run is green and the rollup commit is on main. +#[must_use] +pub fn outcome(green: bool, sha_on_main: Option<&str>) -> Outcome { + match (green, sha_on_main) { + (true, Some(sha)) if !sha.is_empty() => Outcome::Close { + sha: sha.to_string(), + }, + (false, _) => Outcome::KeepOpen { + reason: "rollup run red".to_string(), + }, + _ => Outcome::KeepOpen { + reason: "rollup commit not on main".to_string(), + }, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/aprender-rollup/src/tests.rs b/crates/aprender-rollup/src/tests.rs new file mode 100644 index 0000000000..840e7b99f8 --- /dev/null +++ b/crates/aprender-rollup/src/tests.rs @@ -0,0 +1,115 @@ +use super::*; + +fn cand(n: u64, files: &[&str]) -> Candidate { + Candidate { + number: n, + additions: 10, + deletions: 5, + green: true, + approved: true, + labels: vec![OPT_IN_LABEL.to_string()], + files: files.iter().map(|s| (*s).to_string()).collect(), + } +} + +#[test] +fn eligible_candidate_passes() { + assert_eq!(check(&cand(1, &["a.rs"])), None); +} + +#[test] +fn each_gate_rejects() { + let mut c = cand(1, &["a.rs"]); + c.green = false; + assert_eq!(check(&c), Some(Reject::NotGreen)); + let mut c = cand(1, &["a.rs"]); + c.approved = false; + assert_eq!(check(&c), Some(Reject::NotApproved)); + let mut c = cand(1, &["a.rs"]); + c.labels.clear(); + assert_eq!(check(&c), Some(Reject::NoOptInLabel)); +} + +#[test] +fn line_limit_is_additions_plus_deletions_inclusive() { + let mut c = cand(1, &["a.rs"]); + c.additions = 150; + c.deletions = 50; + assert_eq!(check(&c), None); + c.deletions = 51; + assert_eq!(check(&c), Some(Reject::TooLarge(201))); + c.additions = u32::MAX; + assert_eq!(check(&c), Some(Reject::TooLarge(u32::MAX))); +} + +#[test] +fn forbidden_paths() { + for p in [".github/workflows/ci.yml", "contracts/x.yaml", "Cargo.lock"] { + assert_eq!( + check(&cand(1, &["a.rs", p])), + Some(Reject::ForbiddenFile(p.to_string())) + ); + } + assert!(!is_forbidden("crates/x/Cargo.lock.rs")); + assert!(!is_forbidden("docs/contracts/x.md")); +} + +#[test] +fn select_orders_by_number_and_skips_overlap() { + let sel = select(&[ + cand(3, &["b.rs"]), + cand(1, &["a.rs"]), + cand(2, &["a.rs", "c.rs"]), + ]); + assert_eq!(sel.picked, vec![1, 3]); + assert_eq!( + sel.rejected, + vec![( + 2, + Reject::FileOverlap { + with: 1, + file: "a.rs".to_string() + } + )] + ); +} + +#[test] +fn select_caps_at_ten_without_rejecting_the_rest() { + let cands: Vec = (1..=12).map(|n| cand(n, &[&format!("f{n}.rs")])).collect(); + let sel = select(&cands); + assert_eq!(sel.picked, (1..=10).collect::>()); + assert!(sel.rejected.is_empty()); +} + +#[test] +fn ineligible_never_blocks_later_candidates() { + let mut bad = cand(1, &["a.rs"]); + bad.green = false; + let sel = select(&[bad, cand(2, &["a.rs"])]); + assert_eq!(sel.picked, vec![2]); +} + +#[test] +fn split_halves_and_stops_at_one() { + assert_eq!(split(&[1, 2, 3, 4]), Some((vec![1, 2], vec![3, 4]))); + assert_eq!(split(&[1, 2, 3]), Some((vec![1], vec![2, 3]))); + assert_eq!(split(&[1]), None); + assert_eq!(split(&[]), None); +} + +#[test] +fn close_only_green_with_sha_on_main() { + assert_eq!( + outcome(true, Some("abc")), + Outcome::Close { + sha: "abc".to_string() + } + ); + assert!(matches!( + outcome(false, Some("abc")), + Outcome::KeepOpen { .. } + )); + assert!(matches!(outcome(true, None), Outcome::KeepOpen { .. })); + assert!(matches!(outcome(true, Some("")), Outcome::KeepOpen { .. })); +} From 5a7718b4446cf1b83b7b0b14c5e0379343b36303 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 19 Oct 2026 09:13:26 +0200 Subject: [PATCH 02/17] chore(roadmap): PMAT-5737 T-rollup ticket (Refs #5737, #5394) Agent: aprender-w5404 Co-Authored-By: Claude Sonnet 5.5 --- docs/roadmaps/entries/PMAT-5737.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/roadmaps/entries/PMAT-5737.yaml diff --git a/docs/roadmaps/entries/PMAT-5737.yaml b/docs/roadmaps/entries/PMAT-5737.yaml new file mode 100644 index 0000000000..6ed288b1c8 --- /dev/null +++ b/docs/roadmaps/entries/PMAT-5737.yaml @@ -0,0 +1,19 @@ +- id: PMAT-5737 + github_issue: 5737 + item_type: task + title: 'T-rollup: rollup bot (aprender-rollup crate + gh layer + bin)' + status: planned + priority: high + assigned_to: null + created: 2026-10-19T07:13:22Z + updated: 2026-10-19T07:13:22Z + spec: null + acceptance_criteria: + - 'INTAKE refs #5394; step 1 on feat/rollup-bot @138d94c28f' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 5510 From 42326e4fc3a326c956705e2a1d56ac80de41b029 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 19 Oct 2026 09:15:21 +0200 Subject: [PATCH 03/17] feat(rollup): parse gh pr list JSON into candidates, failing closed (Refs #5737) Agent: aprender-w5404 Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 3 ++ crates/aprender-rollup/Cargo.toml | 3 ++ crates/aprender-rollup/src/gh_json.rs | 58 +++++++++++++++++++++ crates/aprender-rollup/src/gh_json/tests.rs | 57 ++++++++++++++++++++ crates/aprender-rollup/src/lib.rs | 2 + 5 files changed, 123 insertions(+) create mode 100644 crates/aprender-rollup/src/gh_json.rs create mode 100644 crates/aprender-rollup/src/gh_json/tests.rs diff --git a/Cargo.lock b/Cargo.lock index c090ca97c8..c8cd30b42e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1391,6 +1391,9 @@ dependencies = [ [[package]] name = "aprender-rollup" version = "0.71.3" +dependencies = [ + "serde_json", +] [[package]] name = "aprender-serve" diff --git a/crates/aprender-rollup/Cargo.toml b/crates/aprender-rollup/Cargo.toml index 4e3417dcb7..50b49c7914 100644 --- a/crates/aprender-rollup/Cargo.toml +++ b/crates/aprender-rollup/Cargo.toml @@ -10,5 +10,8 @@ authors = ["PAIML Engineering"] # Build tooling, never installed or published. publish = false +[dependencies] +serde_json = { workspace = true } + [lints] workspace = true diff --git a/crates/aprender-rollup/src/gh_json.rs b/crates/aprender-rollup/src/gh_json.rs new file mode 100644 index 0000000000..dfc440c718 --- /dev/null +++ b/crates/aprender-rollup/src/gh_json.rs @@ -0,0 +1,58 @@ +//! Parse `gh pr list --json number,additions,deletions,labels,files,reviewDecision,statusCheckRollup` +//! into [`Candidate`]s. Pure: the caller runs `gh` (GraphQL, ETag, GH-1/GH-2) and passes the text. + +use crate::Candidate; +use serde_json::Value; + +/// The `--json` field list the parser expects. +pub const GH_FIELDS: &str = + "number,additions,deletions,labels,files,reviewDecision,statusCheckRollup"; + +fn check_ok(c: &Value) -> bool { + // CheckRun: status COMPLETED + conclusion; StatusContext: state. + match (c["conclusion"].as_str(), c["state"].as_str()) { + (Some(k), _) => matches!(k, "SUCCESS" | "NEUTRAL" | "SKIPPED"), + (None, Some(s)) => s == "SUCCESS", + _ => false, + } +} + +fn parse_one(v: &Value) -> Result { + let number = v["number"].as_u64().ok_or("missing number")?; + let n = |k: &str| u32::try_from(v[k].as_u64().unwrap_or(u64::MAX)).unwrap_or(u32::MAX); + let strs = |k: &str, f: &str| -> Vec { + v[k].as_array() + .map(|a| { + a.iter() + .filter_map(|x| x[f].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + }; + let checks = v["statusCheckRollup"].as_array(); + Ok(Candidate { + number, + additions: n("additions"), + deletions: n("deletions"), + green: checks.is_some_and(|c| !c.is_empty() && c.iter().all(check_ok)), + approved: v["reviewDecision"].as_str() == Some("APPROVED"), + labels: strs("labels", "name"), + files: strs("files", "path"), + }) +} + +/// Parse a JSON array of PRs. A malformed row fails the whole read (not_measured, L23). +/// +/// # Errors +/// Returns the parse or field error text. +pub fn parse_prs(json: &str) -> Result, String> { + let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?; + v.as_array() + .ok_or_else(|| "expected a JSON array".to_string())? + .iter() + .map(parse_one) + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/crates/aprender-rollup/src/gh_json/tests.rs b/crates/aprender-rollup/src/gh_json/tests.rs new file mode 100644 index 0000000000..4259ca9e64 --- /dev/null +++ b/crates/aprender-rollup/src/gh_json/tests.rs @@ -0,0 +1,57 @@ +use super::*; + +const OK: &str = r#"[{"number":7,"additions":20,"deletions":3, + "labels":[{"name":"rollup-ok"},{"name":"x"}], + "files":[{"path":"a.rs"},{"path":"b.rs"}], + "reviewDecision":"APPROVED", + "statusCheckRollup":[{"conclusion":"SUCCESS"},{"conclusion":"SKIPPED"},{"state":"SUCCESS"}]}]"#; + +#[test] +fn parses_a_green_approved_pr() { + let c = &parse_prs(OK).expect("parse")[0]; + assert_eq!((c.number, c.additions, c.deletions), (7, 20, 3)); + assert!(c.green && c.approved); + assert_eq!(c.labels, ["rollup-ok", "x"]); + assert_eq!(c.files, ["a.rs", "b.rs"]); +} + +#[test] +fn one_failed_or_pending_check_is_not_green() { + for bad in [ + r#"{"conclusion":"FAILURE"}"#, + r#"{"status":"IN_PROGRESS"}"#, + r#"{"state":"PENDING"}"#, + ] { + let j = OK.replace(r#"{"conclusion":"SKIPPED"}"#, bad); + assert!(!parse_prs(&j).expect("parse")[0].green, "{bad}"); + } +} + +#[test] +fn no_checks_is_not_green() { + let j = OK.replace(r#""statusCheckRollup":[{"conclusion":"SUCCESS"},{"conclusion":"SKIPPED"},{"state":"SUCCESS"}]"#, r#""statusCheckRollup":[]"#); + assert!(!parse_prs(&j).expect("parse")[0].green); +} + +#[test] +fn review_decision_other_than_approved_is_not_approved() { + for d in ["CHANGES_REQUESTED", "REVIEW_REQUIRED", ""] { + let j = OK.replace("APPROVED", d); + assert!(!parse_prs(&j).expect("parse")[0].approved, "{d}"); + } +} + +#[test] +fn missing_line_counts_fail_closed_as_too_large() { + let j = r#"[{"number":1,"labels":[],"files":[],"statusCheckRollup":[]}]"#; + let c = &parse_prs(j).expect("parse")[0]; + assert_eq!(c.additions, u32::MAX); +} + +#[test] +fn malformed_input_is_an_error_not_an_empty_list() { + assert!(parse_prs("not json").is_err()); + assert!(parse_prs("{}").is_err()); + assert!(parse_prs(r#"[{"additions":1}]"#).is_err()); + assert_eq!(parse_prs("[]"), Ok(vec![])); +} diff --git a/crates/aprender-rollup/src/lib.rs b/crates/aprender-rollup/src/lib.rs index 5c1cd632e0..895e703ef6 100644 --- a/crates/aprender-rollup/src/lib.rs +++ b/crates/aprender-rollup/src/lib.rs @@ -4,6 +4,8 @@ //! merge-group run [`split`] halves it. The bot closes a constituent only after the //! rollup commit is on main, with the SHA posted; never on red (see [`Outcome`]). +pub mod gh_json; + /// Largest PR (additions + deletions, from `gh`) that may join a rollup. pub const MAX_LINES: u32 = 200; /// Most constituents in one rollup. From c7ad9af6deddd0b9f0832be35e79240468f167c2 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 19 Oct 2026 09:17:55 +0200 Subject: [PATCH 04/17] feat(rollup): plan subcommand and report text, binary-debt row (Refs #5737) Agent: aprender-w5404 Co-Authored-By: Claude Sonnet 5.5 --- contracts/binary-debt-v1.yaml | 1 + crates/aprender-rollup/src/lib.rs | 1 + crates/aprender-rollup/src/main.rs | 48 ++++++++++++++++++++++ crates/aprender-rollup/src/report.rs | 28 +++++++++++++ crates/aprender-rollup/src/report/tests.rs | 37 +++++++++++++++++ 5 files changed, 115 insertions(+) create mode 100644 crates/aprender-rollup/src/main.rs create mode 100644 crates/aprender-rollup/src/report.rs create mode 100644 crates/aprender-rollup/src/report/tests.rs diff --git a/contracts/binary-debt-v1.yaml b/contracts/binary-debt-v1.yaml index 20d798ae7a..543912118d 100644 --- a/contracts/binary-debt-v1.yaml +++ b/contracts/binary-debt-v1.yaml @@ -111,6 +111,7 @@ binaries: - {crate: aprender, bin: apr, class: KEEP, reason: "the one entry point", target: null} - {crate: aprender-compute-xtask, bin: aprender-compute-xtask, class: KEEP, reason: "build tooling, never installed", target: null} - {crate: aprender-ci-tools, bin: aprender-ci-tools, class: KEEP, reason: "CI tooling ported from scripts/**/*.py (C301), never installed", target: null} + - {crate: aprender-rollup, bin: aprender-rollup, class: KEEP, reason: "rollup bot plan/build tooling (T-rollup, #5737), never installed", target: null} - {crate: aprender-contracts-cli, bin: pv, class: KEEP, reason: "contract CLI, dogfooded by every gate", target: null} - {crate: aprender-contracts-cli, bin: pv-sat, class: KEEP, reason: "ONT-5 SHACL reasoner (added after the census); gates call it as its own ELF. Folding it into `pv sat` is a cop ruling, and would count as debt", target: null} - {crate: aprender-zram-generator, bin: aprender-zram-generator, class: KEEP, reason: "systemd generator, must be a standalone ELF", target: null} diff --git a/crates/aprender-rollup/src/lib.rs b/crates/aprender-rollup/src/lib.rs index 895e703ef6..dc6356edd4 100644 --- a/crates/aprender-rollup/src/lib.rs +++ b/crates/aprender-rollup/src/lib.rs @@ -5,6 +5,7 @@ //! rollup commit is on main, with the SHA posted; never on red (see [`Outcome`]). pub mod gh_json; +pub mod report; /// Largest PR (additions + deletions, from `gh`) that may join a rollup. pub const MAX_LINES: u32 = 200; diff --git a/crates/aprender-rollup/src/main.rs b/crates/aprender-rollup/src/main.rs new file mode 100644 index 0000000000..3a0226bcd2 --- /dev/null +++ b/crates/aprender-rollup/src/main.rs @@ -0,0 +1,48 @@ +//! `aprender-rollup plan`: read open PRs through `gh` (GraphQL) and print the rollup it would build. +//! Read-only; building, merging and closing are later steps. + +use aprender_rollup::{gh_json, report, select}; +use std::process::{Command, ExitCode}; + +fn main() -> ExitCode { + if std::env::args().nth(1).as_deref() != Some("plan") { + eprintln!("usage: aprender-rollup plan"); + return ExitCode::from(2); + } + let out = Command::new("gh") + .args([ + "pr", + "list", + "--state", + "open", + "--limit", + "100", + "--json", + gh_json::GH_FIELDS, + ]) + .output(); + let text = match out { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(), + Ok(o) => { + eprintln!( + "not_measured: gh failed: {}", + String::from_utf8_lossy(&o.stderr).trim() + ); + return ExitCode::FAILURE; + } + Err(e) => { + eprintln!("not_measured: cannot run gh: {e}"); + return ExitCode::FAILURE; + } + }; + match gh_json::parse_prs(&text) { + Ok(c) => { + print!("{}", report::plan_text(&select(&c))); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("not_measured: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/aprender-rollup/src/report.rs b/crates/aprender-rollup/src/report.rs new file mode 100644 index 0000000000..0c589e58ae --- /dev/null +++ b/crates/aprender-rollup/src/report.rs @@ -0,0 +1,28 @@ +//! Human/machine text for a rollup plan (what `rollup-bot plan` prints). + +use crate::{Reject, Selection}; + +fn reason(r: &Reject) -> String { + match r { + Reject::NotGreen => "checks not all green".to_string(), + Reject::NotApproved => "not approved".to_string(), + Reject::NoOptInLabel => format!("missing label {}", crate::OPT_IN_LABEL), + Reject::TooLarge(n) => format!("{n} lines > {}", crate::MAX_LINES), + Reject::ForbiddenFile(f) => format!("touches {f} (CI, contract or lockfile)"), + Reject::FileOverlap { with, file } => format!("shares {file} with #{with}"), + } +} + +/// One line for the rollup, then one `skip` line per rejected PR. +#[must_use] +pub fn plan_text(sel: &Selection) -> String { + let picked: Vec = sel.picked.iter().map(|n| format!("#{n}")).collect(); + let mut out = format!("rollup {} [{}]\n", sel.picked.len(), picked.join(" ")); + for (n, r) in &sel.rejected { + out.push_str(&format!("skip #{n}: {}\n", reason(r))); + } + out +} + +#[cfg(test)] +mod tests; diff --git a/crates/aprender-rollup/src/report/tests.rs b/crates/aprender-rollup/src/report/tests.rs new file mode 100644 index 0000000000..d7835cb179 --- /dev/null +++ b/crates/aprender-rollup/src/report/tests.rs @@ -0,0 +1,37 @@ +use super::*; + +#[test] +fn empty_plan_is_one_line() { + assert_eq!(plan_text(&Selection::default()), "rollup 0 []\n"); +} + +#[test] +fn lists_picked_then_each_skip_with_its_reason() { + let sel = Selection { + picked: vec![1, 3], + rejected: vec![ + ( + 2, + Reject::FileOverlap { + with: 1, + file: "a.rs".to_string(), + }, + ), + (4, Reject::TooLarge(201)), + (5, Reject::NotGreen), + (6, Reject::NotApproved), + (7, Reject::NoOptInLabel), + (8, Reject::ForbiddenFile("Cargo.lock".to_string())), + ], + }; + assert_eq!( + plan_text(&sel), + "rollup 2 [#1 #3]\n\ + skip #2: shares a.rs with #1\n\ + skip #4: 201 lines > 200\n\ + skip #5: checks not all green\n\ + skip #6: not approved\n\ + skip #7: missing label rollup-ok\n\ + skip #8: touches Cargo.lock (CI, contract or lockfile)\n" + ); +} From 52ac446116b7e54ec673ed8ba8345c4606a3b731 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 19 Oct 2026 09:58:03 +0200 Subject: [PATCH 05/17] feat(rollup): branch builder steps and build subcommand, ejects conflicting PRs (Refs #5737) Agent: aprender-w5404 Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-rollup/src/build.rs | 50 ++++++++++++ crates/aprender-rollup/src/build/tests.rs | 27 +++++++ crates/aprender-rollup/src/lib.rs | 1 + crates/aprender-rollup/src/main.rs | 97 ++++++++++++++++------- 4 files changed, 147 insertions(+), 28 deletions(-) create mode 100644 crates/aprender-rollup/src/build.rs create mode 100644 crates/aprender-rollup/src/build/tests.rs diff --git a/crates/aprender-rollup/src/build.rs b/crates/aprender-rollup/src/build.rs new file mode 100644 index 0000000000..4c675cbe7a --- /dev/null +++ b/crates/aprender-rollup/src/build.rs @@ -0,0 +1,50 @@ +//! Git steps that assemble a rollup branch from picked PRs (pure; `main.rs` runs them). + +/// Branch name for a rollup of `picked` (lowest PR number first, so it is stable). +#[must_use] +pub fn branch_name(picked: &[u64]) -> String { + let first = picked.iter().min().copied().unwrap_or(0); + format!("rollup/{first}-{}", picked.len()) +} + +/// Commit message: constituents are named so the bot can close them after the SHA lands. +#[must_use] +pub fn message(picked: &[u64]) -> String { + let list: Vec = picked.iter().map(|n| format!("#{n}")).collect(); + format!("Rollup of {} PRs: {}", picked.len(), list.join(" ")) +} + +/// Ordered git argument lists: branch from `base`, then fetch and merge each PR head. +#[must_use] +pub fn steps(base: &str, picked: &[u64]) -> Vec> { + let v = |a: &[&str]| a.iter().map(|s| (*s).to_string()).collect::>(); + let mut out = vec![ + v(&["fetch", "origin", base]), + v(&[ + "checkout", + "-B", + &branch_name(picked), + &format!("origin/{base}"), + ]), + ]; + for n in picked { + out.push(v(&["fetch", "origin", &format!("pull/{n}/head")])); + out.push(v(&[ + "merge", + "--no-ff", + "-m", + &format!("Merge #{n}"), + "FETCH_HEAD", + ])); + } + out +} + +/// Recovery step after a merge conflict: the PR is ejected and stays open. +#[must_use] +pub fn abort_step() -> Vec { + vec!["merge".to_string(), "--abort".to_string()] +} + +#[cfg(test)] +mod tests; diff --git a/crates/aprender-rollup/src/build/tests.rs b/crates/aprender-rollup/src/build/tests.rs new file mode 100644 index 0000000000..173d80970e --- /dev/null +++ b/crates/aprender-rollup/src/build/tests.rs @@ -0,0 +1,27 @@ +use super::*; + +#[test] +fn branch_name_uses_lowest_number_and_count() { + assert_eq!(branch_name(&[7, 3, 9]), "rollup/3-3"); +} + +#[test] +fn message_names_every_constituent() { + assert_eq!(message(&[3, 7]), "Rollup of 2 PRs: #3 #7"); +} + +#[test] +fn steps_branch_from_base_then_fetch_and_merge_each() { + let s = steps("main", &[3, 7]); + assert_eq!(s[0], ["fetch", "origin", "main"]); + assert_eq!(s[1], ["checkout", "-B", "rollup/3-2", "origin/main"]); + assert_eq!(s[2], ["fetch", "origin", "pull/3/head"]); + assert_eq!(s[3], ["merge", "--no-ff", "-m", "Merge #3", "FETCH_HEAD"]); + assert_eq!(s[4][2], "pull/7/head"); + assert_eq!(s.len(), 6); +} + +#[test] +fn abort_is_merge_abort() { + assert_eq!(abort_step(), ["merge", "--abort"]); +} diff --git a/crates/aprender-rollup/src/lib.rs b/crates/aprender-rollup/src/lib.rs index dc6356edd4..4ed0b01b03 100644 --- a/crates/aprender-rollup/src/lib.rs +++ b/crates/aprender-rollup/src/lib.rs @@ -4,6 +4,7 @@ //! merge-group run [`split`] halves it. The bot closes a constituent only after the //! rollup commit is on main, with the SHA posted; never on red (see [`Outcome`]). +pub mod build; pub mod gh_json; pub mod report; diff --git a/crates/aprender-rollup/src/main.rs b/crates/aprender-rollup/src/main.rs index 3a0226bcd2..06ce946bec 100644 --- a/crates/aprender-rollup/src/main.rs +++ b/crates/aprender-rollup/src/main.rs @@ -1,15 +1,11 @@ -//! `aprender-rollup plan`: read open PRs through `gh` (GraphQL) and print the rollup it would build. -//! Read-only; building, merging and closing are later steps. +//! `aprender-rollup plan` prints the rollup it would build (read-only). +//! `aprender-rollup build ` assembles it locally in worktree ``; it never pushes or closes. -use aprender_rollup::{gh_json, report, select}; +use aprender_rollup::{build, gh_json, report, select}; use std::process::{Command, ExitCode}; -fn main() -> ExitCode { - if std::env::args().nth(1).as_deref() != Some("plan") { - eprintln!("usage: aprender-rollup plan"); - return ExitCode::from(2); - } - let out = Command::new("gh") +fn candidates() -> Result, String> { + let o = Command::new("gh") .args([ "pr", "list", @@ -20,26 +16,71 @@ fn main() -> ExitCode { "--json", gh_json::GH_FIELDS, ]) - .output(); - let text = match out { - Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).into_owned(), - Ok(o) => { - eprintln!( - "not_measured: gh failed: {}", - String::from_utf8_lossy(&o.stderr).trim() - ); - return ExitCode::FAILURE; - } - Err(e) => { - eprintln!("not_measured: cannot run gh: {e}"); - return ExitCode::FAILURE; - } - }; - match gh_json::parse_prs(&text) { - Ok(c) => { - print!("{}", report::plan_text(&select(&c))); - ExitCode::SUCCESS + .output() + .map_err(|e| format!("cannot run gh: {e}"))?; + if !o.status.success() { + return Err(format!( + "gh failed: {}", + String::from_utf8_lossy(&o.stderr).trim() + )); + } + gh_json::parse_prs(&String::from_utf8_lossy(&o.stdout)) +} + +fn git(dir: &str, args: &[String]) -> bool { + Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .status() + .is_ok_and(|s| s.success()) +} + +/// Merge each picked PR; a conflicting PR is aborted and ejected (stays open). +fn assemble(dir: &str, picked: &[u64]) -> Vec { + let mut merged = Vec::new(); + for n in picked { + let one = build::steps("main", &[*n]); + // steps 2..4 are this PR's fetch + merge; branch setup already ran for the full set + if git(dir, &one[2]) && git(dir, &one[3]) { + merged.push(*n); + } else { + let _ = git(dir, &build::abort_step()); + eprintln!("eject #{n}: conflicts with the rollup so far"); } + } + merged +} + +fn run(cmd: &str, dir: Option<&str>) -> Result<(), String> { + let sel = select(&candidates()?); + if cmd == "plan" { + print!("{}", report::plan_text(&sel)); + return Ok(()); + } + let dir = dir.ok_or("usage: aprender-rollup build ")?; + let setup = build::steps("main", &sel.picked); + if !git(dir, &setup[0]) || !git(dir, &setup[1]) { + return Err("cannot create rollup branch".to_string()); + } + let merged = assemble(dir, &sel.picked); + println!( + "{}\n{}", + build::branch_name(&sel.picked), + build::message(&merged) + ); + Ok(()) +} + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + let cmd = args.get(1).map(String::as_str).unwrap_or(""); + if cmd != "plan" && cmd != "build" { + eprintln!("usage: aprender-rollup plan | build "); + return ExitCode::from(2); + } + match run(cmd, args.get(2).map(String::as_str)) { + Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("not_measured: {e}"); ExitCode::FAILURE From b2ec68cab82e071e134f9e66bb85922158a60f9d Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 19 Oct 2026 10:14:16 +0200 Subject: [PATCH 06/17] feat(rollup): gh args to close with SHA or keep open with reason (Refs #5737) Agent: aprender-w5404 Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-rollup/src/close.rs | 29 +++++++++++++++++++++++ crates/aprender-rollup/src/close/tests.rs | 26 ++++++++++++++++++++ crates/aprender-rollup/src/lib.rs | 1 + 3 files changed, 56 insertions(+) create mode 100644 crates/aprender-rollup/src/close.rs create mode 100644 crates/aprender-rollup/src/close/tests.rs diff --git a/crates/aprender-rollup/src/close.rs b/crates/aprender-rollup/src/close.rs new file mode 100644 index 0000000000..8f16429d8e --- /dev/null +++ b/crates/aprender-rollup/src/close.rs @@ -0,0 +1,29 @@ +//! `gh` argument lists for the only two things the bot does to a constituent PR. + +use crate::Outcome; + +/// Comment-and-close args for a constituent, or a reasoned comment if it stays open. +/// Closing needs a non-empty SHA on main; `Outcome` already guarantees that. +#[must_use] +pub fn gh_args(pr: u64, outcome: &Outcome) -> Vec { + let n = pr.to_string(); + match outcome { + Outcome::Close { sha } => { + let body = format!("Landed in rollup commit {sha} on main."); + ["pr", "close", &n, "--comment", &body] + .iter() + .map(|s| (*s).to_string()) + .collect() + } + Outcome::KeepOpen { reason } => { + let body = format!("Ejected from rollup, left open: {reason}"); + ["pr", "comment", &n, "--body", &body] + .iter() + .map(|s| (*s).to_string()) + .collect() + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/aprender-rollup/src/close/tests.rs b/crates/aprender-rollup/src/close/tests.rs new file mode 100644 index 0000000000..64c39aa6ec --- /dev/null +++ b/crates/aprender-rollup/src/close/tests.rs @@ -0,0 +1,26 @@ +use super::*; + +#[test] +fn close_posts_the_sha() { + let a = gh_args( + 12, + &Outcome::Close { + sha: "abc123".to_string(), + }, + ); + assert_eq!(a[..3], ["pr", "close", "12"]); + assert!(a.last().is_some_and(|b| b.contains("abc123"))); +} + +#[test] +fn keep_open_never_closes_and_gives_reason() { + let a = gh_args( + 12, + &Outcome::KeepOpen { + reason: "red".to_string(), + }, + ); + assert_eq!(a[..3], ["pr", "comment", "12"]); + assert!(!a.iter().any(|s| s == "close")); + assert!(a.last().is_some_and(|b| b.contains("red"))); +} diff --git a/crates/aprender-rollup/src/lib.rs b/crates/aprender-rollup/src/lib.rs index 4ed0b01b03..13acc43376 100644 --- a/crates/aprender-rollup/src/lib.rs +++ b/crates/aprender-rollup/src/lib.rs @@ -5,6 +5,7 @@ //! rollup commit is on main, with the SHA posted; never on red (see [`Outcome`]). pub mod build; +pub mod close; pub mod gh_json; pub mod report; From 4ff0fe97126c5207a24cd7992bfcd1a0c3c7c8a1 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 19 Oct 2026 12:14:00 +0200 Subject: [PATCH 07/17] feat(rollup): merge-group verdict, halves bisect, close plan (Refs #5737) Agent: aprender-w5018 Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-rollup/src/lib.rs | 1 + crates/aprender-rollup/src/mg.rs | 98 ++++++++++++++++++ crates/aprender-rollup/src/mg/tests.rs | 138 +++++++++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 crates/aprender-rollup/src/mg.rs create mode 100644 crates/aprender-rollup/src/mg/tests.rs diff --git a/crates/aprender-rollup/src/lib.rs b/crates/aprender-rollup/src/lib.rs index 13acc43376..c265fff5ee 100644 --- a/crates/aprender-rollup/src/lib.rs +++ b/crates/aprender-rollup/src/lib.rs @@ -7,6 +7,7 @@ pub mod build; pub mod close; pub mod gh_json; +pub mod mg; pub mod report; /// Largest PR (additions + deletions, from `gh`) that may join a rollup. diff --git a/crates/aprender-rollup/src/mg.rs b/crates/aprender-rollup/src/mg.rs new file mode 100644 index 0000000000..b02d07a2ea --- /dev/null +++ b/crates/aprender-rollup/src/mg.rs @@ -0,0 +1,98 @@ +//! Merge-group result check, bisection on red, and the close plan (T-rollup, #5394). +//! +//! Pure: the caller runs `gh run list --json status,conclusion` and passes the text, and +//! supplies the "test this subset" step to [`bisect`]. A constituent is closed only when +//! its subset went green and the rollup SHA is on main ([`crate::outcome`]). + +use crate::{close, outcome}; +use serde_json::Value; + +/// State of the merge-group runs for one rollup (or one half of it). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + Green, + Red, + Pending, +} + +/// Fold `gh run list --json status,conclusion` into one verdict. Any failed run is red; +/// no runs, or any unfinished run with none failed, is pending (never green). +/// +/// # Errors +/// Returns the parse error text (a failed read is not_measured, L23). +pub fn parse_verdict(json: &str) -> Result { + let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?; + let runs = v.as_array().ok_or("expected a JSON array")?; + let mut all_ok = !runs.is_empty(); + for r in runs { + match (r["status"].as_str(), r["conclusion"].as_str()) { + (Some("completed"), Some("success" | "neutral" | "skipped")) => {} + (Some("completed"), Some(_)) => return Ok(Verdict::Red), + _ => all_ok = false, + } + } + Ok(if all_ok { + Verdict::Green + } else { + Verdict::Pending + }) +} + +/// Result of bisecting a rollup: who may land, who broke it, who is still unknown. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Bisect { + pub good: Vec, + pub culprits: Vec, + pub undecided: Vec, +} + +/// Halve a red set until each culprit is isolated. `test` runs a subset through the merge +/// group. A green subset is good; a red single entry is a culprit; pending stays undecided. +pub fn bisect(picked: &[u64], test: &mut dyn FnMut(&[u64]) -> Verdict) -> Bisect { + let mut out = Bisect::default(); + walk(picked, test, &mut out); + out.good.sort_unstable(); + out.culprits.sort_unstable(); + out.undecided.sort_unstable(); + out +} + +fn walk(set: &[u64], test: &mut dyn FnMut(&[u64]) -> Verdict, out: &mut Bisect) { + if set.is_empty() { + return; + } + match test(set) { + Verdict::Green => out.good.extend_from_slice(set), + Verdict::Pending => out.undecided.extend_from_slice(set), + Verdict::Red => match crate::split(set) { + None => out.culprits.extend_from_slice(set), + Some((a, b)) => { + walk(&a, test, out); + walk(&b, test, out); + } + }, + } +} + +/// One `gh` action per constituent. Good ones close with the SHA, only when it is on main; +/// culprits and undecided ones get a reasoned comment and stay open. +#[must_use] +pub fn close_plan(b: &Bisect, sha_on_main: Option<&str>) -> Vec<(u64, Vec)> { + let mut plan = Vec::new(); + for n in &b.good { + plan.push((*n, close::gh_args(*n, &outcome(true, sha_on_main)))); + } + for n in &b.culprits { + plan.push((*n, close::gh_args(*n, &outcome(false, sha_on_main)))); + } + for n in &b.undecided { + let o = crate::Outcome::KeepOpen { + reason: "rollup run not finished".to_string(), + }; + plan.push((*n, close::gh_args(*n, &o))); + } + plan +} + +#[cfg(test)] +mod tests; diff --git a/crates/aprender-rollup/src/mg/tests.rs b/crates/aprender-rollup/src/mg/tests.rs new file mode 100644 index 0000000000..863bbf6684 --- /dev/null +++ b/crates/aprender-rollup/src/mg/tests.rs @@ -0,0 +1,138 @@ +use super::*; + +fn run(status: &str, conclusion: Option<&str>) -> String { + let c = conclusion.map_or("null".to_string(), |c| format!("\"{c}\"")); + format!("{{\"status\":\"{status}\",\"conclusion\":{c}}}") +} + +#[test] +fn verdict_green_needs_every_run_success() { + let j = format!( + "[{},{}]", + run("completed", Some("success")), + run("completed", Some("success")) + ); + assert_eq!(parse_verdict(&j), Ok(Verdict::Green)); +} + +#[test] +fn verdict_red_if_any_run_failed_even_with_others_pending() { + let j = format!( + "[{},{}]", + run("in_progress", None), + run("completed", Some("failure")) + ); + assert_eq!(parse_verdict(&j), Ok(Verdict::Red)); +} + +#[test] +fn verdict_pending_when_unfinished_and_none_failed() { + let j = format!( + "[{},{}]", + run("completed", Some("success")), + run("queued", None) + ); + assert_eq!(parse_verdict(&j), Ok(Verdict::Pending)); +} + +#[test] +fn no_runs_is_pending_not_green() { + assert_eq!(parse_verdict("[]"), Ok(Verdict::Pending)); +} + +#[test] +fn cancelled_and_timed_out_are_red() { + for k in ["cancelled", "timed_out", "startup_failure"] { + let j = format!("[{}]", run("completed", Some(k))); + assert_eq!(parse_verdict(&j), Ok(Verdict::Red), "{k}"); + } +} + +#[test] +fn malformed_read_is_not_measured() { + assert!(parse_verdict("not json").is_err()); + assert!(parse_verdict("{}").is_err()); +} + +#[test] +fn bisect_all_green_lands_everything() { + let r = bisect(&[1, 2, 3, 4], &mut |_| Verdict::Green); + assert_eq!( + r, + Bisect { + good: vec![1, 2, 3, 4], + culprits: vec![], + undecided: vec![] + } + ); +} + +#[test] +fn bisect_finds_the_single_culprit() { + let r = bisect(&[1, 2, 3, 4, 5], &mut |s| { + if s.contains(&4) { + Verdict::Red + } else { + Verdict::Green + } + }); + assert_eq!(r.culprits, vec![4]); + assert_eq!(r.good, vec![1, 2, 3, 5]); + assert!(r.undecided.is_empty()); +} + +#[test] +fn bisect_single_red_entry_is_a_culprit() { + let r = bisect(&[7], &mut |_| Verdict::Red); + assert_eq!(r.culprits, vec![7]); +} + +#[test] +fn bisect_pending_is_undecided_never_closed() { + let r = bisect(&[1, 2], &mut |_| Verdict::Pending); + assert_eq!(r.undecided, vec![1, 2]); + assert!(r.good.is_empty() && r.culprits.is_empty()); +} + +#[test] +fn bisect_two_culprits_are_both_found() { + let r = bisect(&[1, 2, 3, 4], &mut |s| { + if s.contains(&1) || s.contains(&4) { + Verdict::Red + } else { + Verdict::Green + } + }); + assert_eq!(r.culprits, vec![1, 4]); + assert_eq!(r.good, vec![2, 3]); +} + +#[test] +fn close_plan_closes_good_with_sha_and_comments_culprits() { + let b = Bisect { + good: vec![1, 2], + culprits: vec![3], + undecided: vec![4], + }; + let p = close_plan(&b, Some("abc")); + assert_eq!(p.len(), 4); + assert!(p + .iter() + .any(|(n, a)| *n == 1 && a[1] == "close" && a[4].contains("abc"))); + assert!(p.iter().any(|(n, a)| *n == 3 && a[1] == "comment")); + assert!(p.iter().any(|(n, a)| *n == 4 && a[1] == "comment")); + assert!(!p + .iter() + .any(|(n, a)| (*n == 3 || *n == 4) && a[1] == "close")); +} + +#[test] +fn close_plan_without_sha_on_main_closes_nothing() { + let b = Bisect { + good: vec![1, 2], + culprits: vec![], + undecided: vec![], + }; + let p = close_plan(&b, None); + assert!(p.iter().all(|(_, a)| a[1] == "comment")); +} From 3fb3298be8a590932924f27bc7a93f17ccd3281e Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 19 Oct 2026 12:21:40 +0200 Subject: [PATCH 08/17] feat(rollup): verify command wires merge-group verdict, bisect halves and close (Refs #5737) Agent: aprender-w5018 Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-rollup/src/main.rs | 69 +++++++++++++++++++++++--- crates/aprender-rollup/src/mg.rs | 18 +++++++ crates/aprender-rollup/src/mg/tests.rs | 7 +++ 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/crates/aprender-rollup/src/main.rs b/crates/aprender-rollup/src/main.rs index 06ce946bec..b0bcd0f68d 100644 --- a/crates/aprender-rollup/src/main.rs +++ b/crates/aprender-rollup/src/main.rs @@ -1,7 +1,8 @@ //! `aprender-rollup plan` prints the rollup it would build (read-only). //! `aprender-rollup build ` assembles it locally in worktree ``; it never pushes or closes. -use aprender_rollup::{build, gh_json, report, select}; +use aprender_rollup::mg::{self, Verdict}; +use aprender_rollup::{build, close, gh_json, outcome, report, select}; use std::process::{Command, ExitCode}; fn candidates() -> Result, String> { @@ -72,14 +73,70 @@ fn run(cmd: &str, dir: Option<&str>) -> Result<(), String> { Ok(()) } +fn gh(args: &[String]) -> Result { + let o = Command::new("gh") + .args(args) + .output() + .map_err(|e| format!("cannot run gh: {e}"))?; + if !o.status.success() { + return Err(format!( + "gh failed: {}", + String::from_utf8_lossy(&o.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&o.stdout).into_owned()) +} + +/// `verify [--apply]`: read the merge-group verdict for the +/// rollup branch. Green + SHA on main closes each constituent with the SHA; red prints the +/// two halves to rebuild and test; pending touches nothing. Without `--apply`, dry run. +fn verify(a: &[String]) -> Result<(), String> { + let [branch, sha, list, rest @ ..] = a else { + return Err("usage: aprender-rollup verify [--apply]".into()); + }; + let picked: Vec = list + .split(',') + .map(|s| { + s.trim_start_matches('#') + .parse() + .map_err(|_| format!("bad PR {s}")) + }) + .collect::>()?; + let runs = gh(&mg::run_list_args(branch))?; + let v = mg::parse_verdict(&runs)?; + println!("verdict {v:?}"); + let sha = (sha != "-").then_some(sha.as_str()); + match v { + Verdict::Pending => {} + Verdict::Red => match aprender_rollup::split(&picked) { + Some((x, y)) => println!("bisect: rebuild halves {x:?} and {y:?}"), + None => println!("culprit #{}", picked[0]), + }, + Verdict::Green => { + for n in &picked { + let args = close::gh_args(*n, &outcome(true, sha)); + println!("#{n}: gh {}", args.join(" ")); + if rest.iter().any(|r| r == "--apply") { + gh(&args)?; + } + } + } + } + Ok(()) +} + fn main() -> ExitCode { let args: Vec = std::env::args().collect(); let cmd = args.get(1).map(String::as_str).unwrap_or(""); - if cmd != "plan" && cmd != "build" { - eprintln!("usage: aprender-rollup plan | build "); - return ExitCode::from(2); - } - match run(cmd, args.get(2).map(String::as_str)) { + let res = match cmd { + "plan" | "build" => run(cmd, args.get(2).map(String::as_str)), + "verify" => verify(&args[2..]), + _ => { + eprintln!("usage: aprender-rollup plan | build | verify [--apply]"); + return ExitCode::from(2); + } + }; + match res { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("not_measured: {e}"); diff --git a/crates/aprender-rollup/src/mg.rs b/crates/aprender-rollup/src/mg.rs index b02d07a2ea..a3591cec85 100644 --- a/crates/aprender-rollup/src/mg.rs +++ b/crates/aprender-rollup/src/mg.rs @@ -38,6 +38,24 @@ pub fn parse_verdict(json: &str) -> Result { }) } +/// `gh` args listing the merge-group runs for a rollup branch. +#[must_use] +pub fn run_list_args(branch: &str) -> Vec { + [ + "run", + "list", + "--event", + "merge_group", + "--branch", + branch, + "--json", + "status,conclusion", + ] + .iter() + .map(|s| (*s).to_string()) + .collect() +} + /// Result of bisecting a rollup: who may land, who broke it, who is still unknown. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Bisect { diff --git a/crates/aprender-rollup/src/mg/tests.rs b/crates/aprender-rollup/src/mg/tests.rs index 863bbf6684..3ca605ec4b 100644 --- a/crates/aprender-rollup/src/mg/tests.rs +++ b/crates/aprender-rollup/src/mg/tests.rs @@ -136,3 +136,10 @@ fn close_plan_without_sha_on_main_closes_nothing() { let p = close_plan(&b, None); assert!(p.iter().all(|(_, a)| a[1] == "comment")); } + +#[test] +fn run_list_args_filter_merge_group_on_the_branch() { + let a = run_list_args("rollup/5-3"); + assert!(a.windows(2).any(|w| w == ["--event", "merge_group"])); + assert!(a.windows(2).any(|w| w == ["--branch", "rollup/5-3"])); +} From 3f533834306cd0c30f14b8cbde20ae88d2019f16 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 19 Oct 2026 12:46:56 +0200 Subject: [PATCH 09/17] feat(rollup): cap a rollup at 3 entries until p_red is measured (Refs #5737) Agent: aprender-w5018 Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-rollup/src/lib.rs | 4 ++-- crates/aprender-rollup/src/tests.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/aprender-rollup/src/lib.rs b/crates/aprender-rollup/src/lib.rs index c265fff5ee..31b91956fb 100644 --- a/crates/aprender-rollup/src/lib.rs +++ b/crates/aprender-rollup/src/lib.rs @@ -12,8 +12,8 @@ pub mod report; /// Largest PR (additions + deletions, from `gh`) that may join a rollup. pub const MAX_LINES: u32 = 200; -/// Most constituents in one rollup. -pub const MAX_ENTRIES: usize = 10; +/// Most constituents in one rollup (operator: b = 3, only with bisection, until p_red is measured). +pub const MAX_ENTRIES: usize = 3; /// Label a PR owner sets to opt in. pub const OPT_IN_LABEL: &str = "rollup-ok"; diff --git a/crates/aprender-rollup/src/tests.rs b/crates/aprender-rollup/src/tests.rs index 840e7b99f8..01bb30b57d 100644 --- a/crates/aprender-rollup/src/tests.rs +++ b/crates/aprender-rollup/src/tests.rs @@ -75,10 +75,10 @@ fn select_orders_by_number_and_skips_overlap() { } #[test] -fn select_caps_at_ten_without_rejecting_the_rest() { +fn select_caps_at_three_without_rejecting_the_rest() { let cands: Vec = (1..=12).map(|n| cand(n, &[&format!("f{n}.rs")])).collect(); let sel = select(&cands); - assert_eq!(sel.picked, (1..=10).collect::>()); + assert_eq!(sel.picked, (1..=3).collect::>()); assert!(sel.rejected.is_empty()); } From 2b4c4e5681f21fd4d82511de6bbbab5c326250c1 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 19 Oct 2026 13:14:42 +0200 Subject: [PATCH 10/17] fix(rollup): bind verdict to head SHA, check SHA on main, name over-cap rejects (Refs #5737) Agent: aprender-w5018 Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-rollup/src/lib.rs | 9 ++++- crates/aprender-rollup/src/main.rs | 22 ++++++++--- crates/aprender-rollup/src/mg.rs | 24 +++++++++--- crates/aprender-rollup/src/mg/tests.rs | 45 ++++++++++++++++++---- crates/aprender-rollup/src/report.rs | 1 + crates/aprender-rollup/src/report/tests.rs | 9 +++++ crates/aprender-rollup/src/tests.rs | 5 ++- 7 files changed, 94 insertions(+), 21 deletions(-) diff --git a/crates/aprender-rollup/src/lib.rs b/crates/aprender-rollup/src/lib.rs index 31b91956fb..1ee6db57cf 100644 --- a/crates/aprender-rollup/src/lib.rs +++ b/crates/aprender-rollup/src/lib.rs @@ -37,7 +37,12 @@ pub enum Reject { NoOptInLabel, TooLarge(u32), ForbiddenFile(String), - FileOverlap { with: u64, file: String }, + FileOverlap { + with: u64, + file: String, + }, + /// Eligible, but the rollup already holds [`MAX_ENTRIES`]. + OverCap, } /// Files a rollup never carries: CI workflows, contracts, the lockfile. @@ -99,6 +104,8 @@ pub fn select(cands: &[Candidate]) -> Selection { } else if taken.len() < MAX_ENTRIES { sel.picked.push(c.number); taken.push((c, c.number)); + } else { + sel.rejected.push((c.number, Reject::OverCap)); } } sel diff --git a/crates/aprender-rollup/src/main.rs b/crates/aprender-rollup/src/main.rs index b0bcd0f68d..3bf7ce56a8 100644 --- a/crates/aprender-rollup/src/main.rs +++ b/crates/aprender-rollup/src/main.rs @@ -87,11 +87,11 @@ fn gh(args: &[String]) -> Result { Ok(String::from_utf8_lossy(&o.stdout).into_owned()) } -/// `verify [--apply]`: read the merge-group verdict for the +/// `verify [--apply]`: read the merge-group verdict for the /// rollup branch. Green + SHA on main closes each constituent with the SHA; red prints the /// two halves to rebuild and test; pending touches nothing. Without `--apply`, dry run. fn verify(a: &[String]) -> Result<(), String> { - let [branch, sha, list, rest @ ..] = a else { + let [branch, head, sha, list, rest @ ..] = a else { return Err("usage: aprender-rollup verify [--apply]".into()); }; let picked: Vec = list @@ -103,15 +103,19 @@ fn verify(a: &[String]) -> Result<(), String> { }) .collect::>()?; let runs = gh(&mg::run_list_args(branch))?; - let v = mg::parse_verdict(&runs)?; + let v = mg::parse_verdict(&runs, head)?; println!("verdict {v:?}"); - let sha = (sha != "-").then_some(sha.as_str()); + let on_main = |s: &str| git_ok(&mg::ancestor_args(s)); + let sha = (sha != "-" && on_main(sha)).then_some(sha.as_str()); match v { Verdict::Pending => {} Verdict::Red => match aprender_rollup::split(&picked) { Some((x, y)) => println!("bisect: rebuild halves {x:?} and {y:?}"), None => println!("culprit #{}", picked[0]), }, + Verdict::Green if sha.is_none() => { + println!("green, waiting for the rollup commit on origin/main; nothing posted"); + } Verdict::Green => { for n in &picked { let args = close::gh_args(*n, &outcome(true, sha)); @@ -125,6 +129,14 @@ fn verify(a: &[String]) -> Result<(), String> { Ok(()) } +/// Run `git` in the current checkout; true on exit 0. +fn git_ok(args: &[String]) -> bool { + Command::new("git") + .args(args) + .status() + .is_ok_and(|s| s.success()) +} + fn main() -> ExitCode { let args: Vec = std::env::args().collect(); let cmd = args.get(1).map(String::as_str).unwrap_or(""); @@ -132,7 +144,7 @@ fn main() -> ExitCode { "plan" | "build" => run(cmd, args.get(2).map(String::as_str)), "verify" => verify(&args[2..]), _ => { - eprintln!("usage: aprender-rollup plan | build | verify [--apply]"); + eprintln!("usage: aprender-rollup plan | build | verify [--apply]"); return ExitCode::from(2); } }; diff --git a/crates/aprender-rollup/src/mg.rs b/crates/aprender-rollup/src/mg.rs index a3591cec85..7f5f1d2674 100644 --- a/crates/aprender-rollup/src/mg.rs +++ b/crates/aprender-rollup/src/mg.rs @@ -15,16 +15,21 @@ pub enum Verdict { Pending, } -/// Fold `gh run list --json status,conclusion` into one verdict. Any failed run is red; +/// Fold `gh run list --json status,conclusion,headSha` (only runs of `head` count) into one verdict. Any failed run is red; /// no runs, or any unfinished run with none failed, is pending (never green). /// /// # Errors /// Returns the parse error text (a failed read is not_measured, L23). -pub fn parse_verdict(json: &str) -> Result { +pub fn parse_verdict(json: &str, head: &str) -> Result { let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?; - let runs = v.as_array().ok_or("expected a JSON array")?; + let runs: Vec<&Value> = v + .as_array() + .ok_or("expected a JSON array")? + .iter() + .filter(|r| r["headSha"].as_str() == Some(head)) + .collect(); let mut all_ok = !runs.is_empty(); - for r in runs { + for r in &runs { match (r["status"].as_str(), r["conclusion"].as_str()) { (Some("completed"), Some("success" | "neutral" | "skipped")) => {} (Some("completed"), Some(_)) => return Ok(Verdict::Red), @@ -49,13 +54,22 @@ pub fn run_list_args(branch: &str) -> Vec { "--branch", branch, "--json", - "status,conclusion", + "status,conclusion,headSha", ] .iter() .map(|s| (*s).to_string()) .collect() } +/// `git` args: is `sha` an ancestor of `origin/main` (i.e. on main)? +#[must_use] +pub fn ancestor_args(sha: &str) -> Vec { + ["merge-base", "--is-ancestor", sha, "origin/main"] + .iter() + .map(|s| (*s).to_string()) + .collect() +} + /// Result of bisecting a rollup: who may land, who broke it, who is still unknown. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Bisect { diff --git a/crates/aprender-rollup/src/mg/tests.rs b/crates/aprender-rollup/src/mg/tests.rs index 3ca605ec4b..40105a4620 100644 --- a/crates/aprender-rollup/src/mg/tests.rs +++ b/crates/aprender-rollup/src/mg/tests.rs @@ -2,7 +2,7 @@ use super::*; fn run(status: &str, conclusion: Option<&str>) -> String { let c = conclusion.map_or("null".to_string(), |c| format!("\"{c}\"")); - format!("{{\"status\":\"{status}\",\"conclusion\":{c}}}") + format!("{{\"headSha\":\"H\",\"status\":\"{status}\",\"conclusion\":{c}}}") } #[test] @@ -12,7 +12,7 @@ fn verdict_green_needs_every_run_success() { run("completed", Some("success")), run("completed", Some("success")) ); - assert_eq!(parse_verdict(&j), Ok(Verdict::Green)); + assert_eq!(parse_verdict(&j, "H"), Ok(Verdict::Green)); } #[test] @@ -22,7 +22,7 @@ fn verdict_red_if_any_run_failed_even_with_others_pending() { run("in_progress", None), run("completed", Some("failure")) ); - assert_eq!(parse_verdict(&j), Ok(Verdict::Red)); + assert_eq!(parse_verdict(&j, "H"), Ok(Verdict::Red)); } #[test] @@ -32,26 +32,26 @@ fn verdict_pending_when_unfinished_and_none_failed() { run("completed", Some("success")), run("queued", None) ); - assert_eq!(parse_verdict(&j), Ok(Verdict::Pending)); + assert_eq!(parse_verdict(&j, "H"), Ok(Verdict::Pending)); } #[test] fn no_runs_is_pending_not_green() { - assert_eq!(parse_verdict("[]"), Ok(Verdict::Pending)); + assert_eq!(parse_verdict("[]", "H"), Ok(Verdict::Pending)); } #[test] fn cancelled_and_timed_out_are_red() { for k in ["cancelled", "timed_out", "startup_failure"] { let j = format!("[{}]", run("completed", Some(k))); - assert_eq!(parse_verdict(&j), Ok(Verdict::Red), "{k}"); + assert_eq!(parse_verdict(&j, "H"), Ok(Verdict::Red), "{k}"); } } #[test] fn malformed_read_is_not_measured() { - assert!(parse_verdict("not json").is_err()); - assert!(parse_verdict("{}").is_err()); + assert!(parse_verdict("not json", "H").is_err()); + assert!(parse_verdict("{}", "H").is_err()); } #[test] @@ -143,3 +143,32 @@ fn run_list_args_filter_merge_group_on_the_branch() { assert!(a.windows(2).any(|w| w == ["--event", "merge_group"])); assert!(a.windows(2).any(|w| w == ["--branch", "rollup/5-3"])); } + +#[test] +fn runs_of_another_head_are_ignored() { + let stale_red = "{\"headSha\":\"OLD\",\"status\":\"completed\",\"conclusion\":\"failure\"}"; + let stale_green = "{\"headSha\":\"OLD\",\"status\":\"completed\",\"conclusion\":\"success\"}"; + assert_eq!( + parse_verdict(&format!("[{stale_red}]"), "H"), + Ok(Verdict::Pending) + ); + assert_eq!( + parse_verdict(&format!("[{stale_green}]"), "H"), + Ok(Verdict::Pending) + ); + let j = format!("[{stale_red},{}]", run("completed", Some("success"))); + assert_eq!(parse_verdict(&j, "H"), Ok(Verdict::Green)); +} + +#[test] +fn run_list_args_ask_for_head_sha() { + assert!(run_list_args("b").iter().any(|a| a.contains("headSha"))); +} + +#[test] +fn ancestor_args_check_against_origin_main() { + assert_eq!( + ancestor_args("abc"), + ["merge-base", "--is-ancestor", "abc", "origin/main"] + ); +} diff --git a/crates/aprender-rollup/src/report.rs b/crates/aprender-rollup/src/report.rs index 0c589e58ae..21b4565da7 100644 --- a/crates/aprender-rollup/src/report.rs +++ b/crates/aprender-rollup/src/report.rs @@ -10,6 +10,7 @@ fn reason(r: &Reject) -> String { Reject::TooLarge(n) => format!("{n} lines > {}", crate::MAX_LINES), Reject::ForbiddenFile(f) => format!("touches {f} (CI, contract or lockfile)"), Reject::FileOverlap { with, file } => format!("shares {file} with #{with}"), + Reject::OverCap => format!("rollup already holds {}", crate::MAX_ENTRIES), } } diff --git a/crates/aprender-rollup/src/report/tests.rs b/crates/aprender-rollup/src/report/tests.rs index d7835cb179..b337fe2cd2 100644 --- a/crates/aprender-rollup/src/report/tests.rs +++ b/crates/aprender-rollup/src/report/tests.rs @@ -35,3 +35,12 @@ fn lists_picked_then_each_skip_with_its_reason() { skip #8: touches Cargo.lock (CI, contract or lockfile)\n" ); } + +#[test] +fn over_cap_is_named_in_the_plan() { + let sel = Selection { + picked: vec![1], + rejected: vec![(9, Reject::OverCap)], + }; + assert!(plan_text(&sel).contains("skip #9: rollup already holds")); +} diff --git a/crates/aprender-rollup/src/tests.rs b/crates/aprender-rollup/src/tests.rs index 01bb30b57d..8cb6ebc92c 100644 --- a/crates/aprender-rollup/src/tests.rs +++ b/crates/aprender-rollup/src/tests.rs @@ -75,11 +75,12 @@ fn select_orders_by_number_and_skips_overlap() { } #[test] -fn select_caps_at_three_without_rejecting_the_rest() { +fn select_caps_at_three_and_names_the_rest() { let cands: Vec = (1..=12).map(|n| cand(n, &[&format!("f{n}.rs")])).collect(); let sel = select(&cands); assert_eq!(sel.picked, (1..=3).collect::>()); - assert!(sel.rejected.is_empty()); + assert_eq!(sel.rejected.len(), 9); + assert!(sel.rejected.iter().all(|(_, r)| *r == Reject::OverCap)); } #[test] From 8e1b7f8a5a1e8dd4a8f1b953d1730313113403b5 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 19 Oct 2026 13:16:48 +0200 Subject: [PATCH 11/17] feat(rollup): bind build to judged head, all-skipped checks are not green (Refs #5737) Agent: aprender-w5018 Co-Authored-By: Claude Sonnet 5.5 --- crates/aprender-rollup/src/build.rs | 6 ++++++ crates/aprender-rollup/src/build/tests.rs | 5 +++++ crates/aprender-rollup/src/gh_json.rs | 10 +++++++-- crates/aprender-rollup/src/gh_json/tests.rs | 10 ++++++++- crates/aprender-rollup/src/lib.rs | 5 +++++ crates/aprender-rollup/src/main.rs | 24 +++++++++++++++++---- crates/aprender-rollup/src/mg.rs | 10 +++++---- crates/aprender-rollup/src/mg/tests.rs | 12 +++++++++++ crates/aprender-rollup/src/report/tests.rs | 2 ++ crates/aprender-rollup/src/tests.rs | 9 ++++++++ 10 files changed, 82 insertions(+), 11 deletions(-) diff --git a/crates/aprender-rollup/src/build.rs b/crates/aprender-rollup/src/build.rs index 4c675cbe7a..4975458d89 100644 --- a/crates/aprender-rollup/src/build.rs +++ b/crates/aprender-rollup/src/build.rs @@ -40,6 +40,12 @@ pub fn steps(base: &str, picked: &[u64]) -> Vec> { out } +/// Print the commit the last `fetch` brought in, to compare with the head judged at selection. +#[must_use] +pub fn head_step() -> Vec { + vec!["rev-parse".to_string(), "FETCH_HEAD".to_string()] +} + /// Recovery step after a merge conflict: the PR is ejected and stays open. #[must_use] pub fn abort_step() -> Vec { diff --git a/crates/aprender-rollup/src/build/tests.rs b/crates/aprender-rollup/src/build/tests.rs index 173d80970e..647f094d66 100644 --- a/crates/aprender-rollup/src/build/tests.rs +++ b/crates/aprender-rollup/src/build/tests.rs @@ -25,3 +25,8 @@ fn steps_branch_from_base_then_fetch_and_merge_each() { fn abort_is_merge_abort() { assert_eq!(abort_step(), ["merge", "--abort"]); } + +#[test] +fn head_check_step_reads_fetch_head() { + assert_eq!(head_step(), ["rev-parse", "FETCH_HEAD"]); +} diff --git a/crates/aprender-rollup/src/gh_json.rs b/crates/aprender-rollup/src/gh_json.rs index dfc440c718..85d325c1bb 100644 --- a/crates/aprender-rollup/src/gh_json.rs +++ b/crates/aprender-rollup/src/gh_json.rs @@ -6,7 +6,7 @@ use serde_json::Value; /// The `--json` field list the parser expects. pub const GH_FIELDS: &str = - "number,additions,deletions,labels,files,reviewDecision,statusCheckRollup"; + "number,additions,deletions,labels,files,reviewDecision,statusCheckRollup,headRefOid"; fn check_ok(c: &Value) -> bool { // CheckRun: status COMPLETED + conclusion; StatusContext: state. @@ -17,6 +17,11 @@ fn check_ok(c: &Value) -> bool { } } +/// A real pass: all-skipped/neutral does not count as green. +fn is_success(c: &Value) -> bool { + c["conclusion"].as_str() == Some("SUCCESS") || c["state"].as_str() == Some("SUCCESS") +} + fn parse_one(v: &Value) -> Result { let number = v["number"].as_u64().ok_or("missing number")?; let n = |k: &str| u32::try_from(v[k].as_u64().unwrap_or(u64::MAX)).unwrap_or(u32::MAX); @@ -34,10 +39,11 @@ fn parse_one(v: &Value) -> Result { number, additions: n("additions"), deletions: n("deletions"), - green: checks.is_some_and(|c| !c.is_empty() && c.iter().all(check_ok)), + green: checks.is_some_and(|c| c.iter().all(check_ok) && c.iter().any(is_success)), approved: v["reviewDecision"].as_str() == Some("APPROVED"), labels: strs("labels", "name"), files: strs("files", "path"), + head: v["headRefOid"].as_str().unwrap_or_default().to_string(), }) } diff --git a/crates/aprender-rollup/src/gh_json/tests.rs b/crates/aprender-rollup/src/gh_json/tests.rs index 4259ca9e64..2f4efc8f2b 100644 --- a/crates/aprender-rollup/src/gh_json/tests.rs +++ b/crates/aprender-rollup/src/gh_json/tests.rs @@ -1,7 +1,7 @@ use super::*; const OK: &str = r#"[{"number":7,"additions":20,"deletions":3, - "labels":[{"name":"rollup-ok"},{"name":"x"}], + "headRefOid":"abc123","labels":[{"name":"rollup-ok"},{"name":"x"}], "files":[{"path":"a.rs"},{"path":"b.rs"}], "reviewDecision":"APPROVED", "statusCheckRollup":[{"conclusion":"SUCCESS"},{"conclusion":"SKIPPED"},{"state":"SUCCESS"}]}]"#; @@ -13,6 +13,7 @@ fn parses_a_green_approved_pr() { assert!(c.green && c.approved); assert_eq!(c.labels, ["rollup-ok", "x"]); assert_eq!(c.files, ["a.rs", "b.rs"]); + assert_eq!(c.head, "abc123"); } #[test] @@ -55,3 +56,10 @@ fn malformed_input_is_an_error_not_an_empty_list() { assert!(parse_prs(r#"[{"additions":1}]"#).is_err()); assert_eq!(parse_prs("[]"), Ok(vec![])); } + +#[test] +fn only_skipped_checks_are_not_green() { + let j = r#"[{"number":1,"additions":1,"deletions":1,"labels":[],"files":[],"headRefOid":"h", + "reviewDecision":"APPROVED","statusCheckRollup":[{"conclusion":"SKIPPED"},{"conclusion":"NEUTRAL"}]}]"#; + assert!(!parse_prs(j).expect("parse")[0].green); +} diff --git a/crates/aprender-rollup/src/lib.rs b/crates/aprender-rollup/src/lib.rs index 1ee6db57cf..be82e4a04e 100644 --- a/crates/aprender-rollup/src/lib.rs +++ b/crates/aprender-rollup/src/lib.rs @@ -27,6 +27,8 @@ pub struct Candidate { pub approved: bool, pub labels: Vec, pub files: Vec, + /// Head commit the checks and approval were read at. + pub head: String, } /// Why a PR stays out of a rollup; posted as the ejection reason. @@ -77,6 +79,8 @@ pub fn check(c: &Candidate) -> Option { #[derive(Debug, Default, PartialEq, Eq)] pub struct Selection { pub picked: Vec, + /// Head SHA of each pick (parallel to `picked`), re-checked when the rollup is built. + pub heads: Vec, pub rejected: Vec<(u64, Reject)>, } @@ -103,6 +107,7 @@ pub fn select(cands: &[Candidate]) -> Selection { .push((c.number, Reject::FileOverlap { with, file })); } else if taken.len() < MAX_ENTRIES { sel.picked.push(c.number); + sel.heads.push(c.head.clone()); taken.push((c, c.number)); } else { sel.rejected.push((c.number, Reject::OverCap)); diff --git a/crates/aprender-rollup/src/main.rs b/crates/aprender-rollup/src/main.rs index 3bf7ce56a8..4ea93d258e 100644 --- a/crates/aprender-rollup/src/main.rs +++ b/crates/aprender-rollup/src/main.rs @@ -28,6 +28,19 @@ fn candidates() -> Result, String> { gh_json::parse_prs(&String::from_utf8_lossy(&o.stdout)) } +/// Trimmed stdout of a successful `git` run in `dir`. +fn git_out(dir: &str, args: &[String]) -> Option { + let o = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .ok()?; + o.status + .success() + .then(|| String::from_utf8_lossy(&o.stdout).trim().to_string()) +} + fn git(dir: &str, args: &[String]) -> bool { Command::new("git") .arg("-C") @@ -38,12 +51,15 @@ fn git(dir: &str, args: &[String]) -> bool { } /// Merge each picked PR; a conflicting PR is aborted and ejected (stays open). -fn assemble(dir: &str, picked: &[u64]) -> Vec { +fn assemble(dir: &str, picked: &[u64], heads: &[String]) -> Vec { let mut merged = Vec::new(); - for n in picked { + for (n, want) in picked.iter().zip(heads) { let one = build::steps("main", &[*n]); // steps 2..4 are this PR's fetch + merge; branch setup already ran for the full set - if git(dir, &one[2]) && git(dir, &one[3]) { + if !git(dir, &one[2]) || git_out(dir, &build::head_step()).as_deref() != Some(want.as_str()) + { + eprintln!("eject #{n}: head moved since it was judged green and approved"); + } else if git(dir, &one[3]) { merged.push(*n); } else { let _ = git(dir, &build::abort_step()); @@ -64,7 +80,7 @@ fn run(cmd: &str, dir: Option<&str>) -> Result<(), String> { if !git(dir, &setup[0]) || !git(dir, &setup[1]) { return Err("cannot create rollup branch".to_string()); } - let merged = assemble(dir, &sel.picked); + let merged = assemble(dir, &sel.picked, &sel.heads); println!( "{}\n{}", build::branch_name(&sel.picked), diff --git a/crates/aprender-rollup/src/mg.rs b/crates/aprender-rollup/src/mg.rs index 7f5f1d2674..58c3124170 100644 --- a/crates/aprender-rollup/src/mg.rs +++ b/crates/aprender-rollup/src/mg.rs @@ -28,15 +28,17 @@ pub fn parse_verdict(json: &str, head: &str) -> Result { .iter() .filter(|r| r["headSha"].as_str() == Some(head)) .collect(); - let mut all_ok = !runs.is_empty(); + // Green needs every run finished and at least one real success: all-skipped is not a pass. + let (mut unfinished, mut succeeded) = (false, false); for r in &runs { match (r["status"].as_str(), r["conclusion"].as_str()) { - (Some("completed"), Some("success" | "neutral" | "skipped")) => {} + (Some("completed"), Some("success")) => succeeded = true, + (Some("completed"), Some("neutral" | "skipped")) => {} (Some("completed"), Some(_)) => return Ok(Verdict::Red), - _ => all_ok = false, + _ => unfinished = true, } } - Ok(if all_ok { + Ok(if succeeded && !unfinished { Verdict::Green } else { Verdict::Pending diff --git a/crates/aprender-rollup/src/mg/tests.rs b/crates/aprender-rollup/src/mg/tests.rs index 40105a4620..4f7bddec40 100644 --- a/crates/aprender-rollup/src/mg/tests.rs +++ b/crates/aprender-rollup/src/mg/tests.rs @@ -172,3 +172,15 @@ fn ancestor_args_check_against_origin_main() { ["merge-base", "--is-ancestor", "abc", "origin/main"] ); } + +#[test] +fn a_run_with_only_skipped_jobs_is_not_green() { + let j = format!("[{}]", run("completed", Some("skipped"))); + assert_eq!(parse_verdict(&j, "H"), Ok(Verdict::Pending)); + let j = format!( + "[{},{}]", + run("completed", Some("skipped")), + run("completed", Some("success")) + ); + assert_eq!(parse_verdict(&j, "H"), Ok(Verdict::Green)); +} diff --git a/crates/aprender-rollup/src/report/tests.rs b/crates/aprender-rollup/src/report/tests.rs index b337fe2cd2..f53cac75dc 100644 --- a/crates/aprender-rollup/src/report/tests.rs +++ b/crates/aprender-rollup/src/report/tests.rs @@ -8,6 +8,7 @@ fn empty_plan_is_one_line() { #[test] fn lists_picked_then_each_skip_with_its_reason() { let sel = Selection { + heads: vec![], picked: vec![1, 3], rejected: vec![ ( @@ -39,6 +40,7 @@ fn lists_picked_then_each_skip_with_its_reason() { #[test] fn over_cap_is_named_in_the_plan() { let sel = Selection { + heads: vec![], picked: vec![1], rejected: vec![(9, Reject::OverCap)], }; diff --git a/crates/aprender-rollup/src/tests.rs b/crates/aprender-rollup/src/tests.rs index 8cb6ebc92c..4aae208abe 100644 --- a/crates/aprender-rollup/src/tests.rs +++ b/crates/aprender-rollup/src/tests.rs @@ -9,6 +9,7 @@ fn cand(n: u64, files: &[&str]) -> Candidate { approved: true, labels: vec![OPT_IN_LABEL.to_string()], files: files.iter().map(|s| (*s).to_string()).collect(), + head: format!("sha{n}"), } } @@ -114,3 +115,11 @@ fn close_only_green_with_sha_on_main() { assert!(matches!(outcome(true, None), Outcome::KeepOpen { .. })); assert!(matches!(outcome(true, Some("")), Outcome::KeepOpen { .. })); } + +#[test] +fn select_records_the_head_each_pick_was_judged_at() { + let cands = vec![cand(4, &["a.rs"]), cand(2, &["b.rs"])]; + let sel = select(&cands); + assert_eq!(sel.picked, [2, 4]); + assert_eq!(sel.heads, ["sha2", "sha4"]); +} From 7c7704671862a03b93132e7f3919ccac4e70d270 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 19 Oct 2026 20:00:13 +0200 Subject: [PATCH 12/17] fix(ci): regenerate tree_reader_tests.txt (aprender-rollup registered) and shapes.ttl after merging main Co-Authored-By: Claude Sonnet 5.5 --- contracts/shapes.ttl | 68 +++++++++++++++++------------------ scripts/tree_reader_tests.txt | 1 + 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/contracts/shapes.ttl b/contracts/shapes.ttl index bed403e57d..a718856fa0 100644 --- a/contracts/shapes.ttl +++ b/contracts/shapes.ttl @@ -1983,11 +1983,11 @@ ] ; sh:property [ sh:path ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:in ( "8.9" "12.1" ) ; @@ -2067,11 +2067,11 @@ sh:maxCount 1 ; sh:class ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2084,11 +2084,11 @@ sh:targetClass ; sh:property [ sh:path ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2103,11 +2103,11 @@ sh:targetClass ; sh:property [ sh:path ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2194,7 +2194,7 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; sh:property [ sh:path ; @@ -2217,7 +2217,7 @@ ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2239,7 +2239,7 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; sh:property [ sh:path ; @@ -2257,7 +2257,7 @@ sh:path ; sh:minCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; sh:property [ sh:path ; @@ -2269,7 +2269,7 @@ ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2283,7 +2283,7 @@ ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2347,11 +2347,11 @@ sh:path ; sh:minCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2392,11 +2392,11 @@ sh:path ; sh:minCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2448,11 +2448,11 @@ sh:path ; sh:minCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2511,11 +2511,11 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2543,11 +2543,11 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2569,11 +2569,11 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2595,11 +2595,11 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2626,11 +2626,11 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2721,7 +2721,7 @@ sh:minCount 1 ; sh:class ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; sh:property [ sh:path ; @@ -2733,7 +2733,7 @@ ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2768,11 +2768,11 @@ sh:maxCount 1 ; sh:class ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; diff --git a/scripts/tree_reader_tests.txt b/scripts/tree_reader_tests.txt index 5315c8d0da..0a8e611ff2 100644 --- a/scripts/tree_reader_tests.txt +++ b/scripts/tree_reader_tests.txt @@ -195,6 +195,7 @@ aprender-rag-cli --lib aprender-review-experiment --lib build_corpus aprender-review-experiment --lib contamination aprender-review-experiment --lib prereg +aprender-rollup --lib aprender-serve --lib capability aprender-serve --lib cli::cli_tests aprender-serve --lib gguf::inference::matmul From 2a637f00c791b473bb2e6056b9a58e82bd6009c3 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Mon, 19 Oct 2026 22:29:26 +0200 Subject: [PATCH 13/17] fix(contracts): regenerate shapes.ttl and witness with the in-tree pv (ont4b freshness gate) Co-Authored-By: Claude Sonnet 5.5 --- contracts/shapes.ttl | 68 ++++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/contracts/shapes.ttl b/contracts/shapes.ttl index a718856fa0..bed403e57d 100644 --- a/contracts/shapes.ttl +++ b/contracts/shapes.ttl @@ -1983,11 +1983,11 @@ ] ; sh:property [ sh:path ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:in ( "8.9" "12.1" ) ; @@ -2067,11 +2067,11 @@ sh:maxCount 1 ; sh:class ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2084,11 +2084,11 @@ sh:targetClass ; sh:property [ sh:path ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2103,11 +2103,11 @@ sh:targetClass ; sh:property [ sh:path ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2194,7 +2194,7 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; sh:property [ sh:path ; @@ -2217,7 +2217,7 @@ ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2239,7 +2239,7 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; sh:property [ sh:path ; @@ -2257,7 +2257,7 @@ sh:path ; sh:minCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; sh:property [ sh:path ; @@ -2269,7 +2269,7 @@ ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2283,7 +2283,7 @@ ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2347,11 +2347,11 @@ sh:path ; sh:minCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2392,11 +2392,11 @@ sh:path ; sh:minCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2448,11 +2448,11 @@ sh:path ; sh:minCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2511,11 +2511,11 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2543,11 +2543,11 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2569,11 +2569,11 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2595,11 +2595,11 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2626,11 +2626,11 @@ sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2721,7 +2721,7 @@ sh:minCount 1 ; sh:class ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; sh:property [ sh:path ; @@ -2733,7 +2733,7 @@ ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; @@ -2768,11 +2768,11 @@ sh:maxCount 1 ; sh:class ; sh:nodeKind sh:IRI ; - sh:node ; + sh:node ; ] ; . - a sh:NodeShape ; + a sh:NodeShape ; sh:property [ sh:path ; sh:minCount 1 ; From acde1c41ec2a53a4783464a72f12ac097702256c Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 01:36:50 +0200 Subject: [PATCH 14/17] Regenerate roadmap.yaml from entries (make roadmap-aggregate) Refs #5737 Co-Authored-By: Claude Opus 5.5 --- docs/roadmaps/roadmap.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap.yaml index 73533d0fce..3bce667faa 100644 --- a/docs/roadmaps/roadmap.yaml +++ b/docs/roadmaps/roadmap.yaml @@ -25321,3 +25321,22 @@ Not done, pending the ruling: moving the 31 fixture files to an obviously synthe - kind:code notes: null epic: 3998 +- id: PMAT-5737 + github_issue: 5737 + item_type: task + title: 'T-rollup: rollup bot (aprender-rollup crate + gh layer + bin)' + status: planned + priority: high + assigned_to: null + created: 2026-10-19T07:13:22Z + updated: 2026-10-19T07:13:22Z + spec: null + acceptance_criteria: + - 'INTAKE refs #5394; step 1 on feat/rollup-bot @138d94c28f' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 5510 From a9b1e3f67183fc5984de4468b5e75870152dcb38 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 02:45:38 +0200 Subject: [PATCH 15/17] aprender-rollup: README and binary-snapshot row so the binary-debt ledger entry resolves (lint, readme_contract, ont4d) Refs PMAT-5737 Co-Authored-By: Claude Opus 5.5 --- contracts/contracts.nt | 14 ++++++++++++++ crates/aprender-rollup/README.md | 10 ++++++++++ evidence/binary/snapshot.jsonl | 1 + 3 files changed, 25 insertions(+) create mode 100644 crates/aprender-rollup/README.md diff --git a/contracts/contracts.nt b/contracts/contracts.nt index 218e0a585d..63db821bb8 100644 --- a/contracts/contracts.nt +++ b/contracts/contracts.nt @@ -1730,6 +1730,20 @@ "aprender-rag"^^ . "a7a00dd65876e1a486acf580879c8528d9262d20a6211a293b8558b58838967d"^^ . "aprender-rag 0.70.0 (47d198202)"^^ . + . + . + . + "acde1c41ec2a53a4783464a72f12ac097702256c"^^ . + "true"^^ . + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"^^ . + "aprender-rollup"^^ . + "aprender-rollup"^^ . + "1a8d68713c3c2cdbf8a416eef593606e234d0687eace936e7c23159787c8f312"^^ . + "crates/aprender-rollup/src/main.rs"^^ . + "aprender-rollup"^^ . + "acde1c41e"^^ . + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"^^ . + ""^^ . . . . diff --git a/crates/aprender-rollup/README.md b/crates/aprender-rollup/README.md new file mode 100644 index 0000000000..f6a26d9b01 --- /dev/null +++ b/crates/aprender-rollup/README.md @@ -0,0 +1,10 @@ +# aprender-rollup + +Rollup bot core: picks small green PRs to land as one merge-queue entry +(T-rollup, #5394, PMAT-5737). Part of the [paiml/aprender](https://github.com/paiml/aprender) +monorepo. `publish = false`, build tooling, never installed. + +| Subcommand | Effect | +|------------|--------| +| `aprender-rollup plan` | prints the rollup it would build (read-only) | +| `aprender-rollup build ` | assembles the rollup locally in worktree ``; never pushes or closes | diff --git a/evidence/binary/snapshot.jsonl b/evidence/binary/snapshot.jsonl index 47fe538704..0d3ac1322d 100644 --- a/evidence/binary/snapshot.jsonl +++ b/evidence/binary/snapshot.jsonl @@ -19,6 +19,7 @@ {"schema":"binary-snapshot/v1","package":"aprender-qa-certify","target":"aprender-qa-readme-sync","name":"aprender-qa-readme-sync","src_path":"crates/aprender-qa-certify/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"aprender-qa-readme-sync 0.70.0 (47d198202)","version_sha256":"f5272da6ac0c9ce78272891e6fa9fab2d004480c80c75a0ce56a42be65a4d507","binary_sha256":"5a1a71cc88d2eafe930c6578e27e526e06b88f77ce8547197b7677c78df68453","help_sha256":"edfc1fade8953df6a4cbfd49e5e0a1da55f604dd8aeffa0d49763b3c39bc5fa3","help_exit":0,"commands":[],"options":["--csv","--readme"],"positionals":[],"routes":[],"tools":[]} {"schema":"binary-snapshot/v1","package":"aprender-qa-cli","target":"apr-qa","name":"apr-qa","src_path":"crates/aprender-qa-cli/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"apr-qa 0.70.0 (47d198202)","version_sha256":"31ef071f9c7b471a1fb09798ad92435623491583a674cdb5de7e1e3c88c41aa2","binary_sha256":"1b9087ae6055960718263d8491d18e93712130d37a597ea2a4c4140b81b3303c","help_sha256":"a8cb537ca5744570d2eb338170b8da5d48088358f17dbd88aa8a538d3a5f102f","help_exit":0,"commands":["bootstrap","certify","export-csv","export-evidence","generate","kernel-coverage","list","lock-playbooks","parity","report","run","score","tickets","tools","validate-contract"],"options":[],"positionals":[],"routes":[],"tools":[]} {"schema":"binary-snapshot/v1","package":"aprender-rag-cli","target":"aprender-rag","name":"aprender-rag","src_path":"crates/aprender-rag-cli/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"aprender-rag 0.70.0 (47d198202)","version_sha256":"a7a00dd65876e1a486acf580879c8528d9262d20a6211a293b8558b58838967d","binary_sha256":"d6945efe30bd87ec5865d7bd48a7f232399f9b1a3e03c5f972ff04ac7d99e857","help_sha256":"49786a548b7c25c6a67c16db5bd8b1ab336b410556955ef5f7f6657a6fdbd26d","help_exit":0,"commands":["demo","extract-frames","index","info","query","transcribe"],"options":[],"positionals":[],"routes":[],"tools":[]} +{"schema":"binary-snapshot/v1","package":"aprender-rollup","target":"aprender-rollup","name":"aprender-rollup","src_path":"crates/aprender-rollup/src/main.rs","git_sha":"acde1c41ec2a53a4783464a72f12ac097702256c","version":"","version_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","binary_sha256":"1a8d68713c3c2cdbf8a416eef593606e234d0687eace936e7c23159787c8f312","help_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","help_exit":2,"commands":[],"options":[],"positionals":[],"routes":[],"tools":[]} {"schema":"binary-snapshot/v1","package":"aprender-simulate","target":"aprender-simulate","name":"aprender-simulate","src_path":"crates/aprender-simulate/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"aprender-simulate 0.70.0 (47d198202)","version_sha256":"a7ad86bad2c4090750f8d805b282e4002f21b3e62ddf30924c6549b080f64532","binary_sha256":"1d57fceab026705fbcb61e62721b0cbdc4299277f2e4cfd76340981781ccb241","help_sha256":"adaaa5979da2b555e584a1ca8e918e8c4859c1bab12c89c3924044a1a002f0ee","help_exit":0,"commands":["emc-check","emc-validate","help","list-emc","render","run","validate","verify","version"],"options":[],"positionals":[],"routes":[],"tools":[]} {"schema":"binary-snapshot/v1","package":"aprender-test-cli","target":"aprender-test-cli","name":"aprender-test-cli","src_path":"crates/aprender-test-cli/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"aprender-test-cli 0.70.0 (47d198202)","version_sha256":"419743418d4db8edf98ce3859c7dbf8339e7f6e1518078ebeea25cccf9edbfba","binary_sha256":"879b610adf5b3ad8e501d4393a3baa5bf33e37a8564f588c440596260a9ed809","help_sha256":"097224de11e0b6ba9e38e363e4d4238f17aa9aca0a9425627faed1abba0b1d36","help_exit":0,"commands":["animation check","audio check","av-sync check","av-sync report","build","comply","comply check","comply diff","comply enforce","comply migrate","comply report","config","coverage","init","llm bench","llm data-audit","llm experiment","llm experiment compare","llm experiment init","llm experiment status","llm gen-dataset","llm load","llm report","llm score","llm sweep","llm test","playbook","record","report","serve","serve score","serve tree","serve viz","stress","test","video check","watch"],"options":["--color","--quiet","--verbose"],"positionals":[],"routes":[],"tools":[]} {"schema":"binary-snapshot/v1","package":"aprender-train-bench","target":"aprender-train-bench","name":"aprender-train-bench","src_path":"crates/aprender-train-bench/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"aprender-train-bench 0.70.0 (47d198202)","version_sha256":"1500e4fce255af39f1026cfdb9a0347ad4d81834fd9590fa9d552508b028cff1","binary_sha256":"056d8f340d7d22a182be7ccf869033513c527d91218df7d9d0cdf77a170e1d4e","help_sha256":"b5aa1cc255f9c515f81b46bee28321b64bbf1e66cbae739517d188b7dc0ddf6a","help_exit":0,"commands":["ablation","alpha","compare","cost-performance","recommend","temperature"],"options":["--format","--no-color","--quiet","--verbose"],"positionals":[],"routes":[],"tools":[]} From 88320a1b6b4873395bab17079b701548458c9a3f Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 05:01:07 +0200 Subject: [PATCH 16/17] aprender-rollup: ship the CLI as an aprender-ci-tools bin so FALSIFY-MONO-011 holds without growing the shrink-only allowlist Refs PMAT-5737 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + Cargo.toml | 1 + contracts/binary-debt-v1.yaml | 2 +- contracts/contracts.nt | 28 +++++++++---------- crates/aprender-ci-tools/Cargo.toml | 11 ++++++++ .../src/bin/aprender-rollup.rs} | 0 crates/aprender-rollup/README.md | 4 ++- evidence/binary/snapshot.jsonl | 2 +- 8 files changed, 32 insertions(+), 17 deletions(-) rename crates/{aprender-rollup/src/main.rs => aprender-ci-tools/src/bin/aprender-rollup.rs} (100%) diff --git a/Cargo.lock b/Cargo.lock index c8cd30b42e..edddf6a8eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -426,6 +426,7 @@ name = "aprender-ci-tools" version = "0.71.3" dependencies = [ "aprender-build-sha", + "aprender-rollup", "clap", "regex", "serde", diff --git a/Cargo.toml b/Cargo.toml index 0050cf07ee..12f2b08d7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -322,6 +322,7 @@ aprender-train-distill = { path = "crates/aprender-train-distill", version = "0. aprender-orchestrate = { path = "crates/aprender-orchestrate", version = "0.71.3" } aprender-contracts = { path = "crates/aprender-contracts", version = "0.71.3" } aprender-build-sha = { path = "crates/aprender-build-sha", version = "0.71.3" } +aprender-rollup = { path = "crates/aprender-rollup", version = "0.71.3" } aprender-profile = { path = "crates/aprender-profile", version = "0.71.3" } aprender-profile-core = { path = "crates/aprender-profile-core", version = "0.71.3" } aprender-zram-core = { path = "crates/aprender-zram-core", version = "0.71.3" } diff --git a/contracts/binary-debt-v1.yaml b/contracts/binary-debt-v1.yaml index 543912118d..3a081ab34c 100644 --- a/contracts/binary-debt-v1.yaml +++ b/contracts/binary-debt-v1.yaml @@ -111,7 +111,7 @@ binaries: - {crate: aprender, bin: apr, class: KEEP, reason: "the one entry point", target: null} - {crate: aprender-compute-xtask, bin: aprender-compute-xtask, class: KEEP, reason: "build tooling, never installed", target: null} - {crate: aprender-ci-tools, bin: aprender-ci-tools, class: KEEP, reason: "CI tooling ported from scripts/**/*.py (C301), never installed", target: null} - - {crate: aprender-rollup, bin: aprender-rollup, class: KEEP, reason: "rollup bot plan/build tooling (T-rollup, #5737), never installed", target: null} + - {crate: aprender-ci-tools, bin: aprender-rollup, class: KEEP, reason: "rollup bot plan/build tooling (T-rollup, #5737), never installed", target: null} - {crate: aprender-contracts-cli, bin: pv, class: KEEP, reason: "contract CLI, dogfooded by every gate", target: null} - {crate: aprender-contracts-cli, bin: pv-sat, class: KEEP, reason: "ONT-5 SHACL reasoner (added after the census); gates call it as its own ELF. Folding it into `pv sat` is a cop ruling, and would count as debt", target: null} - {crate: aprender-zram-generator, bin: aprender-zram-generator, class: KEEP, reason: "systemd generator, must be a standalone ELF", target: null} diff --git a/contracts/contracts.nt b/contracts/contracts.nt index 6f97f3e45a..8676236ebc 100644 --- a/contracts/contracts.nt +++ b/contracts/contracts.nt @@ -1730,20 +1730,20 @@ "aprender-rag"^^ . "a7a00dd65876e1a486acf580879c8528d9262d20a6211a293b8558b58838967d"^^ . "aprender-rag 0.70.0 (47d198202)"^^ . - . - . - . - "acde1c41ec2a53a4783464a72f12ac097702256c"^^ . - "true"^^ . - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"^^ . - "aprender-rollup"^^ . - "aprender-rollup"^^ . - "1a8d68713c3c2cdbf8a416eef593606e234d0687eace936e7c23159787c8f312"^^ . - "crates/aprender-rollup/src/main.rs"^^ . - "aprender-rollup"^^ . - "acde1c41e"^^ . - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"^^ . - ""^^ . + . + . + . + "acde1c41ec2a53a4783464a72f12ac097702256c"^^ . + "true"^^ . + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"^^ . + "aprender-rollup"^^ . + "aprender-ci-tools"^^ . + "1a8d68713c3c2cdbf8a416eef593606e234d0687eace936e7c23159787c8f312"^^ . + "crates/aprender-ci-tools/src/bin/aprender-rollup.rs"^^ . + "aprender-rollup"^^ . + "acde1c41e"^^ . + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"^^ . + ""^^ . . . . diff --git a/crates/aprender-ci-tools/Cargo.toml b/crates/aprender-ci-tools/Cargo.toml index dbfabc85f1..0ea79ad78f 100644 --- a/crates/aprender-ci-tools/Cargo.toml +++ b/crates/aprender-ci-tools/Cargo.toml @@ -11,6 +11,7 @@ authors = ["PAIML Engineering"] publish = false [dependencies] +aprender-rollup = { workspace = true } clap = { workspace = true } regex = { workspace = true } serde = { workspace = true } @@ -26,3 +27,13 @@ aprender-build-sha = { workspace = true } # #4219: APR_GIT_SHA for --version [lints] workspace = true + +# The rollup CLI ships here, not as a bin of aprender-rollup (FALSIFY-MONO-011: the +# [[bin]] allowlist is shrink-only, and this package is already on it). +[[bin]] +name = "aprender-ci-tools" +path = "src/main.rs" + +[[bin]] +name = "aprender-rollup" +path = "src/bin/aprender-rollup.rs" diff --git a/crates/aprender-rollup/src/main.rs b/crates/aprender-ci-tools/src/bin/aprender-rollup.rs similarity index 100% rename from crates/aprender-rollup/src/main.rs rename to crates/aprender-ci-tools/src/bin/aprender-rollup.rs diff --git a/crates/aprender-rollup/README.md b/crates/aprender-rollup/README.md index f6a26d9b01..a377d4381c 100644 --- a/crates/aprender-rollup/README.md +++ b/crates/aprender-rollup/README.md @@ -2,7 +2,9 @@ Rollup bot core: picks small green PRs to land as one merge-queue entry (T-rollup, #5394, PMAT-5737). Part of the [paiml/aprender](https://github.com/paiml/aprender) -monorepo. `publish = false`, build tooling, never installed. +monorepo. `publish = false`, build tooling, never installed. This crate is library-only; the CLI +ships as the `aprender-rollup` bin of `aprender-ci-tools` (`cargo run -p aprender-ci-tools --bin aprender-rollup -- plan`), +so the shrink-only [[bin]] allowlist (FALSIFY-MONO-011) does not grow. | Subcommand | Effect | |------------|--------| diff --git a/evidence/binary/snapshot.jsonl b/evidence/binary/snapshot.jsonl index 0d3ac1322d..124958c6dc 100644 --- a/evidence/binary/snapshot.jsonl +++ b/evidence/binary/snapshot.jsonl @@ -19,7 +19,7 @@ {"schema":"binary-snapshot/v1","package":"aprender-qa-certify","target":"aprender-qa-readme-sync","name":"aprender-qa-readme-sync","src_path":"crates/aprender-qa-certify/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"aprender-qa-readme-sync 0.70.0 (47d198202)","version_sha256":"f5272da6ac0c9ce78272891e6fa9fab2d004480c80c75a0ce56a42be65a4d507","binary_sha256":"5a1a71cc88d2eafe930c6578e27e526e06b88f77ce8547197b7677c78df68453","help_sha256":"edfc1fade8953df6a4cbfd49e5e0a1da55f604dd8aeffa0d49763b3c39bc5fa3","help_exit":0,"commands":[],"options":["--csv","--readme"],"positionals":[],"routes":[],"tools":[]} {"schema":"binary-snapshot/v1","package":"aprender-qa-cli","target":"apr-qa","name":"apr-qa","src_path":"crates/aprender-qa-cli/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"apr-qa 0.70.0 (47d198202)","version_sha256":"31ef071f9c7b471a1fb09798ad92435623491583a674cdb5de7e1e3c88c41aa2","binary_sha256":"1b9087ae6055960718263d8491d18e93712130d37a597ea2a4c4140b81b3303c","help_sha256":"a8cb537ca5744570d2eb338170b8da5d48088358f17dbd88aa8a538d3a5f102f","help_exit":0,"commands":["bootstrap","certify","export-csv","export-evidence","generate","kernel-coverage","list","lock-playbooks","parity","report","run","score","tickets","tools","validate-contract"],"options":[],"positionals":[],"routes":[],"tools":[]} {"schema":"binary-snapshot/v1","package":"aprender-rag-cli","target":"aprender-rag","name":"aprender-rag","src_path":"crates/aprender-rag-cli/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"aprender-rag 0.70.0 (47d198202)","version_sha256":"a7a00dd65876e1a486acf580879c8528d9262d20a6211a293b8558b58838967d","binary_sha256":"d6945efe30bd87ec5865d7bd48a7f232399f9b1a3e03c5f972ff04ac7d99e857","help_sha256":"49786a548b7c25c6a67c16db5bd8b1ab336b410556955ef5f7f6657a6fdbd26d","help_exit":0,"commands":["demo","extract-frames","index","info","query","transcribe"],"options":[],"positionals":[],"routes":[],"tools":[]} -{"schema":"binary-snapshot/v1","package":"aprender-rollup","target":"aprender-rollup","name":"aprender-rollup","src_path":"crates/aprender-rollup/src/main.rs","git_sha":"acde1c41ec2a53a4783464a72f12ac097702256c","version":"","version_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","binary_sha256":"1a8d68713c3c2cdbf8a416eef593606e234d0687eace936e7c23159787c8f312","help_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","help_exit":2,"commands":[],"options":[],"positionals":[],"routes":[],"tools":[]} +{"schema":"binary-snapshot/v1","package":"aprender-ci-tools","target":"aprender-rollup","name":"aprender-rollup","src_path":"crates/aprender-ci-tools/src/bin/aprender-rollup.rs","git_sha":"acde1c41ec2a53a4783464a72f12ac097702256c","version":"","version_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","binary_sha256":"1a8d68713c3c2cdbf8a416eef593606e234d0687eace936e7c23159787c8f312","help_sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","help_exit":2,"commands":[],"options":[],"positionals":[],"routes":[],"tools":[]} {"schema":"binary-snapshot/v1","package":"aprender-simulate","target":"aprender-simulate","name":"aprender-simulate","src_path":"crates/aprender-simulate/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"aprender-simulate 0.70.0 (47d198202)","version_sha256":"a7ad86bad2c4090750f8d805b282e4002f21b3e62ddf30924c6549b080f64532","binary_sha256":"1d57fceab026705fbcb61e62721b0cbdc4299277f2e4cfd76340981781ccb241","help_sha256":"adaaa5979da2b555e584a1ca8e918e8c4859c1bab12c89c3924044a1a002f0ee","help_exit":0,"commands":["emc-check","emc-validate","help","list-emc","render","run","validate","verify","version"],"options":[],"positionals":[],"routes":[],"tools":[]} {"schema":"binary-snapshot/v1","package":"aprender-test-cli","target":"aprender-test-cli","name":"aprender-test-cli","src_path":"crates/aprender-test-cli/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"aprender-test-cli 0.70.0 (47d198202)","version_sha256":"419743418d4db8edf98ce3859c7dbf8339e7f6e1518078ebeea25cccf9edbfba","binary_sha256":"879b610adf5b3ad8e501d4393a3baa5bf33e37a8564f588c440596260a9ed809","help_sha256":"097224de11e0b6ba9e38e363e4d4238f17aa9aca0a9425627faed1abba0b1d36","help_exit":0,"commands":["animation check","audio check","av-sync check","av-sync report","build","comply","comply check","comply diff","comply enforce","comply migrate","comply report","config","coverage","init","llm bench","llm data-audit","llm experiment","llm experiment compare","llm experiment init","llm experiment status","llm gen-dataset","llm load","llm report","llm score","llm sweep","llm test","playbook","record","report","serve","serve score","serve tree","serve viz","stress","test","video check","watch"],"options":["--color","--quiet","--verbose"],"positionals":[],"routes":[],"tools":[]} {"schema":"binary-snapshot/v1","package":"aprender-train-bench","target":"aprender-train-bench","name":"aprender-train-bench","src_path":"crates/aprender-train-bench/src/main.rs","git_sha":"47d198202ff793c91cb790beba358f531505cb51","version":"aprender-train-bench 0.70.0 (47d198202)","version_sha256":"1500e4fce255af39f1026cfdb9a0347ad4d81834fd9590fa9d552508b028cff1","binary_sha256":"056d8f340d7d22a182be7ccf869033513c527d91218df7d9d0cdf77a170e1d4e","help_sha256":"b5aa1cc255f9c515f81b46bee28321b64bbf1e66cbae739517d188b7dc0ddf6a","help_exit":0,"commands":["ablation","alpha","compare","cost-performance","recommend","temperature"],"options":["--format","--no-color","--quiet","--verbose"],"positionals":[],"routes":[],"tools":[]} From 43ec3cd92d32bfc8998e63e8a9ed4d24ed125472 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 05:02:27 +0200 Subject: [PATCH 17/17] contracts.nt: generator order for the re-keyed aprender-rollup binary rows Refs PMAT-5737 Co-Authored-By: Claude Opus 5.5 --- contracts/contracts.nt | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/contracts/contracts.nt b/contracts/contracts.nt index 8676236ebc..81a4534709 100644 --- a/contracts/contracts.nt +++ b/contracts/contracts.nt @@ -872,6 +872,20 @@ "aprender-ci-tools"^^ . "2ffcc380b81bf4779669ef921b1f2607d68308693adb5fe1cafbd5cc5beb2574"^^ . "aprender-ci-tools 0.70.0 (8b7d6a04c5)"^^ . + . + . + . + "acde1c41ec2a53a4783464a72f12ac097702256c"^^ . + "true"^^ . + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"^^ . + "aprender-rollup"^^ . + "aprender-ci-tools"^^ . + "1a8d68713c3c2cdbf8a416eef593606e234d0687eace936e7c23159787c8f312"^^ . + "crates/aprender-ci-tools/src/bin/aprender-rollup.rs"^^ . + "aprender-rollup"^^ . + "acde1c41e"^^ . + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"^^ . + ""^^ . . . . @@ -1730,20 +1744,6 @@ "aprender-rag"^^ . "a7a00dd65876e1a486acf580879c8528d9262d20a6211a293b8558b58838967d"^^ . "aprender-rag 0.70.0 (47d198202)"^^ . - . - . - . - "acde1c41ec2a53a4783464a72f12ac097702256c"^^ . - "true"^^ . - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"^^ . - "aprender-rollup"^^ . - "aprender-ci-tools"^^ . - "1a8d68713c3c2cdbf8a416eef593606e234d0687eace936e7c23159787c8f312"^^ . - "crates/aprender-ci-tools/src/bin/aprender-rollup.rs"^^ . - "aprender-rollup"^^ . - "acde1c41e"^^ . - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"^^ . - ""^^ . . . .