Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
138d94c
feat(rollup): pure selection, bisection and close-outcome core for th…
noahgift Oct 19, 2026
5a7718b
chore(roadmap): PMAT-5737 T-rollup ticket (Refs #5737, #5394)
noahgift Oct 19, 2026
42326e4
feat(rollup): parse gh pr list JSON into candidates, failing closed (…
noahgift Oct 19, 2026
c7ad9af
feat(rollup): plan subcommand and report text, binary-debt row (Refs …
noahgift Oct 19, 2026
52ac446
feat(rollup): branch builder steps and build subcommand, ejects confl…
noahgift Oct 19, 2026
b2ec68c
feat(rollup): gh args to close with SHA or keep open with reason (Ref…
noahgift Oct 19, 2026
4ff0fe9
feat(rollup): merge-group verdict, halves bisect, close plan (Refs #5…
noahgift Oct 19, 2026
3fb3298
feat(rollup): verify command wires merge-group verdict, bisect halves…
noahgift Oct 19, 2026
9e5cb42
Merge remote-tracking branch 'origin/main' into feat/rollup-bot
noahgift Oct 19, 2026
3f53383
feat(rollup): cap a rollup at 3 entries until p_red is measured (Refs…
noahgift Oct 19, 2026
2b4c4e5
fix(rollup): bind verdict to head SHA, check SHA on main, name over-c…
noahgift Oct 19, 2026
8e1b7f8
feat(rollup): bind build to judged head, all-skipped checks are not g…
noahgift Oct 19, 2026
9de6cb7
Merge main into feat/rollup-bot (Refs #5737)
noahgift Oct 19, 2026
d69d39d
Merge remote-tracking branch 'origin/main' into fx5742
noahgift Oct 19, 2026
7c77046
fix(ci): regenerate tree_reader_tests.txt (aprender-rollup registered…
noahgift Oct 19, 2026
2a637f0
fix(contracts): regenerate shapes.ttl and witness with the in-tree pv…
noahgift Oct 19, 2026
cab84b7
Merge origin/main into feat/rollup-bot
noahgift Oct 19, 2026
acde1c4
Regenerate roadmap.yaml from entries (make roadmap-aggregate)
noahgift Oct 19, 2026
a9b1e3f
aprender-rollup: README and binary-snapshot row so the binary-debt le…
noahgift Oct 20, 2026
f76377c
Merge remote-tracking branch 'origin/main' into remerge-5742-2340
noahgift Oct 20, 2026
88320a1
aprender-rollup: ship the CLI as an aprender-ci-tools bin so FALSIFY-…
noahgift Oct 20, 2026
43ec3cd
contracts.nt: generator order for the re-keyed aprender-rollup binary…
noahgift Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -321,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" }
Expand Down
1 change: 1 addition & 0 deletions contracts/binary-debt-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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-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}
Expand Down
14 changes: 14 additions & 0 deletions contracts/contracts.nt
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,20 @@
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-ci-tools> <https://ont.paiml.dev/v1alpha1/bin/target> "aprender-ci-tools"^^<http://www.w3.org/2001/XMLSchema#string> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-ci-tools> <https://ont.paiml.dev/v1alpha1/bin/versionSha256> "2ffcc380b81bf4779669ef921b1f2607d68308693adb5fe1cafbd5cc5beb2574"^^<http://www.w3.org/2001/XMLSchema#string> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-ci-tools> <https://ont.paiml.dev/v1alpha1/bin/versionString> "aprender-ci-tools 0.70.0 (8b7d6a04c5)"^^<http://www.w3.org/2001/XMLSchema#string> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-rollup> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/ns/prov#Entity> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-rollup> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://ont.paiml.dev/v1alpha1/Binary> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-rollup> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://ont.paiml.dev/v1alpha1/bin/target/aprender-ci-tools/aprender-rollup> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-rollup> <https://ont.paiml.dev/v1alpha1/bin/gitSha> "acde1c41ec2a53a4783464a72f12ac097702256c"^^<http://www.w3.org/2001/XMLSchema#string> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-rollup> <https://ont.paiml.dev/v1alpha1/bin/helpFailed> "true"^^<http://www.w3.org/2001/XMLSchema#boolean> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-rollup> <https://ont.paiml.dev/v1alpha1/bin/helpSha256> "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"^^<http://www.w3.org/2001/XMLSchema#string> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-rollup> <https://ont.paiml.dev/v1alpha1/bin/name> "aprender-rollup"^^<http://www.w3.org/2001/XMLSchema#string> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-rollup> <https://ont.paiml.dev/v1alpha1/bin/package> "aprender-ci-tools"^^<http://www.w3.org/2001/XMLSchema#string> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-rollup> <https://ont.paiml.dev/v1alpha1/bin/sha256> "1a8d68713c3c2cdbf8a416eef593606e234d0687eace936e7c23159787c8f312"^^<http://www.w3.org/2001/XMLSchema#string> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-rollup> <https://ont.paiml.dev/v1alpha1/bin/srcPath> "crates/aprender-ci-tools/src/bin/aprender-rollup.rs"^^<http://www.w3.org/2001/XMLSchema#string> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-rollup> <https://ont.paiml.dev/v1alpha1/bin/target> "aprender-rollup"^^<http://www.w3.org/2001/XMLSchema#string> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-rollup> <https://ont.paiml.dev/v1alpha1/bin/versionLacksSha> "acde1c41e"^^<http://www.w3.org/2001/XMLSchema#string> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-rollup> <https://ont.paiml.dev/v1alpha1/bin/versionSha256> "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"^^<http://www.w3.org/2001/XMLSchema#string> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-ci-tools/aprender-rollup> <https://ont.paiml.dev/v1alpha1/bin/versionString> ""^^<http://www.w3.org/2001/XMLSchema#string> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-compute-xtask/aprender-compute-xtask> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/ns/prov#Entity> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-compute-xtask/aprender-compute-xtask> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://ont.paiml.dev/v1alpha1/Binary> .
<https://ont.paiml.dev/v1alpha1/binary/aprender-compute-xtask/aprender-compute-xtask> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://ont.paiml.dev/v1alpha1/bin/target/aprender-compute-xtask/aprender-compute-xtask> .
Expand Down
11 changes: 11 additions & 0 deletions crates/aprender-ci-tools/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ authors = ["PAIML Engineering"]
publish = false

[dependencies]
aprender-rollup = { workspace = true }
clap = { workspace = true }
regex = { workspace = true }
serde = { workspace = true }
Expand All @@ -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"
174 changes: 174 additions & 0 deletions crates/aprender-ci-tools/src/bin/aprender-rollup.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
//! `aprender-rollup plan` prints the rollup it would build (read-only).
//! `aprender-rollup build <dir>` assembles it locally in worktree `<dir>`; it never pushes or closes.

use aprender_rollup::mg::{self, Verdict};
use aprender_rollup::{build, close, gh_json, outcome, report, select};
use std::process::{Command, ExitCode};

fn candidates() -> Result<Vec<aprender_rollup::Candidate>, String> {
let o = Command::new("gh")
.args([
"pr",
"list",
"--state",
"open",
"--limit",
"100",
"--json",
gh_json::GH_FIELDS,
])
.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))
}

/// Trimmed stdout of a successful `git` run in `dir`.
fn git_out(dir: &str, args: &[String]) -> Option<String> {
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")
.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], heads: &[String]) -> Vec<u64> {
let mut merged = Vec::new();
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_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());
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 <dir>")?;
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, &sel.heads);
println!(
"{}\n{}",
build::branch_name(&sel.picked),
build::message(&merged)
);
Ok(())
}

fn gh(args: &[String]) -> Result<String, String> {
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 <branch> <head-sha> <sha|-> <n,n,..> [--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, head, sha, list, rest @ ..] = a else {
return Err("usage: aprender-rollup verify <branch> <sha|-> <n,n,..> [--apply]".into());
};
let picked: Vec<u64> = list
.split(',')
.map(|s| {
s.trim_start_matches('#')
.parse()
.map_err(|_| format!("bad PR {s}"))
})
.collect::<Result<_, _>>()?;
let runs = gh(&mg::run_list_args(branch))?;
let v = mg::parse_verdict(&runs, head)?;
println!("verdict {v:?}");
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));
println!("#{n}: gh {}", args.join(" "));
if rest.iter().any(|r| r == "--apply") {
gh(&args)?;
}
}
}
}
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<String> = std::env::args().collect();
let cmd = args.get(1).map(String::as_str).unwrap_or("");
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 <dir> | verify <branch> <head-sha> <sha|-> <n,..> [--apply]");
return ExitCode::from(2);
}
};
match res {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("not_measured: {e}");
ExitCode::FAILURE
}
}
}
17 changes: 17 additions & 0 deletions crates/aprender-rollup/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[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

[dependencies]
serde_json = { workspace = true }

[lints]
workspace = true
12 changes: 12 additions & 0 deletions crates/aprender-rollup/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# 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. 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 |
|------------|--------|
| `aprender-rollup plan` | prints the rollup it would build (read-only) |
| `aprender-rollup build <dir>` | assembles the rollup locally in worktree `<dir>`; never pushes or closes |
56 changes: 56 additions & 0 deletions crates/aprender-rollup/src/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//! 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<String> = 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<Vec<String>> {
let v = |a: &[&str]| a.iter().map(|s| (*s).to_string()).collect::<Vec<_>>();
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
}

/// Print the commit the last `fetch` brought in, to compare with the head judged at selection.
#[must_use]
pub fn head_step() -> Vec<String> {
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<String> {
vec!["merge".to_string(), "--abort".to_string()]
}

#[cfg(test)]
mod tests;
32 changes: 32 additions & 0 deletions crates/aprender-rollup/src/build/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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"]);
}

#[test]
fn head_check_step_reads_fetch_head() {
assert_eq!(head_step(), ["rev-parse", "FETCH_HEAD"]);
}
Loading
Loading