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..fc92d921b --- /dev/null +++ b/.github/scripts/test_announcement_admin.cjs @@ -0,0 +1,89 @@ +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('preview sizing isolates the legacy gateway frame and fits the entire viewport',()=>{ + const vm=require('node:vm'),fs=require('node:fs'),path=require('node:path'); + const root=path.join(__dirname,'../../admin-panel/public/notifyappusers'); + const html=fs.readFileSync(path.join(root,'dashboard.html'),'utf8'); + const css=fs.readFileSync(path.join(root,'composer.css'),'utf8'); + assert.ok(html.includes('class="preview-phone"')); + assert.ok(css.includes('.preview-phone{')); + assert.ok(!html.includes('class="phone-frame"')); + const device={style:{}},frame={style:{width:'260px',height:'520px'}}; + const stage={clientWidth:420,clientHeight:650,querySelector:selector=>({'.preview-device':device,'.preview-phone':frame})[selector]}; + const nodes={previewStage:stage,previewWidth:{value:'393'},previewHeight:{value:'600'},previewZoom:{value:'fit'},reviewDialog:{open:false}}; + const context=vm.createContext({TextEncoder,document:{getElementById:id=>nodes[id]}}); + vm.runInContext(fs.readFileSync(path.join(root,'dashboard.js'),'utf8'),context); + for(const width of [360,393,430])for(const height of [600,740,850]){ + nodes.previewWidth.value=String(width);nodes.previewHeight.value=String(height);context.fitPreview(); + assert.equal(frame.style.width,(width+18)+'px');assert.equal(frame.style.height,(height+18)+'px'); + assert.ok(parseFloat(device.style.width)<=stage.clientWidth-28); + assert.ok(parseFloat(device.style.height)<=stage.clientHeight-28); + } + stage.clientWidth=0;stage.clientHeight=0;context.fitPreview(); + assert.equal(frame.style.transform,'scale(0)'); + nodes.previewZoom.value='1';context.fitPreview(); + assert.equal(frame.style.transform,'scale(1)'); + assert.equal(device.style.width,'448px');assert.equal(device.style.height,'868px'); +}); + +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)},location:{origin:'https://boxcasts.web.app'},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); +}); + + +test('incomplete URLs keep the composer guidance instead of a browser TypeError',()=>{ + for(const release_url of ['github.com/x','https://'])assert.throws(()=>plan({...base(),release_alert:'true',release_url}),{message:'Use a boxlore GitHub release page.'}); + for(const image of ['example.com/image.png','https://'])assert.throws(()=>plan({...base(),image}),{message:'Use an HTTPS image URL without embedded credentials.'}); +}); + +test('preview messages require the same origin and the expected parent',()=>{ + const vm=require('node:vm'),fs=require('node:fs'),path=require('node:path'); + const origin='https://boxcasts.web.app',handlers={},sent=[],rendered=[]; + const parent={postMessage:(message,target)=>sent.push({message,target})}; + const context=vm.createContext({location:{origin},window:{parent,addEventListener:(name,handler)=>handlers[name]=handler}}); + vm.runInContext(fs.readFileSync(path.join(__dirname,'../../admin-panel/public/notifyappusers/announcements/renderer.js'),'utf8'),context); + context.window.renderAnnouncement=(payload)=>rendered.push(payload); + const data={source:'boxlore-preview',payload:{title:'Test'}}; + handlers.message({origin:'https://other.example',source:parent,data}); + handlers.message({origin,source:{},data}); + handlers.message({origin,source:parent,data:{...data,source:'other'}}); + assert.equal(rendered.length,0); + handlers.message({origin,source:parent,data}); + assert.equal(rendered.length,1);assert.equal(rendered[0].title,'Test'); + context.notify('dismiss');assert.equal(sent.length,2); + assert.ok(sent.every(message=>message.target===origin)); +}); diff --git a/.github/scripts/test_release_copy_validation.py b/.github/scripts/test_release_copy_validation.py new file mode 100644 index 000000000..709ee6121 --- /dev/null +++ b/.github/scripts/test_release_copy_validation.py @@ -0,0 +1,167 @@ +"""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_legacy_labels_follow_the_same_normalization_as_tag_resolution(self): + for labels in (["user-impact"], ["user-impact-high", "backend-change"], ["backend-fix"]): + with self.subTest(labels=labels): + impact, backend, _, _ = uc.validate_release_copy("fix: example", SAMPLE_PR_BODY, labels) + self.assertEqual((impact, backend), uc._resolve_pr_tags(labels)) + + def test_multiple_impact_labels_rejected_even_when_aliases_match(self): + for labels in (["user-impact", "user-impact-high"], ["user-impact", "user-impact-high", "backend-change"], ["user-impact-high", "user-impact-high"]): + with self.subTest(labels=labels), self.assertRaisesRegex(ValueError, "exactly one"): + uc.validate_release_copy("fix: example", SAMPLE_PR_BODY, labels) + + def test_conflicting_legacy_impact_is_not_silently_ignored(self): + with self.assertRaisesRegex(ValueError, "exactly one"): + uc.validate_release_copy("fix: example", SAMPLE_PR_BODY, ["backend-fix", "user-impact-high"]) + + 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..b3aaea3b3 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,130 @@ 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 = [ + impact + for label in labels or [] + if _normalize_token(label) != "backend-change" + and (impact := _normalize_user_impact(label)) + ] + 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 = impacts[0] + 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 +1246,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 +1255,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 +1278,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..d9c7067cf --- /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. The preview frame has its own scoped styling and explicit dimensions, so legacy gateway phone styles cannot crop its content. 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. Preview messages are accepted only from the same-origin parent; malformed URLs retain field-specific guidance. 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 000000000..0ca1de353 Binary files /dev/null and b/admin-panel/public/notifyappusers/announcements/font.ttf differ diff --git a/admin-panel/public/notifyappusers/announcements/renderer.css b/admin-panel/public/notifyappusers/announcements/renderer.css new file mode 100644 index 000000000..6b2d516a2 --- /dev/null +++ b/admin-panel/public/notifyappusers/announcements/renderer.css @@ -0,0 +1,21 @@ +@font-face{font-family:Boxlore;src:url(font.ttf);font-weight:100 1000;font-display:swap} +:root{--surface:#fff;--container:#f0eef9;--text:#1b1b24;--muted:#62616f;--accent:#5145d8;--on-accent:#fff;--accent-container:#e5dfff;--on-container:#21156b;--scale:1;--roundness:100} +*{box-sizing:border-box}html,body{width:100%;height:100%;margin:0}body{font-family:Boxlore,sans-serif;font-variation-settings:'ROND' var(--roundness);background:rgba(0,0,0,.38);color:var(--text);display:flex;align-items:center;justify-content:center;padding:20px} +#alert{box-shadow:0 3px 16px rgba(0,0,0,.12);visibility:hidden;background:var(--container);border-radius:28px;width:min(100%,560px);max-height:90vh;display:flex;flex-direction:column;overflow:hidden} +header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 16px 0 24px;flex-shrink:0} +#badge{font-size:calc(12px * var(--scale));font-weight:500;line-height:1.4;color:var(--on-container);background:var(--accent-container);border-radius:12px;padding:8px 12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:calc(100% - 52px)} +button{font:inherit;font-size:calc(14px * var(--scale));font-weight:500;line-height:calc(20px * var(--scale));min-height:48px;border:0;border-radius:999px;padding:8px 24px;cursor:pointer;color:var(--accent);background:transparent;overflow-wrap:anywhere}button:focus-visible,#content:focus-visible{outline:3px solid var(--accent);outline-offset:-3px} +#close{font-size:28px;line-height:1;width:48px;min-width:48px;padding:0;color:var(--text)} +#content{padding:12px 24px;overflow:auto;min-height:0;overscroll-behavior:contain;flex:1} +h1{font-size:calc(24px * var(--scale));line-height:1.33;font-weight:500;margin:0 0 16px;overflow-wrap:anywhere;letter-spacing:-.4px} +#body{color:var(--muted);font-size:calc(16px * var(--scale));line-height:1.5;overflow-wrap:anywhere}p{margin:0 0 12px}h2{font-size:calc(22px * var(--scale));line-height:1.3;margin:8px 0 12px;font-weight:500}ul{padding-inline-start:22px;padding-inline-end:0;margin:0 0 16px}li{margin-bottom:8px}blockquote{margin:16px 0;padding:16px;border-radius:20px;background:var(--accent-container);color:var(--on-container)} +#image{display:none;width:100%;max-height:200px;object-fit:cover;border-radius:20px;margin:0 0 18px;aspect-ratio:16/9}#image.cover{max-height:260px;aspect-ratio:1;object-fit:contain} +footer{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;padding:12px 20px;flex-shrink:0}#action,#download{background:var(--accent);color:var(--on-accent)} +button[hidden]{display:none!important}footer.release{display:grid;grid-template-columns:1fr;gap:8px;padding:16px 24px}footer.release #download{width:100%;min-height:48px}footer.release #github{background:var(--tonal-container,var(--accent-container));color:var(--on-tonal-container,var(--on-container))}footer.release #dismiss{justify-self:center;min-height:48px;padding:8px 20px} +body.fullscreen{padding:0;background:var(--surface);align-items:stretch}body.fullscreen #alert{box-shadow:none;width:100%;max-width:none;border-radius:0;background:var(--surface);height:100%;max-height:100%}body.fullscreen header{padding:12px 16px 0 24px}body.fullscreen #content{padding:12px 24px}body.fullscreen h1{font-size:calc(28px * var(--scale));margin-bottom:16px}body.fullscreen footer{padding:16px 24px;background:var(--surface)} +@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}} + +footer:not(.release) #dismiss{order:0}footer:not(.release) #action{order:1} + +html{-webkit-text-size-adjust:100%;text-size-adjust:100%}h2[data-level="2"]{font-size:calc(16px * var(--scale))}h2[data-level="3"],h2[data-level="4"],h2[data-level="5"],h2[data-level="6"]{font-size:calc(14px * var(--scale))}p:last-child{margin-bottom:0} +#body{display:flex;flex-direction:column;gap:12px}#body p{margin:0;white-space:pre-wrap}#body h2{margin:8px 0 0;color:var(--text)}#body blockquote{margin:0;border-radius:16px;white-space:pre-wrap}.list-row{display:flex;align-items:flex-start;gap:10px}.list-row p{flex:1;min-width:0}#body a{color:var(--accent);text-decoration:none}#body blockquote a{color:var(--on-container)}#body code{font-family:monospace;font-size:1em} diff --git a/admin-panel/public/notifyappusers/announcements/renderer.html b/admin-panel/public/notifyappusers/announcements/renderer.html new file mode 100644 index 000000000..c783b3679 --- /dev/null +++ b/admin-panel/public/notifyappusers/announcements/renderer.html @@ -0,0 +1,9 @@ + + + + +
+
+

+
+
diff --git a/admin-panel/public/notifyappusers/announcements/renderer.js b/admin-panel/public/notifyappusers/announcements/renderer.js new file mode 100644 index 000000000..3e2832812 --- /dev/null +++ b/admin-panel/public/notifyappusers/announcements/renderer.js @@ -0,0 +1,76 @@ +'use strict'; +// Content is constructed as text nodes. Payloads never become HTML, CSS or JavaScript. +function inline(parent, text) { + const pattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*(.+?)\*\*|__(.+?)__|\*(.+?)\*|_(.+?)_|`([^`]+)`/g; + let end = 0; + for (const match of text.matchAll(pattern)) { + parent.append(document.createTextNode(text.slice(end, match.index))); + const tag = match[1] !== undefined ? 'a' : match[3] !== undefined || match[4] !== undefined ? 'strong' : match[5] !== undefined || match[6] !== undefined ? 'em' : 'code'; + const node = document.createElement(tag); + if (tag === 'a') { node.href = match[2]; node.target = '_blank'; node.rel = 'noopener noreferrer'; } + node.textContent = match[1] ?? match[3] ?? match[4] ?? match[5] ?? match[6] ?? match[7]; + parent.append(node); end = match.index + match[0].length; + } + parent.append(document.createTextNode(text.slice(end))); +} +function content(parent, text) { + parent.replaceChildren(); let paragraph = []; + function flush() { if (paragraph.length) { const item = document.createElement('p'); inline(item, paragraph.join('\n')); parent.append(item); paragraph = []; } } + for (const line of String(text || '').replace(/\r\n?/g, '\n').split('\n')) { + const value = line.trim(); if (!value) { flush(); continue; } + const heading = /^(#{1,6})\s+(.+)$/.exec(value); + const bullet = /^(?:[-*+•]|(\d+)[.)])\s+(.+)$/.exec(value); + const callout = /^>\s?(.*)$/.exec(value); + if (heading || callout) { + flush(); const item = document.createElement(heading ? 'h2' : 'blockquote'); + if (heading) item.dataset.level = String(heading[1].length); + inline(item, heading?.[2] ?? callout[1]); parent.append(item); + } else if (bullet) { + flush(); const item = document.createElement('div'); item.className = 'list-row'; + const marker = document.createElement('span'); marker.textContent = bullet[1] ? bullet[1] + '.' : '•'; + const copy = document.createElement('p'); inline(copy, bullet[2]); item.append(marker, copy); parent.append(item); + } else paragraph.push(line); + } + flush(); +} +function notify(action) { + window.parent.postMessage({source:'boxlore-announcement', action}, location.origin); +} +window.renderAnnouncement = function (payload, profile = {}) { + const colors = profile.colors || {}; + for (const [key, value] of Object.entries(colors)) { + if (/^(surface|container|text|muted|accent|on-accent|accent-container|on-container|tonal-container|on-tonal-container)$/.test(key) && /^#[0-9a-f]{6}$/i.test(value)) document.documentElement.style.setProperty('--' + key, value); + } + document.documentElement.style.setProperty('--scale', String(Math.min(2, Math.max(.85, Number(profile.fontScale) || 1)))); + document.documentElement.style.setProperty('--roundness', String(Math.min(100, Math.max(0, Number(profile.roundness ?? 100))))); + document.documentElement.dir = profile.rtl ? 'rtl' : 'ltr'; + document.body.classList.toggle('fullscreen', payload.presentation === 'fullscreen'); + document.getElementById('badge').textContent = payload.category || 'ANNOUNCEMENT'; + document.getElementById('title').textContent = payload.title || ''; + const body = document.getElementById('body'); + if (body.dataset.copy !== String(payload.body || '')) { content(body, payload.body); body.dataset.copy = String(payload.body || ''); } + const image = document.getElementById('image'); + let url; try { url = new URL(payload.image); } catch (_) { /* no image */ } + image.className = payload.image_style === 'cover' ? 'cover' : ''; + image.style.display = url?.protocol === 'https:' && !url.username && !url.password ? 'block' : 'none'; + image.onerror = () => { image.style.display = 'none'; }; + if (image.style.display === 'block') image.src = url.href; else image.removeAttribute('src'); + const action = document.getElementById('action'); + action.textContent = payload.action_label || 'Open'; + const release = payload.release_alert === 'true' || (payload.release_alert == null && /^(WHAT[’']?S NEW|NEW RELEASE)$/i.test(payload.category || '')); + document.querySelector('footer').classList.toggle('release', release); + action.hidden = release || payload.show_action_in_app === 'false' || !payload.route; + const download = document.getElementById('download'); + download.hidden = !release; download.textContent = profile.play ? (profile.playLabel || 'Update on Google Play') : (profile.downloadLabel || 'Download update'); + download.onclick = () => notify('download'); + const github = document.getElementById('github'); + github.hidden = !release; github.textContent = profile.githubLabel || 'View on GitHub'; + github.onclick = () => notify('github'); + action.onclick = () => notify('action'); + document.getElementById('close').onclick = document.getElementById('dismiss').onclick = () => notify('dismiss'); + document.getElementById('close').ariaLabel = profile.closeLabel || 'Close announcement'; + document.getElementById('dismiss').textContent = profile.dismissLabel || 'Dismiss'; + document.getElementById('alert').style.visibility = 'visible'; +}; +window.addEventListener('message', event => { if (event.origin === location.origin && event.source === window.parent && event.data?.source === 'boxlore-preview') window.renderAnnouncement(event.data.payload, event.data.profile); }); +window.parent.postMessage({source:'boxlore-announcement', action:'ready'}, location.origin); diff --git a/admin-panel/public/notifyappusers/composer.css b/admin-panel/public/notifyappusers/composer.css new file mode 100644 index 000000000..f6aee215d --- /dev/null +++ b/admin-panel/public/notifyappusers/composer.css @@ -0,0 +1,6 @@ +@font-face{font-family:Boxlore;src:url(announcements/font.ttf);font-weight:100 1000;font-display:swap} +.composer,.composer *{box-sizing:border-box}.composer{padding:24px 32px;margin:auto;max-width:1440px;color:#e6e6ee;font:15px/1.5 Boxlore,system-ui,sans-serif;font-variation-settings:'ROND' 100}.composer-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.composer h1{font-size:25px;font-weight:600;letter-spacing:-.5px;margin:0}.composer h2{font-size:18px;font-weight:600;margin:0 0 12px}.composer-header p,.review-head p{font-size:12px;color:#b4afc8;margin:0 0 4px}.composer-grid{display:grid;grid-template-columns:minmax(360px,550px) minmax(420px,1fr);gap:32px}.editor,.preview{min-width:0}.editor form{background:#1b1c24;border:1px solid #33343e;border-radius:24px;overflow:hidden;display:flex;flex-direction:column;height:calc(100dvh - 176px);min-height:600px}.kind-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:18px 24px}.kind-row label{margin:0}.kind-row select{max-width:240px}.editor-tabs{display:flex;gap:4px;padding:0 18px 12px;border-bottom:1px solid #33343e}.editor-tabs button{flex:1;background:transparent;color:#c8c5d3;border-radius:12px;margin:0}.editor-tabs .selected{background:#3f347b;color:#ece7ff}.editor-scroll{overflow:auto;flex:1;padding:4px 24px 24px}.composer label{display:block;font-size:13px;font-weight:600;margin:16px 0 6px}.composer input:not([type=checkbox]),.composer textarea,.composer select{width:100%;background:#272832;border:1px solid #4a4a58;border-radius:12px;padding:11px 12px;color:inherit;font:inherit;min-width:0}.composer input[type=checkbox]{width:18px;height:18px;accent-color:#9c8cff;flex-shrink:0}.composer textarea{resize:vertical;min-height:220px}.field-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:14px}.hint{font-size:12px;line-height:1.5;color:#b5b1c3;margin:8px 0 14px}.composer button,.file-button{font:600 14px/1.4 Boxlore,system-ui;min-height:44px;padding:10px 16px;border:0;border-radius:22px;color:#ece7ff;background:#30313e;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:8px}.composer button:focus-visible,.composer input:focus-visible,.composer select:focus-visible,.composer textarea:focus-visible{outline:3px solid #c6baff;outline-offset:2px}.composer button:disabled{opacity:.55;cursor:wait}.quiet{background:transparent!important}.check{display:flex!important;align-items:flex-start;gap:10px;font-weight:400!important}.check input{margin:2px 0 0}.format-tools{display:flex;gap:4px;margin:8px 0}.format-tools button{min-height:32px;padding:5px 10px;border-radius:8px;font-size:12px}.editor-actions{flex-shrink:0;padding:16px 24px;background:#20212b;border-top:1px solid #33343e;display:flex;align-items:center;justify-content:space-between;gap:12px}.editor-actions .hint{margin:0}#sendBtn,#confirmSend{background:#9c8cff;color:#21164b}.editor-actions p{margin:4px 0;font-size:12px;max-width:260px}#statusText,#reviewError{color:#f2bf8d}.composer details summary{cursor:pointer;font-size:13px;color:#d1cadd;list-style:none}.composer details summary::after{content:' +';color:#9c8cff}.composer details[open] summary::after{content:' −'}.composer details{margin:20px 0}.draft-tools{padding:14px 20px;background:#1b1c24;border:1px solid #33343e;border-radius:18px}.draft-tools .file-button{margin:0;font-size:13px}.file-button input{max-width:1px!important;width:1px!important;opacity:0;position:absolute}.preview{height:calc(100dvh - 108px);min-height:620px;display:flex;flex-direction:column}.preview-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-shrink:0;position:relative}.preview-head .hint{margin:0}.preview-options{margin:0!important;position:relative}.preview-options[open]{position:absolute;right:0;top:0;z-index:5;width:330px;border:1px solid #4a4a58;border-radius:18px;background:#20212b;padding:16px;box-shadow:0 12px 48px #0008}.preview-options:not([open]) summary{padding:8px 12px;background:#272832;border-radius:14px}.preview-options[open] summary{text-align:right}.preview-options label{margin-top:12px}#previewStage{flex:1;min-height:0;overflow:auto;display:flex;align-items:center;justify-content:center;margin:16px 0 8px;background:#191a22;border:1px solid #33343e;border-radius:24px;padding:12px}.preview-device{position:relative;flex-shrink:0}.preview-phone{position:absolute;top:0;left:0;padding:8px;background:#090a0e;border:1px solid #44434f;border-radius:30px;transform-origin:top left;flex-shrink:0}.composer iframe{border:0;display:block;flex-shrink:0}.preview-phone iframe{border-radius:22px}.preview-caption{font-size:12px;color:#b5b1c3;margin:0;text-align:center;flex-shrink:0}.preview-explainer{margin:10px 0 0!important;flex-shrink:0}.preview-explainer summary{text-align:center;font-size:12px!important}#pushBody{white-space:pre-wrap;overflow-wrap:anywhere}#templatesList button{margin:8px 4px 0 0}#historyList{font-size:12px}#historyList a{color:#c6baff;display:block;margin:10px 0}.composer dialog{color:#e6e6ee;background:#1b1c24;border:1px solid #4a4a58;border-radius:24px;padding:24px;max-height:94dvh;overflow:auto;width:min(100% - 40px,1000px)}.composer dialog::backdrop{background:#000b}.review-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.review-head h2{margin:0}.review-head button{font-size:24px;background:transparent}.review-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:32px;align-items:start}#reviewStage{display:flex;justify-content:center;align-items:flex-start;min-height:0;overflow:auto}#reviewPreview{max-width:none;border-radius:20px;transform-origin:top center}.review-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}.review-grid dl{font-size:13px;background:#272832;border-radius:18px;padding:20px;margin:20px 0}.review-grid dt{color:#b5b1c3}.review-grid dd{margin:0 0 16px;overflow-wrap:anywhere}.review-grid dd:last-child{margin-bottom:0}[hidden]{display:none!important}@media(max-width:920px){.composer{padding:16px}.composer-grid{grid-template-columns:1fr}.editor form{height:auto;min-height:0;max-height:85dvh}.preview{height:850px;max-height:100dvh}.review-grid{grid-template-columns:1fr}#reviewStage{max-height:55dvh}.composer-header h1{font-size:21px}.composer-grid{gap:24px}} + +#reviewStage{position:relative}.excluded-preview{position:absolute;inset:8px;display:flex;align-items:center;justify-content:center;padding:32px;text-align:center;background:#121318;color:#e6e6ee;border-radius:22px;margin:0;font-size:16px;line-height:1.5} + +.review-push-copy{padding:20px;border-radius:18px;background:#272832}#reviewPushBody{white-space:pre-wrap;overflow-wrap:anywhere} diff --git a/admin-panel/public/notifyappusers/dashboard.html b/admin-panel/public/notifyappusers/dashboard.html new file mode 100644 index 000000000..0171b5dc3 --- /dev/null +++ b/admin-panel/public/notifyappusers/dashboard.html @@ -0,0 +1,51 @@ + +
+

boxlore / Announcements

Compose an announcement

+
+
+
+
+ +
+
+ +
+ +

Headings, lists, callouts and emphasis are supported. HTML appears as text.

+ +
+ +
+ + +
+

+
+
Drafts and workflow runs
+
+
+

App preview

Classic light · 393 × 740 dp

Preview settings
+
+

Live app layout. Preview controls don’t send messages or download files.

+
What this preview covers

The selected colors, lettering, content dimensions and text size use the app’s renderer. System bars are excluded. Custom themes and Android system notifications vary by phone.

+
+
+

Final review

Review before sending

+
diff --git a/admin-panel/public/notifyappusers/dashboard.js b/admin-panel/public/notifyappusers/dashboard.js new file mode 100644 index 000000000..7b220a8ea --- /dev/null +++ b/admin-panel/public/notifyappusers/dashboard.js @@ -0,0 +1,60 @@ +'use strict'; +const REPOSITORY = 'boxcreate/boxlore'; +const WORKFLOW = `https://api.github.com/repos/${REPOSITORY}/actions/workflows/send-notification.yml`; +const FIELDS = {title:'inputTitle',body:'inputBody',type:'inputType',route:'inputRoute',image:'inputImage',target:'inputTarget',collapse_key:'inputCollapseKey',sound:'inputSound',action_label:'inputActionLabel',category:'inputCategory',presentation:'inputPresentation',tone:'inputTone',image_style:'inputImageStyle',release_version_code:'inputReleaseCode',release_url:'inputReleaseUrl'}; +const BOOLS = {dry_run:'inputDryRun',test_mode:'inputTestMode',show_action_in_push:'inputShowPush',show_action_in_app:'inputShowApp',release_alert:'inputReleaseAlert',include_play:'inputIncludePlay'}; +let reviewedDraft = null; +function readDraft(){const result={};for(const [key,id] of Object.entries(FIELDS))result[key]=document.getElementById(id).value;for(const[key,id]of Object.entries(BOOLS))result[key]=String(document.getElementById(id).checked);return result;} +function loadDraft(draft){for(const[key,id]of Object.entries(FIELDS))if(draft[key]!==undefined)document.getElementById(id).value=draft[key];for(const[key,id]of Object.entries(BOOLS))if(draft[key]!==undefined)document.getElementById(id).checked=String(draft[key])==='true';syncKind();pvUpdate();} +function draftUrl(value,message){try{return new URL(value);}catch(_){throw Error(message);}} +function plan(draft){ + for(const field of Object.keys(BOOLS))if(!['true','false'].includes(draft[field]))throw Error(`Choose a supported ${field.replaceAll('_',' ')} value.`); + for(const[field,values]of Object.entries({target:['all_users','prod_users','debug_users','test_users','direct_users'],type:['push','in-app','both'],sound:['default','chime','announcement','silent'],presentation:['compact','fullscreen'],tone:['primary','secondary','tertiary','error'],image_style:['banner','cover']}))if(!values.includes(draft[field]))throw Error(`Choose a supported ${field.replaceAll('_',' ')}.`); + if(!draft.title.trim()||!draft.body.trim())throw Error('Add a title and message.'); + if(draft.test_mode==='true'&&!['test_users','debug_users'].includes(draft.target))throw Error('Test mode only permits isolated test or debug builds.'); + if(draft.release_alert==='true'&&!['direct_users','test_users','debug_users'].includes(draft.target)&&draft.include_play!=='true')throw Error('Choose Direct installs for a release alert, or explicitly include Google Play.'); + if(draft.target==='direct_users'&&draft.include_play==='true')throw Error('Choose All installs to include Google Play.'); + if(!/^\d+$/.test(draft.release_version_code)||Number(draft.release_version_code)>2147483647)throw Error('Use a release version code from 0 to 2,147,483,647.'); + if(draft.release_alert==='true'&&draft.release_url){const url=draftUrl(draft.release_url,'Use a boxlore GitHub release page.');if(url.protocol!=='https:'||url.hostname!=='github.com'||url.port||url.username||url.password||url.search||url.hash||!(url.pathname==='/boxcreate/boxlore/releases/latest'||url.pathname.startsWith('/boxcreate/boxlore/releases/tag/')))throw Error('Use a boxlore GitHub release page.');} + if(draft.image){const url=draftUrl(draft.image,'Use an HTTPS image URL without embedded credentials.');if(url.protocol!=='https:'||url.username||url.password)throw Error('Use an HTTPS image URL without embedded credentials.');} + const data={};for(const key of ['title','body','type','sound','action_label','show_action_in_push','show_action_in_app','category','presentation','tone','image_style','release_alert','include_play','release_version_code','test_mode'])data[key]=draft[key]; + data.body=data.body.replace(/\\n/g,'\n');data.release_version_code=String(Number(data.release_version_code)); + for(const key of ['route','image'])if(draft[key])data[key]=draft[key]; + if(draft.release_alert==='true'&&draft.release_url)data.release_url=draft.release_url; + const encoder=new TextEncoder();const bytes=Object.entries(data).reduce((n,[key,value])=>n+encoder.encode(key).length+encoder.encode(value).length,0); + if(bytes>2048)throw Error(`Message uses ${bytes} bytes; the limit is 2,048. Shorten it or link to the full notes.`); + return {topic:draft.target,data,collapse_key:draft.collapse_key,dry_run:draft.dry_run==='true',test_mode:draft.test_mode==='true',payload_bytes:bytes}; +} +function parsePreview(value){const draft=value?.draft||{...value?.data,target:value?.topic,collapse_key:value?.collapse_key,dry_run:String(value?.dry_run),test_mode:String(value?.test_mode)};if(typeof draft.title!=='string'||typeof draft.body!=='string')throw Error('Choose an exported announcement preview.');const normalized={route:'',image:'',release_url:'',collapse_key:'',...draft};plan(normalized);return normalized;} +function canonical(value){if(Array.isArray(value))return value.map(canonical);if(value&&typeof value==='object')return Object.fromEntries(Object.keys(value).sort().map(key=>[key,canonical(value[key])]));return value;} +async function digest(value){const bytes=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify(canonical(value))));return [...new Uint8Array(bytes)].map(b=>b.toString(16).padStart(2,'0')).join('');} +const PALETTES={light:{surface:'#F8F8FB',container:'#EDEDF4',text:'#1A1B23',muted:'#494854',primary:['#5145D8','#FFFFFF','#E5E0FF','#261D58'],secondary:['#55647C','#FFFFFF','#DFE6F2','#202D42'],tertiary:['#397B6D','#FFFFFF','#CDEDE1','#123C32'],error:['#B3261E','#FFFFFF','#F9DEDC','#410E0B']},dark:{surface:'#121318',container:'#20212A',text:'#E6E6EE',muted:'#C8C5D3',primary:['#9C8CFF','#21164B','#3F347B','#ECE7FF'],secondary:['#A3B1CA','#253248','#354258','#E2E8F3'],tertiary:['#89C6B5','#12392F','#274C41','#D0EFE3'],error:['#F2B8B5','#601410','#8C1D18','#F9DEDC']}}; +function profile(draft){const colors=PALETTES[document.getElementById('previewMode').value];const accent=colors[draft.tone]||colors.primary;return {colors:{surface:colors.surface,container:colors.container,text:colors.text,muted:colors.muted,'tonal-container':colors.secondary[2],'on-tonal-container':colors.secondary[3],accent:accent[0],'on-accent':accent[1],'accent-container':accent[2],'on-container':accent[3]},fontScale:Number(document.getElementById('previewScale').value),roundness:Number(document.getElementById('previewRoundness').value),rtl:document.getElementById('previewRtl').checked,play:document.getElementById('previewPlay').checked};} +function fitPreview(){const stage=document.getElementById('previewStage');const device=stage.querySelector('.preview-device');const frame=stage.querySelector('.preview-phone');const width=Number(document.getElementById('previewWidth').value)+18;const height=Number(document.getElementById('previewHeight').value)+18;const zoom=document.getElementById('previewZoom').value;const scale=zoom==='fit'?Math.max(0,Math.min(1,(stage.clientWidth-28)/width,(stage.clientHeight-28)/height)):1;frame.style.width=width+'px';frame.style.height=height+'px';device.style.width=width*scale+'px';device.style.height=height*scale+'px';frame.style.transform=`scale(${scale})`;if(document.getElementById('reviewDialog').open){const review=document.getElementById('reviewPreview');const maxHeight=window.innerHeight*(window.matchMedia('(max-width:920px)').matches ? 0.5 : 0.7);const ratio=Math.min(1,maxHeight/(height-18),document.getElementById('reviewStage').clientWidth/(width-18));review.style.transform=`scale(${ratio})`;document.getElementById('reviewStage').style.height=(height-18)*ratio+'px';}} +function isPreviewExcluded(draft,play){return draft.type==='push'||(play&&draft.release_alert==='true'&&draft.include_play!=='true');} +function updateFrame(frame,draft){const excluded=isPreviewExcluded(draft,document.getElementById('previewPlay').checked);frame.style.visibility=excluded?'hidden':'';frame.setAttribute('aria-hidden',String(excluded));const notice=document.getElementById(frame.id==='appPreview'?'appPreviewExcluded':'reviewPreviewExcluded');notice.hidden=!excluded;notice.textContent=draft.type==='push'?'This message only sends a system notification. No in-app alert will appear.':'This release alert will not appear on Google Play installs. Enable Include Google Play in Delivery to send it to them.';frame.width=document.getElementById('previewWidth').value;frame.height=document.getElementById('previewHeight').value;frame.contentWindow.postMessage({source:'boxlore-preview',payload:{...draft,body:draft.body.replace(/\\n/g,'\n')},profile:profile(draft)},location.origin);fitPreview();} +function syncKind(){const release=document.getElementById('inputReleaseAlert').checked;document.getElementById('inputKind').value=release?'release':'announcement';for(const node of document.querySelectorAll('[data-release-only]'))node.hidden=!release;for(const node of document.querySelectorAll('[data-normal-only]'))node.hidden=release;const route=document.getElementById('actionRouteGroup');(release?document.getElementById('legacyRouteMount'):document.querySelector('[data-normal-only]')).prepend(route);} +function chooseKind(){const release=document.getElementById('inputKind').value==='release';document.getElementById('inputReleaseAlert').checked=release;document.getElementById('inputCategory').value=release?'NEW RELEASE':'ANNOUNCEMENT';document.getElementById('inputPresentation').value=release?'fullscreen':'compact';document.getElementById('inputActionLabel').value=release?'Download':'Open';if(release&&!document.getElementById('inputTestMode').checked)document.getElementById('inputTarget').value='direct_users';syncKind();pvUpdate();} +function showEditorTab(tab){for(const node of document.querySelectorAll('[data-panel]'))node.hidden=node.dataset.panel!==tab;for(const button of document.querySelectorAll('[data-tab]')){button.classList.toggle('selected',button.dataset.tab===tab);button.setAttribute('aria-pressed',String(button.dataset.tab===tab));}} +function formatCopy(before,after){const input=document.getElementById('inputBody');const start=input.selectionStart,end=input.selectionEnd;input.setRangeText(before+input.value.slice(start,end)+after,start,end,'select');input.focus();pvUpdate();} +function pvUpdate(){reviewedDraft=null;document.getElementById('approveDraft').checked=false;const draft=readDraft();updateFrame(document.getElementById('appPreview'),draft);document.getElementById('previewInteraction').textContent=isPreviewExcluded(draft,document.getElementById('previewPlay').checked)?'No in-app alert for this delivery. Review the notification text before sending.':'Live app layout. Preview controls don’t send messages or download files.';document.getElementById('pushTitle').textContent=draft.title;document.getElementById('pushBody').textContent=draft.body;document.getElementById('previewProfileLabel').textContent=`Classic ${document.getElementById('previewMode').value} · ${document.getElementById('previewWidth').value} × ${document.getElementById('previewHeight').value} dp · ${Math.round(Number(document.getElementById('previewScale').value)*100)}% text`;try{document.getElementById('payloadCount').textContent=`${plan(draft).payload_bytes} / 2,048 payload bytes`;document.getElementById('sendBtn').disabled=false;}catch(error){document.getElementById('payloadCount').textContent=error.message;document.getElementById('sendBtn').disabled=true;}} +function reviewRows(draft,audience){ + const inApp=draft.type!=='push',push=draft.type!=='in-app'; + return [['Audience',audience],['Delivery',{push:'System notification','in-app':'In-app alert',both:'System notification and in-app alert'}[draft.type]],['Send mode',draft.dry_run==='true'?'Provider validation only — no delivery':'Live delivery'],['Appearance',inApp?(draft.presentation==='fullscreen'?'Full screen':'Compact alert'):'Android system notification'],['In-app actions',inApp?(draft.release_alert==='true'?'Download update · View on GitHub · Dismiss':(draft.show_action_in_app==='true'&&draft.route?`${draft.action_label} · Dismiss`:'Dismiss')):'No in-app alert'],['Notification action',push&&draft.show_action_in_push==='true'&&draft.route?draft.action_label:'None'],['Play release alerts',draft.release_alert==='true'?(draft.include_play==='true'?'Explicitly included':'Excluded'):'Ordinary announcement'],[inApp&&draft.release_alert==='true'?'GitHub page':'Action destination',inApp&&draft.release_alert==='true'?(draft.release_url||'Latest release'):(draft.route||'None')]]; +} +function handleSend(event){event.preventDefault();try{const draft=readDraft();plan(draft);reviewedDraft=JSON.parse(JSON.stringify(draft));const audience=document.getElementById('inputTarget').selectedOptions[0].textContent;document.getElementById('reviewSummary').textContent=draft.dry_run==='true'?`Validate with Firebase for ${audience}. No message will be delivered.`:`Send this announcement to ${audience}. ${draft.test_mode==='true'?'Production builds are excluded.':'This delivers a message to the selected audience.'}`;document.getElementById('confirmSend').textContent=draft.dry_run==='true'?'Approve validation':'Approve and send';document.getElementById('reviewError').textContent='';const details=document.getElementById('reviewDetails');details.replaceChildren();for(const[label,value]of reviewRows(draft,audience)){const term=document.createElement('dt'),definition=document.createElement('dd');term.textContent=label;definition.textContent=value;details.append(term,definition);}document.getElementById('reviewPushCopy').hidden=draft.type==='in-app';document.getElementById('reviewPushTitle').textContent=draft.title;document.getElementById('reviewPushBody').textContent=draft.body.replace(/\\n/g,'\n');document.getElementById('reviewDialog').showModal();updateFrame(document.getElementById('reviewPreview'),reviewedDraft);document.getElementById('reviewDialog').scrollTop=0;document.getElementById('reviewHeading').focus({preventScroll:true});}catch(error){document.getElementById('statusText').textContent=error.message;}} +function closeReview(){document.getElementById('reviewDialog').close();reviewedDraft=null;} +async function approveSend(){ + const button=document.getElementById('confirmSend');if(!reviewedDraft||!document.getElementById('approveDraft').checked){document.getElementById('reviewError').textContent='Review this message and its audience, then tick the approval box.';return;} + if(JSON.stringify(readDraft())!==JSON.stringify(reviewedDraft)){document.getElementById('reviewError').textContent='The draft changed. Close this review and review it again.';return;} + if(!window.__ADMIN_PAT){document.getElementById('reviewError').textContent='Sign in again before sending.';return;} + button.disabled=true; + try{const inputs={...reviewedDraft,preview_only:'false',approval_digest:await digest(plan(reviewedDraft))};const response=await fetch(WORKFLOW+'/dispatches',{method:'POST',headers:{Accept:'application/vnd.github+json',Authorization:`token ${window.__ADMIN_PAT}`,'Content-Type':'application/json'},body:JSON.stringify({ref:'master',inputs})});if(!response.ok){const error=await response.json().catch(()=>({}));throw Error(error.message||'Could not start the workflow. Try again.');}closeReview();document.getElementById('statusText').textContent='Workflow requested. Check Recent runs for validation or delivery results; device receipt is not confirmed.';fetchWorkflowHistory();}catch(error){document.getElementById('reviewError').textContent=error.message;}finally{button.disabled=false;} +} +async function exportDraft(){try{const draft=readDraft();const output={...plan(draft),approval_digest:await digest(plan(draft)),draft,mode:'offline-preview',sent:false};const url=URL.createObjectURL(new Blob([JSON.stringify(output,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='boxlore-announcement-preview.json';a.click();URL.revokeObjectURL(url);}catch(error){document.getElementById('statusText').textContent=error.message;}} +function saveTemplate(){const name=document.getElementById('templateName').value.trim();if(!name)return;const templates=JSON.parse(localStorage.getItem('boxlore-announcement-drafts')||'{}');templates[name]=readDraft();localStorage.setItem('boxlore-announcement-drafts',JSON.stringify(templates));loadTemplates();} +function loadTemplates(){const parent=document.getElementById('templatesList');parent.replaceChildren();for(const[name,draft]of Object.entries(JSON.parse(localStorage.getItem('boxlore-announcement-drafts')||'{}'))){const button=document.createElement('button');button.textContent=name;button.onclick=()=>loadDraft(draft);parent.append(button);}} +async function fetchWorkflowHistory(){const parent=document.getElementById('historyList');if(!window.__ADMIN_PAT){parent.textContent='Sign in to view workflow runs.';return;}try{const response=await fetch(WORKFLOW+'/runs?per_page=5',{headers:{Accept:'application/vnd.github+json',Authorization:`token ${window.__ADMIN_PAT}`}});if(!response.ok)throw Error('Could not load runs. Try Refresh runs.');const data=await response.json();parent.replaceChildren();for(const run of data.workflow_runs){const link=document.createElement('a');link.href=run.html_url;link.target='_blank';link.rel='noopener';link.textContent=`${run.display_title} · ${run.conclusion||run.status}`;parent.append(link);}}catch(error){parent.textContent=error.message;}} +function initApp(){document.getElementById('notifyForm').addEventListener('input',pvUpdate);document.getElementById('notifyForm').addEventListener('change',pvUpdate);for(const id of ['previewMode','previewScale','previewWidth','previewHeight','previewZoom','previewRtl','previewRoundness','previewPlay'])document.getElementById(id).addEventListener('change',pvUpdate);window.addEventListener('message',event=>{if(event.origin!==location.origin||event.data?.source!=='boxlore-announcement')return;for(const id of ['appPreview','reviewPreview']){const frame=document.getElementById(id);if(event.source!==frame.contentWindow)continue;if(event.data.action==='ready')updateFrame(frame,reviewedDraft||readDraft());else{const descriptions={download:'In the app: opens the updater and downloads the verified release.',github:'In the app: opens this release on GitHub.',dismiss:'In the app: dismisses this announcement.',action:'In the app: opens the configured action destination.'};document.getElementById('previewInteraction').textContent=descriptions[event.data.action]||'Live app layout.';}}});document.getElementById('importDraft').onchange=async event=>{try{const data=JSON.parse(await event.target.files[0].text());loadDraft(parsePreview(data));}catch(error){document.getElementById('statusText').textContent='Could not open this preview. Choose an announcement-preview JSON file.';}};new ResizeObserver(fitPreview).observe(document.getElementById('previewStage'));window.addEventListener('resize',fitPreview);syncKind();loadTemplates();fetchWorkflowHistory();pvUpdate();} +function logout(){window.__ADMIN_PAT=null;if(typeof firebase!=='undefined'&&firebase.auth)firebase.auth().signOut().then(()=>location.reload());else location.reload();} +if(typeof module!=='undefined')module.exports={plan,canonical,PALETTES,isPreviewExcluded,parsePreview,reviewRows}; diff --git a/admin-panel/public/notifyappusers/preview.html b/admin-panel/public/notifyappusers/preview.html new file mode 100644 index 000000000..5c7c071dd --- /dev/null +++ b/admin-panel/public/notifyappusers/preview.html @@ -0,0 +1 @@ +boxlore announcement preview
diff --git a/docs/TESTING.md b/docs/TESTING.md index 1203c2469..b9027ade7 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -49,8 +49,44 @@ Run commands from the repository root. Start with the affected behavior and broa For Android code changes, the full CI command is `./gradlew detekt testDebugUnitTest :koverVerifyMerged :app:dependencyGuard :core:catalog:dependencyGuard :core:playback:dependencyGuard --continue`, with `./gradlew lintDebug` in a separate job. The unit workflow also runs the architecture shell guards and Python release-tooling tests. `ktlintCheck` is a local check; it is not part of the current PR workflow. +For release-copy and announcement changes, the Python suite covers required copy, preserved wording/order, historical backfill, batch failure before writes, UTF-8 payload limits, provider dry-run flags and test-mode audience rejection with a fake sender. **Offline release rehearsal** (manual dispatch) runs the same suite and writes reviewable candidate files without credentials, publishing or messages. **Release copy** checks the PR's body and impact labels without an Android build. See [release-copy and rehearsal recovery](../scripts/README.md#authored-release-copy-and-isolated-rehearsals) before testing live notifications. These are offline checks; APK installation and actual notification receipt require separate device acceptance. + Report which checks completed and distinguish automated coverage from manual checks or paths that still need verification. Script tests exercise local logic; they do not verify a production deployment. +For updater work, run `./gradlew :app:testDebugUnitTest --tests 'cx.aswin.boxlore.updates.*'` for offline/fake HTTP fixtures. These tests never publish releases, deliver FCM or install APKs. The optimized preflight builds the direct APK and `bundlePlayRelease`, checks each variant's R8 contracts, and rejects installer code/permission in Play. A real new-version installation still needs device acceptance with a separately approved staged APK; do not create a production release or alert for testing. On-device acceptance: Settings → Check for updates → inspect available/current/error state; for an approved available candidate, Download update → verify progress → Install update → confirm Android installation. Repeat interruption, permission return and cancellation, checking that listening data survives. + +## Isolated update and announcement testing + +No production release or shared production alert is needed. Use a dedicated test phone where possible. updateTest keeps the normal package/signature: installation preserves listening data, but installing a higher-code test candidate can prevent returning to an older production APK without a downgrade. Plan the exit build before testing installation; never uninstall merely to bypass this. + +```sh +./gradlew :app:assembleUpdateTest +cp app/build/outputs/apk/updateTest/app-updateTest.apk /tmp/boxlore-test-current.apk +./gradlew :app:assembleUpdateTest -PboxloreUpdateTestVersionCode= +cp app/build/outputs/apk/updateTest/app-updateTest.apk /tmp/boxlore-test-next.apk +adb install -r /tmp/boxlore-test-current.apk +adb reverse tcp:8765 tcp:8765 +python3 .github/scripts/serve_update_test.py --apk /tmp/boxlore-test-next.apk --version-code +``` + +Install the current test APK, open boxlore, then Settings → Check for updates. The available icon appears only after a valid newer compatible result. Open it, download, pause/resume, and install. When Android opens Allow from this source, enable it yourself, return to boxlore, and tap Install update again. The screen and verified file remain; returning never auto-installs. Cancel Android installation and check that Install remains available. Process restart re-verifies the existing file; removing its cache file returns to Download without a new automatic transfer. + +Restart the local server with `--state unavailable`, `--state incompatible`, or `--state corrupt` to test failures. Use Settings' manual check to bypass intervals. A manifest at the installed code should hide the icon. Integrity/signature/package/version checks must reject invalid candidates. The shipping app rejects testOnly/loopback manifests; Play bundles exclude the direct installer and install permission. + +For a long release-note test, add `--notes-file /tmp/boxlore-test-notes.txt` with UTF-8 sample text. The fixture rejects notes beyond the app's 24,000 UTF-16 character or 64 KiB response bounds. Scroll the notes to the end and confirm the close control and download/install footer remain visible. The `/notes` link shows the same sample text. This does not publish release notes or send an alert. + +For local visual review, export an announcement from the admin's Download preview, then run: + +```sh +python3 .github/scripts/preview_announcement_on_device.py --preview /tmp/announcement-preview.json --serial +``` + +This uses an ADB-only receiver present in updateTest and no FCM. The preview clears version eligibility locally so current devices can show its layout; delivery filtering is tested separately. Open the app and check Compact/Full screen, large text, light/dark, RTL, long titles, imagery and scrollable body. Release actions: Download update starts the native updater, View on GitHub keeps the message available on return, and Dismiss clears it. Permission/settings round-trips retain the updater. + +For a real transport test after workflows/panel are deployed, keep Test mode on and audience Isolated test builds. First retain Validate without delivery, review and approve provider validation. Only then turn validation off, review the changed draft and approve one actual test_users send. Production builds neither subscribe to this audience nor accept test-only payloads. Test Ordinary alert versus Release, both presentations, installed-version suppression, explicit Play override and normal episode notification independence. Do not select all_users/prod_users for an isolated test. + +Run `node --test .github/scripts/test_announcement_admin.cjs` and the Python suite for approval, payload bounds, audience restrictions, preview action semantics and font parity. Announcements render natively; verify preview style against phone screenshots. The optimized artifact gate rejects the isolated receiver/marker in shipping outputs. Report local/device/provider verification separately. + ## Stack - JUnit 5 (+ Vintage where leftovers remain) @@ -191,6 +227,8 @@ See [`docs/screenshots/README.md`](screenshots/README.md). | Workflow | Runs | When | Status | | :--- | :--- | :--- | :--- | | `unit-tests.yml` | Architecture + detekt + unit + Kover + lint + Dependency Guard + Python release tests | PR / master push / dispatch | Done | +| `release-copy.yml` | Authored release regions and exactly one impact label | PR, including body / label edits | Configured | +| `release-rehearsal.yml` | Hermetic script tests and offline candidate previews | Manual | Configured | | `coderabbit-threads-resolved.yml` | Fail unless all non-outdated CodeRabbit review threads are Resolved | PR / review | Done | | `gitleaks.yml` | Secret scan | PR / push to master | Done | | `maestro-nightly.yml` | Validate Maestro YAML; optional Cloud device job when configured | Nightly / manual | Done | @@ -237,11 +275,13 @@ The gate derives contracts from pre-R8 release class files and the merged manife Gson roots are intentionally declared in `verify_release_contracts.py`; add a new reflective JSON entry point there when introducing one. Nested model/field additions, new workers, new manifest components, and new API methods are discovered from compiled release classes automatically. This compares optimization against current source; it does not detect an intentional source-level schema rename or prove SDK/network behavior. A successful build alone is never release acceptance. ```sh -./gradlew :app:assembleRelease :app:bundleRelease :app:lintRelease +./gradlew :app:assembleRelease :app:bundlePlayRelease :app:lintRelease :app:lintPlayRelease python3 .github/scripts/verify_release_contracts.py --sdk \ --report app/build/reports/release-contracts.json \ - app/build/outputs/apk/release/app-release.apk \ - app/build/outputs/bundle/release/app-release.aab + app/build/outputs/apk/release/app-release.apk +python3 .github/scripts/verify_release_contracts.py --sdk --app-variant playRelease \ + --report app/build/reports/play-release-contracts.json \ + app/build/outputs/bundle/playRelease/app-playRelease.aab python3 -m unittest discover -s .github/scripts -p 'test_*.py' -v ``` diff --git a/scripts/README.md b/scripts/README.md index 594b16d14..50826747e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -48,6 +48,37 @@ These commands test local logic; running a production script requires the intern CI configuration helpers, maintenance tools, and data files also live here. Check the owning workflow or runbook before using a tool; its presence in this directory does not mean it runs automatically. +### Authored release copy and isolated rehearsals + +The publication and same-version refresh jobs build the direct `assembleRelease` APK and installer-free `bundlePlayRelease` AAB, validate their optimized contracts separately, and verify the downloadable APK before generating/uploading `update.json`. The manifest identifies the immutable release APK URL, version code/name, Android minimum, byte size, checksum and full reviewed listener bullets. A refresh updates its checksum without creating a newer version or sending an update alert. If manifest publication fails after verified assets, rerun publication/refresh to repair the manifest; do not bump again solely for a failed upload. No manifest is advertised before the download check passes. App checks remain read-only and do not rely on FCM delivery. + +Release tooling lives in `.github/scripts/`. The PR author or coding agent writes the exact developer and listener copy in the [PR template](../.github/PULL_REQUEST_TEMPLATE.md). `update_changelog.py` normalizes historical impact-label aliases consistently with tag resolution, requires exactly one recognized impact label, rejects multiple labels even when they normalize to the same level, requires developer copy for every PR and listener copy for all user-impact labels, and never calls a text-generation service. The **Release copy** check refreshes on PR body and label edits. It is a fast standalone check; no Android build or repository-rule change is involved. + +README order is Critical → New features → Improvements → Fixes → Security → Other. Within a category, higher impact leads, then newer PRs; bullet order within each PR is preserved. CHANGELOG uses Keep a Changelog categories, then impact and PR order. All authored bullets are retained. `no-user-impact` stays in CHANGELOG only. Existing release notes and legacy markers remain readable. + +If copy is missing, fill that PR's release-copy region. For a merged PR, run **Changelog and Release → backfill-changelog** with its number, then retry the failed operation. Do not hand-edit generated regions. Release/tooling PRs can still use the established `[skip changelog]` exception. + +**Offline release rehearsal** writes candidate version files, release notes and an announcement payload to an artifact without editing source files, creating a release, using credentials or sending FCM. Same-version refresh previews keep the version unchanged and produce no announcement. Locally: + +```bash +python3 .github/scripts/rehearse_release.py --output /tmp/boxlore-release-preview +python3 .github/scripts/rehearse_release.py --output /tmp/boxlore-refresh-preview --operation refresh-latest-artifacts +``` + +For a custom announcement, **Send boxlore Notification** separates three steps: + +1. `preview_only=true` writes `announcement-preview.json` without Firebase access or delivery. Import it in the admin composer or use Download preview there. +2. `dry_run=true` validates with Firebase without device delivery. Test mode permits only debug_users or isolated test_users. +3. Real delivery requires the exact preview's approval digest. In the admin, Review announcement snapshots the text, appearance, destinations and audience; tick the review box and approve. Changing the draft requires another review. Automatic release publication prepares this artifact and does not send until approved. + +The composer offers compact/fullscreen alerts, controlled accent/image styles and exact selected-profile rendering, with a separate system-notification text preview. Release alerts have native Download update, View on GitHub and Dismiss actions. Optional fields retain old payload compatibility. Direct release delivery targets the new direct_users topic and excludes play_users/test_users; old clients sharing prod_users are not silently included. Ordinary announcements still support both channels, and manual include-Play is explicit. Topic payloads are checked against the 2,048-byte UTF-8 limit. FCM acceptance is not device receipt confirmation. + +If preview validation fails, correct the reported field or shorten the message, then preview again. If approval is stale, review the current draft and approve it again. If Firebase validation fails, inspect that workflow step; no message was delivered. If delivery is accepted but the device shows nothing, check the selected build/audience, notification permission, release code and Play exclusion. Retry a live message only after confirming that a previous request was not accepted. + +The public composer and renderer are versioned here; its existing private gateway/auth/hosting configuration remains unchanged and ignored. Deploying the panel is a separate operation. See [admin composer](../admin-panel/README.md) and [isolated app testing](../docs/TESTING.md#isolated-update-and-announcement-testing). + +The local updater fixture accepts `--notes-file` for long UTF-8 sample release notes. It preserves the supplied text in the test manifest and `/notes` page, validates the Android character/response bounds, and never publishes or sends anything. + ### Check New Episodes [`.github/workflows/new-episode-check.yml`](../.github/workflows/new-episode-check.yml) runs approximately every 30 minutes on GitHub Actions. It polls notification registrations independently of catalog sync. @@ -85,15 +116,21 @@ Repair considers catalog rows without a valid HTTPS `feedUrl`. It uses the authe ### Release artifact contract guard -The release workflow runs `.github/scripts/verify_release_contracts.py` after optimized APK/AAB builds and before either publication path uploads artifacts. It compares pre-R8 project classes with optimized DEX using Android SDK `dexdump`, checks dynamic resource names with `aapt2`, and verifies named raw-resource payloads in both artifacts. Literal resource URIs are discovered from main/release Kotlin sources, including notification sounds. Run locally after `assembleRelease bundleRelease`: +The release workflow runs `.github/scripts/verify_release_contracts.py` after optimized APK/AAB builds and before either publication path uploads artifacts. It compares pre-R8 project classes with optimized DEX using Android SDK `dexdump`, checks dynamic resource names with `aapt2`, and verifies named raw-resource payloads in both artifacts. Literal resource URIs are discovered from main/release Kotlin sources, including notification sounds. Run locally after `assembleRelease bundlePlayRelease`: ```sh python3 .github/scripts/verify_release_contracts.py --sdk \ --report app/build/reports/release-contracts.json \ - app/build/outputs/apk/release/app-release.apk \ - app/build/outputs/bundle/release/app-release.aab + app/build/outputs/apk/release/app-release.apk +python3 .github/scripts/verify_release_contracts.py --sdk --app-variant playRelease \ + --report app/build/reports/play-release-contracts.json \ + app/build/outputs/bundle/playRelease/app-playRelease.aab ``` `release_classfile.py` reads compiled JVM contracts; `release_dex.py` reads optimized contracts and R8 class names. `test_release_contracts.py` covers missing/private constructors, persisted names, nested JSON graphs, field/default changes, generic erasure, annotations, serializers, resource loss, and workflow wiring. These fast tests run in PR CI. Actions → Manual optimized release validation → Run workflow optionally exercises the real optimizer on the selected branch without release credentials. It does not run automatically on PRs, publish a release or send notifications. See [optimized-release validation](../docs/TESTING.md#optimized-release-validation) for scope and manual acceptance. All three artifact-build jobs explicitly request `platform-tools` during Android SDK setup; the pinned setup action's default also requests the removed `tools` package and fails before compilation. The workflow regression check covers the manual preflight and both signed publication paths. + +The update manifest preserves authored listener-note emphasis and links as Markdown instead of flattening them to plain text. The app renders the supported listener Markdown subset. + +Local update fixtures support `--upcoming-file` for testing an unreleased Markdown preview. `upcoming_notes` extracts authored listener copy from README Upcoming Changes. The approved workflow refreshes only optional upcoming text in the latest verified update feed after README synchronization. Metadata writers share a concurrency group to prevent stale overwrites. This does not change APK metadata or send FCM; it becomes active only when these workflow changes are shipped.