Skip to content

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:

  1. Loads the current IssueStatus and skips when ReleaseNote == true (idempotent fast-path; survives pod restarts without re-posting).
  2. Fetches the merged PR from GitHub (GET /repos/{repo}/pulls/{prNumber}) for title + body + labels + author.
  3. Calls ReleaseNotePolicy.ShouldPost(prTitle, hasNoReleaseNoteLabel) — drops:
  4. PRs whose title matches the image-pin pattern ^chore:\s*pin\s+\S+\s+image(s)?\s+to\s+sha- (regex from rc#1439a).
  5. PRs carrying the no-release-note label (explicit author opt-out).
  6. 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 IssueStatus projection filtered by ReleaseNote == true && ReleasePostedAt != null.
  • Sorted by ReleasePostedAt DESC, capped at 30 rows. Same shape as /api/stuck-issues so 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 helpers
  • src/ConductorE.Core/UseCases/ReleaseNotePolicy.cs — ShouldPost + FormatAttribution (rc#1439a)
  • src/ConductorE.Core/Domain/Events.cs — ReleaseNotePosted event record (rc#1439a)
  • src/ConductorE.Core/Domain/ReadModels.cs — IssueStatus.ReleaseNote / ReleaseSummary / ReleasePostedAt / ReleaseSourcePrNumber
  • src/ConductorE.Api/Adapters/MartenProjections.cs — IssueStatusProjection.Apply(ReleaseNotePosted)
  • src/ConductorE.Api/Program.cs — DI registration + GET /api/releases
  • src/ConductorE.Api/Dashboard.html — Releases tab + loadReleases() JS
  • tests/ConductorE.Api.Tests/ReleaseNoteServiceTests.cs — pure-helper unit tests + projection / endpoint contract tests