Skip to content

Promote develop to main: the mtime restore that never ran, and X-Blog-Check - #77

Merged
ptr727 merged 6 commits into
mainfrom
develop
Aug 9, 2026
Merged

Promote develop to main: the mtime restore that never ran, and X-Blog-Check#77
ptr727 merged 6 commits into
mainfrom
develop

Conversation

@ptr727

@ptr727 ptr727 commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Promotes develop to main. Five commits, each already reviewed and squash-merged to develop as its own pull request.

Closes #74.

What this carries

Two things worth reading before merging

The mtime restore has never worked in CI. Every release since #65 has been a full copy. Nothing is broken by that — it costs bandwidth and disk the prune timer reclaims — but a feature this repo shipped, promoted, released and told the host side about was inert. The measurement came from the VPS side as 0 of 3,275 shared inodes, and the cause was found in the run log:

1,052 files to be processed in work dir
fatal: refusing to run without --i-still-use-this
1,052 files not found in log, trying merge commits

Twelve refusals inside one green step.

The first deploy after this promotion will still link nothing, and that is expected. --link-dest compares against /<environment>/current/, which was built with unrestored mtimes, so the two generations were stamped by different clocks and nothing can match. The deploy after that is where the shared-inode count should jump. The host side has been told this in the channel as §S, correcting §R which predicted the jump one deploy too early.

Verified end to end on the local production mirror

Not inferred from the code. With the tool installed by hand and the transition seeded once:

==> restoring file mtimes with git-restore-mtime 2025.08
==> mtimes restored, newest 1785594994 against HEAD 1786282540
==> 1052 of 3269 files hard-linked from releases/20260809-142130
PASS - 1253 URLs honored

The restore reaches the release and not just the working tree, which is the property that matters: site/media (778 files) and site/external (270) both carry 2026-08-01, the commit date, rather than the build date.

And the saving it exists for:

20260809-142130   584M     the seeded full copy
20260809-142325    18M     the release after it

18 MB instead of 584 MB. Both mirrors answer PASS - 1253 URLs honored.

Merge

Plain --merge, never --delete-branch: on a develop -> main promotion that flag deletes develop.

Nothing publishes on this merge — publish-release.yml is workflow_dispatch only and a human merge never auto-publishes. HISTORY.md is deliberately untouched and remains unamended for this work.

ptr727 and others added 5 commits August 8, 2026 19:45
…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>
…ved (#72)

* Ask for the deploy's missing ref gate, and refresh three rows that moved

The release workflow gates its branch in both directions: a dispatch from
anything but main or develop is refused, and validate-release fails loudly
if the default branch carries a prerelease suffix or a non-default branch
carries none. The deploy has half of that. assert-ref refuses production
from any ref but main, and staging accepts any ref at all, so a staging
deploy can be dispatched from main, which is what happened here today.

Recorded with the part that makes it more than a one-line change: the
comment above that job argues for the present behavior, so the code and
the reason move together or the file ends up contradicting itself. The
full-ref comparison stays, for the reason already written there.

Three state rows had moved and are re-measured rather than adjusted:

  the release row, now 1.0.54 from main and 1.0.54-gcd9285a9fe from
  develop, each pinned to its own branch tip

  the production row, now release 20260809-030521, read back from the
  live X-Blog-Release header

  and the note that this is the first production release built with the
  mtimes restored, so it is the first that can arrive as hard links and
  the first where a bad mode could ride one forward. The shared-inode
  count is the host's to report, so it is asked for rather than inferred.

Requested by the maintainer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Stop the State table contradicting itself, and retire the release hold

Review caught three contradictions, all of them mine and all the same
mistake: new state was prepended to old prose instead of replacing it, so
each cell asserted both readings at once.

The Branches row said "Nothing published" and named 1.0.11 as newest,
while the row directly above it named 1.0.54. Both were written today.
What is actually true is narrower and is what the row now says: the merge
published nothing, because a human merge never auto-publishes, and the
releases were cut afterwards by explicit dispatch.

The VPS production row said "The description below is the M7a record"
with that record inlined immediately after it in the same cell, so the
sentence pointed at nothing a reader could locate. The row now leads with
what is served today and names M7a as history at the end.

The Next item still said production serves 20260808-154717 in the present
tense. It now names both and points at the State table as the current
value, so the number lives in one place.

Two more of the same class that review did not reach:

The "Publish a release from main, once the pipeline has soaked" item is
done and is retired. The soak reasoning is kept deliberately, because it
was overridden rather than satisfied and the same argument returns at the
next release: 1.0.54 names a working pipeline and a rehearsal host, not a
completed cutover.

The robots.txt item's reference to 20260808-154717 is left alone. It
reads "verified from the served bytes on release ...", which is a past
measurement and is still true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Correct a gate this repo does not have, and a directional reference

The item claimed publish-release.yml carries a validate-release job that
fails when a branch's version classification disagrees with it. It does
not. There is no validate-release anywhere under .github/ in this repo:
that is a build-repo construct in the fleet contract, and I described the
contract as though it were the implementation.

What is actually there is one guard, an Assert dispatch ref step refusing
any ref but main or develop. Everything else is derived from the ref with
nothing to disagree with it. NBGV reads publicReleaseRefSpec from
version.json to give main a clean X.Y.Z and every other branch a -g<sha>
suffix, and the release's prerelease flag is computed as ref_name is not
main. main cannot cut a prerelease because no input exists that could ask
it to.

That correction makes the item's argument better rather than weaker, so
the argument is rewritten rather than patched. The deploy is not lacking
symmetry with the release. It differs in kind: environment is an
independent dispatch input, so the ref and the target are two values that
can disagree, and only one of the two disagreements is caught today. A
workflow taking its target as a separate input has to check it; one
deriving its target from the ref has nothing to check.

Also: the Branches row pointed at "the row above" for the release
versions, which sit two rows below it.

Both found by review, as suppressed comments carrying no thread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Reconcile the two URL totals, and stop calling the validate job absent

Two numbers in the State table disagreed without saying why. The URL
contract row implies 328 + 917 = 1,245, and the production row claimed a
1,253-URL contract. Both are right and they count different things:

  build gate  328 render, 917 redirect, 778 legacy image URLs, against
              files on disk
  live check  328 + 917 + 8 curated media = 1,253, against a running
              server

Verified by counting the lists rather than by arithmetic on the prose:
golden-urls.txt is 328, redirect-urls.txt is 917, golden-media-live.txt
is 8. The URL contract row now states that two gates read different
subsets and why the live one carries 8 images rather than 778, and the
production row names its total as that subset rather than as a second
contract.

The 1,245 under the HUGO_BASEURL item is deliberately left alone. It is a
counterfactual about 2026-08-07, when the live check was 1,245, so
updating it to today's number would make a true sentence false.

Separately, "exactly one guard" was wrong: publish-release.yml also runs
the reusable validate job the pull request runs, so a dispatch cannot
release a ref that fails validation. It is now "exactly one ref guard",
with the validate job named and the distinction stated, since it gates
the sources rather than the branch-to-classification relationship the
item is about.

Both found by review, as suppressed comments carrying no thread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Name the deploy that exercised the mtime restore, since it has happened

The item said "the next production deploy is the first to exercise it".
That was true when written and stopped being true an hour later in the
same session, when 20260809-030521 deployed. It now names that release
and dates it.

The sentence also carried the only forward-looking part worth keeping, so
that is stated as the open half rather than dropped: the shared-inode
count is server-side, the host had been measuring 0 across every pipeline
release, and roughly 1052 of 1791 is what the change predicts. It is
asked in the channel rather than inferred from this side, because nothing
here can read an inode on that host.

Swept for the rest of the class and found none: no other future-tense
claim in the file describes something already done.

Found by review, as a suppressed comment carrying no thread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#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>
…est (#73)

* Announce synthetic traffic with X-Blog-Check on every live-check request

97.7% of the traffic reaching production is ours, and the only thing
separating it from real visitors was a coincidence: the CI runner's curl
and the VPS host's curl are byte-identical at 8.5.0, so user agent cannot
tell our deploy gate from their smoke probe, and the CI half of the client
address rotates every run. One runner image bump merges the two and
nothing would report it.

The host side captures the field already and their ci/smoke.sh sends
vps/smoke. This is the other half.

The value carries provenance rather than a boolean, <source>/<id>, so
"which run produced this 404" is one query rather than a correlation
across timestamps. Derived rather than configured: github/<run-id>-<attempt>
under Actions, proxmox/manual elsewhere, and CHECK_TAG overrides both to
name a purpose for a hand run.

The run attempt is in the id deliberately, which is a refinement on the
design as proposed. A re-run of a failed workflow keeps its GITHUB_RUN_ID
and takes a new GITHUB_RUN_ATTEMPT, so the id alone merges a retry into
the run it was retrying, and that is exactly the case someone reads the
log to understand.

It is two curl config files rather than one, and that is the part not to
collapse later. The tag is unconditional; the Pangolin token is sent only
to the origin it belongs to, because a redirect that one day points
off-site must not mail the credential there. Folding them together would
either give the tag that restriction for no reason or take it away from
the token. Both are now assembled from the tag first, with the token
appended where it is allowed, so every request is attributable including
the off-site hop that deliberately carries no credential.

Verified rather than assumed: the header is on the wire under curl -v,
all three derivations produce the expected value, the full run still
reports PASS - 1253 URLs honored, and two tagged requests were sent to
production for the host side to confirm capture.

CHECK_TAG is added to the env-docs gate's KNOBS and described in
ENVIRONMENT.md, and the gate was watched failing on it before the row
was written.

Design agreed with the VPS agent in their section 31.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Validate what goes into the curl config, since a config is options not headers

A curl config file is a list of directives rather than a list of headers,
so a value interpolated into a quoted `header = "..."` line can leave it.
A newline ends the line and starts a new directive; a double quote closes
the string with the same effect. Either turns an override into "add a
curl option nobody typed".

CHECK_TAG takes a strict allowlist, because this repo defines its
grammar: the `<source>/<id>` the design already states, so letters,
digits, dot, underscore, hyphen and the separating slash. Anything else
fails at entry.

The Pangolin token values take a narrower rule, refusing only a quote or
a newline, because the grammar of a credential belongs to its issuer and
not to this script. Neither character is legal in an HTTP header value,
so a token carrying one is a paste accident rather than a token. The
failure names the variable and never echoes the value.

Each guard was demonstrated failing rather than assumed:

  CHECK_TAG=$'proxmox/x\noutput = /tmp/pwned'   exit 2
  CHECK_TAG='proxmox/x" header = "X-Evil: 1'    exit 2
  CHECK_TAG='proxmox/media dev'                 exit 2
  PANGOLIN_ACCESS_TOKEN_ID=$'a\nb'              exit 2

And that exercise found one of my own: I had added a guard rejecting an
empty CHECK_TAG, and it is unreachable. An empty value is already treated
as unset and takes the derived default, which is the behaviour we want,
so the guard could never fire and its message told the reader the
opposite of what happens. Removed rather than left as a rule describing a
case that cannot occur.

Full run still reports PASS - 1253 URLs honored. shellcheck and shfmt
clean.

Found by Copilot review on #73.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Enforce the tag shape the comment already claimed, rather than describing it

The validation checked a character set and the comment above it said the
grammar was <source>/<id>. Those are different rules, and the weaker one
was the one running: `smoke`, `/smoke`, `proxmox/` and `a/b/c` all passed
while reading as conforming.

That matters because the shape is the whole point of provenance over a
boolean. Grouping the log by source is only reliable if every tag has a
source half, and a tag with no slash or three slashes breaks the query
quietly rather than loudly.

Now enforced: exactly one slash, both halves non-empty, from the same
narrow character set. Each rule was demonstrated rejecting and the two
legal shapes demonstrated passing:

  smoke               must be <source>/<id>
  /smoke              needs a non-empty half either side
  proxmox/            needs a non-empty half either side
  a/b/c               takes exactly one /
  proxmox/media dev   character set
  proxmox/media-dev   accepted
  github/999-3        accepted

Both derived defaults were re-checked against their own rule rather than
assumed to satisfy it, which is the failure mode of adding a validator
after the values it governs.

ENVIRONMENT.md now states the shape is enforced instead of expected.

Full run still PASS - 1253 URLs honored.

Found by Copilot review on #73, as a suppressed comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Rewrite the four failure messages so they read as prose

A stray space before a comma, in `one / , as <source>/<id>` and in
`<source>/<id> , such as`, which reads as a typo in the one place a
reader is already confused. It came from trying to keep a bare `/`
from running into the punctuation after it.

Fixed by naming the character instead of printing it: "takes exactly one
slash" and "either side of the slash" have nothing to collide with. The
example message loses the space and keeps the comma.

The character-set message had the same collision in a different form,
`. _ - / -- got`, where the slash ran into the separator. The set is
quoted now rather than bare.

All four read back as a user sees them:

  may contain only letters, digits, and the characters '. _ - /' -- got 'proxmox/media dev'
  takes exactly one slash, as <source>/<id> -- got 'a/b/c'
  needs a non-empty half either side of the slash -- got '/smoke'
  must be <source>/<id>, such as proxmox/media-dev -- got 'smoke'

Found by Copilot review on #73, which also pointed out the second
instance rather than only the first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Say which characters apply to which part of the tag

The ENVIRONMENT.md row required "exactly one /" and then said the value
may contain "only letters, digits, . _ -", a set with no slash in it. Read
strictly the two clauses contradict; read charitably the reader has to
guess that the character set governs the halves rather than the whole.

The row now says the slash is the separator and the only one allowed, and
that each half is drawn from the character set. That is what the script
enforces: the charset check permits a slash anywhere, and the two rules
after it leave exactly one, in the separator position, so each half can
only contain the rest.

Found by Copilot review on #73, as a suppressed comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Set globasciiranges rather than inheriting it, since the allowlist rests on it

`A-Za-z0-9` in a shell pattern is a collation range, not an ASCII range,
so what the allowlist actually permits depends on a shell option this
script never set. It passes today because `globasciiranges` is on by
default in bash 5.2, which makes the guarantee an accident of the build.

Demonstrated rather than argued, under en_US.UTF-8:

  shopt -u globasciiranges    aé -> ACCEPT   aÉ -> ACCEPT
  shopt -s globasciiranges    aé -> REJECT   aÉ -> REJECT

So a value containing an accented letter would have been written into the
curl config on a shell where the option is off, while the comment above it
called the set narrow.

The consequence was never a config injection: neither a quote nor a
newline can arrive this way, and those are the two characters that break
out of a quoted config line. What was wrong is the claim. A validator
that describes itself as strict has to be strict on its own terms rather
than on the terms of whatever shell runs it.

Set explicitly, and re-verified against the hostile case: with the option
forced off beforehand, in a UTF-8 locale, `proxmox/café` is rejected.

Full run still PASS - 1253 URLs honored. shellcheck and shfmt clean.

Found by Copilot review on #73, as a suppressed comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Reject CR in a token, and fail when globasciiranges cannot be set

Two findings, and the second is the more embarrassing.

The token guard refused a quote and a newline and allowed a carriage
return. Header injection is classically CRLF and a lone CR is enough on
its own, so refusing LF while allowing CR leaves exactly the shape the
guard exists for. Measured before fixing: a token containing \r was
accepted. Both variables now reject all three, and the LF and quote cases
were re-run to confirm nothing regressed.

And the `shopt -s globasciiranges` added in the previous commit was
itself unchecked. This script runs under `set -uo pipefail` and not `-e`,
so on a shell without that option the command prints to stderr, returns
1, and execution steps straight over it — leaving the allowlist
locale-dependent underneath a comment promising it is not. Which is the
same defect the previous commit set out to fix, reintroduced one line
below it: a guarantee resting on something nobody checked.

`shopt` returns 1 on an unknown option name, verified, so the failure is
now fatal and says why.

Full run still PASS - 1253 URLs honored. shellcheck and shfmt clean.

Both found by Copilot review on #73, 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>
…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>
Copilot AI lite review requested due to automatic review settings August 9, 2026 14:26

Copilot AI 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

Promotes develop to main, carrying forward previously reviewed work that hardens the deploy/release pipeline around restoring mtimes (so --link-dest can hard-link static assets) and adds explicit provenance tagging for synthetic traffic (X-Blog-Check) in live URL checks.

Changes:

  • Update CI deploy workflow to restore mtimes via a pinned git-restore-mtime action and fail loudly if mtimes were not actually restored.
  • Require and validate git-restore-mtime in the local release path (deploy/make-release.sh) so local and CI builds fail the same way.
  • Add X-Blog-Check provenance tagging (validated CHECK_TAG) to live-check requests and document the knob.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
TODO.md Updates state/backlog record for the promotion and recent pipeline work (note: one statement is now inconsistent with the workflow implementation).
ENVIRONMENT.md Documents CHECK_TAG as a per-invocation knob for live-check provenance tagging.
deploy/make-release.sh Makes mtime restore mandatory (with minimum version gating) and asserts it actually took effect, excluding uncommitted static/ paths locally.
checks/check-live-urls.sh Adds X-Blog-Check header to all live-check requests with strict CHECK_TAG validation; keeps auth token origin-boundary handling.
checks/check-env-docs.py Adds CHECK_TAG to the set of per-invocation knobs enforced against ENVIRONMENT.md.
.github/workflows/deploy-site-task.yml Switches from apt-installed git-restore-mtime to a pinned action and adds an explicit “assert restored” step.

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

Comment thread TODO.md Outdated
* 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>
Copilot AI review requested due to automatic review settings August 9, 2026 15:03

Copilot AI 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 6 out of 6 changed files in this pull request and generated no new comments.

@ptr727
ptr727 merged commit 917e68f into main Aug 9, 2026
5 checks passed
Sign up for free to 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.

git restore-mtime silently restores nothing in CI: git now refuses whatchanged

2 participants