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:
| Job | When | What |
|---|---|---|
site:build | every merge request and every push to main | hugo --minify --gc; the output is a one-day artifact. A merge request proves the site builds and nothing else. |
site:publish | pushes to main only | rclone 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:
- An R2 bucket,
corvus-site, in the Cloudflare account. - A custom domain
the.corvus.observeron that bucket. It is a proxied record of the zone that Cloudflare creates and serves itself. The zone’s strict TLS and https redirect apply to it. - An account token that can only write objects of this bucket. R2’s S3 API takes the token id as access key id and the sha256 of the token value as the secret, so the four CI variables above derive from the token and nothing is stored outside the state.
- A transform rule of the zone that rewrites every path of the site ending
in a slash to that path plus
index.html. R2 serves objects by exact key and would otherwise answer 404 on/adr/.
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:
| Scope | Permission | For |
|---|---|---|
Zone corvus.observer | Zone Settings: edit, DNS: edit, Transform Rules: edit | the records, the custom domain, the index rewrite |
| Account | Workers R2 Storage: edit | the bucket and the custom domain |
| Account | Account API Tokens: edit | the 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:
- Frontmatter
title,milestone(the GitLab id) andadr(the number it delivers; left out for a milestone planned from a scope in prose). The ADR page shows a “Delivered by” box from this field. - The text before the first heading is the decision in one paragraph.
- Then
## Phases,## Open questions,## Deviations and noise,## Out of scopeand## Timeline, any of which may be left 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:
| Finding | Meaning | What to do |
|---|---|---|
| Still a scaffold | The decision paragraph is the ADR’s context verbatim, or every section is empty. | Write the narrative, in its own merge request. |
| No narrative | A data file with no milestones/<slug>.md; the page does not exist. | Run corvus-plan scaffold "<title>" and write it. |
| Different ADR | The 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 changed | An issue or merge request the narrative mentions is now closed, merged or reopened. | Update the phase story or the timeline. |
| Not mentioned | An 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 MR | An 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 dependencies | A “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
- Publish job fails with the artifact built: check the four CI
variables exist in the
docsproject and are protected; they are written byplatform, so the fix isahoy infra applythere, never the GitLab UI. - A path answers 404 that exists in
public/: the index rewrite rule of the zone;ahoy infra planinplatformshould show no change. - A page shows an old time after a merge: the
mainpipeline is still running or failed; the publish is the last job of it. - The sync writes nothing although issues moved: the
glabsession is on a token that cannot read the group, or the milestone was closed and its file already says so.