diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-09 23:37:07 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-09 23:37:07 -0500 |
| commit | 5e43d8c4ad8685e88331ac78641ca84666cb9e7a (patch) | |
| tree | 36f6bba78736a5b28dd115c79f39758a31bc01af | |
| parent | 495ecca19425efdc641aa27641292b71282c891f (diff) | |
| download | archangel-5e43d8c4ad8685e88331ac78641ca84666cb9e7a.tar.gz archangel-5e43d8c4ad8685e88331ac78641ca84666cb9e7a.zip | |
feat(build): add AUR local-repo build helpers
Add build-aur.sh, sourced by build.sh, that builds the v1 genuine-AUR set into a local pacman repo and emits an auditable manifest. The pure helpers carry the testable surface: the package sets (one source of truth for the build array and the package-list append), the [aur] stanza renderer, the TSV manifest header/row, the package-file locator, the staged repo replacement, and the build-environment preflight.
makepkg refuses to run as root, so the orchestrator drops to $SUDO_USER for the clone and build. It stages on the same filesystem and swaps in with mv -T on full success, so a failure ships no repo and leaves no stale one. On any failure error() names the package, the phase, and the log path.
The orchestrator and manifest-append need root, network, and makepkg, so they stay out of bats and are covered by the build integration test and the manual checklist instead. Eighteen unit tests cover the pure helpers across Normal, Boundary, and Error.
| -rw-r--r-- | .gitignore | 4 | ||||
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | build-aur.sh | 231 | ||||
| -rw-r--r-- | tests/unit/test_build_aur.bats | 206 |
4 files changed, 442 insertions, 1 deletions
@@ -9,6 +9,10 @@ vm/ test-logs/ reference-repos/ +# Baked AUR local repo (built by build-aur.sh each run) and its staging dir +aur-packages/ +aur-packages.staging/ + # Personal session/workflow docs (not project documentation) .ai/ todo.org @@ -31,7 +31,7 @@ SHELL := /bin/bash # Lint all bash scripts lint: @echo "==> Running shellcheck..." - @shellcheck -x build.sh scripts/*.sh installer/archangel installer/zfssnapshot installer/lib/*.sh + @shellcheck -x build.sh build-aur.sh scripts/*.sh installer/archangel installer/zfssnapshot installer/lib/*.sh @echo "==> Shellcheck complete" # Run bats unit tests diff --git a/build-aur.sh b/build-aur.sh new file mode 100644 index 0000000..9b83a33 --- /dev/null +++ b/build-aur.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# build-aur.sh - AUR local-repository helpers for build.sh +# +# Sourced by build.sh (not executed directly). build.sh runs as root with +# `set -euo pipefail` and provides SCRIPT_DIR, SUDO_USER, BUILD_LOG, and the +# info/warn/error helpers. The functions here build a fixed set of +# genuine-AUR packages into a local pacman repo and emit an auditable +# manifest. See docs/aur-local-repo-spec.org. +# +# The pure helpers (package lists, stanza/manifest rendering, staged +# replace, preflight) carry no side effects and are unit-tested in +# tests/unit/test_build_aur.bats. The build_aur_packages orchestrator and +# aur_manifest_append need root + network + makepkg, so they are covered by +# the build integration test and the manual verification checklist instead. + +############################# +# Package sets (single source of truth) +############################# + +# The v1 genuine-AUR build set: packages with no exact official-repo match +# whose runtime + make deps all resolve from official / archzfs / the baked +# local repo (the v1 dependency gate). paru (second helper) and +# mkinitcpio-firmware (pulls AUR firmware deps) are deferred to vNext. +# Audited 2026-06-09. This list is the one place the set is named; build.sh +# reads it for the package-list append, and the manifest records what +# actually shipped. +aur_v1_packages() { + printf '%s\n' \ + downgrade \ + yay \ + informant \ + zrepl \ + pacman-cleanup-hook \ + sanoid \ + zfs-auto-snapshot \ + topgrade \ + ventoy-bin +} + +# Official `extra`-repo packages that the audit reclassified out of the AUR. +# These are NOT built — they go straight into packages.x86_64 and install +# from the normal repos. Listed here so build.sh has one source for them. +aur_official_packages() { + printf '%s\n' \ + arch-wiki-lite \ + rate-mirrors \ + arch-audit \ + btop \ + duf \ + dust \ + procs +} + +############################# +# Pacman-config rendering +############################# + +# Render the [aur] pacman repo stanza for the given Server value, with a +# leading blank line so it appends cleanly after an existing stanza (the +# same shape as build.sh's [archzfs] block). SigLevel = Optional TrustAll: +# the repo is trusted by construction (we built it on this host); GPG +# signing is vNext. The Server differs per namespace — a build-host +# absolute file:// path for profile/pacman.conf, file:///usr/share/aur-packages +# for the live-runtime config. +aur_repo_stanza() { + local server="$1" + printf '\n[aur]\nSigLevel = Optional TrustAll\nServer = %s\n' "$server" +} + +############################# +# Manifest rendering (TSV) +############################# + +# Emit the manifest column header. Nine tab-separated columns; keep in lockstep +# with aur_manifest_row. +aur_manifest_header() { + printf 'name\tpkgbase\tfilename\tpkgver\tpkgrel\tcommit\tsource_url\ttimestamp\tsha256\n' +} + +# Format one manifest row from nine positional fields, tab-separated. Pure +# formatter so the column layout is unit-testable without a real build; +# aur_manifest_append gathers the field values and calls this. +aur_manifest_row() { + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$@" +} + +############################# +# Filesystem helpers +############################# + +# Print the basename of the single *.pkg.tar.zst in $1. Returns 1 (empty +# output) when the directory holds no package file — used both to locate a +# freshly-built package and as the build-failure signal when makepkg +# produced nothing. +aur_pkgfile_name() { + local dir="$1" f + f=$(find "$dir" -maxdepth 1 -name '*.pkg.tar.zst' -print -quit 2>/dev/null) + [[ -n "$f" ]] || return 1 + basename "$f" +} + +# Staged replacement: move $staging into place at $repo_dir. Caller stages +# on the same filesystem (a sibling dir), so this is a local rename. The +# rm/mv window is not strictly atomic, but the build only calls this after +# every package built, repo-add ran, and the manifest emitted — so a +# failure earlier ships no repo, and a failure here leaves no stale repo. +# mv -T treats $repo_dir as the destination name rather than a dir to move +# into, which matters once $repo_dir has been recreated by a concurrent run. +aur_repo_replace() { + local staging="$1" repo_dir="$2" + rm -rf "$repo_dir" + mv -T "$staging" "$repo_dir" +} + +############################# +# Preflight +############################# + +# Guard the build environment before any clone/makepkg. $1 is the invoking +# user (SUDO_USER); the rest, if given, are the commands to require +# (defaults to git/makepkg/repo-add — overridable so the unit tests stay +# host-independent). Fails with a named reason on empty/root user or a +# missing command. makepkg refuses to run as root, which is why a usable +# non-root SUDO_USER is mandatory. +aur_preflight() { + local sudo_user="$1" + shift + local required=("$@") + [[ ${#required[@]} -gt 0 ]] || required=(git makepkg repo-add) + + if [[ -z "$sudo_user" ]]; then + echo "AUR build preflight: SUDO_USER is not set — run build.sh via sudo" >&2 + return 1 + fi + if [[ "$sudo_user" == "root" ]]; then + echo "AUR build preflight: invoking user is root; makepkg refuses to run as root" >&2 + return 1 + fi + local cmd + for cmd in "${required[@]}"; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "AUR build preflight: required command not found: $cmd" >&2 + return 1 + fi + done + return 0 +} + +############################# +# Build orchestrator (impure) +############################# + +# Append one manifest row for the package just built into $pkgdir and +# collected into $staging. Reads the version straight from the package +# metadata (pacman -Qp) so a dash in the package name can't confuse a +# filename parse, and records the AUR commit, source URL, build timestamp, +# and SHA256. +aur_manifest_append() { + local staging="$1" pkgdir="$2" pkg="$3" + local filename commit sha256 nameversion pkgname fullver pkgver pkgrel timestamp source_url + + filename=$(aur_pkgfile_name "$pkgdir") || return 1 + commit=$(git -C "$pkgdir" rev-parse HEAD 2>/dev/null || echo unknown) + sha256=$(sha256sum "$staging/$filename" | awk '{print $1}') + nameversion=$(pacman -Qp "$staging/$filename" 2>/dev/null) + pkgname=${nameversion%% *} + fullver=${nameversion##* } + pkgver=${fullver%-*} + pkgrel=${fullver##*-} + timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ) + source_url="https://aur.archlinux.org/${pkg}.git" + + aur_manifest_row "$pkgname" "$pkg" "$filename" "$pkgver" "$pkgrel" \ + "$commit" "$source_url" "$timestamp" "$sha256" >> "$staging/manifest.tsv" +} + +# Build the v1 AUR set into a local pacman repo at $repo_dir (default +# $SCRIPT_DIR/aur-packages). Clones each package from the AUR and runs +# makepkg as $SUDO_USER (makepkg can't run as root), collects the built +# package, appends a manifest row, then builds the repo db with repo-add +# and atomically-ish swaps the staging dir into place. On any failure +# error() names the package + phase + log path and no repo ships. Relies on +# build.sh's info/warn/error and globals. +build_aur_packages() { + local repo_dir="${1:-${SCRIPT_DIR:-.}/aur-packages}" + local staging="${repo_dir}.staging" + local sudo_user="${SUDO_USER:-}" + local -a packages + mapfile -t packages < <(aur_v1_packages) + + aur_preflight "$sudo_user" || error "AUR build preflight failed (see above)" + + if [[ ${#packages[@]} -eq 0 ]]; then + warn "No AUR packages in the v1 set — skipping local repo creation" + return 0 + fi + + local build_dir + build_dir="$(sudo -u "$sudo_user" mktemp -d /tmp/aur-build.XXXXXX)" \ + || error "AUR build: could not create build dir as $sudo_user" + # shellcheck disable=SC2064 # expand build_dir now so RETURN cleans this dir + trap "rm -rf '$build_dir'" RETURN + + rm -rf "$staging" + mkdir -p "$staging" + aur_manifest_header > "$staging/manifest.tsv" + + local pkg + for pkg in "${packages[@]}"; do + info "Building AUR package: $pkg" + sudo -u "$sudo_user" git clone --depth 1 \ + "https://aur.archlinux.org/${pkg}.git" "$build_dir/${pkg}" \ + || error "AUR clone failed: $pkg (see ${BUILD_LOG:-build log})" + sudo -u "$sudo_user" bash -c \ + "cd '$build_dir/${pkg}' && makepkg -s --noconfirm --needed" \ + || error "AUR build failed: $pkg (makepkg; see ${BUILD_LOG:-build log})" + cp "$build_dir/${pkg}"/*.pkg.tar.zst "$staging/" \ + || error "AUR collect failed: $pkg (no package file produced)" + aur_manifest_append "$staging" "$build_dir/${pkg}" "$pkg" \ + || error "AUR manifest failed: $pkg" + done + + repo-add "$staging/aur.db.tar.gz" "$staging"/*.pkg.tar.zst \ + || error "repo-add failed (see ${BUILD_LOG:-build log})" + + aur_repo_replace "$staging" "$repo_dir" + + local count + count=$(find "$repo_dir" -maxdepth 1 -name '*.pkg.tar.zst' | wc -l) + info "AUR local repo ready: $repo_dir ($count packages)" +} diff --git a/tests/unit/test_build_aur.bats b/tests/unit/test_build_aur.bats new file mode 100644 index 0000000..06a78aa --- /dev/null +++ b/tests/unit/test_build_aur.bats @@ -0,0 +1,206 @@ +#!/usr/bin/env bats +# Unit tests for build-aur.sh — the AUR local-repo build helpers. +# +# Only the pure, side-effect-free helpers are unit-tested here. The +# build_aur_packages orchestrator clones from the AUR, runs makepkg as +# $SUDO_USER, and needs root + network, so it is exercised by the build +# integration test and the manual verification checklist, not bats. + +setup() { + # shellcheck disable=SC1091 + source "${BATS_TEST_DIRNAME}/../../build-aur.sh" +} + +############################# +# aur_v1_packages — single source of truth for the v1 build set +############################# + +@test "aur_v1_packages lists the nine audited v1 packages" { + run aur_v1_packages + [ "$status" -eq 0 ] + [ "$(echo "$output" | wc -l)" -eq 9 ] + for pkg in downgrade yay informant zrepl pacman-cleanup-hook \ + sanoid zfs-auto-snapshot topgrade ventoy-bin; do + [[ "$output" == *"$pkg"* ]] + done +} + +@test "aur_v1_packages excludes the vNext-deferred packages" { + run aur_v1_packages + [ "$status" -eq 0 ] + # paru (second helper) and mkinitcpio-firmware (AUR-of-AUR deps) are vNext + [[ "$output" != *"paru"* ]] + [[ "$output" != *"mkinitcpio-firmware"* ]] +} + +@test "aur_v1_packages emits one package per line" { + run aur_v1_packages + # No line carries two names (no embedded spaces) + while IFS= read -r line; do + [[ "$line" != *" "* ]] + done <<< "$output" +} + +############################# +# aur_official_packages — official extra packages, not built +############################# + +@test "aur_official_packages lists the audited official extra set" { + run aur_official_packages + [ "$status" -eq 0 ] + for pkg in arch-wiki-lite rate-mirrors arch-audit btop duf dust procs; do + [[ "$output" == *"$pkg"* ]] + done +} + +@test "aur_official_packages does not overlap the AUR build set" { + local official aur + official=$(aur_official_packages) + aur=$(aur_v1_packages) + while IFS= read -r pkg; do + [[ -n "$pkg" ]] || continue + [[ "$aur" != *"$pkg"* ]] + done <<< "$official" +} + +############################# +# aur_repo_stanza — renders the [aur] pacman stanza +############################# + +@test "aur_repo_stanza renders header, SigLevel, and the given Server" { + run aur_repo_stanza "file:///usr/share/aur-packages" + [ "$status" -eq 0 ] + [[ "$output" == *"[aur]"* ]] + [[ "$output" == *"SigLevel = Optional TrustAll"* ]] + [[ "$output" == *"Server = file:///usr/share/aur-packages"* ]] +} + +@test "aur_repo_stanza uses the build-host path when given one" { + run aur_repo_stanza "file:///home/build/archangel/aur-packages" + [ "$status" -eq 0 ] + [[ "$output" == *"Server = file:///home/build/archangel/aur-packages"* ]] + [[ "$output" != *"/usr/share/aur-packages"* ]] +} + +############################# +# aur_manifest_header / aur_manifest_row — TSV manifest formatting +############################# + +@test "aur_manifest_header emits nine tab-separated columns" { + run aur_manifest_header + [ "$status" -eq 0 ] + local cols + cols=$(echo "$output" | awk -F'\t' '{print NF}') + [ "$cols" -eq 9 ] + [[ "$output" == *"name"* ]] + [[ "$output" == *"pkgver"* ]] + [[ "$output" == *"commit"* ]] + [[ "$output" == *"sha256"* ]] +} + +@test "aur_manifest_row formats nine fields as one tab-separated line" { + run aur_manifest_row yay yay yay-12.0-1-x86_64.pkg.tar.zst 12.0 1 \ + abc123 https://aur.archlinux.org/yay.git 2026-06-09T19:00:00 deadbeef + [ "$status" -eq 0 ] + local cols + cols=$(echo "$output" | awk -F'\t' '{print NF}') + [ "$cols" -eq 9 ] + [[ "$output" == *$'yay\tyay\tyay-12.0-1'* ]] + [[ "$output" == *"abc123"* ]] + [[ "$output" == *"deadbeef"* ]] +} + +@test "aur_manifest_row keeps fields aligned to the header columns" { + local header row hcols rcols + header=$(aur_manifest_header) + row=$(aur_manifest_row a b c d e f g h i) + hcols=$(echo "$header" | awk -F'\t' '{print NF}') + rcols=$(echo "$row" | awk -F'\t' '{print NF}') + [ "$hcols" -eq "$rcols" ] +} + +############################# +# aur_pkgfile_name — find the built package file in a dir +############################# + +@test "aur_pkgfile_name returns the basename of the built package" { + local d + d=$(mktemp -d) + touch "$d/yay-12.0-1-x86_64.pkg.tar.zst" + run aur_pkgfile_name "$d" + [ "$status" -eq 0 ] + [ "$output" = "yay-12.0-1-x86_64.pkg.tar.zst" ] + rm -rf "$d" +} + +@test "aur_pkgfile_name returns 1 when no package file is present" { + local d + d=$(mktemp -d) + run aur_pkgfile_name "$d" + [ "$status" -eq 1 ] + [ -z "$output" ] + rm -rf "$d" +} + +############################# +# aur_repo_replace — staged replacement, same filesystem +############################# + +@test "aur_repo_replace moves staging into place and removes staging" { + local parent staging repo + parent=$(mktemp -d) + staging="$parent/aur.staging" + repo="$parent/aur" + mkdir -p "$staging" + touch "$staging/new.pkg.tar.zst" + + aur_repo_replace "$staging" "$repo" + + [ -f "$repo/new.pkg.tar.zst" ] + [ ! -e "$staging" ] + rm -rf "$parent" +} + +@test "aur_repo_replace leaves no stale package from a prior repo" { + local parent staging repo + parent=$(mktemp -d) + staging="$parent/aur.staging" + repo="$parent/aur" + mkdir -p "$repo" + touch "$repo/stale.pkg.tar.zst" + mkdir -p "$staging" + touch "$staging/fresh.pkg.tar.zst" + + aur_repo_replace "$staging" "$repo" + + [ -f "$repo/fresh.pkg.tar.zst" ] + [ ! -e "$repo/stale.pkg.tar.zst" ] + rm -rf "$parent" +} + +############################# +# aur_preflight — build-environment guard +############################# + +@test "aur_preflight passes for a non-root user with present commands" { + run aur_preflight alice bash + [ "$status" -eq 0 ] +} + +@test "aur_preflight fails when SUDO_USER is empty" { + run aur_preflight "" bash + [ "$status" -ne 0 ] + [[ "$output" == *"SUDO_USER"* ]] +} + +@test "aur_preflight fails when the invoking user is root" { + run aur_preflight root bash + [ "$status" -ne 0 ] + [[ "$output" == *"root"* ]] +} + +@test "aur_preflight fails and names a missing command" { + run aur_preflight alice this_command_does_not_exist_xyz + [ "$status" -ne 0 ] + [[ "$output" == *"this_command_does_not_exist_xyz"* ]] +} |
