diff --git a/.gitea/workflows/update.yaml b/.gitea/workflows/update.yaml index 754c797..785c46f 100644 --- a/.gitea/workflows/update.yaml +++ b/.gitea/workflows/update.yaml @@ -81,8 +81,22 @@ jobs: chmod 600 ~/.git-credentials mkdir -p ~/.ssh && chmod 700 ~/.ssh + + # Validate the key HERE rather than letting it surface as an opaque + # `Permission denied (publickey)` at push time, several steps later. + if [ -z "${AUR_SSH_KEY//[[:space:]]/}" ]; then + echo "::error::AUR_SSH_KEY secret is empty or unset" >&2 + exit 1 + fi printf '%s\n' "${AUR_SSH_KEY}" > ~/.ssh/aur chmod 600 ~/.ssh/aur + if ! ssh-keygen -y -P "" -f ~/.ssh/aur >/dev/null 2>&1; then + echo "::error::AUR_SSH_KEY is not a usable unencrypted private key." >&2 + echo "Paste the whole file including the BEGIN/END lines; a passphrase-protected key cannot be used unattended." >&2 + exit 1 + fi + echo "AUR key fingerprint: $(ssh-keygen -lf ~/.ssh/aur | awk '{print $2}')" + ssh-keyscan -t rsa,ecdsa,ed25519 aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null cat >> ~/.ssh/config <<'EOF' Host aur.archlinux.org diff --git a/README.md b/README.md index 89b505b..1d1fa22 100644 --- a/README.md +++ b/README.md @@ -85,12 +85,15 @@ its `sha256sums` entry is regenerated first. A README-only edit does not. | `NO_GIT_PUSH=1` | dry run: do everything except push | | `FORCE_REBUILD=1` | bump `pkgrel` even with no detected change | | `FORCE_REFRESH=1` | re-download remote sources without a version change | -| `AUR_FORCE=1` | force-push over diverged AUR history (see below) | | `GIT_HOST` | git host holding the package repos | | `GITHUB_TOKEN` | optional; avoids GitHub's 60/hour anonymous rate limit | -A rejected AUR push usually means someone pushed there directly. The run fails -loudly rather than overwriting it; inspect, then re-run with `AUR_FORCE=1`. +There is deliberately no force-push escape hatch. The AUR accepts fast-forwards +only and refuses force pushes from anyone who is not an AUR administrator, so +such a flag could never do anything except recommend a remedy that always fails. +`sync-aur.sh` checks fast-forward status *before* pushing and, on failure, prints +git's actual error and names a cause that fits it — a rejected SSH key, an +unreachable host, or a genuine divergence — rather than assuming. ## Exit codes diff --git a/autoupdate.sh b/autoupdate.sh index a783eb6..9accdb6 100755 --- a/autoupdate.sh +++ b/autoupdate.sh @@ -9,7 +9,6 @@ # NO_GIT_PUSH=1 do everything except push (dry run) # FORCE_REBUILD=1 bump pkgrel even with no detected change # FORCE_REFRESH=1 re-download remote sources even without a version change -# AUR_FORCE=1 force-push over diverged AUR history # # This is a RECONCILER, not a version-bump script. A new upstream release is # only one of the things that can make a package diverge from the AUR; a manual @@ -93,7 +92,11 @@ update_one() { # A failure in here exits non-zero and is NOT mistaken for "no new version". local oldver newver oldver="$(pkgbuild_get pkgver)" - newver="$("$LATEST_VERSION_CMD" .)" + # Checked explicitly rather than leaning on `set -e`, so the failure is + # attributed to the lookup instead of surfacing several steps later as an + # unrelated .SRCINFO complaint. + newver="$("$LATEST_VERSION_CMD" .)" || die "upstream version lookup failed for ${pkg}" + validate_version "$newver" "upstream version for ${pkg}" info "upstream ${newver}, local ${oldver:-}" local version_changed=0 @@ -267,8 +270,28 @@ extract_appimage_assets() { rm -rf "$(readlink -f "${tmp}/squashfs-root" 2>/dev/null || true)" "$tmp" } +# One package per process, deliberately. +# +# The obvious `( update_one "$pkg" ) || { rc=1; ... }` is broken: putting a +# subshell in an && / || list disables `set -e` for its ENTIRE body, not just +# for the subshell as a unit. update_one then sails past a failed sync-aur.sh +# straight into `git push origin`, which is precisely the "origin ahead of the +# AUR" state this whole tool exists to avoid -- and it happened in production. +# +# inner() { echo start; false; echo "REACHED CODE AFTER FAILURE"; } +# ( inner ) || echo caught # prints REACHED CODE AFTER FAILURE +# ( inner ) # correctly aborts +# +# Re-exec'ing gives each package a fresh shell whose `set -e` is intact; the +# parent's AND-OR context cannot reach across a process boundary. The CI matrix +# already passes exactly one package, so the loop only affects local runs. +if [ -n "${AUTOUPDATE_SINGLE:-}" ]; then + update_one "$1" + exit 0 +fi + rc=0 for pkg in "$@"; do - ( update_one "$pkg" ) || { rc=1; warn "${pkg} FAILED"; } + AUTOUPDATE_SINGLE=1 "$0" "$pkg" || { rc=1; warn "${pkg} FAILED"; } done exit "$rc" diff --git a/sync-aur.sh b/sync-aur.sh index c62119d..ffebd53 100755 --- a/sync-aur.sh +++ b/sync-aur.sh @@ -50,6 +50,16 @@ if git fetch --quiet aur-ro master 2>/dev/null; then log "AUR already in sync (${pkgname})" exit 0 fi + # Established BEFORE pushing, rather than inferred from a failure afterwards. + # The AUR only ever accepts fast-forwards, so this is the whole decision -- + # there is no force to fall back on. + if ! git merge-base --is-ancestor FETCH_HEAD HEAD; then + die "the AUR has commits origin does not, for ${pkgname}. + The AUR accepts fast-forwards only and refuses force pushes from anyone who + is not an AUR administrator, so this cannot be overwritten. Rebase or replay + the local changes onto the AUR's HEAD, then re-run." + fi + log "AUR differs from origin; pushing (${pkgname})" else log "no AUR repo for ${pkgname} yet; the first push will create it" @@ -60,20 +70,28 @@ if [ -n "${NO_GIT_PUSH:-}" ]; then exit 0 fi -if git push aur HEAD:master; then +if push_output="$(git push aur HEAD:master 2>&1)"; then + printf '%s\n' "$push_output" log "pushed ${pkgname} to the AUR" exit 0 fi -# A rejected push here usually means the AUR has commits origin does not, -# i.e. someone pushed directly. Overwriting that silently would destroy it. -if [ -n "${AUR_FORCE:-}" ]; then - warn "AUR_FORCE set: force-pushing over the AUR history for ${pkgname}" - git push --force aur HEAD:master - exit 0 -fi +# Report what git actually said, then name a remedy that can work. The previous +# version blamed every failure on a diverged history and recommended a force +# push -- wrong on both counts for what turned out to be an unregistered SSH key. +printf '%s\n' "$push_output" >&2 -die "push to the AUR was rejected for ${pkgname}. - The AUR history has probably diverged from origin (someone pushed there - directly). Inspect it, then re-run with AUR_FORCE=1 to overwrite. - This run leaves origin untouched and will be retried on the next pass." +case "$push_output" in + *"Permission denied"*|*"publickey"*|*"Could not read from remote repository"*) + die "the AUR rejected the SSH key for ${pkgname}. + Check that AUR_SSH_KEY holds a valid private key and that its public half is + saved on your AUR account (My Account -> SSH Public Key; the form needs your + account password before it will save)." ;; + *"non-fast-forward"*|*"fetch first"*) + die "the AUR moved between the pre-check and the push for ${pkgname}. + Nothing to do: the next run re-reads the AUR and reconciles." ;; + *"Host key verification failed"*|*"Could not resolve hostname"*) + die "could not reach aur.archlinux.org for ${pkgname}; see the error above." ;; + *) + die "push to the AUR failed for ${pkgname}; see the git error above." ;; +esac diff --git a/test.sh b/test.sh index ece4c66..3ff4aac 100755 --- a/test.sh +++ b/test.sh @@ -59,6 +59,21 @@ EOS done [ -n "${PORT:-}" ] || { echo "could not start test http server" >&2; exit 1; } + # A git shim used by one test to simulate an SSH auth rejection on push. + # Everything else passes straight through to the real git. + REAL_GIT="$(command -v git)" + mkdir -p "$SANDBOX/bin" + cat > "$SANDBOX/bin/git" <&2 + echo "fatal: Could not read from remote repository." >&2 + exit 128 +fi +exec "$REAL_GIT" "\$@" +EOS + chmod +x "$SANDBOX/bin/git" + export PKG_REPO_BASE="file://$SANDBOX/origin" export AUR_HTTPS_BASE="file://$SANDBOX/aur" export AUR_SSH_BASE="file://$SANDBOX/aur" @@ -333,8 +348,18 @@ test_lookup_failure_is_loud() { make_pkg p9 local before; before="$(aur_head p9)" FAKE_VERSION_FAIL=1 run_update p9 - assert_eq "1" "$?" "broken upstream lookup: exits 1, not mistaken for 'no new version'" + local rc=$? + assert_eq "1" "$rc" "broken upstream lookup: exits 1, not mistaken for 'no new version'" assert_eq "$before" "$(aur_head p9)" "broken upstream lookup: nothing pushed" + + # Assert the REASON, not just the exit code. This previously passed even with + # the lookup failure being swallowed: the run carried on with an empty version + # and exited 1 several steps later when .SRCINFO generation rejected it, which + # is the pman-helper pkgver="" bug wearing a different hat. + grep -q "upstream version lookup failed" "$SANDBOX/last.log" \ + && pass "broken upstream lookup: fails AT the lookup, not later" \ + || fail "broken upstream lookup: fails AT the lookup, not later" \ + "expected the lookup-failure message; got: $(tail -3 "$SANDBOX/last.log")" } test_empty_version_aborts_before_commit() { @@ -397,6 +422,78 @@ test_multiarch_colliding_downloads() { assert_ne "$x" "$a" "colliding downloads: sums differ despite the shared :: target" } +# The ordering guarantee: if the AUR push fails, origin must NOT be pushed. +# +# This regressed in production. `( update_one ) || ...` disables set -e for the +# whole subshell body, so a failed sync-aur.sh did not stop the run and origin +# ended up ahead of the AUR -- the exact state the tool exists to prevent. +test_aur_failure_leaves_origin_unpushed() { + make_pkg p16 + local clone="$SANDBOX/manual-p16" + git clone --quiet "file://$SANDBOX/origin/p16" "$clone" + ( cd "$clone" && sed -i 's/^pkgdesc=.*/pkgdesc="needs publishing"/' PKGBUILD \ + && makepkg --printsrcinfo > .SRCINFO \ + && git commit --quiet -am "change to publish" && git push --quiet origin main ) + + local origin_before aur_before + origin_before="$(git -C "$SANDBOX/origin/p16" rev-parse main)" + aur_before="$(aur_head p16)" + + # Push target does not exist -> the AUR push fails, the fetch still works. + AUR_SSH_BASE="file://$SANDBOX/nonexistent" FAKE_VERSION=1.0.0 run_update p16 + local rc=$? + + assert_eq "1" "$rc" "aur failure: run exits 1" + assert_eq "$aur_before" "$(aur_head p16)" "aur failure: AUR unchanged" + assert_eq "$origin_before" "$(git -C "$SANDBOX/origin/p16" rev-parse main)" \ + "aur failure: origin NOT pushed (the ordering guarantee)" + + # It must not blame divergence for what is a plain push failure. + grep -q "has commits origin does not" "$SANDBOX/last.log" \ + && fail "aur failure: not misreported as divergence" "blamed divergence for a non-divergence failure" \ + || pass "aur failure: not misreported as divergence" +} + +# A real non-fast-forward must be named as such -- and must NOT suggest forcing, +# since the AUR refuses force pushes from non-administrators. +test_genuine_divergence_is_reported() { + make_pkg p17 + local clone="$SANDBOX/manual-p17" + git clone --quiet "file://$SANDBOX/aur/p17.git" "$clone" + ( cd "$clone" && echo "edited directly on the AUR" >> README.md \ + && git commit --quiet -am "out-of-band AUR commit" \ + && git push --quiet origin HEAD:master ) + + FAKE_VERSION=1.0.0 run_update p17 + local rc=$? + assert_eq "1" "$rc" "divergence: exits 1" + grep -q "has commits origin does not" "$SANDBOX/last.log" \ + && pass "divergence: reported as divergence" \ + || fail "divergence: reported as divergence" "$(tail -4 "$SANDBOX/last.log")" + grep -qi "AUR_FORCE" "$SANDBOX/last.log" \ + && fail "divergence: does not suggest forcing" "AUR_FORCE is gone; nothing should mention it" \ + || pass "divergence: does not suggest forcing" +} + +test_auth_failure_is_reported_as_auth() { + make_pkg p18 + local clone="$SANDBOX/manual-p18" + git clone --quiet "file://$SANDBOX/origin/p18" "$clone" + ( cd "$clone" && sed -i 's/^pkgdesc=.*/pkgdesc="publish me"/' PKGBUILD \ + && makepkg --printsrcinfo > .SRCINFO \ + && git commit --quiet -am "change" && git push --quiet origin main ) + + PATH="$SANDBOX/bin:$PATH" FAKE_PUSH_AUTH_FAIL=1 FAKE_VERSION=1.0.0 run_update p18 + local rc=$? + assert_eq "1" "$rc" "auth failure: exits 1" + grep -q "rejected the SSH key" "$SANDBOX/last.log" \ + && pass "auth failure: reported as an SSH key problem" \ + || fail "auth failure: reported as an SSH key problem" "$(tail -4 "$SANDBOX/last.log")" + grep -q "has commits origin does not" "$SANDBOX/last.log" \ + && fail "auth failure: not misreported as divergence" "this is the production bug" \ + || pass "auth failure: not misreported as divergence" +} + test_srcinfo_guard() { make_pkg p13 local clone="$SANDBOX/manual-p13"