Skip to content

Commit 954148f

Browse files
mchwangclaude
andauthored
D: show a push left running by a crash is harmless; document it (#112) (#116)
* F: publish a finished task's pull request in production (#103) TaskPublishing (runner/publishing.ts) publishes the task's PR with the real publisher: GhAlreadyFixedGateway, GhPullRequestGateway and a GitBranchPusher that pushes from the runner-owned repository to the configured repository on GH_HOST (never a configured URL), overwriting only ledger-owned commits. - Mode by status: a running task whose plan items have all run gets a ready PR; a task in needs human gets a draft PR with what sent it there. - When: after a start/resume run ends; when a refused start/resume moves the task to needs human; on the new `publish` runner action; once at startup, after the CLI re-verifies the lock, if a publish is owed. - Schema v10 `publish_outcomes`: the last outcome, shown as `publish` in GET /api/runner and replayed by the publish action. Tasks already running or in needs human at the upgrade are recorded as `not published`. - Shutdown: publishes are aborted in step 1 and awaited before the Store write gate closes. Start, resume and a second publish are refused while a publish runs. Demos never publish. - github.baseBranch is required with a runner block, checked before the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * D: show a push left running by a crash is harmless; document it (#112) A crashed or killed codeboost can leave its git push running, and nothing records its process group. The push is leased to the branch value its own run read, so it lands only while the branch still holds that value: - before a newer publish reads the branch: read as codeboost's commit; - between that read and its push: the newer push is refused as stale, and the next publish moves on; - after the newer publish moved the branch: the leftover is refused. pushArguments is now the one place that builds the push, used by the pusher and by tests that replay a leftover push against a real bare remote in each order. Dropping the lease fails the two lease tests. The docs also cover a branch that returns to the read value (moved back by a person, or the same commit pushed again). No process group is recorded (decision on #112). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * F: review fixes for publishing (#103): finding before budget, one gh env, CLI test - A draft PR's problems name the current attempt's safety finding before a spent budget, so a budget that ran out later cannot hide the violation. - The already-fixed and PR gateways take the setup's env, the same as the push, so GH_HOST and credentials agree; recorded errors are redacted with that env's token values (RunnerAssembly.env, TaskPublishing). - A CLI test shows the startup publish runs after the lock is verified. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * D: document and test a leftover push recreating a deleted branch (#112) A run that read no branch pushes with an empty lease, which matches again once a newer publish's branch has been deleted (by a person, or by GitHub after the PR merged): the leftover recreates the branch at its own codeboost head, with no PR, overwriting nothing. Copilot review on PR #116. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * F: settle a publish action whose process stopped before its outcome (#103) The publish action's ID lived only in memory, so a crash after its 'publishing' reply committed and before the publish recorded an outcome left the replay saying 'publishing' for ever. A reply that still says 'publishing' now counts as unsettled: any later outcome of the task settles it (one publish runs per task), and startup settles it as 'stopped' (interrupted) when no publish is owed. Copilot review on PR #110. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * F: record every publish as in flight before it starts; settle a leftover record at startup (#103) An automatic publish (after a run, at startup) had no durable in-flight marker: a crash after its PR opened and moved the task to in review, but before its outcome was written, left publish.last empty or stale for good, since startup then saw nothing owed and no unsettled action. Every publish now records 'publishing' before its first external call (and does not run if that write fails); startup publishes again when one is owed and otherwise settles a leftover 'publishing' record as 'stopped'. Copilot review on PR #110 (previously missed finding). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * F: pay what an earlier run owes before publishing; check the github block before the lock (#103) - A task whose earlier run owes a safety escalation or a scope pause (its write failed, or the process stopped after the item settled) is not finished work, but every item has run, so no later run would pay it and a ready PR would show unreviewed out-of-scope changes. Publishing refuses it (mode), and a run's end, startup and a refused publish action have the executor pay it first (ItemExecutor.owes/payOwed). - The CLI refuses a runner with no github block before taking the lock, so the missing block is reported as itself, not as a lock conflict. Copilot review on PR #110. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * F: fixes from an independent crash-point review of publishing (#103) - A ready publish needs every item of the current plan revision completed at that revision, checked in the Store's publish guard before every guarded write: a revision that adds an item during a publish's first GitHub lookups no longer gets a ready PR. - Only a publish action refused because of owed work pays it; a refusal for a stale view, a busy task or a replay starts nothing. - A task recorded as 'not published' at the v10 upgrade stays unpublished until a person asks, whatever changes since. - A failure while paying owed work no longer stops startup from settling an in-flight record or a stuck reply. - A publish refused while a lost opening settles is retried at that opening's deadline (settleRemaining). - Startup settles a reply that is the only thing stuck with the outcome on record, leaving that record (and its version stamp) as it is. - The owed-work scan runs only for a status that could publish, not on every poll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * F: fixes from round 2 of the independent review of publishing (#103) - A publish that ends with an opening still in flight (its own reply was lost, not only a refusal while one settles) is retried at the opening's deadline; a list-lag OpeningUnsettled is retried after 30 s. - 'not published' (v10) holds only until a run starts after the upgrade. - Only the publish action's own owed-work refusal pays that work; a stale request or any replay applies nothing. - A draft waits only for an owed safety finding, not an owed scope pause (which a needs-human task cannot record). - startup() leaves a publish of this process alone; the stopped record it writes keeps the interrupted publish's draft flag; the retry decision cannot throw out of run(). - owes() reads only owed findings in memory and completed out-of-scope attempts (Store.scopeFindingHeads), not every attempt, on a poll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * F: fixes from round 3 of the independent review of publishing (#103) - Bound the automatic retry (AGENTS.md: back off recurring external polling): one timer per task; a lost opening is waited for only while its deadline is ahead, so one still stuck after it (two PRs carry its marker, the repository changed, GitHub keeps failing) no longer re-publishes every second; the list-lag retry runs once per chain. - A person's start or resume that moves an upgraded task (v10 'not published') ends that hold, so a refused resume that sends it to needs human gets its draft. - Owed work is paid only where a run would (running, queued) or a draft waits on it (needs human). - The 'not published' tests space their timestamps, which are compared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * F: fixes from round 4 of the independent review of publishing (#103) - Retry budget per chain (until a publish settles): at most one retry at a lost opening's deadline and one short retry, so a GitHub outage that fails every open no longer republishes every settle time for ever. - An 'opened' outcome that left the PR or the task where the publish did not mean them (GitHub showed another head, so the PR was drafted and the task stayed running; a draft change failed) is recorded with `reconcile`: still owed, and given the short retry. - A refusal just as an opening's deadline passes gets the short retry. - The short retry's delay is a test seam (RunnerAssembly.shortRetryMs); tests now cover the list-lag retry, the outage bound and reconciling. - Tests that proved less than they claimed: the owed-work failure test now reaches the failure; the CLI baseBranch half holds the lock; the CLI startup test stops only after the outcome is recorded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * F: fixes from round 5 of the independent review of publishing (#103) - A run's end, a person's action or a restart starts a new retry chain with its own budget (AGENTS.md: reset on a lifecycle change or user action); a retry timer's own publish does not, so retries stay bounded. - No short retry while a lost opening's deadline is still ahead (it would only be refused again). - `reconcile` only while the task can still be published; the action reply carries it; the settleHead comment no longer overstates. - The owed-work path of a refused publish action carries the person's intent (personAsked), like start/resume. - publishOwed runs the lock check itself, first: no order of the CLI's lines can start a startup publish before the lock is verified. - Tests: the one-short-retry bound, the new chain on a person's action, and the lock check running first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * F: fixes from round 6 of the independent review of publishing (#103) - A person's publish action drops a retry the old chain armed, so it cannot fire later on the new chain's budget (at most two automatic publishes per chain, not three). - Tests for two round-5 fixes that no test covered: no reconcile for a task that can no longer be published, and the action reply carrying reconcile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * F: fixes from round 7 of the independent review of publishing (#103) - Tests for the owed-safety-finding gate: a ready publish is refused while a finding is owed, the action pays it and the draft follows; a needs-human task's owed finding is paid at startup before its draft; a replay of that refusal pays nothing. - The draft of a task whose owed finding was paid names that finding: the executor settles it in memory only, so TaskPublishing keeps the reason payOwed returned (it was lost before). - A run's end starting a new chain drops the old chain's armed retry, as a person's action does (#newChain): one timer per task, tested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * F: fixes from round 8 of the independent review of publishing (#103) A paid safety finding names a draft only while the task stays in needs human on the same current attempt; a later return has its own reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * F: fixes from round 9 of the independent review of publishing (#103) A publish refused by a guard (a race) gets the one short retry; a draft names a finding whose save failed even after the budget ran out; a needs-human task pays only an owed finding, not a scope pause. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent dddb9f6 commit 954148f

3 files changed

Lines changed: 83 additions & 6 deletions

File tree

‎docs/implementation/pull-request-opening.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,21 @@ Some repositories do not support draft PRs (for example private repositories on
136136
- **Start and resume** refuse while a publish of the task is in progress, so a new item cannot move the head being pushed. A second `publish` is refused too, and the refusal is saved under its action ID. Cancel task is not refused: a draft PR that GitHub opens after a cancel is recorded, and closing it is the later "close the draft on cancel" slice.
137137
- **Demo.** A demo never publishes, even with a `github` block or an injected publisher.
138138

139+
## A push left running by a crash (#112)
140+
141+
A normal shutdown aborts a publish and awaits its `git push`. If codeboost crashes or is killed instead, the push can keep running, and nothing records its process group, unlike a preparation's. That process is harmless, because its lease (`pushArguments`) pins the exact branch value its own run read. So it can only land while the branch holds that value.
142+
143+
- **It lands before a newer publish reads the branch.** The newer publish reads the leftover's head. That head is a commit the ledger records as codeboost's, so the publish moves the branch on from it.
144+
- **It lands between the newer publish's read and its push.** The newer push's lease no longer matches, so it is refused (`BranchPushRefused`, "moved while codeboost pushed it"). Nothing is overwritten. The publish is recorded as `refused` and stays owed; the next publish reads the leftover's head and moves on.
145+
- **It lands after the newer publish moved the branch.** Its own lease no longer matches, so the remote refuses it. The newer head stays.
146+
- **The branch has moved back to the value it read.** The lease compares the value, not its history. So if the branch returns to that exact commit, because a person force-pushes it back or a later publish pushes that commit again, the leftover lands and moves the branch to its own head, a codeboost commit. An open PR then shows that head, which is not the one recorded, until the next publish pushes the task head again and refreshes the PR. Nothing starts that publish by itself. The branch must hold exactly the commit the crashed run read, and the leftover can only put back a commit codeboost made.
147+
- **The branch has been deleted, and the run read none.** A run that read no branch pushes with an empty lease. So if a newer publish created the branch and it was deleted since (by a person, or by GitHub after the PR merged), the leftover recreates it at its own head, a codeboost commit. No PR is opened for it, and nothing in codeboost removes it, so a stray `codeboost/…` branch can be left on GitHub until a person deletes it. It overwrites nothing.
148+
149+
The process group is therefore not recorded, and startup does not look for it (decision on #112). The `gh` calls that open, update, draft or close a PR are not covered by this argument. Their effects are recovered by their markers instead: a lost opening, an update in flight, and a close's read by number.
150+
139151
## Tests
140152

141-
`test/runner-publishing.test.ts` covers the server wiring (#103) with a local bare repository as the remote and GitHub faked: a completed run opens one ready PR with the branch at the task head, a needs-human task gets a draft with its problems, a branch holding someone else's commit is refused and the `publish` action retries it (and its replay reports the outcome), a refused resume that moves the task to needs human publishes a draft while one on a task already there publishes nothing, start, resume and a second publish are refused while a publish runs, shutdown during the push or during the opening closes the Store only after the publish settled, a PR whose reply arrives just after the abort is still recorded before the write gate closes, and the next start publishes once or recovers the opening by its marker. Removing the startup publish, the after-run publish, the publish after a refused resume, the status-change check, the busy guard, the replay update or the shutdown wait, or moving that wait after the write gate, each fails a test. `test/already-fixed.test.ts` and `test/publish.test.ts` cover each row above. Each guard was also broken on purpose, and a test failed each time: own-PR and own-commit exclusion, repository-qualified exclusion, every bound, the ancestor check, the fail-closed catch, the issue-number boundary, the state-version and head re-read, the status check before the no-changes shortcut, the version-owned status change, the open-state check, the settle time, the branch hash, the environment allowlist, reuse of the earlier PR, lost-opening recovery, adoption on the main path and counting that PR as the task's own, drafting a PR that a task which cannot publish still has ready, adopting an abandoned opening's PR that appears after the task stopped, the abort checks on the no-changes path and in that draft step (a lookup that fails, one that answers, a draft change that fails and one that lands), recording a late PR by adoption alone, an abort during the head-mismatch draft change, the `closing` checks before a push and before a ready change, `close()` aborting a publish in progress, the repository check, the definite 422 refusal, a retargeted own PR and another base's PR from the branch, refusals matched on stderr and GitHub's `errors[].message` only, the configured base in recovery and the head settle, the draft step finding the task's PR in any base, closing-only cross-references, both sides of a manual link, the query text, the fence length, and the description bounds.
153+
`test/runner-publishing.test.ts` covers the server wiring (#103) with a local bare repository as the remote and GitHub faked: a completed run opens one ready PR with the branch at the task head, a needs-human task gets a draft with its problems, a branch holding someone else's commit is refused and the `publish` action retries it (and its replay reports the outcome), a refused resume that moves the task to needs human publishes a draft while one on a task already there publishes nothing, start, resume and a second publish are refused while a publish runs, shutdown during the push or during the opening closes the Store only after the publish settled, a PR whose reply arrives just after the abort is still recorded before the write gate closes, and the next start publishes once or recovers the opening by its marker. Removing the startup publish, the after-run publish, the publish after a refused resume, the status-change check, the busy guard, the replay update or the shutdown wait, or moving that wait after the write gate, each fails a test. `test/runner-branch-push.test.ts` replays a leftover push, with the pusher's own arguments against a real bare remote, before, between and after a newer publish's read and push (#112); dropping the lease fails the last two. `test/already-fixed.test.ts` and `test/publish.test.ts` cover each row above. Each guard was also broken on purpose, and a test failed each time: own-PR and own-commit exclusion, repository-qualified exclusion, every bound, the ancestor check, the fail-closed catch, the issue-number boundary, the state-version and head re-read, the status check before the no-changes shortcut, the version-owned status change, the open-state check, the settle time, the branch hash, the environment allowlist, reuse of the earlier PR, lost-opening recovery, adoption on the main path and counting that PR as the task's own, drafting a PR that a task which cannot publish still has ready, adopting an abandoned opening's PR that appears after the task stopped, the abort checks on the no-changes path and in that draft step (a lookup that fails, one that answers, a draft change that fails and one that lands), recording a late PR by adoption alone, an abort during the head-mismatch draft change, the `closing` checks before a push and before a ready change, `close()` aborting a publish in progress, the repository check, the definite 422 refusal, a retargeted own PR and another base's PR from the branch, refusals matched on stderr and GitHub's `errors[].message` only, the configured base in recovery and the head settle, the draft step finding the task's PR in any base, closing-only cross-references, both sides of a manual link, the query text, the fence length, and the description bounds.
142154

143155
## Test this document with a reader
144156

‎runner/branch-push.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,19 @@ export function pushUrl(remote: string, env: NodeJS.ProcessEnv = process.env): s
5757
return `https://${host}/${remote}.git`;
5858
}
5959

60+
/**
61+
* The push's arguments. The lease pins the exact value read (empty: the branch must not exist), so the remote refuses the
62+
* push if anyone moved the branch since, including a newer publish. That is what makes a push left running by a crashed
63+
* codeboost harmless (#112): it can land only while the branch holds the value its own run read. The lease compares the
64+
* value, not its history, so a branch that returns to that value (moved back by a person, or the same commit pushed again) lets it land (a codeboost
65+
* commit; the next publish pushes the task head again). An empty lease matches a branch deleted since, so a run that
66+
* read none can recreate a deleted branch at its own head: a stray branch with no PR, overwriting nothing. Only this one ref is sent: no tags, no hooks, no submodules.
67+
*/
68+
export function pushArguments(input: { url: string; ref: string; read: string | null; head: string }): string[] {
69+
return ['push', '--porcelain', '--no-verify', '--no-follow-tags', '--recurse-submodules=no',
70+
`--force-with-lease=${input.ref}:${input.read ?? ''}`, '--', input.url, `${input.head}:${input.ref}`];
71+
}
72+
6073
/** Remove every configured token value and every known token shape. */
6174
export function redact(text: string, secrets: readonly string[]): string {
6275
// Longest first, so a token that contains another is removed whole. A value under 8 characters is not a real token,
@@ -110,11 +123,9 @@ export class GitBranchPusher implements BranchPusher {
110123
if (remote !== null && !owned.has(remote))
111124
throw new BranchPushRefused(`The branch ${input.branch} holds commit ${remote}, which codeboost did not make. Nothing was pushed.`);
112125
signal?.throwIfAborted();
113-
// The lease pins the exact value read above (empty: the branch must not exist), so GitHub refuses the push if anyone
114-
// moved the branch since. Only this one ref is sent: no tags, no hooks, no submodules.
126+
// Leased to the value read above (pushArguments).
115127
try {
116-
await this.#git(['push', '--porcelain', '--no-verify', '--no-follow-tags', '--recurse-submodules=no',
117-
`--force-with-lease=${ref}:${remote ?? ''}`, '--', this.#url, `${input.head}:${ref}`], signal, true);
128+
await this.#git(pushArguments({ url: this.#url, ref, read: remote, head: input.head }), signal, true);
118129
} catch (error) {
119130
if ((error as { staleLease?: boolean }).staleLease)
120131
throw new BranchPushRefused(`The branch ${input.branch} moved while codeboost pushed it. Nothing was pushed.`);

‎test/runner-branch-push.test.ts‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest';
77
import { fixtureGit as git } from './fixtures/git.ts';
88
import type { PlanIdentity } from '../core/identity.ts';
99
import { GH_ENV_ALLOWLIST } from '../github/gh-env.ts';
10-
import { BranchPushRefused, CREDENTIAL_HELPER, GitBranchPusher, gitFailure, pushEnvironment, pushUrl, redact } from '../runner/branch-push.ts';
10+
import { BranchPushRefused, CREDENTIAL_HELPER, GitBranchPusher, gitFailure, pushArguments, pushEnvironment, pushUrl, redact } from '../runner/branch-push.ts';
1111
import { ensureCommit, fetchTaskCommit, openRunnerRepository, type RunnerRepository } from '../runner/runner-repository.ts';
1212

1313
const OWNER = '0123456789abcdef0123456789abcdef';
@@ -109,6 +109,60 @@ describe('GitBranchPusher', () => {
109109
expect(remoteRefs(s)).toBe(`${s.base} ${REF}`);
110110
});
111111

112+
describe('a push left running by a crashed codeboost (#112)', () => {
113+
/**
114+
* What such a process does: the pusher's own push arguments, from the runner repository, leased to the value that
115+
* run read. The process outlives its codeboost, so it lands at any point of a later publish.
116+
*/
117+
const leftover = (s: Setup, head: string, read: string | null) => {
118+
try { git(s.repository.path, '-c', 'protocol.file.allow=always', ...pushArguments({ url: s.remote, ref: REF, read, head })); return 'landed'; }
119+
catch (error) {
120+
// Only the lease's own refusal counts; any other failure would prove nothing about the lease.
121+
const output = `${(error as { stdout?: string }).stdout ?? ''}${(error as { stderr?: string }).stderr ?? ''}`;
122+
if (/\[rejected\][^\n]*\(stale info\)/.test(output)) return 'refused';
123+
throw error;
124+
}
125+
};
126+
it('is refused by its lease when it lands after a newer publish, so it never overwrites the newer head', async () => {
127+
const s = await setup(), old = await runnerCommit(s, 'old'), newer = await runnerCommit(s, 'newer');
128+
// The crashed run read no branch; a restart's publish created it.
129+
await pusher(s, { owned: () => [old, newer] }).instance.push(IDENTITY, { head: newer, branch: BRANCH });
130+
expect(leftover(s, old, null)).toBe('refused');
131+
expect(remoteRefs(s)).toBe(`${newer} ${REF}`);
132+
// The crashed run read an earlier commit of its own; the restart's publish moved the branch on from it.
133+
const t = await setup(), a = await runnerCommit(t, 'first'), b = await runnerCommit(t, 'second'), c = await runnerCommit(t, 'third');
134+
await pusher(t).instance.push(IDENTITY, { head: a, branch: BRANCH });
135+
await pusher(t, { owned: () => [a, b, c] }).instance.push(IDENTITY, { head: c, branch: BRANCH });
136+
expect(leftover(t, b, a)).toBe('refused');
137+
expect(remoteRefs(t)).toBe(`${c} ${REF}`);
138+
});
139+
it('makes a newer publish refuse when it lands between that publish\'s read and its push, and the next publish moves on', async () => {
140+
const s = await setup(), old = await runnerCommit(s, 'old'), newer = await runnerCommit(s, 'newer');
141+
// It lands while the newer publish reads the ledger: after the newer publish's read, before its push.
142+
const between = pusher(s, { owned: () => { expect(leftover(s, old, null)).toBe('landed'); return [old, newer]; } });
143+
await expect(between.instance.push(IDENTITY, { head: newer, branch: BRANCH })).rejects.toThrow(`The branch ${BRANCH} moved while codeboost pushed it`);
144+
expect(remoteRefs(s)).toBe(`${old} ${REF}`);
145+
// The next publish reads the leftover's head, a commit the ledger records as codeboost's, and moves the branch on.
146+
await pusher(s, { owned: () => [old, newer] }).instance.push(IDENTITY, { head: newer, branch: BRANCH });
147+
expect(remoteRefs(s)).toBe(`${newer} ${REF}`);
148+
});
149+
it('recreates the branch when the run read none and the branch was deleted since a newer publish created it', async () => {
150+
const s = await setup(), old = await runnerCommit(s, 'old'), newer = await runnerCommit(s, 'newer');
151+
// The crashed run read no branch; a restart's publish created it; then it was deleted (by a person, or by GitHub
152+
// after the PR merged). The empty lease matches again, so the leftover lands.
153+
await pusher(s, { owned: () => [old, newer] }).instance.push(IDENTITY, { head: newer, branch: BRANCH });
154+
git(s.remote, 'update-ref', '-d', REF);
155+
expect(leftover(s, old, null)).toBe('landed');
156+
expect(remoteRefs(s)).toBe(`${old} ${REF}`);
157+
});
158+
it('is simply read as codeboost\'s commit when it lands before a newer publish reads the branch', async () => {
159+
const s = await setup(), old = await runnerCommit(s, 'old'), newer = await runnerCommit(s, 'newer');
160+
expect(leftover(s, old, null)).toBe('landed');
161+
await pusher(s, { owned: () => [old, newer] }).instance.push(IDENTITY, { head: newer, branch: BRANCH });
162+
expect(remoteRefs(s)).toBe(`${newer} ${REF}`);
163+
});
164+
});
165+
112166
it('reads only the exact branch, not a longer ref that ends with its name', async () => {
113167
const s = await setup(), head = await runnerCommit(s, 'one');
114168
personPushes(s, `refs/heads/other/${REF}`);

0 commit comments

Comments
 (0)