feat(missions): publish the mission branch, gated by commit_policy
Completes delivery. A phase's work is now captured, committed, gated and pushed — in that order, so every failure costs strictly less than the one before it. Publishing is last for a reason. By the time it runs the patch is on disk, the artifact is registered and the work is on a local branch, so a rejected ref, a rotated token or an unreachable forge costs a push and nothing else. A test pushes at a path that does not exist and asserts the commit is still there afterwards. The gate decides the branch name, never whether the work survives: - green, or policy `always` → `clawmates/mission-<m8>-<p8>` - red / unrunnable / no suite → `…-wip` - `on_reviewer_approval` → `…-review` Both land on the forge. A human can inspect, fix and re-push a branch; nobody can recover work discarded for failing a test. Deleting a red branch reproduces the old behaviour on purpose rather than by accident. `verify_tests` runs the project's own suite through the runtime container and returns `Option<bool>` — `None` for "could not establish", which the gate treats as unproven. An unreadable exit status is not a pass. That is the same fail-closed stance as the phase evaluator, and it is here because this tranche has now found four separate things reporting success while doing nothing. Never force-push. A rejected update is reported and left alone: the remote ref belongs to whoever set it, and overwriting it to make delivery look tidy is how a mission eats someone else's commit. The push URL is built fresh from the repo row and the ambient token, not read from `.git/config` — which no longer carries credentials, since agents run as root in a container that mounts the checkout. Tests push to a real `git init --bare` remote and assert the ref and its content actually arrived. A mock would have accepted anything. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3ea288dbb5
commit
e2871c4361
@@ -26,6 +26,7 @@ async fn capture(
|
||||
&root.join(mission.to_string()).join("repo"),
|
||||
&root.join("_outputs").join(mission.to_string()),
|
||||
0,
|
||||
mission_delivery::Gate::Always,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -426,3 +427,161 @@ fn a_rerun_lands_on_its_own_branch() {
|
||||
"pass 2 is named for the pass, not the index: {second}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Push against a real bare repository.
|
||||
///
|
||||
/// A mock remote would accept whatever we sent and prove nothing; the failures
|
||||
/// worth catching here — a rejected ref, a branch that never arrives, work
|
||||
/// pushed to the wrong name — are all things only a real git remote reports.
|
||||
#[tokio::test]
|
||||
async fn a_gated_push_reaches_the_remote() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let remote = tmp.path().join("remote.git");
|
||||
std::fs::create_dir_all(&remote).unwrap();
|
||||
Command::new("git")
|
||||
.args(["init", "--bare", "--quiet"])
|
||||
.arg(&remote)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let mission = Uuid::now_v7();
|
||||
let repo = seed_repo(tmp.path(), mission);
|
||||
std::fs::write(repo.join("work.rs"), "fn shipped() {}\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "--quiet", "-m", "work"]);
|
||||
git(
|
||||
&repo,
|
||||
&["checkout", "-B", "clawmates/mission-test-aaaaaaaa"],
|
||||
);
|
||||
|
||||
let out = mission_delivery::publish_phase_branch(
|
||||
&repo,
|
||||
remote.to_str().unwrap(),
|
||||
"clawmates/mission-test-aaaaaaaa",
|
||||
mission_delivery::Gate::Always,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.pushed, "push failed: {:?}", out.error);
|
||||
assert_eq!(out.branch, "clawmates/mission-test-aaaaaaaa");
|
||||
|
||||
// The remote genuinely has it, with the content.
|
||||
let refs = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&remote)
|
||||
.args(["for-each-ref", "--format=%(refname:short)"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let refs = String::from_utf8_lossy(&refs.stdout);
|
||||
assert!(
|
||||
refs.contains("clawmates/mission-test-aaaaaaaa"),
|
||||
"refs: {refs}"
|
||||
);
|
||||
|
||||
let show = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&remote)
|
||||
.args(["show", "clawmates/mission-test-aaaaaaaa:work.rs"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&show.stdout).contains("fn shipped()"));
|
||||
}
|
||||
|
||||
/// A red suite must not block delivery — it must redirect it. The work still
|
||||
/// reaches the forge, on a branch whose name says it is unproven.
|
||||
#[tokio::test]
|
||||
async fn a_failed_gate_publishes_to_a_wip_branch() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let remote = tmp.path().join("remote.git");
|
||||
std::fs::create_dir_all(&remote).unwrap();
|
||||
Command::new("git")
|
||||
.args(["init", "--bare", "--quiet"])
|
||||
.arg(&remote)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let mission = Uuid::now_v7();
|
||||
let repo = seed_repo(tmp.path(), mission);
|
||||
std::fs::write(repo.join("half_done.rs"), "fn broken() {}\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "--quiet", "-m", "wip"]);
|
||||
git(
|
||||
&repo,
|
||||
&["checkout", "-B", "clawmates/mission-test-bbbbbbbb"],
|
||||
);
|
||||
|
||||
let out = mission_delivery::publish_phase_branch(
|
||||
&repo,
|
||||
remote.to_str().unwrap(),
|
||||
"clawmates/mission-test-bbbbbbbb",
|
||||
mission_delivery::Gate::OnGreenTests,
|
||||
Some(false),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(out.pushed, "a failed gate still publishes: {:?}", out.error);
|
||||
assert!(
|
||||
out.branch.ends_with("-wip"),
|
||||
"verdict is in the name: {}",
|
||||
out.branch
|
||||
);
|
||||
|
||||
let refs = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&remote)
|
||||
.args(["for-each-ref", "--format=%(refname:short)"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let refs = String::from_utf8_lossy(&refs.stdout);
|
||||
assert!(
|
||||
refs.contains("-wip"),
|
||||
"the work reached the forge anyway: {refs}"
|
||||
);
|
||||
assert!(
|
||||
!refs.contains("clawmates/mission-test-bbbbbbbb\n"),
|
||||
"and did not claim the clean branch name"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unreachable remote is a degraded success, not a failure: the patch and
|
||||
/// the local branch both still exist.
|
||||
#[tokio::test]
|
||||
async fn an_unreachable_remote_does_not_lose_the_work() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mission = Uuid::now_v7();
|
||||
let repo = seed_repo(tmp.path(), mission);
|
||||
std::fs::write(repo.join("work.rs"), "fn kept() {}\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "--quiet", "-m", "work"]);
|
||||
git(
|
||||
&repo,
|
||||
&["checkout", "-B", "clawmates/mission-test-cccccccc"],
|
||||
);
|
||||
|
||||
let out = mission_delivery::publish_phase_branch(
|
||||
&repo,
|
||||
&tmp.path().join("does-not-exist.git").display().to_string(),
|
||||
"clawmates/mission-test-cccccccc",
|
||||
mission_delivery::Gate::Always,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!out.pushed);
|
||||
assert!(
|
||||
out.error.is_some(),
|
||||
"the reason is recorded for the operator"
|
||||
);
|
||||
|
||||
// The commit is still there locally — nothing was rolled back.
|
||||
let show = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["show", "HEAD:work.rs"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&show.stdout).contains("fn kept()"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user