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