Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions doc/howto/QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ The last rung is the only one that needs credentials, and the only one where a c

### What you need

A **scratch repository** you are willing to have commits pushed to and branches force-moved on. Do not point this at anything you care about — the merger pushes to the target branch and rewrites the head branch of every change it lands.
A **scratch repository** you are willing to have commits pushed to. Do not point this at anything you care about — the merger pushes every landed change straight to the target branch.

A **token** for it, scoped to that one repository.

Expand All @@ -262,7 +262,7 @@ For a **fine-grained** token, grant these repository permissions. Each is here b
| Permission | Access | Needed by |
|---|---|---|
| Metadata | Read | mandatory on every fine-grained token; GitHub adds it for you |
| Contents | Read and write | the git merger — clone, fetch, push to the target branch, and force-move each landed change's head branch |
| Contents | Read and write | the git merger — clone, fetch, and push to the target branch |
| Pull requests | Read | the change provider reads pull request metadata, and `land -pr` reads the head commit |
| Pull requests | Read **and write** | only for `make demo-requests`, which opens pull requests |
| Actions | Read and write | the build runner — dispatch a run per batch, poll it, cancel it |
Expand Down Expand Up @@ -306,11 +306,9 @@ It opens real pull requests, enqueues each as it is created, and watches them la
demo-queue/3 https://github.com/behinddwalls/sq-demo/pull/524 25s accepted → … → landed
```

All three show **Merged** on GitHub and their commits are on `main`.
Their commits land on `main`, but the pull requests themselves stay open: nothing here calls GitHub's API to close them, and `SQUASH_REBASE` rewrites the commits, so a pull request's original head never becomes reachable from `main` for GitHub to notice on its own. Closing them requires a separately driven automation, which nothing in this stack provides.

Worth understanding *why* they show merged, because nothing called an API to close them. A provider marks a change merged once its head commit is reachable from the target branch. `SQUASH_REBASE` rewrites the commits, so a pull request's original head is nowhere in `main` — and `updateHeadBranch` therefore moves its branch to the commit it landed as. GitHub draws its own conclusion from that.

`make demo-requests STACKED=true` submits a chain instead, each pull request targeting the previous one's branch. All of them land as one push to `main`, and all of them show as merged.
`make demo-requests STACKED=true` submits a chain instead, each pull request targeting the previous one's branch. All of them land as one push to `main`, and all of them stay open the same way.

**These builds are real.** This rung dispatches your workflow once per speculative batch and polls it to completion, so `landed` here means a build of that batch passed — not that a fake said so. It is the only rung where nothing is faked, and the only one that costs you Actions minutes. [Using real CI](#using-real-ci) below covers what the workflow has to accept and why testing the *combination* is the whole point.

Expand Down Expand Up @@ -405,9 +403,9 @@ Both MySQL services mount **anonymous** volumes, so a stop/start cycle orphans a

**`PROVIDER=github`: the push is rejected on the first try.** Branch protection on `main` — required status checks, or a linear-history or no-force-push rule — applies to the merger like anyone else. Either relax it on the scratch repo or add the token's identity to the bypass list.

**`PROVIDER=github`: the change lands but the pull request stays open.** Two causes, distinguishable in Runway's logs. If the change came from a **fork**, this is expected and permanent: the head branch lives in the contributor's repository, which this stack has no business writing to, and the log says `no head branch on this remote for change`. Otherwise it is **protection on the head branch** blocking the force update, logged as `could not move change head branch`. The land itself succeeded either way — the failure is reported and deliberately not retried, because the push already happened and cannot be undone.
**`PROVIDER=github`: the change lands but the pull request stays open.** Expected under `REBASE` and `SQUASH_REBASE`: nothing here calls GitHub's API to close a pull request, and rewriting its commits leaves its original head unreachable from the target branch, so GitHub has nothing to notice the change by. Closing it requires a separately driven automation, which nothing in this stack provides.

**A change is rejected as stale.** Its head moved after it was submitted, so the commit named is no longer the one under review. Re-submit it. This also happens if you re-land a change that already landed, since landing moved its branch.
**A change is rejected as stale.** Its head moved after it was submitted, so the commit named is no longer the one under review. Re-submit it.

**`grpcurl` reports `target server does not expose service`.** The server registers reflection, but its descriptor references `api/base/change/proto/change.proto` while the generated code registers that file as `change.proto`, so reflection cannot resolve the gateway's descriptor. Use the client CLI, which is what every command here does.

Expand Down
6 changes: 1 addition & 5 deletions runway/extension/merger/git/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ go_library(
"author.go",
"changeref.go",
"git_merger.go",
"headbranch.go",
"objects.go",
],
importpath = "github.com/uber/submitqueue/runway/extension/merger/git",
Expand All @@ -27,10 +26,7 @@ go_library(

go_test(
name = "go_default_test",
srcs = [
"git_merger_test.go",
"headbranch_test.go",
],
srcs = ["git_merger_test.go"],
data = [
"@git",
"@git//:git_receive_pack",
Expand Down
18 changes: 1 addition & 17 deletions runway/extension/merger/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,23 +79,7 @@ Redelivery is safe: once imported, the source head is contained in the target, s

`Merge` commits and reports outputs; `CheckMergeability` runs the identical apply but never pushes, then resets the checkout to discard the local commits and reports empty outputs. A multi-step check commits its intermediate steps locally so it sees the same conflict surface a real merge would.

For a committing merge nothing reaches the remote until every step has applied cleanly (a `PROMOTE` is itself a single atomic fast-forward ref update). A step that fails to apply aborts its in-progress git operation and returns without pushing. With head-branch updates enabled a merge writes two things rather than one — the head branches first, then the target — and only the target's push is the point of no return. If the push fails because the remote tip moved between reset and push, the whole reset/apply/push cycle is retried up to a bounded number of attempts; detection re-fetches the tip and compares it to the SHA the cycle was based on.

## Head branches

A provider decides whether a change merged while it processes the push to the target branch, comparing the change's recorded head against what that push makes reachable. `MERGE` and `PROMOTE` satisfy that on their own — the first keeps the change's head reachable through second-parent history, the second fast-forwards the target to it. The picking strategies do not: `REBASE` and `SQUASH_REBASE` produce new commits, so the change's original head appears nowhere in the target's history and the change is recorded as closed after it has, in every meaningful sense, landed.

Enabling head-branch updates closes that gap. Before the target is pushed, each change's head branch is moved to the commit that change became — its last replayed commit under `REBASE`, its single squashed commit under `SQUASH_REBASE`. The provider records that new head, and when the target push arrives moments later it finds exactly that commit reachable, so it marks the change merged. Nothing here knows what a pull request is: the branch is found by matching the change's pinned head SHA against the remote's branch tips, so the same mechanism serves a GitHub pull request, a GitLab merge request, or a bare branch.

**The ordering is the mechanism.** Moving the head branch *after* the target has been pushed leaves the provider comparing against the pre-merge head at the only moment it looks, and it records the change closed rather than merged — even though the branch ends up on a commit that is demonstrably in the target. Doing both in a single atomic push behaves the same way, since the provider still evaluates the target update against the head it had recorded beforehand. Only a separate, earlier push works.

Three cases are declined rather than guessed at. A change whose head matches **no branch** on this remote is normally one proposed from a fork, whose branch lives in another repository and is not this merger's to move — such a change lands normally. A head matching **several branches** is ambiguous, and the URI does not say which one the change was proposed from, so rewriting a guess risks clobbering an unrelated branch. The **target branch itself** is never a candidate, so a change whose head coincides with the target tip cannot make the merger rewrite the branch it just landed on.

Each push carries a lease against the SHA the change's URI pinned, so an author who pushes in the window between reading the remote's branches and updating them fails the lease instead of losing their work. A failure to move a branch fails the merge, before the target is pushed: landing a change while knowing its head could not be moved produces exactly the half-merged state the option exists to prevent. The three declined cases above are not failures and do not stop the merge.

A branch a failed attempt already moved is remembered for the next one. Once moved, it no longer sits at the SHA the URI pinned, so a retry could not find it by matching tips and would strand it on a commit that never landed; the attempt's resolved branch and the value the next lease must name are carried forward instead.

Off by default — moving a branch the merger was not asked to move is a surprise unless a deployment opted in.
For a committing merge nothing reaches the remote until every step has applied cleanly (a `PROMOTE` is itself a single atomic fast-forward ref update). A step that fails to apply aborts its in-progress git operation and returns without pushing, so the target push is the sole point of no return. If the push fails because the remote tip moved between reset and push, the whole reset/apply/push cycle is retried up to a bounded number of attempts; detection re-fetches the tip and compares it to the SHA the cycle was based on.

## Failure classification

Expand Down
Loading
Loading