From 3dc698d2e7b195244380d412d5c7bc2044b4b194 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 9 Aug 2026 06:08:27 -0700 Subject: [PATCH 1/4] Restore mtimes in the local release too, so both paths fail the same way The deploy workflow restores mtimes and asserts the restore happened. make-release.sh did neither, and the gap was invisible on this host because a long-lived working tree already carries old mtimes, so local releases linked 1052 files while CI linked zero. A fresh clone here would have reproduced the CI defect exactly and said nothing. git-restore-mtime is required rather than optional. Absent, the release refuses to build and names the version to install, because skipping when a tool is missing is how the CI version shipped broken for four releases: it printed a reassuring line and restored nothing. The assertion is the same self-calibrating one the workflow uses, with one difference that CI does not need. A working tree can legitimately hold a static file newer than any commit, so locally modified and untracked paths are excluded rather than the check being skipped whenever the tree is dirty. A clean tree takes the same single find the workflow runs. Demonstrated failing before being trusted, all three states: not installed exits 1, names the version and the reason installed but a no-op exits 1 on the assertion, having printed the same "1,052 files to be processed" line the broken v2022.12 prints working v2025.08 1,052 files updated, assertion passes The second is the real bug reproduced with a stub, rather than a hypothetical. One thing this surfaced that is not a defect and needs saying. The first restored release CANNOT link, because it is compared against a predecessor built with unrestored mtimes, so the existing zero-shared-files guard fires and refuses it. That guard is correct and the changeover needs one NO_LINK_DEST=1 release to seed a restored generation, which is what that knob already exists for. Measured on the staging mirror: first restored release, against an unrestored predecessor 0 of 3269 seeded with NO_LINK_DEST=1 full copy the next ordinary release 1052 of 3269 1052 is the same number the two-clone measurement in #65 predicted and the same count Hugo reports as static files. The mirror still answers PASS - 1253 URLs honored afterwards. Co-Authored-By: Claude Opus 5 (1M context) --- deploy/make-release.sh | 59 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/deploy/make-release.sh b/deploy/make-release.sh index 89c5eec..7c1b048 100755 --- a/deploy/make-release.sh +++ b/deploy/make-release.sh @@ -82,6 +82,65 @@ command -v hugo >/dev/null || { cd "$REPO" +# Git stores no mtimes, so a checkout stamps every file with the moment it was written, and +# a release built from a fresh clone then links nothing against the previous one. This host's +# long-lived working tree has old mtimes already and links fine, which is exactly what makes +# the gap easy to miss: it is invisible here and total in a clean checkout. +# +# The deploy workflow does the same thing with the same assertion after it, deliberately, so +# the local path and CI fail the same way for the same reason rather than one of them being +# the trusted one. +# +# Required rather than optional. Skipping when absent is how the CI version shipped broken +# for four releases: it printed a reassuring line and restored nothing. +command -v git-restore-mtime >/dev/null || { + echo "git-restore-mtime not found on PATH" >&2 + echo " it is what makes --link-dest able to link, and a release built without it is a full copy" >&2 + echo " install git-tools v2025.08 or newer -- the v2022.12 in Debian and Ubuntu calls" >&2 + echo " 'git whatchanged', which current git refuses to run, so it restores nothing and exits 0" >&2 + exit 1 +} + +echo "==> restoring file mtimes" +git-restore-mtime static + +# Asserted rather than trusted, because the failure this exists for is a restore that reports +# success and does nothing. A restored file cannot be newer than the commit it was dated from, +# so nothing under static/ may be newer than HEAD's commit time. +# +# Locally modified files are excluded, which is the one way this differs from CI. A working +# tree can legitimately hold a static file newer than any commit; a fresh CI checkout cannot, +# so there the same check needs no exclusion. Comparing the clean files only keeps the +# assertion meaningful during an edit loop instead of being skipped whenever the tree is dirty. +mtime_bound="$(git log -1 --format=%ct)" + +# `git status --porcelain` covers modified, staged and untracked in one list, so an empty +# result means every file under static/ is tracked and unchanged. That is the CI case, and it +# takes the same one-pass `find` the workflow uses. +declare -A mtime_dirty=() +while IFS= read -r -d '' entry; do + mtime_dirty["${entry:3}"]=1 +done < <(git status --porcelain -z -- static) + +if [ ${#mtime_dirty[@]} -eq 0 ]; then + mtime_newest=$(find static -type f -printf '%T@\n' | sort -n | tail -1 | cut -d. -f1) +else + echo "==> ${#mtime_dirty[@]} uncommitted path(s) under static/, excluded from the mtime check" + mtime_newest=0 + while IFS= read -r -d '' f; do + [ -n "${mtime_dirty[$f]:-}" ] && continue + t=$(stat -c %Y "$f") + [ "$t" -gt "$mtime_newest" ] && mtime_newest=$t + done < <(git ls-files -z -- static) +fi + +if [ "$mtime_newest" -gt "$mtime_bound" ]; then + echo "mtime restore did nothing: static/ holds unmodified files newer than HEAD's commit," >&2 + echo " so they still carry their checkout time and --link-dest will link nothing" >&2 + exit 1 +fi +echo "==> mtimes restored, newest $mtime_newest against HEAD $mtime_bound" + # Hugo maps HUGO_ onto config, so HUGO_BASEURL overrides hugo.yaml with no flag. # A mirror built without it serves canonical tags, feed links, and permalinks pointing at production. # The build gate passes either way, so the effective value is logged rather than left implicit. From 83cc84473cc8c103211b0acc870ab0a8c289100c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 9 Aug 2026 06:14:11 -0700 Subject: [PATCH 2/4] Accept either install shape, and refuse the broken version by name Requiring `git-restore-mtime` on PATH would have refused a correctly installed tool. The Debian and Ubuntu package puts it in git's exec-path at /usr/lib/git-core, where only the subcommand form resolves, which is what the deploy workflow's own comment says and what this script ignored. A manual install to /usr/local/bin gives the opposite: the bare name works and the subcommand does not. Both are now accepted, and the one that resolves is the one used. More usefully, the version is gated rather than left to the assertion. v2022.12 fails in the one way an outcome check catches late and a reader never catches at all: it calls `git whatchanged`, current git refuses to run that, so it prints files to be processed, processes none, and exits 0. Refusing it here names the cause, where the assertion can only report the symptom. 2025.08 is the floor because that is the release which replaced whatchanged with `git log`. Versions are YYYY.MM, so dropping the dot compares them as integers. Four states, each demonstrated rather than assumed: absent names both invocation forms and where to get it v2022.12 refused, with the whatchanged defect named bare name restores, 1052 of 3269 linked git subcommand restores, 1052 of 3269 linked The last was tested through GIT_EXEC_PATH against a directory carrying the real exec-path plus the script, so the bare name genuinely did not resolve and only the subcommand branch could have run. Mirror still answers PASS - 1253 URLs honored. Found by Copilot review on #76. Co-Authored-By: Claude Opus 5 (1M context) --- deploy/make-release.sh | 43 +++++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/deploy/make-release.sh b/deploy/make-release.sh index 7c1b048..ffd0ec1 100755 --- a/deploy/make-release.sh +++ b/deploy/make-release.sh @@ -19,6 +19,10 @@ usage() { REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# The release upstream replaced `git whatchanged` with `git log`. Anything older restores +# nothing and exits 0, so this is the floor rather than a preference. +MTIME_MIN=2025.08 + # The deploy root and the base URL are the only host-specific values, and they pair per environment. # ENV_FILE selects the environment, because `set -a` overwrites a value the caller exported. # The first argument overrides the root, being read after this. @@ -93,16 +97,41 @@ cd "$REPO" # # Required rather than optional. Skipping when absent is how the CI version shipped broken # for four releases: it printed a reassuring line and restored nothing. -command -v git-restore-mtime >/dev/null || { - echo "git-restore-mtime not found on PATH" >&2 +# Both invocation forms are accepted, because how it installs decides which one resolves. The +# Debian and Ubuntu package puts it in git's exec-path at /usr/lib/git-core, where only the +# subcommand form works; a manual install to /usr/local/bin gives the bare name and no +# subcommand. Testing only one would refuse a correctly installed tool. +MTIME_CMD=() +if command -v git-restore-mtime >/dev/null 2>&1; then + MTIME_CMD=(git-restore-mtime) +elif git restore-mtime --version >/dev/null 2>&1; then + MTIME_CMD=(git restore-mtime) +else + echo "git-restore-mtime not found, as either 'git-restore-mtime' or 'git restore-mtime'" >&2 echo " it is what makes --link-dest able to link, and a release built without it is a full copy" >&2 - echo " install git-tools v2025.08 or newer -- the v2022.12 in Debian and Ubuntu calls" >&2 - echo " 'git whatchanged', which current git refuses to run, so it restores nothing and exits 0" >&2 + echo " install git-tools $MTIME_MIN or newer, from https://github.com/MestreLion/git-tools" >&2 exit 1 -} +fi + +# The version is gated rather than left to the assertion below, because v2022.12 fails in the +# one way an outcome check catches late and a reader never catches at all: it calls +# `git whatchanged`, which current git refuses to run, so it reports files to be processed, +# processes none, and exits 0. Refusing it here names the cause; the assertion would only +# report the symptom. Versions are YYYY.MM, so dropping the dot compares them as integers. +mtime_version="$("${MTIME_CMD[@]}" --version 2>/dev/null | grep -oE '[0-9]{4}\.[0-9]{2}' | head -1)" +if [ -z "$mtime_version" ]; then + echo "${MTIME_CMD[*]} did not report a version, so it cannot be checked for the whatchanged defect" >&2 + exit 1 +fi +if [ "${mtime_version//./}" -lt "${MTIME_MIN//./}" ]; then + echo "${MTIME_CMD[*]} is $mtime_version, and $MTIME_MIN or newer is required" >&2 + echo " before $MTIME_MIN it calls 'git whatchanged', which current git refuses to run, so it" >&2 + echo " restores nothing and still exits 0 -- every release would silently be a full copy" >&2 + exit 1 +fi -echo "==> restoring file mtimes" -git-restore-mtime static +echo "==> restoring file mtimes with ${MTIME_CMD[*]} $mtime_version" +"${MTIME_CMD[@]}" static # Asserted rather than trusted, because the failure this exists for is a restore that reports # success and does nothing. A restored file cannot be newer than the commit it was dated from, From 69b1cad071689e4fd02c4e31e6e717e453153062 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 9 Aug 2026 06:20:29 -0700 Subject: [PATCH 3/4] Make the version diagnostic reachable, and parse a rename correctly Two defects in the code added one commit ago, both of which made a guard describe a case it could not reach. With `set -e` and `pipefail`, an unmatched grep inside a command substitution aborts the script at the assignment, so the "did not report a version" branch below it was unreachable and an unparseable version would have surfaced as a bare exit 1 with no message. Reproduced in isolation first: the line after the assignment never printed. Tolerating the failed match on that assignment makes the diagnostic run, verified with a stub reporting "version unknown". And `git status --porcelain -z` emits TWO NUL records for a rename or a copy, `XY ` then a bare ``. The loop read the second as another status record and stripped three characters off a bare path, recording `tic/a.txt` for `static/a.txt`. The real path then stayed out of the exclusion set, so the assertion could fail on a file that is legitimately uncommitted. Both halves are now excluded, since both are uncommitted. Measured against a real rename in static/ rather than a constructed one: R static/apple-touch-icon-renamed.png static/apple-touch-icon.png excluded: static/apple-touch-icon-renamed.png excluded: static/apple-touch-icon.png ==> 2 uncommitted path(s) under static/, excluded from the mtime check 1051 of 3269 files hard-linked One fewer than 1052, which is the renamed file correctly not matching. The rename was reverted afterwards and the mirror rebuilt from the clean tree, answering PASS - 1253 URLs honored. Both found by Copilot review on #76, as suppressed comments. Co-Authored-By: Claude Opus 5 (1M context) --- deploy/make-release.sh | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/deploy/make-release.sh b/deploy/make-release.sh index ffd0ec1..59c3445 100755 --- a/deploy/make-release.sh +++ b/deploy/make-release.sh @@ -118,7 +118,10 @@ fi # `git whatchanged`, which current git refuses to run, so it reports files to be processed, # processes none, and exits 0. Refusing it here names the cause; the assertion would only # report the symptom. Versions are YYYY.MM, so dropping the dot compares them as integers. -mtime_version="$("${MTIME_CMD[@]}" --version 2>/dev/null | grep -oE '[0-9]{4}\.[0-9]{2}' | head -1)" +# `|| true` because `set -e` with `pipefail` makes an unmatched grep abort the script at this +# assignment, so the check below would never run and an unreadable version would surface as a +# bare exit 1 with no message. Verified: without it the next line is unreachable. +mtime_version="$("${MTIME_CMD[@]}" --version 2>/dev/null | grep -oE '[0-9]{4}\.[0-9]{2}' | head -1)" || true if [ -z "$mtime_version" ]; then echo "${MTIME_CMD[*]} did not report a version, so it cannot be checked for the whatchanged defect" >&2 exit 1 @@ -146,9 +149,17 @@ mtime_bound="$(git log -1 --format=%ct)" # `git status --porcelain` covers modified, staged and untracked in one list, so an empty # result means every file under static/ is tracked and unchanged. That is the CI case, and it # takes the same one-pass `find` the workflow uses. +# A rename or a copy emits TWO NUL records, `XY ` then a bare ``, so the loop has to +# consume the second explicitly. Reading it as another status record would strip three +# characters off a bare path and record `tic/a.txt` for `static/a.txt`, leaving the real path +# unexcluded and the assertion able to fail on a file that is legitimately uncommitted. +# Both halves of a rename are excluded, since both are uncommitted. declare -A mtime_dirty=() while IFS= read -r -d '' entry; do mtime_dirty["${entry:3}"]=1 + case "${entry:0:1}" in + R | C) IFS= read -r -d '' mtime_orig && mtime_dirty["$mtime_orig"]=1 ;; + esac done < <(git status --porcelain -z -- static) if [ ${#mtime_dirty[@]} -eq 0 ]; then From f5330b41b8dae8c54e54fb86658aa6bd1c081699 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 9 Aug 2026 06:27:41 -0700 Subject: [PATCH 4/4] Pick the first usable git-restore-mtime, not the first one that exists Both invocation forms were accepted and the bare name always won, so a stale manual install at /usr/local/bin vetoed a current packaged one behind it and the release refused to build with a perfectly good tool present. Accepting both forms and then letting the worse one decide is not really accepting both. Each candidate is now version-checked and the first ACCEPTABLE one wins. Only when none meets the floor does it refuse, and it names what it found rather than only what it wanted. Measured, with a 2022.12 stub on PATH and a real 2025.08 in git's exec-path: ==> restoring file mtimes with git restore-mtime 2025.08 ==> 1052 of 3269 files hard-linked and with only the stale one reachable: no usable git-restore-mtime: found git-restore-mtime 2022.12, git restore-mtime 2022.12, and 2025.08 or newer is required Both forms report the same tool there, correctly: git resolves a subcommand from PATH as well as from its exec-path, so one stale binary is genuinely both candidates. Found by Copilot review on #76, as a suppressed comment. Co-Authored-By: Claude Opus 5 (1M context) --- deploy/make-release.sh | 72 +++++++++++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/deploy/make-release.sh b/deploy/make-release.sh index 59c3445..170471c 100755 --- a/deploy/make-release.sh +++ b/deploy/make-release.sh @@ -101,38 +101,58 @@ cd "$REPO" # Debian and Ubuntu package puts it in git's exec-path at /usr/lib/git-core, where only the # subcommand form works; a manual install to /usr/local/bin gives the bare name and no # subcommand. Testing only one would refuse a correctly installed tool. +# +# Each candidate is version-checked and the first ACCEPTABLE one wins, rather than the first +# one that merely exists. A host can carry both, and an old manual install must not veto a +# current packaged one sitting behind it. +# +# The version is gated rather than left to the assertion below, because before MTIME_MIN the +# tool calls `git whatchanged`, which current git refuses to run, so it reports files to be +# processed, processes none, and exits 0. Refusing it here names the cause; the assertion can +# only report the symptom. Versions are YYYY.MM, so dropping the dot compares them as integers. +mtime_probe() { + # The failed match is tolerated because `set -e` with `pipefail` would otherwise abort the + # whole script at the assignment, making every diagnostic below unreachable. + "$@" --version 2>/dev/null | grep -oE '[0-9]{4}\.[0-9]{2}' | head -1 || true +} + MTIME_CMD=() -if command -v git-restore-mtime >/dev/null 2>&1; then - MTIME_CMD=(git-restore-mtime) -elif git restore-mtime --version >/dev/null 2>&1; then - MTIME_CMD=(git restore-mtime) -else - echo "git-restore-mtime not found, as either 'git-restore-mtime' or 'git restore-mtime'" >&2 +mtime_version="" +mtime_found="" +for mtime_form in bare subcommand; do + mtime_try=() + case "$mtime_form" in + bare) command -v git-restore-mtime >/dev/null 2>&1 && mtime_try=(git-restore-mtime) ;; + subcommand) git restore-mtime --version >/dev/null 2>&1 && mtime_try=(git restore-mtime) ;; + esac + [ ${#mtime_try[@]} -gt 0 ] || continue + + mtime_try_version="$(mtime_probe "${mtime_try[@]}")" + if [ -z "$mtime_try_version" ]; then + mtime_found="${mtime_found}${mtime_found:+, }${mtime_try[*]} (no version reported)" + continue + fi + mtime_found="${mtime_found}${mtime_found:+, }${mtime_try[*]} $mtime_try_version" + if [ "${mtime_try_version//./}" -ge "${MTIME_MIN//./}" ]; then + MTIME_CMD=("${mtime_try[@]}") + mtime_version="$mtime_try_version" + break + fi +done + +if [ ${#MTIME_CMD[@]} -eq 0 ]; then + if [ -z "$mtime_found" ]; then + echo "git-restore-mtime not found, as either 'git-restore-mtime' or 'git restore-mtime'" >&2 + else + echo "no usable git-restore-mtime: found $mtime_found, and $MTIME_MIN or newer is required" >&2 + echo " before $MTIME_MIN it calls 'git whatchanged', which current git refuses to run, so it" >&2 + echo " restores nothing and still exits 0 -- every release would silently be a full copy" >&2 + fi echo " it is what makes --link-dest able to link, and a release built without it is a full copy" >&2 echo " install git-tools $MTIME_MIN or newer, from https://github.com/MestreLion/git-tools" >&2 exit 1 fi -# The version is gated rather than left to the assertion below, because v2022.12 fails in the -# one way an outcome check catches late and a reader never catches at all: it calls -# `git whatchanged`, which current git refuses to run, so it reports files to be processed, -# processes none, and exits 0. Refusing it here names the cause; the assertion would only -# report the symptom. Versions are YYYY.MM, so dropping the dot compares them as integers. -# `|| true` because `set -e` with `pipefail` makes an unmatched grep abort the script at this -# assignment, so the check below would never run and an unreadable version would surface as a -# bare exit 1 with no message. Verified: without it the next line is unreachable. -mtime_version="$("${MTIME_CMD[@]}" --version 2>/dev/null | grep -oE '[0-9]{4}\.[0-9]{2}' | head -1)" || true -if [ -z "$mtime_version" ]; then - echo "${MTIME_CMD[*]} did not report a version, so it cannot be checked for the whatchanged defect" >&2 - exit 1 -fi -if [ "${mtime_version//./}" -lt "${MTIME_MIN//./}" ]; then - echo "${MTIME_CMD[*]} is $mtime_version, and $MTIME_MIN or newer is required" >&2 - echo " before $MTIME_MIN it calls 'git whatchanged', which current git refuses to run, so it" >&2 - echo " restores nothing and still exits 0 -- every release would silently be a full copy" >&2 - exit 1 -fi - echo "==> restoring file mtimes with ${MTIME_CMD[*]} $mtime_version" "${MTIME_CMD[@]}" static