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
8 changes: 8 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@

<!-- What problem this solves. -->

## Release notes

<!--
What people using Cutawan will notice, in their words. Add the same lines under
"## [Unreleased]" in CHANGELOG.md (### Added / Improved / Fixed); CI checks it.
Nothing visible? Write "None" and add the "no release notes" label.
-->

## How did you test it?

<!--
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,23 @@ on:
push:
branches: [main]
pull_request:
# labeled/unlabeled so adding "no release notes" re-runs the check.
types: [opened, synchronize, reopened, labeled, unlabeled]

Copy link
Copy Markdown
Contributor

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

labeled and unlabeled were added on the whole pull_request workflow so the release-notes job can re-run. Every other job, including the 20-minute mac-package build plus pipeline and smoke, now also runs when any label is added or removed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit afca87d. Configure here.


jobs:
release-notes:
name: release notes
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Require CHANGELOG notes for app changes
env:
BASE_REF: origin/${{ github.base_ref }}
PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
run: node scripts/check-release-notes.mjs
mac-package:
name: packaged Apple Silicon inference and export
runs-on: macos-latest
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ jobs:
with:
node-version: 22
cache: npm
# Every release says what changed; fail before spending build time.
- name: Check release notes
shell: bash
run: node scripts/changelog-section.mjs "$(node -p "require('./package.json').version")" --require > /dev/null
- run: npm ci
- name: Validate packaged Mac before publishing
if: runner.os == 'macOS'
Expand Down Expand Up @@ -100,7 +104,7 @@ jobs:
set -euo pipefail
node scripts/verify-release.mjs release
version=$(node -p "require('./package.json').version")
node scripts/changelog-section.mjs "$version" > notes.md
node scripts/changelog-section.mjs "$version" --require > notes.md
if gh release view "v$version" --json isDraft > release-state.json; then
node -e 'if (!require("./release-state.json").isDraft) throw new Error("This version is already published; bump the version before releasing.")'
else
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ still pre-1.0, minor bumps carry new features and patch bumps carry fixes.
- Cut words straight from the transcript: drag across them and press Delete or "Cut selection". Cut words are struck through.
- Undo and redo for clip edits (⌘Z / ⇧⌘Z), and Premiere-style keys: Space or K play/pause, J/L step a second, arrow keys step a frame, I/O set in and out.
- The trim bar shows how long the clip plays after pauses and cuts.
- "What's new" after each update, and any time from Settings → Updates. When an update is available, its notes show there before you download it.

### Improved

Expand Down
23 changes: 23 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,29 @@ npm run typecheck # both tsconfigs, node and web
npm run lint # eslint
```

### Release notes

Every change people will notice needs a line in `CHANGELOG.md` under
`## [Unreleased]`, grouped as `### Added`, `### Improved` or `### Fixed`. CI
fails a pull request that changes `src/`, `resources/` or `package.json`
without one. For internal-only changes (tests, CI, docs, refactors with no
visible effect), add the `no release notes` label instead.

These lines are what people read: the release workflow publishes a version's
section as its GitHub release notes, the app shows the same text in Settings →
Updates before someone updates, and again as "What's new" after they do. So
write them for someone using Cutawan, not for a code reviewer:

- Say what changed for them: "Clips appear as soon as they're scored" rather
than "Move eager reframe out of analyzeProject".
- One change per bullet, plain words, no file or function names.
- Measurements, failed trials and limits go in a `### Validation` section (or
a doc linked from it). It appears on GitHub but not in the app.

To release, rename `## [Unreleased]` to `## [x.y.z] - YYYY-MM-DD` in the same
commit as the version bump. The release workflow refuses to build a version
with no notes.

## How the code is laid out

`README.md` has the full tree. The short version:
Expand Down
22 changes: 13 additions & 9 deletions scripts/changelog-section.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@
* release notes.
*
* Plain Node ESM rather than a tsx script like the rest of `scripts/`, so the
* release workflow can run it without an `npm ci` just to get tsx.
* release workflow can run it without an `npm ci` just to get tsx. Mirrors
* `changelogSection` in src/shared/releaseNotes.ts.
*
* Usage: node scripts/changelog-section.mjs 0.7.0
* Exits 0 with empty output if there is no section for that version, so a
* release with no changelog entry still publishes rather than failing.
* Usage: node scripts/changelog-section.mjs 0.7.0 [--require]
* With --require (the release workflow), a missing or empty section fails:
* every release must say what changed. Without it, prints nothing.
*/
import { readFile } from 'node:fs/promises'

const version = process.argv[2]
if (!version) {
console.error('usage: node scripts/changelog-section.mjs <version>')
const required = process.argv.includes('--require')
if (!version || version.startsWith('--')) {
console.error('usage: node scripts/changelog-section.mjs <version> [--require]')
process.exit(2)
}

Expand All @@ -26,10 +28,12 @@ const isHeading = (line) => /^##\s+/.test(line)
const headingVersion = (line) => line.match(/^##\s+\[?([0-9]+\.[0-9]+\.[0-9]+)\]?/)?.[1]

const start = lines.findIndex((l) => headingVersion(l) === version)
if (start === -1) process.exit(0)

const rest = lines.slice(start + 1)
const rest = start === -1 ? [] : lines.slice(start + 1)
const end = rest.findIndex(isHeading)
const body = (end === -1 ? rest : rest.slice(0, end)).join('\n').trim()

if (!body && required) {
console.error(`CHANGELOG.md has no notes for ${version}. Rename "## [Unreleased]" to "## [${version}] - <date>" before releasing.`)
process.exit(1)
}
process.stdout.write(body)
73 changes: 73 additions & 0 deletions scripts/check-release-notes.mjs
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')) {
Comment thread
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(', ')}.`)
6 changes: 6 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ async function runSmokeCapture(win: BrowserWindow, dir: string): Promise<void> {
app.quit()
return
}
// Seeded profiles have projects and no record of seen notes, so the
// after-update "What's new" dialog opens; capture and dismiss it.
if (await win.webContents.executeJavaScript(`Boolean(document.querySelector('[data-testid="whats-new"]'))`)) {
await shot('whats-new')
await click('[data-testid="whats-new-close"]')
}
await shot('home')
await click('[data-testid="project-card"]')
await shot('clips')
Expand Down
2 changes: 2 additions & 0 deletions src/main/updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const CHECK_TIMEOUT_MS = 10_000
interface GithubRelease {
tag_name?: string
html_url?: string
body?: string | null
draft?: boolean
prerelease?: boolean
assets?: ReleaseAsset[]
Expand Down Expand Up @@ -132,6 +133,7 @@ export function evaluateUpdate(
latestVersion,
updateAvailable: latestVersion !== null && compareVersions(latestVersion, currentVersion) > 0,
releaseUrl: (usable ? release?.html_url : null) ?? (latestVersion ? RELEASES_PAGE : null),
releaseNotes: usable ? release?.body?.trim() || null : null,
autoUpdateSupported,
sourceUpdateSupported,
error: null,
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import EditorScreen from './components/EditorScreen'
import SettingsModal from './components/SettingsModal'
import TopBar from './components/TopBar'
import SetupWizard from './components/SetupWizard'
import WhatsNewAfterUpdate from './components/WhatsNew'
import { startUpdateChecks } from '@shared/updateSchedule'

function ScreenView({ screen }: { screen: Screen }): React.JSX.Element {
Expand Down Expand Up @@ -71,6 +72,7 @@ export default function App(): React.JSX.Element {
</main>
{settingsOpen && <SettingsModal />}
{settings && !settings.setupComplete && <SetupWizard />}
<WhatsNewAfterUpdate />
</div>
)
}
18 changes: 18 additions & 0 deletions src/renderer/src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
Server,
} from 'lucide-react'
import { useStore } from '../store'
import { NotesList, userNotes, WhatsNewDialog } from './WhatsNew'
import { parseNotes } from '@shared/releaseNotes'
import SizeTargetControls from './SizeTargetControls'
import { DEFAULT_BRAND_COLORS, resolveCaptionStyle } from '@shared/captionStyles'
import type {
Expand Down Expand Up @@ -836,6 +838,9 @@ function UpdatesSection(): React.JSX.Element {
Object.values(s.exports).some((e) => e.status === 'exporting') ||
Object.values(s.reframeBusy).some(Boolean) || Object.values(s.captionBusy).some(Boolean))

const [notesOpen, setNotesOpen] = useState(false)
const upcoming = updateCheck?.updateAvailable && updateCheck.releaseNotes ? userNotes(parseNotes(updateCheck.releaseNotes)) : []

return (
<div className="max-w-xl">
<label className="flex items-center gap-2 text-sm font-medium">
Expand All @@ -844,7 +849,20 @@ function UpdatesSection(): React.JSX.Element {
</label>
<p className="mt-1 text-xs text-zinc-500">
Cutawan v{settings?.appVersion ?? '…'} — updates are checked on launch and every six hours.
{settings?.appVersion && (
<button type="button" onClick={() => setNotesOpen(true)} data-testid="whats-new-button"
className="ml-1.5 text-zinc-400 underline underline-offset-2 hover:text-zinc-200">
What&apos;s new in this version
</button>
)}
</p>
{notesOpen && settings?.appVersion && <WhatsNewDialog version={settings.appVersion} onClose={() => setNotesOpen(false)} />}
Comment thread
cursor[bot] marked this conversation as resolved.
{upcoming.length > 0 && (
<div className="mt-2.5 rounded-lg border border-surface-600 bg-surface-850 px-3 py-2.5" data-testid="update-notes">
<p className="mb-2 text-xs font-medium text-zinc-200">What&apos;s new in v{updateCheck?.latestVersion}</p>
<NotesList blocks={upcoming} compact />
</div>
)}

{updateDownload.status !== 'idle' || (updateCheck?.updateAvailable &&
(updateCheck.autoUpdateSupported || updateCheck.manualDownloadSupported)) ? (
Expand Down
98 changes: 98 additions & 0 deletions src/renderer/src/components/WhatsNew.tsx
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&apos;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])
Comment thread
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)
}} />
}
Loading
Loading