SLOPSHOPPER

release-checklist

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

newpaneguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · release-checklist
│ ┃ Release checklist ✕ › fix the failing auth test and add an audit log call │ ┃ 5 of 7 checks failed │ ┃ ✗ project.yml · missing ⏺ Read(src/auth.ts) │ ┃ ✗ release.sh · Scripts/release.sh missing ⎿ Read 6 lines │ ┃ – release/ output · no release.sh ⏺ Update(src/auth.ts) │ ┃ ✗ .gitignore · *.dmg not ignored ⎿ Added 2 lines, removed 1 line │ ┃ ✗ Developer ID · no Developer ID Application ⏺ Bash(bun test) │ ┃ identity ⎿ 3 pass, 1 fail │ ┃ ✗ git · 2 changes on feat/auth-refresh │ ┃ – CHANGES.md · no version given ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /release-checklist │ ⎿ release-checklist: Release checklist: 5 of 7 checks failed. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Release checklist
5 of 7 checks failed ✗ project.yml · missing ✗ release.sh · Scripts/release.sh missing – release/ output · no release.sh ✗ .gitignore · *.dmg not ignored ✗ Developer ID · no Developer ID Application identity ✗ git · 2 changes on feat/auth-refresh – CHANGES.md · no version given
README

release-checklist

Runs pre-release checks for a macOS app project in a pane, and reminds you to verify the build after a release script runs.

What it does

  • /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).
  • After a successful Bash command running 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.

Commands

CommandEffect
/release-checklist [version]Run the checks and open the pane
/release-checklist closeClose the pane

Options

OptionTypeDefaultMeaning
notaryProfilestringAppliMacVincentGithubExpected default of NOTARY_PROFILE in Scripts/release.sh — set it to your own notarytool keychain profile

Install

claude --plugin-dir /path/to/ModsTools/mods/release-checklist

Limits

  • Specific to one workflow: XcodeGen project.yml + Scripts/release.sh with a NOTARY_PROFILE default and a RELEASE_DIR.
  • The version check passes if any MARKETING_VERSION in project.yml matches.
  • Checks are a snapshot; rerun the command to refresh. The checklist never builds, signs or notarizes anything.

Develop

claude plugin validate mods/release-checklist
claude plugin test mods/release-checklist   # 14 tests
Source 3 files
hooks/register.tsx 92 lines
1import { 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}
92
hooks/checks.ts 113 lines
1import 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)
113
types/index.d.ts 10 lines
1export 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