feat(merge): gate publication on the merged tree's own tests

The other half of the merge button. Merging told you the branch went in; nothing
checked that what came out still worked.

Verified BEFORE publishing, not reverted after. `merge_locally` and
`push_merged` are separate functions so the caller can run the project's tests
between them, which means a merge that breaks the base is simply never pushed —
`main` is not broken for however long it takes someone to notice. A test asserts
`merge_locally` contains no push, because the moment it does, verification
becomes after-the-fact and the guarantee is gone.

Outcomes, all reported to the operator rather than swallowed:
  Passed      -> published
  NoSuite     -> published, and SAID so; a repo with no tests is a fact about the
                 repo, not a pass
  Failed      -> not published, exit code reported, branch untouched so it can be
                 fixed and merged again
  CouldNotRun -> not published. Fail closed: a suite that could not run has not
                 passed, and publishing on "we could not check" is how a green
                 main stops meaning anything.

`verify_tests` runs `cargo test` as ROOT in a container, so the merge workdir
ends up holding a root-owned `target/` the server (uid 65532) cannot delete —
the same leak found three times today. Purged through the container before the
ordinary cleanup.

248 lib tests.
This commit is contained in:
Omar Sobh
2026-08-07 22:51:14 -07:00
parent 28090d1de0
commit 5b49d5a1a8
2 changed files with 118 additions and 3 deletions
+55 -2
View File
@@ -469,8 +469,61 @@ pub async fn merge_branch(
})));
}
let outcome =
crate::auto_merge::merge_on_operator_approval(&workdir, &auth.url, &branch, base).await;
let container = std::env::var("CLAWMATES_RUNTIME_CONTAINER")
.unwrap_or_else(|_| "clawmates-runtime".to_string());
let outcome = async {
let merged =
crate::auto_merge::merge_on_operator_approval(&workdir, &auth.url, &branch, base)
.await?;
if !merged.merged {
return Ok(merged);
}
// Run the project's own tests against the MERGED tree, before it is
// published. Verifying first rather than reverting after is the
// difference between "main was never broken" and "main was broken until
// someone noticed".
//
// The merge is already committed locally at this point; refusing here
// simply never pushes it, and the branch is still there to retry.
match crate::mission_delivery::verify_tests(&workdir, &container).await {
crate::mission_delivery::TestOutcome::Passed => {}
crate::mission_delivery::TestOutcome::NoSuite => {
eprintln!(
"missions::merge_branch: {branch} has no discoverable test suite — publishing unverified"
);
}
crate::mission_delivery::TestOutcome::Failed(code) => {
return Ok(crate::auto_merge::MergeOutcome {
merged: false,
reason: format!(
"the merged tree FAILS the project's tests (exit {code}) — not published. The branch is unchanged; fix it and merge again."
),
});
}
// Fail closed. A suite that could not run has not passed, and
// publishing on "we could not check" is how a green main stops
// meaning anything.
crate::mission_delivery::TestOutcome::CouldNotRun(why) => {
return Ok(crate::auto_merge::MergeOutcome {
merged: false,
reason: format!("could not run the tests on the merged tree ({why}) — not published"),
});
}
}
crate::auto_merge::push_merged(&workdir, &auth.url, base).await?;
Ok::<_, String>(crate::auto_merge::MergeOutcome {
merged: true,
reason: format!("tests pass on the merged tree; published to {base}"),
})
}
.await;
// Purge through the container: `verify_tests` runs `cargo test` as ROOT, so
// the workdir now holds a root-owned `target/` the server (uid 65532) cannot
// delete. Same defect as the bench and judge copies.
crate::root_copy::purge(&container, &workdir).await;
let _ = tokio::fs::remove_dir_all(&workdir).await;
match outcome {