docs/backend/announcements-endpoint.md
This document specifies the backend work required to support the Announcements feature shipped on the client side. Hand to the backend team / agent. Self-contained.
The client (GitHub Store app) ships in-app announcements pulled from a single backend endpoint. Use cases include privacy-policy change notices, surveys, initiative endorsements (e.g. "Keep Android Open"), security advisories, and backend-status notices.
Privacy stance is non-negotiable: the endpoint receives no user identifier, returns the same payload to everyone, and the backend cannot link sequential requests to a specific user. All read/dismiss state is recorded only on the device.
The client is already merged. Any backend that returns a well-formed empty list at the spec'd endpoint will satisfy the client; content authoring tooling can come later.
GET /v1/announcements
User-Agent).Cache-Control: public, max-age=600 (10 min).ETag / If-None-Match support recommended (cuts payload and bandwidth on revalidation).{
"version": 1,
"fetchedAt": "2026-06-15T12:34:56Z",
"items": [
{ /* AnnouncementDto, see schema */ }
]
}
Return 200 with items: [] when nothing is active. Never 404.
Standard 5xx for backend errors. Client falls back to its local cache, logs the failure, and shows a faint "Couldn't refresh" caption in the inbox header. No retry storm — single attempt per cold start with a 1h backoff on 5xx.
Each announcement is one object in items. The client deserializes via the following Kotlin DTO (this is the contract — keep field names and types stable):
data class AnnouncementDto(
val id: String, // required, stable, kebab-case recommended
val publishedAt: String, // required, ISO 8601 UTC, e.g. "2026-06-15T00:00:00Z"
val expiresAt: String? = null, // optional, ISO 8601 UTC; client hides past items
val severity: String, // "INFO" | "IMPORTANT" | "CRITICAL" — case-insensitive
val category: String, // "NEWS" | "PRIVACY" | "SURVEY" | "SECURITY" | "STATUS"
val title: String, // required, ≤ 80 chars
val body: String, // required, ≤ 600 chars
val ctaUrl: String? = null, // optional external URL
val ctaLabel: String? = null, // optional CTA button label
val dismissible: Boolean = true, // false reserved for security/privacy notices
val requiresAcknowledgment: Boolean = false, // when true, "Dismiss" becomes "I've read this"
val minVersionCode: Int? = null, // inclusive; client-side filter
val maxVersionCode: Int? = null, // inclusive; client-side filter
val platforms: List<String>? = null, // ["ANDROID", "DESKTOP"]; null = both
val installerTypes: List<String>? = null, // ["DEFAULT", "SHIZUKU"]; null = both
val iconHint: String? = null, // "INFO" | "WARNING" | "SECURITY" | "CELEBRATION" | "CHANGE"
val i18n: Map<String, AnnouncementLocaleDto> = emptyMap()
)
data class AnnouncementLocaleDto(
val title: String? = null,
val body: String? = null,
val ctaUrl: String? = null,
val ctaLabel: String? = null
)
The title / body / ctaUrl / ctaLabel fields at the top level are the English defaults. Localized variants live under i18n keyed by BCP-47 locale code (en, zh-CN, ja, ko, pt-BR, etc.).
Client resolution order:
i18n[fullLocale] (e.g. zh-CN)i18n[primaryLocale] (e.g. zh)Untranslated locales fall back to English silently. There is no client-side error for missing translations.
id — non-empty, ≤ 64 chars, kebab-case recommended (e.g. 2026-06-15-keep-android-open).publishedAt, expiresAt — must parse as ISO 8601. Reject otherwise.severity, category, iconHint — must match the enum strings above (case-insensitive). Reject otherwise.title length ≤ 80 chars per locale variant. Reject otherwise.body length ≥ 50 chars and ≤ 600 chars per locale variant. The 50-char floor blocks "various improvements" filler.requiresAcknowledgment = true → dismissible must be false (you can't both dismiss and acknowledge in the same UX).severity = CRITICAL → requiresAcknowledgment must be true. The client only auto-promotes critical items to a modal when this flag is set; otherwise the advisory would only surface inside the inbox and miss the urgency the severity implies. Mirrors pendingCriticalAcknowledgment in AnnouncementsRepository.kt.category = SECURITY → severity must be IMPORTANT or CRITICAL.category = PRIVACY → requiresAcknowledgment must be true for any policy change item (legal requirement).ctaUrl if present — must be https:// (no http://, no other schemes).i18n[locale] keys must be valid BCP-47 codes.id across items → reject the whole payload, not just the duplicate.Validation runs at write time (PR review / admin-tool save) AND at serve time (defensive). Reject malformed entries from the served payload entirely; do not return them with garbled data.
ignoreUnknownKeys = true.severity, category, or iconHint enum values is fine — client maps unknown values to safe defaults (INFO, NEWS, null respectively).version field. Client v1 will only read version: 1 items; future clients can fan out.Recommended: filesystem-in-repo approach. Each announcement is one JSON file in announcements/<id>.json. CI deploys the directory; serve endpoint reads the directory at startup and refreshes on file change (or on a scheduled tick).
Pros:
i18n blocks; same review flow as code.git revert.Cons:
If you prefer a DB-backed model (Postgres announcements table), that's also fine — the client only sees the served payload, not the storage model. Keep the schema mirror of the DTO above plus an i18n JSONB column.
announcements/
2026-06-15-keep-android-open.json
2026-07-01-privacy-update.json
2026-08-05-survey-q3.json
Each file IS the AnnouncementDto (single object, not wrapped in items). The endpoint glues them into the envelope at serve time.
At serve time, filter out items where expiresAt < now. Return all others. Do NOT filter by minVersionCode / maxVersionCode / platforms / installerTypes — that's client-side filtering (preserves anonymity; backend never knows what platform the requester is on).
Return items sorted by publishedAt descending (newest first). Client also sorts client-side defensively, so this is a soft requirement.
When proposing a new announcement, every author should answer "yes" to all of these before opening the PR:
ctaUrl.INFO. Use CRITICAL only when data, security, or app function is at risk — and pair it with requiresAcknowledgment: true.requiresAcknowledgment: true and dismissible: false.expiresAt? Time-bound items (surveys, initiatives) should expire; evergreen news rarely belongs in this channel at all.Cadence target: ≤ 1 non-security item per month. More than that and users learn to dismiss reflexively, which kills credibility for the next real announcement.
The client's privacy promise rests on these. Violating them undermines the entire feature.
i18n block.)A privacy-policy paragraph for the website is suggested at the bottom of this doc.
{
"version": 1,
"fetchedAt": "2026-06-01T00:00:00Z",
"items": []
}
{
"version": 1,
"fetchedAt": "2026-06-15T12:34:56Z",
"items": [
{
"id": "2026-06-15-keep-android-open",
"publishedAt": "2026-06-15T00:00:00Z",
"expiresAt": "2026-09-15T00:00:00Z",
"severity": "INFO",
"category": "NEWS",
"title": "Backing Keep Android Open",
"body": "GitHub Store supports the Keep Android Open initiative. Google's proposed sideloading restrictions for 2026 would make installing apps outside Play harder for everyone. Read the full statement and find out how to participate.",
"ctaUrl": "https://github-store.org/news/keep-android-open",
"ctaLabel": "Read more",
"dismissible": true,
"requiresAcknowledgment": false,
"iconHint": "CHANGE",
"i18n": {
"zh-CN": {
"title": "支持「保持 Android 开放」",
"body": "GitHub Store 支持「保持 Android 开放」倡议。Google 在 2026 年提出的侧载限制会让所有人在 Play 商店之外安装应用更加困难。点击了解完整声明,并参与到这一行动中。",
"ctaLabel": "阅读详情"
},
"ja": {
"title": "「Keep Android Open」を支持します",
"body": "GitHub Store は「Keep Android Open」イニシアチブを支持します。Google が 2026 年に提案したサイドローディング制限は、Play 以外でのアプリ導入を全ユーザーにとって難しくします。声明全文と参加方法をご覧ください。",
"ctaLabel": "詳細を読む"
}
}
}
]
}
{
"version": 1,
"fetchedAt": "2026-07-02T08:00:00Z",
"items": [
{
"id": "2026-07-02-download-verify-gap",
"publishedAt": "2026-07-02T08:00:00Z",
"severity": "CRITICAL",
"category": "SECURITY",
"title": "Update to 1.8.x — download verification gap in 1.7.0–1.7.3",
"body": "Versions 1.7.0 through 1.7.3 had a gap in download integrity verification under specific mirror configurations. No data was at risk; install integrity was. Update to 1.8.x or later as soon as possible. The full advisory has details on what was affected and how the fix works.",
"ctaUrl": "https://github-store.org/news/2026-07-download-verify-advisory",
"ctaLabel": "View advisory",
"dismissible": false,
"requiresAcknowledgment": true,
"maxVersionCode": 14,
"iconHint": "SECURITY"
}
]
}
(Note maxVersionCode: 14 — client-side filter so users on 1.8.x and later won't see this advisory at all.)
For the backend agent / implementer:
/v1/announcements route to the existing Ktor server.Cache-Control: public, max-age=600 and ETag / If-None-Match handling.Suggested day-1 seed announcement (English-only, optional):
{
"id": "2026-XX-XX-announcements-launched",
"publishedAt": "2026-XX-XXT00:00:00Z",
"severity": "INFO",
"category": "NEWS",
"title": "Announcements live",
"body": "GitHub Store now ships an in-app announcements channel for cross-version updates that don't fit a release. We use it sparingly — privacy notices, surveys, occasional advocacy. You can mute categories you don't care about from the inbox top-right menu.",
"ctaUrl": null,
"dismissible": true,
"requiresAcknowledgment": false,
"iconHint": "INFO"
}
Suggested wording — adjust to fit existing privacy policy voice:
Announcements feed. GitHub Store fetches a public, anonymous feed at
https://api.github-store.org/v1/announcementson launch. The endpoint receives no user identifier and returns the same payload to every caller. Whether you have read or dismissed an individual announcement is recorded only on your device; we do not record this server-side.
announcements/i18n/<locale>/<id>.json dir? Single-file is simpler unless you anticipate heavy translator parallelism.Cache-Control header. 10-minute freshness is the target.<id>.json against the schema before PR would catch most authoring mistakes early. Optional v1 nicety.If the backend agent wants to push back on any decision above, here is the reasoning:
severity: CRITICAL + requiresAcknowledgment: true is the only path the client takes for blocking modals. Use sparingly.The full client-side rationale is in the planning doc maintained alongside the original feature work; this backend doc is the authoritative interface contract.