Skip to content

Restore file mtimes in CI so the deploy can hard-link static files - #65

Merged
ptr727 merged 2 commits into
developfrom
restore-mtimes-in-ci
Aug 8, 2026
Merged

Restore file mtimes in CI so the deploy can hard-link static files#65
ptr727 merged 2 commits into
developfrom
restore-mtimes-in-ci

Conversation

@ptr727

Copy link
Copy Markdown
Owner

The deploy uploads with --link-dest against the previous release, and a file links only when size and mtime both match. Git stores no mtimes, so a checkout stamps every file with the moment it was written and nothing ever linked: every release has been a full copy.

This is the second half of an ordered pair. The first half, the live media check, merged in #64, and the ordering is load-bearing rather than tidy.

Measured, not argued

Two independent clones each, built and rsynced with --link-dest:

Files linked
without the restore0 of 1791
with the restore1052 of 1791

1052 corroborates from three directions: it is the number Hugo reports as static files, the number that links on a locally built release, and the number of files in static/. Those files are 566 MB of the 586 MB a release occupies.

Why it is safe to rely on

Deterministic. Two independent clones produced byte-identical mtimes across all 1052 files, because static/ has stable last-commit times. A fresh checkout gave 2026-08-08 13:07:11, differing every run; after the restore both clones gave 2026-08-01 07:36:34.

It reaches the artifact. Hugo preserves a static file's mtime into public/, verified by touching a source file and rebuilding rather than assumed, so restoring in the source tree is not a no-op.

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.

Two details worth knowing

The subcommand form is required. The Debian package installs to /usr/lib/git-core/git-restore-mtime, which is git's exec-path and not on PATH. So git restore-mtime resolves and the bare git-restore-mtime does not. Checked by listing the package contents before writing the step.

Full history is now required for a second reason.fetch-depth: 0 was there so a shallow clone would not change page metadata; a shallow clone also has no commit to date a file from. The checkout comment says both now, so nobody optimizes it away on the strength of one.

The ordering, which is the risky part

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. A media file that acquires a bad mode would stay present, correctly named, and unreadable, through every later release.

Nothing in a build sees that: is_file() on the runner is true and the name is right. The live media check added in #64 is what notices, by requesting images and failing on the 403. That is why this change waited for it.

Verification

actionlint and editorconfig-checker clean. The local scratch clones used to measure the above are deleted.

Not exercised here: an actual pipeline deploy. The first deploy after this merges is the one that proves the link count against the real host, and the host side has been reporting zero shared inodes, which is the number this is aimed at.

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) <noreply@anthropic.com>
CopilotAI lite review requested due to automatic review settings August 8, 2026 20:10

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the deploy workflow to restore static/ file mtimes in CI so rsync --link-dest can hard-link unchanged static assets between releases, significantly reducing deploy storage and transfer by making mtimes deterministic across checkouts.

Changes:

  • Keep full Git history (fetch-depth: 0) and document why it’s required for mtime restoration.
  • Install and run git restore-mtime against static/ during the deploy job before the Hugo build.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread.github/workflows/deploy-site-task.yml
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) <noreply@anthropic.com>
CopilotAI review requested due to automatic review settings August 8, 2026 20:15

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

@ptr727
ptr727 merged commit 11722e1 into developAug 8, 2026
5 checks passed
@ptr727
ptr727 deleted the restore-mtimes-in-ci branch August 9, 2026 00:10
ptr727 added a commit that referenced this pull request Aug 9, 2026
…next (#71)
State drifts silently, so this is the state after the 2026-08-09 promotion
rather than new work.
The mtime restore was listed under "Next, in dependency order" and has
been in deploy-site-task.yml since #65. It is marked done, with the
measurement kept, and with the part that is still ahead named: the next
production deploy is the first to exercise it, and therefore the first
where a badly moded file can ride a link into every later release. The
live media check landed before it for that reason.
A Branches row records that main carries every commit on develop with an
empty content diff, and that nothing published, since a promotion that
publishes nothing looks identical to one that failed to.
ProjectTemplate#633 is added to the hub table, and "Owed to the hub" no
longer reads "Nothing": porting the line-ending gate into the hub's
scripts/ is offered there and is owed only if the hub wants that shape.
It is marked do-not-start, because the open design question changes the
code rather than merely delaying it.
Three traps, each hit this session and none of them mechanically
preventable:
a review existing on the head is not a finished review, which is how a
green report went out over an open finding
a rule naming a target that does not exist reads as coverage, which is
what hid the unpinned script
a gate is only as good as its matcher, and a wrong matcher fails
quietly, which is why check-eol-pins.py now compares itself against
git check-attr rather than against the documentation
And one new item: checks/README.md is the authority for its directory
since #66 and names one of the three gates in it.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ptr727 added a commit that referenced this pull request Aug 9, 2026
…#75)
The Ubuntu package is git-tools v2022.12, and that release shells out to
`git whatchanged`. Current git refuses to run that without
`--i-still-use-this`, a flag there is no way to pass through the tool, so
it failed twelve times inside one step, restored nothing, and exited 0.
Every release since #65 has been a full copy while CI reported success,
which the VPS agent measured from the other end as 0 of 3,275 shared
inodes.
Upstream fixed it: MestreLion/git-tools 91dc541 replaced whatchanged with
`git log`, released as v2025.08. chetan/git-restore-mtime-action v2.3
vendors that exact version, verified by reading the vendored script at the
pinned SHA -- `__version__ = "2025.08"` and no whatchanged anywhere in it.
Taking the action rather than the tarball also drops the apt round trip
the step's own comment called out as a thing that can fail on its own,
and it pins by SHA like every other action here.
The assertion is the half that matters, because the failure mode was a
step that exited 0 having done nothing. A restored file cannot be newer
than the commit it was dated from, so no file under static/ may be newer
than HEAD's own commit time. A checkout necessarily happens after the
commit it checks out, so an unrestored tree always breaches that bound and
a restored one never does. It calibrates from the repository, so nothing
goes stale as content moves.
Counting distinct mtime days was written first and is wrong. git restores
the LAST COMMIT time and static/ arrived in a bulk import, so a correctly
restored tree here has exactly ONE distinct day and that check would have
failed the good case. It was caught only because it was run against a real
restored clone instead of being trusted, which is the same discipline this
whole change exists to enforce.
Measured on two clones of this repository, the assertion run verbatim:
restored ok margin 651427s
unrestored ERROR margin -33196s
And the property the deploy actually needs: two independent clones,
restored, produce byte-identical path+mtime sets, so --link-dest can
match. 1,052 files updated by the new script, the same 1,052 as before.
Closes#74.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ptr727 added a commit that referenced this pull request Aug 9, 2026
…way (#76)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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 <new>` then a bare `<old>`. 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ptr727 added a commit that referenced this pull request Aug 9, 2026
* Stop crediting #65 with a fix that never ran
The item described the mechanism #75 replaced: installing the Ubuntu
package and calling `git restore-mtime static`. CI uses a pinned action
now, and make-release.sh requires v2025.08 or newer by either invocation
form.
The worse half was a claim of mine, added earlier the same day, that
release 20260809-030521 was the first production deploy to exercise the
restore. It exercised nothing. That release ran v2022.12, which refuses to
run under current git and exits 0 regardless, and the host measured the
result as 0 of 3,275 shared inodes. The entry asserted a working feature
on the strength of a merge rather than a measurement, which is the exact
distinction this repo keeps having to relearn.
The item now credits #75 and #76, says plainly that #65 shipped broken,
and carries what was actually measured: 1052 of 3269 linked on the local
production mirror with the tool installed by hand, the release stamped
2026-08-01 rather than at build time, and 584 MB then 18 MB for two
releases.
It also records the part that will otherwise read as a regression: the
first restored release cannot link, because its predecessor was built with
unrestored mtimes, so the first deploy after the promotion is expected to
link zero and the one after it is the real test.
The retained #65 diagnosis keeps its wording and gains a lead-in saying
so. It contains a "today" that means 2026-08-08 and names a fix that did
not run, both of which read as current state without it.
Found by Copilot review on #77.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Use the file's US spelling
The lead-in added one commit ago wrote "behaviour" two words away from the
"behavior" it was introducing, in a file that uses the US form throughout.
Nothing gates spelling here: cspell runs over README.md and HISTORY.md
only, so this is convention rather than a rule, which is why it needed a
reader to catch it.
Found by Copilot review on #78.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Describe the assertion's actual scope, not a stronger one
The entry said both paths assert that nothing under static/ is newer than
HEAD's commit. CI does. make-release.sh excludes modified and untracked
paths, deliberately, because a working tree can legitimately hold a static
file newer than any commit and skipping the check whenever the tree is
dirty would make it useless during an edit loop.
So the claim was stronger than the code, which is the same defect this
file keeps producing: prose that describes the rule someone meant rather
than the one that runs.
It now states the shared invariant as tracked and unmodified files, and
names why the two paths differ: a fresh checkout has nothing uncommitted
in it, so CI needs no exclusion to reach the same guarantee.
Found by Copilot review on #78, as a suppressed comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Say static/ where static/ is what is walked
"CI compares the whole tree" reads as the repository. Both assertions run
`find static`, and nothing outside that directory is examined by either,
which matters because the sentence is the thing a future edit to the guard
would be read against.
Verified against both implementations rather than from memory:
.github/workflows/deploy-site-task.yml:133 find static -type f
deploy/make-release.sh:186 find static -type f
The difference between the two paths is the exclusion, not the scope, and
the sentence now says so.
Found by Copilot review on #78, as a suppressed comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Match the file's number format, and say permissions rather than mode
Counts in the new text alternated between 1052 and 1,052 next to 0 of
3,275 in the same block, which makes two measurements of the same quantity
look like different quantities at a glance. The comma form is what this
file mostly uses, ten instances to six, and 1,052 already appeared with
one, so the new text follows it.
The preserved 2026-08-08 diagnosis is deliberately left alone. Its lead-in
says it is kept as written, and reformatting inside it would make that
false for the sake of consistency it explicitly opts out of.
And "a badly moded file" reads as fashion rather than permissions. It is
now "a file that acquires the wrong permissions", which is what the
sentence is about: a hard link carries its inode's mode, so a wrong one
rides the chain into every later release.
Found by Copilot review on #78, as suppressed comments.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ptr727