From 6e9435ab4ee3518e1c35cd9cfc56322cc4381546 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 12 Jul 2026 08:53:43 -0700 Subject: [PATCH] add release tag helper --- .github/workflows/release.yml | 76 ++++++++----- docs/releases.md | 21 ++++ scripts/tag-version.sh | 202 ++++++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+), 28 deletions(-) create mode 100644 docs/releases.md create mode 100755 scripts/tag-version.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 075d7a3..ace46ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,34 +21,6 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Derive release metadata - run: | - TAG="$GITHUB_REF_NAME" - - if [[ ! "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?)$ ]]; then - echo "Tag must be in vX.Y.Z or vX.Y.Z-suffix format, received: $TAG" - exit 1 - fi - - VERSION="${BASH_REMATCH[1]}" - MARKETING_VERSION="$VERSION" - if [[ "$VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then - MARKETING_VERSION="${BASH_REMATCH[1]}" - fi - IS_PRERELEASE=false - # Stable is exactly X.Y.Z; anything else is treated as prerelease metadata. - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - IS_PRERELEASE=true - fi - - BUILD_NUMBER="$GITHUB_RUN_NUMBER" - - echo "RELEASE_TAG=$TAG" >> "$GITHUB_ENV" - echo "RELEASE_VERSION=$VERSION" >> "$GITHUB_ENV" - echo "MARKETING_VERSION=$MARKETING_VERSION" >> "$GITHUB_ENV" - echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV" - echo "IS_PRERELEASE=$IS_PRERELEASE" >> "$GITHUB_ENV" - - name: Load Sparkle build settings env: SPARKLE_APPCAST_URL: ${{ vars.SPARKLE_APPCAST_URL }} @@ -80,6 +52,54 @@ jobs: SPARKLE_PUBLIC_KEY = $SPARKLE_PUBLIC_KEY EOF + - name: Validate and derive release metadata + run: | + TAG="$GITHUB_REF_NAME" + + if [[ ! "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)(-([0-9A-Za-z]+([.-][0-9A-Za-z]+)*))?$ ]]; then + echo "Tag must be in vX.Y.Z or vX.Y.Z-suffix format, received: $TAG" + exit 1 + fi + + MARKETING_VERSION="${BASH_REMATCH[1]}" + RELEASE_VERSION="${TAG#v}" + IS_PRERELEASE=false + if [[ "$TAG" != "v$MARKETING_VERSION" ]]; then + IS_PRERELEASE=true + fi + + PROJECT_VERSIONS="$( + xcodebuild \ + -project BitDream.xcodeproj \ + -scheme BitDream \ + -configuration Release \ + -destination "generic/platform=macOS" \ + -showBuildSettings | + awk '$1 == "MARKETING_VERSION" && $2 == "=" { print $3 }' | + sort -u + )" + VERSION_COUNT="$(printf '%s\n' "$PROJECT_VERSIONS" | awk 'NF { count++ } END { print count + 0 }')" + + if [[ "$VERSION_COUNT" -ne 1 ]]; then + echo "Expected exactly one project MARKETING_VERSION, but found $VERSION_COUNT." + printf '%s\n' "$PROJECT_VERSIONS" + exit 1 + fi + + if [[ "$MARKETING_VERSION" != "$PROJECT_VERSIONS" ]]; then + echo "Release tag $TAG does not match project MARKETING_VERSION $PROJECT_VERSIONS." + echo "Expected v$PROJECT_VERSIONS or v$PROJECT_VERSIONS-suffix." + exit 1 + fi + + BUILD_NUMBER="$GITHUB_RUN_NUMBER" + + echo "RELEASE_TAG=$TAG" >> "$GITHUB_ENV" + echo "RELEASE_VERSION=$RELEASE_VERSION" >> "$GITHUB_ENV" + echo "MARKETING_VERSION=$MARKETING_VERSION" >> "$GITHUB_ENV" + echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV" + echo "IS_PRERELEASE=$IS_PRERELEASE" >> "$GITHUB_ENV" + - name: Import Apple signing certificate env: P12_BASE64: ${{ secrets.APPLE_CERT_P12_BASE64 }} diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..73e8c96 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,21 @@ +# Creating a Release + +Set the app and widget `MARKETING_VERSION` to the same `major.minor.patch` value and merge the change into `main`. The local checkout must be clean, match `origin/main`, and have `Config/BuildSettings.local.xcconfig` configured. + +Run the preflight checks, then create a stable release tag: + +```bash +./scripts/tag-version.sh --dry-run +./scripts/tag-version.sh +``` + +For a prerelease, provide a suffix without the leading hyphen: + +```bash +./scripts/tag-version.sh --dry-run --prerelease beta.1 +./scripts/tag-version.sh --prerelease beta.1 +``` + +Pushing the tag starts `.github/workflows/release.yml`, which builds, signs, notarizes, and publishes the macOS release. The workflow rejects tags that do not match the Xcode marketing version. + +See `docs/build-settings.md` and `docs/sparkle-updates.md` for configuration details. diff --git a/scripts/tag-version.sh b/scripts/tag-version.sh new file mode 100755 index 0000000..387b4fd --- /dev/null +++ b/scripts/tag-version.sh @@ -0,0 +1,202 @@ +#!/bin/bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: ./scripts/tag-version.sh [--dry-run] [--prerelease SUFFIX] + +Create and push a release tag based on the app's MARKETING_VERSION. + +Options: + --dry-run Run every preflight check without creating or pushing a tag. + --prerelease SUFFIX Append a prerelease suffix, for example beta.1. + -h, --help Show this help text. +EOF +} + +dry_run=false +prerelease_suffix="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + dry_run=true + shift + ;; + --prerelease) + if [[ $# -lt 2 || -z "$2" ]]; then + echo "--prerelease requires a suffix." >&2 + usage >&2 + exit 2 + fi + prerelease_suffix="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -n "$prerelease_suffix" && ! "$prerelease_suffix" =~ ^[0-9A-Za-z]+([.-][0-9A-Za-z]+)*$ ]]; then + echo "Prerelease suffix must contain alphanumeric identifiers separated by dots or hyphens: $prerelease_suffix" >&2 + exit 2 +fi + +for command in git xcodebuild; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command not found: $command" >&2 + exit 1 + fi +done + +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repository_root="$(git -C "$script_directory" rev-parse --show-toplevel 2>/dev/null)" || { + echo "The release script must be located inside the BitDream repository." >&2 + exit 1 +} +cd "$repository_root" + +if [[ ! -f BitDream.xcodeproj/project.pbxproj ]]; then + echo "The release script must be located inside the BitDream repository." >&2 + exit 1 +fi + +if [[ -n "$(git status --porcelain)" ]]; then + echo "The working tree must be clean before creating a release tag." >&2 + git status --short >&2 + exit 1 +fi + +if ! git remote get-url origin >/dev/null 2>&1; then + echo "The repository does not have an origin remote." >&2 + exit 1 +fi + +if [[ ! -f Config/BuildSettings.local.xcconfig ]]; then + echo "Missing Config/BuildSettings.local.xcconfig." >&2 + echo "Create it from Config/BuildSettings.example.local.xcconfig before tagging a release." >&2 + exit 1 +fi + +echo "Fetching origin/main and release tags..." +git fetch origin refs/heads/main:refs/remotes/origin/main --tags + +head_commit="$(git rev-parse HEAD)" +main_commit="$(git rev-parse refs/remotes/origin/main)" + +if [[ "$head_commit" != "$main_commit" ]]; then + echo "HEAD must match origin/main before creating a release tag." >&2 + echo "HEAD: $head_commit" >&2 + echo "origin/main: $main_commit" >&2 + exit 1 +fi + +versions="$({ + xcodebuild \ + -project BitDream.xcodeproj \ + -scheme BitDream \ + -configuration Release \ + -destination "generic/platform=macOS" \ + -showBuildSettings +} | awk '$1 == "MARKETING_VERSION" && $2 == "=" { print $3 }' | sort -u)" + +version_count="$(printf '%s\n' "$versions" | awk 'NF { count++ } END { print count + 0 }')" +if [[ "$version_count" -ne 1 ]]; then + echo "Expected exactly one MARKETING_VERSION, but found $version_count." >&2 + if [[ -n "$versions" ]]; then + printf '%s\n' "$versions" >&2 + fi + exit 1 +fi + +version="$versions" +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "MARKETING_VERSION must use numeric major.minor.patch format: $version" >&2 + exit 1 +fi + +tag="v$version" +if [[ -n "$prerelease_suffix" ]]; then + tag="$tag-$prerelease_suffix" +fi + +if git show-ref --verify --quiet "refs/tags/$tag"; then + echo "Tag already exists: $tag" >&2 + exit 1 +fi + +if ! remote_tag="$(git ls-remote --tags origin "refs/tags/$tag")"; then + echo "Unable to check whether $tag already exists on origin." >&2 + exit 1 +fi + +if [[ -n "$remote_tag" ]]; then + echo "Tag already exists on origin: $tag" >&2 + exit 1 +fi + +short_commit="$(git rev-parse --short HEAD)" +echo +echo "Release version: $version" +if [[ -n "$prerelease_suffix" ]]; then + echo "Prerelease: $prerelease_suffix" +fi +echo "Tag: $tag" +echo "Commit: $short_commit" + +if [[ "$dry_run" == true ]]; then + echo + echo "Dry run complete. No tag was created or pushed." + exit 0 +fi + +echo +if ! read -r -p "Create and push $tag to origin? [y/N] " response; then + echo >&2 + echo "Release cancelled." >&2 + exit 1 +fi + +case "$response" in + y|Y|yes|YES|Yes) ;; + *) + echo "Release cancelled." + exit 0 + ;; +esac + +git tag "$tag" + +if ! git push origin "refs/tags/$tag"; then + echo "Tag push failed; removing the newly created local tag." >&2 + git tag -d "$tag" >/dev/null + exit 1 +fi + +origin_url="$(git remote get-url origin)" +case "$origin_url" in + https://github.com/*) + repository_url="${origin_url%.git}" + ;; + git@github.com:*) + repository_url="https://github.com/${origin_url#git@github.com:}" + repository_url="${repository_url%.git}" + ;; + *) + repository_url="" + ;; +esac + +echo +echo "Pushed $tag. The GitHub release workflow has started." +if [[ -n "$repository_url" ]]; then + echo "$repository_url/actions/workflows/release.yml" +fi