diff --git a/bin/sync-upstream b/bin/sync-upstream index 70567cf..206350e 100755 --- a/bin/sync-upstream +++ b/bin/sync-upstream @@ -5,6 +5,7 @@ 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 @@ -17,7 +18,10 @@ Usage: $0 [PACKAGE...] Update packages that track an upstream vendor release feed instead of the AUR. -A package opts in by providing pkgbuilds//.omarchy/upstream.sh, a hook +A package whose upstream ships tagged GitHub releases with a checksum manifest +opts in declaratively, via "upstream" in .omarchy/package.json (see +helpers/upstream-github.sh for the schema); no code needed. Anything with a +bespoke feed provides pkgbuilds//.omarchy/upstream.sh instead, a hook that reports the newest upstream release as JSON on stdout: { @@ -304,12 +308,21 @@ sync_package() { return 0 fi - if [[ ! -f "$hook" ]]; then + 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 is missing .omarchy/upstream.sh" + print_error "Package $package has neither an upstream declaration nor .omarchy/upstream.sh" ((++FAILED)) else - print_info "Skipping $package: no upstream hook" + print_info "Skipping $package: no upstream source" ((++SKIPPED)) fi return 0 @@ -325,7 +338,13 @@ sync_package() { print_info "Checking $package for upstream releases..." local release - if ! release=$(cd "$package_dir" && PACKAGE_NAME="$package" \ + 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 diff --git a/helpers/package-metadata.sh b/helpers/package-metadata.sh index 0d495bd..561c3de 100644 --- a/helpers/package-metadata.sh +++ b/helpers/package-metadata.sh @@ -13,6 +13,7 @@ # { "source": "aur", "rebuild_on": ["qt6-base"] } # { "source": "local" } # { "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. @@ -164,9 +165,14 @@ package_has_upstream_hook() { [[ -f "$pkgdir/.omarchy/upstream.sh" ]] } +package_has_upstream_provider() { + local pkgdir="$1" + [[ -n "$(package_metadata_value "$pkgdir" '.upstream.github' "")" ]] +} + packages_for_upstream_sync() { package_dirs | while IFS= read -r pkgdir; do - if package_has_upstream_hook "$pkgdir"; then + if package_has_upstream_hook "$pkgdir" || package_has_upstream_provider "$pkgdir"; then basename "$pkgdir" fi done @@ -340,6 +346,20 @@ validate_package_metadata() { return 1 fi + if ! jq -e ' + (.upstream // {}) | type == "object" + and (if . == {} then true else + ((.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) ;; diff --git a/helpers/upstream-github.sh b/helpers/upstream-github.sh new file mode 100644 index 0000000..adf2b4b --- /dev/null +++ b/helpers/upstream-github.sh @@ -0,0 +1,139 @@ +# GitHub-releases upstream provider for bin/sync-upstream. +# +# A package whose upstream ships tagged GitHub releases with a checksum +# manifest asset needs no upstream.sh hook: the whole feed is data, declared +# in .omarchy/package.json -- +# +# "upstream": { +# "github": "jdx/mise", +# "checksums": "SHASUMS256.txt", +# "assets": { +# "x86_64": "mise-{tag}-linux-x64.tar.xz", +# "aarch64": "mise-{tag}-linux-arm64.tar.xz" +# } +# } +# +# {tag} and {pkgver} interpolate into asset names; tags may carry a leading +# "v", which is stripped for pkgver. Drafts and prereleases are ignored. The +# provider emits the same JSON contract as an upstream.sh hook, so +# bin/sync-upstream's validation and min_release_age backstop apply +# unchanged; a feed that fits no convention keeps a bespoke upstream.sh. + +package_upstream_github_repo() { + local pkgdir="$1" + package_metadata_value "$pkgdir" '.upstream.github' "" +} + +# Emits the newest qualifying release as hook-contract JSON. min_release_age +# is honored during selection (newest release older than the window wins, +# even when a younger one exists) and BYPASS_MIN_RELEASE_AGE=1 lifts it. +# Unusable tags or timestamps anywhere in the feed fail the sync rather than +# being skipped: a feed this provider cannot fully read is a feed it should +# not silently choose from. +github_upstream_release() { + local package_dir="$1" min_age="${2:-0}" + local metadata repo checksums_name + metadata=$(metadata_file_for_dir "$package_dir") + + repo=$(jq -r '.upstream.github // ""' "$metadata") + if [[ ! "$repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "invalid upstream.github repository: '${repo:-}'" >&2 + return 1 + fi + checksums_name=$(jq -r '.upstream.checksums // ""' "$metadata") + if [[ -z "$checksums_name" ]]; then + echo "upstream.checksums names the checksum manifest asset and is required" >&2 + return 1 + fi + local arches + mapfile -t arches < <(jq -r '.upstream.assets // {} | keys[]' "$metadata") + if [[ ${#arches[@]} -eq 0 ]]; then + echo "upstream.assets must map at least one architecture to an asset name" >&2 + return 1 + fi + + local releases now + now=$(date +%s) + if ! releases=$(curl -fsSL "https://api.github.com/repos/$repo/releases?per_page=20"); then + echo "could not fetch the release feed for $repo" >&2 + return 1 + fi + + local candidates=0 best_tag="" best_pkgver="" best_published_at="" + local tag published_at pkgver published_epoch + while IFS=$'\t' read -r tag published_at; do + if [[ ! "$tag" =~ ^v?([A-Za-z0-9._+]+)$ ]]; then + echo "$repo release has an unusable tag: ${tag:-}" >&2 + return 1 + fi + pkgver=${BASH_REMATCH[1]} + + if [[ -z "$published_at" ]] || ! published_epoch=$(date --date="$published_at" +%s 2>/dev/null); then + echo "$repo release $tag has an invalid published_at: ${published_at:-}" >&2 + return 1 + fi + candidates=$((candidates + 1)) + + if (( now - published_epoch < min_age )); then + if [[ "${BYPASS_MIN_RELEASE_AGE:-}" == "1" ]]; then + echo "Bypassing release-age gate for $repo $tag" >&2 + else + continue + fi + fi + + if [[ -z "$best_pkgver" ]] || [[ "$(vercmp "$pkgver" "$best_pkgver")" -gt 0 ]]; then + best_tag=$tag + best_pkgver=$pkgver + best_published_at=$published_at + fi + done < <(jq -r '.[] | select((.draft or .prerelease) | not) | [.tag_name // empty, .published_at // empty] | @tsv' <<<"$releases") + + if (( candidates == 0 )); then + echo "no stable releases found in the feed for $repo" >&2 + return 1 + fi + if [[ -z "$best_tag" ]]; then + echo "every recent $repo release is still inside the release-age quarantine; skipping" >&2 + echo '{}' + return 0 + fi + + # Already checked in: report no update instead of re-fetching checksums. + local current_pkgver + current_pkgver=$(grep -m1 '^pkgver=' "$package_dir/PKGBUILD" | cut -d= -f2- | tr -d "\"'") + if [[ "$best_pkgver" == "$current_pkgver" ]]; then + echo '{}' + return 0 + fi + + local checksums + if ! checksums=$(curl -fsSL "https://github.com/$repo/releases/download/$best_tag/$checksums_name"); then + echo "could not fetch $checksums_name for $repo $best_tag" >&2 + return 1 + fi + + local jq_args=(--arg pkgver "$best_pkgver" --arg published_at "$best_published_at") + local jq_filter='{pkgver: $pkgver, published_at: $published_at, sha256sums: {}}' + local arch template filename checksum + for arch in "${arches[@]}"; do + if [[ ! "$arch" =~ ^[a-z0-9_]+$ ]]; then + echo "invalid architecture key in upstream.assets: '$arch'" >&2 + return 1 + fi + template=$(jq -r --arg arch "$arch" '.upstream.assets[$arch]' "$metadata") + filename=${template//\{pkgver\}/$best_pkgver} + filename=${filename//\{tag\}/$best_tag} + # Manifest lines are " ", with the name sometimes prefixed + # "./" (sha256sum of a local path) or "*" (binary-mode marker). + checksum=$(awk -v f="$filename" '$2 == f || $2 == "./" f || $2 == "*" f { print $1; exit }' <<<"$checksums") + if [[ ! "$checksum" =~ ^[0-9a-f]{64}$ ]]; then + echo "no valid checksum for $filename in $repo $best_tag $checksums_name" >&2 + return 1 + fi + jq_args+=(--arg "sum_$arch" "$checksum") + jq_filter+=" | .sha256sums[\"$arch\"] = [\$sum_$arch]" + done + + jq -n "${jq_args[@]}" "$jq_filter" +} diff --git a/pkgbuilds/mise-bin/.omarchy/package.json b/pkgbuilds/mise-bin/.omarchy/package.json index bbe9ae1..f90090e 100644 --- a/pkgbuilds/mise-bin/.omarchy/package.json +++ b/pkgbuilds/mise-bin/.omarchy/package.json @@ -1,5 +1,13 @@ { "source": "local", "release_ring": "fast", - "min_release_age": "24h" + "min_release_age": "24h", + "upstream": { + "github": "jdx/mise", + "checksums": "SHASUMS256.txt", + "assets": { + "x86_64": "mise-{tag}-linux-x64.tar.xz", + "aarch64": "mise-{tag}-linux-arm64.tar.xz" + } + } } diff --git a/pkgbuilds/mise-bin/.omarchy/upstream.sh b/pkgbuilds/mise-bin/.omarchy/upstream.sh deleted file mode 100644 index 8db9297..0000000 --- a/pkgbuilds/mise-bin/.omarchy/upstream.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/bin/bash -set -euo pipefail - -repo="jdx/mise" - -# Keep a compromised mise release from reaching Omarchy before there has been -# time for maintainers and the community to notice and pull it. The window -# comes from min_release_age in .omarchy/package.json, exported by -# bin/sync-upstream as MIN_RELEASE_AGE_SECONDS. Walking the release list -# instead of gating on /releases/latest alone means mise's near-daily cadence -# cannot starve updates: the newest release that has finished its quarantine -# ships even while an even newer one is still inside it. Nothing younger than -# the window ever ships without the explicit BYPASS_MIN_RELEASE_AGE=1 bypass, -# which bin/sync-upstream honors too. -minimum_release_age_seconds=${MIN_RELEASE_AGE_SECONDS:-0} -now=$(date +%s) - -releases=$(curl -fsSL "https://api.github.com/repos/$repo/releases?per_page=20") - -candidates=0 -best_tag="" -best_pkgver="" -best_published_at="" -while IFS=$'\t' read -r tag published_at; do - if [[ ! "$tag" =~ ^v([A-Za-z0-9._+]+)$ ]]; then - echo "mise release has an invalid tag: ${tag:-}" >&2 - exit 1 - fi - pkgver=${BASH_REMATCH[1]} - - if [[ -z "$published_at" ]] || ! published_epoch=$(date --date="$published_at" +%s); then - echo "mise release $tag has an invalid published_at: ${published_at:-}" >&2 - exit 1 - fi - candidates=$((candidates + 1)) - - if (( now - published_epoch < minimum_release_age_seconds )); then - if [[ "${BYPASS_MIN_RELEASE_AGE:-}" == "1" ]]; then - echo "Bypassing mise release-age gate for $tag" >&2 - else - continue - fi - fi - - if [[ -z "$best_pkgver" ]] || [[ "$(vercmp "$pkgver" "$best_pkgver")" -gt 0 ]]; then - best_tag=$tag - best_pkgver=$pkgver - best_published_at=$published_at - fi -done < <(jq -r '.[] | select((.draft or .prerelease) | not) | [.tag_name // empty, .published_at // empty] | @tsv' <<<"$releases") - -if (( candidates == 0 )); then - echo "No stable mise releases found in the release feed" >&2 - exit 1 -fi - -if [[ -z "$best_tag" ]]; then - echo "Every recent mise release is still inside the release-age quarantine; skipping" >&2 - echo '{}' - exit 0 -fi - -tag=$best_tag -pkgver=$best_pkgver -checksums=$(curl -fsSL \ - "https://github.com/$repo/releases/download/$tag/SHASUMS256.txt") - -checksum_for() { - local filename=$1 - local checksum - checksum=$(awk -v filename="./$filename" '$2 == filename { print $1 }' <<<"$checksums") - - if [[ ! "$checksum" =~ ^[0-9a-f]{64}$ ]]; then - echo "No valid checksum found for $filename in $tag" >&2 - exit 1 - fi - - echo "$checksum" -} - -x86_64=$(checksum_for "mise-v${pkgver}-linux-x64.tar.xz") -aarch64=$(checksum_for "mise-v${pkgver}-linux-arm64.tar.xz") - -jq -n \ - --arg pkgver "$pkgver" \ - --arg published_at "$best_published_at" \ - --arg x86_64 "$x86_64" \ - --arg aarch64 "$aarch64" \ - '{pkgver: $pkgver, published_at: $published_at, sha256sums: {x86_64: [$x86_64], aarch64: [$aarch64]}}'