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.