Context / Problem
resources/content/release-notes/ is bucketed into chunk-N/ (ordinal-100, ADR 0004), but v13.0.0.md exists in BOTH chunk-2/ (canonical) and top-level (orphan). The two diverge:
chunk-2/v13.0.0.md (559 lines) — carries the YAML frontmatter (tagName, name, publishedAt, isPrerelease, isDraft) the content sync stamps from the GitHub release metadata.
release-notes/v13.0.0.md (551 lines) — the raw note, no frontmatter; git history is all release-pipeline edits (add atomic changelog hash, add social names).
Root Cause — two writers, no cleanup
buildScripts/release/publish.mjs (lines 83-89) hard-requires resources/content/release-notes/v{version}.md (top-level) to exist; the human authors it pre-release; publish reads it as the GitHub-release body and appends the atomic-changelog hash. It never moves or removes it.
- The content sync (
SyncService.runFullSync() / the data-sync pipeline) later pulls the published GitHub release and buckets it into chunk-N/v{version}.md with frontmatter (its git anchor is chore: ticket sync [skip ci]).
Neither path removes the top-level → it lingers as an orphan.
Impact (verified)
/news/releases/13.0.0 appears twice in apps/portal/sitemap.xml (grep count = 2).
apps/portal/resources/data/releases.json double-counts 13.0.0 (count = 2) → 168 indexed vs 167 real releases.
- The orphan diverges from canonical (no frontmatter), so consumers reading the top-level get a subtly different record.
- Only v13.0.0 is affected — it's the single release cut since chunk-bucketing landed; every prior release lives only in a chunk.
The Fix
The top-level note is a transient authoring input; once the canonical chunk-N copy exists, the top-level must be removed.
- Immediate cleanup: delete the stale
resources/content/release-notes/v13.0.0.md (chunk-2 copy is canonical) + regenerate the release index + sitemap to dedupe.
- Durable guard (as-built, operator's call):
publish.mjs-owns-cleanup. After gh release create (Step 5), publish.mjs deletes the top-level staging note; the Step-6 broad git add . stages the removal. The content sync re-materializes the canonical chunk-N copy from the published release and no longer owns orphan deletion. (Sync-owns-cleanup was the original draft alternative; superseded — publish.mjs already owns the staging note's lifecycle, so the cleanup lives with the creation.)
Contract Ledger Matrix
(Updated to the as-built owner — publish.mjs, not sync. See ## Evolution.)
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback / Edge Case |
Docs |
Evidence |
| Release-note location contract |
ADR 0004 (ordinal-100 chunks) + this ticket |
Exactly one canonical chunk-N/v{version}.md per release; no top-level release-notes/v*.md survives a release+sync cycle |
If a top-level orphan exists, the release-pipeline cleanup removes it |
n/a (internal pipeline) |
sitemap + releases.json have exactly one entry per version |
publish.mjs release-note lifecycle (buildScripts/release/publish.mjs) |
Existing release flow + this ticket |
Still requires the top-level release-notes/v{version}.md as the authoring input; after gh release create (Step 5), fs.removeSync(releaseNotePath) deletes it, and the Step-6 broad git add . stages the removal |
Unchanged release-authoring UX; fs.existsSync guards the removal if the note is already absent |
inline comment marking it a transient staging input |
PublishReleaseNoteOrphan.spec asserts the cleanup follows gh release create |
Content-sync release-notes bucketing (SyncService.runFullSync / ReleaseNotesSyncer) |
Existing sync |
Re-materializes the canonical chunk-N/v{version}.md (with frontmatter) from the published GitHub release; does NOT own orphan deletion — publish.mjs deletes the staging note before the post-release sync runs |
If the chunked copy already exists, idempotent upsert; no top-level interaction |
n/a |
unchanged sync behavior (no new sync code in this PR) |
Evolution
Owner decision (as-built, PR #13276 — operator's call): the durable guard is publish.mjs-owns-cleanup, not the originally-drafted sync-owns-cleanup. publish.mjs already hard-requires and reads the top-level staging note, so it owns that note's full lifecycle: it deletes the staging note immediately after gh release create. The content sync (ReleaseNotesSyncer) continues to materialize the canonical chunked copy but carries no orphan-deletion responsibility, and this PR adds no sync code. The Contract Ledger above reflects this as-built owner; the original draft's sync-bucketing-owns-cleanup row was superseded at implementation.
Acceptance Criteria
- No top-level
release-notes/v*.md orphan survives a release + sync cycle.
/news/releases/{version} appears exactly once in the sitemap; releases.json has exactly one entry per version.
- The v13.0.0 orphan is removed and the index/sitemap regenerated (167 unique releases).
- A guard (in
publish.mjs) prevents future orphans, with a regression test.
Out of Scope
- Reworking the ordinal-100 chunk math itself (ADR 0004 is stable).
- Migrating already-bucketed release notes (only the v13.0.0 top-level orphan needs cleanup).
Related
buildScripts/release/publish.mjs (creates/requires the top-level note; as-built owner of the cleanup)
ai/services/github-workflow/SyncService.mjs (runFullSync content sync; buckets into chunk-N; no longer owns deletion)
buildScripts/docs/index/release.mjs (release index builder — candidate for a defensive dedupe)
- ADR 0004 (Universal Ordinal-100 Content Architecture)
- #13260 / #13266 (content↔derived coherence; same content-pipeline area)
Evidence: L2 (unit/regression on the cleanup guard + a sitemap/index uniqueness assertion) → L2 required (deterministic file-pipeline behavior).
Authored by Claude Opus 4.8 (Claude Code), @neo-opus-grace (Grace).
Context / Problem
resources/content/release-notes/is bucketed intochunk-N/(ordinal-100, ADR 0004), butv13.0.0.mdexists in BOTHchunk-2/(canonical) and top-level (orphan). The two diverge:chunk-2/v13.0.0.md(559 lines) — carries the YAML frontmatter (tagName,name,publishedAt,isPrerelease,isDraft) the content sync stamps from the GitHub release metadata.release-notes/v13.0.0.md(551 lines) — the raw note, no frontmatter; git history is all release-pipeline edits (add atomic changelog hash,add social names).Root Cause — two writers, no cleanup
buildScripts/release/publish.mjs(lines 83-89) hard-requiresresources/content/release-notes/v{version}.md(top-level) to exist; the human authors it pre-release; publish reads it as the GitHub-release body and appends the atomic-changelog hash. It never moves or removes it.SyncService.runFullSync()/ the data-sync pipeline) later pulls the published GitHub release and buckets it intochunk-N/v{version}.mdwith frontmatter (its git anchor ischore: ticket sync [skip ci]).Neither path removes the top-level → it lingers as an orphan.
Impact (verified)
/news/releases/13.0.0appears twice inapps/portal/sitemap.xml(grep count = 2).apps/portal/resources/data/releases.jsondouble-counts13.0.0(count = 2) → 168 indexed vs 167 real releases.The Fix
The top-level note is a transient authoring input; once the canonical
chunk-Ncopy exists, the top-level must be removed.resources/content/release-notes/v13.0.0.md(chunk-2 copy is canonical) + regenerate the release index + sitemap to dedupe.publish.mjs-owns-cleanup. Aftergh release create(Step 5),publish.mjsdeletes the top-level staging note; the Step-6 broadgit add .stages the removal. The content sync re-materializes the canonicalchunk-Ncopy from the published release and no longer owns orphan deletion. (Sync-owns-cleanup was the original draft alternative; superseded — publish.mjs already owns the staging note's lifecycle, so the cleanup lives with the creation.)Contract Ledger Matrix
(Updated to the as-built owner — publish.mjs, not sync. See ## Evolution.)
chunk-N/v{version}.mdper release; no top-levelrelease-notes/v*.mdsurvives a release+sync cyclereleases.jsonhave exactly one entry per versionpublish.mjsrelease-note lifecycle (buildScripts/release/publish.mjs)release-notes/v{version}.mdas the authoring input; aftergh release create(Step 5),fs.removeSync(releaseNotePath)deletes it, and the Step-6 broadgit add .stages the removalfs.existsSyncguards the removal if the note is already absentPublishReleaseNoteOrphan.specasserts the cleanup followsgh release createSyncService.runFullSync/ReleaseNotesSyncer)chunk-N/v{version}.md(with frontmatter) from the published GitHub release; does NOT own orphan deletion — publish.mjs deletes the staging note before the post-release sync runsEvolution
Owner decision (as-built, PR #13276 — operator's call): the durable guard is
publish.mjs-owns-cleanup, not the originally-drafted sync-owns-cleanup.publish.mjsalready hard-requires and reads the top-level staging note, so it owns that note's full lifecycle: it deletes the staging note immediately aftergh release create. The content sync (ReleaseNotesSyncer) continues to materialize the canonical chunked copy but carries no orphan-deletion responsibility, and this PR adds no sync code. The Contract Ledger above reflects this as-built owner; the original draft's sync-bucketing-owns-cleanup row was superseded at implementation.Acceptance Criteria
release-notes/v*.mdorphan survives a release + sync cycle./news/releases/{version}appears exactly once in the sitemap;releases.jsonhas exactly one entry per version.publish.mjs) prevents future orphans, with a regression test.Out of Scope
Related
buildScripts/release/publish.mjs(creates/requires the top-level note; as-built owner of the cleanup)ai/services/github-workflow/SyncService.mjs(runFullSynccontent sync; buckets into chunk-N; no longer owns deletion)buildScripts/docs/index/release.mjs(release index builder — candidate for a defensive dedupe)Evidence: L2 (unit/regression on the cleanup guard + a sitemap/index uniqueness assertion) → L2 required (deterministic file-pipeline behavior).
Authored by Claude Opus 4.8 (Claude Code), @neo-opus-grace (Grace).