diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 551174f..735388b 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -2362,6 +2362,14 @@ repo_slug() { printf '%s\n' "$FELIS_REPO_URL" | sed -e 's#^.*github\.com[:/]##' -e 's#\.git$##' } +# fork_token_hint is what a failed GitHub fetch says about FELIS_GITHUB_TOKEN. The official +# repository is public and needs none, so the sentence appears only for a fork, where GitHub +# answers a private repository the caller cannot see with 404, as it does a missing one. +fork_token_hint() { + [ "$(repo_slug | tr '[:upper:]' '[:lower:]')" != "felismc/felis" ] || return 0 + printf ' If %s is a private fork, set FELIS_GITHUB_TOKEN to a token with read access to it.' "$FELIS_REPO_URL" +} + # github_api GETs a REST path and prints the body. # # The token goes in through `curl --config -` rather than `-H "Authorization: ..."` @@ -2812,12 +2820,11 @@ resolve_install_ref() { # to an earlier release (docs/troubleshooting.md ยง16). validate_settings checked # the tag's form; this checks it was published. load_release_json "$FELIS_RELEASE" || die "could not find the published Felis release ${FELIS_RELEASE}. - Check the tag against the repository's releases page. If the repository is private, set FELIS_GITHUB_TOKEN to a token with read access to it." + Check the tag against the repository's releases page.$(fork_token_hint)" FELIS_REF="$FELIS_RELEASE" else log "resolving the newest published Felis release" - FELIS_REF="$(github_latest_tag)" || die "could not resolve the newest Felis release. - If the repository is private, set FELIS_GITHUB_TOKEN to a token with read access to it. + FELIS_REF="$(github_latest_tag)" || die "could not resolve the newest Felis release from api.github.com.$(fork_token_hint) If no release has been published yet, set FELIS_VERSION_BOOTSTRAP=dev to build main instead." fi # A release IS its tag, so the stamp is final here and stamp_version leaves it be. @@ -2849,11 +2856,11 @@ resolve_install_ref() { # "+" the tail is build metadata, ignored for ordering, so the build reads as current # against v1.2.3 and as behind against v1.3.0 โ€” both correct. # -# `git describe` is also unreliable here: the primary clone is --depth 1 and carries no +# `git describe` is also unreliable here: the primary fetch is --depth 1 and carries no # tags, so describe falls back to a bare SHA, which updates.Parse rejects outright (it -# fails closed on a non-numeric core). fetch_source does retry with a full clone when the -# shallow one fails, which WOULD carry tags โ€” that is exactly the point: the stamp must not -# depend on which arm happened to win. rev-parse needs no history at all. +# fails closed on a non-numeric core). checkout_ref does fall back to the whole history +# when the shallow fetch fails, which WOULD carry tags โ€” that is exactly the point: the +# stamp must not depend on which arm happened to win. rev-parse needs no history at all. stamp_version() { local sha [ -n "$FELIS_VERSION" ] && return 0 @@ -2874,27 +2881,53 @@ fetch_source() { return 0 fi resolve_install_ref + local work failed + failed="could not check out ${FELIS_REF} from ${FELIS_REPO_URL}: check that this ref exists there and that this host can reach it.$(fork_token_hint)" if [ -d "${SRC_DIR}/.git" ]; then log "updating source in ${SRC_DIR}" - git_auth -C "$SRC_DIR" fetch --depth 1 origin "$FELIS_REF" \ - || die "could not fetch ${FELIS_REF} from ${FELIS_REPO_URL}; if the repository is private, set FELIS_GITHUB_TOKEN to a token with read access to it" - git -C "$SRC_DIR" checkout -f FETCH_HEAD + checkout_ref "$SRC_DIR" || die "$failed" else + # Whatever sits at SRC_DIR without a .git (a tree staged for FELIS_SKIP_FETCH, the + # remains of an interrupted clone) is replaced, and only once the new checkout is + # whole: git clone refuses a non-empty destination, which stopped the rerun outright. log "cloning ${FELIS_REPO_URL} (${FELIS_REF})" mkdir -p "$(dirname "$SRC_DIR")" - # The fallback checks the ref out explicitly. It used to be a bare full clone, which - # silently landed on the default branch: harmless when FELIS_REF was always "main", - # but the release channel now asks for a tag, and a build stamped v1.2.3 that - # actually contains main is worse than a failed install. - git_auth clone --depth 1 --branch "$FELIS_REF" "$FELIS_REPO_URL" "$SRC_DIR" 2>/dev/null \ - || { git_auth clone "$FELIS_REPO_URL" "$SRC_DIR" \ - && git_auth -C "$SRC_DIR" checkout -f "$FELIS_REF"; } \ - || die "could not check out ${FELIS_REF} from ${FELIS_REPO_URL}; if the repository is private, set FELIS_GITHUB_TOKEN to a token with read access to it" + work="$(mktemp -d "${SRC_DIR}.new.XXXXXX")" + chmod 755 "$work" + if ! { git -C "$work" init -q && git -C "$work" remote add origin "$FELIS_REPO_URL" \ + && checkout_ref "$work"; }; then + rm -rf "$work" + die "$failed" + fi + rm -rf "$SRC_DIR" + mv "$work" "$SRC_DIR" fi stamp_version ok "source ready at ${SRC_DIR}" } +# checkout_ref checks FELIS_REF out in the repository at $1, whose origin is FELIS_REPO_URL. +# A branch, a tag or a full commit id comes down at depth 1. An abbreviated commit id is no +# ref a server answers for, so it falls back to the whole history and is resolved there, +# with origin's branch ahead of a local one an earlier clone left behind. The ref is always +# checked out explicitly: a build stamped v1.2.3 that actually holds main is worse than a +# failed install. +checkout_ref() { + local commit + if git_auth -C "$1" fetch -q --depth 1 origin "$FELIS_REF" 2>/dev/null; then + git -C "$1" checkout -q -f FETCH_HEAD + return + fi + if [ -f "$1/.git/shallow" ]; then + git_auth -C "$1" fetch -q --unshallow --tags origin '+refs/heads/*:refs/remotes/origin/*' || return 1 + else + git_auth -C "$1" fetch -q --tags origin '+refs/heads/*:refs/remotes/origin/*' || return 1 + fi + commit="$(git -C "$1" rev-parse -q --verify "refs/remotes/origin/${FELIS_REF}^{commit}" \ + || git -C "$1" rev-parse -q --verify "${FELIS_REF}^{commit}")" || return 1 + git -C "$1" checkout -q -f "$commit" +} + install_embedded_binary() { local src src="${FELIS_BOOTSTRAP_BINARY:-}" diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh index c3d52a1..d1510ea 100644 --- a/deploy/bootstrap_test.sh +++ b/deploy/bootstrap_test.sh @@ -1305,12 +1305,16 @@ expect "git never prompts without a token" "GIT: prompt=0" "$(run_git_auth '')" expect "git never prompts with a token" "GIT: prompt=0" "$(run_git_auth ghp_example)" fblock="$(awk '/^fetch_source\(\) \{/,/^}/' "$BS")" -[ -n "$fblock" ] || { echo "FAIL: no fetch_source found in $BS"; exit 1; } -[ "$(printf '%s\n' "$fblock" | wc -l)" -lt 40 ] \ - || { echo "FAIL: the extracted block is not the function -- did its closing brace move?"; exit 1; } +cblock="$(awk '/^checkout_ref\(\) \{/,/^}/' "$BS")" +hblock="$(awk '/^repo_slug\(\) \{/,/^}/; /^fork_token_hint\(\) \{/,/^}/' "$BS")" +for blk in "$fblock" "$cblock" "$hblock"; do + [ -n "$blk" ] || { echo "FAIL: fetch_source, checkout_ref or fork_token_hint is missing from $BS"; exit 1; } + [ "$(printf '%s\n' "$blk" | wc -l)" -lt 40 ] \ + || { echo "FAIL: an extracted block is not the function -- did its closing brace move?"; exit 1; } +done -run_fetch() { # src-dir - SRC_DIR="$1" FELIS_REF=main FELIS_REPO_URL=https://example.invalid/felis.git bash -c ' +run_fetch() { # src-dir repo-url + SRC_DIR="$1" FELIS_REF=main FELIS_REPO_URL="$2" bash -c ' die() { printf "DIE: %s\n" "$*"; exit 1; } log() { :; } ok() { :; } @@ -1318,14 +1322,90 @@ run_fetch() { # src-dir stamp_version() { :; } git_auth() { return 128; } git() { :; } + '"$hblock"' + '"$cblock"' '"$fblock"' fetch_source' } -expect "a failed clone names the token" "set FELIS_GITHUB_TOKEN" "$(run_fetch "$sdir/src")" +expect "a failed clone from a fork names the token" "set FELIS_GITHUB_TOKEN" \ + "$(run_fetch "$sdir/src" https://github.com/someone/felis.git)" +out="$(run_fetch "$sdir/src" https://github.com/FelisMC/Felis.git)" +expect "a failed clone from the official repository says what to check" "check that this ref exists" "$out" +case "$out" in + *private*|*FELIS_GITHUB_TOKEN*) echo "FAIL the official repository is public, so no token is asked for: $out"; fails=$((fails + 1)) ;; + *) echo "PASS the official repository is public, so no token is asked for" ;; +esac mkdir -p "$sdir/src/.git" -expect "a failed fetch into an existing checkout names the token" "set FELIS_GITHUB_TOKEN" \ - "$(run_fetch "$sdir/src")" +expect "a failed fetch into an existing checkout of a fork names the token" "set FELIS_GITHUB_TOKEN" \ + "$(run_fetch "$sdir/src" https://github.com/someone/felis.git)" + +# --- a rerun takes over whatever sits at SRC_DIR, and an abbreviated commit id ---------------- +# Real git against a local repository: git clone refuses a non-empty destination, so a tree +# staged for FELIS_SKIP_FETCH (or left by an interrupted clone) used to stop the rerun, and a +# short sha is no ref a server answers a shallow fetch for. + +gdir="$(mktemp -d)" +trap 'rm -f "$jar" "$sfn"; rm -rf "$vdir" "$sdir" "$smtp_dir" "$gdir"' EXIT +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 +git init -q -b main "$gdir/up" +for v in one two; do + echo "$v" >"$gdir/up/a.txt" + git -C "$gdir/up" add a.txt + git -C "$gdir/up" -c user.name=t -c user.email=t@example.invalid commit -q -m "$v" +done +first="$(git -C "$gdir/up" rev-parse --short=7 HEAD~1)" +second="$(git -C "$gdir/up" rev-parse --short=7 HEAD)" + +run_real_fetch() { # src-dir ref + SRC_DIR="$1" FELIS_REF="$2" FELIS_REPO_URL="file://$gdir/up" FELIS_GITHUB_TOKEN="" bash -c ' + set -Eeuo pipefail + die() { printf "DIE: %s\n" "$*"; exit 1; } + log() { :; } + ok() { :; } + resolve_install_ref() { :; } + stamp_version() { :; } + '"$gablock"' + '"$hblock"' + '"$cblock"' + '"$fblock"' + fetch_source + printf "AT %s %s | %s\n" "$(git -C "$SRC_DIR" rev-parse --short=7 HEAD)" "$(cat "$SRC_DIR/a.txt")" "$(ls -A "$SRC_DIR" | tr "\n" " ")"' 2>&1 +} + +mkdir -p "$gdir/src" +echo staged >"$gdir/src/staged.txt" +out="$(run_real_fetch "$gdir/src" main)" +expect "a staged tree without .git is replaced by the checkout" "AT ${second} two" "$out" +case "$out" in + *staged.txt*) echo "FAIL the staged tree's files are gone after the checkout: $out"; fails=$((fails + 1)) ;; + *) echo "PASS the staged tree's files are gone after the checkout" ;; +esac +expect "a rerun over a shallow checkout takes an abbreviated commit id" "AT ${first} one" \ + "$(run_real_fetch "$gdir/src" "$first")" +expect "a fresh clone takes an abbreviated commit id" "AT ${first} one" \ + "$(run_real_fetch "$gdir/fresh" "$first")" +# A full clone left a local main behind origin's; with the shallow fetch failing, the whole +# history arm must still land on origin's main. +git clone -q "file://$gdir/up" "$gdir/stale" +git -C "$gdir/stale" reset -q --hard HEAD~1 +gablock_noshallow="$gablock +git_auth() { case \" \$* \" in *\" --depth \"*) return 1 ;; esac; GIT_TERMINAL_PROMPT=0 git \"\$@\"; }" +gablock_real="$gablock"; gablock="$gablock_noshallow" +expect "the whole-history arm takes origin's branch over a stale local one" "AT ${second} two" \ + "$(run_real_fetch "$gdir/stale" main)" +gablock="$gablock_real" +mkdir -p "$gdir/kept" +echo staged >"$gdir/kept/staged.txt" +expect "a ref that does not exist stops the install" "DIE: could not check out nope" \ + "$(run_real_fetch "$gdir/kept" nope)" +[ "$(cat "$gdir/kept/staged.txt" 2>/dev/null)" = staged ] \ + && echo "PASS a failed checkout leaves what was there alone" \ + || { echo "FAIL a failed checkout must leave what was there alone"; fails=$((fails + 1)); } +leftover="$(find "$gdir" -maxdepth 1 -name '*.new.*')" +[ -z "$leftover" ] && echo "PASS no half-made checkout is left beside SRC_DIR" \ + || { echo "FAIL a half-made checkout was left behind: $leftover"; fails=$((fails + 1)); } +unset GIT_CONFIG_GLOBAL GIT_CONFIG_NOSYSTEM # --- default install keeps backups, and retention envs reach the renderer ---------------- # A default install must render the world-archive PVC (without one, backup/restore answer an