feat: tbd_ship — intent-named trunk-based development tool (branch+PR+CI-gated auto-merge) #40

Closed
opened 2026-06-15 19:35:32 +00:00 by mathias · 1 comment
Owner

What

Add a high-level, intent-named tool — working name tbd_ship — that performs the canonical trunk-based loop in one call: branch from base → write/commit change → open PR → wait for CI → auto-merge to main when green → clean up the branch. With conventions enforced inside the tool.

Why

The primitives already exist (file_write_branch, pr_create, pr_merge, branch_list, branch_delete, repo_status, workflow_run_status). What's missing is an intent verb for the act we actually do most: "ship this small, low-risk change to trunk, safely." Today an agent must orchestrate 4–5 mechanism-named calls and remember the conventions each time — so even a one-file change becomes a branch+PR ceremony left dangling for human merge, which is the opposite of trunk-based for a tiny safe change.

This is the same disease just diagnosed for the brain interface (hyperguild #43): mechanism-named primitives with no verb for the actual intent. Same fix, different repo — name the act, encode the convention, enforce the safety.

The load-bearing design choice: CI-green replaces human review

Trunk-based doesn't skip safety — it moves it from a human reading a PR to a machine running checks. So the entire value of tbd_ship depends on the CI gate being real and fail-closed:

  • Merge only when the head SHA's workflow runs are all completed + success (workflow_run_list / workflow_run_status against the PR head SHA).
  • Checks pending/queued/in_progress → do not merge; return PR URL + run status, let caller poll or re-invoke. Never merge on incomplete.
  • Checks red → do not merge; return the failing run. Never merge.
  • No checks configured / no runs for the SHAfail closed to PR-only. Do NOT silently merge. "No CI" must never collapse into "commit straight to main with no safety" — that's precisely the confidently-wrong-agent failure mode the platform exists to prevent (cf. the UFW lockdown and ip rule flush incidents). The trunk shortcut is only safe because CI is the wall; if there's no wall, fall back to ceremony.
  • Respect branch protection: probe branch_protection_get at call time; if protection requires reviews/approvals that auto-merge can't satisfy, fail closed to PR-only with a clear message. Don't assume a fixed config across repos.

Proposed surface

tool: tbd_ship
params:
  owner, name          : repo
  path, content        : the change (single-file to start; multi-file is a follow-up)
  message              : commit message
  base                 : default "main"
  branch               : optional; default derive short-lived name e.g. tbd/<slug>-<shortsha>
  pr_title, pr_body    : PR metadata
  merge_strategy       : default "squash" (tiny PRs → linear history)
  delete_branch        : default true (short-lived branches are the norm)
  ci_timeout_seconds   : optional; if set, poll up to timeout then return pending state
returns:
  { merged: bool, pr_url, merge_sha?, ci_status, reason_if_not_merged }

Convention enforcement inside the tool:

  • short-lived branch naming (reject/normalize long-lived-looking names)
  • never commit directly to base — always via branch+PR even on the auto-merge path (the PR is the CI trigger + audit record, not a review gate)
  • squash-merge default for linear trunk history

Scope

In:

  • tbd_ship composite assembled from existing primitives (no new infra)
  • CI-gate logic (the fail-closed table above)
  • branch-protection probe + graceful PR-only fallback
  • single-file change to start

Out:

  • multi-file atomic ship (follow-up — needs a tree/commit API, bigger change)
  • changing any repo's actual branch-protection config (that's an infra/per-repo decision, not this tool's job)
  • rebase/conflict resolution (if base moved and merge isn't clean → fail closed, return for human)

Acceptance criteria

  • tbd_ship lands a single-file change to main via branch+PR+auto-merge when CI is green
  • Pending CI → returns PR + pending status, does NOT merge
  • Red CI → returns failing run, does NOT merge
  • No CI configured → falls back to PR-only, does NOT merge (explicit test)
  • Branch protection requiring review → PR-only fallback with clear message
  • Short-lived branch auto-named and deleted on successful merge
  • Squash default; linear history on main
  • Unmergeable (base moved, conflict) → fail closed, return for human
  • All four no-merge paths (pending/red/no-ci/protected) covered by tests

Related

  • hyperguild #43 — same intent-vs-mechanism interface problem, brain repo
  • existing primitives this composes: file_write_branch, pr_create, pr_merge, workflow_run_status, branch_protection_get, repo_status

Note on naming

tbd_ship is a working name. If the convention surface grows (e.g. a separate tbd_hotfix, or a tbd_pr for the no-auto-merge case), consider a small tbd_* family rather than overloading flags. Out of scope to decide now.

## What Add a high-level, intent-named tool — working name `tbd_ship` — that performs the canonical trunk-based loop in one call: branch from base → write/commit change → open PR → **wait for CI → auto-merge to main when green** → clean up the branch. With conventions enforced inside the tool. ## Why The primitives already exist (`file_write_branch`, `pr_create`, `pr_merge`, `branch_list`, `branch_delete`, `repo_status`, `workflow_run_status`). What's missing is an **intent verb** for the act we actually do most: "ship this small, low-risk change to trunk, safely." Today an agent must orchestrate 4–5 mechanism-named calls and remember the conventions each time — so even a one-file change becomes a branch+PR ceremony left dangling for human merge, which is the *opposite* of trunk-based for a tiny safe change. This is the same disease just diagnosed for the brain interface (hyperguild #43): mechanism-named primitives with no verb for the actual intent. Same fix, different repo — name the act, encode the convention, enforce the safety. ## The load-bearing design choice: CI-green replaces human review Trunk-based doesn't *skip* safety — it *moves* it from a human reading a PR to a machine running checks. So the entire value of `tbd_ship` depends on the CI gate being real and fail-closed: - Merge **only** when the head SHA's workflow runs are all `completed` + success (`workflow_run_list` / `workflow_run_status` against the PR head SHA). - Checks **pending/queued/in_progress** → do not merge; return PR URL + run status, let caller poll or re-invoke. Never merge on incomplete. - Checks **red** → do not merge; return the failing run. Never merge. - **No checks configured / no runs for the SHA** → **fail closed to PR-only.** Do NOT silently merge. "No CI" must never collapse into "commit straight to main with no safety" — that's precisely the confidently-wrong-agent failure mode the platform exists to prevent (cf. the UFW lockdown and `ip rule flush` incidents). The trunk shortcut is only safe *because* CI is the wall; if there's no wall, fall back to ceremony. - Respect branch protection: probe `branch_protection_get` at call time; if protection requires reviews/approvals that auto-merge can't satisfy, fail closed to PR-only with a clear message. Don't assume a fixed config across repos. ## Proposed surface ``` tool: tbd_ship params: owner, name : repo path, content : the change (single-file to start; multi-file is a follow-up) message : commit message base : default "main" branch : optional; default derive short-lived name e.g. tbd/<slug>-<shortsha> pr_title, pr_body : PR metadata merge_strategy : default "squash" (tiny PRs → linear history) delete_branch : default true (short-lived branches are the norm) ci_timeout_seconds : optional; if set, poll up to timeout then return pending state returns: { merged: bool, pr_url, merge_sha?, ci_status, reason_if_not_merged } ``` Convention enforcement inside the tool: - short-lived branch naming (reject/normalize long-lived-looking names) - never commit directly to `base` — always via branch+PR even on the auto-merge path (the PR is the CI trigger + audit record, not a review gate) - squash-merge default for linear trunk history ## Scope In: - `tbd_ship` composite assembled from existing primitives (no new infra) - CI-gate logic (the fail-closed table above) - branch-protection probe + graceful PR-only fallback - single-file change to start Out: - multi-file atomic ship (follow-up — needs a tree/commit API, bigger change) - changing any repo's actual branch-protection config (that's an infra/per-repo decision, not this tool's job) - rebase/conflict resolution (if base moved and merge isn't clean → fail closed, return for human) ## Acceptance criteria - [ ] `tbd_ship` lands a single-file change to main via branch+PR+auto-merge when CI is green - [ ] Pending CI → returns PR + pending status, does NOT merge - [ ] Red CI → returns failing run, does NOT merge - [ ] **No CI configured → falls back to PR-only, does NOT merge** (explicit test) - [ ] Branch protection requiring review → PR-only fallback with clear message - [ ] Short-lived branch auto-named and deleted on successful merge - [ ] Squash default; linear history on main - [ ] Unmergeable (base moved, conflict) → fail closed, return for human - [ ] All four no-merge paths (pending/red/no-ci/protected) covered by tests ## Related - hyperguild #43 — same intent-vs-mechanism interface problem, brain repo - existing primitives this composes: `file_write_branch`, `pr_create`, `pr_merge`, `workflow_run_status`, `branch_protection_get`, `repo_status` ## Note on naming `tbd_ship` is a working name. If the convention surface grows (e.g. a separate `tbd_hotfix`, or a `tbd_pr` for the no-auto-merge case), consider a small `tbd_*` family rather than overloading flags. Out of scope to decide now.
Author
Owner

Shipped in v0.6.0 (internal/tools/tbd_ship.go).

tbd_ship = branch from base → write the file → open a PR → auto-merge (squash) → delete the branch, in one call. The safety is a pure, fail-closed gate (evaluateShipGate): merge=true only when every workflow run for the PR head commit is completed+success and the base isn't review-protected. Everything else fails closed to PR-only with a reason.

Acceptance criteria → tests:

  • lands a single-file change via branch+PR+auto-merge when CI green — TestTBDShip_GreenCI_Merges
  • Pending CI → no merge — gate matrix (in_progress/queued/mixed) in TestEvaluateShipGate
  • Red CI → no merge — TestTBDShip_RedCI_FailsClosed (+ cancelled in gate)
  • No CI configured → PR-only, no mergeTestTBDShip_NoCI_FailsClosed
  • Branch protection requiring review → PR-only — TestTBDShip_ReviewProtected_FailsClosed
  • Short-lived branch auto-named (tbd/<slug>-<hash>) + deleted on merge — asserted in the green test
  • Squash default (Do:"squash"), linear history
  • Unmergeable (409) → fail closed, PR left open — TestTBDShip_MergeConflict_FailsClosed
  • All four no-merge paths covered

task check green. Also handles new-vs-existing files (fetches the blob sha for updates) and probes branch_protection_get up front (real error fails closed). ci_timeout_seconds polls the head commit's runs to completion; default 0 = snapshot (returns pending immediately after opening the PR, since CI hasn't started yet — set a timeout, e.g. 300, to actually auto-merge in one call).

Brain corroboration for the fail-closed design: agentsquad#36 (non-compiling code, reviewer-approved, merged straight to main with no CI wall).

Out of scope (as stated): multi-file atomic ship, branch-protection config changes, rebase/conflict resolution.

Follow-up filed: re-invoke-to-merge collides on the deterministic branch (a second call with the same change hits "branch exists"). Filing an idempotent resume path separately.

Closing #40.

Shipped in v0.6.0 (`internal/tools/tbd_ship.go`). `tbd_ship` = branch from base → write the file → open a PR → auto-merge (squash) → delete the branch, in one call. The safety is a **pure, fail-closed gate** (`evaluateShipGate`): `merge=true` **only** when every workflow run for the PR head commit is `completed`+`success` **and** the base isn't review-protected. Everything else fails closed to PR-only with a reason. **Acceptance criteria → tests:** - ✅ lands a single-file change via branch+PR+auto-merge when CI green — `TestTBDShip_GreenCI_Merges` - ✅ Pending CI → no merge — gate matrix (`in_progress`/`queued`/mixed) in `TestEvaluateShipGate` - ✅ Red CI → no merge — `TestTBDShip_RedCI_FailsClosed` (+ `cancelled` in gate) - ✅ **No CI configured → PR-only, no merge** — `TestTBDShip_NoCI_FailsClosed` - ✅ Branch protection requiring review → PR-only — `TestTBDShip_ReviewProtected_FailsClosed` - ✅ Short-lived branch auto-named (`tbd/<slug>-<hash>`) + deleted on merge — asserted in the green test - ✅ Squash default (`Do:"squash"`), linear history - ✅ Unmergeable (409) → fail closed, PR left open — `TestTBDShip_MergeConflict_FailsClosed` - ✅ All four no-merge paths covered `task check` green. Also handles new-vs-existing files (fetches the blob sha for updates) and probes `branch_protection_get` up front (real error fails closed). `ci_timeout_seconds` polls the head commit's runs to completion; default 0 = snapshot (returns `pending` immediately after opening the PR, since CI hasn't started yet — set a timeout, e.g. 300, to actually auto-merge in one call). Brain corroboration for the fail-closed design: agentsquad#36 (non-compiling code, reviewer-approved, merged straight to main with no CI wall). **Out of scope (as stated):** multi-file atomic ship, branch-protection config changes, rebase/conflict resolution. **Follow-up filed:** re-invoke-to-merge collides on the deterministic branch (a second call with the same change hits "branch exists"). Filing an idempotent resume path separately. Closing #40.
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mathias/gitea-mcp#40