From 4aca3bdbc773f918b0e73d922a45492fa0e36ac9 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 26 Sep 2026 20:01:17 -0400 Subject: [PATCH] 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}- 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. --- .github/scripts/approve-pr-workflows.cjs | 18 ++- .github/scripts/approve-sync-push.cjs | 33 ++++++ .github/scripts/sync-pr-branch.sh | 40 +++++++ .github/workflows/build-pr.yml | 9 +- .github/workflows/sync-rebuilds.yml | 61 ++++++++++- .github/workflows/sync-upstream.yml | 61 ++++++++++- README.md | 16 +++ tests/pr-workflow-approval.cjs | 134 +++++++++++++++++++++++ 8 files changed, 363 insertions(+), 9 deletions(-) create mode 100644 .github/scripts/approve-sync-push.cjs create mode 100755 .github/scripts/sync-pr-branch.sh diff --git a/.github/scripts/approve-pr-workflows.cjs b/.github/scripts/approve-pr-workflows.cjs index 0ee32b3..df5c83b 100644 --- a/.github/scripts/approve-pr-workflows.cjs +++ b/.github/scripts/approve-pr-workflows.cjs @@ -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.'); diff --git a/.github/scripts/approve-sync-push.cjs b/.github/scripts/approve-sync-push.cjs new file mode 100644 index 0000000..7f4ea85 --- /dev/null +++ b/.github/scripts/approve-sync-push.cjs @@ -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 }); +}; diff --git a/.github/scripts/sync-pr-branch.sh b/.github/scripts/sync-pr-branch.sh new file mode 100755 index 0000000..6dbe7ab --- /dev/null +++ b/.github/scripts/sync-pr-branch.sh @@ -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" diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index ab0bec4..a49723a 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -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: diff --git a/.github/workflows/sync-rebuilds.yml b/.github/workflows/sync-rebuilds.yml index 4d8af04..8b849dd 100644 --- a/.github/workflows/sync-rebuilds.yml +++ b/.github/workflows/sync-rebuilds.yml @@ -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: "🔴 Rebuild trigger sync failed
View run" \ '{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 }); diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml index 2e69136..67ce46c 100644 --- a/.github/workflows/sync-upstream.yml +++ b/.github/workflows/sync-upstream.yml @@ -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: "🔴 Upstream sync failed
View run" \ '{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 }); diff --git a/README.md b/README.md index 8823595..94cad21 100644 --- a/README.md +++ b/README.md @@ -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-` (or +`auto/sync-rebuilds-`) 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 diff --git a/tests/pr-workflow-approval.cjs b/tests/pr-workflow-approval.cjs index e4fc661..d704936 100644 --- a/tests/pr-workflow-approval.cjs +++ b/tests/pr-workflow-approval.cjs @@ -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); + } +});