--- name: cut-release description: Cut a new TMDb release — work out the next SemVer version from the evidence, do the pre-tag housekeeping a tag would otherwise freeze in place, draft release notes, then tag and publish the GitHub release. Presents the version (with its reasoning) and the full notes for approval and STOPS; nothing is tagged or published until you say go. --- # Cut Release Takes `main` from "a pile of merged PRs" to "a tagged, published release". **This skill is never headless.** Every other skill in this repo may decide for itself; this one may not. A tag is the one artifact consumers resolve against, and publishing one is effectively irreversible: SwiftPM hands a new **minor or patch** to everyone inside the current `from:` range the moment it exists, a new **major** reaches everyone who has since bumped, and deleting a tag does not un-fetch it from anyone who already resolved. Phases 1–5 are read-only and local. Phase 6 is a **hard stop**. Phases 7–9 run only on an explicit go-ahead. If invoked with no human able to answer, run to the hard stop, print the summary, and exit. Do not tag. ## Phase 1 — Preflight Stop on any failure; report which one and what to do. - On `main`, clean tree, in sync with `origin/main` (`git status --porcelain`, `git rev-list --count main..origin/main`). - `git fetch --tags`; establish the last release: `git tag --sort=-v:refname | head -1`. - CI is **green on the current `main` tip**: `gh api repos/adamayoung/TMDb/commits//check-runs`. This is the *entry* check — the commit that actually gets tagged does not exist yet (Phase 7 creates it), and Phase 7 re-checks that one. Starting from a red `main` just means finding out later. - There is something to release — at least one commit since the last tag that is not purely a tag-housekeeping commit. ## Phase 2 — Work out the version Two independent signals. They must agree, or you stop. **Signal A — the CHANGELOG (authoritative).** The open `## [X.Y.Z]` section at the top (no date = unreleased) is the *proposal*. Count `**Breaking:**` markers inside it, and read the section headings: - any `**Breaking:**` → **major** - otherwise any `### Added` content → **minor** - otherwise (`### Fixed` / `### Changed` only) → **patch** **Signal B — the commits (cross-check).** `git log ..HEAD`, bucketed by gitmoji. Consumer-visible: `✨` feature, `🐛` fix, `♻️` refactor, `🔒` security, `⚡️` perf. Not consumer-visible: `🔧` chore, `📝` docs, `👷` CI, `✅` tests, `📦` build. Count multibyte-safely — `sort | uniq -c` over emoji collapses distinct ones in a non-UTF-8 locale and will hand you a confidently wrong tally. **Gitmoji alone is not enough to pick a version in this repo, and you must not try.** A `🐛` here is routinely source-breaking: "🐛 Surface task cancellation as `TMDbError.cancelled`" (PR #433) was filed as a fix and added an enum case, which breaks every exhaustive `switch` downstream. Signal B exists to catch *omissions* from the CHANGELOG, not to compute the bump. Then reconcile: - The proposed section heading must match what Signal A computes from its own content. A section headed `[20.0.0]` containing no `**Breaking:**` is a mis-labelled release — **stop and report**, do not silently downgrade it. - `knowledge/next-major.md`'s status line names the open window. If it disagrees with the CHANGELOG heading, stop. - The computed tag must not already exist. **Coverage check.** Every commit since the last tag that touches `Sources/` and carries a consumer-visible gitmoji should be represented somewhere in the open CHANGELOG section. List any that are not. This is the check that catches a real change shipping invisibly; the not-consumer-visible buckets legitimately have no CHANGELOG entry and are not flagged. ## Phase 3 — Pre-tag housekeeping **Branch first.** `git checkout -b chore/prepare--release`. Phase 1 put you on a clean `main`, and CLAUDE.md forbids editing there — but the practical reason is re-entrancy: if the run is abandoned at the Phase 6 stop, edits made on `main` leave a dirty tree that fails this skill's *own* Phase 1 preflight on the next attempt. Branching now costs nothing and makes an abort free. **A tag snapshots the tree**, so anything that says "unreleased" is frozen saying it, forever, inside the very release it describes. These four are not optional, and all of them were needed for 19.0.0 (PR #406): 1. **`README.md` install snippet.** `from: ""` resolves to `>=prev, ` block: every commit since the last tag, one line each — ` `. Collapsed, because it is a reference, not a read. 5. **Full Changelog** compare link: `https://github.com/adamayoung/TMDb/compare/...`. ### Voice Slack's iOS notes are the reference: they open with something human, and they never pretend a bugfix release is a moon landing. Aim for a colleague writing to other developers — dry, specific, occasionally silly, never markety. - Good: naming the actual absurdity. *"This release is mostly about a movie database telling us, with total confidence, that Bryan Cranston has never been in a TV series."* - Good: cheerful understatement. *"Eighteen of twenty tagged images were quietly going in the bin. They are no longer going in the bin."* - Avoid: "We're excited to announce", "packed with", "🚀", exclamation marks in rows, and any claim the diff does not support. - Avoid: whimsy about the *breaking* part. Joke about the bug you fixed, not about the work you have just made someone do. If this release breaks builds, the opener stays light but stays short — then get straight to the impact. Length: the opener is 2–3 sentences. If it needs a fourth, it is a paragraph, and paragraphs are what section 3 is for. ## Phase 5 — Assemble the summary Put together, for one message: - **Version** and the **reasoning** — the deciding evidence, not a recitation. "Major: 14 `**Breaking:**` entries, including four public enums gaining cases" beats "there were breaking changes". - Anything Signal B flagged as missing from the CHANGELOG. - The four housekeeping edits, as a diff summary. - **The complete release notes**, verbatim as they will be published. ## Phase 6 — STOP Present the summary and **wait**. Do not tag, push, or publish anything. Ask plainly whether to go ahead. Treat anything short of a clear yes as a no — "looks good" about the *notes* is not approval to *publish*. If the answer is a change request, apply it and return to this phase; the gate re-arms every time. ## Phase 7 — Land the housekeeping Only after approval. The branch already exists from Phase 3. Commit the Phase 3 edits as `🔧 Prepare the release`, open the PR via `/pr`, and let its CI run. The narrowed docs/config gate usually applies here — the diff is Markdown only — but `/pr` owns that call; do not pre-empt it. Merge once green. **Then wait for `main`'s own run.** `ci.yml` triggers on push to `main` with `**/*.md` among its paths, so the squashed merge commit gets a CI run of its own — a *different* commit from the PR head, and the one the tag will point at. Phase 1's "green on the exact commit being tagged" means this run, not the PR's. Poll it: ```bash gh api repos/adamayoung/TMDb/commits/$(git rev-parse HEAD)/check-runs \ --jq '[.check_runs[] | {name, status, conclusion}]' ``` Red or still running → do not tag. This is the cheapest place in the whole skill to be patient. ## Phase 8 — Tag On the merged `main`, at the housekeeping commit: ```bash git checkout main && git pull git tag -a -m "" git push origin ``` Annotated, not lightweight — a release tag should carry an author and a date. **Regenerate the commit list before publishing.** The notes were drafted in Phase 4, before the housekeeping merge existed — so their `
` list is short by exactly the commit being tagged, while the compare link below it includes it. Re-run the log against the new tag and refresh that block. ## Phase 9 — Publish ```bash gh release create --repo adamayoung/TMDb \ --title "" --notes-file --verify-tag --latest ``` `--verify-tag` refuses to invent a tag that does not exist, which is what turns a typo into a failed command instead of a phantom release. Then confirm: print the release URL, and re-read the published notes once to check nothing was mangled in transit. ## After Report the tag, the release URL, and what the next window is now open for. If any `knowledge/next-major.md` entries were deliberately carried forward rather than shipped, name them — that is the moment someone can still object. ## Failure modes worth knowing - **A red tag.** Tagging a commit whose CI never passed. Phase 1 exists for this; do not skip it because "the PR was green" — the merge commit is a different commit, and it gets its own run. - **Assuming a bad tag is cheap to undo.** Two active tag rulesets cover `~ALL` tags with `creation`, `update` and `deletion` rules. The repository-role bypass means the owner *can* force it, but nothing here is a casual `git push --delete` — and SwiftPM consumers who already resolved the version are unaffected by any of it. Get it right going in. - **A frozen "unreleased".** Skipping Phase 3 ships ADRs and a `next-major.md` that insist, inside the release, that the release has not happened. - **A README nobody can follow.** The `from:` bump is invisible in testing and breaks discovery for every new consumer on a major. - **Version invented from commits.** Deriving the bump from gitmoji rather than the CHANGELOG will under-call a major in this repo, roughly every time.