aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--Makefile2
-rw-r--r--build-aur.sh231
-rw-r--r--tests/unit/test_build_aur.bats206
4 files changed, 442 insertions, 1 deletions
diff --git a/.gitignore b/.gitignore
index 0bf0a95..ed1cf19 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/Makefile b/Makefile
index 8f39f7c..15c53ef 100644
--- a/Makefile
+++ b/Makefile
@@ -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"* ]]
+}