diff --git a/.github/actions/install-hugo/action.yml b/.github/actions/install-hugo/action.yml new file mode 100644 index 0000000..e9af64a --- /dev/null +++ b/.github/actions/install-hugo/action.yml @@ -0,0 +1,39 @@ +name: Install Hugo +description: Install the pinned Hugo extended build, verified by checksum before install. + +# The single declaration of which generator this repo builds with. +# It used to live in both validate-task.yml and deploy-site-task.yml, with an instruction to update both and nothing enforcing it. +# A one-sided bump was silent and produced exactly the failure the pin exists to prevent: validation building the site with one generator while the deploy shipped a tree built by another, each verifying its own checksum against its own version and both passing. +# No Dependabot ecosystem tracks Hugo, so both values move by hand and there was no bot to catch the skew either. +# +# Pinned by version and by checksum rather than installed from a floating action, because the site is reproducible only if the generator is, and a minor bump can change rendered output. +# Update both values together, from the checksums file on the Hugo release. + +# The pin is hardcoded below rather than exposed as inputs with defaults. +# An overridable input would let two callers pass different values and reintroduce the divergence this action exists to remove, which is the same defect one level up. +# A pin that callers cannot override is correct by construction rather than by everyone agreeing to omit the argument. +# Change it here, in one place, and every caller moves together or none does. + +runs: + using: composite + steps: + + # A tampered or moved artifact fails at the checksum rather than producing a wrong site. + # The extended build is asserted from the binary rather than inferred from the file name, since the name is the only thing that carried that requirement before. + - name: Install Hugo step + shell: bash + env: + HUGO_VERSION: 0.164.0 + HUGO_SHA256: 8325f3653032d0fc536503691f4833dc4eb6c6be02ee62466758f3f37a7f2fcd + run: | + set -Eeuo pipefail + deb="hugo_extended_${HUGO_VERSION}_linux-amd64.deb" + curl -sSLf -o "$deb" \ + "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/${deb}" + echo "${HUGO_SHA256} ${deb}" | sha256sum --check --strict + sudo dpkg --install "$deb" + hugo version + hugo version | grep -q extended || { + echo "::error::Hugo reports a non-extended build, which cannot process this site's SCSS." + exit 1 + } diff --git a/.github/workflows/deploy-site-task.yml b/.github/workflows/deploy-site-task.yml index 3693b28..e1b8f5d 100644 --- a/.github/workflows/deploy-site-task.yml +++ b/.github/workflows/deploy-site-task.yml @@ -7,12 +7,28 @@ on: description: The GitHub Environment to deploy to, production or staging. required: true type: string + outputs: + # A caller records what shipped, rather than having to read the host to find out. + # The verification below proves this id is the one answering, so it is the value a rollback names. + release-id: + description: The id of the release installed by this run. + value: ${{ jobs.deploy.outputs.release-id }} + site-url: + description: The base URL the deploy was verified against. + value: ${{ jobs.deploy.outputs.site-url }} +# The transport's options are pinned rather than left to the runner's OpenSSH defaults, and declared once so the two transfers cannot drift apart. +# StrictHostKeyChecking=yes refuses an unknown or changed host key outright, where the default asks and a non-interactive runner then resolves that ambiguously. +# UserKnownHostsFile names the file the deploy key step writes, so the check reads the pinned value rather than whatever the runner image carries. +# BatchMode=yes makes every prompt an immediate failure, so a credential problem surfaces as a failed step rather than a job that hangs to its timeout. +# IdentitiesOnly=yes stops the agent offering other keys, so the deploy authenticates as the confined account or not at all. env: - # Pinned by version and checksum, because the site is reproducible only if the generator is. - # Update both values together. - HUGO_VERSION: 0.164.0 - HUGO_SHA256: 8325f3653032d0fc536503691f4833dc4eb6c6be02ee62466758f3f37a7f2fcd + SSH_TRANSPORT: >- + ssh -i ~/.ssh/deploy + -o IdentitiesOnly=yes + -o StrictHostKeyChecking=yes + -o UserKnownHostsFile=~/.ssh/known_hosts + -o BatchMode=yes jobs: @@ -22,6 +38,8 @@ jobs: assert-environment: name: Assert environment name job runs-on: ubuntu-latest + # Nothing here reads the repository, so it needs no token scope at all. + permissions: {} steps: - name: Assert environment is known step env: @@ -44,6 +62,9 @@ jobs: environment: ${{ inputs.environment }} permissions: contents: read + outputs: + release-id: ${{ steps.release.outputs.id }} + site-url: ${{ vars.HUGO_BASEURL }} steps: @@ -53,15 +74,9 @@ jobs: with: fetch-depth: 0 + # The pin lives in the action, so the deploy and validation cannot install different generators. - name: Install Hugo step - run: | - set -Eeuo pipefail - deb="hugo_extended_${HUGO_VERSION}_linux-amd64.deb" - curl -sSLf -o "$deb" \ - "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/${deb}" - echo "${HUGO_SHA256} ${deb}" | sha256sum --check --strict - sudo dpkg --install "$deb" - hugo version + uses: ./.github/actions/install-hugo # REQUIRE_BROTLI below makes a missing binary fatal, so this keeps the build from failing. - name: Install brotli step @@ -116,7 +131,7 @@ jobs: set -Eeuo pipefail rsync -az --mkpath --no-g --chmod=D2755,F644 \ --link-dest="/${ENVIRONMENT}/current/" \ - -e "ssh -i ~/.ssh/deploy -o IdentitiesOnly=yes" \ + -e "$SSH_TRANSPORT" \ "${RUNNER_TEMP}/bundle/releases/${RELEASE_ID}/" \ "${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/releases/${RELEASE_ID}/" @@ -130,7 +145,7 @@ jobs: run: | set -Eeuo pipefail rsync -a --no-recursive \ - -e "ssh -i ~/.ssh/deploy -o IdentitiesOnly=yes" \ + -e "$SSH_TRANSPORT" \ "${RUNNER_TEMP}/bundle/current" \ "${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/" diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 773055d..aa727d0 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -20,15 +20,19 @@ jobs: # Staging deploys from any ref, since proving a branch before it merges is what staging is for. # First, so a mis-dispatched production deploy fails before anything is installed or written. + # Compared against the full ref rather than ref_name, because tags and branches are separate namespaces that share a short name. + # A tag named main would satisfy a ref_name comparison while pointing at an arbitrary commit, which is a bypass of the one gate protecting production. assert-ref: name: Assert deploy ref job runs-on: ubuntu-latest + # Nothing here reads the repository, so it needs no token scope at all. + permissions: {} steps: - name: Assert ref matches environment step run: | set -Eeuo pipefail - if [ "${{ inputs.environment }}" = "production" ] && [ "${{ github.ref_name }}" != "main" ]; then - echo "::error::Deploy production from main; got ${{ github.ref_name }}." + if [ "${{ inputs.environment }}" = "production" ] && [ "${{ github.ref }}" != "refs/heads/main" ]; then + echo "::error::Deploy production from main; got ${{ github.ref }}." exit 1 fi diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 2656e82..b0506c5 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -5,13 +5,6 @@ name: Validate task on: workflow_call: -env: - # Hugo is pinned by version and checksum rather than installed from a floating action. - # The site build is only reproducible if the generator is, and a minor Hugo bump can change output. - # Update both values together, from the checksums file on the Hugo release. - HUGO_VERSION: 0.164.0 - HUGO_SHA256: 8325f3653032d0fc536503691f4833dc4eb6c6be02ee62466758f3f37a7f2fcd - jobs: # Source-only repo: lint plus a site build gated on the URL contract, using the same configs the editor uses. @@ -29,15 +22,16 @@ jobs: # Doc linters run as pinned action wrappers. # The editorconfig-checker action is install-only, so it runs via Docker instead. - # The markdown glob excludes the imported archive and the vendored theme. + # The markdown glob excludes the imported archive and the vendored theme trees. # Neither is authored here, and .markdownlint-cli2.jsonc is carried verbatim so it cannot scope them. + # The theme exclusion reaches inside a theme directory rather than all of themes/, so a file we author about a vendored tree is still linted. - name: Lint Markdown step - uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0 + uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0 with: globs: | **/*.md !content/** - !themes/** + !themes/*/** !public/** # The cspell gate covers README + HISTORY only. @@ -77,16 +71,9 @@ jobs: done python3 -c 'import yaml,sys; yaml.safe_load(open("hugo.yaml"))' - # Pinned by checksum, so a tampered or moved artifact fails here rather than producing a wrong site. + # The pin lives in the action, so validation and the deploy cannot install different generators. - name: Install Hugo step - run: | - set -Eeuo pipefail - deb="hugo_extended_${HUGO_VERSION}_linux-amd64.deb" - curl -sSLf -o "$deb" \ - "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/${deb}" - echo "${HUGO_SHA256} ${deb}" | sha256sum --check --strict - sudo dpkg --install "$deb" - hugo version + uses: ./.github/actions/install-hugo # --panicOnWarning is the real gate. # Two PaperMod templates are overridden in layouts/ precisely so it can stay on. diff --git a/.gitignore b/.gitignore index 97d4da2..6872d64 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,9 @@ __pycache__/ secrets/ **/secrets .env + +# The working copies of the host channel described in OPERATIONS.md. +# They carry server internals, and the host's own backup is what makes them durable. +# A fixed path so the files are found by name rather than in a session directory. +# Anchored, unlike the secrets patterns above, because an unanchored name would also ignore a content directory called comms, and a swallowed page is a URL lost silently. +/comms/ diff --git a/HISTORY.md b/HISTORY.md index 5d16e86..12b4d39 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,6 +2,8 @@ Pieter Viljoen's blog, and the tooling that builds, verifies, and deploys it. +The live blog is hosted at [blog.insanegenius.com][blog-link]. + ## Release History - Version 1.0: @@ -11,3 +13,7 @@ Pieter Viljoen's blog, and the tooling that builds, verifies, and deploys it. - CI gates that contract on every pull request, alongside the doc, shell, and workflow linters, with the Hugo version pinned by checksum so a build is reproducible. - A self-contained release bundle carrying the site, the web-server config, and the redirect maps together, so a rollback reverts the rules and the content they refer to as one unit. - The site is not yet serving its public address. This release is the source and its pipeline, not the cutover. + + + +[blog-link]: https://blog.insanegenius.com diff --git a/OPERATIONS.md b/OPERATIONS.md index 0003b31..5ef1031 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -161,10 +161,16 @@ Verify with `checks/check-live-urls.sh` against the environment before consideri ## Retention -Ten releases are kept. Unchanged files hard-link to the previous release, so the static tree is stored once rather than ten times, and a release costs roughly the size of the generated output. +Ten releases are kept at every deploy root, by two independent mechanisms that agree on the number rather than by one mechanism reaching both. + +**On a local mirror, [`deploy/make-release.sh`](./deploy/make-release.sh) prunes as its last step.** Unchanged files hard-link to the previous release, so the static tree is stored once rather than ten times, and a release costs roughly the size of the generated output. The script asserts both halves of that rather than assuming them. It fails when the prune leaves more releases than the limit, and when hard-linking produces no shared files at all. Both have failed silently before, and on a compressing filesystem the disk usage looks plausible either way. +**At a VPS deploy root, the host's `blog-prune-releases.timer` prunes and nothing in this repo does.** It runs daily and keeps ten release bundles per environment, and the release `current` resolves to is retained unconditionally without consuming one of the ten, so the live site survives even a misconfigured count. **Name the unit rather than the number**, because a second daily retention runs on the same host, `pangolin-backup.timer`, and it keeps fourteen encrypted config archives. "Ten, daily" identifies neither of them once it is read on its own. It orders by modification time rather than by name, because the release id is a caller-supplied argument and a label passed in place of a timestamp would sort wrongly and retire the wrong releases. It refuses outright, removing nothing, when `current` dangles or is not a symlink, since a broken `current` means the site is already serving nothing and guessing which release was meant to be live is the wrong move while it is. + +**Nothing prunes on the deploy path, and that is what keeps the deploy key's capability small.** A prune racing a deploy could take the rollback target, where a lingering release only costs disk. This is also why the key needs no delete capability, which is the property "Server Hardening" depends on. The count and the timer belong to the host, so this section records what the host declares rather than holding a second copy of it. See "Who Owns What". + ## Who Owns What The site and the server it runs on are maintained separately, so the boundary is written down rather than inferred. This repo owns the artifact and what proves it correct; the host owns where a release may be written and what happens to it afterwards. @@ -179,6 +185,37 @@ The site and the server it runs on are maintained separately, so the boundary is The two meet at the container contract in [`deploy/README.md`](./deploy/README.md#container-contract). A defect on the host side is fixed on the host; a pipeline that needs the contract to say something different asks for a contract change rather than growing a second copy of the other side's work. +### The Channel Between the Two Sides + +The two sides are maintained by two agents that share no filesystem, no repository, and no session. They exchange rounds through two files on the VPS, in `/srv/agent-comms/`, each named for its author rather than for a direction, because every directional word inverts depending on which side reads it: + +| File | Author | From this side | +| --- | --- | --- | +| `vps-agent.md` | the host | pull it, and never write it | +| `blog-agent.md` | this repo | pull it, append a round, push it back | + +The working copies live in `comms/`, which is gitignored, so they are found by name rather than in whichever session directory last held them: + +```sh +rsync -a root@:/srv/agent-comms/vps-agent.md comms/vps-agent.md +rsync -a --no-o --no-g --chmod=F644 comms/blog-agent.md root@:/srv/agent-comms/blog-agent.md +``` + +**The push suppresses owner and group deliberately.** `-a` implies `-o` and `-g`, and the transfer connects as root, so a plain `rsync -a` carries this workstation's numeric uid onto a host that has no such user and leaves the file owned by a number. + +Four rules, each covering a way the channel has already failed or could: + +- **Never pass `--delete`.** Nothing in that directory should be removed by a transfer, and no permission scheme prevents it, since both sides connect as root. +- **Write only the file this side authors.** The other file is read-only here by convention alone. +- **Re-pull immediately before appending.** Both sides can write in the same minute, so a copy pulled an hour ago is not a base to push from. Pushing this side's file is a read-modify-write, and it is the one operation that can silently drop a round. +- **Timestamp every round from `date`, and add a changelog row.** Nothing sequences the rounds, so the timestamps are the only thing distinguishing a round that arrived late from a round that disagrees. A guessed timestamp is worse than none: a future-dated round sorts ahead of a genuinely later reply, which is the confusion the header exists to prevent. + +**Convention is the only thing protecting either file, so the copies are what matter.** Each side connects as root, so nothing stops either file being overwritten, and one has been. What protects the record is the host's nightly backup, which covers both files and reaches an off-host copy, plus the maintainer's own copy. + +**This repository holds the channel's rules and not its contents.** The rounds themselves stay out of git: they carry host detail this repository does not own, and publishing them here would put a second, unreviewed copy of the server's internals in a public repository to gain a backup the host already has. + +**A transfer into that directory uses `rsync` rather than `scp` for a reason worth keeping.** The host sets `fs.protected_regular = 2`, which refuses `O_CREAT` on an existing file in a group-writable sticky directory whose owner differs from the file's, and root does not bypass it. `scp` and `sftp` open with `O_CREAT` and fail there. `rsync` writes a temporary file and renames, so it succeeds. The directory's current ownership keeps the rule from applying at all, and the failure returns the moment anyone tightens the permissions. + ## Serving Caddy serves the bundle and binds an internal port only. TLS and the public listener belong to the proxy in front of it, so `auto_https off` and `admin off` are deliberate. @@ -248,6 +285,7 @@ The deploy account exists to receive a release and nothing else. - Its key is restricted in `authorized_keys` with `restrict` and a forced command, so it cannot open a shell, allocate a terminal, or forward a port. - **One key covers both environments**, rather than one per environment. Recorded here as a decision rather than an omission, because the opposite is the obvious default and this file asserted it until the two environments actually existed. A per-environment split pays off only where the two keys never share a machine, and here they would: both private keys sit on the maintainer's one workstation, and both secrets in one GitHub store, so whatever reaches one reaches the other. The split would buy a boundary that is already crossed everywhere it is held. - **The forced command is therefore the only boundary left, and it is confined to the parent of both roots.** That is what a single key costs: `rrsync` pins a key to one directory, so the two deploy roots sit under one parent and one pinned command covers both. The roots are `/srv/blog/sites/production` and `/srv/blog/sites/staging`, and the confinement root is `/srv/blog/sites`. +- **The deploy workflow's ref gate is a security control rather than a tidiness check, and it is load-bearing for the same reason.** One key confined to the parent of both roots means a run's environment name, not a credential, decides which of the two trees it writes into. The two GitHub Environments hold separate secrets and separate variables, and that separation stops at the runner: whichever key is installed reaches both trees. So the gate refusing a production deploy from any ref but the default branch is the boundary the credentials do not draw, and it is dispatchable by anyone who can dispatch the workflow. Treat it as part of this list rather than as workflow housekeeping. - **That parent holds content and nothing else, which is why it is not `/srv/blog`.** `/srv/blog` is the deploy account's home directory and contains `/srv/blog/.ssh/authorized_keys`. Confining the key there would let it rewrite the very file that defines what the key may do, and a `--delete` at the root would take `.ssh` with it. Confinement that encloses its own definition is not confinement. The extra `sites/` level is a security boundary rather than tidiness. - Unattended upgrades run with automatic reboot, which is safe because the site is static and the swap survives a restart. @@ -258,3 +296,5 @@ A deploy key that can write a release can already rewrite the site's Caddy confi **The deploy root needs no backup.** The site is reproducible from this repository by running the deploy again, so the only thing worth protecting on the server is its configuration: the container definition, the proxy configuration, the deploy account and its restricted key, and the upgrade schedule. A bare-metal restore is therefore rebuilding the host, restoring that configuration, and running a deploy. Treat any procedure that backs up the deploy root as protecting a copy of something git already holds. + +**A rebuild regenerates the host's SSH keys, and the deploy verifies them, so one step belongs to this side.** Cloud-init deletes and recreates host keys when the instance identity changes, and the deploy transport sets `StrictHostKeyChecking=yes` against a pinned `DEPLOY_SSH_KNOWN_HOSTS`, held per environment. A rebuilt host therefore presents a key the pinned value does not match, and every deploy fails closed until the value is replaced **on both environments**. That blocks the rollback path as well as the deploy path, at exactly the moment a rebuild makes both matter. Read the new fingerprint and update both environments before the first deploy that follows a rebuild. diff --git a/TODO.md b/TODO.md index c78ba00..b75b056 100644 --- a/TODO.md +++ b/TODO.md @@ -4,18 +4,20 @@ Running backlog for this repo, kept in a committed file so the work survives acr ## State -The site is built and gated in CI. It is on GitHub, and it is not yet serving its public address. +The site is built, gated in CI, and deployed to staging by pipeline. It is not yet serving its public address. | Piece | State | | --- | --- | | Content and media | done. 514 pages, 778 media files hash-verified against the export tar | | URL contract | done. 328 render, 917 redirect, 778 legacy image URLs, all gated | -| Deploy shape | done and proven against a running Caddy, on a local publish mirror and a local staging mirror | +| 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 | | GitHub repo | public, both rulesets active, `configure.sh check` exits 0 | -| Release pipeline | proven end to end. Release `1.0.11` carries the tag, source archive, README, and LICENSE | +| 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 | -| VPS | untouched | +| Deploy pipeline | `deploy-site.yml` is dispatchable and has deployed staging from CI end to end | +| VPS staging | live at `blog.vps.insanegenius.net`, behind the auth gate, serving a pipeline release | +| VPS production | environment configured, resource deliberately disabled, DNS still on the old platform | ## Blocked on the maintainer @@ -24,29 +26,27 @@ The site is built and gated in CI. It is on GitHub, and it is not yet serving it ## Next, in dependency order -- Provision the VPS: an unprivileged `blogdeploy` user, the deploy root, and `unattended-upgrades` with automatic reboot. -- The deploy roots are `/srv/blog/sites/{production,staging}` and the confinement root is `/srv/blog/sites`, which is already what the VPS carries. The extra `sites/` level exists because `/srv/blog` is the deploy account's home and holds its own `authorized_keys`, so confining the key there would let it rewrite its own permissions. Restrict the **single** deploy key with `restrict,command=...`, no pty and no forwarding, pinned to that parent. One key rather than one per environment is a deliberate decision, recorded with its reasoning in [OPERATIONS.md](./OPERATIONS.md#server-hardening): the split only pays where the two keys never share a machine, and both sit on one workstation and in one secret store. The cost is that the forced command can no longer separate the environments, which is why the roots share a parent. -- Add the staging DNS record for `blog.vps.insanegenius.net` and expose it through Pangolin. It sits under the existing VPS wildcard, so no new certificate is needed, and **authentication stays on**: staging serves a byte-identical copy of the public site, and an open one is a duplicate handed to every crawler. `check-live-urls.sh` gets through with a resource access token instead. -- Write `deploy-site.yml` and prove it: a dry run that mutates nothing, then a real run, then a forced mid-deploy failure to confirm rollback keeps the site up. Report the measured deploy shape back to [ProjectTemplate#456][hub-issue], which is waiting on it before the publish type can be defined. -- Deploy to a temporary production FQDN and validate there before touching the live record. Lower the `blog` A-record TTL to 60s a day ahead, then flip it to the VPS, unproxied. +- **Retest the deploy transport against the real host**, which is [#33][issue-33] and is joint work with whoever holds the server. The transport now pins `StrictHostKeyChecking`, `UserKnownHostsFile`, and `BatchMode`, so it fails closed where it previously failed open, and a stale `DEPLOY_SSH_KNOWN_HOSTS` stops a deploy rather than being tolerated. Staging first, since a broken transport blocks the rollback path as well as the deploy. +- **Declare what the VPS keeps.** `hugo.deploy.retention` asks for a retention count declared at the destination, and this repo's deploy credential is write-only by design so the prune belongs to the host. The ownership is recorded in `OPERATIONS.md`, the count is not, and the "ten releases" beside it describes `deploy/make-release.sh` on the local mirrors rather than the containers on the VPS. Confirm the host's timer and its count, then write it next to the ownership line. +- **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. +- **Deploy production once, to a name that is not the live one.** The production environment is configured and its Pangolin resource is deliberately disabled, so nothing has ever run against it. Validate there before the record moves. +- Lower the `blog` A-record TTL to 60s a day ahead, then flip it to the VPS, unproxied. - Watch server logs for 404s daily for the first week, because real traffic finds what the golden list missed. Append anything new to `checks/golden-urls.txt` and add a redirect. - 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. - 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. ## Owed to the hub -The hub is owed a spec update for this repo's publishing type. The change is [ProjectTemplate#558][hub-spec-issue], carrying proposed wording for both additions, and [ProjectTemplate#456][hub-issue] holds the intake questions and the measured answers. +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. -**Frame it as a variant of the existing registry-push leaf, not a new release surface.** A NuGet or PyPI leaf builds an artifact and pushes it to its own destination, contributing no `release-asset-*`. This repo does exactly that. Only two things differ, and neither changes the seam: +**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). -| Same as NuGet and PyPI | Unique here | -| --- | --- | -| A leaf builds, then pushes to its own destination | The build is Hugo rather than a language toolchain | -| No `release-asset-*` contributed | The transport is rsync over SSH to a host the project owns | -| Publish is dispatch-gated, never a merge | The destination is a filesystem, so the artifact carries its own version | -| Credentials come from a GitHub Environment | Two environments serve the same artifact, so a deploy must prove which one answered | +Two things become due at that promotion, neither of them work this repo can do first: + +- The two `driftNotes` the hub's registry carries for `hugo.vendored.provenance` and `hugo.generator.pinned` describe work [#30][pr-30] already finished, so they are reconciled away rather than carried. Filed as [ProjectTemplate#563][issue-563], with the measurement that nothing retires them mechanically despite the intent to: the freshness check is gated on a repo having no findings at all, and this repo has one it cannot clear. +- This repo's [`spec/secrets.json`](./spec/secrets.json) note states `types: ["source-only"]` in prose and needs the second type once the registry declares it. -What the type genuinely needs is therefore small: a destination row in `Output Seam by Destination`, and one guarantee that a deploy is verified against the running host by release rather than by transport success. The release model, the branching model, and the never-publish-on-merge rule all hold unchanged. +The reference leaf the hub now ships carries one step this repo's deploy does not, a prune of the remote release tree. That is the corrected form of the check rather than a gap here: this repo's credential cannot observe the destination, so the leaf's own comments say to delete the step and record the ownership on the host side, which is what the entry above tracks. ## Open decisions @@ -61,20 +61,20 @@ Both are recorded in [AUDIT.md](./AUDIT.md) and reported to [ProjectTemplate#456 ## Hub conformance, and what is open against the hub -Reconverged 2026-08-03. This repo is cataloged in [`registry/repos.json`][hub-registry] and the hub authored [`reports/blog/audit.md`][hub-report]. Before that it was in no registry, so no hub tool had ever measured it and the fleet ledger under-counted by exactly this repo. +Reconverged 2026-08-05, and the run is written up in [`reports/Blog/audit.md`](./reports/Blog/audit.md). This repo is cataloged in [`registry/repos.json`][hub-registry] and the hub authored [`reports/blog/audit.md`][hub-report]. Before that it was in no registry, so no hub tool had ever measured it and the fleet ledger under-counted by exactly this repo. -**Measured against hub `main` `3b802b9eb9a841c0149d018f4db6ffa1b9419051`**, and the ref is named because `main` moves, which is the trap below. Every verbatim section of `AGENTS.md` and `GOVERNANCE.md` byte-matches, as do both ruleset payloads and `.markdownlint-cli2.jsonc`. The one exception is `repo-config/configure.sh`, one commit behind on [ProjectTemplate#553][pr-553], which fixes the jq portability defect this repo reported as [#549][issue-549] and is owed a re-vendor. Both links above are pinned to that same ref rather than to `main`, so this record stays checkable after the hub moves again. +**Measured against hub `main` `3b802b9eb9a841c0149d018f4db6ffa1b9419051`**, and the ref is named because `main` moves, which is the trap below. Every verbatim unit now matches: the re-vendor of `repo-config/configure.sh` this record previously owed, for the jq portability defect reported as [#549][issue-549] and fixed at the hub in [#553][pr-553], landed with this change. The links above are pinned to that same ref rather than to `main`, so this record stays checkable after the hub moves again. -Four findings are open at the hub. None is work this repo can do, and each changes what a fleet audit of this repo means, which is why they are recorded here rather than only in the issues. +Two findings are open at the hub. Neither is work this repo can do, and each changes what a fleet audit of this repo means, which is why they are recorded here rather than only in the issues. | Issue | What it means here | | --- | --- | | [#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. | -| [#554][issue-554] | `spec/audit.py` still compares `bypass_actors` after the payloads stopped declaring it, so this repo reports two DEFECTs that no agent action can clear. | -| [#456][hub-issue] | The static-site type, still waiting on a measured deploy shape from the VPS work below. | +| [#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. | + +Two more are resolved and are named because their absence from the table would otherwise read as an oversight. [#554][issue-554] made `spec/audit.py` report two DEFECTs here that no agent action could clear, and the fix is in the hub `main` this run measured against, so those two findings are gone. [#456][hub-issue] and [#558][hub-spec-issue] were the static-site type, now authored, per the section above. -**The live ruleset bypass is deliberate and stays.** Both rulesets carry the `RepositoryRole` admin entry. The owner is automatically an admin and holds that capability regardless, so the entry grants nothing new, and the payloads stopped declaring it because code should not be in the business of granting a bypass at all. `configure.sh check` reports it as unmanaged and exits 0. Only `spec/audit.py` disagrees, which is [#554][issue-554]. +**The live ruleset bypass is deliberate and stays.** Both rulesets carry the `RepositoryRole` admin entry. The owner is automatically an admin and holds that capability regardless, so the entry grants nothing new, and the payloads stopped declaring it because code should not be in the business of granting a bypass at all. `configure.sh check` reports it as unmanaged and exits 0, and the hub audit no longer disagrees. ## Traps @@ -136,16 +136,20 @@ The deploy root is deliberately absent from this table. The rsync destination is +[issue-33]: https://github.com/ptr727/Blog/issues/33 [migration-post]: ./content/posts/2026/08/01/moving-this-blog-from-wordpress-to-hugo.md +[pr-30]: https://github.com/ptr727/Blog/pull/30 [hub-issue]: https://github.com/ptr727/ProjectTemplate/issues/456 -[hub-spec-issue]: https://github.com/ptr727/ProjectTemplate/issues/558 [hub-registry]: https://github.com/ptr727/ProjectTemplate/blob/3b802b9eb9a841c0149d018f4db6ffa1b9419051/registry/repos.json [hub-report]: https://github.com/ptr727/ProjectTemplate/blob/3b802b9eb9a841c0149d018f4db6ffa1b9419051/reports/blog/audit.md +[hub-spec-issue]: https://github.com/ptr727/ProjectTemplate/issues/558 +[hub-type-pr]: https://github.com/ptr727/ProjectTemplate/pull/560 [issue-549]: https://github.com/ptr727/ProjectTemplate/issues/549 [issue-550]: https://github.com/ptr727/ProjectTemplate/issues/550 [issue-552]: https://github.com/ptr727/ProjectTemplate/issues/552 [issue-554]: https://github.com/ptr727/ProjectTemplate/issues/554 +[issue-563]: https://github.com/ptr727/ProjectTemplate/issues/563 [pr-553]: https://github.com/ptr727/ProjectTemplate/pull/553 diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 4842702..d0716da 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -239,7 +239,15 @@ check_ruleset() { # payload-file - the live ruleset must match the committed pol # Dropping that would turn array order into false drift. # A scalar array sorts directly, and required_status_checks sorts by context, its identifying field. local ptypes norm - norm='def n: walk(if type=="array" then (if length==0 then . elif (all(.[]; type=="string" or type=="number")) then sort elif (all(.[]; type=="object" and has("context"))) then sort_by(.context) else . end) else . end); n' + # The walk/1 builtin arrived in jq 1.6, so it is defined here rather than called. + # A host on jq 1.5 would otherwise not degrade, it would fail to compile the filter. + # The check_ruleset function would then report drift on every parameterized rule it never actually compared. + # That is the inverse of the false clean this comparison was written to close, so the portable definition is worth its length. + norm='def w(f): . as $in + | if type == "object" then reduce keys_unsorted[] as $k ({}; . + { ($k): ($in[$k] | w(f)) }) | f + elif type == "array" then map(w(f)) | f + else f end; + def n: w(if type=="array" then (if length==0 then . elif (all(.[]; type=="string" or type=="number")) then sort elif (all(.[]; type=="object" and has("context"))) then sort_by(.context) else . end) else . end); n' ptypes="$(jq -r '[.rules[] | select(has("parameters")) | .type] | .[]' "$file")" while IFS= read -r t; do [ -z "$t" ] && continue diff --git a/reports/Blog/audit.md b/reports/Blog/audit.md index 2f33baf..0ee5a71 100644 --- a/reports/Blog/audit.md +++ b/reports/Blog/audit.md @@ -1,50 +1,53 @@ # Blog Audit -Self-audit of this repository against its own committed ground truth, per [AUDIT.md](../../AUDIT.md). Read-only, and confined to this repository. +Self-audit of this repository against its own committed ground truth, per [AUDIT.md](../../AUDIT.md), and against the fleet ground truth the hub publishes. Read-only, and confined to this repository. It replaces the 2026-08-01 run rather than editing it, which is what the run-stamp discipline asks for: a run records what it observed, and a later run supersedes the whole file. -**Date:** 2026-08-01 -**Hub ref carried:** `ptr727/ProjectTemplate` `main` `3a7cc64` -**Declared:** `types: ["source-only"]`, `workflowModel: release`, `lineEndings: "lf"` +**Date:** 2026-08-05 +**Hub ref carried:** `main` `3b802b9` +**Run stamps:** `audit run 2026-08-05T14:12:14Z | hub 3b802b9` and `audit run 2026-08-05T14:13:11Z | hub 2a1afc0` (the second reads the hub's unpromoted `develop`) +**Declared:** `types: ["source-only"]`, `workflowModel: release`, `lineEndings: "lf"`, `releaseTrigger: dispatch-only` ## Verdict -**Operational.** Every applicable check passes against the live repository. - -The release surface passes. The **deploy** to the VPS is **deferred**, not failed, and that deferral is declared rather than hidden, tracked in [ProjectTemplate#456][hub-issue], which [`STANDUP.md` section 5][standup] permits. +**Operational.** Every applicable check passes, and the two open drift findings are both blocked on something outside this repository. | Dimension | Result | | --- | --- | -| 1. Settings and rulesets | **Pass.** `configure.sh check` exits 0 | -| 2. Secrets | **Pass.** Both required present in both stores, forbidden one absent | -| 3. The URL contract | **Pass.** Enforced by CI, not only locally | -| Baseline file presence | **Pass.** 23 of 23 | -| Verbatim fidelity | **Pass.** 4 of 4 | -| Release | **Pass.** Dispatch-only, proven by release `1.0.11` | -| Deploy to the VPS | **Deferred**, deliberately | +| 1. Settings and rulesets | **Pass.** `configure.sh check` exits 0 on 22 assertions | +| 2. Secrets, repository scope | **Pass.** Both required present in both stores, the forbidden one absent | +| 3. Secrets, environment scope | **Pass.** Both environments carry every declared name | +| 4. The URL contract | **Pass.** Gated in CI on the build, and against the running site on a deploy | +| 5. Hub conformance run | **Pass** on the mechanized subset, two drifts outstanding | +| 6. The `hugo` type, hand-evaluated | **Pass** on eight of nine checks, one drift | +| Release | **Pass.** Dispatch-only, newest is `1.0.17-g4b2def3ee9` from `develop` | +| Deploy | **Pass.** Proven end to end against VPS staging by pipeline | + +## What Changed Since the Previous Run + +The 2026-08-01 run recorded the deploy as deferred and the VPS as not provisioned. Both have since happened, so the deferral this repository declared is closed rather than carried: + +- `deploy-site.yml` is dispatchable and has deployed staging from CI end to end, verified against the live site by release id rather than by the transport's exit status. +- The `staging` and `production` environments exist and hold their credentials. +- The hub authored the `hugo` type and the `self-hosted` target from this repository's measured shape, which is what [ProjectTemplate#456][hub-issue] and [#558][hub-spec-issue] were holding for. Both are closed. ## 1. Settings and Rulesets **Pass.** ```text -$ repo-config/configure.sh check ptr727/Blog release -... 31 assertions, all ok ... +$ repo-config/configure.sh check +... 22 assertions, all ok ... Configuration matches on ptr727/Blog. exit 0 ``` -Both rulesets are active and carry every expected rule. `develop` allows squash only, `main` allows merge only, and both bind the required check by the same name the workflow produces: - -```text -'develop' required checks = ["Check pull request workflow status job"] -'main' required checks = ["Check pull request workflow status job"] -``` +This run is the first with the re-vendored comparator, which compares `copilot_code_review` parameters as well as `pull_request` and `required_status_checks`, so three parameterized rules per ruleset are now compared rather than two. -The ordering constraint was honored at standup: the workflow was dispatched once and reported before any ruleset was applied. Applying first would have deadlocked the first pull request, because the required check binds by name and only appears after a run. +Both rulesets are active. `develop` allows squash only, `main` allows merge only, and both bind the required check by the name the workflow produces. `has_discussions = true` follows public visibility, `default_branch = main`, and both Dependabot security features are enabled. -`has_discussions = true`, derived by `configure.sh` from public visibility rather than from a committed setting. `default_branch = main`. Dependabot vulnerability alerts and automated security updates are enabled. +Each ruleset carries a `RepositoryRole 5` bypass entry that the payloads do not declare. `configure.sh check` reports it as unmanaged and exits 0, which is the correct reading: the owner holds that capability by role regardless, so the entry grants nothing the payload could withhold. -## 2. Secrets +## 2. Secrets, Repository Scope **Pass.** Names only. No secret value was read, printed, or logged. @@ -54,83 +57,105 @@ The ordering constraint was honored at standup: the workflow was dispatched once | `CODEGEN_APP_PRIVATE_KEY` | present | present | | `CODEGEN_APP_ID` (forbidden) | absent | absent | -`CODEGEN_APP_ID` is forbidden because the App-token action takes `client-id`, and the deprecated `app-id` name silently does nothing. +## 3. Secrets, Environment Scope -The `staging` and `production` environments do not exist yet, which is correct: they hold deploy credentials for a VPS that has not been provisioned, and `AUDIT.md` places them outside the baseline audit. +**Pass**, against [`spec/secrets.json`](../../spec/secrets.json)'s `environments` block. No fleet tool reads this block, because neither the hub's validator nor its audit runner can enumerate an environment-scoped store, so this section is the only thing that checks these names at all. -## 3. The URL Contract +| Name | Kind | `staging` | `production` | +| --- | --- | --- | --- | +| `DEPLOY_SSH_PRIVATE_KEY` | secret | present | present | +| `DEPLOY_SSH_HOST`, `DEPLOY_SSH_USER`, `DEPLOY_SSH_KNOWN_HOSTS` | variable | present | present | +| `HUGO_BASEURL` | variable | present | present | +| `PANGOLIN_ACCESS_TOKEN_ID`, `PANGOLIN_ACCESS_TOKEN` | secret, staging only | present | absent, as declared | -**Pass, and now enforced by CI rather than only locally**, which is the material change from the pre-standup state. +A third environment, `copilot`, exists and holds no variables, no secrets, and no protection rules. GitHub creates it for its coding agent. It is recorded here because an environment that appears without being declared is exactly what this section exists to notice, and because an empty one is the only safe shape for it: `secrets: inherit` in [`deploy-site.yml`](../../.github/workflows/deploy-site.yml) passes repository secrets to the callee, and no path binds `copilot` to a deploy. -From the first run on `main`: +## 4. The URL Contract -```text -hugo v0.164.0+extended (pinned by version and sha256) -Pages 514 | Total in 858 ms (zero warnings under --panicOnWarning) -render : 328/328 golden URLs built -media : 778/778 legacy image URLs resolve after the @uploads rewrite -assets : 1012/1012 local asset references resolve -PASS - the built site honors the URL contract -``` +**Pass, on both halves, and the second half now runs in CI.** + +The build half is gated on every pull request. [`checks/check-url-parity.py:16`](../../checks/check-url-parity.py) declares floors under the known-good counts, so a truncated list fails rather than passing while covering nothing, and [`.github/workflows/validate-task.yml:88`](../../.github/workflows/validate-task.yml) runs it against the built tree. -Every gate in the validation job passed on its first attempt: markdownlint, cspell, actionlint, `editorconfig-checker`, shellcheck, `shfmt -d`, config validation, the Hugo build, and the contract check. +The live half was a hand-run step against a local mirror at the previous audit. It is now the terminal step of the deploy ([`deploy-site-task.yml:155`](../../.github/workflows/deploy-site-task.yml)), which runs [`checks/check-live-urls.sh:18`](../../checks/check-live-urls.sh) against the environment it just wrote, with its own floors, `EXPECT_SITE_ENV`, and `EXPECT_RELEASE`. All 1,245 URLs were verified this way against VPS staging behind its auth gate, in run `30959030274` on 2026-08-04: 328 that must render, 917 that must redirect, `PASS - 1245 URLs honored`. -Floor assertions are present and below the real counts, so a truncated list fails rather than passing while covering nothing: +## 5. Hub Conformance Run + +The hub's `spec/audit.py` was run three times against this repository: `main`, `develop`, and the convergence branch, all from a full hub clone so the stale-versus-modified classification could walk the canonical's history. + +Findings before the fixes in this change, identical on `main` and `develop`: ```text -checks/check-live-urls.sh:18 FLOOR=(["golden-urls.txt"]=320 ["redirect-urls.txt"]=900) +DRIFT branch: 1 path(s) changed on both main and develop since the merge-base ... +DRIFT carried: AGENTS.md references the template repo by name or link +DRIFT verbatim: repo-config/configure.sh matches a past hub revision, not the current canonical +LETTER history: HISTORY.md intro does not mirror the README intro ``` -**The redirect half is proven, against a running server rather than a build.** `deploy/make-release.sh` installs the build on the local mirror, then `checks/check-live-urls.sh` follows all 1,245 URLs against it, checking each redirect's destination rather than trusting its status code: +After, on the convergence branch: ```text -==> checking 328 URLs that must render -==> checking 917 URLs that must redirect -PASS - 1245 URLs honored +DRIFT branch: 1 path(s) changed on both main and develop since the merge-base ... +DRIFT carried: AGENTS.md references the template repo by name or link +1 repo(s) audited; 0 defect/letter/error finding(s). ``` -That is a local mirror, not CI and not production. CI cannot run it, because the validation workflow has no server to point at, so this remains a pre-pull-request step documented in [OPERATIONS.md](../../OPERATIONS.md) rather than an automated gate. It becomes automatable once staging exists. +Both remaining findings are blocked outside this change: -## Baseline File Presence +- **The branch drift is a promotion, not a divergence.** `.github/workflows/validate-task.yml` changed on both branches since the merge-base, because the same Dependabot bump landed on each independently and `develop` also carries the generator-pin change. Compared directly, `develop` supersedes `main` on every line of that file, so the reconciliation is the `develop -> main` promotion and nothing else. +- **The `AGENTS.md` finding cannot be cleared from here.** The `Fleet Bootstrap` section is byte-locked across the fleet and names the hub, while the same audit forbids a carried file from naming it. Carrying the canonical correctly cannot pass, which is [ProjectTemplate#552][issue-552]. -**Pass, 23 of 23** applicable to `types: ["source-only"]` plus `workflowModel: release`. +## 6. The `hugo` Type, Hand-Evaluated -`OPERATIONS.md` is retained although it left the required set when the workflow model changed from `operational` to `release`. Carrying an extra file is not drift. +The hub authored a nine-check `hugo` type from this repository's measured deploy shape. It is on the hub's `develop` and **not** on `main`, so it is not ground truth yet and this section is anticipatory: it records what a promoted type would find, so the promotion is not the first time anyone looks. -## Verbatim Fidelity +**Nothing mechanizes these checks.** `spec/audit.py` does not read `spec/project-types.json` at all, so a clean run of it says nothing about any of the nine, and reading one as evidence would be the empty-query trap this repository has been caught by before. Every row below was evaluated by hand against the file it cites. That, and the two registry notes it leaves stranded, are reported to the hub as [ProjectTemplate#563][issue-563]. -**Pass, 4 of 4**, compared after line-ending normalization as [`spec/fidelity-model.md`][fidelity] specifies: `.markdownlint-cli2.jsonc`, `repo-config/configure.sh`, `repo-config/main.json`, `repo-config/develop.json`. +| Check | Verdict | Evidence | +| --- | --- | --- | +| `hugo.build.strict` | **Pass** | `hugo --gc --minify --panicOnWarning` at [`validate-task.yml:83`](../../.github/workflows/validate-task.yml) and [`deploy/make-release.sh:94`](../../deploy/make-release.sh), the same command on both paths | +| `hugo.urls.parity` | **Pass** | Floors at [`check-url-parity.py:16`](../../checks/check-url-parity.py) and [`check-live-urls.sh:18`](../../checks/check-live-urls.sh), both under the committed counts | +| `hugo.output.uncommitted` | **Pass** | `public/` ignored at [`.gitignore:5`](../../.gitignore), nothing tracked under it, and the markdown glob excludes `content/**`, `themes/*/**`, and `public/**` | +| `hugo.generator.pinned` | **Pass** | Version and SHA256 declared once, in [`.github/actions/install-hugo/action.yml:26`](../../.github/actions/install-hugo/action.yml), verified before install | +| `hugo.vendored.provenance` | **Pass** | Upstream, commit, and local edits recorded at [`themes/README.md:12`](../../themes/README.md) | +| `hugo.deploy.environment` | **Pass** | Environment re-asserted in its own job at [`deploy-site-task.yml:38`](../../.github/workflows/deploy-site-task.yml), bound at `:62`, every host value from `vars` | +| `hugo.deploy.atomic` | **Pass** | Upload to `releases//` at `:124`, pointer flipped by `rsync` through a temporary and a rename at `:140`, no `--delete` anywhere | +| `hugo.deploy.verified` | **Pass** | `EXPECT_RELEASE` and `EXPECT_SITE_ENV` at `:155`, polled to a bounded timeout at [`check-live-urls.sh:155`](../../checks/check-live-urls.sh), with an unreachable host reported distinctly at `:144` | +| `hugo.deploy.retention` | **Drift** | See below | -Eight carried files arrived CRLF and were normalized to LF to satisfy this repository's declared `lineEndings`. That is governed drift rather than a fidelity deviation, and it is reported upstream as an onboarding trap, since nothing in the standup text says to normalize after carrying. +**`hugo.deploy.retention` is the one that does not pass cleanly.** The check accepts two shapes, and this repository is the second: the deploy credential is a forced `rsync` command confined write-only, so it can neither delete a release nor read the destination back to count one, and the prune therefore belongs to the host. That ownership is recorded, at [`OPERATIONS.md:176`](../../OPERATIONS.md). What is not recorded is the count that binds the host: the "Ten releases are kept" in the Retention section above it describes `deploy/make-release.sh`, which installs on the local mirrors and prunes there, and no line says what the VPS containers keep or that their timer exists. The check asks for a declared count at the destination, and the destination the pipeline writes to has none. -## Release Proven, Deploy Deferred +This is a documentation gap rather than a disk-space one, and it is not this repository's to fill alone, since the count is the host's to declare. It is carried as a residual delta below rather than guessed at here. -The two are separate and only one of them is outstanding. +**One observation that no check covers.** The build command is written out twice, at the two citations in the first row, with nothing asserting the two copies agree. That is the same shape as the generator pin before [#29][issue-29] moved it into a composite action, one class down in severity: a one-sided edit would validate with one command and ship a tree built by another. The pin itself is now single-sourced, so the exposure is the flag set rather than the generator. -**The release is proven.** `publish-release.yml` is dispatch-only, and release `1.0.11` on 2026-08-01 carries the tag, the source archive, the README, and the LICENSE. The hub registry declares it accordingly: `publish` names the GitHub release and `releaseTrigger` is `dispatch-only`. +## Baseline File Presence and Verbatim Fidelity -**The deploy is deferred.** This repository will deploy a built site to a VPS over SSH, which is a release surface the fleet spec has no type for. The measured shape will be reported to [ProjectTemplate#456][hub-issue] once CI has run a deploy, rather than predicted now. The VPS does not exist, so there is nothing to measure. +**Pass.** Every carried file the scope selectors resolve to is present, and every verbatim unit matches the hub canonical after line-ending normalization, which is what this change's re-vendor of `repo-config/configure.sh` restored. ## Deliberate Deviations -Both are recorded in [AUDIT.md](../../AUDIT.md) and reported upstream, so neither can later read as drift. +Both are unchanged, recorded in [AUDIT.md](../../AUDIT.md), and reported upstream, so neither reads as drift later. -1. **`lineEndings: "lf"` on a `release` repo.** `GOVERNANCE.md` "Line Endings" grants the native-platform default to operational repos only. Every consumer here is Linux, and the fleet CRLF default would need an LF override for the scripts, the workflow YAML, the Caddyfile, the generated maps, and the content tree, which is the over-normalization that rule exists to prevent. The rule keys on `workflowModel` when the determining factor is the consuming platform. -2. **`types: ["source-only"]` rather than `docs`.** Both `docs` predicates are false: it detects a "governance-only repo" and asserts lint-only CI with no build, while this repo builds a site and gates a URL contract. `source-only` detects "no `build-*-task.yml`", which is true. Both selectors resolve to the same baseline file set, so only one of them is honest and it costs nothing. +1. **`lineEndings: "lf"` on a `release` repo**, where the rule grants the native-platform default to operational repos only. Every consumer here is Linux. +2. **`types: ["source-only"]` rather than `docs`**, because both `docs` predicates are false for a repository that builds a site and gates a URL contract. The hub's unpromoted `develop` adds `hugo` alongside it, which resolves this deviation rather than replacing it. ## Residual Deltas Carried forward rather than closed: -- The redirect half of the contract is proven only against the local mirror, by hand, before a pull request. CI has no server to point at, so nothing enforces it automatically until staging exists. -- No deploy exists, so the VPS deploy surface stays deferred. The GitHub release is the only channel that currently ships, which is what the hub registry declares, and the VPS target is revisited when a deploy has actually run. -- `checks/README.md` carries a small prose backlog of `dash` and `semicolon` findings, left for the next edit of that file per the correct-as-you-next-edit rule. +- **The retention count at the VPS destination is undeclared**, per section 6. Confirming that the host's prune timer exists and what it keeps is a question for the host side, and the answer belongs in `OPERATIONS.md` next to the ownership line that already points there. +- **The deploy transport's new SSH options have not been exercised against the real host**, which is [#33][issue-33]. They fail closed where the previous configuration failed open, so a stale `DEPLOY_SSH_KNOWN_HOSTS` now stops a deploy rather than being tolerated. +- **A rollback through the pipeline is unproven.** The server side rolls back in well under a second by hand, and a two-phase upload-then-flip should make a part-way failure safe, but no failing run has demonstrated it. +- **The `hugo` type is not ground truth yet.** Section 6 is anticipatory until the hub promotes it to `main`, at which point this repository's registry entry, its own `spec/secrets.json` note, and the type row above all become measurable rather than predicted. -[fidelity]: https://github.com/ptr727/ProjectTemplate/blob/main/spec/fidelity-model.md -[standup]: https://github.com/ptr727/ProjectTemplate/blob/main/STANDUP.md +[issue-29]: https://github.com/ptr727/Blog/issues/29 +[issue-33]: https://github.com/ptr727/Blog/issues/33 [hub-issue]: https://github.com/ptr727/ProjectTemplate/issues/456 +[hub-spec-issue]: https://github.com/ptr727/ProjectTemplate/issues/558 +[issue-552]: https://github.com/ptr727/ProjectTemplate/issues/552 +[issue-563]: https://github.com/ptr727/ProjectTemplate/issues/563 diff --git a/themes/README.md b/themes/README.md new file mode 100644 index 0000000..c1c7189 --- /dev/null +++ b/themes/README.md @@ -0,0 +1,36 @@ +# Vendored themes + +This directory holds third-party theme source, copied in rather than fetched by a manager. It sits outside `PaperMod/` deliberately, so replacing that directory wholesale on an update does not take this record with it. + +Vendoring is the decision; not recording what was vendored was the gap. Without an upstream ref there is no way to ask what changed upstream, whether a fix landed, or whether a local edit is still needed, and a 125-file copy is a large surface to carry blind. + +## PaperMod + +| | | +| --- | --- | +| Upstream | | +| Commit | `154d006e0182dfc7da38008323976b02e6bfab4a` | +| Committed upstream | 2026-05-10 | +| Describes as | `v8.0-138-g154d006` | +| License | MIT, retained at `PaperMod/LICENSE` | + +The commit was recovered by matching all 125 tracked blobs against upstream history rather than by reading a version marker, since the copy carries none. Every file matches that commit exactly except the two below, so the identification is not approximate. + +### Local edits + +Both sit in extension points the theme documents for this purpose, so neither is a fork of theme logic. + +| File | Edit | +| --- | --- | +| `PaperMod/assets/css/extended/blank.css` | The theme's custom-CSS slot, which ships empty. Carries the Lexend body font and the `gallery` and `gallery-cols-*` rules the gallery shortcode needs. | +| `PaperMod/layouts/_partials/extend_head.html` | The theme's head-extension partial, which ships empty. Carries the Google Fonts preconnect and stylesheet links for Lexend. | + +Both could live outside the vendored tree instead: Hugo resolves a project's own `assets/css/extended/` and `layouts/_partials/` ahead of the theme's, so moving them would make an update a clean directory replace with nothing to reapply. Worth doing at the next update rather than as a change of its own. + +Separately, `layouts/` at the repository root already overrides two theme templates, for the reason recorded in [`TODO.md`](../TODO.md): PaperMod uses APIs Hugo deprecated in 0.158, and `--panicOnWarning` would otherwise fail on the theme rather than on content. Whether those overrides are still needed is answerable by diffing against the commit above, which is what this record exists for. + +## Updating + +Compare against the recorded commit first, so the local edits above are known before anything moves. Replace `PaperMod/` with the new upstream tree, reapply the two edits (or move them out, per the note above), update the table here, and confirm the site still builds under `--panicOnWarning`, which is the gate the theme has failed before. + +No bot watches this. `.github/dependabot.yml` covers GitHub Actions only, since a vendored copy has no manifest to track, so an update is a deliberate act.