Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,18 @@ Add impact labels on the PR (`gh pr edit <n> --add-label user-impact-critical --

<!--
changelog-on-merge (update_changelog.py) and prepare-release (prepare_release.py)
paste these two regions as-is. Groq must not rewrite them.
paste these regions as-is. The PR author or coding agent writes and reviews the
copy; workflows only order it and add PR links. No AI rewriting or fallback.

Required for user-impact-critical. Optional for other labels — if a region is
filled, it is still used verbatim for that surface.

Leave a region empty (or only `-` / TBD) to fall back to AI for that surface only.
Developer copy is required for every PR. Listener copy is required for every
user-impact label; no-user-impact changes appear only in CHANGELOG.md.
Empty bullets or TBD fail validation. Release/tooling PRs may use the established
[skip changelog] exception.
Do not hand-edit CHANGELOG.md or README Upcoming / What's New; this section is
the source those workflows copy from.
-->

### 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.

Expand All @@ -117,7 +118,7 @@ Keep a Changelog bullets for engineers. Use `### Added` / `### Changed` / `### F

<!-- release-copy:changelog:end -->

### 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.
Expand Down
42 changes: 17 additions & 25 deletions .github/scripts/prepare_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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 ""),
Expand All @@ -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 = [
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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",
)
Expand Down Expand Up @@ -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"
),
)
Expand Down
89 changes: 89 additions & 0 deletions .github/scripts/test_announcement_admin.cjs
Original file line number Diff line number Diff line change
@@ -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` <b>plain HTML</b>');
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('<b>plain HTML</b>')));
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));
});
Loading
Loading