A pane running pre-release checks for a macOS project and a post-release verification reminder

Runs pre-release checks for a macOS app project in a pane, and reminds you to verify the build after a release script runs.
/release-checklist [version] gathers data from the session folder and opens a pane with one line per check (✓ ok, ✗ fail, – skipped) and a summary (1 of 7 checks failed):project.yml: has a MARKETING_VERSION, and one matching the given version if any.release.sh: Scripts/release.sh default NOTARY_PROFILE="${NOTARY_PROFILE:-...}" matches the expected profile (option notaryProfile; code only, comments ignored).release/ output: RELEASE_DIR in the script points into a release folder..gitignore: ignores *.dmg.Developer ID: security find-identity -v -p codesigning lists a Developer ID Application identity.git: working tree clean and not on main.CHANGES.md: mentions the version (skipped when no version is given).release.sh, shows a 15 s toast: verify with spctl -a -t exec -vv <App>.app, xcrun stapler validate <dmg>, codesign --verify --deep --strict <App>.app.| Command | Effect |
|---|---|
/release-checklist [version] | Run the checks and open the pane |
/release-checklist close | Close the pane |
| Option | Type | Default | Meaning |
|---|---|---|---|
notaryProfile | string | AppliMacVincentGithub | Expected default of NOTARY_PROFILE in Scripts/release.sh — set it to your own notarytool keychain profile |
claude --plugin-dir /path/to/ModsTools/mods/release-checklist
project.yml + Scripts/release.sh with a NOTARY_PROFILE default and a RELEASE_DIR.MARKETING_VERSION in project.yml matches.claude plugin validate mods/release-checklist
claude plugin test mods/release-checklist # 14 testshooks/register.tsx 92 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Report } from '../types'
5import { DEFAULT_NOTARY_PROFILE, REMINDER, isReleaseRun, row, runChecks, summary } from './checks'
6import type { Inputs } from './checks'
7
8const PANE = 'release-checklist'
9const TITLE = 'Release checklist'
10const report = atom({ plugin: 'release-checklist', key: 'report' } as const, null)
11
12async function file($: EngineInterface, cwd: string, name: string): Promise<string | null> {
13 try {
14 return await $.fs.read(`${cwd}/${name}`)
15 } catch {
16 return null
17 }
18}
19
20async function run($: EngineInterface, cwd: string, argv: string[]): Promise<string | null> {
21 try {
22 const ran = await $.process.run(argv, { cwd, timeoutMs: 15_000 })
23 return ran.exitCode === 0 ? ran.stdout : null
24 } catch {
25 return null
26 }
27}
28
29async function gather($: EngineInterface, version: string): Promise<Inputs> {
30 const cwd = await $.session.cwd()
31
32 return {
33 version,
34 project: await file($, cwd, 'project.yml'),
35 release: await file($, cwd, 'Scripts/release.sh'),
36 gitignore: await file($, cwd, '.gitignore'),
37 identities: await run($, cwd, ['security', 'find-identity', '-v', '-p', 'codesigning']),
38 porcelain: await run($, cwd, ['git', 'status', '--porcelain']),
39 branch: await run($, cwd, ['git', 'rev-parse', '--abbrev-ref', 'HEAD']),
40 changes: await file($, cwd, 'CHANGES.md'),
41 }
42}
43
44export const register: Register = (on, options) => {
45 const profile =
46 typeof options?.notaryProfile === 'string' && options.notaryProfile.trim() !== '' ? options.notaryProfile.trim() : DEFAULT_NOTARY_PROFILE
47
48 on('session.start', async ($, e, next) => {
49 await $.command.register({
50 name: 'release-checklist',
51 description: 'Run the pre-release checks of this macOS project in a pane ([version] | close)',
52 })
53
54 return next(e)
55 })
56
57 on('command.run', { command: 'release-checklist' }, async ($, e) => {
58 const arg = e.args.trim()
59 if (arg === 'close' || arg === 'off') {
60 await $.ui.close({ id: PANE })
61 return { text: 'Release checklist closed.' }
62 }
63 const next: Report = { version: arg, checks: runChecks(await gather($, arg), profile) }
64 await update($, report, () => next)
65 await $.ui.open({ id: PANE, title: TITLE })
66
67 return { text: `Release checklist: ${summary(next.checks)}.` }
68 })
69
70 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
71 const ran = await next(e)
72 if (isReleaseRun(e.command) && ran.deny === undefined && ran.isError !== true) $.ui.toast(REMINDER, { timeoutMs: 15_000 })
73
74 return ran
75 })
76
77 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
78 const { Box, Text } = $.ui.resolve(e)
79 const current: Report | null = await read($, report)
80 if (current === null) return <Text dimColor>Run /release-checklist [version].</Text>
81
82 return (
83 <Box flexDirection="column">
84 <Text bold>{current.version === '' ? summary(current.checks) : `${current.version} · ${summary(current.checks)}`}</Text>
85 {current.checks.map(check => (
86 <Text dimColor={check.state === 'skip'}>{row(check, e.props.bodyColumns)}</Text>
87 ))}
88 </Box>
89 )
90 })
91}
92hooks/checks.ts 113 lines1import type { Check, CheckState } from '../types'
2
3export const DEFAULT_NOTARY_PROFILE = 'AppliMacVincentGithub'
4export const REMINDER =
5 'Release done. Verify: spctl -a -t exec -vv <App>.app · xcrun stapler validate <dmg> · codesign --verify --deep --strict <App>.app'
6
7/** What the pane gathers from the folder; null means unreadable or absent. */
8export type Inputs = {
9 version: string
10 project: string | null
11 release: string | null
12 gitignore: string | null
13 identities: string | null
14 porcelain: string | null
15 branch: string | null
16 changes: string | null
17}
18
19const code = (text: string) => text.split('\n').filter(line => !/^\s*#/.test(line))
20
21export const marketingVersions = (yml: string): string[] =>
22 [...yml.matchAll(/^\s*MARKETING_VERSION:\s*["']?([^"'\s#]+)/gm)].map(one => one[1]!)
23
24/** The default of `NOTARY_PROFILE="${NOTARY_PROFILE:-X}"`, comments ignored. */
25export const notaryProfile = (script: string): string | null =>
26 /^\s*(?:export\s+)?NOTARY_PROFILE="\$\{NOTARY_PROFILE:-([^}"]*)\}"/m.exec(code(script).join('\n'))?.[1] ?? null
27
28export const releaseDir = (script: string): string | null =>
29 /^\s*RELEASE_DIR=["']?([^"'\n]*)/m.exec(code(script).join('\n'))?.[1] ?? null
30
31export const ignoresDmg = (gitignore: string): boolean =>
32 code(gitignore).some(line => ['*.dmg', '**/*.dmg'].includes(line.trim()))
33
34export const hasDeveloperId = (output: string): boolean => output.includes('Developer ID Application:')
35
36const make = (label: string, state: CheckState, detail: string): Check => ({ label, state, detail })
37const pass = (ok: boolean) => (ok ? 'ok' : 'fail')
38
39export function runChecks(i: Inputs, expectedProfile = DEFAULT_NOTARY_PROFILE): Check[] {
40 const list: Check[] = []
41
42 if (i.project === null) list.push(make('project.yml', 'fail', 'missing'))
43 else {
44 const found = marketingVersions(i.project)
45 const first = found[0]
46 if (first === undefined) list.push(make('project.yml', 'fail', 'no MARKETING_VERSION'))
47 else if (i.version === '') list.push(make('project.yml', 'ok', `MARKETING_VERSION ${first}`))
48 else list.push(make('project.yml', pass(found.includes(i.version)), `MARKETING_VERSION ${found.join(', ')}, wanted ${i.version}`))
49 }
50
51 if (i.release === null) {
52 list.push(make('release.sh', 'fail', 'Scripts/release.sh missing'))
53 list.push(make('release/ output', 'skip', 'no release.sh'))
54 } else {
55 const profile = notaryProfile(i.release)
56 list.push(
57 make(
58 'release.sh',
59 pass(profile === expectedProfile),
60 profile === null ? 'no NOTARY_PROFILE default' : `NOTARY_PROFILE default ${profile}`,
61 ),
62 )
63 const dir = releaseDir(i.release)
64 list.push(
65 make('release/ output', pass(dir !== null && dir.includes('/release')), dir === null ? 'no RELEASE_DIR' : `RELEASE_DIR ${dir}`),
66 )
67 }
68
69 if (i.gitignore === null) list.push(make('.gitignore', 'fail', 'missing'))
70 else list.push(make('.gitignore', pass(ignoresDmg(i.gitignore)), ignoresDmg(i.gitignore) ? '*.dmg ignored' : '*.dmg not ignored'))
71
72 if (i.identities === null) list.push(make('Developer ID', 'skip', 'security unavailable'))
73 else list.push(make('Developer ID', pass(hasDeveloperId(i.identities)), hasDeveloperId(i.identities) ? 'identity found' : 'no Developer ID Application identity'))
74
75 if (i.porcelain === null || i.branch === null) list.push(make('git', 'skip', 'not a git repository'))
76 else {
77 const dirty = i.porcelain.split('\n').filter(line => line.trim() !== '').length
78 const branch = i.branch.trim()
79 const clean = dirty === 0
80 const off = branch !== 'main'
81 list.push(
82 make(
83 'git',
84 pass(clean && off),
85 `${clean ? 'clean' : `${dirty} change${dirty === 1 ? '' : 's'}`} on ${branch}${off ? '' : ' (not main!)'}`,
86 ),
87 )
88 }
89
90 if (i.version === '') list.push(make('CHANGES.md', 'skip', 'no version given'))
91 else if (i.changes === null) list.push(make('CHANGES.md', 'fail', 'missing'))
92 else {
93 const mentioned = i.changes.includes(i.version)
94 list.push(make('CHANGES.md', pass(mentioned), mentioned ? `mentions ${i.version}` : `no mention of ${i.version}`))
95 }
96
97 return list
98}
99
100export const mark = (state: CheckState) => (state === 'ok' ? '✓' : state === 'fail' ? '✗' : '–')
101
102export function summary(checks: Check[]): string {
103 const failed = checks.filter(one => one.state === 'fail').length
104 return failed === 0 ? `${checks.length} checks, none failed` : `${failed} of ${checks.length} checks failed`
105}
106
107export function row(check: Check, columns: number): string {
108 const line = `${mark(check.state)} ${check.label} · ${check.detail}`
109 return line.length > columns ? line.slice(0, columns - 1) + '…' : line
110}
111
112export const isReleaseRun = (command: string) => /(^|[\s;&|/])release\.sh(\s|$)/.test(command)
113types/index.d.ts 10 lines1export type CheckState = 'ok' | 'fail' | 'skip'
2export type Check = { label: string; state: CheckState; detail: string }
3export type Report = { version: string; checks: Check[] }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'release-checklist': { report: Report | null }
8 }
9}
10