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:
1 parent
6df9953d8f
commit
4aca3bdbc7
8 files changed
+363
-9
No files matched your search
@@ -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.');
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
Executable
+40
@@ -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"
|
||||
@@ -19,9 +19,16 @@ on:
|
||||
description: "Space-separated package directories to build"
|
||||
required: true
|
||||
|
||||
# A new push normally cancels the PR's in-flight build. The sync bots'
|
||||
# branches (auto/sync-*) are the exception: they are force-pushed with fresh
|
||||
# upstream releases several times a day, which kept cancelling multi-hour
|
||||
# aarch64 builds before they could finish. There the newest run waits
|
||||
# instead (GitHub keeps at most one pending run per group, replacing older
|
||||
# pending ones), and when it starts it reuses every artifact the finished
|
||||
# build uploaded, so only packages whose tree changed are built again.
|
||||
concurrency:
|
||||
group: build-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'auto/sync-')) }}
|
||||
|
||||
jobs:
|
||||
# Builds cost real machines, so they run only for trusted authors:
|
||||
|
||||
@@ -17,6 +17,12 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
outputs:
|
||||
branch: ${{ steps.branch.outputs.branch }}
|
||||
pushed_at: ${{ steps.pushed.outputs.at }}
|
||||
number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
operation: ${{ steps.cpr.outputs.pull-request-operation }}
|
||||
head_sha: ${{ steps.cpr.outputs.pull-request-head-sha }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -24,6 +30,17 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# A scoped dispatch regenerates only the named packages. Pushed to the
|
||||
# shared branch, that would replace every other pending update in its
|
||||
# PR, so it gets a branch and PR of its own.
|
||||
- name: Choose the PR branch
|
||||
id: branch
|
||||
env:
|
||||
PACKAGES: ${{ github.event.inputs.packages }}
|
||||
run: |
|
||||
read -r -a package_args <<< "${PACKAGES:-}"
|
||||
.github/scripts/sync-pr-branch.sh auto/sync-rebuilds "${package_args[@]}" | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
# Runs in an Arch container against the mirror the x86_64 builder itself
|
||||
# uses, because the question being asked is what that builder will link
|
||||
# against and a different mirror can be hours ahead of it. Recording a
|
||||
@@ -68,13 +85,21 @@ jobs:
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Runs created by this push are newer than this; the approve job
|
||||
# waits for them. A minute's slack absorbs runner clock skew.
|
||||
- name: Record push time
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
id: pushed
|
||||
run: echo "at=$(date -u -d '-1 minute' +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.changes.outputs.has_changes == 'true'
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: 'chore: rebuild against updated dependencies'
|
||||
title: 'chore: rebuild against updated dependencies'
|
||||
title: "chore: rebuild against updated dependencies${{ steps.branch.outputs.scope && format(' ({0})', steps.branch.outputs.scope) || '' }}"
|
||||
body: |
|
||||
Automated pkgrel bump for packages that link against a dependency
|
||||
which has moved in the official repositories.
|
||||
@@ -84,7 +109,7 @@ jobs:
|
||||
bump is what makes the rebuilt package an upgrade pacman will offer;
|
||||
without it the build produces the version already published and no
|
||||
one receives it.
|
||||
branch: auto/sync-rebuilds
|
||||
branch: ${{ steps.branch.outputs.branch }}
|
||||
delete-branch: true
|
||||
labels: automated
|
||||
reviewers: ryanrhughes
|
||||
@@ -100,3 +125,35 @@ jobs:
|
||||
"🔴 <strong>Rebuild trigger sync failed</strong><br><a href=\"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\">View run</a>" \
|
||||
'{content: $content}')" \
|
||||
"$BASECAMP_CHATBOT_URL"
|
||||
|
||||
# GitHub holds pull_request runs from a GITHUB_TOKEN push for approval and
|
||||
# creates no pull_request_target run for it, so approve-pr.yml never sees
|
||||
# the sync's own pushes. Once a maintainer has labelled the PR
|
||||
# build-approved, release the held runs for the commit just pushed. A
|
||||
# separate job, so the sync container's token never holds actions: write.
|
||||
approve:
|
||||
needs: sync
|
||||
if: ${{ !cancelled() && needs.sync.outputs.operation == 'updated' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Release held build and test runs if build-approved
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
NUMBER: ${{ needs.sync.outputs.number }}
|
||||
BRANCH: ${{ needs.sync.outputs.branch }}
|
||||
HEAD_SHA: ${{ needs.sync.outputs.head_sha }}
|
||||
SINCE: ${{ needs.sync.outputs.pushed_at }}
|
||||
with:
|
||||
script: |
|
||||
const approve = require('./.github/scripts/approve-sync-push.cjs');
|
||||
const { NUMBER, BRANCH, HEAD_SHA, SINCE } = process.env;
|
||||
await approve({ github, context, core, number: Number(NUMBER),
|
||||
branch: BRANCH, headSha: HEAD_SHA, since: SINCE });
|
||||
@@ -17,6 +17,12 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
outputs:
|
||||
branch: ${{ steps.branch.outputs.branch }}
|
||||
pushed_at: ${{ steps.pushed.outputs.at }}
|
||||
number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
operation: ${{ steps.cpr.outputs.pull-request-operation }}
|
||||
head_sha: ${{ steps.cpr.outputs.pull-request-head-sha }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -24,6 +30,17 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# A scoped dispatch regenerates only the named packages. Pushed to the
|
||||
# shared branch, that would replace every other pending update in its
|
||||
# PR, so it gets a branch and PR of its own.
|
||||
- name: Choose the PR branch
|
||||
id: branch
|
||||
env:
|
||||
PACKAGES: ${{ github.event.inputs.packages }}
|
||||
run: |
|
||||
read -r -a package_args <<< "${PACKAGES:-}"
|
||||
.github/scripts/sync-pr-branch.sh auto/sync-upstream "${package_args[@]}" | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
# Runs in an Arch container for vercmp: whether a release is an upgrade has
|
||||
# to be decided by the same comparator pacman will use on users' machines.
|
||||
- name: Update packages from upstream release feeds
|
||||
@@ -70,13 +87,21 @@ jobs:
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Runs created by this push are newer than this; the approve job
|
||||
# waits for them. A minute's slack absorbs runner clock skew.
|
||||
- name: Record push time
|
||||
if: ${{ !cancelled() && steps.changes.outputs.has_changes == 'true' }}
|
||||
id: pushed
|
||||
run: echo "at=$(date -u -d '-1 minute' +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Pull Request
|
||||
if: ${{ !cancelled() && steps.changes.outputs.has_changes == 'true' }}
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: 'chore: sync upstream releases'
|
||||
title: 'chore: sync upstream releases'
|
||||
title: "chore: sync upstream releases${{ steps.branch.outputs.scope && format(' ({0})', steps.branch.outputs.scope) || '' }}"
|
||||
body: |
|
||||
Automated update of packages that track an upstream vendor release
|
||||
feed rather than the AUR.
|
||||
@@ -84,7 +109,7 @@ jobs:
|
||||
Release watches and providers are declared in `.omarchy/package.json`;
|
||||
exceptional feeds use `.omarchy/upstream.sh`. Failed package updates
|
||||
are left untouched; check the workflow result for outstanding failures.
|
||||
branch: auto/sync-upstream
|
||||
branch: ${{ steps.branch.outputs.branch }}
|
||||
delete-branch: true
|
||||
labels: automated
|
||||
reviewers: ryanrhughes
|
||||
@@ -100,3 +125,35 @@ jobs:
|
||||
"🔴 <strong>Upstream sync failed</strong><br><a href=\"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\">View run</a>" \
|
||||
'{content: $content}')" \
|
||||
"$BASECAMP_CHATBOT_URL"
|
||||
|
||||
# GitHub holds pull_request runs from a GITHUB_TOKEN push for approval and
|
||||
# creates no pull_request_target run for it, so approve-pr.yml never sees
|
||||
# the sync's own pushes. Once a maintainer has labelled the PR
|
||||
# build-approved, release the held runs for the commit just pushed. A
|
||||
# separate job, so the sync container's token never holds actions: write.
|
||||
approve:
|
||||
needs: sync
|
||||
if: ${{ !cancelled() && needs.sync.outputs.operation == 'updated' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Release held build and test runs if build-approved
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
NUMBER: ${{ needs.sync.outputs.number }}
|
||||
BRANCH: ${{ needs.sync.outputs.branch }}
|
||||
HEAD_SHA: ${{ needs.sync.outputs.head_sha }}
|
||||
SINCE: ${{ needs.sync.outputs.pushed_at }}
|
||||
with:
|
||||
script: |
|
||||
const approve = require('./.github/scripts/approve-sync-push.cjs');
|
||||
const { NUMBER, BRANCH, HEAD_SHA, SINCE } = process.env;
|
||||
await approve({ github, context, core, number: Number(NUMBER),
|
||||
branch: BRANCH, headSha: HEAD_SHA, since: SINCE });
|
||||
@@ -883,6 +883,22 @@ The repository includes GitHub workflows and systemd services for automated rele
|
||||
1. **sync-upstream.yml** (Every 6 hours): Watches direct upstream feeds and updates owned recipes. Successful package updates reach a PR even if another feed fails; failed recipes stay untouched and the workflow remains red.
|
||||
2. **sync-rebuilds.yml** (Every 6 hours): Bumps pkgrel for packages whose `rebuild_on` dependencies have moved in the official repositories and opens a PR.
|
||||
|
||||
Scheduled runs regenerate one shared PR (`auto/sync-upstream`, `auto/sync-rebuilds`)
|
||||
from master. A manual run with the `packages` input only regenerates those
|
||||
packages, so it opens its own PR on `auto/sync-upstream-<packages>` (or
|
||||
`auto/sync-rebuilds-<packages>`) rather than replacing the shared PR's other
|
||||
pending updates. The next scheduled run still picks the same update up in the
|
||||
shared PR if it has not merged by then; identical package trees reuse the same
|
||||
build artifacts.
|
||||
|
||||
Sync PRs are pushed with `GITHUB_TOKEN`, so GitHub holds their build and test
|
||||
runs for approval on every push and starts no `pull_request_target` workflow
|
||||
for them. Once **`build-approved`** is on a sync PR, the sync workflow's own
|
||||
`approve` job releases the held runs for each commit it pushes. A push to an
|
||||
`auto/sync-*` branch does not cancel the PR's in-flight build: the new build
|
||||
waits for it and then reuses its artifacts, so a long aarch64 build is not
|
||||
restarted by every sync.
|
||||
|
||||
To approve builds for an unvouched contributor's PR, apply **`build-approved`**.
|
||||
Until approval, the PR shows **Awaiting build approval** and its required
|
||||
`result` check stays pending, keeping the PR blocked from merging without
|
||||
|
||||
@@ -186,6 +186,15 @@ test('a delayed tests workflow is also awaited', async () => {
|
||||
assert.deepEqual(state.approved, [1, 2]);
|
||||
});
|
||||
|
||||
test('a lone build left pending behind an older in-flight build does not hold back the tests', async () => {
|
||||
// Sync branches queue rather than cancel, so an approved build can stay
|
||||
// queued for hours. Only a newer held build needs to wait for it to start.
|
||||
const { state, invoke } = fixture([run(1, BUILD), run(2, TESTS)], { queueUntil: Infinity });
|
||||
await invoke();
|
||||
assert.deepEqual(state.approved, [1, 2]);
|
||||
assert.deepEqual(state.transitions, []);
|
||||
});
|
||||
|
||||
test('reopening a labeled PR waits for its new tests, even if old tests passed at the same SHA', async () => {
|
||||
const { state, invoke } = fixture([
|
||||
run(1, TESTS, { created_at: earlier, conclusion: 'success' }), run(2, BUILD),
|
||||
@@ -312,3 +321,128 @@ for (const [name, overrides] of [
|
||||
assert.doesNotMatch(result.stdout, /::notice::Awaiting maintainer build approval/);
|
||||
});
|
||||
}
|
||||
|
||||
// A push to a sync branch must not cancel that PR's multi-hour build; any
|
||||
// other PR still cancels its superseded build.
|
||||
test('only same-repository sync branches queue behind an in-flight build', () => {
|
||||
const expression = workflow.match(/^ cancel-in-progress: \$\{\{(.*)\}\}$/m)[1];
|
||||
const cancels = (repo, ref) => new Function('github', 'startsWith', `return (${expression})`)(
|
||||
{ repository: 'omacom/omarchy-pkgs', head_ref: ref,
|
||||
event: { pull_request: { head: { repo: { full_name: repo } } } } },
|
||||
(text, prefix) => text.startsWith(prefix));
|
||||
assert.equal(cancels('omacom/omarchy-pkgs', 'auto/sync-upstream'), false);
|
||||
assert.equal(cancels('omacom/omarchy-pkgs', 'auto/sync-upstream-ttfx'), false);
|
||||
assert.equal(cancels('omacom/omarchy-pkgs', 'auto/sync-rebuilds'), false);
|
||||
assert.equal(cancels('omacom/omarchy-pkgs', 'ttfx/fix'), true);
|
||||
assert.equal(cancels('someone/omarchy-pkgs', 'auto/sync-upstream'), true);
|
||||
assert.equal(cancels(undefined, ''), true); // workflow_dispatch
|
||||
});
|
||||
|
||||
// The sync workflows release their own GITHUB_TOKEN pushes: GitHub creates
|
||||
// no pull_request_target run for those, so approve-pr.yml never runs.
|
||||
const approveSyncPush = require('../.github/scripts/approve-sync-push.cjs');
|
||||
function syncFixture(options = {}) {
|
||||
const f = fixture([run(1, BUILD, { head_branch: 'auto/sync-upstream' }),
|
||||
run(2, TESTS, { head_branch: 'auto/sync-upstream' })], options);
|
||||
Object.assign(f.state.pr, {
|
||||
user: { login: 'github-actions[bot]' },
|
||||
head: { ...f.state.pr.head, ref: 'auto/sync-upstream', repo: { id: 42, full_name: 'omacom/omarchy-pkgs' } },
|
||||
base: { repo: { full_name: 'omacom/omarchy-pkgs' } },
|
||||
});
|
||||
const push = overrides => approveSyncPush({
|
||||
github: f.github, context: { repo: { owner: 'omacom', repo: 'omarchy-pkgs' }, payload: {} },
|
||||
core: { info() {} }, number: 390, branch: 'auto/sync-upstream', headSha: pr.head.sha,
|
||||
since: earlier, attempts: 6, sleep: async () => {}, ...overrides,
|
||||
});
|
||||
return { ...f, push };
|
||||
}
|
||||
|
||||
test('a labelled sync PR has its bot push released', async () => {
|
||||
const { state, push } = syncFixture();
|
||||
await push();
|
||||
assert.deepEqual(state.approved, [1, 2]);
|
||||
});
|
||||
|
||||
test('an unlabelled sync PR stays held for a maintainer', async () => {
|
||||
const { state, push } = syncFixture();
|
||||
state.pr.labels = [];
|
||||
await push();
|
||||
assert.deepEqual(state.approved, []);
|
||||
});
|
||||
|
||||
test('runs older than the push are not taken for this push', async () => {
|
||||
const { state, push } = syncFixture();
|
||||
await assert.rejects(push({ since: '2026-09-19T03:00:00Z' }), /Timed out/);
|
||||
assert.deepEqual(state.approved, []);
|
||||
});
|
||||
|
||||
for (const [name, change] of [
|
||||
['a contributor PR', current => { current.user.login = 'someone'; }],
|
||||
['a fork PR', current => { current.head.repo.full_name = 'someone/omarchy-pkgs'; }],
|
||||
['another branch', current => { current.head.ref = 'auto/sync-rebuilds'; }],
|
||||
]) {
|
||||
test(`the sync approver refuses ${name}, even when labelled`, async () => {
|
||||
const { state, push } = syncFixture();
|
||||
change(state.pr);
|
||||
await assert.rejects(push(), /refusing to approve/);
|
||||
assert.deepEqual(state.approved, []);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, change] of [
|
||||
['closed', current => { current.state = 'closed'; }],
|
||||
['moved on', current => { current.head.sha = 'newer-sha'; }],
|
||||
]) {
|
||||
test(`a sync PR that has ${name} is left alone`, async () => {
|
||||
const { state, push } = syncFixture();
|
||||
change(state.pr);
|
||||
await push();
|
||||
assert.deepEqual(state.approved, []);
|
||||
});
|
||||
}
|
||||
|
||||
test('the sync approver needs the push it is approving for', async () => {
|
||||
const { state, push } = syncFixture();
|
||||
for (const missing of [{ number: NaN }, { headSha: '' }, { since: '' }, { branch: '' }]) {
|
||||
await assert.rejects(push(missing), /Missing sync PR/);
|
||||
}
|
||||
assert.deepEqual(state.approved, []);
|
||||
});
|
||||
|
||||
// A scoped dispatch must not push to the shared branch: it would replace the
|
||||
// other pending updates in the open sync PR with just the named packages.
|
||||
const branchScript = join(__dirname, '../.github/scripts/sync-pr-branch.sh');
|
||||
const branchFor = (...names) => Object.fromEntries(execFileSync(branchScript,
|
||||
['auto/sync-upstream', ...names], { encoding: 'utf8' })
|
||||
.trim().split('\n').map(line => line.split(/=(.*)/s).slice(0, 2)));
|
||||
|
||||
test('scheduled runs keep the shared branch; scoped runs get their own', () => {
|
||||
assert.deepEqual(branchFor(), { branch: 'auto/sync-upstream', scope: '' });
|
||||
assert.deepEqual(branchFor('ttfx'), { branch: 'auto/sync-upstream-ttfx', scope: 'ttfx' });
|
||||
assert.deepEqual(branchFor('ttfx', 'strata', 'ttfx'),
|
||||
{ branch: 'auto/sync-upstream-strata-ttfx', scope: 'strata ttfx' });
|
||||
assert.equal(branchFor('python-foo.bar').branch, 'auto/sync-upstream-python-foo-bar');
|
||||
const names = ['a-very-long-package-name-one', 'another-very-long-package-name-two'];
|
||||
const long = branchFor(...names, 'third');
|
||||
assert.ok(long.branch.length <= 'auto/sync-upstream-'.length + 60);
|
||||
assert.notEqual(long.branch, branchFor(...names).branch);
|
||||
});
|
||||
|
||||
test('scoped branch names reject anything that is not a package name', () => {
|
||||
for (const name of ['../x', 'A', 'x y', 'a@{b', '-x', '.x', 'x;true']) {
|
||||
assert.equal(spawnSync(branchScript, ['auto/sync-upstream', name]).status, 1, name);
|
||||
}
|
||||
});
|
||||
|
||||
test('sync workflows push scoped runs aside and keep actions: write out of the sync job', () => {
|
||||
for (const file of ['sync-upstream.yml', 'sync-rebuilds.yml']) {
|
||||
const text = readFileSync(join(__dirname, '../.github/workflows', file), 'utf8');
|
||||
const sync = text.slice(text.indexOf('\n sync:\n'), text.indexOf('\n approve:\n'));
|
||||
const approveJob = text.slice(text.indexOf('\n approve:\n'));
|
||||
assert.match(sync, /sync-pr-branch\.sh auto\/sync-[\w-]+ "\$\{package_args\[@\]\}"/, file);
|
||||
assert.match(sync, /branch: \$\{\{ steps\.branch\.outputs\.branch \}\}/, file);
|
||||
assert.doesNotMatch(sync, /^ +actions: write$/m, file);
|
||||
assert.match(approveJob, /^ actions: write$/m, file);
|
||||
assert.match(approveJob, /needs\.sync\.outputs\.operation == 'updated'/, file);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user