#!/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" source "$BUILD_ROOT/helpers/upstream-github.sh" TEMP_DIR=$(mktemp -d) trap 'rm -rf "$TEMP_DIR"' EXIT SPECIFIC_PACKAGES=() usage() { cat </.omarchy/upstream.sh instead, a hook that reports the newest upstream release as JSON on stdout: { "pkgver": "1.2.3", "sha256sums": { "x86_64": [""], "aarch64": [""] } } Architecture keys become sha256sums_ in the PKGBUILD; the key "any" means the unsuffixed sha256sums array. An empty object ({}) reports no update. When the reported version is newer than the checked-in one, pkgver and the listed checksum arrays are rewritten and pkgrel is reset to 1. A package may declare "min_release_age" in .omarchy/package.json ("24h", "2d", or bare seconds) to quarantine fresh releases until maintainers have had time to pull a bad or compromised one. The window is exported to the hook as MIN_RELEASE_AGE_SECONDS so it can select the newest release that has already cleared it, and enforced here as a backstop: the hook must then report "published_at" (ISO 8601), and a release younger than the window is treated as no update. A maintainer shipping an emergency update inside the window runs: BYPASS_MIN_RELEASE_AGE=1 $0 . Scheduled automation never sets the bypass, so the resulting change still goes through a reviewed PR. Arguments: PACKAGE One or more package names to update (optional) Commands: self-test Run the offline fixture tests for release selection, the quarantine backstop, and metadata parsing Examples: $0 # Update every package with an upstream source $0 openai-codex-desktop # Update specific packages EOF } while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage exit 0 ;; --*) print_error "Unknown option: $1" exit 1 ;; *) SPECIFIC_PACKAGES+=("$1") shift ;; esac done if ! command -v vercmp >/dev/null 2>&1; then print_error "vercmp not found: this needs pacman to decide whether a release is an upgrade" exit 1 fi print_header "Upstream Package Sync" UPDATED=0 SKIPPED=0 FAILED=0 SPECIFIC_MODE=false get_pkgver() { local package_dir="$1" grep -m1 '^pkgver=' "$package_dir/PKGBUILD" | cut -d= -f2- | tr -d "\"'" } assert_single_assignment() { local pkgbuild="$1" local pattern="$2" local label="$3" if [[ $(grep -c "$pattern" "$pkgbuild") -ne 1 ]]; then print_error "Expected exactly one $label assignment in $pkgbuild" return 1 fi } set_pkgbuild_scalar() { local pkgbuild="$1" local field="$2" local value="$3" assert_single_assignment "$pkgbuild" "^${field}=" "$field" || return 1 sed -i "s/^${field}=.*/${field}=${value}/" "$pkgbuild" } # Replace an array assignment, however many lines the original spans. set_pkgbuild_array() { local pkgbuild="$1" local name="$2" shift 2 local values=("$@") assert_single_assignment "$pkgbuild" "^${name}=(" "$name" || return 1 if [[ ${#values[@]} -eq 0 ]]; then print_error "No values to write for ${name}" return 1 fi local block="$TEMP_DIR/array-block" if [[ ${#values[@]} -eq 1 ]]; then printf "%s=('%s')\n" "$name" "${values[0]}" > "$block" || return 1 else { printf '%s=(\n' "$name" printf " '%s'\n" "${values[@]}" printf ')\n' } > "$block" || return 1 fi local rewritten="$TEMP_DIR/pkgbuild-rewritten" if ! awk -v prefix="${name}=(" -v block="$block" ' !replaced && index($0, prefix) == 1 { while ((getline line < block) > 0) print line close(block) replaced = 1 # A ")" anywhere past the opening closes the array; testing for one at end # of line instead would treat a trailing comment as a continuation and eat # every line up to the next ")". if (index(substr($0, length(prefix) + 1), ")") == 0) skipping = 1 next } skipping { if ($0 ~ /\)/) skipping = 0; next } { print } ' "$pkgbuild" > "$rewritten"; then print_error "Failed to rewrite ${name} in $pkgbuild" rm -f "$rewritten" return 1 fi mv "$rewritten" "$pkgbuild" } # Backstop verdict for a reported release against min_release_age. Returns 0 # when old enough (or no policy is set, or the bypass is deliberate), 1 when # the release is younger than the window, 2 when the report carries no usable # published_at and the age cannot be established at all. release_age_status() { local release="$1" min_age="$2" (( min_age > 0 )) || return 0 [[ "${BYPASS_MIN_RELEASE_AGE:-}" != "1" ]] || return 0 local published_at published_epoch published_at=$(jq -r '.published_at // empty' <<<"$release") if [[ -z "$published_at" ]] || ! published_epoch=$(date --date="$published_at" +%s 2>/dev/null); then return 2 fi (( $(date +%s) - published_epoch >= min_age )) || return 1 } # pacman's own comparator, because nothing else agrees with it at the corners: # sort -V calls 1.0a newer than 1.0, vercmp calls it older, and pacman is what # decides whether a published package is an upgrade. version_is_newer() { local candidate="$1" local current="$2" [[ "$candidate" != "$current" ]] || return 1 [[ "$(vercmp "$candidate" "$current")" -gt 0 ]] } validate_release() { local release="$1" # pkgver is written into the PKGBUILD, which makepkg sources as shell, so it # is held to pacman's own character set rather than merely being non-empty. # The anchors are \A and \z, not ^ and $: jq's $ also matches before a # trailing newline, which would let "1.0\n" through and break the rewrite. jq -e ' (.pkgver | type == "string" and test("\\A[A-Za-z0-9._+]+\\z")) and (.sha256sums | type == "object" and length > 0) and (.sha256sums | to_entries | all( .key | test("\\A[a-z0-9_]+\\z") )) and (.sha256sums | to_entries | all( .value | type == "array" and length > 0 and all(test("\\A[0-9a-f]{64}\\z")) )) and (if has("published_at") then (.published_at | type == "string" and length > 0) else true end) ' <<<"$release" >/dev/null } # Confirm the rewritten PKGBUILD parses and actually holds what we meant to put # in it. Editing shell with awk and sed can go wrong in ways no amount of # pattern-matching anticipates -- an array element carrying a ")" in a comment, # say -- so the result is checked rather than trusted. verify_pkgbuild() { local pkgbuild="$1" local release="$2" local pkgver="$3" shift 3 local arrays=("$@") if ! bash -n "$pkgbuild" 2>/dev/null; then print_error "Rewritten PKGBUILD is not valid shell" return 1 fi local dump if ! dump=$(CARCH=x86_64 bash -c ' source "$1" >/dev/null 2>&1 || exit 1 printf "pkgver\t%s\n" "$pkgver" printf "pkgrel\t%s\n" "$pkgrel" for name in "${@:2}"; do declare -n array="$name" printf "%s\t%s\n" "$name" "${array[*]}" done ' _ "$pkgbuild" "${arrays[@]}" 2>/dev/null); then print_error "Rewritten PKGBUILD could not be read back" return 1 fi local expected expected=$( printf 'pkgver\t%s\n' "$pkgver" printf 'pkgrel\t1\n' local array arch for array in "${arrays[@]}"; do arch="${array#sha256sums}" arch="${arch#_}" [[ -n "$arch" ]] || arch="any" printf '%s\t%s\n' "$array" \ "$(jq -r --arg arch "$arch" '.sha256sums[$arch] | join(" ")' <<<"$release")" done ) if [[ "$dump" != "$expected" ]]; then print_error "Rewritten PKGBUILD does not hold the reported release" diff <(echo "$expected") <(echo "$dump") | sed 's/^/ /' >&2 || true return 1 fi } apply_release() { local package_dir="$1" local release="$2" local pkgver="$3" local pkgbuild="$package_dir/PKGBUILD" local arch array values local arrays=() while IFS= read -r arch; do if [[ "$arch" == "any" ]]; then array="sha256sums" else array="sha256sums_$arch" fi arrays+=("$array") done < <(jq -r '.sha256sums | keys[]' <<<"$release") # validate_release guarantees at least one entry, so an empty list here means # jq died inside the process substitution rather than that there is nothing # to do. if [[ ${#arrays[@]} -eq 0 ]]; then print_error "Could not read the checksum architectures from the reported release" return 1 fi # Every edit lands on a scratch copy that replaces the PKGBUILD in one rename # at the end, so a failure part way through leaves the original untouched # rather than half updated. local scratch="$pkgbuild.sync-upstream" cp "$pkgbuild" "$scratch" || return 1 if ! ( assert_single_assignment "$scratch" '^pkgver=' pkgver || exit 1 assert_single_assignment "$scratch" '^pkgrel=' pkgrel || exit 1 for array in "${arrays[@]}"; do arch="${array#sha256sums}" arch="${arch#_}" [[ -n "$arch" ]] || arch="any" mapfile -t values < <(jq -r --arg arch "$arch" '.sha256sums[$arch][]' <<<"$release") set_pkgbuild_array "$scratch" "$array" "${values[@]}" || exit 1 done set_pkgbuild_scalar "$scratch" pkgver "$pkgver" || exit 1 set_pkgbuild_scalar "$scratch" pkgrel 1 || exit 1 verify_pkgbuild "$scratch" "$release" "$pkgver" "${arrays[@]}" || exit 1 ); then rm -f "$scratch" return 1 fi chmod --reference="$pkgbuild" "$scratch" mv "$scratch" "$pkgbuild" } sync_package() { local package="$1" local package_dir="$PKGBUILDS_DIR/$package" local hook="$package_dir/.omarchy/upstream.sh" if [[ ! -f "$package_dir/PKGBUILD" ]]; then print_error "Package $package has no PKGBUILD" ((++FAILED)) return 0 fi local github_repo github_repo=$(package_upstream_github_repo "$package_dir") if [[ -n "$github_repo" && -f "$hook" ]]; then print_error "Package $package declares both upstream.github and an upstream.sh hook; keep exactly one" ((++FAILED)) return 0 fi if [[ -z "$github_repo" && ! -f "$hook" ]]; then if [[ "$SPECIFIC_MODE" == true ]]; then print_error "Package $package has neither an upstream declaration nor .omarchy/upstream.sh" ((++FAILED)) else print_info "Skipping $package: no upstream source" ((++SKIPPED)) fi return 0 fi local min_age if ! min_age=$(package_min_release_age_seconds "$package_dir"); then print_error "Invalid min_release_age in $package_dir/.omarchy/package.json" ((++FAILED)) return 0 fi print_info "Checking $package for upstream releases..." local release if [[ -n "$github_repo" ]]; then if ! release=$(github_upstream_release "$package_dir" "$min_age"); then print_error "GitHub release provider failed for $package" ((++FAILED)) return 0 fi elif ! release=$(cd "$package_dir" && PACKAGE_NAME="$package" \ MIN_RELEASE_AGE_SECONDS="$min_age" \ BYPASS_MIN_RELEASE_AGE="${BYPASS_MIN_RELEASE_AGE:-}" \ bash .omarchy/upstream.sh); then print_error "Upstream hook failed for $package" ((++FAILED)) return 0 fi if ! jq -e . >/dev/null 2>&1 <<<"$release"; then print_error "Upstream hook for $package did not report valid JSON" ((++FAILED)) return 0 fi if [[ "$(jq -r 'has("pkgver")' <<<"$release")" == "false" ]]; then print_info " No upstream update reported" ((++SKIPPED)) return 0 fi if ! validate_release "$release"; then print_error "Upstream hook for $package reported a malformed release" ((++FAILED)) return 0 fi # Backstop for min_release_age: the selection already honors the window, # but a provider or hook bug must not be able to ship a release younger # than the policy. local age_status=0 release_age_status "$release" "$min_age" || age_status=$? case "$age_status" in 1) print_warning " Reported release is inside the ${min_age}s minimum release age; leaving it alone" ((++SKIPPED)) return 0 ;; 2) print_error "min_release_age is set for $package but its source reported no usable published_at; refusing an unverifiable release" ((++FAILED)) return 0 ;; esac local pkgver current_pkgver pkgver=$(jq -r '.pkgver' <<<"$release") current_pkgver=$(get_pkgver "$package_dir") if [[ -z "$current_pkgver" ]]; then print_error "Could not read pkgver from $package_dir/PKGBUILD" ((++FAILED)) return 0 fi if [[ "$pkgver" == "$current_pkgver" ]]; then print_info " Already at $current_pkgver" ((++SKIPPED)) return 0 fi if ! version_is_newer "$pkgver" "$current_pkgver"; then print_warning " Upstream reports $pkgver, older than the checked-in $current_pkgver; leaving it alone" ((++SKIPPED)) return 0 fi if ! apply_release "$package_dir" "$release" "$pkgver"; then print_error "Failed to update $package" ((++FAILED)) return 0 fi print_success " $current_pkgver -> $pkgver" ((++UPDATED)) } # Offline fixture tests: the network fetches in helpers/upstream-github.sh # are swapped for fixture readers, everything else runs the production code # paths. Covers release selection (fallback past quarantined releases, # draft/prerelease filtering, bypass, unchanged version), failure paths # (unusable tags/timestamps, missing checksums), checksum template mapping # for both architectures, the min_release_age backstop, the duration parser, # and manifest validation. cmd_self_test() { local failures=0 check() { local desc="$1" expected="$2" got="$3" if [[ "$expected" == "$got" ]]; then echo " ok: $desc" else echo " FAIL: $desc (expected '$expected', got '$got')" failures=$((failures + 1)) fi } local pkg="$TEMP_DIR/selftest-pkg" mkdir -p "$pkg/.omarchy" printf 'pkgver=1.0.0\npkgrel=1\n' > "$pkg/PKGBUILD" cat > "$pkg/.omarchy/package.json" <<'EOF' { "source": "local", "min_release_age": "24h", "upstream": { "github": "example/tool", "checksums": "SHASUMS256.txt", "assets": { "x86_64": "tool-{tag}-x64.tar.xz", "aarch64": "tool-v{pkgver}-arm64.tar.xz" } } } EOF local young old2d old3d young=$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ) old2d=$(date -u -d '2 days ago' +%Y-%m-%dT%H:%M:%SZ) old3d=$(date -u -d '3 days ago' +%Y-%m-%dT%H:%M:%SZ) # v2.0.0 is inside the 24h window; v1.9.9/v1.9.8 are a prerelease and a # draft that would outrank v1.9.0 if the filters failed. local sum_x19 sum_a19 sum_x20 sum_a20 sum_x19=$(printf 'a%.0s' {1..64}) sum_a19=$(printf 'b%.0s' {1..64}) sum_x20=$(printf 'c%.0s' {1..64}) sum_a20=$(printf 'd%.0s' {1..64}) FIXTURE_RELEASES=$(jq -n --arg young "$young" --arg old2 "$old2d" --arg old3 "$old3d" '[ {tag_name: "v2.0.0", published_at: $young, draft: false, prerelease: false}, {tag_name: "v1.9.9", published_at: $old2, draft: false, prerelease: true}, {tag_name: "v1.9.8", published_at: $old2, draft: true, prerelease: false}, {tag_name: "v1.9.0", published_at: $old2, draft: false, prerelease: false}, {tag_name: "v1.8.0", published_at: $old3, draft: false, prerelease: false} ]') FIXTURE_CHECKSUMS=$(printf '%s\n' \ "$sum_x19 ./tool-v1.9.0-x64.tar.xz" \ "$sum_a19 tool-v1.9.0-arm64.tar.xz" \ "$sum_x20 *tool-v2.0.0-x64.tar.xz" \ "$sum_a20 tool-v2.0.0-arm64.tar.xz") github_fetch_releases() { printf '%s' "$FIXTURE_RELEASES"; } github_fetch_checksums() { printf '%s\n' "$FIXTURE_CHECKSUMS"; } echo "Release selection:" local out out=$(github_upstream_release "$pkg" 86400 2>/dev/null) || out="" check "quarantine falls back past the young v2.0.0" "1.9.0" "$(jq -r '.pkgver // ""' <<<"$out")" check "selected release reports its published_at" "$old2d" "$(jq -r '.published_at // ""' <<<"$out")" check "x86_64 checksum via {tag} template and ./ prefix" "$sum_x19" "$(jq -r '.sha256sums.x86_64[0] // ""' <<<"$out")" check "aarch64 checksum via {pkgver} template" "$sum_a19" "$(jq -r '.sha256sums.aarch64[0] // ""' <<<"$out")" out=$(github_upstream_release "$pkg" 0 2>/dev/null) || out="" check "no policy selects the newest stable release" "2.0.0" "$(jq -r '.pkgver // ""' <<<"$out")" check "prerelease v1.9.9 and draft v1.9.8 are never selected" "" "$(jq -r 'select(.pkgver == "1.9.9" or .pkgver == "1.9.8") | .pkgver' <<<"$out")" out=$(BYPASS_MIN_RELEASE_AGE=1 github_upstream_release "$pkg" 86400 2>/dev/null) || out="" check "bypass lifts the quarantine" "2.0.0" "$(jq -r '.pkgver // ""' <<<"$out")" check "x86_64 checksum via * binary-mode prefix" "$sum_x20" "$(jq -r '.sha256sums.x86_64[0] // ""' <<<"$out")" out=$(github_upstream_release "$pkg" 8640000 2>/dev/null) || out="" check "everything quarantined reports no update" "{}" "$(jq -c . <<<"$out")" printf 'pkgver=1.9.0\npkgrel=1\n' > "$pkg/PKGBUILD" out=$(github_upstream_release "$pkg" 86400 2>/dev/null) || out="" check "already checked in reports no update" "{}" "$(jq -c . <<<"$out")" printf 'pkgver=1.0.0\npkgrel=1\n' > "$pkg/PKGBUILD" echo "Failure paths:" local rc FIXTURE_RELEASES=$(jq -n '[{tag_name: "v1.9.0", published_at: "not-a-date", draft: false, prerelease: false}]') rc=0; github_upstream_release "$pkg" 86400 >/dev/null 2>&1 || rc=$? check "invalid published_at fails the sync" "1" "$rc" FIXTURE_RELEASES=$(jq -n --arg old "$old2d" '[{tag_name: "release 1.9!", published_at: $old, draft: false, prerelease: false}]') rc=0; github_upstream_release "$pkg" 86400 >/dev/null 2>&1 || rc=$? check "unusable tag fails the sync" "1" "$rc" FIXTURE_RELEASES=$(jq -n --arg old "$old2d" '[{tag_name: "v1.9.0", published_at: $old, draft: false, prerelease: false}]') FIXTURE_CHECKSUMS="$sum_x19 ./tool-v1.9.0-x64.tar.xz" rc=0; github_upstream_release "$pkg" 86400 >/dev/null 2>&1 || rc=$? check "missing aarch64 checksum fails the sync" "1" "$rc" echo "Quarantine backstop:" local rel st rel=$(jq -n --arg p "$old2d" '{pkgver: "1.9.0", published_at: $p, sha256sums: {}}') st=0; release_age_status "$rel" 86400 || st=$? check "old enough passes" "0" "$st" rel=$(jq -n --arg p "$young" '{pkgver: "2.0.0", published_at: $p, sha256sums: {}}') st=0; release_age_status "$rel" 86400 || st=$? check "too young is held" "1" "$st" st=0; release_age_status "$rel" 0 || st=$? check "no policy passes anything" "0" "$st" st=0; BYPASS_MIN_RELEASE_AGE=1 release_age_status "$rel" 86400 || st=$? check "deliberate bypass passes" "0" "$st" rel=$(jq -n '{pkgver: "2.0.0", sha256sums: {}}') st=0; release_age_status "$rel" 86400 || st=$? check "missing published_at is unprovable" "2" "$st" echo "Duration parser:" local agepkg="$TEMP_DIR/selftest-age" mkdir -p "$agepkg/.omarchy" check_age() { local json_value="$1" expected="$2" got jq -n "{source: \"local\", min_release_age: $json_value}" > "$agepkg/.omarchy/package.json" got=$(package_min_release_age_seconds "$agepkg") || got="" check "min_release_age $json_value" "$expected" "$got" } check_age '"24h"' 86400 check_age '"90m"' 5400 check_age '"2d"' 172800 check_age '3600' 3600 check_age '"600s"' 600 check_age '"010h"' 36000 check_age '"abc"' "" check_age '"24hh"' "" check_age 'false' "" check_age '"9999999999"' "" echo "Manifest validation:" printf 'pkgver=1.0.0\n' > "$agepkg/PKGBUILD" local vst echo '{"source": "local", "upstream": false}' > "$agepkg/.omarchy/package.json" vst=0; validate_package_metadata "$agepkg" >/dev/null || vst=$? check "upstream: false is rejected" "1" "$vst" echo '{"source": "local", "upstream": {"github": "example/tool"}}' > "$agepkg/.omarchy/package.json" vst=0; validate_package_metadata "$agepkg" >/dev/null || vst=$? check "upstream without checksums/assets is rejected" "1" "$vst" cp "$pkg/.omarchy/package.json" "$agepkg/.omarchy/package.json" vst=0; validate_package_metadata "$agepkg" >/dev/null || vst=$? check "the real declaration shape is accepted" "0" "$vst" echo "" if [[ "$failures" -eq 0 ]]; then print_success "Self-test passed" else print_error "$failures self-test failure(s)" exit 1 fi } if [[ ${#SPECIFIC_PACKAGES[@]} -gt 0 && "${SPECIFIC_PACKAGES[0]}" == "self-test" ]]; then cmd_self_test exit 0 fi if [[ ${#SPECIFIC_PACKAGES[@]} -gt 0 ]]; then SPECIFIC_MODE=true for package in "${SPECIFIC_PACKAGES[@]}"; do sync_package "$package" done else while IFS= read -r package; do sync_package "$package" done < <(packages_for_upstream_sync) fi echo "" if [[ $FAILED -gt 0 ]]; then print_error "Upstream sync completed with failures" else print_success "Upstream sync complete!" fi echo " Target: $PKGBUILDS_DIR" echo " Updated: $UPDATED" echo " Skipped: $SKIPPED" echo " Failed: $FAILED" if [[ $FAILED -gt 0 ]]; then exit 1 fi