Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions TODO.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,8 @@ The site is built, gated in CI, and deployed to staging by pipeline. It is not y
| Media integrity | closed for the files themselves. The conversion's only loss was 19 images, inside five galleries, restored from the capture, and the 98 files with no page links are adjudicated rather than unknown, with `ORPHANED_MEDIA` failing if that count moves either way. How media was *presented* was a separate loss, found later and now gated |
| URL contract | done. 328 render, 917 redirect, 778 legacy image URLs, all gated |
| Deploy shape | done. Proven on two local mirrors and on the VPS, by hand and by pipeline |
| CI workflows | green. Validation runs on every pull request and feeds the required check |
| CI workflows | green. Validation runs on every pull request and feeds the required check. Three gates live in `checks/`: the URL contract, the environment-docs pair, and the line-ending pins |
| Branches | converged 2026-08-09. [#68][pr-68] promoted `develop` to `main` as a merge commit, `main` carries every commit on `develop`, and the content diff is empty. Nothing published: no run fired on `main` and `1.0.11` is still the newest release, which is the release model behaving as specified |
| GitHub repo | public, both rulesets active, `configure.sh check` exits 0 |
| Release pipeline | proven end to end. `1.0.17-g4b2def3ee9` is the newest, a prerelease from `develop` |
| Fleet conformance | cataloged in the hub registry, audited, and carrying the current canonical |
Expand DownExpand Up@@ -41,10 +42,12 @@ The site is built, gated in CI, and deployed to staging by pipeline. It is not y
- **A wrong `HUGO_BASEURL` is still invisible to every gate here, and the `Sitemap:` line does not change that.** Worth stating because the opposite is easy to believe: the parity check compares the advertised origin against the one on the home page's canonical link, and both come from `baseURL`, so they agree whenever the build is coherent — including when `baseURL` was wrong for the environment. Nothing inside the artifact can see it, which is why the check belongs on the side that knows which host it is serving, and the VPS side does it by reading the origin out of the deployed `sitemap.xml`, `og:url` and `feed.xml`. What the comparison does catch is an origin **written rather than derived**, a committed `static/robots.txt` shadowing the template being the way that happens.
- **Media is checked live now, which unblocks the item below.** [`checks/golden-media-live.txt`](./checks/golden-media-live.txt) is fetched by `check-live-urls.sh` against a running server, covering both media trees and the `@uploads` rule, and asserting status, a non-zero body and an image content type so that a 403 from a bad mode, a 404 from a lost transfer, a truncated file and a soft-404 error page are each caught. Verified against production, and each of the four failure shapes was reproduced rather than assumed. The record of why it was needed follows.
- **~~Nothing checks that media survived the trip to the server.~~ Closed 2026-08-08, by the item above.** The VPS agent noticed in §24.3 that a 3,095-request gate run fetched no image at all, and asked whether `golden-media-legacy.txt` is wired in. It is, but only at build time, in `check-url-parity.py`, against files on disk. The live check requests pages and redirects and never an image, so a media tree lost **between the build and the server**, a partial upload, is caught by neither: the build passed before the loss and the live gate never asks. On a site whose value is eighteen years of posts with images in them, that is the gap worth closing rather than the one that was suspected. A handful of media URLs in the live check would close it, chosen to cover both trees rather than to be exhaustive, since the build gate already proves the set. The mechanism that makes this concrete rather than theoretical is the hard-link trap below: a link carries its inode's mode, so a media file that acquires a bad one rides the chain into every later release, present and correctly named and unreadable to the server, which `is_file()` on the runner cannot see and a check that never requests an image cannot either.
- **Restore file mtimes in CI so `--link-dest` links. The media check it waited on now exists, so this is unblocked.** The host side measured zero shared inodes across every release the pipeline has delivered, against 1052 of 3266 on a release built here, and the cause is neither the call site nor the confined rsync: both were tested there and link correctly through a relative symlink. Git stores no mtimes, so a CI checkout writes all 3,272 files inside a 23-second window and the `static/` tree that would otherwise match arrives freshly stamped with everything else. `git-restore-mtime` is the fix and needs no checkout change, since `deploy-site-task.yml` already uses `fetch-depth: 0`, and it is deterministic across runs in exactly the place that matters, because `static/` has stable last-commit times. **The ordering is the part worth writing down.** Today every file arrives as a fresh inode, so `--no-g --chmod=D2755,F644` re-establishes the mode contract on every deploy; make the mtimes honest and about a third of the tree starts arriving as links carrying whatever mode its chain began with, which is the trap above. Harmless as things stand, since every inode in the current chain was made by that same rsync line, and it means the live media check should exist first. Nothing is broken meanwhile: the cost is ~585 MB a release, which the host's prune timer reclaims.
- **~~Restore file mtimes in CI so `--link-dest` links.~~ Done, [#65][pr-65], and on `main` since the 2026-08-09 promotion.** `deploy-site-task.yml` installs `git-restore-mtime` and runs `git restore-mtime static` before the upload. Measured with two independent clones each way: 0 of 1791 files linked without the restore, 1052 with, and that 1052 corroborates from three directions, being what Hugo reports as static files, what links on a locally built release, and the file count in `static/`. **The next production deploy is the first to exercise it**, so it is also the first where a badly moded file can ride a link into every later release, which is the trap below. The live media check landed first, deliberately, and is what watches for exactly that. The reasoning is kept below because it is what the next change to the deploy's linking behavior will need.
- The host side measured zero shared inodes across every release the pipeline has delivered, against 1052 of 3266 on a release built here, and the cause is neither the call site nor the confined rsync: both were tested there and link correctly through a relative symlink. Git stores no mtimes, so a CI checkout writes all 3,272 files inside a 23-second window and the `static/` tree that would otherwise match arrives freshly stamped with everything else. `git-restore-mtime` is the fix and needs no checkout change, since `deploy-site-task.yml` already uses `fetch-depth: 0`, and it is deterministic across runs in exactly the place that matters, because `static/` has stable last-commit times. **The ordering is the part worth writing down.** Today every file arrives as a fresh inode, so `--no-g --chmod=D2755,F644` re-establishes the mode contract on every deploy; make the mtimes honest and about a third of the tree starts arriving as links carrying whatever mode its chain began with, which is the trap above. Harmless as things stand, since every inode in the current chain was made by that same rsync line, and it means the live media check should exist first. Nothing is broken meanwhile: the cost is ~585 MB a release, which the host's prune timer reclaims.
- Lower the `blog` A-record TTL to 60s a day ahead, then flip it to the VPS, unproxied.
- **Publish a release from `main`, once the pipeline has soaked.** `1.0.11` is the newest release from `main` and was cut on 2026-08-01, ahead of every deploy change, so the next one is the first that would describe a site actually serving its public address. The mechanism is proven and is not what this waits on: it waits on the switchover being trusted rather than merely green, which is what the log review under **Recurring operations** establishes and no gate can. A release cut before that names a state that has not held yet.
- Add the weekly non-blocking external-link-check workflow, which is the one gate that cannot be blocking because it fails on other people's outages.
- **[`checks/README.md`](./checks/README.md) names one of the three gates in its own directory.** It is written as the URL contract's document, titled `URL Parity Gate`, and `check-env-docs.py` and `check-eol-pins.py` appear in it nowhere. Each carries its reasoning in its own docstring, which is where the detail belongs, but [#66][pr-66] made a directory's README the authority on that directory and this one currently describes a subset of it without saying so. The small fix is a section naming what else lives there and what each gate answers; the alternative is to state in the README that it is scoped to the contract alone. Either resolves it, and the present state is the one that reads as complete while not being.
- Decommission WordPress.com only after **30 clean days**, and downgrade to free rather than deleting, which keeps the media reachable as a safety net and preserves the ability to re-export. Do not start sooner: the conversion fetched media over HTTP from the live site.

## Recurring operations
Expand All@@ -65,7 +68,9 @@ The site is built, gated in CI, and deployed to staging by pipeline. It is not y

## Owed to the hub

Nothing. The spec update this repo owed the hub has landed: [ProjectTemplate#560][hub-type-pr] authored the `hugo` type, the `self-hosted` target, the `deploy-ssh` mechanism, guarantees D4.6 and D5.6, and a reference leaf pair, all measured from what this repo actually runs rather than from the prediction the intake carried. [#456][hub-issue] and [#558][hub-spec-issue] are closed with it.
**One thing, and it is conditional.** [ProjectTemplate#633][issue-633] reports that the hub's `repo_gate.py --check eol` compares `.gitattributes` and `.editorconfig` to each other and never to the tree, and offers to port this repo's [`checks/check-eol-pins.py`](./checks/check-eol-pins.py) into the hub's `scripts/` with tests in the `test_repo_gate.py` style. That offer is owed only if the hub wants that shape. **Do not start it before the hub rules**, because the open design question changes the code: the dead-pattern direction cannot be lifted unmodified, since a carried baseline forward-declares pins for files its consumers will have and the template itself does not, which is correct there and a finding anywhere else.

The spec update this repo owed the hub has landed: [ProjectTemplate#560][hub-type-pr] authored the `hugo` type, the `self-hosted` target, the `deploy-ssh` mechanism, guarantees D4.6 and D5.6, and a reference leaf pair, all measured from what this repo actually runs rather than from the prediction the intake carried. [#456][hub-issue] and [#558][hub-spec-issue] are closed with it.

**It is on the hub's `develop` and not on `main`, so it is not ground truth yet.** The registry entry that reclassifies this repo to `types: ["hugo", "source-only"]` with both publish targets sits on the same unpromoted branch. Until the hub promotes, this repo stays `source-only` for audit purposes, and the anticipatory evaluation of the nine `hugo` checks is in [the audit report](./reports/Blog/audit.md).

Expand DownExpand Up@@ -109,6 +114,7 @@ Three findings are open at the hub, recorded here rather than only in the issues
| [#550][issue-550] | Nothing detects a repo missing from the registry, which is how this repo stayed invisible. Three other repos are still absent. |
| [#552][issue-552] | The audit flags any carried `AGENTS.md` naming the template repo, and the byte-locked `Fleet Bootstrap` section names it. Carrying the canonical correctly cannot pass, and it is the one finding the current run cannot clear. |
| [#597][issue-597] | Filed from here, after a mandatory pre-merge gate in `OPERATIONS.md` was skipped on [#40][pr-40]. The ruling is that a verification a runner cannot perform needs a declared destination, not a better per-repo pointer. Answered by [#598][pr-598], which this repo owes work against once it is ground truth. |
| [#633][issue-633] | Filed from here, 2026-08-09. The hub's `eol` gate is document-to-document, so it reported `0 issue(s)` against the exact tree carrying both defects [#69][pr-69] fixed. Where a repo sets `[*] end_of_line = lf`, as this one does, that gate is vacuously satisfied by any pin at all, including one naming a file that does not exist. Open on the hub's side; the conditional work it may ask of this repo is under **Owed to the hub**. |

**What [#598][pr-598] will ask of this repo, once it is ground truth.** It is merged to the hub's `develop` and not to `main`, so it binds nothing yet, per the trap below about reading `main` as ground truth. It makes `Local Verification` a sixth declared `OPERATIONS.md` heading, leading the file as the only pre-merge one. This repo's `OPERATIONS.md` carries **13 level-two headings and matches none of the five declared today**, so the work is a rename and reorder rather than new prose: `Local Verification Before a Pull Request` becomes `Local Verification`, and `Backup and Restore` is one word from the declared `Backup and Recovery`. Both are near-misses rather than absences, which is the shape a heading check will mostly find in a repo that wrote its operational document before the spec declared headings. Do not start until the hub promotes it.

Expand DownExpand Up@@ -147,6 +153,9 @@ Each of these was hit or nearly hit, and each is cheap to re-trip.
- **The Copilot reviewer's login differs by API, and a wrong-form filter reads as a clean review.** REST reports `copilot-pull-request-reviewer[bot]`, GraphQL omits the suffix. A filter written in the other form matches nothing, and an empty result is indistinguishable from no findings. Assert the filter matched before trusting what it returned.
- **A Copilot review hides findings in the review body, where the thread API cannot see them.** The `reviewThreads` query returns line threads only, so a review carrying `Suppressed comments (N)` in a `<details>` block reports zero unresolved while real findings sit unread. Read the review body itself, not just the threads, before calling a review loop finished.
- **`gh pr merge --delete-branch` on a `develop -> main` promotion deletes `develop`.** Use a plain `gh pr merge --merge`.
- **A review that exists on the head is not a finished review.** On [#68][pr-68] `pr_review.py wait` returned after one second with `review_on_head=yes unresolved=0 suppressed=0`, an independent thread poll agreed at zero, and the pull request was reported green. Copilot posted a third finding *after* both reads, and the maintainer found it. The re-request had matched a review still being written, so every query was correct and every query was early. Wait and re-sweep all three surfaces, line threads, issue-level comments and the review bodies, before calling a loop finished. A very short `waited=` is the tell.
- **A rule naming a target that does not exist reads as coverage.** `.gitattributes` pinned `deploy/blog-deploy-shell` and `deploy/authorized_keys`, neither ever tracked in this repo, and the comment above them claimed the extensionless-shebang case was handled. It was not, and the one real instance sat unpinned twenty lines up, invisible precisely because the file looked thorough. [`checks/check-eol-pins.py`](./checks/check-eol-pins.py) gates this file now, in both directions. The generalisation is not gated anywhere: a config that names a path, a job, or a host that is absent will read as protection for as long as nobody checks.
- **A gate is only as good as the matcher underneath it, and a wrong matcher fails quietly.** `check-eol-pins.py` shipped with two defects in one function, `git ls-files` pathspec not sharing gitattributes glob semantics and then a leading `/` being stripped before anchoring was decided. Both were found by review rather than by the gate, and each would have made it report a confident wrong answer. [#70][pr-70] fixed them and it now checks its own matching against `git check-attr` on every run, which is the general move: where a check predicts what a tool will do, compare it against the tool rather than against the documentation.

## Reference

Expand DownExpand Up@@ -182,6 +191,11 @@ The deploy root is deliberately absent from this table. The rsync destination is
[migration-post]: ./content/posts/2026/08/01/moving-this-blog-from-wordpress-to-hugo.md
[pr-30]: https://github.com/ptr727/Blog/pull/30
[pr-40]: https://github.com/ptr727/Blog/pull/40
[pr-65]: https://github.com/ptr727/Blog/pull/65
[pr-66]: https://github.com/ptr727/Blog/pull/66
[pr-68]: https://github.com/ptr727/Blog/pull/68
[pr-69]: https://github.com/ptr727/Blog/pull/69
[pr-70]: https://github.com/ptr727/Blog/pull/70

<!-- External -->

Expand All@@ -196,5 +210,6 @@ The deploy root is deliberately absent from this table. The rsync destination is
[issue-554]: https://github.com/ptr727/ProjectTemplate/issues/554
[issue-563]: https://github.com/ptr727/ProjectTemplate/issues/563
[issue-597]: https://github.com/ptr727/ProjectTemplate/issues/597
[issue-633]: https://github.com/ptr727/ProjectTemplate/issues/633
[pr-553]: https://github.com/ptr727/ProjectTemplate/pull/553
[pr-598]: https://github.com/ptr727/ProjectTemplate/pull/598