From 6ea1f2bf98b348a1fc21e994699312afa04214fc Mon Sep 17 00:00:00 2001
From: Aswin C
Date: Sat, 10 Oct 2026 06:33:29 +0530
Subject: [PATCH 1/5] ci(release): preserve authored notes and add announcement
review tooling
---
.github/PULL_REQUEST_TEMPLATE.md | 15 +-
.github/scripts/prepare_release.py | 42 +-
.github/scripts/test_announcement_admin.cjs | 39 +
.../scripts/test_release_copy_validation.py | 152 +++
.github/scripts/test_update_changelog_copy.py | 68 +-
.github/scripts/update_changelog.py | 903 +++---------------
.github/workflows/release-copy.yml | 23 +
.github/workflows/unit-tests.yml | 6 +
.gitignore | 13 +-
admin-panel/README.md | 19 +
.../notifyappusers/announcements/font.ttf | Bin 0 -> 4153300 bytes
.../notifyappusers/announcements/renderer.css | 21 +
.../announcements/renderer.html | 9 +
.../notifyappusers/announcements/renderer.js | 76 ++
.../public/notifyappusers/composer.css | 6 +
.../public/notifyappusers/dashboard.html | 51 +
.../public/notifyappusers/dashboard.js | 59 ++
.../public/notifyappusers/preview.html | 1 +
docs/TESTING.md | 46 +-
scripts/README.md | 43 +-
20 files changed, 756 insertions(+), 836 deletions(-)
create mode 100644 .github/scripts/test_announcement_admin.cjs
create mode 100644 .github/scripts/test_release_copy_validation.py
create mode 100644 .github/workflows/release-copy.yml
create mode 100644 admin-panel/README.md
create mode 100644 admin-panel/public/notifyappusers/announcements/font.ttf
create mode 100644 admin-panel/public/notifyappusers/announcements/renderer.css
create mode 100644 admin-panel/public/notifyappusers/announcements/renderer.html
create mode 100644 admin-panel/public/notifyappusers/announcements/renderer.js
create mode 100644 admin-panel/public/notifyappusers/composer.css
create mode 100644 admin-panel/public/notifyappusers/dashboard.html
create mode 100644 admin-panel/public/notifyappusers/dashboard.js
create mode 100644 admin-panel/public/notifyappusers/preview.html
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index e2a54e4e2..088fe53da 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -96,17 +96,18 @@ Add impact labels on the PR (`gh pr edit --add-label user-impact-critical --
-### CHANGELOG.md (developer copy)
+### CHANGELOG.md (required developer copy)
Keep a Changelog bullets for engineers. Use `### Added` / `### Changed` / `### Fixed` when you have more than one category. Class/module names are OK here.
@@ -117,7 +118,7 @@ Keep a Changelog bullets for engineers. Use `### Added` / `### Changed` / `### F
-### README What's New / Upcoming (listener copy)
+### README What's New / Upcoming (required for user-impact changes)
Plain listener English for README Upcoming, then What's New on release, and in-app release notes. Product name is **boxlore** (lowercase). No class names, FCM keys, or CI.
Use functional categories: `### Critical` (for `user-impact-critical`), `### Improvements`, or `### Fixes`. Do not use impact levels like `### High` or `### Medium` as headings.
diff --git a/.github/scripts/prepare_release.py b/.github/scripts/prepare_release.py
index 9e19036c3..70c5ecc07 100644
--- a/.github/scripts/prepare_release.py
+++ b/.github/scripts/prepare_release.py
@@ -506,13 +506,21 @@ def pull_request_label_names(pull_request: dict[str, object]) -> list[str]:
def reconcile_changelog(
repository: str,
token: str,
- api_key: str,
base_tag: str,
head_sha: str,
) -> list[int]:
pull_requests = pull_requests_between(repository, token, base_tag, head_sha)
processed: list[int] = []
+ # Reject missing copy before appending any PR in this batch.
+ original_unreleased = unreleased_block(require_file(CHANGELOG_PATH))
+ for pull_request in pull_requests:
+ if not update_changelog._pr_already_present(original_unreleased, int(pull_request["number"])):
+ update_changelog.validate_release_copy(
+ str(pull_request.get("title") or ""), str(pull_request.get("body") or ""),
+ pull_request_label_names(pull_request),
+ )
+
for pull_request in pull_requests:
number = int(pull_request["number"])
current_unreleased = unreleased_block(require_file(CHANGELOG_PATH))
@@ -521,7 +529,6 @@ def reconcile_changelog(
continue
changed = update_changelog.append_changelog(
- api_key,
number,
str(pull_request.get("title") or "").strip(),
str(pull_request.get("body") or ""),
@@ -530,11 +537,9 @@ def reconcile_changelog(
if not changed:
fail(f"PR #{number} produced no changelog entry")
processed.append(number)
- # Soft pacing so a long release range does not trip Groq TPM limits.
- time.sleep(1.5)
- update_changelog.sync_changelog_unreleased(api_key)
- update_changelog.sync_readme_upcoming(api_key)
+ update_changelog.sync_changelog_unreleased()
+ update_changelog.sync_readme_upcoming()
final_unreleased = unreleased_block(require_file(CHANGELOG_PATH))
missing = [
@@ -737,14 +742,8 @@ def prepare_release(args: argparse.Namespace) -> None:
skip_notify=skip_notify,
use_readme_upcoming=use_readme_upcoming,
)
- api_key = os.environ.get("GROQ_API_KEY", "").strip()
if not repository or not token:
fail("GITHUB_REPOSITORY and GITHUB_TOKEN are required")
- if notes_source == "reconciled" and not api_key:
- fail(
- "GROQ_API_KEY is required unless --skip-notify or "
- "--use-readme-upcoming is set"
- )
current = read_app_version()
validate_baseline(current, args.latest_tag, skip_notify=skip_notify)
@@ -755,11 +754,6 @@ def prepare_release(args: argparse.Namespace) -> None:
processed: list[int] = []
gradle_original = require_file(APP_GRADLE_PATH)
- APP_GRADLE_PATH.write_text(
- replace_gradle_version(gradle_original, current, target),
- encoding="utf-8",
- )
-
if skip_notify:
# Version bump + APK badge URL only — leave CHANGELOG / Upcoming / What's New
# untouched. Publish skips in-app announcement; notify manually if needed.
@@ -773,22 +767,22 @@ def prepare_release(args: argparse.Namespace) -> None:
processed = reconcile_changelog(
repository,
token,
- api_key,
args.latest_tag,
args.head_sha,
)
changelog_original = require_file(CHANGELOG_PATH)
readme_original = require_file(README_PATH)
- CHANGELOG_PATH.write_text(
- promote_changelog(changelog_original, target, release_date),
- encoding="utf-8",
- )
+ promoted_changelog = promote_changelog(changelog_original, target, release_date)
promoted_readme = promote_readme(readme_original, target, release_date)
+ CHANGELOG_PATH.write_text(promoted_changelog, encoding="utf-8")
README_PATH.write_text(
update_readme_download_url(promoted_readme, repository, target),
encoding="utf-8",
)
+ APP_GRADLE_PATH.write_text(
+ replace_gradle_version(gradle_original, current, target), encoding="utf-8",
+ )
write_outputs(
{
"current_version": current.name,
@@ -1049,8 +1043,6 @@ def write_release_notes(args: argparse.Namespace) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
f"# boxlore {current.tag}\n\n"
- "> **AI-generated summary:** May contain mistakes. "
- "Verify details against the linked pull requests.\n\n"
f"{body}\n",
encoding="utf-8",
)
@@ -1321,7 +1313,7 @@ def main() -> None:
"--use-readme-upcoming",
action="store_true",
help=(
- "Skip AI reconciliation and promote the reviewed README Upcoming "
+ "Skip PR reconciliation and promote the reviewed README Upcoming "
"region verbatim into What's New and the release notification"
),
)
diff --git a/.github/scripts/test_announcement_admin.cjs b/.github/scripts/test_announcement_admin.cjs
new file mode 100644
index 000000000..d744fc777
--- /dev/null
+++ b/.github/scripts/test_announcement_admin.cjs
@@ -0,0 +1,39 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const {plan, canonical, PALETTES, isPreviewExcluded, parsePreview, reviewRows} = require('../../admin-panel/public/notifyappusers/dashboard.js');
+const base = () => ({title:'boxlore test',body:'First line\nSecond line',type:'both',sound:'silent',action_label:'Open',show_action_in_push:'false',show_action_in_app:'true',category:'ANNOUNCEMENT',presentation:'fullscreen',tone:'primary',image_style:'banner',release_alert:'false',include_play:'false',release_version_code:'0',test_mode:'true',target:'test_users',dry_run:'true',collapse_key:'',route:'',image:''});
+test('isolated audience and legacy payload retained',()=>{const result=plan(base());assert.equal(result.topic,'test_users');assert.equal(result.data.body,'First line\nSecond line');assert.equal(result.data.presentation,'fullscreen');});
+test('test mode rejects production and Play release override is explicit',()=>{for(const target of ['all_users','prod_users','direct_users'])assert.throws(()=>plan({...base(),target}));assert.throws(()=>plan({...base(),test_mode:'false',target:'all_users',release_alert:'true'}));assert.equal(plan({...base(),test_mode:'false',target:'all_users',release_alert:'true',include_play:'true'}).topic,'all_users');});
+test('UTF-8 bounds prevent oversized live request',()=>assert.throws(()=>plan({...base(),body:'é'.repeat(1100)})));
+test('canonical approval preserves exact Unicode text and sorted keys',()=>{assert.equal(JSON.stringify(canonical({b:{z:1,a:2},a:'é'})), '{"a":"é","b":{"a":2,"z":1}}');});
+test('preview classic palettes match actual tokens',()=>{assert.equal(PALETTES.dark.primary[0],'#9C8CFF');assert.equal(PALETTES.light.container,'#EDEDF4');});
+
+test('release preview retains GitHub page and rejects other destinations',()=>{const draft={...base(),release_alert:'true',release_url:'https://github.com/boxcreate/boxlore/releases/tag/v29'};assert.equal(plan(draft).data.release_url,draft.release_url);assert.throws(()=>plan({...draft,release_url:'https://evil.example/release'}));});
+
+test('Play preview shows no release alert unless explicitly included',()=>{const release={...base(),release_alert:'true'};assert.equal(isPreviewExcluded(release,true),true);assert.equal(isPreviewExcluded(release,false),false);assert.equal(isPreviewExcluded({...release,include_play:'true'},true),false);assert.equal(isPreviewExcluded(base(),true),false);});
+
+test('import validates a preview before editing and clears omitted optional destinations',()=>{assert.throws(()=>parsePreview({}));assert.throws(()=>parsePreview({draft:{title:'Broken',body:'Not a preview'}}));const draft=base();const preview=plan(draft);const restored=parsePreview(preview);assert.equal(restored.title,draft.title);assert.equal(restored.route,'');assert.equal(restored.image,'');assert.equal(restored.release_url,'');assert.equal(restored.dry_run,'true');});
+
+test('push-only messages do not promise an in-app alert',()=>{assert.equal(isPreviewExcluded({...base(),type:'push'},false),true);assert.equal(isPreviewExcluded({...base(),type:'push'},true),true);assert.equal(isPreviewExcluded({...base(),type:'in-app'},false),false);});
+
+
+test('push-only review describes notification actions and no in-app buttons',()=>{const draft={...base(),type:'push',release_alert:'true'};const rows=Object.fromEntries(reviewRows(draft,'Isolated test builds'));assert.equal(rows['Appearance'],'Android system notification');assert.equal(rows['In-app actions'],'No in-app alert');assert.equal(rows['Notification action'],'None');assert.equal(rows['Delivery'],'System notification');assert.equal(rows['Send mode'],'Provider validation only — no delivery');});
+test('combined delivery review includes the configured notification action',()=>{const draft={...base(),route:'https://example.org',show_action_in_push:'true'};const rows=Object.fromEntries(reviewRows(draft,'Isolated test builds'));assert.equal(rows['In-app actions'],'Open · Dismiss');assert.equal(rows['Notification action'],'Open');assert.equal(rows['Action destination'],'https://example.org');});
+
+test('browser preview formats the native Markdown subset without interpreting HTML',()=>{
+ const vm=require('node:vm'),fs=require('node:fs'),path=require('node:path');
+ class Node { constructor(tag,text=''){this.tag=tag;this.textContent=text;this.children=[];this.dataset={};} append(...nodes){this.children.push(...nodes);} replaceChildren(){this.children=[];} }
+ const context=vm.createContext({document:{createElement:tag=>new Node(tag),createTextNode:text=>new Node('#text',text)},window:{addEventListener(){},parent:{postMessage(){}}}});
+ vm.runInContext(fs.readFileSync(path.join(__dirname,'../../admin-panel/public/notifyappusers/announcements/renderer.js'),'utf8'),context);
+ const root=new Node('root');
+ context.content(root,'## Heading\nFirst **bold** line\nSecond _italic_ [docs](https://example.org)\n\n1) One\n+ Two\n> A callout\n`code` plain HTML');
+ assert.deepEqual(root.children.map(n=>n.tag),['h2','p','div','div','blockquote','p']);
+ assert.equal(root.children[0].dataset.level,'2');
+ assert.equal(root.children[1].children.find(n=>n.tag==='strong').textContent,'bold');
+ assert.equal(root.children[1].children.find(n=>n.tag==='em').textContent,'italic');
+ const link=root.children[1].children.find(n=>n.tag==='a');assert.equal(link.href,'https://example.org');assert.equal(link.rel,'noopener noreferrer');
+ assert.equal(root.children[2].children[0].textContent,'1.');assert.equal(root.children[3].children[0].textContent,'•');
+ assert.equal(root.children[5].children.find(n=>n.tag==='code').textContent,'code');
+ assert.ok(root.children[5].children.some(n=>n.textContent.includes('plain HTML')));
+ context.inline(root,'[bad](javascript:alert(1))');assert.equal(root.children.filter(n=>n.tag==='a').length,0);
+});
diff --git a/.github/scripts/test_release_copy_validation.py b/.github/scripts/test_release_copy_validation.py
new file mode 100644
index 000000000..76a478b15
--- /dev/null
+++ b/.github/scripts/test_release_copy_validation.py
@@ -0,0 +1,152 @@
+"""Reject missing copy without generating or silently dropping release notes."""
+
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+from argparse import Namespace
+from pathlib import Path
+from unittest.mock import patch
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import prepare_release as pr
+import update_changelog as uc
+from test_update_changelog_copy import SAMPLE_PR_BODY, EMPTY_COPY_BODY, MINIMAL_CHANGELOG
+
+
+class ReleaseCopyValidationTest(unittest.TestCase):
+ def test_every_user_impact_requires_both_regions(self):
+ missing_readme = SAMPLE_PR_BODY.split(uc.PR_README_COPY_START)[0]
+ for label in uc.USER_IMPACT_LABELS[:-1]:
+ with self.subTest(label=label), self.assertRaisesRegex(ValueError, "release-copy:readme"):
+ uc.validate_release_copy("fix: restore downloads", missing_readme, [label])
+
+ def test_no_user_impact_excludes_listener_copy(self):
+ impact, _, entries, groups = uc.validate_release_copy("ci: validation", SAMPLE_PR_BODY, ["no-user-impact"])
+ self.assertEqual(impact, "no-user-impact")
+ self.assertTrue(entries)
+ self.assertEqual(groups, [])
+
+ def test_missing_multiple_or_body_only_labels_are_rejected(self):
+ for labels in ([], ["backend-change"], ["user-impact-high", "user-impact-low"]):
+ with self.subTest(labels=labels), self.assertRaisesRegex(ValueError, "exactly one"):
+ uc.validate_release_copy("fix: user-impact-critical", SAMPLE_PR_BODY, labels)
+
+ def test_placeholders_are_not_authored_copy(self):
+ with self.assertRaisesRegex(ValueError, "empty/TBD"):
+ uc.validate_release_copy("fix: example", EMPTY_COPY_BODY, ["user-impact-critical"])
+
+ def test_duplicate_copy_regions_fail_instead_of_discarding_second_region(self):
+ with self.assertRaisesRegex(ValueError, "exactly one complete"):
+ uc.validate_release_copy("fix: example", SAMPLE_PR_BODY + SAMPLE_PR_BODY, ["user-impact-high"])
+
+ def test_leftover_placeholder_does_not_pass_with_one_valid_note(self):
+ body = SAMPLE_PR_BODY.replace("### Fixed\n", "### Fixed\n- TBD\n")
+ with self.assertRaisesRegex(ValueError, "empty/TBD"):
+ uc.validate_release_copy("fix: example", body, ["user-impact-high"])
+
+ def test_authored_backslashes_are_literal_not_regex_replacement_codes(self):
+ readme = "# boxlore\n" + uc._render_release_notes_shell(upcoming_inner="old", whats_new_inner=None)
+ text = r"The path C:\podcasts\1 now works."
+ updated = uc._update_readme(readme, groups=[{"heading": "Fixes", "bullets": [text]}])
+ self.assertIn(text, updated)
+
+ def test_empty_stored_readme_block_is_still_missing_copy(self):
+ content = MINIMAL_CHANGELOG.replace("## [0.0.12]", "\n### Fixes\n- TBD\n\n\n## [0.0.12]")
+ with self.assertRaisesRegex(ValueError, "#900"):
+ uc.reviewed_readme_groups(content)
+
+ def test_readme_failure_preserves_previous_file(self):
+ with tempfile.TemporaryDirectory() as temp:
+ readme = Path(temp) / "README.md"
+ changelog = Path(temp) / "CHANGELOG.md"
+ readme.write_text("Existing published README", encoding="utf-8")
+ changelog.write_text(MINIMAL_CHANGELOG, encoding="utf-8")
+ with patch.object(uc, "README_PATH", readme), patch.object(uc, "CHANGELOG_PATH", changelog):
+ with self.assertRaisesRegex(ValueError, "#900.*backfill-changelog"):
+ uc.sync_readme_upcoming()
+ self.assertEqual(readme.read_text(), "Existing published README")
+
+ def test_authored_prefixes_and_all_bullets_survive_readme_round_trip(self):
+ content = uc._upsert_readme_copy_block(
+ MINIMAL_CHANGELOG, 900,
+ [{"heading": "Improvements", "bullets": ["Fixed: retain these exact words", "Second authored line"]}],
+ )
+ groups = uc.reviewed_readme_groups(content)
+ readme = "# boxlore\n" + uc._render_release_notes_shell(upcoming_inner="old", whats_new_inner=None)
+ updated = uc._update_readme(readme, groups=groups)
+ self.assertIn("Fixed: retain these exact words", updated)
+ self.assertIn("Second authored line", updated)
+ self.assertNotIn("AI-generated summary", updated)
+
+ def test_no_user_impact_block_cannot_leak_into_readme(self):
+ content = MINIMAL_CHANGELOG.replace("user-impact-low", "no-user-impact")
+ content = uc._upsert_readme_copy_block(content, 900, [{"heading": "Fixes", "bullets": ["Internal CI only"]}])
+ self.assertEqual(uc.reviewed_readme_groups(content), [])
+
+ def test_historical_backfill_supplies_missing_copy_without_rewriting_bullets(self):
+ with tempfile.TemporaryDirectory() as temp:
+ path = Path(temp) / "CHANGELOG.md"
+ path.write_text(MINIMAL_CHANGELOG, encoding="utf-8")
+ with patch.object(uc, "CHANGELOG_PATH", path):
+ self.assertTrue(uc.append_changelog(900, "fix: polish", SAMPLE_PR_BODY, ["user-impact-low"]))
+ updated = path.read_text()
+ self.assertEqual(uc._extract_unreleased_sections(updated), uc._extract_unreleased_sections(MINIMAL_CHANGELOG))
+ self.assertTrue(uc.reviewed_readme_groups(updated))
+
+ def test_released_pr_backfill_does_not_reinsert_into_upcoming(self):
+ content = MINIMAL_CHANGELOG.replace("- Older release", "- Older release " + uc._pr_suffix(700))
+ with tempfile.TemporaryDirectory() as temp:
+ path = Path(temp) / "CHANGELOG.md"
+ path.write_text(content, encoding="utf-8")
+ with patch.object(uc, "CHANGELOG_PATH", path):
+ self.assertFalse(uc.append_changelog(700, "fix: old", SAMPLE_PR_BODY, ["user-impact-low"]))
+ self.assertEqual(content, path.read_text())
+
+ def test_batch_reconciliation_checks_all_copy_before_writing(self):
+ pulls = [
+ {"number": 971, "title": "fix: first", "body": SAMPLE_PR_BODY, "labels": [{"name": "user-impact-critical"}]},
+ {"number": 972, "title": "fix: second", "body": EMPTY_COPY_BODY, "labels": [{"name": "user-impact-critical"}]},
+ ]
+ with tempfile.TemporaryDirectory() as temp:
+ path = Path(temp) / "CHANGELOG.md"
+ path.write_text(MINIMAL_CHANGELOG, encoding="utf-8")
+ with patch.object(pr, "CHANGELOG_PATH", path), patch.object(uc, "CHANGELOG_PATH", path), patch.object(pr, "pull_requests_between", return_value=pulls):
+ with self.assertRaisesRegex(ValueError, "release-copy:changelog"):
+ pr.reconcile_changelog("boxcreate/boxlore", "unused", "v0.0.12", "candidate")
+ self.assertEqual(MINIMAL_CHANGELOG, path.read_text())
+
+ def test_prepare_copy_failure_keeps_version_and_download_links_unchanged(self):
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ gradle = root / "build.gradle.kts"
+ changelog = root / "CHANGELOG.md"
+ readme = root / "README.md"
+ gradle.write_text('versionCode = 12\nversionName = "0.0.12"\n')
+ changelog.write_text(MINIMAL_CHANGELOG.replace("[0.0.12]", "[v0.0.12]"))
+ readme.write_text(uc._render_release_notes_shell(
+ upcoming_inner="Reviewed notes",
+ whats_new_inner=uc._render_whats_new_inner("v0.0.12", "2026-07-25", "Published notes"),
+ ))
+ paths = [gradle, changelog, readme]
+ original = [path.read_bytes() for path in paths]
+ pulls = [{"number": 971, "title": "fix: example", "body": EMPTY_COPY_BODY, "labels": [{"name": "user-impact-high"}]}]
+ with patch.object(pr, "APP_GRADLE_PATH", gradle), patch.object(pr, "CHANGELOG_PATH", changelog), patch.object(pr, "README_PATH", readme), patch.object(uc, "CHANGELOG_PATH", changelog), patch.object(uc, "README_PATH", readme), patch.object(pr, "assert_remote_target_is_free"), patch.object(pr, "pull_requests_between", return_value=pulls), patch.dict(os.environ, {"GITHUB_REPOSITORY": "boxcreate/boxlore", "GITHUB_TOKEN": "offline-test"}, clear=True):
+ with self.assertRaisesRegex(ValueError, "release-copy:changelog"):
+ pr.prepare_release(Namespace(bump="patch", latest_tag="v0.0.12", head_sha="candidate", skip_notify=False, use_readme_upcoming=False))
+ self.assertEqual(original, [path.read_bytes() for path in paths])
+
+ def test_cli_validates_event_without_api_key_or_file_mutation(self):
+ with tempfile.TemporaryDirectory() as temp:
+ event = Path(temp) / "event.json"
+ event.write_text(json.dumps({"pull_request": {"title": "fix: downloads", "body": SAMPLE_PR_BODY, "labels": [{"name": "user-impact-high"}]}}))
+ env = {"PATH": os.environ["PATH"], "GITHUB_EVENT_PATH": str(event)}
+ result = subprocess.run([sys.executable, str(Path(uc.__file__).resolve()), "validate-copy"], cwd=temp, env=env, capture_output=True, text=True)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(list(Path(temp).iterdir()), [event])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/scripts/test_update_changelog_copy.py b/.github/scripts/test_update_changelog_copy.py
index b4d27eb59..3a173aaa8 100644
--- a/.github/scripts/test_update_changelog_copy.py
+++ b/.github/scripts/test_update_changelog_copy.py
@@ -18,7 +18,7 @@
SAMPLE_PR_BODY = """
## Summary
-Something Groq would rewrite into different words.
+Context that must not replace the exact release wording.
### Added
@@ -108,7 +108,7 @@ def test_normalize_readme_heading_aliases(self) -> None:
class LockedChangelogFlowTest(unittest.TestCase):
- def test_append_uses_verbatim_copy_and_skips_groq(self) -> None:
+ def test_append_uses_verbatim_copy(self) -> None:
updated, changed = uc._update_changelog(
MINIMAL_CHANGELOG,
uc.parse_pr_changelog_copy(SAMPLE_PR_BODY),
@@ -132,29 +132,22 @@ def test_append_uses_verbatim_copy_and_skips_groq(self) -> None:
self.assertNotIn("Missing episodes from a show", " ".join(parsed.get("Fixed", [])))
self.assertTrue(any("PodcastRepository" in b for b in parsed["Added"]))
- def test_sync_changelog_does_not_call_groq_when_all_locked(self) -> None:
- locked, _ = uc._update_changelog(
- MINIMAL_CHANGELOG,
- {"Fixed": ["Author-written fix bullet"]},
- 971,
- impact="user-impact-critical",
- copy_locked=True,
+ def test_sync_preserves_every_locked_and_legacy_bullet(self) -> None:
+ content, _ = uc._update_changelog(
+ MINIMAL_CHANGELOG, {"Changed": ["Exact first line", "Exact second line"]},
+ 971, impact="user-impact-critical", copy_locked=True,
)
- with patch.object(uc, "_groq_curate_changelog_unreleased") as groq:
- locked_sections, unlocked = uc._partition_locked_sections(
- uc._extract_unreleased_sections(locked)
- )
- self.assertTrue(any(locked_sections.values()))
- self.assertIn("Author-written fix bullet", locked_sections["Fixed"][0])
- self.assertTrue(uc._is_copy_locked(locked_sections["Fixed"][0]))
- curated = uc._merge_locked_and_curated(
- locked_sections,
- {"Fixed": ["Groq would rewrite this ([#971](https://github.com/boxcreate/boxlore/pull/971))"]},
- )
- self.assertIn("Author-written fix bullet", curated["Fixed"][0])
- self.assertTrue(uc._is_copy_locked(curated["Fixed"][0]))
- self.assertFalse(any("Groq would rewrite" in b for b in curated["Fixed"]))
- groq.assert_not_called()
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "CHANGELOG.md"
+ path.write_text(content, encoding="utf-8")
+ with patch.object(uc, "CHANGELOG_PATH", path):
+ uc.sync_changelog_unreleased()
+ updated = path.read_text(encoding="utf-8")
+ before = uc._extract_unreleased_sections(content)
+ after = uc._extract_unreleased_sections(updated)
+ self.assertCountEqual(before["Changed"], after["Changed"])
+ self.assertLess(updated.index("Exact first line"), updated.index("Unrelated polish"))
+ self.assertFalse(uc.sync_changelog_unreleased())
def test_readme_sync_uses_locked_copy_verbatim_without_ai_notice(self) -> None:
changelog, _ = uc._update_changelog(
@@ -261,33 +254,32 @@ def test_promote_readme_preserves_verbatim_upcoming_without_ai_notice(self) -> N
self.assertIn("Missing episodes now show up in the same show.", whats_new)
self.assertNotIn("AI-generated summary", whats_new)
- def test_append_changelog_skips_groq_when_copy_present(self) -> None:
+ def test_append_stores_authored_copy_without_credentials(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "CHANGELOG.md"
path.write_text(MINIMAL_CHANGELOG, encoding="utf-8")
- with patch.object(uc, "CHANGELOG_PATH", path), patch.object(
- uc, "_groq_entries"
- ) as groq:
+ with patch.object(uc, "CHANGELOG_PATH", path):
changed = uc.append_changelog(
- "unused-key",
- 971,
- "feat(catalog): fill missing PI episodes",
- SAMPLE_PR_BODY,
+ 971, "feat(catalog): fill missing PI episodes", SAMPLE_PR_BODY,
labels=["user-impact-critical", "backend-change"],
)
- groq.assert_not_called()
self.assertTrue(changed)
text = path.read_text(encoding="utf-8")
self.assertIn("copy:locked", text)
self.assertIn("readme-copy:start pr=971", text)
self.assertIn("PodcastRepository unions publisher-feed extras", text)
self.assertIn("Missing episodes from a show", text)
+ self.assertFalse(uc.append_changelog(971, "same", SAMPLE_PR_BODY, labels=[]))
+ self.assertEqual(text, path.read_text(encoding="utf-8"))
- def test_groq_is_not_called_when_appending_locked_copy(self) -> None:
- with patch.object(uc, "_groq_entries") as groq:
- entries = uc.parse_pr_changelog_copy(SAMPLE_PR_BODY)
- self.assertTrue(entries)
- groq.assert_not_called()
+ def test_missing_copy_fails_before_changelog_write(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "CHANGELOG.md"
+ path.write_text(MINIMAL_CHANGELOG, encoding="utf-8")
+ with patch.object(uc, "CHANGELOG_PATH", path):
+ with self.assertRaisesRegex(ValueError, "release-copy:changelog"):
+ uc.append_changelog(971, "fix: example", EMPTY_COPY_BODY, ["user-impact-critical"])
+ self.assertEqual(MINIMAL_CHANGELOG, path.read_text(encoding="utf-8"))
class ChangelogSanitizationAndExtractionTest(unittest.TestCase):
diff --git a/.github/scripts/update_changelog.py b/.github/scripts/update_changelog.py
index 938753f56..20f32e28a 100644
--- a/.github/scripts/update_changelog.py
+++ b/.github/scripts/update_changelog.py
@@ -3,7 +3,7 @@
Author-written copy in the PR body (`` and
``) is highest priority: it is pasted verbatim,
-tagged ``, and never sent to Groq for rewrite. prepare_release
+tagged ``, and ordered without rewriting. prepare_release
promotes locked README Upcoming into What's New as-is.
"""
@@ -14,9 +14,6 @@
import os
import re
import sys
-import time
-import urllib.error
-import urllib.request
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
@@ -47,9 +44,6 @@
""
"
"
)
-GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"
-GROQ_MODEL = os.environ.get("GROQ_MODEL", "llama-3.3-70b-versatile")
-GROQ_USER_AGENT = "boxlore-changelog/1.4"
CATEGORY_ORDER = ("Added", "Changed", "Fixed", "Deprecated", "Removed", "Security")
README_GROUP_ORDER = (
"Critical",
@@ -468,91 +462,6 @@ def _clean_pr_body(body: str) -> str:
return cleaned.strip()
-def _groq_entries(api_key: str, pr_number: int, pr_title: str, pr_body: str) -> dict[str, list[str]]:
- system_prompt = """You write Keep a Changelog entries for boxlore (Android/Kotlin podcast app).
-Return ONLY valid JSON with optional keys: Added, Changed, Fixed, Deprecated, Removed, Security.
-Each value is an array of bullet strings WITHOUT leading dashes.
-
-Audience: developers and technical contributors reading CHANGELOG.md (NOT end-user README copy).
-
-Rules:
-- Use precise technical wording: class/module names, tiers, repositories, and behavior when relevant.
-- Merge related changes into one bullet per feature area (e.g. all SmartQueueEngine work → one Added bullet).
-- For CI-only, dependency-bump, or docs-only PRs, still return exactly one short Changed (or docs) bullet — never an empty object.
-- Prefer summarizing analytics as "PostHog announcement viewed/dismissed/action events" — do not list every event property.
-- When the PR body includes "What changes in the user's life" / Listener impact, still write technical CHANGELOG bullets, but do not invent UI toolkit branding (e.g. "Material 3") as the main story.
-- Use "Fixed ..." for bugs, "Added ..." for features, "Changed ..." for behavior/refactors, "Removed ..." for deletions.
-- Do not invent changes unsupported by the PR title/body.
-- Omit empty categories.
-- No version headers, dates, PR numbers, or markdown in bullets.
-- Aim for 1–6 bullets total per PR (at least one).
-
-Examples:
-- Bugfix PR:
- Input: Title: fix(queue): restore same-show playback continuation (#1017)
- Output: {"Fixed": ["Fixed same-show continuation in SmartQueueEngine by restoring forward chronological queries in LocalEpisodeCatalogDao and RssEpisodeDao"]}
-- Feature PR:
- Input: Title: feat(info): add direct publisher feed refresh on pull-down (#1016)
- Output: {"Added": ["Added direct-feed refresh for subscribed catalog podcasts on pull-to-refresh", "Preserved notification and download settings across pull-to-refresh"]}"""
-
- cleaned_body = _clean_pr_body(pr_body)
- user_prompt = f"""PR #{pr_number}
-Title: {pr_title}
-
-Description:
-{cleaned_body or "(no description)"}
-
-Generate changelog bullets for the [Unreleased] section."""
-
- parsed = _groq_chat_json(
- api_key,
- system_prompt,
- user_prompt,
- "Groq API error",
- )
- if not parsed:
- return {}
-
- normalized: dict[str, list[str]] = {}
- for category in CATEGORY_ORDER:
- raw = parsed.get(category, [])
- if not isinstance(raw, list):
- continue
- bullets = [str(item).strip() for item in raw if str(item).strip()]
- if bullets:
- normalized[category] = bullets
- return normalized
-
-
-def _fallback_entries_from_title(pr_title: str) -> dict[str, list[str]]:
- """Deterministic changelog bullets when Groq returns nothing (e.g. CI/deps PRs)."""
- title = (pr_title or "").strip() or "Maintenance update"
- conventional = re.match(
- r"^(?Pfeat|fix|chore|ci|docs|refactor|test|perf|build|style)"
- r"(?:\([^)]*\))?!?:\s*(?P.+)$",
- title,
- flags=re.IGNORECASE,
- )
- if conventional:
- kind = conventional.group("type").lower()
- summary = conventional.group("summary").strip()
- else:
- kind = "chore"
- summary = title
-
- if kind == "feat":
- category = "Added"
- elif kind == "fix":
- category = "Fixed"
- elif kind == "docs":
- category = "Changed"
- if not summary.lower().startswith("docs"):
- summary = f"Documentation: {summary}"
- else:
- category = "Changed"
- return {category: [summary]}
-
-
def _extract_pr_number(bullet: str) -> int | None:
# 1. Full markdown pull request link: [#1019](https://github.com/.../pull/1019)
match = re.search(r"\[#(\d+)\]\(https://github\.com/[^/]+/[^/]+/pull/\1\)", bullet)
@@ -659,262 +568,10 @@ def _cluster_sections_by_pr(sections: dict[str, list[str]]) -> list[ChangelogClu
return clusters
-def _render_clustered_changelog(clusters: list[ChangelogCluster]) -> str:
- lines: list[str] = []
- for cluster in clusters:
- label = f"PR #{cluster.pr_number}" if cluster.pr_number is not None else "Unlinked"
- impact = cluster.impact or "unlabeled"
- backend = " backend-change" if cluster.backend_change else ""
- lines.append(
- f"## {label} | impact {impact}{backend} | importance {cluster.importance} | "
- f"theme: {cluster.theme}{' | copy-locked' if cluster.copy_locked else ''}"
- )
- by_category: dict[str, list[str]] = defaultdict(list)
- for category, text in cluster.items:
- by_category[category].append(text)
- for category in CATEGORY_ORDER:
- for text in by_category.get(category, []):
- lines.append(f"- [{category}] {text}")
- lines.append("")
- return "\n".join(lines).strip()
-
-
def _strip_pr_links(text: str) -> str:
return re.sub(r"\s*\(\[#\d+\]\([^)]+\)\)\s*$", "", text).strip()
-def _groq_retry_wait_seconds(detail: str, attempt: int) -> float:
- match = re.search(r"try again in ([0-9]+(?:\.[0-9]+)?)s", detail, flags=re.I)
- if match:
- return max(1.0, float(match.group(1)) + 0.5)
- return min(60.0, float(2 ** attempt))
-
-
-def _groq_chat_json(api_key: str, system_prompt: str, user_prompt: str, error_label: str) -> dict:
- payload = {
- "model": GROQ_MODEL,
- "temperature": 0.2,
- "response_format": {"type": "json_object"},
- "messages": [
- {"role": "system", "content": system_prompt},
- {"role": "user", "content": user_prompt},
- ],
- }
-
- max_attempts = 8
- last_detail = ""
- for attempt in range(1, max_attempts + 1):
- request = urllib.request.Request(
- GROQ_API_URL,
- data=json.dumps(payload).encode("utf-8"),
- headers={
- "Authorization": f"Bearer {api_key}",
- "Content-Type": "application/json",
- "User-Agent": GROQ_USER_AGENT,
- },
- method="POST",
- )
- try:
- with urllib.request.urlopen(request, timeout=90) as response:
- body = json.loads(response.read().decode("utf-8"))
- except urllib.error.HTTPError as exc:
- last_detail = exc.read().decode("utf-8", errors="replace")
- if exc.code == 429 and attempt < max_attempts:
- wait = _groq_retry_wait_seconds(last_detail, attempt)
- print(
- f"{error_label}: rate limited (attempt {attempt}/{max_attempts}); "
- f"retrying in {wait:.1f}s"
- )
- time.sleep(wait)
- continue
- print(f"{error_label} ({exc.code}): {last_detail}", file=sys.stderr)
- return {}
- except urllib.error.URLError as exc:
- last_detail = str(exc)
- if attempt < max_attempts:
- wait = min(30.0, float(2 ** attempt))
- print(
- f"{error_label}: network error (attempt {attempt}/{max_attempts}); "
- f"retrying in {wait:.1f}s ({last_detail})"
- )
- time.sleep(wait)
- continue
- print(f"{error_label}: {last_detail}", file=sys.stderr)
- return {}
-
- content = body["choices"][0]["message"]["content"]
- parsed = json.loads(content)
- if not isinstance(parsed, dict):
- print(f"{error_label}: Groq response was not a JSON object", file=sys.stderr)
- return {}
- return parsed
-
- print(f"{error_label}: giving up after retries. {last_detail}", file=sys.stderr)
- return {}
-
-
-def _format_changelog_bullet(
- text: str,
- pr_number: int | None,
- impact: str | None = None,
- backend_change: bool = False,
- copy_locked: bool = False,
-) -> str:
- return _attach_impact(
- _format_readme_bullet(text, pr_number),
- impact,
- backend_change=backend_change,
- copy_locked=copy_locked,
- )
-
-
-def _parse_changelog_bullet(entry: object) -> tuple[str, int | None]:
- return _parse_readme_bullet(entry)
-
-
-def _sections_from_cluster_bullets(
- curated: dict[str, list[tuple[str, int | None]]],
- clusters: list[ChangelogCluster],
-) -> dict[str, list[str]]:
- """Sort bullets within each Keep a Changelog category by cluster importance."""
- pr_importance = {c.pr_number: c.importance for c in clusters if c.pr_number is not None}
- pr_impact = {c.pr_number: c.impact for c in clusters if c.pr_number is not None}
- pr_backend = {
- c.pr_number: c.backend_change for c in clusters if c.pr_number is not None
- }
- pr_locked = {c.pr_number: c.copy_locked for c in clusters if c.pr_number is not None}
-
- def sort_key(item: tuple[str, int | None]) -> tuple[int, str]:
- _, pr_number = item
- return (-pr_importance.get(pr_number, 0), str(pr_number or ""))
-
- sections: dict[str, list[str]] = {}
- for category in CATEGORY_ORDER:
- items = curated.get(category, [])
- if not items:
- continue
- sorted_items = sorted(items, key=sort_key)
- bullets = [
- _format_changelog_bullet(
- text,
- pr,
- pr_impact.get(pr) if pr is not None else None,
- pr_backend.get(pr, False) if pr is not None else False,
- copy_locked=pr_locked.get(pr, False) if pr is not None else False,
- )
- for text, pr in sorted_items
- if text.strip()
- ]
- if bullets:
- sections[category] = bullets
- return sections
-
-
-def _fallback_changelog_from_clusters(clusters: list[ChangelogCluster]) -> dict[str, list[str]]:
- curated: dict[str, list[tuple[str, int | None]]] = defaultdict(list)
- for cluster in clusters:
- if not cluster.items:
- continue
- if cluster.copy_locked:
- for category, text in cluster.items:
- curated[category].append((text, cluster.pr_number))
- continue
- by_category: dict[str, list[str]] = defaultdict(list)
- for category, text in cluster.items:
- by_category[category].append(text)
- for category in CATEGORY_ORDER:
- texts = by_category.get(category, [])
- if not texts:
- continue
- merged = texts[0] if len(texts) == 1 else f"{texts[0]} (+ {len(texts) - 1} related changes)"
- curated[category].append((merged, cluster.pr_number))
- return _sections_from_cluster_bullets(curated, clusters)
-
-
-def _groq_curate_changelog_unreleased(
- api_key: str, sections: dict[str, list[str]]
-) -> dict[str, list[str]]:
- """Re-group and prioritize the full [Unreleased] section for CHANGELOG.md."""
- if not any(sections.values()):
- return {}
-
- clusters = _cluster_sections_by_pr(sections)
- clustered_input = _render_clustered_changelog(clusters)
- if not clustered_input:
- return {}
-
- system_prompt = """You curate clustered changelog drafts for boxlore into a clean Keep a Changelog [Unreleased] section.
-
-Audience: developers — use technical, precise language (class names, tiers, modules, repositories, guards).
-This is NOT the user-facing README; do not simplify away useful implementation detail.
-
-Input is grouped by PR/theme with impact tags and importance scores. Each ## block is one merged feature area.
-Impact levels: user-impact-critical (~100), user-impact-high (~95), user-impact-medium (~70), user-impact-low (~45), no-user-impact (~12).
-Optional flag backend-change may appear with any impact level (pairable).
-Blocks marked copy-locked are author-written and must be preserved verbatim — do not rewrite, merge, or drop them.
-
-Return ONLY valid JSON:
-{"Added": [{"text": "...", "pr": 853}, ...], "Changed": [...], "Fixed": [...], "Removed": [...], ...}
-
-Rules:
-1. ONE bullet object per ## block per category. Merge all [Added] lines in a block into one Added bullet; same for Changed/Fixed/Removed. Exception: copy-locked blocks keep every bullet unchanged.
-2. Include "pr" from the ## PR #NNN header on every bullet.
-3. Preserve every ## block so release reconciliation can prove each merged PR is represented. Put no-user-impact / tooling last and collapse it to one concise Changed bullet.
-4. Within each category array, order bullets by importance (user-impact-critical first, then high, no-user-impact last). backend-change does not by itself lower priority when paired with a user-impact level.
-5. Keep a Changelog category names exactly: Added, Changed, Fixed, Deprecated, Removed, Security.
-6. No PR numbers inside "text" (appended separately). No markdown headers in output.
-7. Prefer 2–4 bullets per PR cluster total across categories, not one line per commit.
-
-Tone examples (CHANGELOG — technical):
-- "Smart Queue v2: tiered SmartQueueEngine (T0–T4, T3.5) with skip memory, region-aware refill, and unified BoxLorePlaybackService path"
-- "EngagementPromptCoordinator: native PostHog NPS surveys, unified NPS/Play review modal, 14-day promoter cooldown"
-- "Fixed Tier 0 newest-sort adding archive episodes on latest-item play; discovery landing guard skips deep continuation"
-
-Do NOT use README listener tone like "shows why each item is there" — use provenance labels, AUTO_FILL, contextSourceId, etc."""
-
- user_prompt = f"""Curate these PR/theme clusters into grouped Keep a Changelog bullets:
-
-{clustered_input}"""
-
- parsed = _groq_chat_json(
- api_key,
- system_prompt,
- user_prompt,
- "Groq CHANGELOG curation error",
- )
-
- curated: dict[str, list[tuple[str, int | None]]] = defaultdict(list)
- for category in CATEGORY_ORDER:
- raw = parsed.get(category, [])
- if not isinstance(raw, list):
- continue
- for entry in raw:
- text, pr_number = _parse_changelog_bullet(entry)
- if text:
- curated[category].append((text, pr_number))
-
- if curated:
- curated_sections = _sections_from_cluster_bullets(curated, clusters)
- expected_prs = {
- cluster.pr_number
- for cluster in clusters
- if cluster.pr_number is not None
- }
- returned_prs = {
- pr_number
- for bullets in curated_sections.values()
- for bullet in bullets
- if (pr_number := _extract_pr_number(bullet)) is not None
- }
- if returned_prs == expected_prs:
- return curated_sections
- print(
- "Groq curation omitted or added PR clusters; "
- "using deterministic fallback."
- )
- return _fallback_changelog_from_clusters(clusters)
-
-
def _unreleased_bounds(content: str) -> tuple[int, int] | None:
match = re.search(r"^## \[Unreleased\]\s*$", content, flags=re.MULTILINE)
if not match:
@@ -1040,46 +697,6 @@ def _sort_section_bullets(sections: dict[str, list[str]]) -> dict[str, list[str]
}
-def _merge_locked_and_curated(
- locked: dict[str, list[str]],
- curated: dict[str, list[str]],
-) -> dict[str, list[str]]:
- locked_prs = {
- _extract_pr_number(bullet)
- for bullets in locked.values()
- for bullet in bullets
- if _extract_pr_number(bullet) is not None
- }
- merged: dict[str, list[str]] = {}
- for category in CATEGORY_ORDER:
- locked_bullets = sorted(locked.get(category, []), key=_bullet_sort_key)
- curated_bullets = [
- bullet
- for bullet in curated.get(category, [])
- if _extract_pr_number(bullet) not in locked_prs
- ]
- combined = locked_bullets + curated_bullets
- if combined:
- merged[category] = combined
- return merged
-
-
-def _sections_excluding_prs(
- sections: dict[str, list[str]],
- pr_numbers: set[int],
-) -> dict[str, list[str]]:
- filtered: dict[str, list[str]] = {}
- for category, bullets in sections.items():
- kept = [
- bullet
- for bullet in bullets
- if (pr := _extract_pr_number(bullet)) is None or pr not in pr_numbers
- ]
- if kept:
- filtered[category] = kept
- return filtered
-
-
def _locked_readme_groups_from_changelog(
content: str,
) -> tuple[list[dict[str, list[str]]], set[int]]:
@@ -1098,15 +715,20 @@ def _locked_readme_groups_from_changelog(
locked_prs: set[int] = set()
for match in README_COPY_BLOCK_RE.finditer(_unreleased_raw(content)):
pr_number = int(match.group(1))
+ if impact_scores.get(pr_number) == USER_IMPACT_SCORE["no-user-impact"]:
+ continue
+ parsed_groups = _parse_readme_copy_inner(match.group(2))
+ if not parsed_groups:
+ continue
locked_prs.add(pr_number)
- for group in _parse_readme_copy_inner(match.group(2)):
+ for group in parsed_groups:
heading = group["heading"]
for bullet in group["bullets"]:
grouped[heading].append(
(
impact_scores.get(pr_number, 0),
pr_number,
- _format_readme_bullet(bullet, pr_number),
+ _format_readme_bullet(bullet, pr_number, preserve_copy=True),
)
)
groups = _sort_readme_groups(
@@ -1128,80 +750,22 @@ def _locked_readme_groups_from_changelog(
return groups, locked_prs
-def _drop_prs_from_readme_groups(
- groups: list[dict[str, list[str]]],
- pr_numbers: set[int],
-) -> list[dict[str, list[str]]]:
- cleaned: list[dict[str, list[str]]] = []
- for group in groups:
- bullets = [
- bullet
- for bullet in group.get("bullets") or []
- if (pr := _extract_pr_number(bullet)) is None or pr not in pr_numbers
- ]
- if bullets:
- cleaned.append({"heading": group["heading"], "bullets": bullets})
- return cleaned
-
-
-def _merge_readme_groups(
- locked: list[dict[str, list[str]]],
- ai_groups: list[dict[str, list[str]]],
-) -> list[dict[str, list[str]]]:
- by_heading: dict[str, list[str]] = defaultdict(list)
- seen: dict[str, set[str]] = defaultdict(set)
- for source in (locked, ai_groups):
- for group in source:
- heading = group["heading"]
- for bullet in group.get("bullets") or []:
- if bullet in seen[heading]:
- continue
- seen[heading].add(bullet)
- by_heading[heading].append(bullet)
- return _sort_readme_groups(
- [{"heading": heading, "bullets": bullets} for heading, bullets in by_heading.items() if bullets]
- )
-
-
-def _format_readme_bullet(text: str, pr_number: int | None) -> str:
+def _format_readme_bullet(text: str, pr_number: int | None, *, preserve_copy: bool = False) -> str:
cleaned = text.strip()
if not cleaned:
return cleaned
- cleaned = re.sub(
- r"^(?:\[(?:Fixed|Added|Changed|Improved|Fix|Add|Change|Security)\]|(?:Fixed|Added|Changed|Improved|Fix|Add|Change|Security):)\s*",
- "",
- cleaned,
- flags=re.IGNORECASE,
- ).strip()
+ if not preserve_copy:
+ cleaned = re.sub(
+ r"^(?:\[(?:Fixed|Added|Changed|Improved|Fix|Add|Change|Security)\]|(?:Fixed|Added|Changed|Improved|Fix|Add|Change|Security):)\s*",
+ "",
+ cleaned,
+ flags=re.IGNORECASE,
+ ).strip()
if pr_number is not None and not _pr_already_present(cleaned, pr_number):
cleaned = f"{cleaned} {_pr_suffix(pr_number)}"
return cleaned
-def _parse_readme_bullet(entry: object) -> tuple[str, int | None]:
- if isinstance(entry, dict):
- text = str(entry.get("text", "")).strip()
- pr_raw = entry.get("pr")
- pr_number = int(pr_raw) if pr_raw is not None else None
- return text, pr_number
- return str(entry).strip(), None
-
-
-def _dominant_readme_heading(cluster: ChangelogCluster) -> str:
- if cluster.impact == "user-impact-critical":
- return "Critical"
- categories = {cat for cat, _ in cluster.items}
- if "Added" in categories:
- return "New features"
- if "Changed" in categories:
- return "Improvements"
- if "Fixed" in categories:
- return "Fixes"
- if "Security" in categories:
- return "Security"
- return "Other"
-
-
def _readme_eligible(cluster: ChangelogCluster) -> bool:
"""Whether a cluster should appear in README Upcoming / release highlights."""
if not cluster.items:
@@ -1221,113 +785,6 @@ def _readme_eligible(cluster: ChangelogCluster) -> bool:
return cluster.importance >= 40
-def _fallback_readme_from_clusters(clusters: list[ChangelogCluster]) -> list[dict[str, list[str]]]:
- """Deterministic grouped README when Groq grouping fails."""
- grouped: dict[str, list[str]] = defaultdict(list)
- for cluster in clusters:
- if not _readme_eligible(cluster):
- continue
- heading = _dominant_readme_heading(cluster)
- preview = cluster.items[0][1]
- if len(preview) > 117:
- preview = preview[:114].rstrip() + "..."
- grouped[heading].append(_format_readme_bullet(preview, cluster.pr_number))
- return _sort_readme_groups(
- [{"heading": heading, "bullets": bullets} for heading, bullets in grouped.items() if bullets]
- )
-
-
-def _groq_curate_readme_upcoming(
- api_key: str, sections: dict[str, list[str]]
-) -> list[dict[str, list[str]]]:
- """Curate all [Unreleased] entries into grouped, importance-sorted README bullets."""
- if not any(sections.values()):
- return []
-
- clusters = _cluster_sections_by_pr(sections)
- clustered_input = _render_clustered_changelog(clusters)
- if not clustered_input:
- return []
-
- system_prompt = """You curate clustered changelog entries into a README "Upcoming Changes" section for boxlore listeners.
-
-Audience: podcast listeners using the app — plain English only.
-This section must answer: what is different in the listener's day-to-day use of boxlore?
-Do NOT use CHANGELOG-style technical terms (SmartQueueEngine, Tier 0, PlaybackRepository, AUTO_FILL, contextSourceId, composable/class names, FCM payload keys, PackageManager, DataStore).
-Never mention: Material 3 / Material You, PostHog, analytics/telemetry/event names, CI, Groq, CodeRabbit, Sonar, refactoring, modules, workflows, or "logs views/actions".
-The paired CHANGELOG entry for the same PR uses developer language; this README section must feel different.
-
-If the PR/cluster text includes a "Listener impact" or "What changes in the user's life" idea, prioritize that outcome over implementation details.
-
-Input is pre-grouped by PR/theme with an importance score (higher = more user-visible). Each ## block is ONE feature area — merge its bullets into as few README lines as possible.
-
-Return ONLY valid JSON:
-{"groups": [{"heading": "...", "bullets": [{"text": "...", "pr": 853}, ...]}, ...]}
-
-Each bullet MUST include "pr" copied from the matching ## PR #NNN block in the input. One bullet object per ## block you keep.
-
-Allowed headings (exactly one per group, omit empty):
-- "Critical" — correctness fixes listeners already feel (missing episodes, late alerts). Lead the list.
-- "New features" — new capabilities listeners can use (from Added clusters that are truly new surfaces)
-- "Improvements" — clearer UI, polish, safer dismissal, better update notes (prefer this for announcement/dialog polish)
-- "Fixes" — bugs resolved (from Fixed clusters)
-- "Security" — privacy/security only
-
-MANDATORY clustering rules:
-1. ONE README bullet per input ## block in most cases. Never split a single PR/theme across multiple bullets.
-2. For importance 90+ themes (queue, playback, discovery): allow at most TWO bullets if they describe clearly distinct user wins; prefer ONE strong sentence.
-3. For importance 40–55 themes (NPS, surveys, review prompts): exactly ONE bullet, never lead the list.
-4. Drop clusters with importance below 40 / no-user-impact entirely from README. Include user-impact-critical, user-impact-high and user-impact-medium always; user-impact-low when space allows. backend-change may be paired with any user-impact level and does not exclude README by itself.
-5. Merge all bullets inside a ## block before writing — e.g. four queue bullets → one: "Smart queue auto-refills, shows why items appear, supports drag reorder, and undo remove." Never rewrite copy-locked blocks.
-6. Sort bullets within each group by the cluster importance score (highest first). user-impact-critical MUST appear before high/medium/low.
-7. Cap at 8 bullets total across all groups. Copy-locked bullets are exempt from the cap and must be kept.
-8. Prefer concrete listener outcomes (easier to read update notes, harder to dismiss by accident, Play installs skip GitHub download prompts) over UI toolkit or logging details.
-
-Importance guidance (respect the scores in input):
-- user-impact-critical / 100: lead README / What's New; author copy if present
-- user-impact-high / 90–99: headline features
-- user-impact-medium / 70–89: solid README entries
-- user-impact-low / 40–55: optional shorter lines
-- no-user-impact / below 40: omit from README (CHANGELOG only)
-
-Rewrite in plain English; no PR numbers inside "text", no Compose/modules jargon. Under 140 chars in "text" (PR link is appended separately)."""
-
- user_prompt = f"""Curate these PR/theme clusters into grouped README bullets.
-Lead with what listeners notice. Ignore analytics and design-system branding.
-
-{clustered_input}"""
-
- parsed = _groq_chat_json(
- api_key,
- system_prompt,
- user_prompt,
- "Groq README curation error",
- )
- raw_groups = parsed.get("groups", [])
- if not isinstance(raw_groups, list):
- return _fallback_readme_from_clusters(clusters)
-
- groups: list[dict[str, list[str]]] = []
- for item in raw_groups:
- if not isinstance(item, dict):
- continue
- heading = str(item.get("heading", "")).strip()
- bullets_raw = item.get("bullets", [])
- if not heading or not isinstance(bullets_raw, list):
- continue
- bullets: list[str] = []
- for entry in bullets_raw:
- text, pr_number = _parse_readme_bullet(entry)
- if text:
- bullets.append(_format_readme_bullet(text, pr_number))
- if bullets:
- groups.append({"heading": heading, "bullets": bullets})
-
- if groups:
- return _sort_readme_groups(groups)
- return _fallback_readme_from_clusters(clusters)
-
-
def _sort_readme_groups(groups: list[dict[str, list[str]]]) -> list[dict[str, list[str]]]:
order = {name: index for index, name in enumerate(README_GROUP_ORDER)}
@@ -1338,40 +795,6 @@ def rank(group: dict[str, list[str]]) -> tuple[int, str]:
return sorted(groups, key=rank)
-def _groq_readme_summary(api_key: str, sections: dict[str, list[str]]) -> list[str]:
- """Flat bullet fallback when grouped curation returns nothing."""
- if not any(sections.values()):
- return []
-
- changelog_text = _render_unreleased(sections)
- system_prompt = """You rewrite changelog entries as short README bullets for end users of boxlore, a podcast Android app.
-Return ONLY valid JSON: {"bullets": ["...", ...]}.
-
-Rules:
-- Write for listeners using the app, not developers. Answer what changed in their day-to-day use.
-- One bullet per user-visible change; merge related technical items into one line.
-- Never mention: Compose, recomposition, lazy grid, PerfLog, CI, Groq, CodeRabbit, Sonar, refactoring, parameters, modules, workflows, Material 3, PostHog, analytics event names, or internal code names.
-- Prefer outcomes like clearer update notes, accidental dismissals avoided, Play installs skipping GitHub download prompts.
-- Keep each bullet under 140 characters.
-- No leading dashes, PR numbers, or markdown links."""
-
- user_prompt = f"""Convert these [Unreleased] changelog entries into user-facing README bullets.
-Ignore implementation detail; keep listener-facing outcomes only.
-
-{changelog_text}"""
-
- parsed = _groq_chat_json(
- api_key,
- system_prompt,
- user_prompt,
- "Groq README summary error",
- )
- raw = parsed.get("bullets", [])
- if not isinstance(raw, list):
- return []
- return [str(item).strip() for item in raw if str(item).strip()]
-
-
def _parse_unreleased_sections(unreleased_block: str) -> dict[str, list[str]]:
unreleased_block = README_COPY_BLOCK_RE.sub("", unreleased_block)
sections: dict[str, list[str]] = {}
@@ -1485,7 +908,7 @@ def _render_whats_new_inner(
version_tag: str,
release_date: str,
body_html: str,
- include_ai_notice: bool = True,
+ include_ai_notice: bool = False,
) -> str:
body = body_html.strip()
if include_ai_notice:
@@ -1521,7 +944,7 @@ def _render_readme_upcoming_body(groups: list[dict[str, list[str]]] | None = Non
return EMPTY_UPCOMING_TEXT
-def _format_upcoming_inner(body: str, include_ai_notice: bool = True) -> str:
+def _format_upcoming_inner(body: str, include_ai_notice: bool = False) -> str:
stripped = body.strip()
if not stripped:
stripped = EMPTY_UPCOMING_TEXT
@@ -1538,7 +961,7 @@ def _render_release_notes_shell(
*,
upcoming_inner: str,
whats_new_inner: str | None,
- include_ai_notice: bool = True,
+ include_ai_notice: bool = False,
) -> str:
upcoming = _format_upcoming_inner(upcoming_inner, include_ai_notice=include_ai_notice)
whats_new_block = ""
@@ -1568,7 +991,7 @@ def _render_readme_upcoming_block(
content: str,
groups: list[dict[str, list[str]]] | None = None,
bullets: list[str] | None = None,
- include_ai_notice: bool = True,
+ include_ai_notice: bool = False,
) -> str:
"""Rewrite the Upcoming body; preserve the latest What's New region if present."""
body = _render_readme_upcoming_body(groups=groups, bullets=bullets)
@@ -1603,7 +1026,7 @@ def _update_readme(
content: str,
groups: list[dict[str, list[str]]] | None = None,
bullets: list[str] | None = None,
- include_ai_notice: bool = True,
+ include_ai_notice: bool = False,
) -> str:
block = _render_readme_upcoming_block(
content,
@@ -1616,7 +1039,8 @@ def _update_readme(
flags=re.DOTALL,
)
if pattern.search(content):
- updated = pattern.sub(block, content, count=1)
+ # Authored text may contain backslashes; never treat it as a regex replacement.
+ updated = pattern.sub(lambda _: block, content, count=1)
else:
anchor = re.search(
r"^(|## Search\b|)",
@@ -1670,177 +1094,125 @@ def _update_changelog(
return _write_unreleased_region(content, merged, copy_blocks), True
-def append_changelog(
- api_key: str,
- pr_number: int,
+def validate_release_copy(
pr_title: str,
pr_body: str,
- labels: list[str] | None = None,
-) -> bool:
- if not CHANGELOG_PATH.exists():
- print("CHANGELOG.md not found", file=sys.stderr)
- sys.exit(1)
-
- impact, backend_change = _resolve_pr_tags(
- labels=labels, title=pr_title, body=pr_body
- )
- if impact:
- backend_note = " + backend-change" if backend_change else ""
- print(f"PR #{pr_number} tags: {impact}{backend_note}")
- else:
- print(
- f"::warning::PR #{pr_number} is missing a required user-impact label "
- f"({', '.join(USER_IMPACT_LABELS)}). "
- f"Optional pairable label: {BACKEND_CHANGE_LABEL}. "
- "Prioritization will use theme heuristics only."
+ labels: list[str] | None,
+) -> tuple[str, bool, dict[str, list[str]], list[dict[str, list[str]]]]:
+ """Validate author copy before writing files; never synthesize missing wording."""
+ impacts = {label for label in labels or [] if label in USER_IMPACT_LABELS}
+ if len(impacts) != 1:
+ raise ValueError(
+ "Add exactly one PR impact label: " + ", ".join(USER_IMPACT_LABELS) +
+ ". Then rerun release-copy validation."
)
- if backend_change:
- print(f"PR #{pr_number} also has backend-change")
-
- changelog_original = CHANGELOG_PATH.read_text(encoding="utf-8")
- locked_entries = parse_pr_changelog_copy(pr_body, pr_title, impact)
- copy_locked = bool(locked_entries)
- if copy_locked:
- print(f"PR #{pr_number}: using verbatim CHANGELOG copy from the PR body (not Groq).")
- entries = locked_entries
- else:
- if impact == "user-impact-critical":
- print(
- f"::warning::PR #{pr_number} is user-impact-critical but "
- "release-copy:changelog is empty; falling back to Groq."
- )
- entries = _groq_entries(api_key, pr_number, pr_title, pr_body)
- if not entries:
- print(
- f"Groq returned no changelog entries for PR #{pr_number}; "
- "synthesizing a Changed/Added/Fixed bullet from the PR title."
+ impact = next(iter(impacts))
+ backend = any(_is_backend_label(label) for label in labels or [])
+ for name, start, end in (
+ ("changelog", PR_CHANGELOG_COPY_START, PR_CHANGELOG_COPY_END),
+ ("readme", PR_README_COPY_START, PR_README_COPY_END),
+ ):
+ if name == "readme" and impact == "no-user-impact":
+ continue
+ inner = _extract_marked_region(pr_body, start, end)
+ if pr_body.count(start) != 1 or pr_body.count(end) != 1 or inner is None:
+ raise ValueError(
+ f"Keep exactly one complete release-copy:{name} region in the PR body. "
+ "Use the PR template markers, then rerun validation."
)
- entries = _fallback_entries_from_title(pr_title)
- if not entries:
- print("Could not synthesize a changelog entry from the PR title.")
- return False
-
- changelog_updated, changelog_changed = _update_changelog(
- changelog_original,
- entries,
- pr_number,
- impact=impact,
- backend_change=backend_change,
- copy_locked=copy_locked,
- )
- if changelog_changed:
- changelog_original = changelog_updated
-
- readme_groups = parse_pr_readme_copy(pr_body, impact)
- if readme_groups:
- print(f"PR #{pr_number}: storing verbatim README copy for Upcoming / What's New.")
- with_copy = _upsert_readme_copy_block(changelog_original, pr_number, readme_groups)
- if with_copy != changelog_original:
- changelog_original = with_copy
- changelog_changed = True
- elif impact == "user-impact-critical":
- print(
- f"::warning::PR #{pr_number} is user-impact-critical but "
- "release-copy:readme is empty; README will be Groq-curated."
+ for line in inner.splitlines():
+ bullet = re.match(r"^[-*]\s*(.*)$", line.strip())
+ if bullet and _is_placeholder_bullet(bullet.group(1)):
+ raise ValueError(
+ f"Replace or remove the empty/TBD bullet in release-copy:{name}. "
+ "Every release note must have authored wording."
+ )
+ entries = parse_pr_changelog_copy(pr_body, pr_title, impact)
+ if not entries:
+ raise ValueError(
+ "Fill the PR's release-copy:changelog region with categorized developer bullets. "
+ "Empty bullets and TBD are placeholders. Save the PR body, then rerun the failed workflow."
)
+ groups = parse_pr_readme_copy(pr_body, impact)
+ if impact != "no-user-impact" and not groups:
+ raise ValueError(
+ "Fill the PR's release-copy:readme region with listener-facing changes. "
+ "Save the PR body, then rerun the failed workflow. No automatic copy is generated."
+ )
+ if impact == "no-user-impact":
+ groups = []
+ return impact, backend, entries, groups
- if changelog_changed:
- CHANGELOG_PATH.write_text(changelog_original, encoding="utf-8")
- print(f"Updated CHANGELOG.md for PR #{pr_number}.")
- else:
- print(f"No CHANGELOG changes for PR #{pr_number}.")
- return changelog_changed
-
-
-def sync_changelog_unreleased(api_key: str) -> bool:
- if not CHANGELOG_PATH.exists():
- print("CHANGELOG.md not found", file=sys.stderr)
- sys.exit(1)
+def append_changelog(
+ pr_number: int,
+ pr_title: str,
+ pr_body: str,
+ labels: list[str] | None = None,
+) -> bool:
original = CHANGELOG_PATH.read_text(encoding="utf-8")
- unreleased = _extract_unreleased_sections(original)
- copy_blocks = _collect_readme_copy_blocks(_unreleased_raw(original))
- if not unreleased and not copy_blocks:
- print("No [Unreleased] entries to curate.")
+ # Historical entries without authored copy remain readable and idempotent.
+ if _pr_already_present(original, pr_number):
+ if not _pr_already_present(_unreleased_raw(original), pr_number):
+ return False
+ groups = parse_pr_readme_copy(pr_body)
+ impact, _ = _resolve_pr_tags(labels)
+ if groups and impact != "no-user-impact" and pr_number not in _locked_readme_groups_from_changelog(original)[1]:
+ updated = _upsert_readme_copy_block(original, pr_number, groups)
+ if updated != original:
+ CHANGELOG_PATH.write_text(updated, encoding="utf-8")
+ return True
+ print(f"CHANGELOG already contains PR #{pr_number}; no entry rewritten.")
return False
+ impact, backend, entries, groups = validate_release_copy(pr_title, pr_body, labels)
+ updated, changed = _update_changelog(
+ original, entries, pr_number,
+ impact=impact, backend_change=backend, copy_locked=True,
+ )
+ if groups:
+ updated = _upsert_readme_copy_block(updated, pr_number, groups)
+ if changed:
+ CHANGELOG_PATH.write_text(updated, encoding="utf-8")
+ print(f"Stored authored release copy for PR #{pr_number}.")
+ return changed
- locked, unlocked = _partition_locked_sections(unreleased)
- if not any(unlocked.values()):
- print("All [Unreleased] bullets are copy-locked; skipping Groq CHANGELOG curation.")
- curated = _sort_section_bullets(locked)
- else:
- print("Curating unlocked CHANGELOG bullets with Groq; preserving copy-locked bullets.")
- groq_curated = _groq_curate_changelog_unreleased(api_key, unlocked)
- if not groq_curated and not any(locked.values()):
- print("CHANGELOG curation produced no entries.")
- return False
- curated = _merge_locked_and_curated(locked, groq_curated or {})
- updated = _replace_unreleased_sections(original, curated, copy_blocks=copy_blocks)
+def sync_changelog_unreleased() -> bool:
+ original = CHANGELOG_PATH.read_text(encoding="utf-8")
+ sections = _extract_unreleased_sections(original)
+ copy_blocks = _collect_readme_copy_blocks(_unreleased_raw(original))
+ updated = _replace_unreleased_sections(
+ original, _sort_section_bullets(sections), copy_blocks=copy_blocks,
+ )
if updated != original:
CHANGELOG_PATH.write_text(updated, encoding="utf-8")
- print("Re-grouped CHANGELOG.md [Unreleased] section.")
+ print("Ordered CHANGELOG bullets by category, impact and PR; wording unchanged.")
return True
-
- print("No CHANGELOG changes written.")
return False
-def sync_readme_upcoming(api_key: str) -> bool:
- if not CHANGELOG_PATH.exists():
- print("CHANGELOG.md not found", file=sys.stderr)
- sys.exit(1)
- if not README_PATH.exists():
- print("README.md not found", file=sys.stderr)
- sys.exit(1)
+def reviewed_readme_groups(content: str) -> list[dict[str, list[str]]]:
+ groups, copied_prs = _locked_readme_groups_from_changelog(content)
+ clusters = _cluster_sections_by_pr(_extract_unreleased_sections(content))
+ missing = [c.pr_number for c in clusters if _readme_eligible(c) and c.pr_number not in copied_prs]
+ if missing:
+ names = ", ".join(f"#{number}" if number is not None else "an unlinked entry" for number in missing)
+ raise ValueError(
+ "Missing listener release copy for " + names + ". Fill each PR's "
+ "release-copy:readme region, run backfill-changelog for that PR, then retry. "
+ "No README entries were rewritten or omitted."
+ )
+ return groups
- readme_original = README_PATH.read_text(encoding="utf-8")
- changelog_content = CHANGELOG_PATH.read_text(encoding="utf-8")
- unreleased = _extract_unreleased_sections(changelog_content)
- if not unreleased:
- print("No [Unreleased] entries; clearing README upcoming section.")
- readme_updated = _update_readme(readme_original)
- else:
- locked_groups, locked_prs = _locked_readme_groups_from_changelog(changelog_content)
- unlocked = _sections_excluding_prs(unreleased, locked_prs)
- unlocked_clusters = [c for c in _cluster_sections_by_pr(unlocked) if _readme_eligible(c)]
- ai_groups: list[dict[str, list[str]]] = []
- if unlocked_clusters:
- print("Curating README from unlocked [Unreleased] changelog entries...")
- ai_groups = _groq_curate_readme_upcoming(api_key, unlocked)
- if not ai_groups:
- print("Grouped curation empty; falling back to cluster bullets.")
- ai_groups = _fallback_readme_from_clusters(unlocked_clusters)
- ai_groups = _drop_prs_from_readme_groups(ai_groups, locked_prs)
- elif locked_groups:
- print("Using verbatim README copy; skipping Groq README curation.")
- else:
- all_clusters = [c for c in _cluster_sections_by_pr(unreleased) if _readme_eligible(c)]
- if all_clusters:
- print("Curating README from all [Unreleased] changelog entries...")
- ai_groups = _groq_curate_readme_upcoming(api_key, unreleased)
- if not ai_groups:
- print("Grouped curation empty; falling back to cluster bullets.")
- ai_groups = _fallback_readme_from_clusters(all_clusters)
-
- groups = _merge_readme_groups(locked_groups, ai_groups)
- include_ai_notice = bool(ai_groups)
- if groups:
- readme_updated = _update_readme(
- readme_original,
- groups=groups,
- include_ai_notice=include_ai_notice,
- )
- else:
- readme_updated = _update_readme(readme_original)
-
- if readme_updated != readme_original:
- README_PATH.write_text(readme_updated, encoding="utf-8")
- print("Synced README.md Upcoming Changes section.")
+def sync_readme_upcoming() -> bool:
+ original = README_PATH.read_text(encoding="utf-8")
+ groups = reviewed_readme_groups(CHANGELOG_PATH.read_text(encoding="utf-8"))
+ updated = _update_readme(original, groups=groups, include_ai_notice=False)
+ if updated != original:
+ README_PATH.write_text(updated, encoding="utf-8")
+ print("Synced authored README copy; category order and impact determine placement.")
return True
-
- print("No README changes written.")
return False
@@ -1869,8 +1241,8 @@ def main() -> None:
"command",
nargs="?",
default="all",
- choices=("all", "append", "sync-changelog", "sync-readme"),
- help="append: write PR to CHANGELOG; sync-changelog: re-group [Unreleased]; sync-readme: curate README; all: append + sync-changelog + sync-readme",
+ choices=("all", "append", "sync-changelog", "sync-readme", "validate-copy"),
+ help="append: write PR to CHANGELOG; sync-changelog: re-group [Unreleased]; sync-readme: order authored README copy; validate-copy: check a PR event; all: append + sync-changelog + sync-readme",
)
parser.add_argument(
"--pr-json",
@@ -1878,9 +1250,18 @@ def main() -> None:
)
args = parser.parse_args()
- api_key = _require_env("GROQ_API_KEY")
+ if args.command == "validate-copy":
+ event = json.loads(Path(_require_env("GITHUB_EVENT_PATH")).read_text(encoding="utf-8"))
+ pull = event["pull_request"]
+ title = str(pull.get("title") or "")
+ if "[skip changelog]" in title or str(pull.get("head", {}).get("ref", "")).startswith("release/v"):
+ print("Release/tooling PR opts out of changelog copy.")
+ return
+ validate_release_copy(title, str(pull.get("body") or ""), _labels_from_env_or_payload(pull))
+ print("Release copy is ready: authored wording will be preserved.")
+ return
changelog_changed = False
- changelog_curated = False
+ changelog_ordered = False
readme_changed = False
if args.command in ("all", "append"):
@@ -1892,18 +1273,22 @@ def main() -> None:
pr_body = os.environ.get("PR_BODY", "")
labels = _labels_from_env_or_payload()
changelog_changed = append_changelog(
- api_key, pr_number, pr_title, pr_body, labels=labels
+ pr_number, pr_title, pr_body, labels=labels
)
if args.command in ("all", "sync-changelog"):
- changelog_curated = sync_changelog_unreleased(api_key)
+ changelog_ordered = sync_changelog_unreleased()
if args.command in ("all", "sync-readme"):
- readme_changed = sync_readme_upcoming(api_key)
+ readme_changed = sync_readme_upcoming()
- if not changelog_changed and not changelog_curated and not readme_changed:
+ if not changelog_changed and not changelog_ordered and not readme_changed:
print("No CHANGELOG or README changes written.")
if __name__ == "__main__":
- main()
+ try:
+ main()
+ except (ValueError, OSError, KeyError) as error:
+ print(f"Release copy check failed: {error}", file=sys.stderr)
+ sys.exit(1)
diff --git a/.github/workflows/release-copy.yml b/.github/workflows/release-copy.yml
new file mode 100644
index 000000000..4c4d49e71
--- /dev/null
+++ b/.github/workflows/release-copy.yml
@@ -0,0 +1,23 @@
+name: Release copy
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, edited, labeled, unlabeled, ready_for_review]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: release-copy-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ validate:
+ name: Authored release copy
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
+ - name: Check impact label and exact release wording
+ run: python3 .github/scripts/update_changelog.py validate-copy
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index c600d6080..4d8a2675b 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -56,6 +56,12 @@ jobs:
with:
persist-credentials: false
+ - name: Set up Node for announcement preview checks
+ uses: actions/setup-node@v5
+ with:
+ node-version: '22'
+ - name: Announcement composer contract checks
+ run: node --test .github/scripts/test_announcement_admin.cjs
- name: Changelog and release-note script tests
run: python3 -m unittest discover -s .github/scripts -p 'test_*.py' -v
diff --git a/.gitignore b/.gitignore
index 38ea77be6..3cd9f1b8e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,7 +42,18 @@ bench_node.js
proxy/
# Sub-projects (local only)
-admin-panel/
+# Composer sources are public; the existing gateway and configuration stay local.
+admin-panel/*
+!admin-panel/README.md
+!admin-panel/public/
+admin-panel/public/*
+!admin-panel/public/notifyappusers/
+admin-panel/public/notifyappusers/*
+!admin-panel/public/notifyappusers/dashboard.html
+!admin-panel/public/notifyappusers/dashboard.js
+!admin-panel/public/notifyappusers/composer.css
+!admin-panel/public/notifyappusers/preview.html
+!admin-panel/public/notifyappusers/announcements/
telemetry-worker/
# Local website checkout; keep the rest of docs/ tracked.
diff --git a/admin-panel/README.md b/admin-panel/README.md
new file mode 100644
index 000000000..702c7cf06
--- /dev/null
+++ b/admin-panel/README.md
@@ -0,0 +1,19 @@
+# Announcement composer
+
+## Purpose
+
+The static composer creates legacy-compatible FCM announcement drafts, previews the native app's visual design, and requires explicit review before a workflow request. The existing Google/password/TOTP gateway and its encrypted configuration remain local and unchanged. The composer never stores or reads sender credentials beyond the gateway's existing in-memory session.
+
+## Behavior
+
+- Android renders compact and full-screen announcements in native Compose. The browser preview mirrors their Material 3 surfaces, typography, spacing and actions, using the same Google Sans font. Preview dimensions are content dp excluding system bars; selected Classic colors and text scale match that profile. The surrounding app page is omitted. Actual custom themes, system bars and system notifications are device-owned and cannot be represented as a universal screenshot.
+- Accent roles, banner/uncropped cover, Markdown headings, callouts, bullets and emphasis are controlled presentation options. Raw HTML is text. The native app formats text and invokes its already-parsed actions; it has no embedded browser or JavaScript bridge.
+- Drafts default to isolated test builds and provider validation. Test mode rejects production audiences. A reviewed draft is snapshotted, and edits invalidate approval. Its SHA-256 digest is checked again by the workflow before real delivery. Workflow acceptance is not device delivery confirmation.
+- In-app release alerts always have Download update, View on GitHub and Dismiss. Download enters the native updater; View on GitHub preserves the alert on return. Content, Appearance and Delivery are separate editor tabs, and the final review shows the complete selected viewport and delivery details. Narrow screens open at the preview and scroll to approval; notification-only delivery shows its exact text instead of an in-app alert. Android owns the system notification layout. Ordinary announcements can reach both install channels. GitHub release announcements normally target direct installs, with an explicit manual include-Play override. Release delivery uses the new direct_users audience, excluding Play/test topics; older clients on prod_users are not classifiable and are not included until updated. Automatic publication produces a review artifact, never sends automatically; import that artifact into the composer to edit, review and approve.
+- Saved drafts are local browser data. Existing gateway configuration, tokens, backups and hosting configuration remain ignored; deploying this panel is separate from pushing Android changes.
+
+## Development and verification
+
+Edit browser presentation assets here when the native visual specification changes. Run `python3 .github/scripts/sync_announcement_preview.py` after app-font changes; `--check` checks the font only. Verify style and layout against phone screenshots for compact/full-screen, light/dark and enlarged text. Browser previews exclude Android system bars and cannot promise identical rendering across devices. Serve this directory locally and open `preview.html` for an unauthenticated offline composer; without a gateway session it cannot dispatch. A `preview.html` screen never grants sender access.
+
+Run `node --test .github/scripts/test_announcement_admin.cjs` and the Python release-tooling suite. Test preview/digest parity, edited approval, UTF-8 bounds, test audience rejection and both large-text layouts. Native JVM tests cover persistence, legacy fallback, audience/version filtering and process/permission return. Device acceptance is still required for Android permission and installation prompts.
diff --git a/admin-panel/public/notifyappusers/announcements/font.ttf b/admin-panel/public/notifyappusers/announcements/font.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..0ca1de3530320c843f623d27d94e54bc332dcc7c
GIT binary patch
literal 4153300
zcmcG12YeMp*Z!HgB|VS^353g~C832hAaoK4y;ns90tpZZN!}ES1rfo54Lf2(Pz1yR
zqGAORv4J9DM*%^4lTMPm^MB6H-sA?+_w)DvzAVq3*_qkdDQD)KIdf(qK}1pbvD1*u
zjNw_!ZhfggkvM|T5xsk6^;#9`o
%Jj@Oy^Yj*{rLgyeG%^d3Gk^Mh@#
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