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
19 changes: 12 additions & 7 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ The site is built, gated in CI, and deployed to staging by pipeline. It is not y
| --- | --- |
| Content and media | done. Carries every migrated post and page, with the media hash-verified against the export tar |
| 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 |
| URL contract | done. 328 render, 917 redirect, 778 legacy image URLs, all gated. **Two gates read different subsets of that, which is why two totals appear in this file**: the build gate proves all three against files on disk, and the live check requests 328 + 917 + 8 curated media = **1,253** against a running server, since proving 778 images over the wire on every deploy buys nothing the build gate has not already proven |
| 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. 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 |
| 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. **The merge itself published nothing**, which is the release model behaving as specified: a human merge never auto-publishes, and the releases named two rows down were cut afterwards by explicit dispatch |
| 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` |
| Release pipeline | proven end to end and cut from both branches 2026-08-09. `1.0.54` is the newest release, from `main` and marked Latest, and `1.0.54-gcd9285a9fe` the newest prerelease, from `develop`. Each tag is pinned to its own branch tip, and the clean-versus-suffixed classification is the gate working rather than a coincidence |
| Fleet conformance | cataloged in the hub registry, audited, and carrying the current canonical |
| Deploy pipeline | `deploy-site.yml` is dispatchable and has deployed staging from CI end to end, through a transport retested against the real host |
| VPS staging | live at `blog.vps.insanegenius.net`, behind the auth gate, serving a pipeline release |
| VPS production | **M7a done 2026-08-08.** Serving release `20260808-154717` at `blog.insanegenius.net`, answering `200` unauthenticated, deployed from `main` by pipeline with the 1,245-URL contract verified against the live site. `/robots.txt` answers 200 carrying a `.net` sitemap line, and the gallery fix is live. DNS for the public name is still on the old platform |
| VPS production | serving release `20260809-030521` at `blog.insanegenius.net` as of 2026-08-09, deployed from `main` after the promotion and read back from the live `X-Blog-Release` header rather than from the run's exit code. `200` unauthenticated, `X-Robots-Tag: noindex, nofollow` still set for the rehearsal, and the live check's 1,253 URLs verified against the running site, which is the 328 + 917 + 8 subset described in the URL contract row rather than a different contract. That release is **the first built with the mtimes restored**, so the first whose static tree can arrive as hard links, and therefore the first where a bad file mode could ride one forward into every later release. The shared-inode count is server-side, so ask the host for it rather than inferring it here. **M7a, the first production deploy, was 2026-08-08 on release `20260808-154717`**, which established the unauthenticated `200`, the `.net` sitemap line in `/robots.txt`, and the gallery fix. DNS for the public name is still on the old platform |
| Operations | started, and neither half has completed a **scheduled** run. The off-host log pull is installed, armed for 09:00 UTC daily, and has copied once, started by hand, so the timer itself has never fired and 2026-08-09 is its first scheduled run. The periodic log review has run once by hand, the outward pass only: it read 2026-08-08 traffic and found nothing to add to the URL contract, the inward pass has not run, and neither pass is on the cadence **Recurring operations** sets |

## Blocked on the maintainer
Expand All @@ -30,7 +30,7 @@ The site is built, gated in CI, and deployed to staging by pipeline. It is not y
## Next, in dependency order

- **Prove a rollback through the pipeline.** A forced mid-deploy failure, then a flip back to the previous release, verified by `EXPECT_RELEASE` rather than by the transport exiting zero. The server side has been measured at well under a second by hand; what is unproven is that a **pipeline** run leaves the site serving when its deploy fails part way.
- **Production is deployed, which the VPS agent calls M7a, done 2026-08-08.** `blog.insanegenius.net` serves release `20260808-154717`, answering `200` unauthenticated on a Let's Encrypt certificate issued 2026-08-07, read from the `X-Blog-Release` header rather than from a pipeline's exit code. The host side verified the first production release independently, 9/9 unauthenticated with the built `baseURL` read from the deployed bytes rather than from this repo's config, across a 3,095-request gate run with no unexplained 404s. What remains is **M7b, the `.com` cutover**, and the sub-items below are where this repo stands against it, two of them owed and one already answered. The VPS agent's §19, §20, §23 and §24 carry the detail and that file is not in the repository, so pull it first per [`OPERATIONS.md`](./OPERATIONS.md) "The Channel Between the Two Sides":
- **Production is deployed, which the VPS agent calls M7a, done 2026-08-08.** `blog.insanegenius.net` answers `200` unauthenticated on a Let's Encrypt certificate issued 2026-08-07, with the serving release read from the `X-Blog-Release` header rather than from a pipeline's exit code. **It serves `20260809-030521` today; M7a itself was `20260808-154717`**, and the State table above is the current value, so read it there rather than from this paragraph. The host side verified that first production release independently, 9/9 unauthenticated with the built `baseURL` read from the deployed bytes rather than from this repo's config, across a 3,095-request gate run with no unexplained 404s. What remains is **M7b, the `.com` cutover**, and the sub-items below are where this repo stands against it, two of them owed and one already answered. The VPS agent's §19, §20, §23 and §24 carry the detail and that file is not in the repository, so pull it first per [`OPERATIONS.md`](./OPERATIONS.md) "The Channel Between the Two Sides":
- **`HUGO_BASEURL` on the `production` environment is set to `https://blog.insanegenius.net/`**, done 2026-08-07. It held `https://blog.insanegenius.com/`, the live WordPress address, which is what the workflow both builds with and points the live check at, so a deploy would have baked the old platform's address into every canonical tag, feed link and `sitemap.xml` and then run 1,245 requests at the live site to verify it. **Setting it back to `.com` at M7b is the other half and is not done.**
- **Production emits `X-Robots-Tag: noindex, nofollow` for the length of the rehearsal**, deliberately, because `.net` serves a public duplicate of a live site and Certificate Transparency publishes the hostname. Where a check asserts `index, follow`, make the expected value a parameter rather than flipping a literal, since it reverts at M7b and a hardcoded literal is one more thing to remember at the wrong moment.
- **The two questions in §19.3 are answered.** `HUGO_BASEURL` holds the interim `.net` name, per the item above. Exactly one place hardcodes `blog.insanegenius.com`: `baseURL` on line 1 of `hugo.yaml`, which is the production default every environment overrides through `HUGO_BASEURL`. Nothing under `checks/`, `deploy/`, `layouts/`, or `.github/` carries it.
Expand All @@ -42,10 +42,15 @@ 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.~~ 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.
- **~~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/`. **Release `20260809-030521` is the first production deploy to exercise it**, done 2026-08-09, 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 shared-inode count is the open half**: it is server-side, the host had been measuring 0 across every pipeline release, and roughly 1052 of 1791 is what the change predicts. Asked in the channel rather than inferred from this side. 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.
- **~~Publish a release from `main`, once the pipeline has soaked.~~ Done 2026-08-09, with the maintainer lifting the soak condition explicitly.** `1.0.54` is cut from `main` and marked Latest, and `1.0.54-gcd9285a9fe` from `develop`, each tag pinned to its own branch tip. Before this, `1.0.11` had been the newest from `main` since 2026-08-01, predating every deploy change. **The soak reasoning is kept because it was not satisfied, it was overridden**, and the same argument returns at the next release: what a release from `main` cannot yet claim is a switchover that has been *trusted* rather than merely green, which the log review under **Recurring operations** establishes and no gate can. `1.0.54` therefore names a working pipeline and a rehearsal host, not a completed cutover.
- **Gate the deploy ref by environment in both directions, so a deploy cannot do what a release structurally cannot.** [`deploy-site.yml`](./.github/workflows/deploy-site.yml)'s `assert-ref` job refuses production from any ref but `main`, and **staging accepts any ref at all**. Add the missing arm so staging accepts only `develop`: `develop` deploys staging, `main` deploys production, and neither can reach the other's environment.
- **The release workflow is safe by derivation rather than by a gate, which is the distinction to get right before copying anything from it.** [`publish-release.yml`](./.github/workflows/publish-release.yml) has exactly one **ref** guard, an `Assert dispatch ref step` refusing any ref but `main` or `develop`. It also runs the reusable `validate` job that the pull request runs, so a dispatch cannot release a ref that fails validation, but that gates the *sources* rather than the branch-to-classification relationship this item is about. Everything else follows from the ref with nothing to disagree with it: NBGV reads `publicReleaseRefSpec` in [`version.json`](./version.json) to give `main` a clean `X.Y.Z` and every other branch a `-g<sha>` suffix, and the release's `prerelease:` is computed as `github.ref_name != 'main'`. **`main` cannot cut a prerelease because no input exists that could ask it to.** This repo has no `validate-release` job — that is a build-repo construct in the fleet contract, not something shipped here, and an earlier draft of this item claimed it did.
- **The deploy differs in kind, which is why it needs the gate the release does not.** `environment` is an independent `workflow_dispatch` input, so the ref and the environment are two values that can disagree, and today only one of the two disagreements is caught. That is the whole argument: not that the deploy should imitate the release, but that a workflow taking the target as a separate input has to check it, where one deriving the target from the ref has nothing to check.
- **This reverses a deliberate decision, so the comment above the job has to change with the code.** It currently reads "Staging deploys from any ref, since proving a branch before it merges is what staging is for", which is the argument for the present behavior and would be left contradicting the gate. Whoever makes the change states the new reason instead: staging is the rehearsal for what `develop` will promote, so a deploy from a feature branch proves something that is not what ships. Requested by the maintainer 2026-08-09, after a staging deploy was dispatched from `main` here.
- Keep the existing comparison style, against the full `github.ref` rather than `ref_name`, for the reason already recorded there: a tag and a branch can share a short name, so a tag called `develop` would satisfy a `ref_name` test while pointing at an arbitrary commit. The new arm inherits that hazard exactly and is not a special case.
- 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.
Expand Down