#!/bin/bash set -euo pipefail BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..") source "$BUILD_ROOT/helpers/message-helpers.sh" source "$BUILD_ROOT/helpers/paths.sh" source "$BUILD_ROOT/helpers/package-metadata.sh" TEMP_DIR=$(mktemp -d) trap 'rm -rf "$TEMP_DIR"' EXIT SPECIFIC_PACKAGES=() SELF_TEST=false # Only the repositories a user actually installs from. A Qt release sitting in # testing or kde-unstable is not what the build container will link against, and # rebuilding for it would ship a package built against the wrong ABI. OFFICIAL_REPOS=" core extra multilib core-debug extra-debug " # The published repository, used as the floor a bumped pkgrel has to clear. # Only x86_64 is published today; aarch64 has no repository to compare against. PUBLISHED_BASE_URL="${OMARCHY_PUBLISHED_BASE_URL:-https://pkgs.omarchy.org}" PUBLISHED_MIRRORS=(edge stable) PUBLISHED_ARCH=x86_64 usage() { cat </dev/null) || return 0 awk -v allowed="$OFFICIAL_REPOS" ' /^Repository[[:space:]]*:/ { repo = $3 } /^Version[[:space:]]*:/ { if (index(allowed, " " repo " ") > 0) { print $3; exit } } ' <<<"$info" } declare -A PUBLISHED_VERSION=() PUBLISHED_LOADED=false PUBLISHED_AVAILABLE=true remember_published() { local name="$1" local version="$2" local known="${PUBLISHED_VERSION[$name]:-}" # A package can be in both mirrors at different revisions. The floor is the # highest of them, because that is what a user could already have installed. if [[ -z "$known" || "$(vercmp "$version" "$known")" -gt 0 ]]; then PUBLISHED_VERSION["$name"]="$version" fi } # Versions currently published, read from the repository databases. Split # packages are stored under their own pkgname, so both %NAME% and %BASE% are # recorded to make a pkgbase findable. load_published_versions() { [[ "$PUBLISHED_LOADED" == true ]] && return 0 PUBLISHED_LOADED=true local mirror db name base version for mirror in "${PUBLISHED_MIRRORS[@]}"; do db="$TEMP_DIR/published-$mirror.db.tar.zst" if ! curl -fsSL --max-time 120 -o "$db" \ "$PUBLISHED_BASE_URL/$mirror/$PUBLISHED_ARCH/omarchy.db.tar.zst" 2>/dev/null; then print_warning "Could not read the published $mirror database; bumps are not checked against it this run" PUBLISHED_AVAILABLE=false continue fi while IFS=$'\t' read -r name base version; do [[ -n "$name" && -n "$version" ]] && remember_published "$name" "$version" [[ -n "$base" && -n "$version" ]] && remember_published "$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() } ' ) done } published_version() { local package="$1" load_published_versions echo "${PUBLISHED_VERSION[$package]:-}" } # The pkgver of a full version string, with any epoch and pkgrel removed. version_pkgver() { local version="${1#*:}" echo "${version%-*}" } pkgbuild_field() { local package_dir="$1" local field="$2" (cd "$package_dir" && env -u OMARCHY_SRC bash -c "source PKGBUILD 2>/dev/null; echo \"\${$field:-}\"") } # 2 -> 3, and 1.1 -> 1.2. Anything else is a pkgrel this command has no business # rewriting. bump_pkgrel() { local pkgrel="$1" [[ "$pkgrel" =~ ^[0-9]+(\.[0-9]+)?$ ]] || return 1 local head tail if [[ "$pkgrel" == *.* ]]; then head="${pkgrel%.*}." tail="${pkgrel##*.}" else head="" tail="$pkgrel" fi echo "${head}$((tail + 1))" } write_pkgrel() { local package_dir="$1" local pkgrel="$2" local pkgbuild="$package_dir/PKGBUILD" if [[ $(grep -c '^pkgrel=' "$pkgbuild") -ne 1 ]]; then print_error "Expected exactly one pkgrel assignment in $pkgbuild" return 1 fi # Every edit lands on a scratch copy that replaces the PKGBUILD in one rename, # so a failed rewrite leaves the original alone rather than half updated. local scratch="$pkgbuild.sync-rebuilds" cp "$pkgbuild" "$scratch" || return 1 sed -i "s/^pkgrel=.*/pkgrel=$pkgrel/" "$scratch" if ! bash -n "$scratch" 2>/dev/null; then print_error "Rewritten PKGBUILD is not valid shell" rm -f "$scratch" return 1 fi local written written=$(CARCH=x86_64 bash -c 'source "$1" >/dev/null 2>&1 || exit 1; echo "$pkgrel"' _ "$scratch" 2>/dev/null) if [[ "$written" != "$pkgrel" ]]; then print_error "Rewritten PKGBUILD reads back pkgrel=$written, not $pkgrel" rm -f "$scratch" return 1 fi chmod --reference="$pkgbuild" "$scratch" mv "$scratch" "$pkgbuild" } write_metadata() { local package_dir="$1" local filter="$2" shift 2 local metadata metadata=$(metadata_file_for_dir "$package_dir") local scratch="$metadata.sync-rebuilds" jq "$@" "$filter" "$metadata" > "$scratch" || { rm -f "$scratch"; return 1; } chmod --reference="$metadata" "$scratch" mv "$scratch" "$metadata" } record_triggers() { local package_dir="$1" local current="$2" write_metadata "$package_dir" '.rebuilt_against = $current' --argjson current "$current" } # Metadata that does not parse would otherwise drop its package out of the run # without a word, because every query of it reports nothing and an absent # rebuild_on is indistinguishable from an unreadable one. check_metadata_readable() { local package_dir="$1" local metadata metadata=$(metadata_file_for_dir "$package_dir") [[ -f "$metadata" ]] || return 0 if ! jq empty "$metadata" 2>/dev/null; then print_error "Unreadable metadata, skipping $(basename "$package_dir"): $metadata" ((++FAILED)) return 1 fi } sync_package() { local package="$1" local package_dir="$PKGBUILDS_DIR/$package" if [[ ! -f "$package_dir/PKGBUILD" ]]; then print_error "Package $package has no PKGBUILD" ((++FAILED)) return 0 fi check_metadata_readable "$package_dir" || return 0 local triggers=() mapfile -t triggers < <(package_rebuild_triggers "$package_dir") if [[ ${#triggers[@]} -eq 0 ]]; then if [[ "$SPECIFIC_MODE" == true ]]; then print_error "Package $package does not declare rebuild_on" ((++FAILED)) else print_info "Skipping $package: no rebuild triggers" ((++SKIPPED)) fi return 0 fi print_info "Checking $package against ${triggers[*]}..." local current="{}" trigger version for trigger in "${triggers[@]}"; do version=$(repo_version "$trigger") if [[ -z "$version" ]]; then print_error " $trigger is in no official repository; leaving $package alone" ((++FAILED)) return 0 fi if ! current=$(jq -c --arg name "$trigger" --arg version "$version" '.[$name] = $version' <<<"$current"); then print_error " Could not record $trigger $version for $package" ((++FAILED)) return 0 fi done local recorded if ! recorded=$(package_metadata_value "$package_dir" '.rebuilt_against' ""); then print_error " Could not read .omarchy/package.json for $package" ((++FAILED)) return 0 fi [[ -n "$recorded" && "$recorded" != "null" ]] || recorded="{}" # Walk the declared triggers rather than the record, so a name the record does # not carry reads as changed instead of going unexamined forever. local moved if ! moved=$(jq -r --argjson recorded "$recorded" ' to_entries | map(select($recorded[.key] != .value) | "\(.key) \($recorded[.key] // "unrecorded") -> \(.value)") | join(", ") ' <<<"$current"); then print_error " Could not compare recorded trigger versions for $package" ((++FAILED)) return 0 fi if [[ -z "$moved" ]]; then print_info " Already rebuilt against $(jq -r 'to_entries | map("\(.key) \(.value)") | join(", ")' <<<"$current")" ((++SKIPPED)) return 0 fi local pkgrel next_pkgrel pkgrel=$(pkgbuild_field "$package_dir" pkgrel) # An AUR-synced package gets its PKGBUILD replaced wholesale on the next sync, # so the bump only survives as the dotted Omarchy suffix that sync-aur # reapplies from .omarchy/package.json. local suffix="" if package_sync_enabled "$package_dir"; then if [[ "$pkgrel" == *.* ]]; then next_pkgrel=$(bump_pkgrel "$pkgrel") || next_pkgrel="" suffix="${next_pkgrel##*.}" else next_pkgrel="$pkgrel.1" suffix="1" fi else next_pkgrel=$(bump_pkgrel "$pkgrel") || next_pkgrel="" fi if [[ -z "$next_pkgrel" ]]; then print_error " Cannot bump pkgrel=$pkgrel for $package; bump it by hand" ((++FAILED)) return 0 fi local pkgver epoch old_version new_version pkgver=$(pkgbuild_field "$package_dir" pkgver) epoch=$(pkgbuild_field "$package_dir" epoch) old_version="${epoch:+$epoch:}$pkgver-$pkgrel" new_version="${epoch:+$epoch:}$pkgver-$next_pkgrel" if [[ "$(vercmp "$new_version" "$old_version")" -le 0 ]]; then print_error " pkgrel $pkgrel -> $next_pkgrel would not be an upgrade for $package" ((++FAILED)) return 0 fi # The checked-in version is not the floor. What a user already has is, and a # checkout that has fallen behind the repository can otherwise be bumped to # something pacman orders below what it would replace. local floor floor=$(published_version "$package") if [[ -n "$floor" && "$(version_pkgver "$floor")" == "$pkgver" ]]; then if [[ "$(vercmp "$new_version" "$floor")" -le 0 ]]; then print_error " $new_version would not be an upgrade over the published $floor; leaving $package alone" ((++FAILED)) return 0 fi fi if ! write_pkgrel "$package_dir" "$next_pkgrel"; then print_error " Failed to bump pkgrel for $package" ((++FAILED)) return 0 fi if [[ -n "$suffix" ]] && ! write_metadata "$package_dir" '.pkgrel.suffix = ($suffix | tonumber)' --arg suffix "$suffix"; then print_error " Failed to record pkgrel suffix for $package" ((++FAILED)) return 0 fi if ! record_triggers "$package_dir" "$current"; then print_error " Failed to record trigger versions for $package" ((++FAILED)) return 0 fi print_success " $moved; pkgrel $pkgrel -> $next_pkgrel" ((++UPDATED)) } run_sync() { if [[ ${#SPECIFIC_PACKAGES[@]} -gt 0 ]]; then SPECIFIC_MODE=true for package in "${SPECIFIC_PACKAGES[@]}"; do sync_package "$package" done else # Enumerated up front rather than streamed, so a producer that dies partway # cannot quietly shorten the list of packages considered. local packages=() mapfile -t packages < <(package_dirs) local package_dir for package_dir in "${packages[@]}"; do check_metadata_readable "$package_dir" || continue if package_has_rebuild_triggers "$package_dir"; then sync_package "$(basename "$package_dir")" fi done fi echo "" if [[ $FAILED -gt 0 ]]; then print_error "Rebuild trigger sync completed with failures" else print_success "Rebuild trigger sync complete!" fi echo " Target: $PKGBUILDS_DIR" echo " Updated: $UPDATED" echo " Skipped: $SKIPPED" echo " Failed: $FAILED" [[ $FAILED -eq 0 ]] } # --- self-test --------------------------------------------------------------- # Each case runs the real script against a throwaway repository root, with # pacman and curl replaced by stubs, so what is exercised is the decision path # itself rather than a restatement of it. selftest_root() { local name="$1" local root="$TEMP_DIR/case-$name" mkdir -p "$root/bin" "$root/helpers" "$root/pkgbuilds" "$root/stub" cp "$BUILD_ROOT/bin/sync-rebuilds" "$root/bin/" cp "$BUILD_ROOT"/helpers/*.sh "$root/helpers/" echo "$root" } selftest_package() { local root="$1" name="$2" pkgrel="$3" metadata="$4" pkgver="${5:-1.0}" local dir="$root/pkgbuilds/$name" mkdir -p "$dir/.omarchy" printf 'pkgname=%s\npkgver=%s\npkgrel=%s\narch=(x86_64)\n' "$name" "$pkgver" "$pkgrel" > "$dir/PKGBUILD" printf '%s\n' "$metadata" > "$dir/.omarchy/package.json" } selftest_pacman() { local root="$1" shift printf '%s\n' "$@" > "$root/stub/versions" cat > "$root/stub/pacman" <<'STUB' #!/bin/bash [[ "$1" == "-Si" ]] || exit 1 version=$(awk -F= -v p="$2" '$1 == p { print $2; exit }' "$(dirname "$0")/versions") [[ -n "$version" ]] || exit 1 printf 'Repository : extra\nName : %s\nVersion : %s\n\n' "$2" "$version" STUB chmod +x "$root/stub/pacman" } # Serves a repository database assembled by hand from name=version pairs. With # none given the stub fails, which is how the unreachable-repository path is # exercised. selftest_published() { local root="$1" shift local staging="$root/stub/db" local entry name version if [[ $# -gt 0 ]]; then rm -rf "$staging" mkdir -p "$staging" for entry in "$@"; do name="${entry%=*}" version="${entry#*=}" mkdir -p "$staging/$name-$version" printf '%%FILENAME%%\n%s-%s-x86_64.pkg.tar.zst\n\n%%NAME%%\n%s\n\n%%BASE%%\n%s\n\n%%VERSION%%\n%s\n' \ "$name" "$version" "$name" "$name" "$version" > "$staging/$name-$version/desc" done tar --zstd -cf "$root/stub/omarchy.db.tar.zst" -C "$staging" . fi cat > "$root/stub/curl" <<'STUB' #!/bin/bash out="" while [[ $# -gt 0 ]]; do case "$1" in -o) out="$2"; shift 2 ;; *) shift ;; esac done db="$(dirname "$0")/omarchy.db.tar.zst" [[ -f "$db" && -n "$out" ]] || exit 22 cp "$db" "$out" STUB chmod +x "$root/stub/curl" } cmd_self_test() { local failures=0 local root check() { local label="$1" expected="$2" got="$3" if [[ "$got" == "$expected" ]]; then echo " ok: $label" else echo " FAIL: $label -> $got (expected $expected)" failures=$((failures + 1)) fi } run_case() { local root="$1" shift local status=0 PATH="$root/stub:$PATH" "$root/bin/sync-rebuilds" "$@" > "$root/output" 2>&1 || status=$? echo "$status" } pkgrel_of() { grep -m1 '^pkgrel=' "$1/PKGBUILD" | cut -d= -f2 } print_header "sync-rebuilds self-test" echo "A trigger missing from the record counts as changed:" root=$(selftest_root partial) selftest_package "$root" t-partial 1 '{"source":"local","rebuild_on":["dep-a","dep-b"],"rebuilt_against":{"dep-a":"1-1"}}' selftest_pacman "$root" dep-a=1-1 dep-b=2-2 selftest_published "$root" check "run succeeds" 0 "$(run_case "$root")" check "pkgrel bumped" 2 "$(pkgrel_of "$root/pkgbuilds/t-partial")" check "unrecorded trigger now recorded" "2-2" \ "$(jq -r '.rebuilt_against["dep-b"]' "$root/pkgbuilds/t-partial/.omarchy/package.json")" echo "Opting a package in buys a rebuild rather than a bare record:" root=$(selftest_root fresh) selftest_package "$root" t-fresh 1 '{"source":"local","rebuild_on":["dep-a"]}' selftest_pacman "$root" dep-a=1-1 selftest_published "$root" check "run succeeds" 0 "$(run_case "$root")" check "pkgrel bumped" 2 "$(pkgrel_of "$root/pkgbuilds/t-fresh")" check "trigger recorded" "1-1" \ "$(jq -r '.rebuilt_against["dep-a"]' "$root/pkgbuilds/t-fresh/.omarchy/package.json")" echo "An unchanged package is left alone:" root=$(selftest_root current) selftest_package "$root" t-current 1 '{"source":"local","rebuild_on":["dep-a"],"rebuilt_against":{"dep-a":"1-1"}}' selftest_pacman "$root" dep-a=1-1 selftest_published "$root" check "run succeeds" 0 "$(run_case "$root")" check "pkgrel untouched" 1 "$(pkgrel_of "$root/pkgbuilds/t-current")" echo "Metadata that does not parse fails the run instead of vanishing from it:" root=$(selftest_root unreadable) selftest_package "$root" t-broken 1 '{"source":"local","rebuild_on":["dep-a"' selftest_package "$root" t-good 1 '{"source":"local","rebuild_on":["dep-a"]}' selftest_pacman "$root" dep-a=1-1 selftest_published "$root" check "run fails" 1 "$(run_case "$root")" check "the readable package is still processed" 2 "$(pkgrel_of "$root/pkgbuilds/t-good")" echo "A bump that pacman would not order above the published package is refused:" root=$(selftest_root floor) selftest_package "$root" t-floor 1 '{"source":"local","rebuild_on":["dep-a"],"rebuilt_against":{"dep-a":"0-0"}}' selftest_pacman "$root" dep-a=1-1 selftest_published "$root" t-floor=1.0-5 check "run fails" 1 "$(run_case "$root")" check "pkgrel untouched" 1 "$(pkgrel_of "$root/pkgbuilds/t-floor")" check "record untouched" "0-0" \ "$(jq -r '.rebuilt_against["dep-a"]' "$root/pkgbuilds/t-floor/.omarchy/package.json")" echo "An AUR-synced package is bumped as a dotted suffix that sync-aur reapplies:" root=$(selftest_root aur) selftest_package "$root" t-aur 3 '{"source":"aur","rebuild_on":["dep-a"],"rebuilt_against":{"dep-a":"0-0"}}' selftest_pacman "$root" dep-a=1-1 selftest_published "$root" check "run succeeds" 0 "$(run_case "$root")" check "pkgrel suffixed" "3.1" "$(pkgrel_of "$root/pkgbuilds/t-aur")" check "suffix recorded for the next AUR sync" 1 \ "$(jq -r '.pkgrel.suffix' "$root/pkgbuilds/t-aur/.omarchy/package.json")" echo "" if [[ "$failures" -eq 0 ]]; then print_success "Self-test passed" return 0 fi print_error "$failures self-test failure(s)" return 1 } if [[ "$SELF_TEST" == true ]]; then cmd_self_test exit $? fi for tool in pacman vercmp jq curl; do if ! command -v "$tool" >/dev/null 2>&1; then print_error "$tool not found: reading trigger versions and ordering pkgrels both need pacman" exit 1 fi done print_header "Rebuild Trigger Sync" run_sync