Sandboxed-Agent Git Relay (host-side push/PR with a GitHub App)
The agent in the sandbox can git commit (commits need no secret). Everything that
needs a credential — push and PR creation — is done by a tiny relay on the host,
reading the same .git through the bind mount. The sandbox never holds a token,
a key, or even a forwarded socket; it cannot push at all.
[sandbox: Claude] commit on claude/<topic> (bind-mounted worktree)
│ (filesystem — no network, no creds; merge signal = "Relay-Merge: yes"
│ trailer in the HEAD commit message, also just a commit)
[host: systemd timer, 60s] relay.mjs:
scan refs/heads/claude/* → diff vs origin/main? → mint 1h App token
→ push exact refspec → ensure PR (idempotent)
→ if HEAD has the Relay-Merge trailer: squash-merge once checks pass
REFUSE: non-claude/* refs, force push (diverged), main
[GitHub] ruleset on main (PR + required check, bypass_actors: []) = server-side backstop
(also the merge gate: an early merge attempt gets 405 → retried next tick)
This is the pattern Anthropic documents for agent deployments ("a proxy outside the
agent's security boundary injects credentials; the agent never sees them") — applied
to git with zero in-sandbox moving parts.
When to use this skill
- Autonomous agent working in an egress-restricted devcontainer must produce PRs unattended
- The project rule is "no plaintext credentials inside the sandbox" (cf. ADR-style secrets strategy)
- NOT when a human pushes anyway (just keep push on the host), and NOT for fork-based workflows
Why a GitHub App (and not the alternatives) — verified 2026-06
| Option |
Verdict |
| GitHub App installation token |
✅ 1-hour expiry, mintable offline from the App private key, down-scopable per token (one repo, contents:write + pull_requests:write), pushes/PRs trigger CI normally, PRs show a bot author |
| Deploy key (SSH) |
repo-wide write forever, cannot create PRs (no API), needs ruleset care; SSH-agent forwarding grants ambient use to the sandbox — capability inside the boundary |
GITHUB_TOKEN in Actions |
PRs it creates get CI runs stuck in an approval-required state ("Approve workflows to run") — kills unattended loops |
| Fine-grained PAT |
static user-bound bearer secret; expiry policy = recurring human toil — but if a repo-scoped, expiring token inside the sandbox is an acceptable trade for having no relay at all, that path is documented in sandboxed-agent-github-token-via-1password (token resolved from 1Password at docker compose up, env only) |
| OIDC for git push |
does not exist — GitHub's OIDC is outbound (Actions → clouds) only |
Setup
- Create the App (human, browser): github.com/settings/apps/new → name like
<project>-relay; Homepage URL = the repo; uncheck Webhook Active; Repository permissions: Contents: Read and write, Pull requests: Read and write — and deliberately no Workflows permission: withholding it means the agent pipeline cannot modify its own CI gate (pushes touching .github/workflows/** get a clean REFUSE; land those with a human-credentialed push — see "Relay policy decisions" and references/github-app-setup.md); "Only on this account". Then Generate a private key (.pem downloads) and Install App → only the target repo.
- Store the key on the host, outside every container mount:
~/.config/<project>-relay/app.pem, dir 700 / file 600. Sanity: openssl rsa -in app.pem -check -noout.
- Record IDs in
config.env next to the key — see references/github-app-setup.md for the config template, the JWT smoke test, and the App ID vs Installation ID trap (the number in the install URL is the installation id; the App ID is on the App settings page; GET /apps/<slug> 404s for private apps, and /user/installations needs an App user token — so verify by minting a JWT and calling GET /app + GET /app/installations).
- Install the relay: references/relay-mjs.md →
~/.config/<project>-relay/relay.mjs. ⚠️ Keep it outside the repo — the repo is writable from the sandbox, so in-repo policy code could be edited by the thing it polices.
- systemd user units: references/systemd-units.md (60s timer; set
AccuracySec=10s or the default 1-minute coalescing turns your 60s timer into ~2min ticks). Validate manually first: set -a; . config.env; set +a; node relay.mjs → expect an idle log line.
- Server-side backstop (defense in depth, also the merge gate): ruleset on main with
bypass_actors: [] and delete_branch_on_merge=true — see the cloudflare-workers-builds-keyless-deploy skill's references/ruleset.md (works standalone).
- Sandbox side: allow
git add/commit/checkout/switch in .claude/settings.json, keep Bash(git push:*) in deny (deny rules hold even in bypassPermissions mode); set a distinguishable git identity in the container (git config --global user.name "Claude (<project> sandbox)"); convention: all agent work on claude/<topic> branches.
- Sandbox reads PR/CI status with unauthenticated curl, not
gh: gh refuses to run without a token even against public repos, and putting GH_TOKEN in the sandbox would break the whole premise. For a public repo, unauthenticated REST is enough (60 req/h/IP): curl -s https://api.github.com/repos/<owner>/<repo>/commits/<branch>/check-runs for CI results, .../pulls?head=<owner>:<branch> for PR state. Add the matching Bash(curl -s https://api.github.com/*) allow entry; the egress firewall already covers GitHub's IP ranges.
Relay policy decisions (encoded in relay.mjs)
- Prefix-only: scans
refs/heads/claude/ and pushes refs/heads/X:refs/heads/X exact refspecs. main is structurally unreachable.
- No force, ever: if the remote branch is not an ancestor of the local tip, log
REFUSE and skip — divergence is for a human to resolve. ⚠️ The usual trigger is the agent --amending or rebasing a commit the relay already pushed — the rewritten SHA is no longer a descendant of the pushed tip, so every tick REFUSEs and the PR is stuck (the relay won't force). Recover forward, never force: git reset --hard <pushed-sha>, then add a fast-forward follow-up commit (--allow-empty if it only needs to re-carry a Relay-Merge: yes trailer). Don't be lured into amending by a git log --format='%(trailers:…)' self-check showing the trailer empty: the relay matches the signal with a multiline regex on the full body (/^Relay-Merge: yes$/m), so a blank line before it or its position among other trailers is irrelevant — git's stricter trailer parser is not what detects it.
- Skip when no diff vs
origin/main (three-dot diff): empty new branches produce no PR. ⚠️ This three-dot diff does NOT detect a squash merge — filtering squash residue is isTipAlreadyMerged's job (see "The squash-merge re-merge loop" below), not this check's.
- Token hygiene: minted per tick only when needed, down-scoped (
repositories: [repo], contents+PR write), passed to git via env + an inline credential helper — never argv, never disk. Prepend -c credential.helper= (empty) first: without that reset, a globally configured gh credential helper silently wins and pushes as the user, not the App.
- Idempotent PR:
GET /pulls?head=owner:branch before POST; reruns log "PR exists".
- Merge only on explicit signal: see next section. No trailer → the relay never merges; the human does.
- No
workflows permission — on purpose: the token is contents+pull_requests only, so GitHub rejects any push touching .github/workflows/** (refusing to allow a GitHub App to create or update workflow ... without 'workflows' permission). This is a feature, not a gap: the agent pipeline cannot modify its own CI gate — the same gate that enforces CI-green-before-merge for the Relay-Merge path. The relay detects that one rejection and logs REFUSE <branch>: workflow-file change needs a human push (returning cleanly, not crashing the tick with exitCode=1 on every run). Land CI changes with a human-credentialed push (a user/gh token carrying the workflow scope): git push origin claude/<branch> → next tick the relay sees remote == local, skips the push, and opens the PR normally. Grant the App workflows: write only if you deliberately want agent-authored workflow edits — it hands the autonomous loop the power to weaken its own gate.
Optional: agent-initiated merge (the Relay-Merge: yes trailer)
By default the human merges every PR. To let the agent close the loop unattended
(merge → deploy → verify), the relay also implements merge delegation — same
philosophy as push: the signal comes from inside the boundary (a commit), the
execution and the policy live outside it.
- Signal: the agent puts
Relay-Merge: yes as a trailer in the HEAD commit
message of the claude/* branch. A trailer on an older commit is void — pushing
more commits requires re-signaling. This makes merge a deliberate, auditable,
per-PR decision recorded in history (the trailer survives in the squash commit).
- Execution: the relay squash-merges the open PR, passing the local tip
sha
so GitHub rejects (409) if the branch moved in between (TOCTOU guard), then
deletes the remote and local branch.
- Safety: CI-green is enforced server-side by the main ruleset — the relay
doesn't need to (and doesn't) check CI itself. Signaling before CI finishes is
safe: GitHub answers 405 ("Required status check ... is expected"), logged as
merge pending, retried next tick. No extra App permission is needed
(contents:write covers the merge endpoint).
- What you give up, knowingly: trailer'd PRs reach main (and any main-triggered
deploy) with no human review. Mitigations: (a) the agent's own convention —
don't add the trailer for risky or uncertain changes; (b) grow the ruleset's
required checks (tests!) as the project matures. Record the governance change in
an ADR; the revert path is "delete
tryMerge" — ruleset, App, and sandbox config
are untouched.
The squash-merge re-merge loop (the one that bites — fix is mandatory)
squash merge does not make the branch's commits ancestors of main; only
their content lands, as one brand-new commit. So the three-dot diff
origin/main...branch stays non-empty after a squash merge, and
hasDiffAgainstMain alone judges the branch "still has unmerged work" forever.
Combine that with tryMerge's post-merge local-branch delete failing whenever the
sandbox still has the branch checked out, and the relay re-pushes → re-PRs →
re-squash-merges the same branch every tick, minting an empty squash commit onto
main (and firing the deploy) each time. This actually happened: 9 empty commits +
9 redundant deploys before it was caught (2026-06-13).
The guard — isTipAlreadyMerged(branch, sha), checked before push: ask GitHub
whether a closed PR with merged_at set and head.sha === the current local tip
already exists. If so, the content is already on main → skip push/PR/merge and
delete the local branch (best-effort). Do not rely on tryMerge's cleanup alone:
it cannot remove a checked-out branch, which is exactly the condition that ignites the
loop. The three-dot hasDiffAgainstMain check stays (it cheaply filters truly-empty
branches without an API call), but it is not the squash filter — this is.
Operational corollaries: (a) after signaling Relay-Merge, have the agent git switch off the branch so the local ref can be reaped next tick; (b) squash residue
already on main (empty commits) can't be removed without rewriting protected history
— leave it, it's inert. The guard makes the loop impossible regardless of (a).
Operations
systemctl --user list-timers <project>-relay.timer # next tick
journalctl --user -u <project>-relay.service -f # logs (one line per action)
systemctl --user start <project>-relay.service # manual one-shot
Log vocabulary: pushed / PR created / PR exists / REFUSE / merge pending #N / merged PR #N / idle.
The human's recurring job is reviewing and merging PRs — governance, not secret
handling. With the Relay-Merge trailer enabled, that becomes per-PR opt-out:
no trailer → human merges, as before.
E2E acceptance test
- In the container:
git checkout -b claude/e2e-test, commit a doc tweak, git checkout main.
- Within ~70s the journal shows
pushed claude/e2e-test + PR created: <url>; PR author is app/<project>-relay.
- CI runs on the PR with no approval prompt and goes green.
- Merge (human) → branch auto-deleted; relay's next tick skips the residue via the
isTipAlreadyMerged guard (a squash leaves the three-dot diff non-empty, so "no diff vs main" is not what saves you — see the squash-merge re-merge loop above); deploy pipeline (e.g. Workers Builds) fires off main.
Merge-delegation variant (proves the trailer path end-to-end):
- In the container, on the open branch:
git commit --allow-empty -m "chore: request merge" -m "Relay-Merge: yes".
- Journal shows
pushed (new tip) → merge pending #N (405 while CI re-runs — this confirms the server-side gate) → next tick merged PR #N, branch deleted both sides.
- The whole loop — commit → push → PR → CI → merge → deploy — ran with zero human steps and zero sandbox credentials.
1---2name: sandboxed-agent-git-relay3description: Let a sandboxed coding agent (Claude Code in a Docker devcontainer with a bind-mounted repo) get its work onto GitHub as branches + PRs — and optionally all the way to MERGE — WITHOUT any credential ever entering the sandbox. A host-side systemd timer detects commits on claude/* branches, mints a 1-hour GitHub App installation token, pushes an exact refspec, opens the PR, and (only when the HEAD commit carries a Relay-Merge trailer) squash-merges it once the ruleset's required checks pass. Policy (branch prefix only, no force, never main, merge-by-explicit-signal) is enforced OUTSIDE the boundary. Use when an autonomous agent loop needs push/PR/merge but the rule is "no plaintext credentials in the sandbox". Covers why a GitHub App beats deploy keys and GITHUB_TOKEN, the App ID vs Installation ID confusion, the gh credential-helper hijack, the squash-merge re-merge loop guard, and reading PR/CI status from the sandbox with unauthenticated curl (gh refuses to run tokenless).4license: MIT5---67# Sandboxed-Agent Git Relay (host-side push/PR with a GitHub App)89The agent in the sandbox can `git commit` (commits need no secret). Everything that10needs a credential — push and PR creation — is done by a tiny relay on the **host**,11reading the same `.git` through the bind mount. The sandbox never holds a token,12a key, or even a forwarded socket; it cannot push at all.1314```15[sandbox: Claude] commit on claude/<topic> (bind-mounted worktree)16 │ (filesystem — no network, no creds; merge signal = "Relay-Merge: yes"17 │ trailer in the HEAD commit message, also just a commit)18[host: systemd timer, 60s] relay.mjs:19 scan refs/heads/claude/* → diff vs origin/main? → mint 1h App token20 → push exact refspec → ensure PR (idempotent)21 → if HEAD has the Relay-Merge trailer: squash-merge once checks pass22 REFUSE: non-claude/* refs, force push (diverged), main23[GitHub] ruleset on main (PR + required check, bypass_actors: []) = server-side backstop24 (also the merge gate: an early merge attempt gets 405 → retried next tick)25```2627This is the pattern Anthropic documents for agent deployments ("a proxy outside the28agent's security boundary injects credentials; the agent never sees them") — applied29to git with zero in-sandbox moving parts.3031## When to use this skill3233- Autonomous agent working in an egress-restricted devcontainer must produce PRs unattended34- The project rule is "no plaintext credentials inside the sandbox" (cf. ADR-style secrets strategy)35- NOT when a human pushes anyway (just keep push on the host), and NOT for fork-based workflows3637## Why a GitHub App (and not the alternatives) — verified 2026-063839| Option | Verdict |40|---|---|41| **GitHub App installation token** | ✅ 1-hour expiry, mintable offline from the App private key, down-scopable per token (one repo, `contents:write` + `pull_requests:write`), pushes/PRs trigger CI normally, PRs show a `bot` author |42| Deploy key (SSH) | repo-wide write **forever**, cannot create PRs (no API), needs ruleset care; SSH-agent forwarding grants ambient *use* to the sandbox — capability inside the boundary |43| `GITHUB_TOKEN` in Actions | PRs it creates get CI runs stuck in an **approval-required** state ("Approve workflows to run") — kills unattended loops |44| Fine-grained PAT | static user-bound bearer secret; expiry policy = recurring human toil — **but** if a repo-scoped, expiring token *inside* the sandbox is an acceptable trade for having no relay at all, that path is documented in `sandboxed-agent-github-token-via-1password` (token resolved from 1Password at `docker compose up`, env only) |45| OIDC for git push | does not exist — GitHub's OIDC is outbound (Actions → clouds) only |4647## Setup48491. **Create the App** (human, browser): github.com/settings/apps/new → name like `<project>-relay`; Homepage URL = the repo; **uncheck Webhook Active**; Repository permissions: `Contents: Read and write`, `Pull requests: Read and write` — and deliberately **no `Workflows` permission**: withholding it means the agent pipeline cannot modify its own CI gate (pushes touching `.github/workflows/**` get a clean `REFUSE`; land those with a human-credentialed push — see "Relay policy decisions" and [references/github-app-setup.md](references/github-app-setup.md)); "Only on this account". Then **Generate a private key** (.pem downloads) and **Install App** → only the target repo.502. **Store the key on the host, outside every container mount**: `~/.config/<project>-relay/app.pem`, dir 700 / file 600. Sanity: `openssl rsa -in app.pem -check -noout`.513. **Record IDs** in `config.env` next to the key — see [references/github-app-setup.md](references/github-app-setup.md) for the config template, the JWT smoke test, and the **App ID vs Installation ID trap** (the number in the install URL is the *installation* id; the App ID is on the App settings page; `GET /apps/<slug>` 404s for private apps, and `/user/installations` needs an App user token — so verify by minting a JWT and calling `GET /app` + `GET /app/installations`).524. **Install the relay**: [references/relay-mjs.md](references/relay-mjs.md) → `~/.config/<project>-relay/relay.mjs`. ⚠️ Keep it **outside the repo** — the repo is writable from the sandbox, so in-repo policy code could be edited by the thing it polices.535. **systemd user units**: [references/systemd-units.md](references/systemd-units.md) (60s timer; **set `AccuracySec=10s`** or the default 1-minute coalescing turns your 60s timer into ~2min ticks). Validate manually first: `set -a; . config.env; set +a; node relay.mjs` → expect an idle log line.546. **Server-side backstop** (defense in depth, also the merge gate): ruleset on main with `bypass_actors: []` and `delete_branch_on_merge=true` — see the `cloudflare-workers-builds-keyless-deploy` skill's `references/ruleset.md` (works standalone).557. **Sandbox side**: allow `git add/commit/checkout/switch` in `.claude/settings.json`, keep `Bash(git push:*)` in `deny` (deny rules hold even in `bypassPermissions` mode); set a distinguishable git identity in the container (`git config --global user.name "Claude (<project> sandbox)"`); convention: all agent work on `claude/<topic>` branches.568. **Sandbox reads PR/CI status with unauthenticated curl, not `gh`**: `gh` refuses to run without a token even against public repos, and putting `GH_TOKEN` in the sandbox would break the whole premise. For a public repo, unauthenticated REST is enough (60 req/h/IP): `curl -s https://api.github.com/repos/<owner>/<repo>/commits/<branch>/check-runs` for CI results, `.../pulls?head=<owner>:<branch>` for PR state. Add the matching `Bash(curl -s https://api.github.com/*)` allow entry; the egress firewall already covers GitHub's IP ranges.5758## Relay policy decisions (encoded in relay.mjs)5960- **Prefix-only**: scans `refs/heads/claude/` and pushes `refs/heads/X:refs/heads/X` exact refspecs. main is structurally unreachable.61- **No force, ever**: if the remote branch is not an ancestor of the local tip, log `REFUSE` and skip — divergence is for a human to resolve. ⚠️ **The usual trigger is the agent `--amend`ing or rebasing a commit the relay already pushed** — the rewritten SHA is no longer a descendant of the pushed tip, so every tick `REFUSE`s and the PR is stuck (the relay won't force). **Recover forward, never force**: `git reset --hard <pushed-sha>`, then add a **fast-forward follow-up commit** (`--allow-empty` if it only needs to re-carry a `Relay-Merge: yes` trailer). Don't be lured into amending by a `git log --format='%(trailers:…)'` self-check showing the trailer empty: the relay matches the signal with a **multiline regex on the full body** (`/^Relay-Merge: yes$/m`), so a blank line before it or its position among other trailers is **irrelevant** — git's stricter trailer parser is not what detects it.62- **Skip when no diff vs `origin/main`** (three-dot diff): empty new branches produce no PR. ⚠️ This three-dot diff does **NOT** detect a squash merge — filtering squash residue is `isTipAlreadyMerged`'s job (see "The squash-merge re-merge loop" below), not this check's.63- **Token hygiene**: minted per tick only when needed, down-scoped (`repositories: [repo]`, contents+PR write), passed to git via env + an inline credential helper — never argv, never disk. **Prepend `-c credential.helper=`** (empty) first: without that reset, a globally configured `gh` credential helper silently wins and pushes as the *user*, not the App.64- **Idempotent PR**: `GET /pulls?head=owner:branch` before `POST`; reruns log "PR exists".65- **Merge only on explicit signal**: see next section. No trailer → the relay never merges; the human does.66- **No `workflows` permission — on purpose**: the token is `contents`+`pull_requests` only, so GitHub *rejects any push touching `.github/workflows/**`* (`refusing to allow a GitHub App to create or update workflow ... without 'workflows' permission`). This is a feature, not a gap: the agent pipeline **cannot modify its own CI gate** — the same gate that enforces CI-green-before-merge for the `Relay-Merge` path. The relay detects that one rejection and logs `REFUSE <branch>: workflow-file change needs a human push` (returning cleanly, *not* crashing the tick with `exitCode=1` on every run). Land CI changes with a **human-credentialed push** (a user/`gh` token carrying the `workflow` scope): `git push origin claude/<branch>` → next tick the relay sees `remote == local`, skips the push, and opens the PR normally. Grant the App `workflows: write` **only** if you deliberately want agent-authored workflow edits — it hands the autonomous loop the power to weaken its own gate.6768## Optional: agent-initiated merge (the `Relay-Merge: yes` trailer)6970By default the human merges every PR. To let the agent close the loop unattended71(merge → deploy → verify), the relay also implements **merge delegation** — same72philosophy as push: *the signal comes from inside the boundary (a commit), the73execution and the policy live outside it.*7475- **Signal**: the agent puts `Relay-Merge: yes` as a trailer in the **HEAD** commit76 message of the `claude/*` branch. A trailer on an older commit is void — pushing77 more commits requires re-signaling. This makes merge a deliberate, auditable,78 per-PR decision recorded in history (the trailer survives in the squash commit).79- **Execution**: the relay squash-merges the open PR, passing the local tip `sha`80 so GitHub rejects (409) if the branch moved in between (TOCTOU guard), then81 deletes the remote and local branch.82- **Safety**: CI-green is enforced **server-side** by the main ruleset — the relay83 doesn't need to (and doesn't) check CI itself. Signaling before CI finishes is84 safe: GitHub answers 405 ("Required status check ... is expected"), logged as85 `merge pending`, retried next tick. No extra App permission is needed86 (`contents:write` covers the merge endpoint).87- **What you give up, knowingly**: trailer'd PRs reach main (and any main-triggered88 deploy) with **no human review**. Mitigations: (a) the agent's own convention —89 *don't add the trailer for risky or uncertain changes*; (b) grow the ruleset's90 required checks (tests!) as the project matures. Record the governance change in91 an ADR; the revert path is "delete `tryMerge`" — ruleset, App, and sandbox config92 are untouched.9394### The squash-merge re-merge loop (the one that bites — fix is mandatory)9596`squash` merge does **not** make the branch's commits ancestors of `main`; only97their *content* lands, as one brand-new commit. So the three-dot diff98`origin/main...branch` **stays non-empty after a squash merge**, and99`hasDiffAgainstMain` alone judges the branch "still has unmerged work" forever.100Combine that with `tryMerge`'s post-merge local-branch delete **failing whenever the101sandbox still has the branch checked out**, and the relay re-pushes → re-PRs →102re-squash-merges the same branch **every tick**, minting an empty squash commit onto103`main` (and firing the deploy) each time. This actually happened: 9 empty commits +1049 redundant deploys before it was caught (2026-06-13).105106**The guard — `isTipAlreadyMerged(branch, sha)`, checked before push:** ask GitHub107whether a **closed PR with `merged_at` set and `head.sha === the current local tip`**108already exists. If so, the content is already on `main` → skip push/PR/merge and109delete the local branch (best-effort). Do **not** rely on `tryMerge`'s cleanup alone:110it cannot remove a checked-out branch, which is exactly the condition that ignites the111loop. The three-dot `hasDiffAgainstMain` check stays (it cheaply filters truly-empty112branches without an API call), but it is **not** the squash filter — this is.113114Operational corollaries: (a) after signaling `Relay-Merge`, have the agent `git115switch` off the branch so the local ref can be reaped next tick; (b) squash residue116already on `main` (empty commits) can't be removed without rewriting protected history117— leave it, it's inert. The guard makes the loop impossible regardless of (a).118119## Operations120121```bash122systemctl --user list-timers <project>-relay.timer # next tick123journalctl --user -u <project>-relay.service -f # logs (one line per action)124systemctl --user start <project>-relay.service # manual one-shot125```126127Log vocabulary: `pushed` / `PR created` / `PR exists` / `REFUSE` / `merge pending #N` / `merged PR #N` / `idle`.128129The human's recurring job is **reviewing and merging PRs** — governance, not secret130handling. With the `Relay-Merge` trailer enabled, that becomes **per-PR opt-out**:131no trailer → human merges, as before.132133## E2E acceptance test1341351. In the container: `git checkout -b claude/e2e-test`, commit a doc tweak, `git checkout main`.1362. Within ~70s the journal shows `pushed claude/e2e-test` + `PR created: <url>`; PR author is `app/<project>-relay`.1373. CI runs on the PR with no approval prompt and goes green.1384. Merge (human) → branch auto-deleted; relay's next tick skips the residue via the `isTipAlreadyMerged` guard (a squash leaves the three-dot diff **non-empty**, so "no diff vs main" is not what saves you — see the squash-merge re-merge loop above); deploy pipeline (e.g. Workers Builds) fires off main.139140**Merge-delegation variant** (proves the trailer path end-to-end):1411421. In the container, on the open branch: `git commit --allow-empty -m "chore: request merge" -m "Relay-Merge: yes"`.1432. Journal shows `pushed` (new tip) → `merge pending #N` (405 while CI re-runs — this confirms the server-side gate) → next tick `merged PR #N`, branch deleted both sides.1443. The whole loop — commit → push → PR → CI → merge → deploy — ran with zero human steps and zero sandbox credentials.