-
Notifications
You must be signed in to change notification settings - Fork 5
Require release notes for every change and show them in the app #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| /** | ||
| * Pull-request gate: a change people will notice needs release notes. | ||
| * | ||
| * If the diff against the base branch touches the app (src/, resources/ or | ||
| * package.json) it must also add notes under `## [Unreleased]` in | ||
| * CHANGELOG.md; the release workflow later publishes that section, and the | ||
| * app shows it as "What's new". Internal-only changes (tests, CI, docs, | ||
| * refactors with no visible effect) can carry the `no release notes` label. | ||
| * | ||
| * Usage: BASE_REF=origin/main PR_LABELS='["..."]' node scripts/check-release-notes.mjs | ||
| */ | ||
| import { execFileSync } from 'node:child_process' | ||
| import { readFileSync } from 'node:fs' | ||
|
|
||
| const baseRef = process.env.BASE_REF || 'origin/main' | ||
| // Accept a JSON list, a single JSON string or a bare name, whatever the | ||
| // workflow expression renders to. | ||
| const parseLabels = (raw) => { | ||
| if (!raw) return [] | ||
| try { | ||
| const value = JSON.parse(raw) | ||
| return (Array.isArray(value) ? value : [value]).map((l) => String(l).toLowerCase()) | ||
| } catch { | ||
| return [raw.toLowerCase()] | ||
| } | ||
| } | ||
| const labels = parseLabels(process.env.PR_LABELS) | ||
| if (labels.includes('no release notes')) { | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| console.log('Skipped: labelled "no release notes".') | ||
| process.exit(0) | ||
| } | ||
|
|
||
| const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }) | ||
| const changed = git('diff', '--name-only', `${baseRef}...HEAD`).split('\n').filter(Boolean) | ||
| const userFacing = changed.filter((f) => /^(src|resources)\//.test(f) || f === 'package.json') | ||
| if (!userFacing.length) { | ||
| console.log('No app changes; release notes not required.') | ||
| process.exit(0) | ||
| } | ||
|
|
||
| // Bullets under [Unreleased] and under every version section. A release PR | ||
| // renames [Unreleased] to the new version, so a new or changed section of | ||
| // either kind counts as release notes. | ||
| const sections = (text) => { | ||
| const out = new Map() | ||
| let name = null | ||
| for (const line of text.split(/\r?\n/)) { | ||
| const heading = line.match(/^##\s+\[?([^\]\s]+)\]?/) | ||
| if (heading) { name = heading[1].toLowerCase(); out.set(name, []); continue } | ||
| if (name && /^\s*[-*]\s+\S/.test(line)) out.get(name).push(line.trim()) | ||
| } | ||
| return out | ||
| } | ||
| let baseSections = new Map() | ||
| try { baseSections = sections(git('show', `${baseRef}:CHANGELOG.md`)) } catch { /* new file */ } | ||
| const head = sections(readFileSync('CHANGELOG.md', 'utf8')) | ||
| const added = [...head].filter(([name, bullets]) => | ||
| (name === 'unreleased' || /^[0-9]+\.[0-9]+\.[0-9]+$/.test(name)) && bullets.length && | ||
| bullets.join('\n') !== (baseSections.get(name) ?? []).join('\n')) | ||
|
|
||
| if (!added.length) { | ||
| console.error([ | ||
| 'This pull request changes the app but adds no release notes.', | ||
| '', | ||
| 'Add a line under "## [Unreleased]" in CHANGELOG.md (### Added, ### Improved or', | ||
| '### Fixed) saying what changed for people using Cutawan. If nothing visible', | ||
| 'changed, add the "no release notes" label.', | ||
| '', | ||
| `App files changed: ${userFacing.slice(0, 8).join(', ')}${userFacing.length > 8 ? ', …' : ''}` | ||
| ].join('\n')) | ||
| process.exit(1) | ||
| } | ||
| console.log(`Release notes present: ${added.map(([name]) => `[${name}]`).join(', ')}.`) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| import { useEffect, useState } from 'react' | ||
| import { createPortal } from 'react-dom' | ||
| import { Sparkles, X } from 'lucide-react' | ||
| import changelog from '../../../../CHANGELOG.md?raw' | ||
| import { changelogSection, parseNotes, type NotesBlock } from '@shared/releaseNotes' | ||
| import { useStore } from '../store' | ||
|
|
||
| /** This build's notes, from the CHANGELOG bundled at build time. */ | ||
| export function bundledNotes(version: string): NotesBlock[] { | ||
| return userNotes(parseNotes(changelogSection(changelog, version))) | ||
| } | ||
|
|
||
| /** Validation write-ups are for contributors, not the in-app notes. */ | ||
| export function userNotes(blocks: NotesBlock[]): NotesBlock[] { | ||
| return blocks.filter((block) => !/^validation\b/i.test(block.heading ?? '')) | ||
| } | ||
|
|
||
| /** Grouped release notes; also used for an available update's notes. */ | ||
| export function NotesList({ blocks, compact = false }: { blocks: NotesBlock[]; compact?: boolean }): React.JSX.Element { | ||
| return ( | ||
| <div className={compact ? 'space-y-2' : 'space-y-4'}> | ||
| {blocks.map((block, i) => ( | ||
| <section key={i}> | ||
| {block.heading && ( | ||
| <h3 className={`font-semibold uppercase tracking-wide text-zinc-400 ${compact ? 'text-[10px]' : 'text-[11px]'}`}> | ||
| {block.heading} | ||
| </h3> | ||
| )} | ||
| <ul className={`mt-1.5 list-disc space-y-1 pl-4 ${compact ? 'text-[11px]' : 'text-xs'} leading-relaxed text-zinc-300`}> | ||
| {block.items.map((item, j) => ( | ||
| <li key={j}>{item.replace(/`([^`]+)`/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1')}</li> | ||
| ))} | ||
| </ul> | ||
| </section> | ||
| ))} | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export function WhatsNewDialog({ version, onClose }: { version: string; onClose: () => void }): React.JSX.Element { | ||
| const blocks = bundledNotes(version) | ||
| useEffect(() => { | ||
| const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') onClose() } | ||
| window.addEventListener('keydown', onKey) | ||
| return () => window.removeEventListener('keydown', onKey) | ||
| }, [onClose]) | ||
| // Portalled: opened from Settings, a transformed/clipping ancestor would | ||
| // otherwise confine the full-window overlay to the settings panel. | ||
| return createPortal( | ||
| <div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/80 p-6 backdrop-blur-sm" onClick={onClose}> | ||
| <div role="dialog" aria-label={`What's new in Cutawan ${version}`} data-testid="whats-new" | ||
| className="max-h-[80vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-zinc-700 bg-zinc-900 p-6 shadow-2xl shadow-black/70" | ||
| onClick={(e) => e.stopPropagation()}> | ||
| <div className="flex items-start justify-between gap-4"> | ||
| <h2 className="flex items-center gap-2 text-lg font-semibold tracking-tight"> | ||
| <Sparkles size={18} className="text-accent-400" /> | ||
| What's new in v{version} | ||
| </h2> | ||
| <button type="button" onClick={onClose} aria-label="Close" data-testid="whats-new-close" className="rounded-lg p-1 text-zinc-500 hover:bg-surface-700 hover:text-zinc-200"> | ||
| <X size={16} /> | ||
| </button> | ||
| </div> | ||
| <div className="mt-4"> | ||
| {blocks.length ? <NotesList blocks={blocks} /> : <p className="text-xs text-zinc-500">No release notes for this version.</p>} | ||
| </div> | ||
| </div> | ||
| </div>, | ||
| document.body | ||
| ) | ||
| } | ||
|
|
||
| const SEEN_KEY = 'cutawan:notes-seen-version' | ||
|
|
||
| /** | ||
| * Shows this version's notes once after an update. A fresh install records | ||
| * the version silently; someone with existing projects (updating from a | ||
| * version before this dialog existed) sees the notes. | ||
| */ | ||
| export default function WhatsNewAfterUpdate(): React.JSX.Element | null { | ||
| const version = useStore((s) => s.settings?.setupComplete ? s.settings.appVersion : null) | ||
| const hasProjects = useStore((s) => s.projects.length > 0) | ||
| const [dismissed, setDismissed] = useState<string | null>(null) | ||
| // Read every render (cheap): a fresh install's silent write below must be | ||
| // seen before a first project would otherwise make this look like an update. | ||
| let seen: string | null | ||
| try { seen = localStorage.getItem(SEEN_KEY) } catch { seen = version } | ||
| const pending = Boolean(version && seen !== version && (seen || hasProjects) && bundledNotes(version).length) | ||
| // A fresh install (or a version without notes) is recorded silently. | ||
| useEffect(() => { | ||
| if (!version || pending || seen === version) return | ||
| try { localStorage.setItem(SEEN_KEY, version) } catch { /* storage unavailable */ } | ||
| }, [version, pending, seen]) | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| if (!version || !pending || dismissed === version) return null | ||
| return <WhatsNewDialog version={version} onClose={() => { | ||
| try { localStorage.setItem(SEEN_KEY, version) } catch { /* storage unavailable */ } | ||
| setDismissed(version) | ||
| }} /> | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Label changes rerun entire CI
Medium Severity
labeledandunlabeledwere added on the wholepull_requestworkflow so the release-notes job can re-run. Every other job, including the 20-minutemac-packagebuild plus pipeline and smoke, now also runs when any label is added or removed.Reviewed by Cursor Bugbot for commit afca87d. Configure here.