diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
new file mode 100644
index 0000000..44cad28
--- /dev/null
+++ b/.github/workflows/prepare-release.yml
@@ -0,0 +1,70 @@
+name: Prepare release
+
+# Runs when a PR is merged into main. Reads the PR's release label, computes
+# the next version from the latest tag, pushes an annotated tag, and kicks
+# off release.yml via workflow_dispatch.
+#
+# This is the ONLY automation step that writes a git ref — and it only ever
+# writes a tag, never a commit to main. release.yml is dispatched explicitly
+# (not via the tag push) because tags pushed with GITHUB_TOKEN do not trigger
+# other workflows; this keeps everything on GITHUB_TOKEN with no PAT needed.
+
+on:
+ pull_request:
+ types: [closed]
+ branches: [main]
+
+permissions:
+ contents: write
+ actions: write
+
+concurrency:
+ group: prepare-release
+ cancel-in-progress: false
+
+jobs:
+ prepare:
+ if: github.event.pull_request.merged == true
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Determine version bump from PR label
+ id: bump
+ env:
+ LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }}
+ run: |
+ if echo "$LABELS" | grep -q '"release:major"'; then bump=major
+ elif echo "$LABELS" | grep -q '"release:minor"'; then bump=minor
+ elif echo "$LABELS" | grep -q '"release:patch"'; then bump=patch
+ else
+ echo "No release:patch|minor|major label (release:skip or none) — nothing to release."
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "bump=$bump" >> "$GITHUB_OUTPUT"
+
+ - name: Tag and dispatch release
+ if: steps.bump.outputs.skip != 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ BUMP: ${{ steps.bump.outputs.bump }}
+ MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
+ run: |
+ TAG="$(scripts/next-version.sh "$BUMP")"
+ echo "Next release: $TAG (from release:$BUMP)"
+
+ if git rev-parse "$TAG" >/dev/null 2>&1; then
+ echo "::error::Tag $TAG already exists — aborting."
+ exit 1
+ fi
+
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git tag -a "$TAG" -m "Release $TAG" "$MERGE_SHA"
+ git push origin "$TAG"
+
+ gh workflow run release.yml --ref "$TAG"
+ echo "Pushed $TAG and dispatched release.yml"
diff --git a/.github/workflows/release-label-check.yml b/.github/workflows/release-label-check.yml
new file mode 100644
index 0000000..95e148b
--- /dev/null
+++ b/.github/workflows/release-label-check.yml
@@ -0,0 +1,51 @@
+name: Release label check
+
+# Every PR into main must declare its release intent via exactly one label:
+# release:patch | release:minor | release:major | release:skip
+# This check fails if that's not the case, and comments the version that
+# will be released on merge. Make it a required status check in branch
+# protection so a PR can't merge without a deliberate release decision.
+
+on:
+ pull_request:
+ types: [opened, edited, labeled, unlabeled, synchronize, reopened]
+ branches: [main]
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Require exactly one release label
+ env:
+ LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }}
+ run: |
+ count=0
+ for l in release:major release:minor release:patch release:skip; do
+ if echo "$LABELS" | grep -q "\"$l\""; then count=$((count + 1)); fi
+ done
+ if [ "$count" -ne 1 ]; then
+ echo "::error::PR must have exactly one of: release:patch, release:minor, release:major, release:skip (found $count)"
+ exit 1
+ fi
+
+ - name: Comment next version
+ if: ${{ !contains(github.event.pull_request.labels.*.name, 'release:skip') }}
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }}
+ PR: ${{ github.event.pull_request.number }}
+ run: |
+ if echo "$LABELS" | grep -q '"release:major"'; then bump=major
+ elif echo "$LABELS" | grep -q '"release:minor"'; then bump=minor
+ else bump=patch; fi
+ TAG="$(scripts/next-version.sh "$bump")"
+ gh pr comment "$PR" --edit-last --create-if-none \
+ --body "🔖 On merge this PR will release **$TAG** (\`release:$bump\`)."
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5579da3..ac11064 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,10 +4,16 @@ on:
push:
tags:
- "v*"
+ workflow_dispatch:
permissions:
contents: write
- pull-requests: write
+
+concurrency:
+ # Prevent duplicate/overlapping release runs for the same ref — this is the
+ # v1.7.2 double-run race that lost the GitHub-release creation.
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
env:
APP_NAME: BrowserCat
@@ -106,6 +112,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
BUILD_NUMBER=$(git rev-list --count HEAD)
+ # The git tag is the single source of truth for the version —
+ # project.yml's MARKETING_VERSION is just a placeholder.
+ MARKETING_VERSION="${GITHUB_REF_NAME#v}"
xcodebuild archive \
-project "$PROJECT_FILE" \
-scheme "$SCHEME" \
@@ -117,7 +126,8 @@ jobs:
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Developer ID Application" \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
- CURRENT_PROJECT_VERSION="$BUILD_NUMBER"
+ CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
+ MARKETING_VERSION="$MARKETING_VERSION"
- name: Create export options plist
env:
@@ -208,20 +218,49 @@ jobs:
echo 'SPARKLE_EOF'
} >> "$GITHUB_ENV"
- - name: Update appcast.xml
+ - name: Create GitHub release
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ VERSION=${GITHUB_REF_NAME#v}
+
+ # Idempotent: a re-run (or a stray duplicate) updates assets instead
+ # of failing on "release already exists".
+ if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then
+ gh release upload "${GITHUB_REF_NAME}" \
+ "build/${DISPLAY_NAME}-${VERSION}.dmg" \
+ "build/${DISPLAY_NAME}-${VERSION}.sha256" \
+ --clobber
+ else
+ gh release create "${GITHUB_REF_NAME}" \
+ "build/${DISPLAY_NAME}-${VERSION}.dmg" \
+ "build/${DISPLAY_NAME}-${VERSION}.sha256" \
+ --title "${APP_NAME} ${VERSION}" \
+ --generate-notes
+ fi
+
+ - name: Publish appcast to gh-pages
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=${GITHUB_REF_NAME#v}
BUILD_NUMBER=$(git rev-list --count HEAD)
DMG_PATH="build/${DISPLAY_NAME}-${VERSION}.dmg"
DMG_SIZE=$(stat -f%z "$DMG_PATH")
- DOWNLOAD_URL="https://github.com/${{ github.repository }}/releases/download/${GITHUB_REF_NAME}/${DISPLAY_NAME}-${VERSION}.dmg"
-
+ DOWNLOAD_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/${DISPLAY_NAME}-${VERSION}.dmg"
ED_SIGNATURE=$(echo "$SPARKLE_SIGN_OUTPUT" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p')
ED_LENGTH=$(echo "$SPARKLE_SIGN_OUTPUT" | sed -n 's/.*length="\([^"]*\)".*/\1/p')
+ # The Sparkle feed lives on the dedicated, unprotected gh-pages
+ # branch — never on main. A worktree isolates it from the tag checkout.
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git fetch origin gh-pages:gh-pages
+ git worktree add /tmp/ghpages gh-pages
+
python3 -c "
import datetime, pathlib, sys
- appcast = pathlib.Path('docs/appcast.xml')
+ appcast = pathlib.Path('/tmp/ghpages/appcast.xml')
content = appcast.read_text()
marker = '${VERSION}'
if marker in content:
@@ -244,65 +283,16 @@ jobs:
appcast.write_text(content)
"
- echo "Updated appcast.xml with version ${VERSION}"
- cat docs/appcast.xml
-
- - name: Create GitHub release
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- VERSION=${GITHUB_REF_NAME#v}
-
- gh release create "${GITHUB_REF_NAME}" \
- "build/${DISPLAY_NAME}-${VERSION}.dmg" \
- "build/${DISPLAY_NAME}-${VERSION}.sha256" \
- --title "${APP_NAME} ${VERSION}" \
- --generate-notes
-
- - name: Commit and push appcast.xml
- continue-on-error: true
- env:
- # RELEASE_PAT is a fine-grained PAT with Contents: write on this repo —
- # required because branch protection on `main` rejects direct pushes from
- # the default GITHUB_TOKEN. Falls back to GITHUB_TOKEN, which still works
- # if github-actions[bot] is in the branch-protection bypass list.
- GH_TOKEN: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }}
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
- cp docs/appcast.xml /tmp/appcast.xml
- git checkout main
- cp /tmp/appcast.xml docs/appcast.xml
- git add docs/appcast.xml
- if git diff --quiet --cached -- docs/appcast.xml; then
- echo "No changes to appcast.xml, skipping"
- exit 0
- fi
- git commit -m "Update appcast.xml for ${GITHUB_REF_NAME}"
-
- # Use the (potentially elevated) token for git push.
- git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
-
- if git push origin main; then
- echo "Pushed appcast directly to main"
- exit 0
+ cd /tmp/ghpages
+ if git diff --quiet -- appcast.xml; then
+ echo "appcast.xml unchanged — nothing to publish"
+ else
+ git add appcast.xml
+ git commit -m "Update appcast for ${GITHUB_REF_NAME}"
+ git push origin gh-pages
+ echo "Published ${GITHUB_REF_NAME} to the Sparkle feed (gh-pages)"
fi
- # Branch protection rejected the direct push — fall back to PR + admin merge.
- # Requires RELEASE_PAT (admin scope) for the --admin merge flag to work.
- echo "Direct push to main rejected (branch protection). Opening PR + auto-merge..."
- BRANCH="appcast/${GITHUB_REF_NAME}"
- git checkout -b "$BRANCH"
- git push origin "$BRANCH"
-
- gh pr create \
- --base main \
- --head "$BRANCH" \
- --title "Update appcast.xml for ${GITHUB_REF_NAME}" \
- --body "Automated appcast update for ${GITHUB_REF_NAME} release."
-
- gh pr merge "$BRANCH" --admin --merge --delete-branch
-
- name: Cleanup signing artifacts
if: always()
run: |
diff --git a/CLAUDE.md b/CLAUDE.md
index bb200f2..8fa7462 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -40,6 +40,30 @@ open BrowserCat.xcodeproj # Select "BrowserCat DEV" scheme → Run
| BrowserCat DEV | Debug | BrowserCat DEV | ua.com.rmarinsky.browsercat.dev |
| BrowserCat | Release | BrowserCat | ua.com.rmarinsky.browsercat |
+## Release process
+
+**NEVER push directly to `main`** — not commits, not tags. Everything goes through a PR.
+**NEVER create or push `v*` tags by hand** — CI does that.
+**NEVER hand-edit** `MARKETING_VERSION` in `project.yml` or the Sparkle `appcast.xml`.
+
+To ship a release:
+
+1. Open a PR into `main`.
+2. Add exactly one label: `release:patch` (bug fix / internal), `release:minor`
+ (new user-facing capability), `release:major` (breaking change), or
+ `release:skip` (no release for this PR).
+3. Merge the PR.
+
+CI does the rest: `prepare-release.yml` computes the next version from the
+latest tag, pushes tag `vX.Y.Z`, and dispatches `release.yml`, which builds,
+notarizes, signs, creates the GitHub Release, and updates the Sparkle
+`appcast.xml` on the **`gh-pages`** branch. The app version comes **from the
+git tag** — `project.yml`'s `MARKETING_VERSION` is only a placeholder.
+
+Workflows: `.github/workflows/release-label-check.yml` (PR gate),
+`prepare-release.yml` (tag on merge), `release.yml` (build + publish).
+Version math: `scripts/next-version.sh`.
+
## Architecture
### URL Interception Flow (End-to-End)
diff --git a/release.sh b/release.sh
index 97f0cd2..2334603 100755
--- a/release.sh
+++ b/release.sh
@@ -33,6 +33,12 @@ ARCHIVE_PATH="${BUILD_DIR}/${APP_NAME}.xcarchive"
EXPORT_PATH="${BUILD_DIR}/export"
APP_PATH="${EXPORT_PATH}/${APP_NAME}.app"
+# The git tag is the single source of truth for the version (same as CI).
+# project.yml's MARKETING_VERSION is only a placeholder. Override with
+# RELEASE_VERSION=X.Y.Z for a local build that isn't on a tag.
+MARKETING_VERSION="${RELEASE_VERSION:-$(git -C "${PROJECT_DIR}" describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')}"
+MARKETING_VERSION="${MARKETING_VERSION:-0.0.0}"
+
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
@@ -82,7 +88,7 @@ xcodebuild -resolvePackageDependencies \
-project "${PROJECT_FILE}" \
-scheme "${SCHEME}"
-echo -e "${YELLOW}[3/7] Archiving universal app (arm64 + x86_64)...${NC}"
+echo -e "${YELLOW}[3/7] Archiving universal app (arm64 + x86_64), version ${MARKETING_VERSION}...${NC}"
xcodebuild archive \
-project "${PROJECT_FILE}" \
-scheme "${SCHEME}" \
@@ -93,7 +99,8 @@ xcodebuild archive \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Developer ID Application" \
- DEVELOPMENT_TEAM="${TEAM_ID}"
+ DEVELOPMENT_TEAM="${TEAM_ID}" \
+ MARKETING_VERSION="${MARKETING_VERSION}"
if [ ! -d "${ARCHIVE_PATH}" ]; then
echo -e "${RED}Error: Archive failed${NC}"
diff --git a/scripts/next-version.sh b/scripts/next-version.sh
new file mode 100755
index 0000000..08e6856
--- /dev/null
+++ b/scripts/next-version.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+# Compute the next release tag from the latest `v*` git tag.
+#
+# Usage: next-version.sh
+# Prints: vX.Y.Z
+#
+# Pure tag math — the git tag is the single source of truth for the app
+# version. CI (prepare-release.yml) uses this on PR merge; humans can run
+# it locally to preview the next version. It writes nothing.
+set -euo pipefail
+
+bump="${1:?usage: next-version.sh }"
+
+git fetch --tags --quiet 2>/dev/null || true
+
+latest="$(git tag -l 'v*' | sed 's/^v//' | sort -V | tail -1)"
+latest="${latest:-0.0.0}"
+
+IFS=. read -r major minor patch <<<"$latest"
+major="${major:-0}"; minor="${minor:-0}"; patch="${patch:-0}"
+
+case "$bump" in
+ major) major=$((major + 1)); minor=0; patch=0 ;;
+ minor) minor=$((minor + 1)); patch=0 ;;
+ patch) patch=$((patch + 1)) ;;
+ *) echo "unknown bump: $bump (expected patch|minor|major)" >&2; exit 1 ;;
+esac
+
+echo "v${major}.${minor}.${patch}"