wiki deploy: drift check compares rendered text against NodeBB's stored copy, so every deploy fails #103

Closed
opened 2026-08-07 20:35:23 +00:00 by archvillainette · 0 comments
Owner

Problem Statement

Every deploy-wiki run in sow-topdata fails with:

remote managed wiki content drifted; rerun with --force to overwrite

The operator has not edited any wiki page on NodeBB. The last full re-publish
was days earlier and succeeded. The run still reports drifted: 23 and exits 1,
so the wiki never ships from a v* tag without a manual force=true dispatch.

The drift guard exists to protect hand edits on managed pages. Right now it
fires when nobody edited anything, which trains the operator to leave
WIKI_DEPLOY_FORCE=true on permanently — and that removes the protection the
guard was built for.

Solution

Make the drift verdict mean what its message says: "the live page changed after
we last wrote it."

Three changes to crucible wiki deploy:

  1. Record what NodeBB actually holds after each successful write, and compare
    the next run against that, instead of comparing NodeBB's stored copy against
    the hash of the text Crucible rendered.
  2. Do not call a page drifted when the remote post carries no sourceContent.
    That page was written before sourceContent sync existed; the content read
    back is NodeBB's rendered HTML, not our source, so it can never hash-equal
    anything we wrote. It needs the existing SourceContentSynced repair, not a
    drift refusal.
  3. Name the drifted pages in the error and in the plan output, capped, with a
    total. An operator cannot decide whether --force is safe when the tool
    reports a bare count.

After this, a tag deploy passes unless a human really edited a managed page, and
--force goes back to being the rare deliberate answer.

User Stories

  1. As a wiki operator, I want a v* tag deploy to succeed when nobody edited the
    live wiki, so that releases ship without a manual break-glass dispatch.
  2. As a wiki operator, I want --force to stay off by default, so that hand
    edits on managed pages keep their protection.
  3. As a wiki operator, I want the drift error to list the page IDs that drifted,
    so that I can look at those pages before I decide to overwrite them.
  4. As a wiki operator, I want the drifted page list capped with a total count, so
    that a mass-drift run does not flood the CI log.
  5. As a wiki operator, I want a page whose remote post predates sourceContent
    sync to be repaired on the next deploy, so that the backlog of unsynced pages
    drains instead of blocking every run.
  6. As a wiki operator, I want a page that a person really edited to still be
    reported as drifted and still block the deploy, so that the guard keeps
    working.
  7. As a wiki operator, I want an old deploy manifest (one written before this
    change) to keep working, so that I do not have to re-seed managed namespaces
    to get a green deploy.
  8. As a wiki operator, I want the manifest to converge after one successful
    deploy, so that the second run in a row reports zero drift.
  9. As a wiki operator, I want a --dry-run preview to report the same drift set
    as the live run, so that the preview is worth reading.
  10. As a wiki operator, I want --force to still overwrite drifted pages, so
    that the break-glass path is unchanged.
  11. As a release engineer, I want the extra remote reads to be proportional to
    pages written, not to pages in the wiki, so that a 1200-page deploy with 24
    changes stays fast.
  12. As a release engineer, I want the run to fail closed if the post-write read
    fails, so that the manifest never records a hash we did not verify.
  13. As a topdata maintainer, I want sow-topdata's WIKI_DEPLOY_FORCE to stay
    false in Gitea, so that the repository variable is not a permanent
    workaround.
  14. As a topdata maintainer, I want the stale/archive/purge policies to behave
    exactly as before, so that this fix carries no destructive risk.
  15. As an agent working on the wiki deployer, I want the dead RemoteHash plan
    field either used or deleted, so that the code does not suggest a check that
    never runs.

Implementation Decisions

Module: internal/topdata (wiki_deploy.go). No new package, no new command,
no new CLI flag.

Manifest schema. wikiDeployManifestPage gains one field, remote_hash
(omitempty). It holds computeManagedHash of the post content read back from
NodeBB immediately after a successful create or update. The existing hash
field keeps its current meaning: the hash of the generated managed region, used
for the "nothing changed locally, skip" decision.

Drift comparison. The drift check compares the remote hash observed at plan
time against remote_hash when it is set. When remote_hash is empty (a
manifest written before this change), the check falls back to today's comparison
against hash, so old manifests do not turn into a mass-drift event.

sourceContent gate. When the remote post has no sourceContent, the page is
not counted as drifted. parseNodeBBPost already reports this: it falls back to
the rendered content and leaves SourceContent empty. Such a page flows into
the existing SourceContentSynced repair update, which rewrites both content
and sourceContent and then records remote_hash.

Post-write read-back. After a successful create or update, the deployer
re-reads the post through the existing getPost and stores the hash. One extra
GET per page written; pages that skip are never fetched, so a normal deploy
costs a handful of reads. A failed read-back fails the deploy rather than
recording an unverified hash.

Error and progress output. The NodeBB wiki plan: progress line keeps its
counts. The drift error lists up to 10 drifted page IDs and the total, and keeps
the existing rerun with --force to overwrite instruction.

Unchanged: the --force, --create, --stale-policy,
--reset-managed-namespaces semantics; the edit-lock flow; the managed/manual
region merge; the dry-run early return that reports the plan without failing on
drift.

Testing Decisions

A good test here drives DeployWikiWithOptions against the existing
newFakeNodeBB HTTP double, asserts on the returned DeployResult, on the
manifest written to disk, and on the requests the fake server received. It never
reaches for an unexported hash helper to prove an internal step ran. Prior art:
TestDeployWikiRepairsManifestedPageMissingSourceContentSync and
TestDeployWikiCreatesNodeBBTopicAndWritesManifest in
internal/topdata/wiki_deploy_test.go — same seam, same shape.

That seam is the only one used. No new seam is introduced.

Cases to cover:

  1. NodeBB returns a normalized copy of what we wrote (differs from the generated
    text), the manifest holds a matching remote_hash → deploy succeeds,
    drifted == 0.
  2. A person edited the live page (remote differs from the recorded
    remote_hash) → drifted == 1, deploy fails, error names the page ID.
  3. Remote post has no sourceContent → not drifted, the repair update runs,
    manifest records SourceContentSynced and remote_hash.
  4. Manifest with no remote_hash (old format) and a remote copy equal to the
    recorded hash → no drift, and the run records remote_hash for next time.
  5. --force with a genuinely drifted page → page is overwritten and
    remote_hash is refreshed.
  6. Two runs back to back with unchanged input → second run reports all pages
    skipped and issues no post reads.

make check covers the package; the repo's existing Go test conventions apply.

Out of Scope

  • Any change to the NodeBB westgate-wiki plugin or how it stores
    sourceContent.
  • Three-way merge, conflict markers, or any attempt to keep a human edit and the
    generated text at the same time.
  • A per-page force flag or an interactive picker.
  • Changing sow-topdata's workflow, its WIKI_DEPLOY_* variables, or the
    stale-policy defaults. Once this ships, sow-topdata keeps force=false.
  • Backfilling remote_hash for pages that are currently skipped. They gain it
    the next time they change.

Further Notes

Observed in sow-topdata CI: local pages: 1200, updated: 1, skipped: 1176, drifted: 23. Pages whose generated text did not change are skipped before the
remote is ever read, so the 23 are drawn from the ~24 pages that did change —
nearly all of them. A human-edit explanation would not correlate with "the text
we happened to regenerate this week"; a systematic mismatch does.

wikiDeployPlan.RemoteHash already exists, is computed on the update path, and
is never read. This spec gives it its purpose.

## Problem Statement Every `deploy-wiki` run in `sow-topdata` fails with: ``` remote managed wiki content drifted; rerun with --force to overwrite ``` The operator has not edited any wiki page on NodeBB. The last full re-publish was days earlier and succeeded. The run still reports `drifted: 23` and exits 1, so the wiki never ships from a `v*` tag without a manual `force=true` dispatch. The drift guard exists to protect hand edits on managed pages. Right now it fires when nobody edited anything, which trains the operator to leave `WIKI_DEPLOY_FORCE=true` on permanently — and that removes the protection the guard was built for. ## Solution Make the drift verdict mean what its message says: "the live page changed after we last wrote it." Three changes to `crucible wiki deploy`: 1. Record what NodeBB actually holds after each successful write, and compare the next run against that, instead of comparing NodeBB's stored copy against the hash of the text Crucible rendered. 2. Do not call a page drifted when the remote post carries no `sourceContent`. That page was written before `sourceContent` sync existed; the content read back is NodeBB's rendered HTML, not our source, so it can never hash-equal anything we wrote. It needs the existing `SourceContentSynced` repair, not a drift refusal. 3. Name the drifted pages in the error and in the plan output, capped, with a total. An operator cannot decide whether `--force` is safe when the tool reports a bare count. After this, a tag deploy passes unless a human really edited a managed page, and `--force` goes back to being the rare deliberate answer. ## User Stories 1. As a wiki operator, I want a `v*` tag deploy to succeed when nobody edited the live wiki, so that releases ship without a manual break-glass dispatch. 2. As a wiki operator, I want `--force` to stay off by default, so that hand edits on managed pages keep their protection. 3. As a wiki operator, I want the drift error to list the page IDs that drifted, so that I can look at those pages before I decide to overwrite them. 4. As a wiki operator, I want the drifted page list capped with a total count, so that a mass-drift run does not flood the CI log. 5. As a wiki operator, I want a page whose remote post predates `sourceContent` sync to be repaired on the next deploy, so that the backlog of unsynced pages drains instead of blocking every run. 6. As a wiki operator, I want a page that a person really edited to still be reported as drifted and still block the deploy, so that the guard keeps working. 7. As a wiki operator, I want an old deploy manifest (one written before this change) to keep working, so that I do not have to re-seed managed namespaces to get a green deploy. 8. As a wiki operator, I want the manifest to converge after one successful deploy, so that the second run in a row reports zero drift. 9. As a wiki operator, I want a `--dry-run` preview to report the same drift set as the live run, so that the preview is worth reading. 10. As a wiki operator, I want `--force` to still overwrite drifted pages, so that the break-glass path is unchanged. 11. As a release engineer, I want the extra remote reads to be proportional to pages written, not to pages in the wiki, so that a 1200-page deploy with 24 changes stays fast. 12. As a release engineer, I want the run to fail closed if the post-write read fails, so that the manifest never records a hash we did not verify. 13. As a topdata maintainer, I want `sow-topdata`'s `WIKI_DEPLOY_FORCE` to stay `false` in Gitea, so that the repository variable is not a permanent workaround. 14. As a topdata maintainer, I want the stale/archive/purge policies to behave exactly as before, so that this fix carries no destructive risk. 15. As an agent working on the wiki deployer, I want the dead `RemoteHash` plan field either used or deleted, so that the code does not suggest a check that never runs. ## Implementation Decisions Module: `internal/topdata` (`wiki_deploy.go`). No new package, no new command, no new CLI flag. **Manifest schema.** `wikiDeployManifestPage` gains one field, `remote_hash` (`omitempty`). It holds `computeManagedHash` of the post content read back from NodeBB immediately after a successful create or update. The existing `hash` field keeps its current meaning: the hash of the generated managed region, used for the "nothing changed locally, skip" decision. **Drift comparison.** The drift check compares the remote hash observed at plan time against `remote_hash` when it is set. When `remote_hash` is empty (a manifest written before this change), the check falls back to today's comparison against `hash`, so old manifests do not turn into a mass-drift event. **sourceContent gate.** When the remote post has no `sourceContent`, the page is not counted as drifted. `parseNodeBBPost` already reports this: it falls back to the rendered `content` and leaves `SourceContent` empty. Such a page flows into the existing `SourceContentSynced` repair update, which rewrites both `content` and `sourceContent` and then records `remote_hash`. **Post-write read-back.** After a successful create or update, the deployer re-reads the post through the existing `getPost` and stores the hash. One extra GET per page written; pages that skip are never fetched, so a normal deploy costs a handful of reads. A failed read-back fails the deploy rather than recording an unverified hash. **Error and progress output.** The `NodeBB wiki plan:` progress line keeps its counts. The drift error lists up to 10 drifted page IDs and the total, and keeps the existing `rerun with --force to overwrite` instruction. **Unchanged:** the `--force`, `--create`, `--stale-policy`, `--reset-managed-namespaces` semantics; the edit-lock flow; the managed/manual region merge; the dry-run early return that reports the plan without failing on drift. ## Testing Decisions A good test here drives `DeployWikiWithOptions` against the existing `newFakeNodeBB` HTTP double, asserts on the returned `DeployResult`, on the manifest written to disk, and on the requests the fake server received. It never reaches for an unexported hash helper to prove an internal step ran. Prior art: `TestDeployWikiRepairsManifestedPageMissingSourceContentSync` and `TestDeployWikiCreatesNodeBBTopicAndWritesManifest` in `internal/topdata/wiki_deploy_test.go` — same seam, same shape. That seam is the only one used. No new seam is introduced. Cases to cover: 1. NodeBB returns a normalized copy of what we wrote (differs from the generated text), the manifest holds a matching `remote_hash` → deploy succeeds, `drifted == 0`. 2. A person edited the live page (remote differs from the recorded `remote_hash`) → `drifted == 1`, deploy fails, error names the page ID. 3. Remote post has no `sourceContent` → not drifted, the repair update runs, manifest records `SourceContentSynced` and `remote_hash`. 4. Manifest with no `remote_hash` (old format) and a remote copy equal to the recorded `hash` → no drift, and the run records `remote_hash` for next time. 5. `--force` with a genuinely drifted page → page is overwritten and `remote_hash` is refreshed. 6. Two runs back to back with unchanged input → second run reports all pages skipped and issues no post reads. `make check` covers the package; the repo's existing Go test conventions apply. ## Out of Scope - Any change to the NodeBB `westgate-wiki` plugin or how it stores `sourceContent`. - Three-way merge, conflict markers, or any attempt to keep a human edit and the generated text at the same time. - A per-page force flag or an interactive picker. - Changing `sow-topdata`'s workflow, its `WIKI_DEPLOY_*` variables, or the stale-policy defaults. Once this ships, `sow-topdata` keeps `force=false`. - Backfilling `remote_hash` for pages that are currently skipped. They gain it the next time they change. ## Further Notes Observed in `sow-topdata` CI: `local pages: 1200, updated: 1, skipped: 1176, drifted: 23`. Pages whose generated text did not change are skipped before the remote is ever read, so the 23 are drawn from the ~24 pages that did change — nearly all of them. A human-edit explanation would not correlate with "the text we happened to regenerate this week"; a systematic mismatch does. `wikiDeployPlan.RemoteHash` already exists, is computed on the update path, and is never read. This spec gives it its purpose.
archvillainette added the ready-for-agent label 2026-08-07 20:35:50 +00:00
archvillainette added the Kind/Bug label 2026-08-07 21:15:20 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: ShadowsOverWestgate/sow-tools#103