Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -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"
51 changes: 51 additions & 0 deletions .github/workflows/release-label-check.yml
Original file line number Diff line number Diff line change
@@ -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\`)."
116 changes: 53 additions & 63 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" \
Expand All @@ -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:
Expand Down Expand Up @@ -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 = '<sparkle:shortVersionString>${VERSION}</sparkle:shortVersionString>'
if marker in content:
Expand All @@ -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: |
Expand Down
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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}" \
Expand All @@ -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}"
Expand Down
29 changes: 29 additions & 0 deletions scripts/next-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Compute the next release tag from the latest `v*` git tag.
#
# Usage: next-version.sh <patch|minor|major>
# 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 <patch|minor|major>}"

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}"
Loading