ci: build and deploy the site from Forgejo Actions #1

Merged
oap merged 1 commit from forgejo-actions into v5 2026-09-07 23:35:58 -07:00
Owner

Deploying meant remembering to bump the content submodule, build locally, and rsync public/ to nas. It had not been remembered since June.

.forgejo/workflows/deploy.yaml does it on every push to v5, on the same nix runner as nix-config's CI, and rsyncs to a login on nas pinned to rrsync -wo /var/www/kvartsi.

Merging this closes the loop. The rest of the chain is already proven against the live forge — a push to kartuli-ena ran its publish job, which landed 2de6fb1 content: 246d1c8… on v5, authored as forgejo-actions <owen+forgejo-actions@owenap.com>. That push triggered nothing only because deploy.yaml exists solely on this branch. Merging fires the first real deploy.

A clean checkout did not build

npx quartz build alone fails with Could not resolve "../../.quartz/plugins"Head.tsx and fileTrie.ts import an index that only exists once plugins are fetched. So installing is its own step.

That step is npx quartz plugin installnot npm run install-plugins, and not --latest. Only plugin install resolves from quartz.lock.json and checks each plugin out at its locked commit. The alternatives clone branch tips, which means compiling whatever 36 repos outside our control pushed that morning — and installing a plugin executes it (a tsup build each, plus an npm i of native deps) in the same native:host job that holds the nas key. This runner has no container boundary, so the pin is the control that matters most here.

quartz.lock.json had to be regenerated for that to work at all: its pins dated from April and had gone stale against this fork's own vendored source, failing with No matching export in ".quartz/plugins/index.ts" for import "CustomOgImagesEmitterName". All 36 now pin to current tips. That buys reproducibility and closes the drive-by window; it does not buy trust in the code, so refreshing the pins should stay a deliberate, reviewable commit.

What the first deploy will actually do

Built a clean clone of this branch at its current content pin and diffed the result against the live web root:

files on nas today 123
files this would publish 125
deleted 0
added verbs/to-help.html and its og-image

Purely additive — that page was written after June's manual deploy.

Worth stating because it was not always true. An earlier revision of this branch carried a local Update content commit pinning June's vault while origin/v5 still pinned April's; deploying that combination would have --deleted 19 real pages. The publish automation has since moved the pin forward on its own, so that commit was dropped in the rebase — 246d1c8 is a verified descendant of the old pin, not a divergence.

Details worth knowing

  • .gitmodules moves to the https URL: CI's checkout has no deploy key, and kartuli-ena is public.
  • fetch-depth: 0 because created-modified-date falls back to git for dates; a shallow clone would stamp every page with the clone time.
  • Deploys serialise rather than cancel (cancel-in-progress: false, also Forgejo's advice for deployments). Cancelling mid-rsync can leave the web root half-updated, and the argument for cancelling does not survive the measured rate: 11 vault commits over 4 active days in five months.
  • rsync runs without -p and with --delay-updates. rrsync's allowlist has no --chmod (it dies with "option not allowed"), and preserving the runner's modes would make the site's readability a function of the runner's umask; without -p, nas applies its own. --delay-updates is in the allowlist and stages changed files so a job that dies mid-transfer does not tear the tree.
  • nas's host key is pinned in the workflow rather than TOFU'd.

Depends on

nix-config #25 (merged) and #27 — the kvartsi-deploy login, and NAS_DEPLOY_KEY provisioned without a trailing newline. Both are deployed to nas already.

Deploying meant remembering to bump the content submodule, build locally, and rsync `public/` to nas. It had not been remembered since June. `.forgejo/workflows/deploy.yaml` does it on every push to `v5`, on the same `nix` runner as nix-config's CI, and rsyncs to a login on nas pinned to `rrsync -wo /var/www/kvartsi`. **Merging this closes the loop.** The rest of the chain is already proven against the live forge — a push to kartuli-ena ran its publish job, which landed `2de6fb1 content: 246d1c8…` on `v5`, authored as `forgejo-actions <owen+forgejo-actions@owenap.com>`. That push triggered nothing only because `deploy.yaml` exists solely on this branch. Merging fires the first real deploy. ### A clean checkout did not build `npx quartz build` alone fails with `Could not resolve "../../.quartz/plugins"` — `Head.tsx` and `fileTrie.ts` import an index that only exists once plugins are fetched. So installing is its own step. That step is `npx quartz plugin install` — **not** `npm run install-plugins`, and not `--latest`. Only `plugin install` resolves from `quartz.lock.json` and checks each plugin out at its locked commit. The alternatives clone branch tips, which means compiling whatever 36 repos outside our control pushed that morning — and installing a plugin *executes* it (a `tsup` build each, plus an `npm i` of native deps) in the same `native:host` job that holds the nas key. This runner has no container boundary, so the pin is the control that matters most here. `quartz.lock.json` had to be regenerated for that to work at all: its pins dated from April and had gone stale against this fork's own vendored source, failing with `No matching export in ".quartz/plugins/index.ts" for import "CustomOgImagesEmitterName"`. All 36 now pin to current tips. That buys reproducibility and closes the drive-by window; it does not buy trust in the code, so refreshing the pins should stay a deliberate, reviewable commit. ### What the first deploy will actually do Built a clean clone of this branch at its current content pin and diffed the result against the live web root: | | | |---|---| | files on nas today | 123 | | files this would publish | 125 | | **deleted** | **0** | | added | `verbs/to-help.html` and its og-image | Purely additive — that page was written after June's manual deploy. Worth stating because it was not always true. An earlier revision of this branch carried a local `Update content` commit pinning June's vault while `origin/v5` still pinned April's; deploying that combination would have `--delete`d 19 real pages. The publish automation has since moved the pin forward on its own, so that commit was dropped in the rebase — `246d1c8` is a verified descendant of the old pin, not a divergence. ### Details worth knowing - `.gitmodules` moves to the https URL: CI's checkout has no deploy key, and kartuli-ena is public. - `fetch-depth: 0` because `created-modified-date` falls back to git for dates; a shallow clone would stamp every page with the clone time. - Deploys **serialise** rather than cancel (`cancel-in-progress: false`, also Forgejo's advice for deployments). Cancelling mid-rsync can leave the web root half-updated, and the argument for cancelling does not survive the measured rate: 11 vault commits over 4 active days in five months. - rsync runs without `-p` and with `--delay-updates`. rrsync's allowlist has no `--chmod` (it dies with "option not allowed"), and preserving the runner's modes would make the site's readability a function of the runner's umask; without `-p`, nas applies its own. `--delay-updates` is in the allowlist and stages changed files so a job that dies mid-transfer does not tear the tree. - nas's host key is pinned in the workflow rather than TOFU'd. ### Depends on nix-config #25 (merged) and #27 — the `kvartsi-deploy` login, and `NAS_DEPLOY_KEY` provisioned without a trailing newline. Both are deployed to nas already.
Deploying meant remembering to bump the content submodule, run a local build
and rsync public/ to nas. It had not been remembered since June.

deploy.yaml does it on every push to v5, on the same nix runner as nix-config's
CI, and rsyncs to a login on nas pinned to `rrsync -wo /var/www/kvartsi`.

Installing plugins is its own step before the build, because Head.tsx and
fileTrie.ts import ../../.quartz/plugins — the index that installing generates
— so esbuild cannot resolve it and `npx quartz build` fails on any fresh clone.

That step is `npx quartz plugin install`, which resolves from quartz.lock.json
and checks each plugin out at its locked commit. The alternatives clone branch
tips, which would mean compiling whatever 36 repos outside our control pushed
that morning. Installing a plugin executes it — a tsup build each, plus an npm
install of native deps — in the same native:host job that holds the nas key,
and this runner has no container boundary, so the pin is the control that
matters most here.

The lockfile had to be regenerated for that to work at all: its pins dated from
April and had gone stale against the fork's own vendored source, failing with
`No matching export in ".quartz/plugins/index.ts" for import
"CustomOgImagesEmitterName"`. All 36 now pin to current tips. That buys
reproducibility and closes the drive-by window; it does not buy trust in the
code, and refreshing the pins should stay a deliberate, reviewable commit.

rsync runs with --delay-updates so that a deploy cancelled by a newer push
leaves the previous tree intact rather than a half-updated one; rsync is not
atomic across a tree, and this shrinks the exposure to the final rename batch.

.gitmodules moves to the https URL: the CI checkout has no deploy key, and
kartuli-ena is public. fetch-depth is 0 because created-modified-date falls
back to git for dates, and a shallow clone would stamp every page with the
clone time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oap force-pushed forgejo-actions from f18340af33 to 8d7bd25a67
Some checks failed
deploy / deploy (push) Failing after 4m11s
2026-09-07 23:25:48 -07:00
Compare
@ -0,0 +88,4 @@
-e "ssh -i $ssh_dir/id -o IdentitiesOnly=yes \
-o UserKnownHostsFile=$ssh_dir/known_hosts \
-o StrictHostKeyChecking=yes -o CheckHostIP=no" \
public/ kvartsi-deploy@nas:/
Author
Owner

Why is this public/ kvartsi-deploy@nas:/ instead of public/ kvartsi-deploy@nas:/var/www/kvartsi?

Why is this `public/ kvartsi-deploy@nas:/` instead of `public/ kvartsi-deploy@nas:/var/www/kvartsi`?
Author
Owner

Why is this public/ kvartsi-deploy@nas:/ instead of public/ kvartsi-deploy@nas:/var/www/kvartsi?

Because rrsync is the chroot. The forced command on nas is rrsync -wo /var/www/kvartsi, and rrsync rewrites every path argument to sit under that root before handing them to the real rsync. So the path the client sends is already relative to the web root, and / means the root of the confined subtree — not the filesystem root.

Spelling out the full path would double it. Verified against the real rrsync binary with the same flags the workflow uses, by standing in for nas's sshd and feeding it the same SSH_ORIGINAL_COMMAND:

public/ …@nas:/ — works, file lands in the web root:

landed: webroot/index.html

public/ …@nas:/var/www/kvartsi — fails:

rsync: [Receiver] mkdir ".../webroot/var/www/kvartsi" failed: No such file or directory (2)
rsync error: error in file IO (code 11) at main.c(791) [Receiver=3.4.1]

That is rrsync resolving the request to /var/www/kvartsi/var/www/kvartsi.

It reads oddly, which is fair — the bare / looks alarming until you know the forced command is doing the confinement. There is a comment on the rsync invocation saying so ("rrsync roots the destination at /var/www/kvartsi, hence the bare /"), but it sits above the flags rather than next to the destination. Happy to move it onto that line if you would rather it be impossible to miss.

> Why is this `public/ kvartsi-deploy@nas:/` instead of `public/ kvartsi-deploy@nas:/var/www/kvartsi`? Because rrsync *is* the chroot. The forced command on nas is `rrsync -wo /var/www/kvartsi`, and rrsync rewrites every path argument to sit under that root before handing them to the real rsync. So the path the client sends is already relative to the web root, and `/` means the root of the confined subtree — not the filesystem root. Spelling out the full path would double it. Verified against the real `rrsync` binary with the same flags the workflow uses, by standing in for nas's sshd and feeding it the same `SSH_ORIGINAL_COMMAND`: **`public/ …@nas:/`** — works, file lands in the web root: ``` landed: webroot/index.html ``` **`public/ …@nas:/var/www/kvartsi`** — fails: ``` rsync: [Receiver] mkdir ".../webroot/var/www/kvartsi" failed: No such file or directory (2) rsync error: error in file IO (code 11) at main.c(791) [Receiver=3.4.1] ``` That is rrsync resolving the request to `/var/www/kvartsi/var/www/kvartsi`. It reads oddly, which is fair — the bare `/` looks alarming until you know the forced command is doing the confinement. There is a comment on the rsync invocation saying so ("rrsync roots the destination at /var/www/kvartsi, hence the bare `/`"), but it sits above the flags rather than next to the destination. Happy to move it onto that line if you would rather it be impossible to miss.
oap merged commit 8d7bd25a67 into v5 2026-09-07 23:35:58 -07:00
oap deleted branch forgejo-actions 2026-09-07 23:35:58 -07:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
oap/kvartsi!1
No description provided.