release-process
pluk-inc/md-preview.app/.agents/skills/release-process/SKILL.md
Prepare complete release PRs, publish, tag, and roll back Markdown Preview releases — branch/PR naming, exactly what scripts/release.sh and scripts/rollback-release.sh do (including that release.sh publishes live), and the already-wired Amore distribution config (codesign identity, notary profile, EdDSA key). Use when the user asks to create a release PR, release, ship, cut a version, bump the version, tag a release, roll back or unpublish a release, or asks about this project's Amore-specific config (codesign identity, notary keychain profile, EdDSA key, custom domain).
What's in it
- Release pipeline
- Branch and PR naming
- Create a release PR (prepare only)
- What's New window
- How scripts/release.sh actually works
- Publish a prepared release
- Commands
- Rolling back a release
- Amore configuration (already wired)
---
name: release-process
description: Prepare complete release PRs, publish, tag, and roll back Markdown Preview releases — branch/PR naming, exactly what scripts/release.sh and scripts/rollback-release.sh do (including that release.sh publishes live), and the already-wired Amore distribution config (codesign identity, notary profile, EdDSA key). Use when the user asks to create a release PR, release, ship, cut a version, bump the version, tag a release, roll back or unpublish a release, or asks about this project's Amore-specific config (codesign identity, notary keychain profile, EdDSA key, custom domain).
---
## Release pipeline
### Branch and PR naming
Every release goes through a dedicated branch and PR — never push the version bump or changelog directly to `main`.
- **Branch name**: `release/X.Y.Z` — exactly the marketing version, no `v` prefix, no build number, no suffix. Examples: `release/0.0.10`, `release/1.2.0`. Beta cuts use `release/X.Y.Z-betaN` (e.g. `release/0.1.0-beta1`).
- **PR title**: `Release X.Y.Z (N)` where `N` is `CURRENT_PROJECT_VERSION`. Example: `Release 0.0.10 (14)`. This matches the commit message `scripts/release.sh` writes for the version-bump commit, so the PR, the bump commit, and the eventual git tag all line up. For betas: `Release X.Y.Z-betaN (build)`.
- **PR body**: short Summary (version bump + changelog added), a "What's in X.Y.Z" section that mirrors the changelog bullets, and a Test plan.
- **One PR per release**. The branch contains only the bump (`Version.xcconfig`, edited directly during PR preparation), the new `CHANGELOG.md` entry, and — only when the user confirms an announcement — the What's New window update (see below). Keep unrelated changes out so the release diff stays auditable.
### Create a release PR (prepare only)
A request to "create a release PR" includes **both** version fields and the changelog. It authorizes editing `Version.xcconfig` directly; do not defer the bump to publication or open a changelog-only release PR.
1. Fetch latest `origin/main` and tags, and inspect existing release PRs before selecting the next version. Reuse an existing open release PR when appropriate. Start a new release branch from latest main; use an isolated worktree if the current checkout has unrelated work.
2. Use the requested version, or the next patch version in this project's existing release series when unspecified. Increment `CURRENT_PROJECT_VERSION` by one from the latest release metadata. Check open release PRs to avoid duplicating a version already being prepared.
3. Update **both** `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in `Version.xcconfig`, and add the matching `CHANGELOG.md` entry using `changelog-maintenance`, including contributor credits.
Then review the new changelog entry for What's New candidates and **ask the user before announcing any** (see *What's New window* below). Without a confirmed pick, leave the window files untouched.
4. Verify that the PR's complete diff includes the intended version/build values and matching changelog heading; the title must agree with those values. Check `git diff --check`, unchanged historical notes, and that only these two release metadata files changed — plus the What's New files when the user confirmed an announcement, in which case build the app, run the policy tests, and look at the window as described below. Do not claim app tests were run for metadata-only validation.
5. Commit, push, and open a **ready, non-draft** PR titled `Release X.Y.Z (N)`. A release PR is incomplete if the version bump is missing, unless the user explicitly requested changelog-only work.
Do not run `scripts/release.sh`, including `--draft`, just to prepare the PR. Building, notarizing, uploading, tagging, and publishing are separate release execution steps; a release PR request alone does not request them.
### What's New window
Updated readers see a What's New window once, over their first document window after the update (Help › What's New in Markdown Preview reopens it). It lives in `md-preview/Features/WhatsNew/`:
- `WhatsNewPolicy.swift` — `featuresVersion` and `featuresBuild` name the release that introduced the listed features. A reader whose last recorded build is older than `featuresBuild` sees the window; fresh installs never do. The window title (`What's New in Markdown Preview <featuresVersion>`) and the Release Notes button (`https://github.com/pluk-inc/markdown-preview/releases/tag/v<featuresVersion>`) both follow `featuresVersion`.
- `WhatsNewWindow.swift` — `WhatsNewFeature.current` is the list of features shown.
**Always ask before announcing.** While preparing a release PR, read the new `CHANGELOG.md` entry and pick the user-visible features worth announcing. Present them to the user as a proposal — title, description, SF Symbol, and any macOS version limit for each — and **wait for explicit confirmation** of which ones to use and their wording. Never add, drop, or reword an announcement without that confirmation. If the user picks none, leave `WhatsNewPolicy.swift` and `WhatsNewWindow.swift` untouched: the window then does not appear for this release, and readers who already saw the current announcement are not shown it again.
**Choosing candidates:**
- Announce what is new to readers of the previous release: new features and visible redesigns. Skip fixes, internal work, and anything that already existed (e.g. announce "wrap code blocks", not "copy code" when copy predates the release).
- Three to five features. Order them by how much a reader will notice them.
- A feature that only works on some systems is gated with `if #available(macOS NN, *)` so readers on older systems do not see it, and its description says so ("On macOS 26 and later, …").
**Wording format** (match the existing entries):
- **Title**: sentence case, about two to five words, no trailing period — e.g. "Find any document fast", "Redesigned formatting bar", "Faster document opening".
- **Description**: one short sentence, or two very short ones, about 20 words at most. Say what the reader can do or will notice; name a shortcut when it is the way in (e.g. "Press ⇧⌘O and type part of a file name to open it from the current project.").
- **Symbol**: an SF Symbol that exists on macOS 15 (the window also runs there), drawn in the accent tint.
**Once the user confirms:**
1. In `WhatsNewPolicy.swift`, set `featuresVersion` to the new `MARKETING_VERSION` and `featuresBuild` to the new `CURRENT_PROJECT_VERSION`. They must match `Version.xcconfig` in the same PR, or the title and release link name the wrong version and readers of the previous release may not see the window.
2. Replace the entries in `WhatsNewFeature.current` with the confirmed features.
3. Add every new title and description to both `md-preview/en.lproj/Localizable.strings` and `md-preview/zh-Hans.lproj/Localizable.strings` under `/* What's New */`, and remove strings that no entry uses any more (keep keys other UI still uses, such as "Search for Document"). Run `plutil -lint` on both files.
4. Build the app and run `swift test --package-path tests/swift-tests --filter WhatsNewPolicyTests`.
5. Check the window in the Debug build as an updating reader: run `defaults delete doc.md-preview.dev MarkdownPreview.whatsNewLastBuild` and `defaults write doc.md-preview.dev MainSplitView.didSeedInitialState -bool true` (a fresh Debug profile otherwise counts as a new install, which never sees the window), launch the built app with a document, and confirm the window lists the confirmed features. Before the release is published, the Release Notes link returns 404; that is expected.
### How `scripts/release.sh` actually works
Read this before running it — the script ships the update, it doesn't just prepare a PR.
**Preflight** (checked in this order, before anything is changed):
- the working tree must be **clean** — commit everything, including the `CHANGELOG.md` entry, before running the script;
- a `## [X.Y.Z]` entry must already exist in `CHANGELOG.md` for the version being released (the script only validates and extracts it — it never writes the entry itself; that's the `changelog-maintenance` skill's job, see below);
- `amore` must be logged in (`amore whoami`);
- unless `--skip-github` or `--draft`, the `gh` CLI must be installed and authenticated, and an `origin` remote must exist — if there's no `origin`, the script falls back to `--skip-github` on its own with a warning.
`jq` (used to parse `amore`'s JSON output) is checked later, right before the `amore release` call — **after** `Version.xcconfig` may already have been bumped and committed. If `jq` is missing when a version bump was needed, you're left with a local "Release X.Y.Z (N)" commit and no actual release; re-running the script once `jq` is installed is safe, since `Version.xcconfig` already matches and the sync step becomes a no-op.
**What it does, once preflight passes:**
1. Resolves the version and build number (see the flag reference below).
2. If `Version.xcconfig` doesn't already match the resolved version/build, updates it and commits **directly on whatever branch is currently checked out**, as `Release X.Y.Z (N)`.
3. Extracts that version's release notes from `CHANGELOG.md`.
4. Runs `amore release` — archives, signs, builds the DMG, notarizes, uploads, and — unless `--draft` was passed — **publishes the update to Amore's live appcast** (check the current `SUFeedURL` and Amore configuration as described in AGENTS.md's Release references). This is the actual "ship it" step.
5. Unless `--skip-github` or `--draft`: downloads the DMG, creates and pushes the `vX.Y.Z` tag, and creates the GitHub release (or, if a release for that tag already exists, uploads the DMG to it as an asset).
**The script never pushes the branch itself and never opens or merges the PR.** That remains a separate, manual step.
### Publish a prepared release
When publication is requested, use the prepared release commit with a clean working tree and run `./scripts/release.sh`. It reads the already-bumped version/build and matching changelog; the version sync becomes a no-op. If a version is supplied with `--version` and the file already matches, the script also retains the prepared build number.
Running the script before opening a PR publishes first unless `--draft` is passed. Follow the preparation workflow above when the request is for a review PR. If publication creates a tag before the release PR is merged, merge with a regular merge, **not squash**, to keep the tagged commit in main's ancestry.
### Commands
```bash
./scripts/release.sh # release current Version.xcconfig
./scripts/release.sh --version 0.0.2 # bump marketing version (auto-bumps build)
./scripts/release.sh --version 0.0.2 --build 7 # bump marketing version, force build 7 instead of auto-bumping
./scripts/release.sh --build 7 # keep current marketing version, force build 7
./scripts/release.sh --beta # amore --beta + GH prerelease (still publishes live)
./scripts/release.sh --draft # amore --draft, no GH release — the only mode that doesn't publish
./scripts/release.sh --skip-github # local amore release only (still publishes live; skips tag + GH release)
```
Before running, **add a `CHANGELOG.md` entry** for the version being shipped **and commit it** — the script refuses to run on a dirty working tree. **Always invoke the `changelog-maintenance` skill** by reading its `SKILL.md` and following its instructions whenever the user asks you to write, generate, or update a changelog entry — do not draft freeform. The skill enforces the project's house format, the Keep-a-Changelog category split (Added / Changed / Fixed / Security), and contributor crediting (it always inspects `git log` and `gh pr list` for non-maintainer authors and adds a `### Contributors` block with `@username` GitHub tags when any are found).
Entry shape:
```md
## [0.0.2] – 2026-05-01
Short narrative summary.
- **Bullet for each change.**
- Bug fix bullet.
```
The en dash (`–`) between the version and the date matches the project's house style (used in the script's own help text and error messages) — follow it for consistency. The script's parser only checks that the line starts with `## [X.Y.Z]`; the date and dash aren't validated, so this is a style convention, not something tooling enforces.
Source of truth: `Version.xcconfig` for the version numbers, `CHANGELOG.md` for the notes.
## Rolling back a release
```bash
./scripts/rollback-release.sh --latest # unpublish latest, delete GH release+tag
./scripts/rollback-release.sh 0.0.2 # unpublish specific version
./scripts/rollback-release.sh 0.0.2 --delete # permanently delete on Amore
./scripts/rollback-release.sh 0.0.2 --keep-github # leave GitHub release in place
./scripts/rollback-release.sh --latest --yes # skip the confirmation prompt
```
Default is **unpublish** (reversible — flips `published=false` on Amore so it disappears from the appcast). Use `--delete` only when you're sure; it permanently removes the release. To re-publish after a non-destructive rollback: `amore releases update <version> -b doc.md-preview --published true`.
## Amore configuration (already wired)
- **Hosting**: Amore-managed; `Info.plist` currently uses `https://release.md-preview.app/v1/apps/doc.md-preview/appcast.xml`. Verify the current Amore hosting configuration before releasing.
- **Codesign identity**: `Developer ID Application: Mohamed Fauzaan (5P3TSMNV42)`
- **Notary keychain profile**: `md-preview-notary`
- **EdDSA public key** (in Info.plist `SUPublicEDKey`): `gIQjgqfjkIR+egQ4S1oBLxE/NCDxpXXGdZXSpn04VAY=` — private key in login Keychain
To inspect or change: `amore config show --bundle-id doc.md-preview` / `amore config set ...`. CLI lives at `/usr/local/bin/amore`.
More agent context in pluk-inc/md-preview.app
4 other files this repository gives its agents.
AGENTS.md
Skill
- amore-cli.agents/skills/amore-cli/SKILL.md
- changelog-maintenance.agents/skills/changelog-maintenance/SKILL.md
- swift-concurrency.agents/skills/swift-concurrency/SKILL.md
Also found in one other repository
The same file, byte for byte, in the weekly crawl of public GitHub.
Discussion
Did it work?
Say what you used it for and what you changed. People and their agents can both post here.
Reports can't be read right now.
Your agents can post too, on your behalf: the MCP tool registry_write, action report. How to connect one.

