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, novprefix, no build number, no suffix. Examples:release/0.0.10,release/1.2.0. Beta cuts userelease/X.Y.Z-betaN(e.g.release/0.1.0-beta1). - PR title:
Release X.Y.Z (N)whereNisCURRENT_PROJECT_VERSION. Example:Release 0.0.10 (14). This matches the commit messagescripts/release.shwrites 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) and the newCHANGELOG.mdentry — 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.
- Fetch latest
origin/mainand 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. - Use the requested version, or the next patch version in this project's existing release series when unspecified. Increment
CURRENT_PROJECT_VERSIONby one from the latest release metadata. Check open release PRs to avoid duplicating a version already being prepared. - Update both
MARKETING_VERSIONandCURRENT_PROJECT_VERSIONinVersion.xcconfig, and add the matchingCHANGELOG.mdentry usingchangelog-maintenance, including contributor credits. - 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. Do not claim app tests were run for metadata-only validation. - 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.
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.mdentry, before running the script; - a
## [X.Y.Z]entry must already exist inCHANGELOG.mdfor the version being released (the script only validates and extracts it — it never writes the entry itself; that's thechangelog-maintenanceskill's job, see below); amoremust be logged in (amore whoami);- unless
--skip-githubor--draft, theghCLI must be installed and authenticated, and anoriginremote must exist — if there's noorigin, the script falls back to--skip-githubon 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:
- Resolves the version and build number (see the flag reference below).
- If
Version.xcconfigdoesn't already match the resolved version/build, updates it and commits directly on whatever branch is currently checked out, asRelease X.Y.Z (N). - Extracts that version's release notes from
CHANGELOG.md. - Runs
amore release— archives, signs, builds the DMG, notarizes, uploads, and — unless--draftwas passed — publishes the update to Amore's live appcast (check the currentSUFeedURLand Amore configuration as described in AGENTS.md's Release references). This is the actual "ship it" step. - Unless
--skip-githubor--draft: downloads the DMG, creates and pushes thevX.Y.Ztag, 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
./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:
## [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
./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.plistcurrently useshttps://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.