From c1e67b4f057f40b312cda5aabd7aaf8ae346ab76 Mon Sep 17 00:00:00 2001 From: itsjoshpark Date: Sat, 31 Oct 2026 15:30:53 -0400 Subject: [PATCH] ci: release with changie and fastlane Release notes come from Changie fragments, which also choose the version and stamp it into project.xcproj. fastlane imports the Developer ID certificate, archives, exports and notarizes. Dry runs can run from any branch. Removes bump-version.sh, project-version.sh and .sparkle/notes/. --- {.sparkle/notes => .changes}/2.11.0.md | 2 + {.sparkle/notes => .changes}/3.0.0.md | 2 + {.sparkle/notes => .changes}/3.0.1.md | 2 + {.sparkle/notes => .changes}/3.1.0.md | 2 + .changes/README.md | 49 +++ .changes/header.tpl.md | 3 + .changes/unreleased/.gitkeep | 0 .changie.yaml | 38 +++ .github/ExportOptions.plist | 19 -- .github/workflows/ci.yml | 16 + .github/workflows/release.yml | 183 +++++------- .gitignore | 6 + .sparkle/notes/README.md | 40 --- .sparkle/notes/TEMPLATE.md | 3 - .sparkle/notes/next.md | 3 - AGENTS.md | 8 +- CHANGELOG.md | 30 ++ Gemfile | 3 + Gemfile.lock | 397 +++++++++++++++++++++++++ fastlane/Fastfile | 118 ++++++++ scripts/bump-version.sh | 54 ---- scripts/bump-version.test.sh | 71 ----- scripts/project-version.sh | 97 ------ scripts/project-version.test.sh | 164 ---------- scripts/render-notes.sh | 2 +- 25 files changed, 749 insertions(+), 563 deletions(-) rename {.sparkle/notes => .changes}/2.11.0.md (93%) rename {.sparkle/notes => .changes}/3.0.0.md (95%) rename {.sparkle/notes => .changes}/3.0.1.md (91%) rename {.sparkle/notes => .changes}/3.1.0.md (88%) create mode 100644 .changes/README.md create mode 100644 .changes/header.tpl.md create mode 100644 .changes/unreleased/.gitkeep create mode 100644 .changie.yaml delete mode 100644 .github/ExportOptions.plist delete mode 100644 .sparkle/notes/README.md delete mode 100644 .sparkle/notes/TEMPLATE.md delete mode 100644 .sparkle/notes/next.md create mode 100644 CHANGELOG.md create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 fastlane/Fastfile delete mode 100755 scripts/bump-version.sh delete mode 100755 scripts/bump-version.test.sh delete mode 100755 scripts/project-version.sh delete mode 100755 scripts/project-version.test.sh diff --git a/.sparkle/notes/2.11.0.md b/.changes/2.11.0.md similarity index 93% rename from .sparkle/notes/2.11.0.md rename to .changes/2.11.0.md index 32ac6d6..2d44e62 100644 --- a/.sparkle/notes/2.11.0.md +++ b/.changes/2.11.0.md @@ -1,3 +1,5 @@ +## 2.11.0 - 2026-08-12 + - New: Inspector window describing the file you are playing. Open it from the Window menu, or press Command-I - Fixed: A file exactly one hour long showed its length as 60:00 rather than 1:00:00 - Fixed: Closing the player window left the file loaded, so the playback controls and menu items stayed active with nothing playing diff --git a/.sparkle/notes/3.0.0.md b/.changes/3.0.0.md similarity index 95% rename from .sparkle/notes/3.0.0.md rename to .changes/3.0.0.md index 6dcd39b..5736aec 100644 --- a/.sparkle/notes/3.0.0.md +++ b/.changes/3.0.0.md @@ -1,3 +1,5 @@ +## 3.0.0 - 2026-08-22 + - New: Front Row offers to convert and play MKV files if possible (using FFmpeg) - New: Open URL distinguishes between network issue and actually unplayable files - Changed: Experience Spatial Audio menu item renamed to Play Spatial Audio Sample diff --git a/.sparkle/notes/3.0.1.md b/.changes/3.0.1.md similarity index 91% rename from .sparkle/notes/3.0.1.md rename to .changes/3.0.1.md index c32e42a..fdf87cd 100644 --- a/.sparkle/notes/3.0.1.md +++ b/.changes/3.0.1.md @@ -1,3 +1,5 @@ +## 3.0.1 - 2026-08-26 + - Fixed: Audio Track and Subtitle menus displayed “None” for some files - Fixed: Tracks with no language tag were listed as “Unknown language” rather than by their name or number - Fixed: Handling files with multichannel Opus or MP3 audio tracks diff --git a/.sparkle/notes/3.1.0.md b/.changes/3.1.0.md similarity index 88% rename from .sparkle/notes/3.1.0.md rename to .changes/3.1.0.md index 3fe0575..f65ca5f 100644 --- a/.sparkle/notes/3.1.0.md +++ b/.changes/3.1.0.md @@ -1 +1,3 @@ +## 3.1.0 - 2026-09-30 + - New: File ▸ Move to Trash moves the playing file to the Trash and returns to the welcome window. Hold Option for Delete Immediately…, which deletes it outright after asking diff --git a/.changes/README.md b/.changes/README.md new file mode 100644 index 0000000..7dda87f --- /dev/null +++ b/.changes/README.md @@ -0,0 +1,49 @@ +# Release notes + +Release notes are kept with [Changie](https://changie.dev) (`brew install changie`). +Every user-facing change adds a fragment in the same pull request: + +```sh +changie new # prompts for the kind and the text +changie new --kind Fixed --body "Something that was broken" +``` + +Kinds are `New`, `Changed`, `Fixed` and `Updated`. With the release type `auto`, +`New` bumps the minor version and the others bump the patch. Changes users don't +notice (CI, refactors, docs) get no fragment. + +The release workflow batches `unreleased/` into `.md`, regenerates +`CHANGELOG.md`, and stamps the version into the project. The file becomes the +GitHub release body as written, and `scripts/render-notes.sh` converts it to the +HTML fragment embedded in the appcast's `` CDATA and shown in +Sparkle's update dialog. Each fragment becomes one line, in the format below, so +write for users rather than summarizing commits: + +```markdown +- New: Something people asked for +- Fixed: Something that was broken +``` + +For a free-text note above the list (e.g. `**Note**: Anything worth calling out +before updating.`), write `unreleased/header.md` before releasing. The release +consumes it. + +Lists, paragraphs, bold, italic, links, tables, task lists, and strikethrough +all work. Two things are rejected, because each would reach one destination and +not the other: + +- **HTML tags**. GitHub renders them, but the appcast conversion drops them, so + the release page and Sparkle's dialog would disagree. +- **Code blocks**, including a line indented four spaces. The appcast is + indented to match, and that indentation would show inside a `
`.
+
+Footnotes are the one GitHub construct left unsupported, and render as literal
+`[^1]` markers.
+
+Rendering uses [cmark-gfm](https://github.com/github/cmark-gfm), GitHub's own
+Markdown implementation, so Sparkle shows what the release page shows. To run
+the script tests locally: `brew install cmark-gfm`.
+
+The `.md` files are the archive of what shipped in each release. Notes
+up to 2.11.0 were written as HTML fragments and have been converted; the appcast
+entries already published keep the HTML they shipped with.
diff --git a/.changes/header.tpl.md b/.changes/header.tpl.md
new file mode 100644
index 0000000..dc68058
--- /dev/null
+++ b/.changes/header.tpl.md
@@ -0,0 +1,3 @@
+# Changelog
+
+What changed in each release of Front Row. Generated by [Changie](https://changie.dev) from `.changes/`; edit those files, not this one.
diff --git a/.changes/unreleased/.gitkeep b/.changes/unreleased/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/.changie.yaml b/.changie.yaml
new file mode 100644
index 0000000..31ea993
--- /dev/null
+++ b/.changie.yaml
@@ -0,0 +1,38 @@
+# Release notes and versioning. Each user-facing change adds a fragment with
+# `changie new`; the release workflow batches them into .changes/.md,
+# which becomes the GitHub release body and, rendered to HTML by
+# scripts/render-notes.sh, the Sparkle update notes. See .changes/README.md.
+changesDir: .changes
+unreleasedDir: unreleased
+headerPath: header.tpl.md
+changelogPath: CHANGELOG.md
+versionExt: md
+# Optional free text (e.g. "**Note**: …") placed above the changes. Create
+# .changes/unreleased/header.md before the release; batch consumes it.
+versionHeaderPath: header.md
+versionFormat: '## {{.Version}} - {{.Time.Format "2006-01-02"}}'
+kindFormat: ""
+changeFormat: "- {{.Kind}}: {{.Body}}"
+body:
+  block: false
+kinds:
+  - label: New
+    auto: minor
+  - label: Changed
+    auto: patch
+  - label: Fixed
+    auto: patch
+  - label: Updated
+    auto: patch
+newlines:
+  afterChangelogHeader: 0
+  afterVersion: 1
+  beforeChangelogVersion: 1
+  endOfVersion: 1
+# Applied by `changie merge`: the marketing version lives at project level in
+# project.xcproj (four spaces of indentation). The build number is the commit
+# count and is passed to the build, never written back.
+replacements:
+  - path: Front Row.xcodeproj/project.xcproj
+    find: '^    "MARKETING_VERSION": ".*",$'
+    replace: '    "MARKETING_VERSION": "{{.VersionNoPrefix}}",'
diff --git a/.github/ExportOptions.plist b/.github/ExportOptions.plist
deleted file mode 100644
index c2157a3..0000000
--- a/.github/ExportOptions.plist
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-	method
-	developer-id
-	teamID
-	TCQ6328PP6
-	
-	signingStyle
-	manual
-	signingCertificate
-	Developer ID Application
-	destination
-	export
-
-
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fa948d3..180f9ff 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -78,6 +78,22 @@ jobs:
           brew update
           brew install cmark-gfm
 
+      - name: Install Changie
+        uses: miniscruff/changie-action@v3
+        with:
+          version: v1.26.0
+
+      # Every fragment must parse and have a known kind, or the release would
+      # fail at its first step.
+      - name: Check the Changie fragments
+        run: |
+          set -euo pipefail
+          if compgen -G ".changes/unreleased/*.yaml" >/dev/null; then
+            changie batch auto --dry-run
+          else
+            echo "No unreleased changes"
+          fi
+
       - name: Run script tests
         run: |
           failures=0
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1d9be20..fd2f54d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,21 +1,21 @@
 # Description:
 # Builds, signs, notarizes, and publishes a new release, then adds it to the
 # Sparkle appcast. Triggered manually from the Actions tab.
-#         1. Choose release type (major, minor, patch)
-#         2. Write release notes to .sparkle/notes/next.md beforehand
+#         1. Each user-facing change adds a Changie fragment (`changie new`,
+#            see .changes/README.md)
+#         2. Choose the release type (auto picks it from the fragments' kinds)
 #
-# The notes go to GitHub as Markdown and to the appcast as HTML, rendered by
-# cmark-gfm so both read the same.
-#
-# The marketing version is read from project.xcproj and bumped by the chosen
-# amount; the build number is the commit count and is never read back as input.
-# Both are passed to xcodebuild, then written back to project.xcproj in the same
-# commit as the appcast — so a local build always reports the last release.
+# Changie batches the fragments into .changes/.md, updates
+# CHANGELOG.md, and stamps MARKETING_VERSION into project.xcproj. The notes go
+# to GitHub as Markdown and to the appcast as HTML, rendered by cmark-gfm so
+# both read the same. The build number is the commit count; it is passed to the
+# build and never written back. fastlane (fastlane/Fastfile) imports the
+# certificate, archives, exports, and notarizes.
 #
 # Nothing outside the runner is touched until notarization succeeds, so a failed
 # run leaves no tag, no release, and no feed change behind — just run it again.
 #
-# The release commit (appcast, version, archived notes) is made first and is the
+# The release commit (appcast, version, notes) is made first and is the
 # commit that gets tagged, so the tag's source reports the version it shipped as.
 # It is pushed to main last: the appcast links to the release's dmg, so the feed
 # only goes live once that download exists.
@@ -38,16 +38,17 @@ on:
     inputs:
       release-type:
         type: choice
-        description: "Choose release type: major, minor, patch"
+        description: "Release type: auto (from the Changie fragments), patch, minor, major"
         required: true
-        default: patch
+        default: auto
         options:
+          - auto
           - patch
           - minor
           - major
       dry-run:
         type: boolean
-        description: "Build and notarize only — no tag, release, or appcast update"
+        description: "Build and notarize only — no tag, release, or appcast update (any branch)"
         required: false
         default: false
 
@@ -81,25 +82,45 @@ jobs:
 
       # Build output must stay outside the workspace: the app target's
       # swift-format lint phase runs `-r ./` from $SRCROOT and would lint SPM's
-      # checkout of Sparkle. The runner context is unavailable in job-level env,
-      # so these are set here.
+      # checkout of Sparkle. fastlane builds into BUILD_ROOT.
       - name: Set build paths
         run: |
           set -euo pipefail
           {
-            echo "ARCHIVE_PATH=$RUNNER_TEMP/build/FrontRow.xcarchive"
+            echo "BUILD_ROOT=$RUNNER_TEMP/build"
             echo "EXPORT_PATH=$RUNNER_TEMP/build/export"
             echo "DERIVED_DATA=$RUNNER_TEMP/build/DerivedData"
+            echo "KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain-db"
+            echo "BUNDLE_PATH=$RUNNER_TEMP/gems"
           } >>"$GITHUB_ENV"
 
+      - name: Install Changie
+        uses: miniscruff/changie-action@v3
+        with:
+          version: v1.26.0
+
+      - name: Set up Ruby
+        uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: "3.4"
+
+      # Gems stay outside the workspace (BUNDLE_PATH) for the same reason as the
+      # build output: the lint phase would lint the fastlane gem's Swift files.
+      - name: Cache gems
+        uses: actions/cache@v6
+        with:
+          path: ${{ runner.temp }}/gems
+          key: gems-${{ runner.os }}-ruby3.4-${{ hashFiles('Gemfile.lock') }}
+
+      - name: Install fastlane
+        run: bundle install
+
       - name: Install cmark-gfm
         run: brew install cmark-gfm
 
       # Before anything depends on these scripts.
       - name: Verify script tests pass
         run: |
-          ./scripts/bump-version.test.sh
-          ./scripts/project-version.test.sh
           ./scripts/append-appcast-item.test.sh
           ./scripts/render-notes.test.sh
 
@@ -111,17 +132,23 @@ jobs:
 
           # The appcast is published from main (SUFeedURL points at
           # raw/main/.sparkle/appcast.xml). Releasing from anywhere else would
-          # tag and publish, then strand the feed entry on a side branch.
-          if [[ "$GITHUB_REF_NAME" != "main" ]]; then
+          # tag and publish, then strand the feed entry on a side branch. A dry
+          # run publishes nothing, so it can run from any branch.
+          if [[ "$GITHUB_REF_NAME" != "main" && "${{ inputs.dry-run }}" != "true" ]]; then
             echo "::error::Releases must be run from main, not $GITHUB_REF_NAME"
             exit 1
           fi
 
-          xcproj="Front Row.xcodeproj/project.xcproj"
-          # The marketing version is authoritative in the project file; the build
-          # number is mechanical and never read back as input.
-          current="$(./scripts/project-version.sh read-marketing "$xcproj")"
-          version="$(./scripts/bump-version.sh "$current" "${{ inputs.release-type }}")"
+          # `changie batch patch` happily releases nothing, so check first.
+          if ! compgen -G ".changes/unreleased/*.yaml" >/dev/null; then
+            echo "::error::No unreleased changes. Add fragments with \`changie new\` before releasing."
+            exit 1
+          fi
+
+          current="$(changie latest)"
+          changie batch "${{ inputs.release-type }}"
+          changie merge
+          version="$(changie latest)"
           build="$(git rev-list --count HEAD)"
 
           if git rev-parse "v$version" >/dev/null 2>&1; then
@@ -136,19 +163,11 @@ jobs:
             exit 1
           fi
 
-          notes=".sparkle/notes/next.md"
-          if [[ ! -f "$notes" ]]; then
-            echo "::error::Missing $notes. Copy .sparkle/notes/TEMPLATE.md to it and write the release notes."
-            exit 1
-          fi
-          if cmp -s "$notes" .sparkle/notes/TEMPLATE.md; then
-            echo "::error::$notes is still the unedited template. Write the release notes before releasing."
-            exit 1
-          fi
-          if [[ -e ".sparkle/notes/$version.md" ]]; then
-            echo "::error::.sparkle/notes/$version.md already exists, so the archived notes for this release would be overwritten."
-            exit 1
-          fi
+          # The release body: the version file without its "## version - date"
+          # heading and the blank line after it.
+          notes="$RUNNER_TEMP/notes.md"
+          tail -n +3 ".changes/$version.md" >"$notes"
+          [[ -s "$notes" ]] || { echo "::error::.changes/$version.md has no notes"; exit 1; }
 
           {
             echo "version=$version"
@@ -182,27 +201,9 @@ jobs:
 
       - name: Import signing certificate
         env:
-          CERT_P12_BASE64: ${{ secrets.DEVELOPER_ID_CERT_P12_BASE64 }}
-          CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }}
-        run: |
-          set -euo pipefail
-          keychain="$RUNNER_TEMP/build.keychain-db"
-          # Ephemeral keychain, discarded with the runner, so the password only
-          # needs to be unguessable for this run.
-          keychain_password="$(openssl rand -base64 32)"
-
-          security create-keychain -p "$keychain_password" "$keychain"
-          security set-keychain-settings -lut 21600 "$keychain"
-          security unlock-keychain -p "$keychain_password" "$keychain"
-
-          printf '%s' "$CERT_P12_BASE64" | base64 --decode >"$RUNNER_TEMP/cert.p12"
-          security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$CERT_PASSWORD" \
-            -T /usr/bin/codesign -T /usr/bin/security
-          rm -f "$RUNNER_TEMP/cert.p12"
-
-          security set-key-partition-list -S apple-tool:,apple:,codesign: \
-            -s -k "$keychain_password" "$keychain" >/dev/null
-          security list-keychain -d user -s "$keychain" login.keychain-db
+          DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.DEVELOPER_ID_CERT_P12_BASE64 }}
+          DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }}
+        run: bundle exec fastlane import_signing_certificate
 
       # Cheap checks, before the ~15 minute build.
       - name: Preflight — verify credentials
@@ -211,7 +212,7 @@ jobs:
           AC_API_ISSUER_ID: ${{ secrets.AC_API_ISSUER_ID }}
         run: |
           set -euo pipefail
-          keychain="$RUNNER_TEMP/build.keychain-db"
+          keychain="$KEYCHAIN_PATH"
 
           identity="$(security find-identity -v -p codesigning "$keychain" |
             sed -n 's/.*"\(Developer ID Application[^"]*\)".*/\1/p' | head -1)"
@@ -253,30 +254,11 @@ jobs:
           fi
           echo "App Store Connect API key accepted"
 
-      - name: Archive
-        run: |
-          set -euo pipefail
-          xcodebuild archive \
-            -project "Front Row.xcodeproj" \
-            -scheme "Front Row" \
-            -configuration Release \
-            -destination "generic/platform=macOS" \
-            -archivePath "$ARCHIVE_PATH" \
-            -derivedDataPath "$DERIVED_DATA" \
-            CURRENT_PROJECT_VERSION=${{ steps.version.outputs.build }} \
-            MARKETING_VERSION=${{ steps.version.outputs.version }} \
-            CODE_SIGN_STYLE=Manual \
-            CODE_SIGN_IDENTITY="Developer ID Application" \
-            PROVISIONING_PROFILE_SPECIFIER="" \
-            DEVELOPMENT_TEAM="$TEAM_ID"
-
-      - name: Export archive
-        run: |
-          set -euo pipefail
-          xcodebuild -exportArchive \
-            -archivePath "$ARCHIVE_PATH" \
-            -exportPath "$EXPORT_PATH" \
-            -exportOptionsPlist .github/ExportOptions.plist
+      - name: Archive and export
+        run: >
+          bundle exec fastlane build
+          version:${{ steps.version.outputs.version }}
+          build:${{ steps.version.outputs.build }}
 
       - name: Verify the exported app
         run: |
@@ -318,15 +300,7 @@ jobs:
         env:
           AC_API_KEY_ID: ${{ secrets.AC_API_KEY_ID }}
           AC_API_ISSUER_ID: ${{ secrets.AC_API_ISSUER_ID }}
-        run: |
-          set -euo pipefail
-          dmg="$EXPORT_PATH/${{ steps.version.outputs.dmg }}"
-          xcrun notarytool submit "$dmg" \
-            --key "$AC_KEY_PATH" --key-id "$AC_API_KEY_ID" --issuer "$AC_API_ISSUER_ID" \
-            --wait
-
-          xcrun stapler staple "$dmg"
-          xcrun stapler validate "$dmg"
+        run: bundle exec fastlane notarize_dmg dmg:"$EXPORT_PATH/${{ steps.version.outputs.dmg }}"
 
       - name: Remove the API key
         if: always()
@@ -377,24 +351,13 @@ jobs:
             --pub-date "$(date -u '+%a, %d %b %Y %H:%M:%S +0000')" \
             --minimum-system-version "$minimum_system_version"
 
-          # Record what was just released, so local builds report the shipped
-          # version and the next run has a marketing version to bump from.
-          ./scripts/project-version.sh write "Front Row.xcodeproj/project.xcproj" \
-            "$version" "${{ steps.version.outputs.build }}"
-
-          # Archive the notes under the version they shipped as, and leave a
-          # fresh template ready for the next release.
-          notes=".sparkle/notes/$version.md"
-          git mv "${{ steps.version.outputs.notes }}" "$notes"
-          cp .sparkle/notes/TEMPLATE.md .sparkle/notes/next.md
-
-          git add .sparkle/appcast.xml "Front Row.xcodeproj/project.xcproj" .sparkle/notes
+          # Changie's batch and merge already ran: the fragments became
+          # .changes/$version.md, CHANGELOG.md was regenerated, and project.xcproj
+          # carries the new marketing version.
+          git add -A .sparkle/appcast.xml .changes CHANGELOG.md "Front Row.xcodeproj/project.xcproj"
           git commit -m "chore: release $version (${{ steps.version.outputs.build }}) [skip ci]"
 
-          {
-            echo "commit=$(git rev-parse HEAD)"
-            echo "notes=$notes"
-          } >>"$GITHUB_OUTPUT"
+          echo "commit=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
 
       - name: Tag and create the GitHub release
         if: ${{ !inputs.dry-run }}
@@ -410,7 +373,7 @@ jobs:
           gh release create "v$version" \
             "$EXPORT_PATH/${{ steps.version.outputs.dmg }}" \
             --title "v$version" \
-            --notes-file "${{ steps.record.outputs.notes }}" ||
+            --notes-file "${{ steps.version.outputs.notes }}" ||
             { echo "::error::Tag v$version is pushed, but creating the GitHub release failed, and main is not updated. Delete the tag (git push origin :v$version) and run again."; exit 1; }
 
       - name: Publish to the appcast and record the version
diff --git a/.gitignore b/.gitignore
index 7c6f114..75d0216 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,9 @@ xcuserdata/
 ## Playgrounds
 timeline.xctimeline
 playground.xcworkspace
+
+# fastlane reports. Install gems outside the repository (see AGENTS.md):
+# the swift-format lint build phase lints everything under ./
+.bundle/
+fastlane/report.xml
+fastlane/README.md
diff --git a/.sparkle/notes/README.md b/.sparkle/notes/README.md
deleted file mode 100644
index 604dd62..0000000
--- a/.sparkle/notes/README.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# Release notes
-
-Write the next release's notes in `next.md`. There is no version in the name,
-so nothing has to be decided in advance — the release workflow works out the
-version from the `patch`/`minor`/`major` choice, renames `next.md` to
-`.md`, and drops a fresh copy of `TEMPLATE.md` in its place.
-
-The workflow refuses to release while `next.md` is still the unedited template.
-
-The file becomes the GitHub release body verbatim, and `scripts/render-notes.sh`
-converts it to the HTML fragment embedded in the appcast's `` CDATA
-and shown in Sparkle's update dialog. So write for users rather than summarizing
-commits:
-
-```markdown
-- New: Something people asked for
-- Fixed: Something that was broken
-
-**Note**: Anything worth calling out before updating.
-```
-
-Lists, paragraphs, bold, italic, links, tables, task lists, and strikethrough
-all work. Two things are rejected, because each would reach one destination and
-not the other:
-
-- **HTML tags**. GitHub renders them, but the appcast conversion drops them, so
-  the release page and Sparkle's dialog would disagree.
-- **Code blocks**, including a line indented four spaces. The appcast is
-  indented to match, and that indentation would show inside a `
`.
-
-Footnotes are the one GitHub construct left unsupported, and render as literal
-`[^1]` markers.
-
-Rendering uses [cmark-gfm](https://github.com/github/cmark-gfm), GitHub's own
-Markdown implementation, so Sparkle shows what the release page shows. To run
-the script tests locally: `brew install cmark-gfm`.
-
-The older `.md` files are the archive of what shipped in each release.
-Notes up to 2.11.0 were written as HTML fragments and have been converted; the
-appcast entries already published keep the HTML they shipped with.
diff --git a/.sparkle/notes/TEMPLATE.md b/.sparkle/notes/TEMPLATE.md
deleted file mode 100644
index a1dab14..0000000
--- a/.sparkle/notes/TEMPLATE.md
+++ /dev/null
@@ -1,3 +0,0 @@
-- New:
-- Changed:
-- Fixed:
diff --git a/.sparkle/notes/next.md b/.sparkle/notes/next.md
deleted file mode 100644
index a1dab14..0000000
--- a/.sparkle/notes/next.md
+++ /dev/null
@@ -1,3 +0,0 @@
-- New:
-- Changed:
-- Fixed:
diff --git a/AGENTS.md b/AGENTS.md
index ce927b5..5b49270 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,12 +19,17 @@ swift-format lint -s -p -r ./
 
 # Auto-fix formatting
 swift-format format -p -r -i ./
+
+# Release lanes (fastlane/Fastfile; run by .github/workflows/release.yml). Keep the
+# gems outside the repository: the lint phase above lints everything under ./
+BUNDLE_PATH=~/.cache/frontrow-gems bundle install
+BUNDLE_PATH=~/.cache/frontrow-gems bundle exec fastlane build version:1.2.3 build:42
 ```
 
 ## Architecture
 
 - Target macOS Sequoia 15.6 or later.
-- Building needs Xcode 27.2 or later (on macOS Tahoe 26.6 or later). The project uses Xcode's JSON project format, `Front Row.xcodeproj/project.xcproj`. `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` are project-level settings there, read and written by `scripts/project-version.sh`.
+- Building needs Xcode 27.2 or later (on macOS Tahoe 26.6 or later). The project uses Xcode's JSON project format, `Front Row.xcodeproj/project.xcproj`. `MARKETING_VERSION` is a project-level setting there, stamped by Changie at release. fastlane builds through `Front Row.xcodeproj/project.xcworkspace` with profile detection skipped, because its `xcodeproj` gem can't parse `project.xcproj`.
 - Do not introduce third-party frameworks without asking first.
 - Group source files by feature, not by layer. A feature folder holds its models and its views together.
 - A feature that outgrows one folder splits by kind into `Models/`, `Views/`, and any domain subfolder it needs (`Conversion/FFmpeg/`). Small features stay flat.
@@ -63,6 +68,7 @@ Applies to every file — Swift, YAML, shell, config.
 ## GitHub PRs
 
 - See `CONTRIBUTING.md`.
+- A user-facing change adds a Changie release-notes fragment: `changie new --kind Fixed --body "…"` (kinds `New`, `Changed`, `Fixed`, `Updated`; see `.changes/README.md`).
 
 ## Git
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..46a5d39
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,30 @@
+# Changelog
+
+What changed in each release of Front Row. Generated by [Changie](https://changie.dev) from `.changes/`; edit those files, not this one.
+
+## 3.1.0 - 2026-09-30
+
+- New: File ▸ Move to Trash moves the playing file to the Trash and returns to the welcome window. Hold Option for Delete Immediately…, which deletes it outright after asking
+
+## 3.0.1 - 2026-08-26
+
+- Fixed: Audio Track and Subtitle menus displayed “None” for some files
+- Fixed: Tracks with no language tag were listed as “Unknown language” rather than by their name or number
+- Fixed: Handling files with multichannel Opus or MP3 audio tracks
+
+## 3.0.0 - 2026-08-22
+
+- New: Front Row offers to convert and play MKV files if possible (using FFmpeg)
+- New: Open URL distinguishes between network issue and actually unplayable files
+- Changed: Experience Spatial Audio menu item renamed to Play Spatial Audio Sample
+- Fixed: Play Spatial Audio Sample started playing without opening a player window
+- Updated: App localizations
+
+**Note**: Front Row no longer runs in the app sandbox in order to use FFmpeg. The list of recently opened files will be reset due to this change.
+
+## 2.11.0 - 2026-08-12
+
+- New: Inspector window describing the file you are playing. Open it from the Window menu, or press Command-I
+- Fixed: A file exactly one hour long showed its length as 60:00 rather than 1:00:00
+- Fixed: Closing the player window left the file loaded, so the playback controls and menu items stayed active with nothing playing
+- Updated: App localizations
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..d856a94
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,3 @@
+source "https://rubygems.org"
+
+gem "fastlane", "~> 2.241"
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..080c273
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,397 @@
+GEM
+  remote: https://rubygems.org/
+  specs:
+    CFPropertyList (3.0.8)
+    abbrev (0.1.2)
+    addressable (2.9.1)
+      public_suffix (>= 2.0.2, < 8.0)
+    artifactory (3.0.17)
+    atomos (0.1.3)
+    aws-eventstream (1.4.1)
+    aws-partitions (1.1300.0)
+    aws-sdk-core (3.263.0)
+      aws-eventstream (~> 1, >= 1.4.1)
+      aws-partitions (~> 1, >= 1.992.0)
+      aws-sigv4 (~> 1.9)
+      base64
+      bigdecimal
+      jmespath (~> 1, >= 1.6.1)
+      logger
+      rexml (~> 3.4, >= 3.4.2)
+    aws-sdk-kms (1.138.0)
+      aws-sdk-core (~> 3, >= 3.263.0)
+      aws-sigv4 (~> 1.5)
+    aws-sdk-s3 (1.237.0)
+      aws-sdk-core (~> 3, >= 3.263.0)
+      aws-sdk-kms (~> 1)
+      aws-sigv4 (~> 1.5)
+    aws-sigv4 (1.12.1)
+      aws-eventstream (~> 1, >= 1.0.2)
+    babosa (1.0.4)
+    base64 (0.3.0)
+    benchmark (0.5.0)
+    bigdecimal (4.1.3)
+    cgi (0.5.2)
+    claide (1.1.0)
+    colored (1.2)
+    colored2 (3.1.2)
+    commander (4.6.0)
+      highline (~> 2.0.0)
+    csv (3.3.7)
+    declarative (0.0.20)
+    digest-crc (0.7.0)
+      rake (>= 12.0.0, < 14.0.0)
+    domain_name (0.7.20261007)
+    dotenv (2.8.1)
+    emoji_regex (3.2.3)
+    erb (6.0.7)
+    excon (1.10.0)
+    faraday (2.15.1)
+      faraday-net_http (>= 2.0, < 3.6)
+      json
+      logger
+    faraday-cookie_jar (0.0.8)
+      faraday (>= 0.8.0)
+      http-cookie (>= 1.0.0)
+    faraday-follow_redirects (0.5.0)
+      faraday (>= 1, < 3)
+    faraday-multipart (1.2.0)
+      multipart-post (~> 2.0)
+    faraday-net_http (3.5.0)
+      net-http (~> 0.5)
+    faraday-retry (2.4.0)
+      faraday (~> 2.0)
+    fastimage (2.4.3)
+    fastlane (2.241.2)
+      CFPropertyList (>= 2.3, < 5.0.0)
+      abbrev (~> 0.1)
+      addressable (>= 2.9.0, < 3.0.0)
+      artifactory (~> 3.0)
+      aws-sdk-s3 (~> 1.197)
+      babosa (>= 1.0.3, < 2.0.0)
+      base64 (~> 0.2)
+      benchmark (>= 0.1.0)
+      bundler (>= 2.4.0, < 5.0.0)
+      cgi (~> 0.4)
+      colored (~> 1.2)
+      commander (~> 4.6)
+      csv (~> 3.3)
+      dotenv (>= 2.1.1, < 3.0.0)
+      emoji_regex (>= 0.1, < 4.0)
+      erb (>= 4.0, < 7.0)
+      excon (>= 1.0.0, < 2.0.0)
+      faraday (~> 2.7)
+      faraday-cookie_jar (~> 0.0.8)
+      faraday-follow_redirects (~> 0.3)
+      faraday-multipart (~> 1.0)
+      faraday-retry (~> 2.0)
+      fastimage (>= 2.1.0, < 3.0.0)
+      fastlane-sirp (>= 1.1.0)
+      gh_inspector (>= 1.1.2, < 2.0.0)
+      google-apis-androidpublisher_v3 (~> 0.99)
+      google-apis-playcustomapp_v1 (~> 0.1)
+      google-cloud-env (>= 1.6.0, < 2.5.0)
+      google-cloud-storage (~> 1.31)
+      highline (~> 2.0)
+      irb (>= 1.8)
+      json (< 3.0.0)
+      jwt (>= 2.10.3, < 4)
+      logger (>= 1.6, < 2.0)
+      mini_magick (>= 4.9.4, < 5.0.0)
+      multi_json (~> 1.12)
+      multipart-post (>= 2.0.0, < 3.0.0)
+      mutex_m (~> 0.3)
+      naturally (~> 2.2)
+      nkf (~> 0.2)
+      optparse (>= 0.1.1, < 1.0.0)
+      ostruct (>= 0.1.0)
+      plist (>= 3.1.0, < 4.0.0)
+      rubyzip (>= 3.4.0, < 4.0.0)
+      security (~> 0.3)
+      simctl (~> 1.6.3)
+      terminal-notifier (>= 3.1.0, < 4.0.0)
+      terminal-table (~> 4)
+      tty-screen (>= 0.6.3, < 1.0.0)
+      tty-spinner (>= 0.8.0, < 1.0.0)
+      word_wrap (~> 1.0.0)
+      xcodeproj (>= 1.13.0, < 2.0.0)
+      xcpretty (~> 0.4.1)
+      xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
+    fastlane-sirp (1.1.0)
+    gh_inspector (1.1.3)
+    google-apis-androidpublisher_v3 (0.110.0)
+      google-apis-core (>= 0.15.0, < 2.a)
+    google-apis-core (1.4.0)
+      addressable (~> 2.9)
+      faraday (~> 2.13)
+      faraday-follow_redirects (~> 0.3)
+      googleauth (~> 1.14)
+      mini_mime (~> 1.1)
+      multi_json (~> 1.11)
+      representable (~> 3.0)
+      retriable (>= 3.1, < 5.0)
+    google-apis-iamcredentials_v1 (0.28.0)
+      google-apis-core (>= 0.15.0, < 2.a)
+    google-apis-playcustomapp_v1 (0.18.0)
+      google-apis-core (>= 0.15.0, < 2.a)
+    google-apis-storage_v1 (0.70.0)
+      google-apis-core (>= 0.15.0, < 2.a)
+    google-cloud-core (1.9.1)
+      google-cloud-env (>= 1.0, < 3.a)
+      google-cloud-errors (~> 1.0)
+    google-cloud-env (2.4.1)
+      base64 (~> 0.2)
+      faraday (>= 1.0, < 3.a)
+    google-cloud-errors (1.7.1)
+    google-cloud-storage (1.62.1)
+      addressable (~> 2.8)
+      digest-crc (~> 0.4)
+      google-apis-core (>= 0.18, < 2)
+      google-apis-iamcredentials_v1 (~> 0.18)
+      google-apis-storage_v1 (>= 0.42)
+      google-cloud-core (~> 1.6)
+      googleauth (~> 1.9)
+      mini_mime (~> 1.0)
+    google-logging-utils (0.2.2)
+    googleauth (1.17.7)
+      faraday (>= 1.0, < 3.a)
+      google-cloud-env (~> 2.2)
+      google-logging-utils (~> 0.1)
+      jwt (>= 1.4, < 4.0)
+      os (>= 0.9, < 2.0)
+      pstore (~> 0.1)
+      signet (>= 0.16, < 2.a)
+    highline (2.0.3)
+    http-cookie (1.1.6)
+      domain_name (~> 0.5)
+    io-console (0.9.5)
+    irb (1.18.0)
+      pp (>= 0.6.0)
+      prism (>= 1.3.0)
+      rdoc (>= 4.0.0)
+      reline (>= 0.4.2)
+    jmespath (1.6.2)
+    json (2.21.2)
+    jwt (3.3.0)
+      base64
+    logger (1.7.0)
+    mini_magick (4.13.2)
+    mini_mime (1.1.5)
+    multi_json (1.21.2)
+    multipart-post (2.4.1)
+    mutex_m (0.3.0)
+    nanaimo (0.4.0)
+    naturally (2.3.0)
+    net-http (0.10.0)
+      net-protocol (>= 0.3.0)
+      uri (>= 0.11.1)
+    net-protocol (0.4.0)
+      timeout
+    nkf (0.3.0)
+    optparse (0.8.1)
+    os (1.1.4)
+    ostruct (0.6.3)
+    plist (3.7.2)
+    pp (0.6.4)
+      prettyprint
+    prettyprint (0.2.1)
+    prism (2.1.0)
+    prism (2.1.0-aarch64-linux-gnu)
+    prism (2.1.0-aarch64-linux-musl)
+    prism (2.1.0-arm-linux-gnu)
+    prism (2.1.0-arm-linux-musl)
+    prism (2.1.0-arm64-darwin)
+    prism (2.1.0-x86_64-darwin)
+    prism (2.1.0-x86_64-linux-gnu)
+    prism (2.1.0-x86_64-linux-musl)
+    pstore (0.3.0)
+    public_suffix (7.0.5)
+    rake (13.4.2)
+    rbs (4.2.0)
+      logger
+      prism (>= 1.6.0)
+      tsort
+    rdoc (8.1.0)
+      erb
+      prism (>= 1.6.0)
+      rbs (>= 4.0.0)
+      tsort
+    reline (0.7.0)
+      io-console (~> 0.5)
+    representable (3.3.1)
+      declarative (< 0.1.0)
+      trailblazer-option (>= 0.1.1, < 0.2.0)
+      uber (< 0.2.0)
+    retriable (4.2.0)
+    rexml (3.4.4)
+    rouge (3.28.0)
+    rubyzip (3.7.0)
+    security (0.4.0)
+    signet (0.22.2)
+      addressable (~> 2.8)
+      faraday (>= 0.17.5, < 3.a)
+      jwt (>= 1.5, < 4.0)
+    simctl (1.6.10)
+      CFPropertyList
+      naturally
+    terminal-notifier (3.1.0)
+    terminal-table (4.0.0)
+      unicode-display_width (>= 1.1.1, < 4)
+    timeout (0.6.1)
+    trailblazer-option (0.1.2)
+    tsort (0.2.0)
+    tty-cursor (0.7.1)
+    tty-screen (0.8.2)
+    tty-spinner (0.9.3)
+      tty-cursor (~> 0.7)
+    uber (0.1.0)
+    unicode-display_width (3.3.0)
+      unicode-emoji (~> 4.3)
+    unicode-emoji (4.3.0)
+    uri (1.1.2)
+    word_wrap (1.0.0)
+    xcodeproj (1.28.1)
+      CFPropertyList (>= 2.3.3, < 4.0)
+      atomos (~> 0.1.3)
+      base64
+      claide (>= 1.0.2, < 2.0)
+      colored2 (~> 3.1)
+      nanaimo (~> 0.4.0)
+      nkf
+      rexml (>= 3.3.6, < 4.0)
+    xcpretty (0.4.1)
+      rouge (~> 3.28.0)
+    xcpretty-travis-formatter (1.0.1)
+      xcpretty (~> 0.2, >= 0.0.7)
+
+PLATFORMS
+  aarch64-linux-gnu
+  aarch64-linux-musl
+  arm-linux-gnu
+  arm-linux-musl
+  arm64-darwin
+  arm64-darwin-24
+  ruby
+  x86_64-darwin
+  x86_64-linux-gnu
+  x86_64-linux-musl
+
+DEPENDENCIES
+  fastlane (~> 2.241)
+
+CHECKSUMS
+  CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
+  abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
+  addressable (2.9.1) sha256=f1212365945455c84e8d031eea40fc6bedf0c663a78d95b3ab93591040bc5c35
+  artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
+  atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
+  aws-eventstream (1.4.1) sha256=ceb6a99c61df4bb00344e54aed0351574beca24f87a0d1487d1d2ed80929ce96
+  aws-partitions (1.1300.0) sha256=e3cc87a4ffe875d7326e6b9f5fe696ff9a59632227f3252061dcba1782664924
+  aws-sdk-core (3.263.0) sha256=9b6d396ed8b3e5ba19b0640fbdbfa1875b0c485353e1bb836f1e72b403c8892d
+  aws-sdk-kms (1.138.0) sha256=b3b4da50b962faa1333d6072f2eb04d8ad71569b32ee0d639657df3f8be8b37d
+  aws-sdk-s3 (1.237.0) sha256=1e8a96a5411124d66f85f397bd00a1d6df53da1946725b6d5980d921a8e11191
+  aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
+  babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
+  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
+  benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
+  bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
+  cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb
+  claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
+  colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
+  colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
+  commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
+  csv (3.3.7) sha256=15a3f16f6628da8664d455eb634e7f22031bcf53044923caf56975109e71b9f8
+  declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
+  digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
+  domain_name (0.7.20261007) sha256=0976c6ab837dbde991b433d03acc38fdb91fa8839df31a1ed75ce0318dc0441c
+  dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
+  emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
+  erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
+  excon (1.10.0) sha256=868d456c6db29f965d7cc6c5eddbb4d77df08e2a2b2dca7ca3547f2a55afc4b4
+  faraday (2.15.1) sha256=8708154deda739c5e86cc42b74960e23e8491fce0eba9e11639a6a34b1ac288f
+  faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
+  faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
+  faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
+  faraday-net_http (3.5.0) sha256=2e7094c34e38754ee048cc3df47376f8657005d9375f8473bce1c3b526a22e36
+  faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
+  fastimage (2.4.3) sha256=304457e3c35bfdaefda3f1dacab2222a96b230b8ce6977b6b54748c1cc7ec539
+  fastlane (2.241.2) sha256=9c23f6d1b4aa5d8570ff8584186d5fdb1fdf7a01dd7ad168501b43fe360fe122
+  fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
+  gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
+  google-apis-androidpublisher_v3 (0.110.0) sha256=0b0b84af02fb7f3a062849bc60d545ff8b37670ea7e90bd947199cc671916513
+  google-apis-core (1.4.0) sha256=74e838a334926e79ce685d3ff9f418956cc23e48c5fd7cade779fea933e96582
+  google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106
+  google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254
+  google-apis-storage_v1 (0.70.0) sha256=6a2925cc39c27a133239738130a4cf37601e00627a36d91e4f528f5a4620ad0c
+  google-cloud-core (1.9.1) sha256=6590e393cbeadf018e6880038e19348d2ccfc83000625c569a9b21ab3b450af0
+  google-cloud-env (2.4.1) sha256=987cc58afc50ce2903128ab4dcd442159be4457246b8137e41d49d5c3fc590dd
+  google-cloud-errors (1.7.1) sha256=818bcf30998d10f28f30532a2595b49438cb7abf42f6ac495843078839987207
+  google-cloud-storage (1.62.1) sha256=10dbf2ebda3af1b4b841335364cb889827c6cb17bf6c9f8bc5313b6bef7a4d3c
+  google-logging-utils (0.2.2) sha256=ad2436ef22e918e2b1c3ad2bbaf5949f42f5389d49fd37e41b43bbdd3068e27c
+  googleauth (1.17.7) sha256=9db21857ddbe055a8b0d44ddb75d4ceda97511167431e30121c77c25a9ce9fe5
+  highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
+  http-cookie (1.1.6) sha256=ba4b82be64de61dc281243dac70e3c382c45142f20268ed9276a3670c93feaa9
+  io-console (0.9.5) sha256=83dd9bbf4c4680416629245b39cd4212eb5f4d9a4ea91804870c1f6b846414d5
+  irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
+  jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
+  json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
+  jwt (3.3.0) sha256=44cc34fbc341c148233cf66641522b4bce0dfb1ce55e8073f05c56f44f4ee546
+  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+  mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
+  mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
+  multi_json (1.21.2) sha256=245531eaf54bdba57aba515ff08b4503505c5e09911daa89c5a561e2f85bea8e
+  multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
+  mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
+  nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
+  naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
+  net-http (0.10.0) sha256=1a2ff6e37a05724973c43f2bf7bf105f59c36a4cd0a7e9b1c021a0a566987c27
+  net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853
+  nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
+  optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
+  os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
+  ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
+  plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
+  pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
+  prettyprint (0.2.1) sha256=d555d57ea6d7cf001d4774b693546a6bd88bec1e778ddffb5c60b68ceec4b7d4
+  prism (2.1.0) sha256=1c0a7c6dea0e93183390fb123f007564dbe663198e2f86ad87204151147601f3
+  prism (2.1.0-aarch64-linux-gnu) sha256=808dddc72def68e1a0463689414c97e0ba211938eddbab05387a4844aa2b3ec2
+  prism (2.1.0-aarch64-linux-musl) sha256=dd1a0732620c1e575dabb07a297a62911fabd72f8e20ce850b522a866f877930
+  prism (2.1.0-arm-linux-gnu) sha256=96b2426b9dbab05448c4add735619929042be3cd7e871ff186468121b62bcfa8
+  prism (2.1.0-arm-linux-musl) sha256=00ef918e42a3dc98f11a461bf473586a3424b0f78bee8a4e43b5a65a5f5e2cd0
+  prism (2.1.0-arm64-darwin) sha256=2d0c2e3e011cfd89c041940907d3ff3def379d7b0f2ba158d1d42238e8ad8afc
+  prism (2.1.0-x86_64-darwin) sha256=6c645d8e6afc742956c7518220ce5106367727c4d9980ac9ab386d55004ddab4
+  prism (2.1.0-x86_64-linux-gnu) sha256=b2b647afce09551e650b8bb3e2fc91d7d73d392a09534ca307ec0d619c33bfd4
+  prism (2.1.0-x86_64-linux-musl) sha256=249f45981726923e259b927902246219fa25ad10a76f43849c9aee1a3d3ec5b8
+  pstore (0.3.0) sha256=97888d57cdc8b99b3587dde5e1d01fc5ca4a9124dfeb2afb84e3dd982e78f829
+  public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
+  rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
+  rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
+  rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a
+  reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
+  representable (3.3.1) sha256=8a5fc8eef99f598147eee0390f606386268b429a840419fcfd3f7a8ccc40f566
+  retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc
+  rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
+  rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
+  rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121
+  security (0.4.0) sha256=fd3fdbd2e50e45616082dbeec0bf0813b1134b2420a034ad35c3af2807f3fdbc
+  signet (0.22.2) sha256=9c6861bb599845ea0cb05ffadd3d64c7a3b07a29bdee0f5352bcbe87b7ade21d
+  simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
+  terminal-notifier (3.1.0) sha256=073609e9da7bb60176ffadda903c8d9231fcdf18ffa7074dc9478b63fe25c69c
+  terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
+  timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
+  trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
+  tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
+  tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
+  tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
+  tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
+  uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
+  unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613
+  unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce
+  uri (1.1.2) sha256=89292e98eabbef6248b0a0ed5e7e398e152f83f17560023a6d94a7f492b169fb
+  word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
+  xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc
+  xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
+  xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
+
+BUNDLED WITH
+  4.0.23
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
new file mode 100644
index 0000000..a3dd602
--- /dev/null
+++ b/fastlane/Fastfile
@@ -0,0 +1,118 @@
+# Signing, build and notarization lanes for the Developer ID release. The
+# release workflow (.github/workflows/release.yml) drives these; versioning and
+# release notes come from Changie (.changie.yaml).
+#
+#   bundle exec fastlane import_signing_certificate      (CI: needs the env below)
+#   bundle exec fastlane build version:1.2.3 build:42
+#   bundle exec fastlane notarize_dmg dmg:path
+#
+# CI environment: KEYCHAIN_PATH, DEVELOPER_ID_CERT_P12_BASE64,
+# DEVELOPER_ID_CERT_PASSWORD, and AC_KEY_PATH, AC_API_KEY_ID, AC_API_ISSUER_ID
+# for notarization.
+
+require "securerandom"
+require "tmpdir"
+
+default_platform(:mac)
+
+# gym reads projects with the xcodeproj gem, which can't parse project.xcproj.
+# Through a workspace it skips projects it can't open and asks xcodebuild instead.
+WORKSPACE = "Front Row.xcodeproj/project.xcworkspace"
+SCHEME = "Front Row"
+TEAM_ID = "TCQ6328PP6"
+IDENTITY = "Developer ID Application"
+
+# Build output must stay outside the repository: the app target's swift-format
+# lint phase runs `-r ./` from $SRCROOT and would lint SPM's checkout of Sparkle.
+def build_root
+  ENV.fetch("BUILD_ROOT", File.join(Dir.tmpdir, "FrontRow-release"))
+end
+
+platform :mac do
+  desc "Import the Developer ID certificate into an ephemeral keychain"
+  lane :import_signing_certificate do
+    keychain = ENV.fetch("KEYCHAIN_PATH")
+    # Discarded with the runner, so the password only needs to be unguessable
+    # for this run.
+    password = SecureRandom.base64(32)
+    certificate = File.join(Dir.tmpdir, "developer-id.p12")
+
+    File.binwrite(certificate, ENV.fetch("DEVELOPER_ID_CERT_P12_BASE64").unpack1("m"))
+    begin
+      create_keychain(
+        path: keychain,
+        password: password,
+        default_keychain: false,
+        unlock: true,
+        timeout: 21_600,
+        lock_when_sleeps: false,
+        add_to_search_list: true
+      )
+      import_certificate(
+        certificate_path: certificate,
+        certificate_password: ENV.fetch("DEVELOPER_ID_CERT_PASSWORD"),
+        keychain_path: keychain,
+        keychain_password: password
+      )
+    ensure
+      File.delete(certificate) if File.exist?(certificate)
+    end
+  end
+
+  desc "Archive and export a Developer ID signed app"
+  lane :build do |options|
+    version = options.fetch(:version)
+    build = options.fetch(:build)
+    UI.user_error!("version must be X.Y.Z, got '#{version}'") unless version.to_s.match?(/\A\d+\.\d+\.\d+\z/)
+    UI.user_error!("build must be a whole number, got '#{build}'") unless build.to_s.match?(/\A\d+\z/)
+
+    build_mac_app(
+      workspace: WORKSPACE,
+      scheme: SCHEME,
+      configuration: "Release",
+      destination: "generic/platform=macOS",
+      archive_path: File.join(build_root, "FrontRow.xcarchive"),
+      derived_data_path: File.join(build_root, "DerivedData"),
+      output_directory: File.join(build_root, "export"),
+      export_method: "developer-id",
+      export_team_id: TEAM_ID,
+      export_options: {
+        # The certificate is imported into an ephemeral keychain. Naming it
+        # explicitly avoids an ambiguous match if more than one identity is present.
+        signingStyle: "manual",
+        signingCertificate: IDENTITY,
+        destination: "export"
+      },
+      # gym's profile detection parses project.pbxproj with the xcodeproj gem,
+      # which can't read the JSON project format (project.xcproj). Developer ID
+      # needs no profile.
+      skip_profile_detection: true,
+      # gym passes these to the export too; xcodebuild accepts them there.
+      xcargs: [
+        "CURRENT_PROJECT_VERSION=#{build}",
+        "MARKETING_VERSION=#{version}",
+        "CODE_SIGN_STYLE=Manual",
+        "CODE_SIGN_IDENTITY=#{IDENTITY.shellescape}",
+        "PROVISIONING_PROFILE_SPECIFIER=",
+        "DEVELOPMENT_TEAM=#{TEAM_ID}"
+      ].join(" "),
+      clean: true
+    )
+  end
+
+  desc "Notarize and staple a dmg"
+  lane :notarize_dmg do |options|
+    api_key = app_store_connect_api_key(
+      key_id: ENV.fetch("AC_API_KEY_ID"),
+      issuer_id: ENV.fetch("AC_API_ISSUER_ID"),
+      key_filepath: ENV.fetch("AC_KEY_PATH")
+    )
+
+    notarize(
+      package: options.fetch(:dmg),
+      api_key: api_key,
+      print_log: true
+    )
+    sh("xcrun", "stapler", "validate", options.fetch(:dmg))
+  end
+end
diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh
deleted file mode 100755
index 670675e..0000000
--- a/scripts/bump-version.sh
+++ /dev/null
@@ -1,54 +0,0 @@
-#!/bin/bash
-# Prints the next marketing version, given the current one and a release type.
-#
-#   bump-version.sh v2.10 minor   ->  2.11.0
-#
-# Input may be two- or three-part, with or without a leading "v"; output is
-# always normalized to X.Y.Z.
-
-set -euo pipefail
-
-current="${1-}"
-release_type="${2-}"
-
-die() {
-  echo "bump-version: $1" >&2
-  exit 1
-}
-
-case "$release_type" in
-  major | minor | patch) ;;
-  "") die "release type is required (major, minor, or patch)" ;;
-  *) die "unknown release type '$release_type' (expected major, minor, or patch)" ;;
-esac
-
-version="${current#v}"
-[[ -n "$version" ]] || die "version is required"
-
-# Leading zeros are rejected: $(( )) reads 08 and 09 as invalid octal, and that
-# error does not stop the script — it would print an unbumped version and exit 0.
-if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))?$ ]]; then
-  die "cannot parse version '$current' (expected X.Y or X.Y.Z, no leading zeros)"
-fi
-
-IFS=. read -r major minor patch <<<"$version"
-patch="${patch:-0}"
-
-major=$((10#$major)) minor=$((10#$minor)) patch=$((10#$patch))
-
-case "$release_type" in
-  major)
-    major=$((major + 1))
-    minor=0
-    patch=0
-    ;;
-  minor)
-    minor=$((minor + 1))
-    patch=0
-    ;;
-  patch)
-    patch=$((patch + 1))
-    ;;
-esac
-
-echo "$major.$minor.$patch"
diff --git a/scripts/bump-version.test.sh b/scripts/bump-version.test.sh
deleted file mode 100755
index 3f88e06..0000000
--- a/scripts/bump-version.test.sh
+++ /dev/null
@@ -1,71 +0,0 @@
-#!/bin/bash
-# Tests for bump-version.sh. Run directly: ./scripts/bump-version.test.sh
-
-set -uo pipefail
-
-script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-bump="$script_dir/bump-version.sh"
-
-failures=0
-
-expect() {
-  local current="$1" release_type="$2" want="$3"
-  local got
-  got="$("$bump" "$current" "$release_type" 2>&1)"
-  if [[ "$got" == "$want" ]]; then
-    echo "ok       $current + $release_type -> $got"
-  else
-    echo "FAIL     $current + $release_type -> got '$got', want '$want'"
-    failures=$((failures + 1))
-  fi
-}
-
-expect_failure() {
-  local current="$1" release_type="$2" description="$3"
-  if "$bump" "$current" "$release_type" >/dev/null 2>&1; then
-    echo "FAIL     $description: expected non-zero exit for '$current' + '$release_type'"
-    failures=$((failures + 1))
-  else
-    echo "ok       $description"
-  fi
-}
-
-# Two-part input normalizes to three-part output.
-expect "v2.10" patch "2.10.1"
-expect "v2.10" minor "2.11.0"
-expect "v2.10" major "3.0.0"
-
-# Three-part input.
-expect "v2.9.2" patch "2.9.3"
-expect "v2.9.2" minor "2.10.0"
-expect "v2.9.2" major "3.0.0"
-
-# The "v" prefix is optional.
-expect "2.10" minor "2.11.0"
-
-# Minor and major bumps zero out the components below them.
-expect "v1.4.7" minor "1.5.0"
-expect "v1.4.7" major "2.0.0"
-
-# Reject anything that would silently produce a wrong version.
-expect_failure "v2.10" ""        "rejects empty release type"
-expect_failure "v2.10" "moderate" "rejects unknown release type"
-expect_failure "not-a-version" minor "rejects non-numeric version"
-expect_failure "v2" minor        "rejects single-component version"
-expect_failure "v1.2.3.4" minor  "rejects four-component version"
-expect_failure "v2.x" minor      "rejects non-numeric component"
-expect_failure "" minor          "rejects empty version"
-
-# Leading zeros are read as octal by $(( )), which fails for 08/09 without
-# aborting the script — it would print an unbumped version and exit 0.
-expect_failure "v1.08" minor     "rejects a leading zero in the minor component"
-expect_failure "v1.2.08" patch   "rejects a leading zero in the patch component"
-expect_failure "v2.09" minor     "rejects 09, which is not valid octal"
-expect_failure "v01.2.3" major   "rejects a leading zero in the major component"
-
-echo
-if (( failures > 0 )); then
-  echo "$failures test(s) failed"
-  exit 1
-fi
-echo "all tests passed"
diff --git a/scripts/project-version.sh b/scripts/project-version.sh
deleted file mode 100755
index 084d96b..0000000
--- a/scripts/project-version.sh
+++ /dev/null
@@ -1,97 +0,0 @@
-#!/bin/bash
-# Reads and writes the version settings in project.xcproj.
-#
-#   project-version.sh read-marketing "Front Row.xcodeproj/project.xcproj"
-#   project-version.sh read-build     "Front Row.xcodeproj/project.xcproj"
-#   project-version.sh write          "Front Row.xcodeproj/project.xcproj" 2.11.0 206
-#
-# project.xcproj is Xcode's JSON project format, in its canonical layout
-# (xcprojformatter): project-level settings sit in the top-level
-# "build-settings" object, one per line at four spaces of indentation.
-# MARKETING_VERSION and CURRENT_PROJECT_VERSION both live there, shared by every
-# configuration, so each appears exactly once, with no per-configuration
-# ("KEY[config=...]") or per-target override. Anything else means the project
-# no longer matches what this assumes, and writing would be a guess.
-
-set -euo pipefail
-
-die() {
-  echo "project-version: $1" >&2
-  exit 1
-}
-
-# Every mention of the setting as a key, at any level or with any condition.
-count_keys() {
-  grep -c "\"$2[\"[]" "$1" || true
-}
-
-# The setting as a plain project-level key: four spaces, then "KEY": "value",
-project_line() {
-  printf '^    "%s": "\\(.*\\)",$' "$1"
-}
-
-read_setting() {
-  local project="$1" setting="$2"
-  [[ -f "$project" ]] || die "project file not found: $project"
-
-  local count
-  count="$(count_keys "$project" "$setting")"
-  (( count > 0 )) || die "$setting not found in $project"
-  (( count == 1 )) ||
-    die "$setting is set $count times in $project; expected one project-level value"
-
-  local value
-  value="$(sed -n "s/$(project_line "$setting")/\1/p" "$project")"
-  [[ -n "$value" ]] || die "$setting in $project is not a project-level setting"
-
-  echo "$value"
-}
-
-command="${1-}"
-project="${2-}"
-
-case "$command" in
-  read-marketing)
-    read_setting "$project" MARKETING_VERSION
-    ;;
-
-  read-build)
-    read_setting "$project" CURRENT_PROJECT_VERSION
-    ;;
-
-  write)
-    marketing="${3-}"
-    build="${4-}"
-
-    [[ -f "$project" ]] || die "project file not found: $project"
-    [[ "$marketing" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] ||
-      die "marketing version must be X.Y.Z, got '$marketing'"
-    [[ "$build" =~ ^[0-9]+$ ]] || die "build number must be a whole number, got '$build'"
-
-    # Reading validates the layout: one project-level value for each setting.
-    read_setting "$project" MARKETING_VERSION >/dev/null
-    read_setting "$project" CURRENT_PROJECT_VERSION >/dev/null
-
-    # Write to a temp file and move into place so a failure cannot leave a
-    # half-rewritten project.
-    tmp="$(mktemp)"
-    trap 'rm -f "$tmp"' EXIT
-
-    sed -e "s/$(project_line MARKETING_VERSION)/    \"MARKETING_VERSION\": \"$marketing\",/" \
-      -e "s/$(project_line CURRENT_PROJECT_VERSION)/    \"CURRENT_PROJECT_VERSION\": \"$build\",/" \
-      "$project" >"$tmp"
-
-    mv "$tmp" "$project"
-    trap - EXIT
-
-    echo "Set $project to $marketing ($build)"
-    ;;
-
-  "")
-    die "a command is required (read-marketing, read-build, or write)"
-    ;;
-
-  *)
-    die "unknown command '$command'"
-    ;;
-esac
diff --git a/scripts/project-version.test.sh b/scripts/project-version.test.sh
deleted file mode 100755
index 562b395..0000000
--- a/scripts/project-version.test.sh
+++ /dev/null
@@ -1,164 +0,0 @@
-#!/bin/bash
-# Tests for project-version.sh. Run directly: ./scripts/project-version.test.sh
-
-set -uo pipefail
-
-script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-tool="$script_dir/project-version.sh"
-
-failures=0
-workdir="$(mktemp -d)"
-trap 'rm -rf "$workdir"' EXIT
-
-check() {
-  if [[ "$2" == "pass" ]]; then
-    echo "ok       $1"
-  else
-    echo "FAIL     $1"
-    failures=$((failures + 1))
-  fi
-}
-
-expect_equal() {
-  local got="$1" want="$2" description="$3"
-  if [[ "$got" == "$want" ]]; then
-    check "$description" pass
-  else
-    check "$description (got '$got', want '$want')" fail
-  fi
-}
-
-# Mirrors the real layout: both settings once, at project level, between
-# per-configuration and per-target settings that must be left alone.
-make_project() {
-  cat >"$1" <<'XCPROJ'
-{
-  "default-configuration": "Release",
-  "targets": [
-    {
-      "name": "App",
-      "build-settings": {
-        "PRODUCT_NAME": "$(TARGET_NAME)",
-      },
-    },
-  ],
-  "build-settings": {
-    "COPY_PHASE_STRIP": "NO",
-    "CURRENT_PROJECT_VERSION": "24",
-    "DEAD_CODE_STRIPPING": "YES",
-    "DEBUG_INFORMATION_FORMAT[config=Debug]": "dwarf",
-    "MARKETING_VERSION": "2.10",
-    "SDKROOT": "macosx",
-  },
-}
-XCPROJ
-}
-
-project="$workdir/project.xcproj"
-make_project "$project"
-
-# --- read -------------------------------------------------------------------
-expect_equal "$("$tool" read-marketing "$project")" "2.10" "reads the marketing version"
-expect_equal "$("$tool" read-build "$project")" "24" "reads the build number"
-
-# --- write ------------------------------------------------------------------
-if "$tool" write "$project" 2.11.0 206 >/dev/null 2>&1; then
-  check "exits zero on write" pass
-else
-  check "exits zero on write" fail
-fi
-
-expect_equal "$("$tool" read-marketing "$project")" "2.11.0" "writes the marketing version"
-expect_equal "$("$tool" read-build "$project")" "206" "writes the build number"
-expect_equal "$(grep -c '^    "MARKETING_VERSION": "2.11.0",$' "$project")" "1" "writes the marketing version in place"
-expect_equal "$(grep -c '^    "CURRENT_PROJECT_VERSION": "206",$' "$project")" "1" "writes the build number in place"
-make_project "$workdir/expected.xcproj"
-expect_equal "$(diff "$workdir/expected.xcproj" "$project" | grep -c '^[<>]')" "4" "changes only the two version lines"
-
-# --- guards -----------------------------------------------------------------
-missing="$workdir/nope.xcproj"
-if "$tool" read-marketing "$missing" >/dev/null 2>&1; then
-  check "rejects a missing project file" fail
-else
-  check "rejects a missing project file" pass
-fi
-
-# A project whose settings are not where we expect must fail loudly rather than
-# silently write nothing.
-empty="$workdir/empty.xcproj"
-printf '{\n}\n' >"$empty"
-if "$tool" read-marketing "$empty" >/dev/null 2>&1; then
-  check "rejects a project with no marketing version" fail
-else
-  check "rejects a project with no marketing version" pass
-fi
-if "$tool" write "$empty" 2.11.0 206 >/dev/null 2>&1; then
-  check "refuses to write when settings are absent" fail
-else
-  check "refuses to write when settings are absent" pass
-fi
-
-# An override anywhere else means the project no longer matches the assumed
-# layout: a per-configuration value, or a per-target one.
-overridden="$workdir/overridden.xcproj"
-for override in '"MARKETING_VERSION[config=Debug]": "2.9",' '"MARKETING_VERSION": "2.9",'; do
-  make_project "$overridden"
-  awk -v line="        $override" '{ print } /"PRODUCT_NAME"/ { print line }' "$overridden" >"$overridden.tmp"
-  mv "$overridden.tmp" "$overridden"
-  [[ "$(grep -c 'MARKETING_VERSION' "$overridden")" == "2" ]] ||
-    { echo "FAIL     test fixture is wrong: overridden project should have 2 MARKETING_VERSION"; failures=$((failures + 1)); }
-  if "$tool" write "$overridden" 2.11.0 206 >/dev/null 2>&1; then
-    check "refuses to write with override $override" fail
-  else
-    check "refuses to write with override $override" pass
-  fi
-done
-
-# A setting that is only set per target is not a project-level setting.
-targetonly="$workdir/targetonly.xcproj"
-make_project "$targetonly"
-awk '!/^    "MARKETING_VERSION"/ { print } /"PRODUCT_NAME"/ { print "        \"MARKETING_VERSION\": \"2.9\"," }' "$targetonly" >"$targetonly.tmp"
-mv "$targetonly.tmp" "$targetonly"
-if "$tool" read-marketing "$targetonly" >/dev/null 2>&1; then
-  check "rejects a marketing version set only on a target" fail
-else
-  check "rejects a marketing version set only on a target" pass
-fi
-
-# Reject values that would corrupt the project file or produce a bad version.
-make_project "$project"
-for bad in "" "2.11.0;" "2.11.0 extra" "not a version"; do
-  if "$tool" write "$project" "$bad" 206 >/dev/null 2>&1; then
-    check "rejects marketing version '$bad'" fail
-  else
-    check "rejects marketing version '$bad'" pass
-  fi
-done
-for bad in "" "abc" "20.6" "-1"; do
-  if "$tool" write "$project" 2.11.0 "$bad" >/dev/null 2>&1; then
-    check "rejects build number '$bad'" fail
-  else
-    check "rejects build number '$bad'" pass
-  fi
-done
-
-# Nothing above should have modified the file.
-expect_equal "$("$tool" read-marketing "$project")" "2.10" "leaves the project untouched when validation fails"
-
-# The real project, which is what a release reads. Both settings must stay
-# unambiguous across every target for the release to work out the next version.
-real="$script_dir/../Front Row.xcodeproj/project.xcproj"
-for command in read-marketing read-build; do
-  if "$tool" "$command" "$real" >/dev/null 2>&1; then
-    check "$command answers for the real project" pass
-  else
-    check "$command answers for the real project" fail
-  fi
-done
-
-echo
-if (( failures > 0 )); then
-  echo "$failures test(s) failed"
-  exit 1
-fi
-echo "all tests passed"
diff --git a/scripts/render-notes.sh b/scripts/render-notes.sh
index 4686b34..27ffb2b 100755
--- a/scripts/render-notes.sh
+++ b/scripts/render-notes.sh
@@ -2,7 +2,7 @@
 # Renders a Markdown release notes file as an HTML fragment on stdout, indented
 # to sit inside the appcast's  CDATA.
 #
-#   render-notes.sh .sparkle/notes/next.md  ->  
    \n
  • New: …
  • \n
+# render-notes.sh notes.md ->
    \n
  • New: …
  • \n
# # cmark-gfm is GitHub's own Markdown implementation, so Sparkle's update dialog # shows what the GitHub release page shows.