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 a3f42c8..76fc6cc 100644
--- a/.github/workflows/build-pr.yml
+++ b/.github/workflows/build-pr.yml
@@ -20,9 +20,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 5eaa588..0c5ccac 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
@@ -72,13 +89,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.
@@ -86,7 +111,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
@@ -102,3 +127,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 4f1cba0..029a784 100644
--- a/README.md
+++ b/README.md
@@ -901,6 +901,22 @@ build-and-publish chain, so the tracker requires this secret before it runs.
The reviewed sync workflows continue to use `GITHUB_TOKEN` and require
maintainer approval as before. See [setup instructions](docs/upstream-sources.md#enable-unattended-branch-updates).
+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);
+ }
+});