diff options
| -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"* ]] +} |
