Back to feed

sledtools/pika branch #127

ph-merge-guardrail-6

Guard ph merge behind ci status

Target branch: master

Merge Commit: a1e6909819e4659296d0f5eb290bb9e9028bbce1

branch: merged tutorial: ready ci: success
Open CI Details

Continuous Integration

CI: success

Compact status on the review page, with full logs on the CI page.

Open CI Details

Latest run #164 success

3 passed

head fad015c0f9b42d846681eb269ce2fc0bd340428e · queued 2026-03-27 00:12:27 · 3 lane(s)

queued 1m 02s · ran 29s

check-pika-followup · success check-notifications · success check-agent-contracts · success

Summary

This branch adds a CI status guardrail to the ph merge command. Before this change, ph merge would unconditionally send the merge request to the server regardless of CI state. Now, cmd_merge fetches branch details and checks CI status before proceeding: if CI has failed (and is not still running), the merge is blocked with a clear error message directing the user to fix CI or pass --force. A new --force flag on the Merge subcommand allows explicit override when necessary. The agent skill documentation (SKILL.md) is also updated to reinforce that merging with failing CI should not happen unless the user explicitly overrides. Two new integration tests validate the block-on-failure and force-override paths, and the existing merge test is updated to supply the branch detail endpoint and the new force parameter.

Tutorial Steps

Update agent skill documentation to prohibit merging on CI failure

Intent: Strengthen the written guidance for AI agents using the land skill so they never merge when CI is red, unless the user explicitly overrides the guardrail.

Affected files: .agents/skills/land/SKILL.md

Evidence
@@ -65,7 +65,7 @@ If CI fails:
-Fix the issue, push again, and repeat.
+Do not merge. Fix the issue, push again, and repeat.
@@ -126,7 +126,8 @@ Focus on:
-instead of pretending the landing path is healthy.
+instead of pretending the landing path is healthy. Do not merge with failing CI unless the user
+explicitly instructs you to override that guardrail.

Two documentation changes in SKILL.md make the policy unambiguous:

  1. The "If CI fails" section now begins with "Do not merge." before the instruction to fix and retry.
  2. The closing guidance paragraph adds an explicit statement: merging with failing CI is forbidden unless the user explicitly instructs an override.

These changes ensure that any agent consuming this skill file treats red CI as a hard stop rather than a suggestion.

Add `--force` flag to the `Merge` CLI subcommand

Intent: Expose a `--force` boolean flag on the `ph merge` subcommand so users can explicitly opt in to merging despite CI failures.

Affected files: crates/ph/src/lib.rs

Evidence
@@ -75,6 +78,8 @@ enum PhCommand {
     Merge {
         branch_or_id: Option<String>,
+        #[arg(long)]
+        force: bool,
     },
@@ -28,7 +28,10 @@ pub fn run() -> anyhow::Result<()> {
-        PhCommand::Merge { branch_or_id } => commands::cmd_merge(&cli, branch_or_id.as_deref()),
+        PhCommand::Merge {
+            branch_or_id,
+            force,
+        } => commands::cmd_merge(&cli, branch_or_id.as_deref(), *force),

The PhCommand::Merge variant gains a new force: bool field annotated with #[arg(long)], making it available as ph merge --force <branch>. The dispatch in run() is updated to destructure and forward the flag to cmd_merge.

Implement CI status guard in `cmd_merge`

Intent: Fetch branch details before merging and block the operation when CI has failed, unless `--force` is passed.

Affected files: crates/ph/src/commands.rs

Evidence
@@ -111,11 +111,13 @@ pub(crate) fn cmd_merge(
-pub(crate) fn cmd_merge(cli: &Cli, branch_or_id: Option<&str>) -> anyhow::Result<()> {
+pub(crate) fn cmd_merge(cli: &Cli, branch_or_id: Option<&str>, force: bool) -> anyhow::Result<()> {
     let resolved = resolve_branch_ref(&api, branch_or_id)?;
+    let branch = api.branch_detail(resolved.branch_id)?;
+    ensure_merge_ci_guard(&branch, force)?;
     let response = api.merge_branch(resolved.branch_id)?;
@@ -282,6 +284,17 @@ pub(crate) fn branch_ci_active(branch: &BranchDetailResponse) -> bool {
+fn ensure_merge_ci_guard(branch: &BranchDetailResponse, force: bool) -> anyhow::Result<()> {
+    if force || branch.branch.ci_status.is_success() || branch_ci_active(branch) {
+        return Ok(());
+    }
+    Err(anyhow!(
+        "refusing to merge branch #{} because ci is {}. Rerun or fix CI, or pass --force if absolutely necessary; --force is discouraged under normal circumstances.",
+        branch.branch.branch_id,
+        branch.branch.ci_status
+    ))
+}

The core logic lives in the new ensure_merge_ci_guard function at crates/ph/src/commands.rs:287. It allows the merge to proceed in exactly three situations:

  1. force is true — the user explicitly opted in.
  2. CI status is success — checked via ci_status.is_success().
  3. CI is still running — checked via the existing branch_ci_active() helper, which looks for any lane in a non-terminal state.

In all other cases (failed, errored, etc.) the function returns an anyhow::Error with a message that names the branch, shows the current CI status, and tells the user about --force.

cmd_merge itself now calls api.branch_detail(resolved.branch_id) immediately after resolving the branch reference, then passes the response through the guard before issuing the actual merge request. This means no merge HTTP call is made when the guard rejects.

Update existing merge test to supply branch detail endpoint

Intent: The existing `merge_and_close_use_authenticated_json_endpoints` test must now serve a `/git/api/forge/branch/11` route (the detail endpoint) and pass the new `force` parameter to `cmd_merge`.

Affected files: crates/ph/src/tests.rs

Evidence
@@ -406,6 +406,53 @@ fn merge_and_close_use_authenticated_json_endpoints() {
+            .route(
+                "/git/api/forge/branch/11",
+                get(|| async {
+                    Json(serde_json::json!({
+                        ...
+                        "ci_status": "success",
@@ -453,7 +500,7 @@ fn merge_and_close_use_authenticated_json_endpoints() {
-    cmd_merge(&cli, Some("feature/merge"), false).expect("merge");
+    cmd_merge(&cli, Some("feature/merge"), false).expect("merge");

Because cmd_merge now fetches branch details, the mock server in the existing test needs to serve that endpoint. A new route for /git/api/forge/branch/11 returns a branch with ci_status: "success" and a single successful CI run, keeping the test green. The call site is updated to pass false for the force parameter.

Add test: merge is blocked when CI has failed

Intent: Verify that `cmd_merge` returns an error containing `--force` when CI status is `failed` and `force` is `false`, and that no merge HTTP call is made.

Affected files: crates/ph/src/tests.rs

Evidence
@@ +513,226 @@
+#[test]
+fn merge_refuses_failed_ci_without_force() {
+    ...
+    let err = cmd_merge(&cli, Some("feature/failing-merge"), false).expect_err("merge blocked");
+    assert!(err.to_string().contains("--force"));
+    assert_eq!(merge_auth.load(Ordering::SeqCst), 0);
+}

The merge_refuses_failed_ci_without_force test stands up a mock server with branch 12 whose ci_status is "failed". It calls cmd_merge with force: false and asserts:

  • The call returns an error.
  • The error message contains "--force" so the user knows how to override.
  • The merge_auth counter remains at 0, confirming the merge endpoint was never hit.

This directly validates the happy path of the guardrail.

Add test: `--force` bypasses the CI guard

Intent: Verify that passing `force: true` allows a merge to proceed even when CI status is `failed`.

Affected files: crates/ph/src/tests.rs

Evidence
@@ +513,226 @@
+#[test]
+fn merge_force_overrides_failed_ci_guard() {
+    ...
+    cmd_merge(&cli, Some("feature/forced-merge"), true).expect("forced merge");
+    assert_eq!(merge_auth.load(Ordering::SeqCst), 1);
+}

The merge_force_overrides_failed_ci_guard test uses branch 13 with ci_status: "failed" but calls cmd_merge with force: true. It asserts:

  • The call succeeds (no error).
  • The merge endpoint was hit exactly once (merge_auth counter is 1).

Together with the previous test, this provides full coverage of both the block and override paths of ensure_merge_ci_guard.

Diff