Release-notes service (rc#1439b)¶
Tracking: rig-conductor#1439 (parent), #1446 (this slice — Api).
This document captures the four decisions baked into the Api slice. The Core slice (rc#1439a / #1449) covered the pure policy + the ReleaseNotePosted event record; this doc covers the wiring that operationalises them.
Decision 1 — Trigger filter¶
ReleaseNoteService consumes MERGED events from the Marten event store (EventTypeName == "merged", polled by sequence id). For each candidate it:
- Loads the current
IssueStatusand skips whenReleaseNote == true(idempotent fast-path; survives pod restarts without re-posting). - Fetches the merged PR from GitHub (
GET /repos/{repo}/pulls/{prNumber}) for title + body + labels + author. - Calls
ReleaseNotePolicy.ShouldPost(prTitle, hasNoReleaseNoteLabel)— drops: - PRs whose title matches the image-pin pattern
^chore:\s*pin\s+\S+\s+image(s)?\s+to\s+sha-(regex from rc#1439a). - PRs carrying the
no-release-notelabel (explicit author opt-out). - If the PR body contains a
Refs <owner/repo>#<n>trailer (introduced by rc#1438), traces back to the source PR and uses its body/title/author. Image-pin merges aren't true releases — the helper degrades gracefully when the trailer is missing or unparseable (post with the image-pin's own info; rare edge case, surfaced rather than dropped).
This list is the minimum filter to avoid the operator's #1 noise vector (hundreds of image-pin merges per week). Future refinements (e.g. drop revert PRs, drop sub-PRs of an open epic) belong in ReleaseNotePolicy, not the service.
Decision 2 — AI-summary fallback¶
The production path will AI-summarise the PR body with a hard 10 s timeout. On failure (LLM unconfigured, timeout, parse error, network error) the service falls back to ReleaseNoteService.SummariseFallback:
- Strip markdown headings, blockquote markers, list bullets from line starts.
- Strip GitHub PR trailers (
Closes / Fixes / Resolves / Refs / Co-Authored-By / Signed-Off-By). - Sentence-split on
[.!?]\s+, take the first 3. - Truncate at 600 chars with an ellipsis if longer.
- Return
"(no description)"for empty bodies — never a null or empty string downstream.
The fallback is the only path implemented in rc#1439b. The LLM hop is deferred to a follow-up slice that introduces an ILlmSummariser port — keeping the LLM client out of ConductorE.Api until a real consumer is wired is consistent with the project's "no LLM HTTP from the Api project" convention.
Decision 3 — Attribution¶
Rendered by ReleaseNotePolicy.FormatAttribution(mergeAuthor, issueAuthor) (rc#1439a):
- Bot merger + distinct human issue author →
(by <human>, shipped via <bot>) - Bot merger only →
(shipped via <bot>) - Human merger with issue author known →
(by <issueAuthor>) - Human merger without issue author →
(by <merger>) - Neither → empty string (announcement renders without a trailing attribution line)
issueAuthor is resolved from a Closes #N / Fixes #N / Resolves #N trailer in the resolved PR body (after the image-pin trace). For now the trailer presence is enough to render (by #<issueNumber>) — a future enrichment can hop to the GitHub issue to look up the author login. This keeps the service self-contained for rc#1439b.
Decision 4 — Dashboard surface¶
A new Releases tab in the operator dashboard renders GET /api/releases:
- Read-side query against the
IssueStatusprojection filtered byReleaseNote == true && ReleasePostedAt != null. - Sorted by
ReleasePostedAtDESC, capped at 30 rows. Same shape as/api/stuck-issuesso the dashboard JS keeps a single rendering convention. - Each row carries: repo, issue #, title, PR #, source PR # (when image-pin traced), merge SHA, release summary (first ~280 chars on the dashboard), and
releasePostedAt.
The tab subscribes to the dashboard's SSE event stream and re-renders on every RELEASE_NOTE_POSTED event so an operator sees a release land within seconds of the Discord post.
Stickiness guarantee¶
The projection's Apply(ReleaseNotePosted) is sticky on all release fields — on a duplicate event (rare; bounded by the projection-side idempotency check the service already does) the original summary wins. LastUpdated is deliberately not bumped so the stuck-watcher dedupe invariant (StuckSince ≥ LastUpdated) stays intact for any issue that was also stuck.
Cold-start backfill cap (rc#1517)¶
The scan cursor (_lastSeqId) is in-memory, so a pod restart re-scans the entire merged-event history. Combined with the DISCORD_RELEASES_WEBHOOK_URL-missing degrade above — which skips posting without emitting RELEASE_NOTE_POSTED, so the sticky flag never sets — an unannounced backlog accumulates while the webhook is down and then floods #releases all at once on the next restart with a working webhook. This happened on 2026-06-08 (#1517): ~40 historical notes posted in ~57s when the conductor restarted after the webhook was rotated.
The fix: ReleaseNotePolicy.IsStaleForBackfill(mergedAt, now, maxAge) (pure Core) caps how old a merge may be and still be announced. TickAsync skips any merge older than the window before the status load or GitHub fetch — so a re-scan can only ever post merges from the last maxAge, bounding any flush to a handful regardless of how long the webhook was down or how far the cursor reset. Stale merges are simply never announced (no Discord post, no event, ReleaseNote stays false — accurate, since no note was posted). The boundary is inclusive; a future-dated timestamp (clock skew) is treated as recent.
Default window: 6 hours (ReleaseNotePolicy.DefaultMaxBackfillAge).
Env vars¶
| Env var | Effect when missing |
|---|---|
DISCORD_RELEASES_WEBHOOK_URL |
Post step is a no-op; no RELEASE_NOTE_POSTED emitted; projection never flips for that release. |
GITHUB_TOKEN |
PR fetch returns null; the release for that merge is silently skipped (no event emitted). |
RELEASE_NOTE_MAX_BACKFILL_AGE_HOURS |
Defaults to 6. Caps how old a merge may be and still be announced (rc#1517). Set to 0 or negative to disable the cap (announce regardless of age). |
The first two degrades match the pattern used by MainGuardAdminAlerter and CiFailureDiscordAlerter.
Pointers¶
src/ConductorE.Api/Services/ReleaseNoteService.cs— the BackgroundService + pure helperssrc/ConductorE.Core/UseCases/ReleaseNotePolicy.cs—ShouldPost+FormatAttribution(rc#1439a)src/ConductorE.Core/Domain/Events.cs—ReleaseNotePostedevent record (rc#1439a)src/ConductorE.Core/Domain/ReadModels.cs—IssueStatus.ReleaseNote / ReleaseSummary / ReleasePostedAt / ReleaseSourcePrNumbersrc/ConductorE.Api/Adapters/MartenProjections.cs—IssueStatusProjection.Apply(ReleaseNotePosted)src/ConductorE.Api/Program.cs— DI registration +GET /api/releasessrc/ConductorE.Api/Dashboard.html— Releases tab +loadReleases()JStests/ConductorE.Api.Tests/ReleaseNoteServiceTests.cs— pure-helper unit tests + projection / endpoint contract tests