corvus

Publish the site and run the daily sync

How the site at the.corvus.observer is published, how the operator runs the daily sync, writes a milestone narrative and reads the drift report. Every step here was exercised on 20 October 2026, when the site went live and three syncs ran from the skill to a merge and a new sync time on the page. Paths are written from the Workspace root, named <root>.

How the site is published

The site is the docs repo rendered by Hugo. The content stays where the repo keeps it (adr/, milestones/, runbooks/, data/) and hugo.yaml mounts those folders into Hugo’s tree, so the repo reads the same with or without the site.

Merging to main is the publish. The pipeline in .gitlab-ci.yml has two jobs:

JobWhenWhat
site:buildevery merge request and every push to mainhugo --minify --gc; the output is a one-day artifact. A merge request proves the site builds and nothing else.
site:publishpushes to main onlyrclone sync of the build into the bucket, with deletion, so the bucket is exactly the last build of main.

The upload reads four protected CI variables of the docs project: R2_ENDPOINT, R2_BUCKET, R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY. The two secret ones are masked and protected, so a merge request pipeline never sees them. Nothing in docs declares them: they are written by platform.

The stylesheet is fingerprinted by Hugo (/css/site.min.<hash>.css), so a publish never serves a stale one from the edge cache. If a template or stylesheet change does not show after a publish, hard-reload once; the HTML itself is not cached by Cloudflare beyond its defaults.

To check a publish: the main pipeline of docs is green, and the page’s footer time matches the data file (see the sync below).

The bucket and the hostname

Both live in platform/infra/site.tf, the group’s only OpenTofu root, and are applied by the operator from a worktree of platform:

A change there is a platform merge request, applied before it is merged:

cd <root>/worktrees/platform/<branch> && ahoy infra plan
cd <root>/worktrees/platform/<branch> && ahoy infra apply

Cloudflare token permissions

The token in platform’s .env.infra (TF_VAR_cloudflare_api_token) needs, besides the zone permissions it already had for DNS:

ScopePermissionFor
Zone corvus.observerZone Settings: edit, DNS: edit, Transform Rules: editthe records, the custom domain, the index rewrite
AccountWorkers R2 Storage: editthe bucket and the custom domain
AccountAccount API Tokens: editthe upload token and the lookup of its permission group

Without the account permissions the plan stops with a 403 on the permission group lookup. The account id travels next to the token as TF_VAR_cloudflare_account_id; it is not a secret.

The daily sync

A sync refreshes the data file of every open milestone from GitLab and lands it through a merge request in docs. It is run by hand, about once a day, with the sync skill in agents (or by typing /sync to Claude Code at the Workspace root). It needs a glab session; the script uses its token. Nothing in a sync writes to GitLab’s issues.

One run is one branch named by the day. A second run the same day reuses it. From a clean checkout:

cd <root> && ahoy worktree create docs sync-$(date +%Y-%m-%d)
cd <root>/agents && uv run corvus-sync run <root>/worktrees/docs/sync-$(date +%Y-%m-%d)

The script prints written or unchanged per milestone. A file is rewritten only when something other than the sync time changed, so unchanged everywhere means there is nothing to land: stop there. It also covers a closed milestone whose data file still says active, so a close shows up in the next sync.

With data written, the skill reads each narrative next to its data and writes the drift report (below), then one commit with every data file that changed:

chore(milestones): sync <date>

<one line per milestone: what moved since the last sync>

It pushes the branch and opens the merge request against main, titled sync <date>, with the drift report as its description. The merge is the operator’s and is the publish: once the main pipeline is green, the milestone pages show the new time (synced <date>, <time> UTC under the title, taken from synced_at in the data). After the merge the worktree is removed:

cd <root> && ahoy worktree remove docs sync-<date>

Writing a narrative

A milestone page is two files with the same slug: the narrative milestones/<slug>.md, which is the operator’s, and the data data/milestones/<slug>.yaml, which is the sync’s and is never edited by hand. The plan skill writes the first narrative when it creates a milestone (uv run corvus-plan scaffold "<title>" from <root>/agents scaffolds one for a milestone that predates it): the frontmatter, the decision paragraph taken from the ADR, and the sections empty.

The narrative is edited like any other change of docs: a worktree, a commit, a merge request, and the merge publishes it. The sync never touches it. Its shape, which the template lays out:

Name issues and merge requests by reference (docs#7, platform!2) or by title. The drift report compares exactly those mentions with the data, so a narrative that names nothing drifts silently.

Reading the drift report

The report in the sync merge request lists, per milestone, what no longer matches between narrative and data. Nothing to report is a finding too: the narrative matches. Each kind and what to do:

FindingMeaningWhat to do
Still a scaffoldThe decision paragraph is the ADR’s context verbatim, or every section is empty.Write the narrative, in its own merge request.
No narrativeA data file with no milestones/<slug>.md; the page does not exist.Run corvus-plan scaffold "<title>" and write it.
Different ADRThe milestone description in GitLab names an ADR other than the frontmatter’s, or one is missing.Decide which is right; fix the description in GitLab or the frontmatter.
State changedAn issue or merge request the narrative mentions is now closed, merged or reopened.Update the phase story or the timeline.
Not mentionedAn issue created since the narrative was last touched that no section names.Place it in a phase, or in deviations and noise if it was not planned.
Closed, no merged MRAn issue closed without a merged merge request that references it.Say in deviations and noise whether it was done elsewhere, dropped, or a mistake.
Prose dependenciesA “Depends on” section the script could not read as references; the order ignores it.Rewrite the section in the issue as one reference per line.

The report never blocks the merge: the data is right regardless, and landing it is the point of the sync. The drift is a to-do list for the narrative’s next merge request.

When something breaks