Conversation
* Fail on media the site carries but links from nowhere The URL contract checked media in one direction only. The legacy list proves an inbound link still lands, and the asset check proves a reference names a real file. Both read outward from a reference, so neither can ask whether anything points at a given file. An image the WordPress conversion dropped from a page therefore stays on disk, stays reachable at its own URL, and reports green in both directions while appearing nowhere on the site. It was not hypothetical. 120 of the 1,048 carried media files are linked from no page, and five gallery shortcodes across three posts are empty. The ESP32 post announces "Below are pictures of the finished case and the utility meters:" and renders an empty div, with a sequential run of camera originals from that month sitting unreferenced. Every gate passed throughout. check_orphans reads inward from the file and is the only check that sees this. collect_refs and ref_to_path are factored out of check_assets, since the two checks are the same reference set read in opposite directions and walking the built site twice to build it would be waste. ORPHANED_MEDIA is an exact count rather than a bound. A ceiling would let restored media leave slack for a later regression to hide in, so the check fails when the count falls as well as when it rises, and names the new number to write when it falls. The rising message and the falling one differ, because "a page stopped linking media" and "media was restored" are opposite events that a single count cannot distinguish on its own. The explanation is printed rather than returned in the failure list, so the caller's "N missing" stays the orphan count. Carrying a diagnostic in that list reported 122 for 121 orphans. Demonstrated failing in both directions before being trusted, per the rule that a gate is only trusted after it has been watched failing. Removing a page's reference while leaving the file gives 121 and fails; linking one of the 120 gives 119 and fails with the instruction to lower the constant. The empty shortcodes are deliberately left in place. Deleting them would erase the evidence and leave the prose promising pictures that never arrive, and recovering the images needs the source export, since gallery membership and order are not derivable from what is carried here. TODO.md records the defect, names the three posts, and notes that the 120 are not all gallery losses. Verified through make-release.sh, which is the checker's real caller, then against the local mirror: PASS - 1245 URLs honored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Normalize the orphan check's path separator, as check_render already does check_orphans compared a native path against a set of URL paths. On Windows `relative_to` yields backslashes while every reference is forward-slashed, so nothing would have matched and all 1,048 carried files would have reported as orphans, failing the gate with a number that describes the separator rather than the site. check_render normalizes for exactly this reason, three functions above, so the new code was inconsistent with the convention beside it rather than establishing a new question. The comment names the cause, since the replace reads as redundant on the platform CI runs. Shown rather than asserted, via PureWindowsPath: 'media\2010\05\ami-warn3.png' is absent from a URL-keyed set and 'media/2010/05/ami-warn3.png' is present. The call is a no-op on POSIX, and all three gate states are unchanged there: 120 passes, 121 fails upward, 119 fails downward. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Fail an empty build outright, and stop claiming the gate asserts zero orphans Three findings from the review of #43, all in the suppressed block. An empty build talked the reader into disabling the gate. With no media trees in the output, `carried` is 0 and `orphaned` is empty, and 0 is fewer than the baseline, so the check reached the branch that reads a drop as progress and advised lowering ORPHANED_MEDIA to 0. Following that advice would have retired the check permanently, on the strength of a build that had produced nothing. That is the failure mode the rule about gates failing loud rather than narrowing quietly exists for, and this one narrowed to nothing while printing a sentence that sounded like good news. No media at all is now a hard failure that names the cause, ahead of any comparison against the baseline. The module docstring and the gate table in checks/README.md both said the check proves "no carried media file is linked from nowhere". It does not, and cannot today: 120 are. It proves the count still equals its recorded baseline. Read beside output beginning `orphans: 120`, the old wording invited the reader to conclude the check was broken rather than that the site is. Both now say what it actually asserts. The new branch was demonstrated failing before being trusted: with the media trees removed the check exits 1, names an incomplete or mislocated build, and emits no advice to lower the constant. The other three states are unchanged at 120 pass, 121 fail, 119 fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Say what the orphan count observes, not which cause produced it A count is all check_orphans can see, and two causes reach each direction. It rises when a page stops linking media and equally when unlinked media is added. It falls when media is linked from a page and equally when orphaned files are deleted. The messages named one cause each and stated it as fact, so a run that had the other cause would have sent a reader looking for a page that never changed, with the check's own output vouching for the wrong theory. This is the same overclaiming the previous commit removed from the docstring, left in place one function lower. Asserting a cause the code cannot observe is worse in a failure message than in prose, because a failure message is read by someone who has no context yet and is deciding where to look first. Both messages now name both causes. The action after a drop is unchanged and still explicit, since lowering the constant is correct whether the count fell because media was linked or because orphaned files were removed. checks/README.md gains the same distinction, so the reasoning survives the next edit to the message text. All four states re-verified: 120 passes, 121 and 119 fail with the reworded messages, and an empty build still fails outright without advising that the constant be lowered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five gallery shortcodes across three posts were emitted empty. The ESP32 post announced "Below are pictures of the finished case and the utility meters:" and rendered an empty div. wp2hugo wrote the wrapper and dropped what it wrapped: WordPress used Gutenberg's wp-block-gallery with nested figures, and only the outer element survived the conversion. The images were recovered from the captured live site rather than guessed. The WordPress export XML does not carry these post bodies, so the crawl under mirror/ is the authoritative source, and it holds each gallery's nested figures in document order with their widths and captions. All 19 files were already on disk, so nothing was refetched. Three captions came back with them. Membership was not inferable from this repo, which is why the earlier note said so. It would also have been guessed wrong: the ESP32 gallery opens with a photo from 2018 and one from 2020, both older images reused in a 2021 post, where the obvious guess was the sequential camera run from that month. Two of the 19 were therefore already linked from their own original posts, which is why the orphan count falls by 17 rather than 19. A count comparison across all 19 posts holding galleries confirms the scope is these five and no others: every populated gallery matches the mirror exactly, and only the five empty ones differ. The comparison has a blind spot worth stating, since it produced a wrong answer first: a mirror page parsed as having fewer figures than the markdown means the parser missed that page's markup, not that images were lost, so only a surplus in the mirror is a finding. Regex could not read nested figures at all and reported a fictional 194-figure loss; the stdlib HTML parser gives the real 19. ORPHANED_MEDIA falls 120 -> 103 in the same change, which is the ratchet working rather than an edit alongside it: the gate failed at 120 and named 103. Verified on the mirror. assets rises 1012 -> 1031, matching the 19 restored references exactly; orphans falls to 103; PASS - 1245 URLs honored; the restored images answer 200; and the ESP32 gallery now renders four figures where it rendered none. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#45) * Record the hub's verification ruling and what it will ask of this repo A finding that lives only in a chat message is not recorded. #597 was filed from here and its ruling landed, and neither appeared in this repo's own backlog, so the next session would have re-derived the whole thing from the miss that started it. #597 is added to the open-against-the-hub table. It was filed after a mandatory pre-merge gate in OPERATIONS.md was skipped on #40, and the ruling is that a verification a runner cannot perform needs a declared destination rather than a better per-repository pointer, because the pointer is what failed. What #598 will ask of this repo is recorded with it, and so is the reason not to act yet: it is merged to the hub's develop and not to main, so it binds nothing, which is the trap this file already records about reading main as ground truth. The work itself is smaller than it sounds and is written down so it is not re-measured. This repo's OPERATIONS.md carries 13 level-two headings and matches none of the five declared today, so the change is a rename and reorder rather than new prose. Both gaps are near-misses rather than absences: the gate's own section is `Local Verification Before a Pull Request` against the incoming `Local Verification`, and `Backup and Restore` is one word from the declared `Backup and Recovery`. That shape is worth stating, since a repository that wrote its operational document before the spec declared headings will have covered the same ground under its own names. The absence of any signal is recorded too. OPERATIONS.md is presence-checked only, which is why the full audit on 2026-08-05 reported nothing about a file using none of the declared headings, and the heading check belongs to a hub cluster that has not shipped. Until it does, nothing here will fail. Reference definitions added for #597, #598, and this repo's #40. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Correct the lead sentence the third hub finding invalidated Adding #597 to the table left the sentence above it saying "Two findings are open at the hub. Neither is work this repo can do." Both halves became false in the same edit: there are three, and the new one is the only entry in the table that this repo will act on. The second half was the more misleading of the two. A reader scanning for what this repo owes would have been told by the lead sentence that nothing in the table qualifies, and stopped there, which is the opposite of why the row was added. The sentence now separates the two kinds rather than generalizing over them, and points at the paragraph stating what the actionable one will ask. Verified by counting: three table rows, and a lead sentence that says three. #597 confirmed still OPEN at the hub rather than assumed, since the change answering it has already merged there and a closed issue would have made "open at the hub" wrong too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two findings, from taking the orphan count apart rather than carrying it. The check could not read an absolute reference. Hugo writes one wherever a template resolves against the base, which the entry-cover image on every list page does, so those files were read as external. That cost both directions at once: 5 files were counted as orphans while being displayed on the site, and 18 references were never checked for existing at all. The second half is the serious one, since a missing file behind an absolute reference passed silently. The origin is read from the home page's canonical link rather than assumed, because staging and production build with different base URLs, and a missing canonical stops the run rather than being guessed at, since a wrong origin inflates the orphan count by exactly the pages that use one. ORPHANED_MEDIA falls 103 -> 98 as a consequence, and assets rises 1031 -> 1049. Neither number moved because the site changed. The remaining 98 are adjudicated rather than unknown, against the captured live site, whose crawl covers all 328 URLs the contract requires. 97 were uploaded to the old platform's media library and never placed on a published page. 1 is that platform's site icon, superseded by the favicon set at the static root. No conversion loss remains, and no image the old site served from its own uploads went unimported, so anything that raises the count from here is new. That is what the exact constant is for, and TODO.md loses the entry calling the cause unknown. Three traps in the adjudication are recorded in checks/README.md because each produced a wrong answer first. A regex cannot read nested elements and reported a fictional 194-figure loss where an HTML parser reports 19. Only a surplus in the mirror is a finding, since a shortfall means the parser missed that page's markup. The old platform generated an attachment page per upload and a foreign host may also serve /wp-content/uploads/, so counting attachment pages makes every unused upload look published and matching a path without its host attributes another site's file to this one. Both were hit here: 23 apparent losses collapsed to 1, and the one apparent never-imported file was a PDF on phyn.com. New coverage demonstrated failing before being trusted. Deleting a file that only an absolute reference names now fails the assets check, where it passed before. Removing the canonical link fails with the origin message. The three earlier orphan states are unchanged. Verified through make-release.sh and against the local mirror: PASS - 1245 URLs honored. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Real traffic is the only source that finds what every check here is blind to. The URL contract proves the 328 URLs someone thought to list and the 917 redirects derived from the export, and it cannot know about a URL nobody recorded, because the crawl that produced the lists is the crawl the lists are checked against. A visitor following a sixteen-year-old link is the one reader who tests that. This was discussed when the migration was designed and had survived only as a one-line first-week task, which is not a periodic process. Both directions are recorded, because they are the same two the media checks read and they are blind to each other for the same reason. Outward finds a non-200 and names content that may be missing, which is the one with an action: add the URL to the golden list and add a redirect. Inward subtracts every URL that has ever answered 200 from the set the site builds, and names content no reader has reached. That one is deliberately slow, since a post can go a year without a visit and still be worth keeping, and its first concrete use is the 97 carried media files the old platform never published: if nothing requests them across a year, that settles whether carrying them is preservation or clutter, which no reasoning from this repo can settle. The three tiers are named, because a request crosses the proxy before it reaches the site and no single log answers both questions. Traefik, and Pangolin's Traefik on the VPS, see every request reaching the host including the ones aimed at names this site does not serve. Pangolin sees what the auth gate rejected. Caddy sees only what arrived. So a 404 count taken from Caddy alone is a floor rather than a total, since a request the edge refused is a reader who found nothing just as surely and appears in no Caddy log. Two parsing properties are stated because both silently produce nothing. The Caddy access log is `format console`, so a line is a timestamp, a level, and a logger name followed by a JSON object rather than being JSON, and a parser assuming one object per line reads zero records. And `trusted_proxies` is what makes `client_ip` the reader rather than the proxy, without which every request appears to come from one internal address. Retention is recorded as the unconfigured prerequisite it is. The containers use Docker's `json-file` driver with no rotation and no size limit, so the log grows without bound and is discarded when the container is recreated. The first is a disk risk and the second silently resets the window the inward pass depends on being long. It belongs to the host rather than to this repo, the same split Retention and Who Owns What already describe for release pruning, so it is recorded and dated to the cutover rather than changed here. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds two human-facing README sections: a short pointer to the migration post, and a high-level walk of how a change reaches the site, with mermaid diagrams for the pipeline, the branch model, and the loop that runs while the site serves. WORKFLOW.md remains the tooling contract. OPERATIONS.md gains The Migration Record, naming the post as the artifact to cite before touching checks/ or deploy/maps/, and Rebuilding from the Exports, recording what the provenance capture holds and which parts can be fetched again. The capture's location was recoverable only from an agent's memory, so CAPTURE_ROOT joins the machine-specific values in secrets/, with checks/README.md pointing at it beside the tool that consumes it. The migration post gains a step 0 making the same point for a reader running their own migration. Log Review is corrected twice: it described retention as unconfigured when the edge access log exists, and it claimed the contract lists are checked against the crawl that produced them. The gates read the built site and a running server, so the circularity is that the lists are their own standard. Absolute counts that describe volatile state, or another system's configuration, are replaced by what they represent. The counts that survive describe the legacy contract, which content additions do not move, and checks/README.md and deploy/README.md now say so outright. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Name every environment file for its server and environment The four environment files under secrets/ named their environment three different ways: by omission for the local production mirror, by a bare word for local staging, and by a server prefix for the two VPS files. The default file's name said nothing at all about which of the four it was, which is the one name a reader most needs, since it is what a bare make-release.sh run publishes through. Every file is now secrets/<server>.<environment>.env with both halves spelled out, matching the production-and-staging-in-every-position rule the environment name already follows because it gets compared: .env -> local.production.env staging.env -> local.staging.env vps.env -> vps.production.env vps.staging.env unchanged The gitignore's bare `.env` pattern matched nothing under the new naming, so it becomes `*.env`, which matches the shape those files are named for. deploy/env.example does not end in `.env` and stays committed. Documenting the four files by name also surfaced a wrong one: OPERATIONS.md "Checking a Site Behind the Auth Gate" sourced the local staging mirror's file for a Pangolin token that only the VPS staging file carries. The gate belongs to the VPS environment, and the local mirror sits behind Traefik and holds neither half of the pair. Verified by releasing to both local mirrors and running the live check against each: PASS, 1245 URLs honored, mirror-production and mirror-staging respectively. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Name the remote-root argument requirement in the usage text The DEPLOY_SSH_HOST guard exits with its own message, so the CLI help was the one place a reader could learn the requirement before hitting it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Promotes develop to main, bringing across the new URL-parity gate that detects carried-but-unreferenced media, the restoration of 19 previously dropped gallery images, and accompanying operational/documentation updates (migration record, deploy workflow, environment file naming).
Changes:
- Extend
checks/check-url-parity.pywith shared reference collection, absolute-reference handling, and an enforcedORPHANED_MEDIAbaseline via a new orphan check. - Restore missing gallery images across three posts by repopulating previously-empty
galleryshortcodes withfigureentries. - Document migration/deploy/log-review procedures and standardize environment file naming to
secrets/<server>.<environment>.env(and update docs accordingly).
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| TODO.md | Updates migration/deploy backlog notes and records additional hub-tracked findings/links. |
| README.md | Adds migration context + end-to-end “how changes ship” documentation with diagrams. |
| OPERATIONS.md | Adds migration record + export rebuild notes; updates env-file naming; adds log-review procedure details. |
| deploy/README.md | Updates environment-file naming guidance and deploy invocation examples. |
| deploy/make-release.sh | Switches default env file to secrets/local.production.env and documents remote-root guard requirement. |
| deploy/env.example | Updates env-file naming examples and records CAPTURE_ROOT in the template. |
| content/posts/2026/08/01/moving-this-blog-from-wordpress-to-hugo.md | Adds “step 0” guidance for capture/input directory placement and provenance retention. |
| content/posts/2022/10/31/halloween-pumpkins-and-lights.md | Restores gallery figures that were previously missing. |
| content/posts/2022/10/30/installing-flair-smart-vents-to-keep-room-temperatures-balanced.md | Restores multiple missing galleries/figures (includes two caption-formatting issues noted in PR comments). |
| content/posts/2021/08/09/esp32-water-and-gas-utility-meter.md | Restores missing gallery figures. |
| checks/README.md | Documents new orphan-media check and clarifies which counts are list-derived vs site-derived. |
| checks/check-url-parity.py | Implements orphan-media gate + absolute URL reference handling and ref collection reuse. |
| .gitignore | Broadens env-file ignore pattern to match new *.env naming. |
Suppressed comments (1)
content/posts/2022/10/30/installing-flair-smart-vents-to-keep-room-temperatures-balanced.md:110
- The caption text is appended after the
figureshortcode, so it will render as plain text inside the.gallerycontainer (not as afigcaption). Consider putting the caption in the shortcode parameter so it stays attached to the image and doesn’t introduce stray text nodes in the gallery grid.
{{< figure src="/media/2022/10/img_1668.jpg?w=589" alt="" caption="" >}}FLAIR vent is smaller than standard vents
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (3)
content/posts/2022/10/30/installing-flair-smart-vents-to-keep-room-temperatures-balanced.md:66
- The figure caption is provided as raw text after the
figureshortcode whilecaptionis set to an empty string. Insidegallery, that text won’t be part of the rendered<figure>/<figcaption>structure and may not be styled/associated correctly. Put the caption text into thecaptionparameter and remove the trailing text.
{{< figure src="/media/2022/10/img_1657.jpg?w=426" alt="" caption="" >}}FLAIR puck vs. my old Ecobee3
content/posts/2022/10/30/installing-flair-smart-vents-to-keep-room-temperatures-balanced.md:110
- This
figureshortcode has an emptycaptionparameter but then appends caption text after the shortcode. In agallery, that trailing text won’t be part of the figure markup and can break caption styling/association. Move the caption into thecaptionparameter and remove the trailing text.
{{< figure src="/media/2022/10/img_1668.jpg?w=589" alt="" caption="" >}}FLAIR vent is smaller than standard vents
content/posts/2022/10/30/installing-flair-smart-vents-to-keep-room-temperatures-balanced.md:175
- The caption text is appended after the
figureshortcode whilecaptionis empty. This makes the caption not part of the rendered figure/figcaption HTML (especially inside agallery). Put the caption into thecaptionparameter and remove the trailing text.
{{< figure src="/media/2022/10/img_1751.jpg?w=519" alt="" caption="" >}}Laser cut white acrylic flange
Promotes
develop(09c8fe6) tomain(876690a). Seven pull requests, each merged with a clean review and green CI.What changed
A gate was added, and it immediately found conversion loss. #43 made the parity check fail on media the site carries but links from nowhere. #44 is what that gate surfaced: 19 gallery images the WordPress conversion had dropped, restored across three posts. The pair is the whole point of the ordering — the check landed first, the content fix second, and the count is now a constant something enforces rather than a number in prose.
The check could not read an absolute reference at all. #46 found that Hugo emits one wherever a template resolves against the base, which the entry-cover image on every list page does, so those files read as external. It cost both directions: five files were counted as orphans while being displayed, and eighteen references were never checked for existing. The second half is the serious one, because a missing file behind an absolute reference passed silently. Orphans fall 103 to 98 and assets rise 1031 to 1049 as a consequence, neither because the site changed.
The 98 remaining orphans are now adjudicated rather than unknown. 97 were uploaded to the old platform's media library and never placed on a published page; one is that platform's site icon, superseded by the favicon set. No conversion loss remains, so anything that raises the count from here is new.
The rest is documentation of process that existed only in an agent's memory. The periodic log review in both directions and its three tiers (#47), the publishing walk and the migration record, with
CAPTURE_ROOTmoved out of memory and intosecrets/(#49), the hub's verification ruling (#45), and the environment files renamed to<server>.<environment>.envso the default file's name says which of the four it is (#50).Verification
The live check was run against the local mirror, which is the half of the URL contract CI cannot reach: the validation workflow proves the URLs that must render, and nothing in a build exercises the redirects.
In-build contract on the same release: 328/328 golden URLs, 778/778 legacy image URLs after the
@uploadsrewrite, 1049/1049 local asset references, 98 orphans against the enforced constant. Build clean underhugo --gc --minify --panicOnWarning.This tip was also deployed to VPS staging through the pipeline before this pull request was opened, which is the part the mirror cannot prove: routing, TLS, the confined deploy key, and the auth gate.
deploy-site.ymldispatched ondevelopinstalled release20260808-033432and its own live check passed against the running site behind the Pangolin gate.Not in this promotion
No publish.
publish-release.ymlisworkflow_dispatchonly, so merging here ships nothing.Production is deployed separately and deliberately. Merging this makes the rehearsal deploy possible; it does not perform it. The
productionenvironment'sHUGO_BASEURLhas been set to the interimhttps://blog.insanegenius.net/per theTODO.mditem, and reverts to.comat the cutover.Merge with a plain merge commit. Do not pass
--delete-branch, which on adevelop -> mainpromotion deletesdevelop.🤖 Generated with Claude Code