Files
omarchy-pkgs/.github/workflows/build-pr.yml
T
Ryan Hughes 537c377fa5 Build PRs on ephemeral droplets; publish merged packages from CI
Every pull request now builds the package directories it touches on
ephemeral DigitalOcean droplets, and every merge to master publishes the
resulting artifacts into the channels each package belongs to. The
repository host's timers become the fallback rather than the pipeline.

Build (.github/workflows/build-pr.yml)
  One job per package per architecture, always against edge. The artifact
  is labelled with the package directory's git tree hash. Tooling (bin/,
  helpers/, build/) is checked out from the base branch; the PR supplies
  only pkgbuilds/, so a PR can change what is built, never how. Builds
  run only for trusted authors: collaborators, .github/VOUCHED.td, or a
  PR carrying the build-approved label. A single required check, result,
  aggregates the matrix.

Publish (.github/workflows/publish.yml, bin/publish-artifact)
  One job per merge. It collects the PR artifacts for the merged tree,
  builds anything that has none, then walks each channel/architecture
  slot once: pull that database, repo-add every package that belongs in
  it, upload packages, signatures, then the database. A published
  filename is immutable; identical bytes under an existing name only
  gain a database entry, different bytes are refused. Fast-ring packages
  reach edge, rc and stable in the same run from the same file.

Matrix (bin/build-matrix)
  Package x architecture, with the channels the artifact ships to,
  decided by package_builds_for_mirror so CI and the host agree.
  arch=any packages build once and land in every architecture database.

Builder (build/build.sh, bin/build, build/Dockerfile)
  With no local published tree, plan against and resolve from the public
  channel over HTTPS. PACKAGER is set to Omarchy. rclone is in the image.

Runners (ci/)
  A controller droplet polls GitHub with curl and creates one g5 droplet
  per queued job from cloud-init, deleting them when off or over-age.
  Builders carry QEMU with credential support for aarch64. Operator SSH
  keys come from GitHub. tests/controller.sh and tests/publish-artifact.sh
  cover the decisions against fixtures and real makepkg output.

Tests run on pull requests only; branch protection requires result,
self-tests and build-isolation with up-to-date branches.
2026-09-18 11:25:32 -04:00

167 lines
7.9 KiB
YAML

name: Build changed packages
# Build every package directory a PR touches, one job per package per arch, on
# the self-hosted droplet pool. 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: `result` is the required status check, so it has to be
# reported on every PR. A PR that touches no package directory gets an empty
# matrix and a passing result in seconds.
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
inputs:
packages:
description: "Space-separated package directories to build"
required: true
concurrency:
group: build-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
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
# and a passing `result`, which is enough for a maintainer to review
# before deciding to spend the compute.
changes:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.list.outputs.matrix }}
count: ${{ steps.gate.outputs.count }}
trusted: ${{ steps.gate.outputs.trusted }}
steps:
# Same rule as the build job: bin/build-matrix comes from base, the
# package directories from the PR head.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha || 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/
- 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 }}
# 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
run: |
if [[ -n "${{ github.event.inputs.packages }}" ]]; then
names="${{ github.event.inputs.packages }}"
else
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)
fi
matrix=$(printf '%s\n' $names | bin/build-matrix)
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
echo "planned=$(jq '.include | length' <<<"$matrix")" >> "$GITHUB_OUTPUT"
jq -r '.include[] | "\(.package) \(.arch) -> \(.channels)"' <<<"$matrix"
- id: gate
env:
STATUS: ${{ steps.vouch.outputs.status || 'dispatch' }}
AUTHOR: ${{ github.event.pull_request.user.login }}
APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'build-approved') }}
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 ;;
*) trusted=$APPROVED ;;
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'
runs-on: [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 droplet'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.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha || 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)"
git status --short | head
- name: Build ${{ matrix.package }} (${{ matrix.arch }}, ships to ${{ matrix.channels }})
env:
CONTAINER_ENGINE: docker
run: bin/build --mirror edge --arch ${{ matrix.arch }} --package ${{ matrix.package }}
# 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"
- name: Upload artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.package }}-${{ matrix.arch }}-${{ steps.tree.outputs.hash }}
path: build-output/edge/${{ matrix.arch }}/*.pkg.tar.zst
if-no-files-found: error
retention-days: 7
# The one required status check. Matrix job names carry the package name, so
# they cannot be listed in branch protection; this job's name is stable and
# it fails if any package failed. It also runs (and passes) when no package
# changed, so tooling-only PRs are not stuck waiting for a status.
result:
needs: [changes, build]
if: always()
runs-on: ubuntu-latest
steps:
- run: |
echo "trusted=${{ needs.changes.outputs.trusted }} build=${{ needs.build.result }}"
# An untrusted author's PR is held, not failed: the required check
# stays pending until a maintainer vouches or labels it.
if [[ "${{ needs.changes.outputs.trusted }}" != "true" ]]; then
echo "::error::Builds were not run: author is not vouched. Add to .github/VOUCHED.td or apply the 'build-approved' label."
exit 1
fi
[[ "${{ needs.build.result }}" == "success" || "${{ needs.build.result }}" == "skipped" ]]