Files
omarchy-pkgs/.github/workflows/build-pr.yml
T
Ryan Hughes aa143d79b7 Stop the PR overlay preview failing stale PRs with SIGPIPE
'git status --short | head' under set -o pipefail: once the PR's
pkgbuilds/ differs from base in more than ten files, head exits, git
takes SIGPIPE and the step fails with 141 before anything builds. Every
PR branched far enough behind master hit it (omadev #423, llmman-bin
#428 and others on 2026-09-28). sed -n '1,10p' prints the same preview
and drains the stream.
2026-09-27 21:36:12 -04:00

287 lines
16 KiB
YAML

name: Build changed packages
# Build every package directory a PR touches, one job per package per arch:
# x86_64 on the self-hosted droplet pool, aarch64 natively on GitHub's arm64
# runners. Artifacts are unsigned; publish.yml signs and publishes them on
# merge.
#
# Tooling runs from the base branch; a PR supplies only pkgbuilds/. The
# vouch gate limits who may spend compute; this limits what their PR can run.
# No paths filter: approved PRs must report the required `result` even when
# no package directory changed. Those PRs get an empty matrix and a passing
# result in seconds; unapproved PRs wait for maintainer approval.
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
inputs:
packages:
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: ${{ !(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:
# collaborators, anyone in .github/VOUCHED.td (read from the default
# branch, so a PR cannot vouch for itself), or a PR a maintainer has
# labelled "build-approved". Everyone else gets this job's plan output
# while the required `result` stays pending until a maintainer approves.
changes:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.list.outputs.matrix }}
count: ${{ steps.gate.outputs.count }}
trusted: ${{ steps.gate.outputs.trusted }}
vouch_status: ${{ steps.vouch.outputs.status }}
empty: ${{ steps.list.outputs.empty }}
steps:
# Same rule as the build job: bin/build-matrix comes from the base
# branch tip, the package directories from the PR head.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.ref || github.sha }}
fetch-depth: 0
persist-credentials: false
- if: github.event_name == 'pull_request'
run: |
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.head.sha }}"
git checkout "${{ github.event.pull_request.head.sha }}" -- pkgbuilds/
# Bootstrap: the PR that introduces this tooling has a base without
# it. Take the plan helper from the PR head in that one case; it
# runs on a hosted runner and only prints a plan.
if [[ ! -x bin/build-matrix ]]; then
git checkout "${{ github.event.pull_request.head.sha }}" -- bin/build-matrix helpers/
echo "::notice::base branch has no bin/build-matrix; using the PR's copy for planning"
fi
- id: vouch
if: github.event_name == 'pull_request'
uses: mitchellh/vouch/action/check-user@f23dbb5e745334f97414ec70463ce7301071a661 # v1
with:
user: ${{ github.event.pull_request.user.login }}
allow-fail: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- id: approval
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const { data: pr } = await github.rest.pulls.get({
...context.repo, pull_number: context.payload.pull_request.number,
});
// Approving or rerunning a held run keeps its original event,
// which may predate the label. Read the current approval instead.
core.setOutput('approved', pr.state === 'open' &&
pr.head.sha === context.payload.pull_request.head.sha &&
pr.labels.some(label => label.name === 'build-approved'));
# One matrix entry per package per architecture. Every package builds
# once, against edge; the channels it ships to on merge are carried
# along for information. A filename means one set of bytes.
- id: list
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ -n "${{ github.event.inputs.packages }}" ]]; then
names="${{ github.event.inputs.packages }}"
else
# A package the PR deletes has nothing to build.
names=$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" -- pkgbuilds \
| awk -F/ '$1=="pkgbuilds" && NF>2 {print $2}' | sort -u \
| while read -r name; do
if git cat-file -e "${{ github.event.pull_request.head.sha }}:pkgbuilds/$name" 2>/dev/null; then echo "$name"; fi
done)
fi
matrix=$(printf '%s\n' $names | bin/build-matrix)
# A package directory whose exact tree already has a build artifact
# (label <pkg>-<arch>-<tree hash>, uploaded only after a successful
# build) is not built again. Pushing a fix for one package to a PR
# that touches fifty rebuilds one, not fifty; publish.yml finds the
# same artifacts on merge. workflow_dispatch is an explicit request
# and always builds.
if [[ "${{ github.event_name }}" == pull_request ]]; then
head="${{ github.event.pull_request.head.sha }}"
kept=(); reused=()
while read -r entry; do
package=$(jq -r .package <<<"$entry"); arch=$(jq -r .arch <<<"$entry")
label="$package-$arch-$(git rev-parse "$head:pkgbuilds/$package")"
found=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/actions/artifacts?name=$label&per_page=5" \
| jq -r '[.artifacts[] | select(.expired|not)] | length' || echo 0)
if (( found > 0 )); then reused+=("$label"); else kept+=("$entry"); fi
done < <(jq -c '.include[]' <<<"$matrix")
matrix=$(printf '%s\n' "${kept[@]}" | jq -sc '{include: .}')
if (( ${#reused[@]} )); then
printf '==> already built, reusing the artifact: %s\n' "${reused[@]}"
{ echo "Reused existing build artifacts (${#reused[@]}):"; printf -- '- %s\n' "${reused[@]}"; } >> "$GITHUB_STEP_SUMMARY"
fi
fi
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
echo "planned=$(jq '.include | length' <<<"$matrix")" >> "$GITHUB_OUTPUT"
jq -r '.include[] | "\(.package) \(.arch) -> \(.channels)"' <<<"$matrix"
# A PR whose diff against its base is empty changes nothing: its
# content already landed some other way (a sync PR beat it, or a
# merge from master swallowed it). Merging it would record a change
# that isn't one. Flag it so `result` fails rather than passes.
if [[ "${{ github.event_name }}" == pull_request ]]; then
total=$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" | wc -l)
echo "empty=$([[ $total -eq 0 ]] && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "files changed vs base: $total"
else
echo "empty=false" >> "$GITHUB_OUTPUT"
fi
- id: gate
env:
STATUS: ${{ github.event_name == 'workflow_dispatch' && 'dispatch' || steps.vouch.outputs.status }}
AUTHOR: ${{ github.event.pull_request.user.login }}
APPROVED: ${{ steps.approval.outputs.approved || 'false' }}
PLANNED: ${{ steps.list.outputs.planned }}
run: |
case "$STATUS" in
bot|collaborator|vouched|dispatch) trusted=true ;;
# A denouncement is absolute: the label cannot override it.
denounced) trusted=false ;;
unknown) trusted=$APPROVED ;;
*) trusted=false ;;
esac
echo "trusted=$trusted" >> "$GITHUB_OUTPUT"
if [[ $trusted == true ]]; then
echo "count=$PLANNED" >> "$GITHUB_OUTPUT"
echo "Author $AUTHOR is trusted ($STATUS); building $PLANNED package(s)."
else
echo "count=0" >> "$GITHUB_OUTPUT"
echo "::warning::Author $AUTHOR is not trusted ($STATUS). $PLANNED package build(s) planned but not run."
if [[ $STATUS == denounced ]]; then
echo "::warning::The author is denounced in .github/VOUCHED.td; the build-approved label does not apply."
else
echo "::warning::A maintainer can add the author to .github/VOUCHED.td, or apply the 'build-approved' label to build this PR."
fi
fi
build:
needs: changes
if: needs.changes.outputs.count != '0'
# The droplets are x86, so aarch64 there runs under QEMU: omarchy-mac-boot
# took 2h47m of the 180 minutes, most of it in check().
# GitHub's arm64 runners (4 vCPU, 16 GB; ~100 GB disk free in our pilots)
# build it natively in 11 minutes, and linux-aurora in 30 (72 under QEMU).
runs-on: ${{ matrix.arch == 'aarch64' && 'ubuntu-24.04-arm' || fromJSON('["self-hosted","omarchy-builder"]') }}
timeout-minutes: 180
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.changes.outputs.matrix) }}
steps:
# Tooling from base: everything that executes on this runner's host
# (bin/, helpers/, build/) comes from the base branch. Only the PR's
# package directories are overlaid. A PR can therefore change what
# gets built, never how the runner builds it. A PR that changes both
# tooling and a package builds the package with the OLD tooling; land
# the tooling first. workflow_dispatch has no PR and runs as checked out.
# The base branch tip, not the event's base.sha: that sha is a snapshot
# taken at the PR's last push, so a tooling fix on master would never
# reach an open PR until someone pushed to it (seen on the daily sync
# PR after the artifact packing fix landed).
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.ref || github.sha }}
persist-credentials: false
- name: Overlay the PR's package directories onto base tooling
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.head.sha }}"
git checkout "${{ github.event.pull_request.head.sha }}" -- pkgbuilds/
echo "tooling: $(git rev-parse --short HEAD) (base) packages: ${{ github.event.pull_request.head.sha }} (PR head)"
# A preview only. `head` exits after ten lines and, under pipefail,
# git's SIGPIPE (141) failed the step for any PR far enough behind
# master to differ in more files; sed reads the whole stream.
git status --short | sed -n '1,10p'
- name: Build ${{ matrix.package }} (${{ matrix.arch }}, ships to ${{ matrix.channels }})
id: build
env:
CONTAINER_ENGINE: docker
run: bin/build --mirror edge --arch ${{ matrix.arch }} --package ${{ matrix.package }}
# makepkg's check() leaves meson's per-test output in the build tree,
# never on stdout, so a failing test shows only a summary line in this
# job log. bin/build bind-mounts $SRC_DIR at /src, so those logs outlive
# the container. Without this upload an arch-specific test failure
# cannot be diagnosed from CI at all (seen on owe 0.2.7, aarch64).
- name: Upload test logs
if: always() && steps.build.outcome == 'failure'
uses: actions/upload-artifact@v4
with:
name: test-logs-${{ matrix.package }}-${{ matrix.arch }}
path: src/**/meson-logs/
if-no-files-found: ignore
retention-days: 14
# The artifact label carries the package directory's git tree hash so
# the publish step can find the build for exactly the tree that merged.
# The package file inside keeps makepkg's standard name untouched.
# The artifact label uses the PR head's tree for this package: that is
# the tree that merges, and what publish looks up.
- name: Tree hash
id: tree
run: echo "hash=$(git rev-parse "${{ github.event.pull_request.head.sha || github.sha }}:pkgbuilds/${{ matrix.package }}")" >> "$GITHUB_OUTPUT"
# The upload action rejects a path containing ':', which is how makepkg
# names a package with an epoch. The files ride inside packages.tar
# (helpers/artifact-helpers.sh); publish.yml unpacks it. Only a
# successful build uploads: the artifact's existence is what lets the
# planner above and publish.yml skip rebuilding this exact tree.
- name: Pack artifact
id: pack
run: |
source helpers/artifact-helpers.sh
pack_packages build-output/edge/${{ matrix.arch }} packages.tar
tar -tvf packages.tar
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.package }}-${{ matrix.arch }}-${{ steps.tree.outputs.hash }}
path: packages.tar
if-no-files-found: error
retention-days: 7
# `result` is required by branch protection. An unvouched author awaiting
# approval gets a differently named informational check, leaving `result`
# unreported (pending). Skipping or passing a job named `result` would count
# as satisfying the requirement even though no build was authorized.
# Actual planning/build failures and denouncements still report `result`.
result:
name: ${{ needs.changes.result == 'success' && needs.changes.outputs.trusted == 'false' && needs.changes.outputs.vouch_status == 'unknown' && needs.changes.outputs.empty == 'false' && 'Awaiting build approval' || 'result' }}
needs: [changes, build]
if: always()
runs-on: ubuntu-latest
steps:
- run: |
echo "trusted=${{ needs.changes.outputs.trusted }} build=${{ needs.build.result }}"
if [[ "${{ needs.changes.result }}" != "success" ]]; then
echo "::error::Build planning or the trust check failed. See the changes job."
exit 1
fi
# Nothing to merge: the PR's diff against its base is empty. Its
# change already landed elsewhere. Close it rather than merge it.
if [[ "${{ needs.changes.outputs.empty }}" == "true" ]]; then
echo "::error::This PR changes no files relative to its base. Its content is already on the target branch; close it instead of merging."
exit 1
fi
if [[ "${{ needs.changes.outputs.trusted }}" == "false" && "${{ needs.changes.outputs.vouch_status }}" == "unknown" && "${{ needs.changes.outputs.empty }}" == "false" ]]; then
echo "::notice::Awaiting maintainer build approval. Apply 'build-approved' to this PR or vouch for the author in .github/VOUCHED.td."
echo "Package builds are waiting for maintainer approval. Apply **build-approved** to this PR to start them. The required **result** check remains pending." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [[ "${{ needs.changes.outputs.trusted }}" != "true" ]]; then
echo "::error::Builds are blocked: the author is denounced or the trust result is invalid. The build-approved label cannot override this."
exit 1
fi
[[ "${{ needs.build.result }}" == "success" || "${{ needs.build.result }}" == "skipped" ]]