Keep sync PRs building across bot pushes

Three things kept the upstream sync PR (#589) from ever finishing a build:

Scoped dispatches wiped the shared PR. A workflow_dispatch with `packages`
regenerates only those packages from master, and pushing that to
auto/sync-upstream replaced 38 pending updates with one. Scoped runs now
push to their own auto/sync-{upstream,rebuilds}-<packages> branch and PR;
scheduled runs keep the shared branch.

build-approved stopped working after the first bot push. A GITHUB_TOKEN
push creates pull_request runs held for approval but no pull_request_target
run, so approve-pr.yml never saw it: its last run on the branch was the
label itself (2026-09-25T19:26), and each of the next four syncs sat at
action_required. The sync workflows now release the held runs for the
commit they just pushed, from a separate job holding actions: write, and
only for their own bot-authored, same-repo PR while build-approved is on
it.

Each approved push cancelled the in-flight build. Approving the 21:43
sync's build cancelled the label-triggered one still queued on strata and
schist-bin. On auto/sync-* branches a new build now waits for the running
one instead, then reuses its artifacts. The approval script no longer
waits for a lone approved build to start before releasing tests, which a
queued build would have turned into a timeout.
This commit is contained in:
Ryan Hughes committed 2026-09-26 20:01:17 -04:00
1 parent 6df9953d8f
commit 4aca3bdbc7
8 files changed
+363 -9

No files matched your search

+14 -4
View File
@@ -1,7 +1,11 @@
const BUILD = '.github/workflows/build-pr.yml';
const TESTS = '.github/workflows/test.yml';
// pullRequest/action/since default to the pull_request_target event. The
// sync workflows pass them explicitly: GitHub creates no pull_request_target
// run for a GITHUB_TOKEN push, so they release their own pushes' held runs.
module.exports = async function approve({ github, context, core, vouchStatus,
pullRequest = context.payload.pull_request, action = context.payload.action, since,
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), attempts = 36 }) {
// Missing/failed vouch lookups must not become approval. Denouncements
// remain absolute, just as they are in the package build gate.
@@ -9,8 +13,8 @@ module.exports = async function approve({ github, context, core, vouchStatus,
throw new Error(`Cannot approve workflows: vouch status is ${vouchStatus || 'missing'}.`);
}
const expected = context.payload.pull_request;
const eventTime = Date.parse(expected.updated_at);
const expected = pullRequest;
const eventTime = Date.parse(since ?? expected.updated_at);
if (!Number.isFinite(eventTime)) throw new Error('Missing PR event timestamp.');
const approved = new Set();
let precedingBuild;
@@ -47,7 +51,7 @@ module.exports = async function approve({ github, context, core, vouchStatus,
const newestBuild = runs.findLast(run => run.path === BUILD);
if (!newestBuild || !(Date.parse(newestBuild.created_at) >= eventTime) ||
!runs.some(run => run.path === TESTS &&
(context.payload.action === 'labeled' || Date.parse(run.created_at) >= eventTime))) continue;
(action === 'labeled' || Date.parse(run.created_at) >= eventTime))) continue;
if (precedingBuild) {
const { data: run } = await github.rest.actions.getWorkflowRun({
@@ -71,7 +75,13 @@ module.exports = async function approve({ github, context, core, vouchStatus,
await github.rest.actions.approveWorkflowRun({ ...context.repo, run_id: run.id });
approved.add(run.id);
core.info(`Approved ${run.path} run ${run.id} for PR #${expected.number}.`);
if (run.path === BUILD) precedingBuild = run.id;
// Only a newer held build needs this one to take the concurrency slot
// first. A lone build may sit pending behind an in-flight build of an
// older commit (sync branches queue rather than cancel); waiting for it
// to start would time out before the tests run was released.
if (run.path === BUILD && pending.some(other => other.path === BUILD && other.id > run.id)) {
precedingBuild = run.id;
}
if (pending.length === 1) return;
}
throw new Error('Timed out waiting for PR workflows. Remove and reapply build-approved to retry.');
+33
View File
@@ -0,0 +1,33 @@
const approvePrWorkflows = require('./approve-pr-workflows.cjs');
const BOT = 'github-actions[bot]';
// A sync workflow pushes its branch with GITHUB_TOKEN. GitHub holds the
// resulting pull_request runs for approval and, unlike a person's push,
// creates no pull_request_target run, so approve-pr.yml never sees it. The
// sync workflow therefore releases the runs for the commit it just pushed,
// under the same rule approve-pr.yml applies: only while a maintainer's
// build-approved label is on the PR. It acts only on its own bot-authored,
// same-repository PR for the branch and commit it pushed.
module.exports = async function approveSyncPush({ github, context, core,
number, branch, headSha, since, approve = approvePrWorkflows, ...options }) {
if (!Number.isInteger(number) || !branch || !headSha || !since) {
throw new Error('Missing sync PR number, branch, head SHA or push time.');
}
const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: number });
const repository = `${context.repo.owner}/${context.repo.repo}`;
if (pr.user?.login !== BOT || pr.head.repo?.full_name !== repository ||
pr.base.repo?.full_name !== repository || pr.head.ref !== branch) {
throw new Error(`PR #${number} is not ${BOT}'s ${branch} PR in ${repository}; refusing to approve.`);
}
if (pr.state !== 'open' || pr.head.sha !== headSha) {
core.info(`PR #${number} is closed or has moved past ${headSha}; nothing to approve.`);
return;
}
if (!pr.labels.some(label => label.name === 'build-approved')) {
core.info(`PR #${number} has no build-approved label; its runs wait for a maintainer.`);
return;
}
await approve({ github, context, core, vouchStatus: 'bot', pullRequest: pr,
action: 'synchronize', since, ...options });
};
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# Usage: sync-pr-branch.sh BASE_BRANCH [PACKAGE...]
#
# Prints the branch a sync workflow run pushes to, as branch=/scope= lines for
# $GITHUB_OUTPUT. An unscoped (scheduled) run owns BASE_BRANCH and regenerates
# it from master every time. A run scoped to named packages regenerates only
# those, so it gets its own branch and PR: pushing it to BASE_BRANCH would
# replace every other pending update there with just the named packages.
set -euo pipefail
base=${1:?base branch required}
shift
if (( $# == 0 )); then
printf 'branch=%s\nscope=\n' "$base"
exit 0
fi
names=()
for name in "$@"; do
# Package directory names, as pacman allows them. Anything else is a typo
# or an attempt to smuggle something into a ref name or PR title.
if [[ ! $name =~ ^[a-z0-9@_+][a-z0-9@._+-]*$ ]]; then
echo "invalid package name: $name" >&2
exit 1
fi
names+=("$name")
done
mapfile -t names < <(printf '%s\n' "${names[@]}" | sort -u)
scope="${names[*]}"
slug=$(printf '%s\n' "${names[@]}" | sed 's/[^a-z0-9]\{1,\}/-/g; s/^-//; s/-$//' | paste -sd- -)
# Keep long package lists to a readable ref; the hash keeps distinct lists apart.
hash=$(printf '%s' "$scope" | sha256sum | cut -c1-10)
if [[ -z $slug ]]; then
slug=$hash
elif (( ${#slug} > 60 )); then
slug="${slug:0:48}"
slug="${slug%-}-$hash"
fi
printf 'branch=%s-%s\nscope=%s\n' "$base" "$slug" "$scope"