From 504b366d51f8dca9e33a8506624d5dfceef9d3e1 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 8 Aug 2026 13:10:14 -0700 Subject: [PATCH 1/2] Restore file mtimes in CI so the deploy can hard-link static files Git stores no mtimes, so a checkout stamps every file with the moment it was written. The deploy uploads with --link-dest against the previous release and a file links only when size and mtime both match, so nothing linked and every release was a full copy. Measured rather than argued, with two independent clones each: without the restore 0 of 1791 files linked with the restore 1052 of 1791 files linked 1052 is the same number Hugo reports as static files and the same number that links on a locally built release, and those files are 566 MB of the 586 MB a release occupies. Restoring is deterministic, which is what makes it usable here: two independent clones produced byte-identical mtimes across all 1052 files, because static/ has stable last-commit times. Hugo preserves a static file's mtime into public/, verified by touching a source and rebuilding, so restoring in the source tree reaches the uploaded artifact. static/ only. Generated pages are written fresh by every build and can never match, and walking the whole history to prove that costs reads for nothing. The step uses `git restore-mtime`, the subcommand form, because the Debian package installs to /usr/lib/git-core rather than onto PATH, so the bare binary name would not resolve. The checkout comment now names this as a second reason full history is required. ORDERING: this deliberately follows the live media check merged in #64. While every file arrived as a fresh inode the upload re-asserted the mode contract on every deploy. Now that a third of the tree arrives as hard links, a link carries the mode its inode chain began with, so a media file that acquires a bad one would stay present, correctly named and unreadable, through every later release. The live check is what notices that, by requesting images and failing on the 403. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/deploy-site-task.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/deploy-site-task.yml b/.github/workflows/deploy-site-task.yml index e1b8f5d..6295f93 100644 --- a/.github/workflows/deploy-site-task.yml +++ b/.github/workflows/deploy-site-task.yml @@ -69,11 +69,35 @@ jobs: steps: # Full history, because a shallow clone silently changes page metadata if git info is on. + # The mtime restore below needs it too: a shallow clone has no commit to date a file from. - name: Checkout code step uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + # Git stores no mtimes, so a checkout stamps every file with the moment it was written. + # The deploy uploads with --link-dest against the previous release, and a file only links + # when size and mtime both match, so today nothing links and every release is a full copy. + # Restoring the last-commit time makes static/ match between releases: measured across two + # independent clones, all 1052 files land on identical mtimes, which is the same 1052 Hugo + # reports as static files and the same 1052 that link on a locally built release. + # + # static/ only. The generated pages are written fresh by every build and can never match, + # and walking the whole tree to prove that costs history reads for nothing. + # + # ORDERING: this is deliberately behind the live media check that #64 added. While every + # file arrives as a fresh inode, the upload re-asserts the mode contract on every deploy. + # Once a third of the tree arrives as hard links, a link carries the mode its inode chain + # began with, so a media file that acquires a bad one stays present, correctly named and + # unreadable, through every later release. The live check is what notices that, by + # requesting images and failing on the 403. + - name: Restore file mtimes step + run: | + set -Eeuo pipefail + sudo apt-get update + sudo apt-get install --yes --no-install-recommends git-restore-mtime + git restore-mtime static + # The pin lives in the action, so the deploy and validation cannot install different generators. - name: Install Hugo step uses: ./.github/actions/install-hugo From b4fedbaa0334fc0cc01736c44d160fac4d7d6342 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 8 Aug 2026 13:14:57 -0700 Subject: [PATCH 2/2] Install both packages in one step, so the job updates apt once The mtime step added its own `apt-get update`, and the brotli step already had one, so the job did two network round trips where one does. Both packages install together now, in a step placed immediately after checkout. Reordering was the part that needed care rather than the merge: consolidating first left `git restore-mtime` running nine lines before the step that installs it, which would have failed the deploy on the first run. Raised by Copilot on PR #65. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/deploy-site-task.yml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/deploy-site-task.yml b/.github/workflows/deploy-site-task.yml index 6295f93..5964693 100644 --- a/.github/workflows/deploy-site-task.yml +++ b/.github/workflows/deploy-site-task.yml @@ -75,6 +75,15 @@ jobs: with: fetch-depth: 0 + # One update for the job, because each one is a network round trip that can fail on its + # own. REQUIRE_BROTLI later makes a missing brotli fatal, so this keeps the build from + # failing, and git-restore-mtime is what the next step runs. + - name: Install build tools step + run: | + set -Eeuo pipefail + sudo apt-get update + sudo apt-get install --yes --no-install-recommends brotli git-restore-mtime + # Git stores no mtimes, so a checkout stamps every file with the moment it was written. # The deploy uploads with --link-dest against the previous release, and a file only links # when size and mtime both match, so today nothing links and every release is a full copy. @@ -91,24 +100,17 @@ jobs: # began with, so a media file that acquires a bad one stays present, correctly named and # unreadable, through every later release. The live check is what notices that, by # requesting images and failing on the 403. + # `git restore-mtime`, the subcommand form, because the package installs into git's + # exec-path at /usr/lib/git-core rather than onto PATH, so the bare name does not resolve. - name: Restore file mtimes step run: | set -Eeuo pipefail - sudo apt-get update - sudo apt-get install --yes --no-install-recommends git-restore-mtime git restore-mtime static # The pin lives in the action, so the deploy and validation cannot install different generators. - name: Install Hugo step uses: ./.github/actions/install-hugo - # REQUIRE_BROTLI below makes a missing binary fatal, so this keeps the build from failing. - - name: Install brotli step - run: | - set -Eeuo pipefail - sudo apt-get update - sudo apt-get install --yes --no-install-recommends brotli - # Derived once and used three times, as the directory name, the stamp, and EXPECT_RELEASE. # Deriving it twice yields ids seconds apart, and the gate then asserts a phantom version. - name: Resolve release id step