Files
omarchy-pkgs/build/build.sh
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

715 lines
27 KiB
Bash
Executable File

#!/bin/bash
# Plan a run or build one planned package in an isolated container.
# Unscoped edge builds exclude skip_build packages. Stable also requires the fast release ring.
# Explicit --package selections may build packages with skip_build=true.
# Setup directories
ARCH=${ARCH:-x86_64}
# ARCH selects the repository target for this script, but make and Kbuild also
# interpret an exported ARCH themselves (Linux calls this target "arm64").
# Keep the shell variable local to the orchestrator so PKGBUILDs see CARCH only.
export -n ARCH
MIRROR=${MIRROR:-edge}
DRY_RUN=${DRY_RUN:-false}
# bin/build plans the whole run, then invokes this script once per package in
# a fresh container. PACKAGES retains the original request for validation.
BUILD_PACKAGE=${BUILD_PACKAGE:-}
BUILD_PLAN_DIR=${BUILD_PLAN_DIR:-}
PKGBUILDS_DIR=${PKGBUILDS_DIR:-/pkgbuilds}
BUILD_OUTPUT_DIR=${BUILD_OUTPUT_DIR:-/build-output/$MIRROR/$ARCH}
FINAL_OUTPUT_DIR=${FINAL_OUTPUT_DIR:-/pkgs.omarchy.org/$MIRROR/$ARCH}
HELPERS_DIR=${HELPERS_DIR:-/helpers}
SRC_DIR=${SRC_DIR:-/src}
# Set by bin/build from OMARCHY_DEFER_RUNTIME_DEPS after it has checked the
# request; re-checked here so the container never trusts a stray value.
DEFER_RUNTIME_DEPS=${DEFER_RUNTIME_DEPS:-false}
source "$HELPERS_DIR/package-metadata.sh"
# Where the channel's published database is read from for planning. On the
# repository host it is the published tree itself. Anywhere else (a CI runner,
# a fresh clone) that tree is absent, so the database is fetched from the
# public channel and the same URL serves as pacman's dependency repository.
# Set OMARCHY_PUBLISHED_REPO_URL= (empty) to disable the remote fallback.
PUBLISHED_REPO_URL=${OMARCHY_PUBLISHED_REPO_URL-https://pkgs.omarchy.org}
PUBLISHED_DB_DIR="$FINAL_OUTPUT_DIR"
PUBLISHED_REPO_SERVER=""
if [[ ! -f "$FINAL_OUTPUT_DIR/omarchy.db.tar.zst" && ! -f "$FINAL_OUTPUT_DIR/omarchy.db" && -n "$PUBLISHED_REPO_URL" ]]; then
remote_channel="$PUBLISHED_REPO_URL/$MIRROR/$ARCH"
remote_db_dir=$(mktemp -d /tmp/omarchy-published.XXXXXX) || exit 1
# Cache-bust: the channel sits behind a CDN that serves a stale database
# for a while after a sync.
if curl -fsSL "$remote_channel/omarchy.db.tar.zst?$(date +%s)" -o "$remote_db_dir/omarchy.db.tar.zst"; then
PUBLISHED_DB_DIR="$remote_db_dir"
PUBLISHED_REPO_SERVER="$remote_channel"
echo "==> No local published tree; planning against $remote_channel"
else
rm -rf "$remote_db_dir"
echo "==> No local published tree and $remote_channel is unavailable; treating the channel as empty"
fi
fi
if [[ $DEFER_RUNTIME_DEPS != "false" && $DEFER_RUNTIME_DEPS != "true" ]]; then
echo "DEFER_RUNTIME_DEPS must be true or false" >&2
exit 1
fi
if [[ $DEFER_RUNTIME_DEPS == "true" ]]; then
deferred_runtime=0
deferred_settings=0
deferred_count=0
for package in $PACKAGES; do
((deferred_count += 1))
case $package in
omarchy|omarchy-dev) deferred_runtime=1 ;;
omarchy-settings|omarchy-settings-dev) deferred_settings=1 ;;
*)
echo "Runtime dependency deferral only applies to the omarchy pair, not $package" >&2
exit 1
;;
esac
done
if (( deferred_runtime != 1 || deferred_settings != 1 || deferred_count != 2 )); then
echo "Runtime dependency deferral requires exactly the omarchy pair" >&2
exit 1
fi
fi
if [[ "$DRY_RUN" != true ]]; then
if [[ -z "$BUILD_PACKAGE" || -z "$BUILD_PLAN_DIR" ]]; then
echo "Use bin/build to plan and run isolated package builds" >&2
exit 1
fi
if ! grep -Fxq -- "$BUILD_PACKAGE" "$BUILD_PLAN_DIR/packages"; then
echo "Package is not in the build plan: $BUILD_PACKAGE" >&2
exit 1
fi
# Import GPG keys
/build/import-gpg-keys.sh || exit 1
mkdir -p "$BUILD_OUTPUT_DIR" "$FINAL_OUTPUT_DIR"
# Bring the container up to date before any makedepends are installed. The
# image is layer-cached, so its glibc drifts behind the mirror while makepkg
# -s pulls makedepends from the freshly synced database -- a partial upgrade
# that breaks the new packages (imagemagick wanting GLIBC_2.44, etc).
# Done before the Omarchy repos are added so only core/extra participate.
echo "==> Updating build container packages..."
sudo pacman -Syu --noconfirm || exit 1
# Configure Omarchy repositories for dependency resolution
echo "==> Configuring Omarchy repositories for dependency resolution..."
# Always add omarchy-build repo first (for incremental builds). Repository
# order is pacman's priority order, so this must precede the official repos;
# otherwise pacman can select an older official package with the same name.
# Packages in build-output are unsigned, so use SigLevel = Never.
sudo sed -i "/^\[core\]$/i [omarchy-build]\nSigLevel = Never\nServer = file://$BUILD_OUTPUT_DIR\n" /etc/pacman.conf
echo " -> omarchy-build (priority 1): $BUILD_OUTPUT_DIR"
# Initialize empty build database if it doesn't exist
cd "$BUILD_OUTPUT_DIR" || exit 1
if [[ ! -f "omarchy-build.db.tar.zst" ]]; then
# Create an empty database
repo-add omarchy-build.db.tar.zst >/dev/null 2>&1 || exit 1
ln -sf omarchy-build.db.tar.zst omarchy-build.db || exit 1
fi
# Fold any packages already in the workspace into the database, whether
# they came with an existing database or were dropped in by an earlier
# workflow job (OMARCHY_KEEP_BUILD_WORKSPACE). Without this a seeded
# workspace with no database would leave those packages invisible to
# dependency resolution.
staged_packages=()
for staged in *.pkg.tar.*; do
[[ -f "$staged" && "$staged" != *.sig ]] || continue
staged_packages+=("$staged")
done
if (( ${#staged_packages[@]} )); then
# Seed once per run. After that, only successful builds update the DB;
# rescanning in every container could reintroduce a failed build's partial
# outputs or overwrite the new version with an older kept artifact.
if [[ ! -e "$BUILD_PLAN_DIR/repository-initialized" ]]; then
echo "==> Rebuilding build database from existing packages..."
repo-add omarchy-build.db.tar.zst "${staged_packages[@]}" >/dev/null 2>&1 || exit 1
ln -sf omarchy-build.db.tar.zst omarchy-build.db || exit 1
fi
# A resumed/repeated build can produce different bytes under the same
# filename. Never let the shared download cache substitute older bytes
# for the staged artifacts described by this run's database.
for staged in "${staged_packages[@]}"; do
sudo rm -f "/var/cache/pacman/pkg/$staged" "/var/cache/pacman/pkg/$staged.sig" || exit 1
done
fi
touch "$BUILD_PLAN_DIR/repository-initialized" || exit 1
# Add omarchy repo if it has a database (stable packages). The local tree
# is trusted as-is; the public channel is verified against the omarchy
# keyring the image already carries.
if [[ -f "$FINAL_OUTPUT_DIR/omarchy.db.tar.zst" ]] || [[ -f "$FINAL_OUTPUT_DIR/omarchy.db" ]]; then
sudo sed -i "/^\[core\]$/i [omarchy]\nSigLevel = Optional TrustAll\nServer = file://$FINAL_OUTPUT_DIR\n" /etc/pacman.conf
echo " -> omarchy (priority 2): $FINAL_OUTPUT_DIR"
elif [[ -n "$PUBLISHED_REPO_SERVER" ]]; then
sudo sed -i "/^\[core\]$/i [omarchy]\nSigLevel = Required DatabaseOptional\nServer = $PUBLISHED_REPO_SERVER\n" /etc/pacman.conf
echo " -> omarchy (priority 2): $PUBLISHED_REPO_SERVER"
fi
# Sync pacman database
sudo pacman -Sy || exit 1
fi
echo "==> Package Builder"
echo "==> Target architecture: $ARCH"
echo "==> Mirror: $MIRROR"
echo "==> Package root: $PKGBUILDS_DIR"
echo "==> Build workspace: $BUILD_OUTPUT_DIR"
echo "==> Final output: $FINAL_OUTPUT_DIR"
if [[ "$DRY_RUN" == true ]]; then
echo "==> Dry run: yes (plan only; makepkg will not run)"
fi
SKIPPED_PACKAGES=""
# Find package directory
find_package_dir() {
local pkg="$1"
package_dir_for_name "$pkg"
}
# Get version from final output (production packages)
#
# Source package directories are named after the PKGBUILD pkgbase, but split
# packages are stored in the repo DB under their individual pkgname entries.
# Cache versions by both %NAME% and %BASE% so a pkgbase like
# libretro-vice-git can be found even though the DB only contains packages like
# libretro-vice-x64-git.
declare -A LOCAL_VERSION_BY_NAME=()
declare -A LOCAL_VERSION_BY_BASE=()
LOCAL_VERSION_CACHE_LOADED=false
LOCAL_VERSION_CACHE_DB=""
load_local_versions() {
local db="$PUBLISHED_DB_DIR/omarchy.db.tar.zst"
if [[ ! -f "$db" ]]; then
db="$PUBLISHED_DB_DIR/omarchy.db"
fi
[[ -f "$db" ]] || return 0
[[ "$LOCAL_VERSION_CACHE_LOADED" == true && "$LOCAL_VERSION_CACHE_DB" == "$db" ]] && return 0
LOCAL_VERSION_BY_NAME=()
LOCAL_VERSION_BY_BASE=()
local name base version
while IFS=$'\t' read -r name base version; do
[[ -n "$name" && -n "$version" ]] && LOCAL_VERSION_BY_NAME["$name"]="$version"
[[ -n "$base" && -n "$version" ]] && LOCAL_VERSION_BY_BASE["$base"]="$version"
done < <(
tar -xOf "$db" --wildcards '*/desc' 2>/dev/null | awk '
function emit() {
if (name != "" && version != "") print name "\t" base "\t" version
name=""; base=""; version=""
}
$0 == "%FILENAME%" { emit(); next }
$0 == "%NAME%" { if (name != "" && version != "") emit(); getline; name=$0; next }
$0 == "%BASE%" { getline; base=$0; next }
$0 == "%VERSION%" { getline; version=$0; next }
END { emit() }
'
)
LOCAL_VERSION_CACHE_LOADED=true
LOCAL_VERSION_CACHE_DB="$db"
}
get_local_version() {
local pkg="$1"
load_local_versions
if [[ -n "${LOCAL_VERSION_BY_NAME[$pkg]:-}" ]]; then
echo "${LOCAL_VERSION_BY_NAME[$pkg]}"
elif [[ -n "${LOCAL_VERSION_BY_BASE[$pkg]:-}" ]]; then
echo "${LOCAL_VERSION_BY_BASE[$pkg]}"
fi
}
# Check if package should be built for current architecture
# Returns 0 (success) if should build, 1 if should skip
should_build_for_arch() {
local pkg="$1"
local pkgdir
pkgdir=$(find_package_dir "$pkg")
[[ -n "$pkgdir" ]] && package_supports_arch "$pkgdir" "$ARCH"
}
# For VCS packages, makepkg recalculates pkgver() before the build. If the
# recalculated pkgver differs from the static PKGBUILD value, makepkg resets
# pkgrel to 1. That is right for stock VCS packages, but wrong for Omarchy's
# patched AUR packages where sync-aur intentionally applies a dotted local
# pkgrel suffix (for example 1.1) to sort above the upstream/AUR package.
# Refresh pkgver once, then restore the local dotted pkgrel before the real
# build so the produced package filename carries the Omarchy revision.
refresh_vcs_pkgver_preserving_local_pkgrel() {
local pkg="$1"
local pkgbuild="PKGBUILD"
grep -qE '^pkgver[[:space:]]*\(\)' "$pkgbuild" || return 0
local original_pkgver original_pkgrel refreshed_pkgver refreshed_pkgrel
original_pkgver=$(bash -c 'source PKGBUILD 2>/dev/null; echo "${pkgver:-}"')
original_pkgrel=$(bash -c 'source PKGBUILD 2>/dev/null; echo "${pkgrel:-}"')
# Omarchy local rebuilds use dotted pkgrels (AUR pkgrel + .suffix). Plain
# integer pkgrels can keep makepkg's normal reset-to-1 behavior on new VCS
# revisions.
[[ "$original_pkgrel" == *.* ]] || return 0
echo " Refreshing VCS pkgver before build (preserving local pkgrel=$original_pkgrel)..."
if [[ -x /usr/local/bin/pacman-for-makepkg ]]; then
PACMAN=/usr/local/bin/pacman-for-makepkg makepkg --nobuild --nodeps --skipinteg --skippgpcheck --noprepare --noconfirm
else
makepkg --nobuild --nodeps --skipinteg --skippgpcheck --noprepare --noconfirm
fi
if [[ $? -ne 0 ]]; then
echo " Failed to refresh VCS pkgver for $pkg"
return 1
fi
refreshed_pkgver=$(bash -c 'source PKGBUILD 2>/dev/null; echo "${pkgver:-}"')
refreshed_pkgrel=$(bash -c 'source PKGBUILD 2>/dev/null; echo "${pkgrel:-}"')
if [[ "$refreshed_pkgrel" != "$original_pkgrel" ]]; then
sed -i "s/^pkgrel=.*/pkgrel=$original_pkgrel/" PKGBUILD
echo " Restored local pkgrel suffix: $refreshed_pkgrel -> $original_pkgrel"
fi
if [[ -n "$refreshed_pkgver" && "$refreshed_pkgver" != "$original_pkgver" ]]; then
echo " Refreshed VCS version: $original_pkgver -> $refreshed_pkgver"
fi
}
# With runtime dependency checks deferred, makepkg runs --nodeps, so the
# build-time dependencies it would normally install with -s have to be
# installed explicitly: makedepends and checkdepends, including the
# architecture-suffixed variants for the current CARCH.
install_deferred_build_dependencies() {
local pkg="$1"
local -a build_deps=()
mapfile -t build_deps < <(
CARCH="$ARCH" bash -c '
source PKGBUILD
arch_makedepends="makedepends_${CARCH}[@]"
arch_checkdepends="checkdepends_${CARCH}[@]"
printf "%s\n" \
"${makedepends[@]}" "${!arch_makedepends}" \
"${checkdepends[@]}" "${!arch_checkdepends}"
' | awk 'NF && !seen[$0]++'
)
if (( ${#build_deps[@]} )); then
echo " Installing build-only dependencies for $pkg..."
sudo pacman -S --needed --noconfirm -- "${build_deps[@]}"
fi
}
# Build a package
build_package() {
local pkg="$1"
local pkgdir
pkgdir=$(find_package_dir "$pkg") || return 1
echo ""
echo " -> Processing: $pkg"
# Install this consumer's freshly built prerequisites in its own container.
# Qualifying the repository also upgrades an older dependency baked into
# the base image, even if that version would satisfy makepkg's check.
if [[ "$DEFER_RUNTIME_DEPS" != true ]]; then
local consumer dependency
local -a built_deps=()
while read -r consumer dependency; do
[[ "$consumer" == "$pkg" ]] && built_deps+=("omarchy-build/$dependency")
done < "$BUILD_PLAN_DIR/dependencies"
if (( ${#built_deps[@]} )); then
echo " Installing freshly built dependencies for $pkg..."
sudo /usr/local/bin/pacman-for-makepkg -S --needed --noconfirm -- "${built_deps[@]}" || return 1
fi
fi
# Copy to build directory
cd /src || return 1
rm -rf "$pkg" || return 1
cp -r "$pkgdir" "$pkg" || return 1
cd "/src/$pkg" || return 1
refresh_vcs_pkgver_preserving_local_pkgrel "$pkg" || {
return 1
}
# Get PKGBUILD version (including epoch if present)
local pkgbuild_version=$(bash -c 'source PKGBUILD; if [[ -n "$epoch" ]]; then echo "${epoch}:${pkgver}-${pkgrel}"; else echo "${pkgver}-${pkgrel}"; fi' 2>/dev/null)
if [[ -z "$pkgbuild_version" ]]; then
echo " Failed to read PKGBUILD version"
return 1
fi
# Show version info (version check already done in first pass)
local local_version=$(get_local_version "$pkg")
if [[ -n "$local_version" ]]; then
echo " Update available: $local_version -> $pkgbuild_version"
else
echo " New package (version: $pkgbuild_version)"
fi
# Import PGP keys from PKGBUILD validpgpkeys and keys/pgp/ directory
local pgp_keys=$(bash -c 'source PKGBUILD 2>/dev/null; echo "${validpgpkeys[@]}"')
if [[ -n "$pgp_keys" ]]; then
echo " Importing PGP keys from validpgpkeys..."
for key in $pgp_keys; do
gpg --receive-keys "$key" 2>/dev/null && echo " Received $key" || echo " Failed to receive $key"
done
fi
if [[ -d "keys/pgp" ]]; then
echo " Importing package-specific PGP keys..."
for keyfile in keys/pgp/*.asc; do
if [[ -f "$keyfile" ]]; then
gpg --import "$keyfile" 2>/dev/null && echo " Imported $(basename "$keyfile")" || echo " Failed to import $(basename "$keyfile")"
fi
done
fi
# Build package without signing (signing is done separately)
# PACMAN override uses a wrapper that adds --ask 4 to auto-resolve conflicts
# (e.g. rustup replacing rust) since --noconfirm defaults to 'N' on those prompts
local -a makepkg_flags=(-scf --noconfirm)
if [[ $DEFER_RUNTIME_DEPS == "true" ]]; then
# The pair's runtime dependencies (each other, and packages other jobs
# of the same pipeline build) are not resolvable here; the assembled set
# is installed in one verified transaction downstream. Only the
# build-time dependencies are installed, then makepkg skips the check.
install_deferred_build_dependencies "$pkg" || {
return 1
}
makepkg_flags=(-cf --noconfirm --nodeps)
fi
if PACMAN=/usr/local/bin/pacman-for-makepkg makepkg "${makepkg_flags[@]}"; then
# Ensure output directory exists
mkdir -p "$BUILD_OUTPUT_DIR"
# Copy only the artifacts makepkg declares as outputs. A PKGBUILD may use
# another pacman package as a source (schist-bin does); a *.pkg.tar.* glob
# would mistake that source archive for one of our freshly built packages.
local -a package_files=()
mapfile -t package_files < <(makepkg --packagelist)
if [[ ${#package_files[@]} -eq 0 ]]; then
echo " Makepkg produced no package files for $pkg"
return 1
fi
local -a new_pkgs=()
local pkg_path pkg_file
for pkg_path in "${package_files[@]}"; do
pkg_file=${pkg_path##*/}
if [[ ! -f "$pkg_file" ]]; then
# makepkg predicts an automatic -debug output whenever debug is
# enabled, but data-only packages may contain no symbols and therefore
# legitimately produce no debug archive.
if [[ "$pkg_file" == *-debug-*.pkg.tar.* ]]; then
continue
fi
echo " Expected package file was not produced: $pkg_file"
return 1
fi
cp "$pkg_file" "$BUILD_OUTPUT_DIR/" || return 1
new_pkgs+=("$pkg_file")
done
cd "$BUILD_OUTPUT_DIR" || return 1
# Add every output from this build, including split packages.
if [[ ${#new_pkgs[@]} -gt 0 ]]; then
repo-add omarchy-build.db.tar.zst "${new_pkgs[@]}" >/dev/null 2>&1 || return 1
ln -sf omarchy-build.db.tar.zst omarchy-build.db || return 1
fi
# A release may publish successful builds even when a peer fails. Record
# outputs only after this package's entire split build has completed.
mkdir -p "$BUILD_PLAN_DIR/artifacts" || return 1
printf '%s\n' "${new_pkgs[@]}" > "$BUILD_PLAN_DIR/artifacts/$pkg" || return 1
echo " Successfully built $pkg"
return 0
else
echo " Makepkg failed for $pkg"
echo " DEBUG: Files in build directory:"
ls -lah *.pkg.tar.* 2>&1 | head -20 || echo " No package files found"
return 1
fi
}
# Get package dependencies from PKGBUILD
get_package_deps() {
local pkg="$1"
local pkgdir=$(find_package_dir "$pkg")
local pkgbuild="$pkgdir/PKGBUILD"
if [[ ! -f "$pkgbuild" ]]; then
return
fi
# Include test dependencies and target-specific arrays: each container must
# receive its prerequisites through the repository, not a previous build.
(
CARCH="$ARCH"
source "$pkgbuild" 2>/dev/null
for kind in depends makedepends checkdepends; do
generic="${kind}[@]"
specific="${kind}_${CARCH}[@]"
printf '%s\n' "${!generic}" "${!specific}"
done
) | awk 'NF && !seen[$0]++' | while read -r dep; do
# Strip version constraints (e.g., 'hyprshade>=1.0' -> 'hyprshade')
dep=$(echo "$dep" | sed 's/[<>=].*$//')
# Check if this dependency exists in our pkgbuilds
if find_package_dir "$dep" >/dev/null 2>&1; then
echo "$dep"
fi
done
}
# For VCS packages (those with a pkgver() function), the static pkgver= in the
# PKGBUILD is just a placeholder; the real version is computed at build time
# from the git checkout. Without this check, version comparison always reports a
# mismatch and we rebuild on every run, producing a package with the same
# name+version as one already in production. Detect this by comparing the
# upstream commit hash to the hash suffix already in the production version
# (both `...gabcdef0` and `...abcdef0` styles are common). Returns 0 when
# upstream is unchanged (build can be skipped).
check_vcs_unchanged() {
local pkg="$1"
local pkgdir="$2"
local pkgbuild="$pkgdir/PKGBUILD"
grep -qE '^pkgver[[:space:]]*\(\)' "$pkgbuild" || return 1
local local_version=$(get_local_version "$pkg")
[[ -z "$local_version" ]] && return 1
# If epoch or pkgrel changed in PKGBUILD, rebuild even if upstream is unchanged
local pkgbuild_epoch=$(cd "$pkgdir" && bash -c 'source PKGBUILD 2>/dev/null; echo "${epoch:-}"')
local pkgbuild_pkgrel=$(cd "$pkgdir" && bash -c 'source PKGBUILD 2>/dev/null; echo "${pkgrel}"')
local prod_pkgrel="${local_version##*-}"
local prod_no_pkgrel="${local_version%-*}"
local prod_epoch=""
if [[ "$prod_no_pkgrel" == *:* ]]; then
prod_epoch="${prod_no_pkgrel%%:*}"
fi
[[ "$pkgbuild_epoch" != "$prod_epoch" ]] && return 1
[[ "$pkgbuild_pkgrel" != "$prod_pkgrel" ]] && return 1
# Compare the commit represented in the published version to the current
# upstream ref. Supports unfragmented git sources as well as #branch=,
# #tag=, and #commit= fragments.
local prod_hash=$(package_extract_vcs_hash_from_version "$local_version")
[[ -z "$prod_hash" ]] && return 1
local upstream_hash=$(package_git_upstream_hash "$pkgdir")
[[ -z "$upstream_hash" ]] && return 1
[[ "$prod_hash" == "$upstream_hash" ]]
}
# Check which packages need building (version check only)
check_needs_build() {
local pkg="$1"
local pkgdir=$(find_package_dir "$pkg")
local pkgbuild="$pkgdir/PKGBUILD"
[[ ! -f "$pkgbuild" ]] && return 1
# Get PKGBUILD version (including epoch if present)
local pkgbuild_version=$(cd "$pkgdir" && bash -c 'source PKGBUILD; if [[ -n "$epoch" ]]; then echo "${epoch}:${pkgver}-${pkgrel}"; else echo "${pkgver}-${pkgrel}"; fi' 2>/dev/null)
[[ -z "$pkgbuild_version" ]] && return 1
# Check if already built
local local_version=$(get_local_version "$pkg")
if grep -qE '^pkgver[[:space:]]*\(\)' "$pkgbuild"; then
if [[ -n "$local_version" && -n "$(package_extract_vcs_hash_from_version "$local_version")" ]]; then
if check_vcs_unchanged "$pkg" "$pkgdir"; then
return 1 # VCS upstream ref is already represented in the repo
else
return 0 # New VCS ref, missing repo package, or pkgrel/epoch changed
fi
elif [[ "$local_version" == "$pkgbuild_version" ]]; then
return 1 # VCS package does not expose a hash; fall back to static version
else
return 0
fi
fi
if [[ "$local_version" == "$pkgbuild_version" ]]; then
return 1 # Already up to date
fi
# Match check-versions: a retained archive is already published even when
# the DB now indexes a newer release (for example, 4.0.4rc1 vs 4.0.3).
# Rebuilding it would produce different bytes under an immutable filename.
if package_version_is_published "$FINAL_OUTPUT_DIR" "$pkg" "$pkgbuild_version" "$ARCH"; then
echo " + $pkg $pkgbuild_version - archive already published; skipping rebuild"
return 1
fi
return 0 # Needs building
}
# Collect packages that should be built for the selected mirror
collect_packages() {
packages_for_unscoped_build "$MIRROR" "$ARCH"
}
# Main execution
if [[ "$DRY_RUN" != true ]]; then
cd "$SRC_DIR" || exit 1
build_package "$BUILD_PACKAGE"
exit $?
fi
echo "==> Checking which packages need building..."
# First pass: determine which packages need building
PACKAGES_TO_BUILD=()
ORDERED_PACKAGES=()
PLANNED_DEPENDENCIES=()
# If PACKAGES is specified, only check those packages
if [[ -n "$PACKAGES" ]]; then
echo "==> Checking specified packages: $PACKAGES"
for pkg_name in $PACKAGES; do
pkgdir=$(find_package_dir "$pkg_name")
if [[ -z "$pkgdir" || ! -f "$pkgdir/PKGBUILD" ]]; then
echo "==> ERROR: Package '$pkg_name' not found in $PKGBUILDS_DIR"
exit 1
fi
if ! package_builds_for_mirror "$pkgdir" "$MIRROR"; then
if [[ "$MIRROR" == "stable" ]]; then
echo " - $pkg_name - not in release_ring=fast; build edge and promote with repo migrate"
else
echo " - $pkg_name - not configured for direct $MIRROR builds"
fi
SKIPPED_PACKAGES="$SKIPPED_PACKAGES $pkg_name"
continue
fi
# Check if package should be built for this architecture
if ! should_build_for_arch "$pkg_name"; then
echo " - $pkg_name - not built for $ARCH"
SKIPPED_PACKAGES="$SKIPPED_PACKAGES $pkg_name"
continue
fi
if check_needs_build "$pkg_name"; then
PACKAGES_TO_BUILD+=("$pkg_name")
else
echo " + $pkg_name - already up to date"
SKIPPED_PACKAGES="$SKIPPED_PACKAGES $pkg_name"
fi
done
else
# Build all packages that need updates from the relevant directories
while IFS= read -r pkg; do
if check_needs_build "$pkg"; then
PACKAGES_TO_BUILD+=("$pkg")
else
echo " + $pkg - already up to date"
SKIPPED_PACKAGES="$SKIPPED_PACKAGES $pkg"
fi
done < <(collect_packages)
fi
if [[ ${#PACKAGES_TO_BUILD[@]} -eq 0 ]]; then
echo "==> All packages are up to date!"
else
echo "==> ${#PACKAGES_TO_BUILD[@]} package(s) need building: ${PACKAGES_TO_BUILD[*]}"
echo "==> Determining build order based on dependencies..."
# Second pass: order only the packages that need building
# Strategy: build packages with no unmet dependencies first
declare -A unmet_deps_count # How many dependencies does this package still need?
declare -A blocks_packages # Which packages are waiting for this one?
# Count unmet dependencies for each package
for pkg in "${PACKAGES_TO_BUILD[@]}"; do
unmet_deps_count[$pkg]=0
done
# Build the dependency relationships
for pkg in "${PACKAGES_TO_BUILD[@]}"; do
while IFS= read -r dep; do
# Only care about deps that are being built in this run
for build_pkg in "${PACKAGES_TO_BUILD[@]}"; do
if [[ "$dep" == "$build_pkg" ]]; then
# pkg needs dep, so increment pkg's unmet count
((unmet_deps_count[$pkg]++))
# Track that dep blocks pkg from building
blocks_packages[$dep]="${blocks_packages[$dep]} $pkg"
PLANNED_DEPENDENCIES+=("$pkg $dep")
fi
done
done < <(get_package_deps "$pkg")
done
# Start with packages that have all dependencies met (count = 0)
ready_to_build=()
for pkg in "${PACKAGES_TO_BUILD[@]}"; do
if [[ ${unmet_deps_count[$pkg]} -eq 0 ]]; then
ready_to_build+=("$pkg")
fi
done
# Build packages as dependencies become available
while [[ ${#ready_to_build[@]} -gt 0 ]]; do
# Take the first ready package
current="${ready_to_build[0]}"
ready_to_build=("${ready_to_build[@]:1}")
ORDERED_PACKAGES+=("$current")
# This package is now built, so packages waiting for it can proceed
for blocked_pkg in ${blocks_packages[$current]}; do
((unmet_deps_count[$blocked_pkg]--))
if [[ ${unmet_deps_count[$blocked_pkg]} -eq 0 ]]; then
ready_to_build+=("$blocked_pkg")
fi
done
done
# Check for circular dependencies
if [[ ${#ORDERED_PACKAGES[@]} -ne ${#PACKAGES_TO_BUILD[@]} ]]; then
echo "ERROR: Circular dependency detected!"
exit 1
fi
echo "==> Build order: ${ORDERED_PACKAGES[*]}"
fi
# The host consumes plain data, never shell code or parsed human log output.
if [[ -n "$BUILD_PLAN_DIR" ]]; then
mkdir -p "$BUILD_PLAN_DIR" || exit 1
printf '%s\n' "${ORDERED_PACKAGES[@]}" | sed '/^$/d' > "$BUILD_PLAN_DIR/packages" || exit 1
printf '%s\n' "${PLANNED_DEPENDENCIES[@]}" | sed '/^$/d' > "$BUILD_PLAN_DIR/dependencies" || exit 1
printf '%s\n' $SKIPPED_PACKAGES | sed '/^$/d' > "$BUILD_PLAN_DIR/skipped" || exit 1
fi
echo ""
echo "==> Plan complete. Packages that would build: ${ORDERED_PACKAGES[*]}"