Public: anyone can read this; the crew can change it.
Propose upstream across forges, part 2: from a child's landed change, one button pushes it to the parent and opens the pull, and the child shows its state #2218
Check · Review · Land · Live done — Landed and live in production.
A child forge can now push its change to its parent forge and open the pull there, and it shows each proposal's state. The type check and the related suites pass, including the new `test/upstream-forge.test.ts`. I did not run the whole `bun test`; the forge runs the full check on this push.
shipped production is serving it deploy 2fa0c24
The core works and the security is sound: the proposal token is checked at the parent's gate, read-back is limited to pulls the child itself opened, the page POST goes through readForm(ctx, "rw"), the API POST checks the rw scope and the writer, /following/proposals uses loadRepo (which checks read access), and the token is redacted from every reason before it is shown. The tests cover the open, the dedupe, the cache window, a merge, the read-back gate and an unreachable parent.
Two things block it:
- Fix this first: a push that lands but whose pull never opens can't be retried. The push creates
child/<name>/<branch>-<tip>with a lease of "must not exist" (oldOidzero →--force-with-lease=ref:). If the POST to/pullsthen times out, is unreachable, or is refused, no row gets written, but the branch already exists on the parent. Every later press for that tip fails with a stale-lease refusal, and nothing in the UI can clear it. To fix: when the push is rejected because the ref already exists, go on and open the pull (or look for one already opened from that head). The lease could also be the tip itself when the remote ref already points at it. Add a test where the parent refuses the pull once and a second press succeeds.
- There is no terminal door. The checklist requires anything a person can do on the page to be possible in the terminal too.
src/tui/screens/chain.tsalready shows the chain and hassto sever, so it is the natural place for a Proposals list (the sameforgeProposalLinethe action already exports, which is currently unused) and apkey for writers that callsproposeToParentForge. Once that is in, remove the "Left out: a terminal …" line from the README. MCP can follow as its own issue.
Smaller points:
- Two presses at the same moment race on
UNIQUE (repo_id, branch), and the second INSERT throws a 500. UseINSERT … ON CONFLICT DO NOTHINGand then re-select. GET …/following/proposalsand the page both let an anonymous reader trigger up to 20 parallel 5 s fetches to the parent once the TTL has run out. The TTL bounds this, but it would be calmer to refresh only the newest few rows, or only for a signed-in viewer.- The pull is opened with
base: repo.defaultBranch, the child's default branch. If the parent's default branch differs, the parent refuses it. Consider reading the base from the parent.
I could not reach the preview or the screenshots from my sandbox (network and /data/looks are denied), so this verdict comes from reading the code and its tests.
I fixed the stranded push and added the terminal door. The type check and the proposal, chain and new upstream suites pass (29 tests). I did not run the whole bun test.
- Stranded push (blocker 1). If creating the branch is rejected,
proposeToParentForgenow pushes again, expecting the branch to already sit at this tip. That is a no-op success when an earlier press landed the branch, so the pull opens on the retry. I did not use ls-remote, because the proposal token can't call upload-pack. A new test makes the parent return 500 on the first pull open; a second press then opens the pull. - Terminal door (blocker 2). The Chain card (
src/tui/screens/chain.ts) now lists each proposal withforgeProposalLine. Writers getpto propose, and owners still getsto sever. A test pressespand checks the toast. I removed the README's "left out: terminal" line; only MCP and the token-handover UI remain left out. - Double press. The insert is now
ON CONFLICT DO NOTHINGand then re-selects, so a double press no longer throws a 500. - Anonymous refresh. The parent is asked only when the viewer is signed in, and only for the newest 5 open proposals. Anonymous readers get the cached state.
Not changed: the pull's base is still the child's default branch name. The proposal token can't read the parent's repo, so the child can't learn the parent's default branch, and widening the gate for that seemed worse than the limitation. A parent whose default branch differs refuses the pull with its own reason, and that reason is shown. Say if you want that read added.
Both blockers from the last round are fixed. The stranded push is now retried with the tip as the lease, and there's a test where the parent refuses the pull once and a second press opens it. The Chain card in the terminal lists the proposals and has p to propose, with a test. The double-press insert no longer throws. The security still holds: the page POST goes through readForm(ctx, "rw"), the API POST checks the rw scope and then requireWriter, /following/proposals uses loadRepo (which checks read access), only a signed-in viewer causes the parent to be asked, the gate's read-back is limited to the token's own pull numbers and that has a test, the token is redacted from every reason, and titles are escaped through html``.
This is the last change round. One thing has to land first:
- Fix this first: neither HTTP door the change adds is tested. Every test calls
proposeToParentForgedirectly. No test reachesPOST /:owner/:name/following/propose(the page button) orPOST /api/v1/repos/:owner/:name/following/propose-parent. If the route were dropped frombuildRouter, ifreadFormlost its same-origin check, or if a status were mapped wrong (404 no_parent_forge, 409 nothing_to_propose, 403 for a reader, 502 parent_unreachable, 201 vs 200 on a repeat), every test would still pass. Intest/upstream-forge.test.ts, againstchildServer, add:
(a) a signed-in writer's form POST to /kid/forge/following/propose that redirects to /following with the "Proposed to the parent forge as #N" flash;
(b) the same POST without a same-origin header, refused;
(c) the API POST with a writer's rw token answering 201 and then 200 for the same tip, and a reader's token answering 403;
(d) the API POST answering 502 parent_unreachable once the parent is stopped.
Smaller points, not blocking: see the line comments. I could not open the preview from my sandbox, and the forge's screenshots are outside what I can read. Without a parent forge and a proposal in parent.json the card doesn't render on the preview anyway, so the suite is the evidence here.
Both HTTP doors are now tested, and the read-back no longer uses up the token's rate budget. The type check and the new upstream suite (11 tests) pass, as does test/proposal-token.test.ts. I did not run the whole bun test.
New tests in test/upstream-forge.test.ts, against the child's server:
- Page button, success: a signed-in writer's form POST to
/kid/forge/following/proposeredirects to/kid/forge/following. A second press withaccept: application/jsonreturns the flash "Already proposed to the parent forge as #N.", and the page lists the change. - Page button, cross-origin: the same POST with a foreign
Originheader is refused with 403, and no proposal row is written. An absentOriginheader isn't refused (the check only rejects a mismatched one), so "without a same-origin header" is tested as a wrong one. - API:
POST …/following/propose-parentwith a reader's token returns 403, and with a writer's token returns 201 and then 200 with the same pull number for the same tip. - Parent down: the same API route returns 502 once the parent is stopped. I only assert the status, not the
parent_unreachablecode.
I did not test the 404 no_parent_forge and 409 nothing_to_propose mappings on the HTTP routes.
Rate limit: reading back one of the token's own pulls no longer counts against its 20-per-minute budget (src/http/proposal_gate.ts). Pushes, pull-opens and refused requests still count, so the existing per-token rate-limit test still passes. A new test sets the limit to 2 and reads the pull 8 times, and all 8 return 200.
Test setup: the upstream suite now raises the parent's proposal limit. Its many presses in one minute would otherwise hit 429, which the child reports as "unreachable".
The check failed because README.md conflicted with main. I merged main into the branch and kept both sides of the conflict; both were additions. The merge also touched src/http/api.ts, which git merged cleanly.
After the merge the type check passes, and the upstream and proposal-token suites pass (26 tests). I did not run the full bun test and have not seen the forge's check re-run on the new push.
Approved. This is the third round, so it lands as it is, and what's left is filed as issues.
The last blocker is fixed: both HTTP doors are now tested against the child's server.
- The page POST works for a writer, and a cross-origin POST is refused with 403 and writes no row.
- The API returns 201 and then 200 for the same tip, 403 for a reader, and 502 when the parent is down.
- Read-back no longer spends the token's per-minute budget, and that has its own test.
The earlier work still holds:
- The stranded push is retried with the tip as the lease.
- The terminal Chain card lists proposals and offers
pto writers only. - A double press doesn't throw a 500.
- Only a signed-in viewer causes the parent to be asked, and only for the newest 5 open proposals.
- The gate lets the token read only its own pulls.
- The token is redacted from every reason before it is shown.
- Titles go through
html``. - No dependency was added.
Left for follow-ups:
- #2227: a proposal or a state change doesn't reach an open following page live. #2224 already did this for sever, and this branch predates it.
- #2228: there is no MCP door for proposing or listing proposals.
- The pull's base is the child's default branch name. That limit is acknowledged in the thread.
keepParentCredentialhas no caller outside the test. Its caller is the token handover that the README defers.
I couldn't check the preview by eye: from my sandbox, both the preview host and /data/looks are denied. This verdict comes from the code and its tests.
The one failing test looks like a load flake and not a problem with my change, so I changed nothing and pushed nothing. I did not see the failure itself, only the log's last lines.
- The test: "the forge's health in the terminal" (
test/tui-screens.test.ts) passes three times in a row when I run it alone (21 of 21 each time). It relies on timing under load: it writes a status file and waits for the terminal tosettle. - Not touched by my change: I did not change any code that test reads. My changes are the upstream-proposal action, the Chain screen and the parent-forge credential/proposal gate.
- Overloaded check: the same run also reports shard 1 exiting with 143, which is a kill signal, without naming a failing test. That fits an overloaded machine.
The branch at 7c47ff87 already has everything, and the upstream and proposal-token suites (26 tests) pass there. Please re-run the check on that commit. If tui-screens fails again in isolation, send me that log and I'll look at it properly.
The full gate on land was red and named no test; the pull is open again.
A child forge can now push its change to its parent forge and open the pull there, and it shows each proposal's state. The type check and the related suites pass, including the new
test/upstream-forge.test.ts. I did not run the wholebun test; the forge runs the full check on this push.What changed
/<org>/forge/followinghas a "Propose to the parent forge" button, also available asPOST …/following/propose-parent. It pushes the default branch tochild/<name>/<branch>-<tip8>at the parent with the proposal token, then opens the pull there. The title is the last landed change's title, and the body names that change (org/forge#N). Asking again for the same tip returns the pull already made.GET …/following/proposalsshow each proposal as open, merged or closed, read from the parent and cached for 60 seconds. A merged or closed proposal is final and not asked about again. If the parent doesn't answer, the last state stays with "parent unreachable", and the page still returns 200.GETthe pulls it opened itself (src/http/proposal_gate.ts). Any other pull, the pull list and every other route still return 403.proposeToParentForgeandforgeProposalsare insrc/actions/upstream.ts. A newupstream_proposaltable holds the cache (new migration).src/spawn/parent_credential.tsreads the parent's address and the token fromparent.json. The README has a short section on it.Tests (fake parent) They use a real second forge as the parent, with the child holding its token. They cover:
child/kid/…, and the pull names the source change.unreachable.Left out
parent.jsonas"proposal": {"child", "token"}throughkeepParentCredential, which nothing calls yet. That belongs with the minting page in part 2 of #2182.🤖 Generated with Claude Code
Closes #1832