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
7 changes: 7 additions & 0 deletions .changes/pr-pipeline/perf-postmerge-attempt-carry-2b9f6a17.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Changed

- pr-merge's `post-merge-dispatch` carries the completed run attempts its post-merge check read from one tick to the next, as `attempts.json` in the `pr-merge-post-merge-report` artifact it already keeps. The check step copies the last trusted run's file (read by `last-report.mjs`) to the path it names in `BABYSIT_POSTMERGE_ATTEMPTS`, and keeps the file the check leaves, whether the check runs or an idle tick reuses the report. On the 2026-10-28 ticks this spares the 8 attempt reads each check made, of the job's 50 to 57 GITHUB_TOKEN calls. The `merge` job's check names no file and reads as before. Permissions are unchanged.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Changed

- `/source-control:babysit-prs`: the post-merge check (`babysit_postmerge.py`) answers an earlier attempt of a re-run full run from the file named in `BABYSIT_POSTMERGE_ATTEMPTS` before reading it, when the file holds that run's attempt, completed, at the same head SHA. A completed attempt never changes, so the answer decides as the read would. The check writes back the completed attempts it used; a check that looks up no attempt leaves the file as it was. A missing or malformed file (another version or repository, a key that does not match its entry, an attempt not completed, a field the reader does not use) is no file, and every attempt is read as before. Without the variable nothing changes.
20 changes: 19 additions & 1 deletion .github/scripts/pr-merge/merge-job.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ test("the early check fails red on a rate-limit stop, after its outputs and repo
// check.
const LAST_SHA = "d".repeat(40);
const lastReport = () => ({ readable: true, ok: true, needsDispatch: false, shared: { mainSha: "a".repeat(40) } });
const runIdleCheck = ({ last, code = "0", settle = 0, exit = 3 } = {}) => {
const runIdleCheck = ({ last, code = "0", settle = 0, exit = 3, attempts } = {}) => {
const dir = tempDir("pr-merge-idle-");
try {
const bin = join(dir, "bin");
Expand All @@ -560,6 +560,7 @@ const runIdleCheck = ({ last, code = "0", settle = 0, exit = 3 } = {}) => {
writeFileSync(join(dir, "post-merge-last/post-merge-code"), `${code}\n`);
writeFileSync(join(dir, "post-merge-last/post-merge-code-sha"), `${LAST_SHA}\n`);
writeFileSync(join(dir, "post-merge-last/lane-records.json"), "the last run's");
if (attempts !== undefined) writeFileSync(join(dir, "post-merge-last/attempts.json"), attempts);
}
const output = join(dir, "output");
const result = spawnSync("bash", ["-c", runScript("Check earlier lane merges", stepsOf(jobBlock("post-merge-dispatch")))], {
Expand All @@ -578,6 +579,8 @@ const runIdleCheck = ({ last, code = "0", settle = 0, exit = 3 } = {}) => {
keptCode: kept("post-merge-code"),
keptSha: kept("post-merge-code-sha"),
records: kept("lane-records.json"),
attempts: kept("attempts.json"),
handed: existsSync(join(dir, "post-merge-attempts.json")) ? readFileSync(join(dir, "post-merge-attempts.json"), "utf8") : null,
};
} finally {
rmSync(dir, { recursive: true, force: true });
Expand Down Expand Up @@ -619,6 +622,21 @@ test("a tick runs the check itself for no report, a refusal, a crash or a check
}
});

// The attempt carry (babysit_postmerge.py ATTEMPTS_ENV): the last run's
// attempts file is handed to the check at the path the step names, and kept
// for the next tick whether the check runs or an idle tick reuses the report.
test("post-merge-dispatch hands the last run's attempts to the check and keeps them", () => {
const file = "${{ runner.temp }}/post-merge-attempts.json";
assert.ok(step("Check earlier lane merges", stepsOf(jobBlock("post-merge-dispatch"))).includes(`BABYSIT_POSTMERGE_ATTEMPTS: ${file}\n`));
for (const options of [{}, { settle: 1 }]) {
const run = runIdleCheck({ last: lastReport(), attempts: "carried", ...options });
assert.equal(run.ran, options.settle === 1);
assert.equal(run.handed, "carried");
assert.equal(run.attempts, "carried");
}
assert.equal(runIdleCheck({ last: lastReport(), settle: 1 }).attempts, null);
});

test("post-merge-dispatch reads the last run's report before its check, from a trusted run alone", () => {
const list = stepsOf(jobBlock("post-merge-dispatch"));
const names = list.map((s) => s.name);
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/pr-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,17 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
BABYSIT_POSTMERGE_LANE_RECORDS: ${{ runner.temp }}/lane-records.json
BABYSIT_POSTMERGE_ATTEMPTS: ${{ runner.temp }}/post-merge-attempts.json
REPO: ${{ github.repository }}
run: |
set -euo pipefail
last=$RUNNER_TEMP/post-merge-last
scripts=.base/plugins/source-control/skills/babysit-prs/scripts
code='' code_sha=''
# The completed attempts the last check read, which the check
# answers with no read (babysit_postmerge.py ATTEMPTS_ENV); kept
# below whether or not the check runs.
[ ! -f "$last/attempts.json" ] || cp "$last/attempts.json" "$RUNNER_TEMP/post-merge-attempts.json"
# An idle tick: the last run's report, from a check that passed
# (exit 0 or 5), stands while the lane room's reuse rule keeps it
# and nothing else the check reads has moved (settle --idle-tick).
Expand Down Expand Up @@ -275,6 +280,7 @@ jobs:
# A reused report keeps the commit whose code made it.
[ -z "$code_sha" ] || echo "$code_sha" >"$kept/post-merge-code-sha"
[ ! -f "$RUNNER_TEMP/lane-records.json" ] || cp "$RUNNER_TEMP/lane-records.json" "$kept/lane-records.json"
[ ! -f "$RUNNER_TEMP/post-merge-attempts.json" ] || cp "$RUNNER_TEMP/post-merge-attempts.json" "$kept/attempts.json"
# A rate-limit stop fails the job red once the outputs are written.
[ "$code" != 75 ] || exit 1

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@
_LANE_RECORD_NAME = re.compile(
r"pr-merge-(?:dispatched-[1-9][0-9]{0,15}|rerun-[1-9][0-9]{0,15}-attempt-2)"
)
# A completed attempt never changes, so pr-merge.yml's post-merge-dispatch
# names a file here holding the earlier attempts of re-run full runs that the
# last check read (`_Context._attempt`), carried in its kept report. An entry
# answers with no read only for the same run, attempt and head SHA, completed;
# the check writes back the completed attempts it used. Any other file is no
# file, and every attempt is read as before.
ATTEMPTS_ENV = "BABYSIT_POSTMERGE_ATTEMPTS"
ATTEMPTS_VERSION = 1
_ATTEMPT_KEY = re.compile(r"[1-9][0-9]{0,15}:[1-9][0-9]{0,2}")
# With `--lane-merges-only`, a red attempt 1 of a lane-dispatched full run
# created within red-main.confirm-wait-minutes plus this, with a newly failing
# check that demotes (`_Context.regressions`), gets that re-run even when no
Expand Down Expand Up @@ -804,7 +813,8 @@ def _runs(
return [r for r in runs if r.created_at >= since and r.head_branch == branch]


def _attempt(owner: str, name: str, run_id: int, attempt: int) -> Run | None:
def _attempt(owner: str, name: str, run_id: int, attempt: int) -> Any:
"""The attempt's payload as the API returns it, or None on a 404."""
args = ["api", f"repos/{owner}/{name}/actions/runs/{run_id}/attempts/{attempt}"]
try:
proc = gh_capture(args)
Expand All @@ -817,11 +827,62 @@ def _attempt(owner: str, name: str, run_id: int, attempt: int) -> Run | None:
f"run {run_id} attempt {attempt}: {(proc.stderr or '').strip()}"
)
try:
return Run.from_api(json.loads(proc.stdout))
return json.loads(proc.stdout)
except ValueError as exc:
raise PostMergeReadError(f"run {run_id} attempt {attempt}: {exc}") from exc


def _attempt_fields(payload: Any) -> dict[str, Any]:
"""The fields of an attempt payload `Run.from_api` reads, and no others."""
item = json_object(payload)
return {
**{
key: item.get(key)
for key in (
"id",
"run_attempt",
"status",
"conclusion",
"head_sha",
"head_branch",
"created_at",
"html_url",
)
},
"actor": {"login": json_object(item.get("actor")).get("login")},
"triggering_actor": {
"login": json_object(item.get("triggering_actor")).get("login")
},
}


def load_attempts(path: str, repo: str) -> dict[str, dict[str, Any]]:
"""The completed attempts in the file at `path`, by `<run id>:<attempt>`.
Raises `ValueError` for any file of another shape, so none of it is used."""
data = json_object(json.loads(Path(path).read_text(encoding="utf-8")))
attempts = data.get("attempts")
if (
data.get("version") != ATTEMPTS_VERSION
or data.get("repo") != repo
or not is_json_object(attempts)
):
raise ValueError("an unexpected shape")
for key, entry in attempts.items():
try:
run = Run.from_api(entry)
except PostMergeReadError as exc:
raise ValueError(f"{key!r}: {exc}") from exc
if not (
_ATTEMPT_KEY.fullmatch(key)
and key == f"{run.id}:{run.attempt}"
and run.status == "completed"
and _FULL_SHA.fullmatch(run.head_sha)
and entry == _attempt_fields(entry)
):
raise ValueError(f"{key!r}: an entry of another shape")
return attempts


# --- Judging runs -------------------------------------------------------------------

_FULL_SHA = re.compile(r"[0-9a-f]{40}")
Expand Down Expand Up @@ -1132,6 +1193,13 @@ class _Context:
lane_proved: dict[str, dict[str, Any]] = field(default_factory=dict)
# Carried records whose sender failed `_lane_sender`: never written back.
lane_rejected: set[str] = field(default_factory=set)
# The attempts file (`ATTEMPTS_ENV`), its attempts once loaded, and the
# completed attempts this check used, which `save_attempts` writes.
attempts_path: str | None = field(
default_factory=lambda: os.environ.get(ATTEMPTS_ENV) or None
)
attempts_carried: dict[str, dict[str, Any]] | None = None
attempts_used: dict[str, dict[str, Any]] = field(default_factory=dict)

def dispatch_runs_through(
self, commit: str, merged_at: datetime, deadline: datetime
Expand Down Expand Up @@ -1940,7 +2008,60 @@ def outcome(self, run: Run, *, attributable: bool) -> tuple[str, str]:
return self.outcome_cache[key]

def _attempt(self, run: Run, n: int) -> Run | None:
return run if run.attempt == n else _attempt(self.owner, self.name, run.id, n)
"""Attempt `n` of `run`: the listed run itself, a completed attempt
the last check carried for the same run and head SHA, or a read."""
if run.attempt == n:
return run
key = f"{run.id}:{n}"
payload = self._carried_attempts().get(key)
if payload is None or Run.from_api(payload).head_sha != run.head_sha:
payload = _attempt(self.owner, self.name, run.id, n)
if payload is None:
return None
found = Run.from_api(payload)
if (
found.status == "completed"
and f"{found.id}:{found.attempt}" == key
and found.head_sha == run.head_sha
):
self.attempts_used[key] = _attempt_fields(payload)
return found

def _carried_attempts(self) -> dict[str, dict[str, Any]]:
if self.attempts_carried is None:
self.attempts_carried = {}
path = self.attempts_path
if path and Path(path).exists():
try:
self.attempts_carried = load_attempts(
path, f"{self.owner}/{self.name}"
)
except Exception as exc: # noqa: BLE001 (any unusable file is no file)
text = re.sub(r"[\r\n%]", " ", f"{type(exc).__name__}: {exc}")
print(f"attempts not used: {text[:300]}", file=sys.stderr)
return self.attempts_carried

def save_attempts(self) -> None:
"""Write the attempts file: the completed attempts this check used.
Untouched when the check looked up no attempt. Best effort: a
failure is logged, and the file is left as it was."""
path = self.attempts_path
if not path or self.attempts_carried is None:
return
payload = {
"version": ATTEMPTS_VERSION,
"repo": f"{self.owner}/{self.name}",
"attempts": self.attempts_used,
}
target = Path(path)
tmp = target.with_name(f"{target.name}.{os.getpid()}.tmp")
try:
tmp.write_text(json.dumps(payload), encoding="utf-8")
os.replace(tmp, target)
except OSError as exc:
print(f"attempts not written to {path}: {exc}", file=sys.stderr)
with contextlib.suppress(OSError):
tmp.unlink(missing_ok=True)

def _outcome(self, run: Run, attributable: bool) -> tuple[str, str]:
first = self._attempt(run, 1)
Expand Down Expand Up @@ -2397,6 +2518,7 @@ def lane_prs_at(sha: str) -> str | None:
finally:
if ctx is not None:
ctx.save_lane_records(merged_since)
ctx.save_attempts()
return PostMergeVerdict(
True,
merges=states,
Expand Down
Loading
Loading