# Package metadata helpers for Omarchy package build tooling # # Expects package directories in $PKGBUILDS_DIR, each with: # .omarchy/package.json # # Minimal schema: # { "source": "aur" } # { "source": "aur", "sync": false } # { "source": "aur", "aur": "different-aur-name" } # { "source": "aur", "release_ring": "fast" } # { "source": "aur", "skip_build": true } # { "source": "aur", "pkgrel": { "suffix": 1, "offset": 1 } } # { "source": "aur", "rebuild_on": ["qt6-base"] } # { "source": "local" } # { "source": "local", "channels": ["edge"] } # { "source": "local", "channels": ["edge", "rc", "stable"] } # { "source": "local", "min_release_age": "24h" } # { "source": "local", "upstream": { "github": "owner/repo", "checksums": "SHASUMS256.txt", "assets": { "x86_64": "name-{tag}-x64.tar.xz" } } } # # bin/sync-aur also writes upstream_commit for AUR-backed packages, and # bin/sync-rebuilds writes rebuilt_against for packages declaring rebuild_on. if [[ -z "${PKGBUILDS_DIR:-}" ]]; then if [[ -n "${BUILD_ROOT:-}" ]]; then PKGBUILDS_DIR="$BUILD_ROOT/pkgbuilds" elif [[ -d /pkgbuilds ]]; then PKGBUILDS_DIR="/pkgbuilds" else PKGBUILDS_DIR="pkgbuilds" fi fi metadata_file_for_dir() { local pkgdir="$1" echo "$pkgdir/.omarchy/package.json" } package_dir_for_name() { local package="$1" local pkgdir="$PKGBUILDS_DIR/$package" [[ -d "$pkgdir" && -f "$pkgdir/PKGBUILD" ]] || return 1 echo "$pkgdir" } package_metadata_value() { local pkgdir="$1" local jq_filter="$2" local default="${3:-}" local metadata metadata=$(metadata_file_for_dir "$pkgdir") if [[ ! -f "$metadata" ]]; then echo "$default" return 0 fi jq -r --arg default "$default" "$jq_filter // \$default" "$metadata" } package_sync_enabled() { local pkgdir="$1" local metadata source sync metadata=$(metadata_file_for_dir "$pkgdir") [[ -f "$metadata" ]] || return 1 source=$(jq -r '.source // ""' "$metadata") [[ "$source" == "aur" ]] || return 1 sync=$(jq -r 'if has("sync") then .sync else true end' "$metadata") [[ "$sync" != "false" ]] } package_release_ring() { local pkgdir="$1" package_metadata_value "$pkgdir" '.release_ring' "" } package_is_fast_ring() { local pkgdir="$1" [[ "$(package_release_ring "$pkgdir")" == "fast" ]] } # Quarantine window for upstream releases, in seconds. Accepts a bare number # of seconds or a number suffixed s/m/h/d ("24h", "2d"). Unset means 0 (no # hold); an unparseable value -- including a non-string/non-number JSON type # like false -- returns 1 so callers fail closed instead of silently dropping # the hold. At most 9 digits: enough for three decades in seconds, and small # enough that no suffix multiplication can overflow 64-bit arithmetic. package_min_release_age_seconds() { local pkgdir="$1" metadata raw metadata=$(metadata_file_for_dir "$pkgdir") if [[ ! -f "$metadata" ]]; then echo 0 return 0 fi # A present-but-empty value maps to "unparseable", not to "absent": only a # missing key means no hold, so '"min_release_age": ""' cannot silently # disable the quarantine. raw=$(jq -r ' if has("min_release_age") | not then "" elif (.min_release_age | type) == "string" or (.min_release_age | type) == "number" then .min_release_age | tostring | if . == "" then "unparseable" else . end else "unparseable" end ' "$metadata") if [[ -z "$raw" ]]; then echo 0 return 0 fi [[ "$raw" =~ ^([0-9]{1,9})([smhd]?)$ ]] || return 1 # Forced base 10: bash arithmetic would otherwise read "010" as octal. local n=$((10#${BASH_REMATCH[1]})) case "${BASH_REMATCH[2]}" in ""|s) echo "$n" ;; m) echo $((n * 60)) ;; h) echo $((n * 3600)) ;; d) echo $((n * 86400)) ;; esac } package_build_skipped() { local pkgdir="$1" local metadata skip_build metadata=$(metadata_file_for_dir "$pkgdir") [[ -f "$metadata" ]] || return 1 skip_build=$(jq -r 'if has("skip_build") then .skip_build else false end' "$metadata") [[ "$skip_build" == "true" ]] } package_has_metadata() { local pkgdir="$1" [[ -f "$(metadata_file_for_dir "$pkgdir")" ]] } package_has_pkgbuild() { local pkgdir="$1" [[ -f "$pkgdir/PKGBUILD" ]] } # Channel membership: where a package may be published. Packages without a # `channels` key are members of every channel (they flow edge -> rc -> stable). package_has_channels() { local pkgdir="$1" metadata metadata=$(metadata_file_for_dir "$pkgdir") [[ -f "$metadata" ]] || return 1 jq -e 'has("channels")' "$metadata" >/dev/null } package_channels() { local pkgdir="$1" metadata metadata=$(metadata_file_for_dir "$pkgdir") [[ -f "$metadata" ]] || return 0 jq -r '(.channels // [])[]' "$metadata" } package_in_channel() { local pkgdir="$1" channel="$2" package_has_channels "$pkgdir" || return 0 package_channels "$pkgdir" | grep -qx "$channel" } package_builds_for_mirror() { local pkgdir="$1" local mirror="$2" package_has_pkgbuild "$pkgdir" || return 1 package_has_metadata "$pkgdir" || return 1 # An explicit `channels` key pins where a package is BUILT: it builds in # each listed channel except stable, which is only ever fed by promotion. # Without the key, the defaults hold: everything builds for edge, and # fast-ring packages also build directly for stable (bin/release then # replicates those artifacts into rc to keep rc and stable in parity). if package_has_channels "$pkgdir"; then case "$mirror" in edge | rc) package_in_channel "$pkgdir" "$mirror" return ;; *) return 1 ;; esac fi case "$mirror" in edge) return 0 ;; stable) package_is_fast_ring "$pkgdir" ;; *) return 1 ;; esac } # Whether `bin/repo advance` may carry this package into the given channel: # a member of that channel that is not built there natively. Native builds are # authoritative — advancing over them could pair a published filename with # different bytes, which the R2 cache would never recover from. package_moves_to_channel() { local pkgdir="$1" channel="$2" package_in_channel "$pkgdir" "$channel" || return 1 ! package_builds_for_mirror "$pkgdir" "$channel" } package_dirs() { [[ -d "$PKGBUILDS_DIR" ]] || return 0 find "$PKGBUILDS_DIR" -mindepth 1 -maxdepth 1 -type d -print | sort | while IFS= read -r pkgdir; do package_has_pkgbuild "$pkgdir" || continue package_has_metadata "$pkgdir" || continue echo "$pkgdir" done } packages_for_aur_sync() { package_dirs | while IFS= read -r pkgdir; do if package_sync_enabled "$pkgdir"; then basename "$pkgdir" fi done } package_has_upstream_hook() { local pkgdir="$1" [[ -f "$pkgdir/.omarchy/upstream.sh" ]] } package_has_upstream_provider() { local pkgdir="$1" metadata metadata=$(metadata_file_for_dir "$pkgdir") [[ -f "$metadata" ]] || return 1 # Any upstream key counts, valid or not: a malformed declaration must reach # bin/sync-upstream and fail loudly there, not vanish from discovery. jq -e 'has("upstream")' "$metadata" >/dev/null } packages_for_upstream_sync() { package_dirs | while IFS= read -r pkgdir; do if package_has_upstream_hook "$pkgdir" || package_has_upstream_provider "$pkgdir"; then basename "$pkgdir" fi done } # Packages that must be rebuilt when a dependency they link against changes, # even though nothing in their own source moved. `rebuild_on` names those # dependencies; `rebuilt_against` records the versions the checked-in pkgrel was # last bumped for. package_rebuild_triggers() { local pkgdir="$1" local metadata metadata=$(metadata_file_for_dir "$pkgdir") [[ -f "$metadata" ]] || return 0 jq -r '(.rebuild_on // [])[]' "$metadata" } package_has_rebuild_triggers() { local pkgdir="$1" [[ -n "$(package_rebuild_triggers "$pkgdir")" ]] } packages_for_rebuild_sync() { package_dirs | while IFS= read -r pkgdir; do if package_has_rebuild_triggers "$pkgdir"; then basename "$pkgdir" fi done } packages_for_mirror() { local mirror="$1" package_dirs | while IFS= read -r pkgdir; do if package_builds_for_mirror "$pkgdir" "$mirror"; then basename "$pkgdir" fi done } packages_for_unscoped_build() { local mirror="$1" package_dirs | while IFS= read -r pkgdir; do if package_builds_for_mirror "$pkgdir" "$mirror" && ! package_build_skipped "$pkgdir"; then basename "$pkgdir" fi done } package_extract_vcs_hash_from_version() { local version="$1" local version_no_pkgrel="${version%-*}" local candidate hash="" # Drop an epoch prefix before scanning for commit-looking components. if [[ "$version_no_pkgrel" == *:* ]]; then version_no_pkgrel="${version_no_pkgrel#*:}" fi while IFS= read -r candidate; do # Prefer candidates explicitly prefixed with `g`, but also accept bare hex # hashes that contain at least one a-f character (e.g. r21251.626ee68). if [[ "$candidate" == g* || "$candidate" =~ [a-f] ]]; then hash="${candidate#g}" fi done < <(echo "$version_no_pkgrel" | grep -oE 'g?[a-f0-9]{7,40}') [[ -n "$hash" ]] && echo "${hash:0:7}" } package_first_git_source() { local pkgdir="$1" (cd "$pkgdir" && env -u OMARCHY_SRC bash -c ' source PKGBUILD 2>/dev/null for s in "${source[@]}"; do url="${s#*::}" [[ "$url" == git+* ]] && { echo "${url#git+}"; break; } done') } package_git_upstream_hash() { local pkgdir="$1" local source_spec source_url fragment ref hash source_spec=$(package_first_git_source "$pkgdir") || return 1 [[ -n "$source_spec" ]] || return 1 source_url="${source_spec%%#*}" fragment="" if [[ "$source_spec" == *"#"* ]]; then fragment="${source_spec#*#}" fi case "$fragment" in "") hash=$(git ls-remote "$source_url" HEAD 2>/dev/null | awk 'NR == 1 { print substr($1, 1, 7) }') ;; branch=*) ref="${fragment#branch=}" [[ -n "$ref" ]] || return 1 hash=$(git ls-remote "$source_url" "refs/heads/$ref" 2>/dev/null | awk 'NR == 1 { print substr($1, 1, 7) }') ;; tag=*) ref="${fragment#tag=}" [[ -n "$ref" ]] || return 1 hash=$(git ls-remote "$source_url" "refs/tags/$ref^{}" "refs/tags/$ref" 2>/dev/null | awk ' $2 ~ /\^\{\}$/ { print substr($1, 1, 7); found=1; exit } NR == 1 { first=substr($1, 1, 7) } END { if (!found && first != "") print first } ') ;; commit=*) ref="${fragment#commit=}" [[ "$ref" =~ ^[a-f0-9]{7,40}$ ]] || return 1 hash="${ref:0:7}" ;; *) return 1 ;; esac [[ -n "$hash" ]] || return 1 echo "$hash" } validate_package_metadata() { local pkgdir="$1" local metadata source sync skip_build aur ring pkgrel_type metadata=$(metadata_file_for_dir "$pkgdir") [[ -f "$metadata" ]] || { echo "missing metadata: $metadata"; return 1; } jq empty "$metadata" >/dev/null || return 1 source=$(jq -r '.source // ""' "$metadata") case "$source" in aur|local) ;; *) echo "invalid source for $(basename "$pkgdir"): $source"; return 1 ;; esac sync=$(jq -r 'if has("sync") then .sync | type else "missing" end' "$metadata") case "$sync" in boolean|missing) ;; *) echo "invalid sync for $(basename "$pkgdir"): must be boolean"; return 1 ;; esac skip_build=$(jq -r 'if has("skip_build") then .skip_build | type else "missing" end' "$metadata") case "$skip_build" in boolean|missing) ;; *) echo "invalid skip_build for $(basename "$pkgdir"): must be boolean"; return 1 ;; esac aur=$(jq -r 'if has("aur") then .aur | type else "missing" end' "$metadata") case "$aur" in string|missing) ;; *) echo "invalid aur for $(basename "$pkgdir"): must be string"; return 1 ;; esac ring=$(jq -r '.release_ring // ""' "$metadata") case "$ring" in ""|fast) ;; *) echo "invalid release_ring for $(basename "$pkgdir"): $ring"; return 1 ;; esac if ! jq -e ' if has("channels") | not then true else .channels | type == "array" and length > 0 and all(. == "edge" or . == "rc" or . == "stable") and (unique | length) == length end ' "$metadata" >/dev/null; then echo "invalid channels for $(basename "$pkgdir"): must be a non-empty array of unique edge/rc/stable values" return 1 fi if ! package_min_release_age_seconds "$pkgdir" >/dev/null; then echo "invalid min_release_age for $(basename "$pkgdir"): must be a number with optional s/m/h/d suffix" return 1 fi # `has` rather than `// {}`: jq's // treats false as absent, which would # let "upstream": false slip through as an empty declaration. if ! jq -e ' if has("upstream") | not then true elif (.upstream | type) != "object" then false else .upstream | ((.github // "") | type == "string" and test("\\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\\z")) and ((.checksums // "") | type == "string" and length > 0) and ((.assets // {}) | type == "object" and length > 0 and (to_entries | all( (.key | test("\\A[a-z0-9_]+\\z")) and (.value | type == "string" and length > 0) ))) end ' "$metadata" >/dev/null; then echo "invalid upstream for $(basename "$pkgdir"): needs github owner/repo, checksums asset name, and an assets arch->name map" return 1 fi pkgrel_type=$(jq -r 'if has("pkgrel") then .pkgrel | type else "missing" end' "$metadata") case "$pkgrel_type" in object|missing) ;; *) echo "invalid pkgrel for $(basename "$pkgdir"): must be an object with optional suffix/offset"; return 1 ;; esac if ! jq -e '(.pkgrel // {}) | type == "object" and ((.suffix // 1) | type == "number" and floor == . and . >= 1) and ((.offset // 0) | type == "number" and floor == . and . >= 0)' "$metadata" >/dev/null; then echo "invalid pkgrel for $(basename "$pkgdir"): must be an object with optional integer suffix >= 1 and offset >= 0" return 1 fi if ! jq -e '(.upstream_commit // "") | type == "string"' "$metadata" >/dev/null; then echo "invalid upstream_commit for $(basename "$pkgdir"): must be a string" return 1 fi if ! jq -e '(.rebuild_on // []) | type == "array" and all(type == "string" and length > 0)' "$metadata" >/dev/null; then echo "invalid rebuild_on for $(basename "$pkgdir"): must be an array of package names" return 1 fi if ! jq -e '(.rebuilt_against // {}) | type == "object" and (to_entries | all(.value | type == "string" and length > 0))' "$metadata" >/dev/null; then echo "invalid rebuilt_against for $(basename "$pkgdir"): must be an object mapping package names to versions" return 1 fi if ! jq -e '((.rebuilt_against // {}) | keys) - (.rebuild_on // []) | length == 0' "$metadata" >/dev/null; then echo "invalid rebuilt_against for $(basename "$pkgdir"): records a package that rebuild_on does not name" return 1 fi }