diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 00000000..fb15873f
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,328 @@
+name: Build
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+ id-token: write
+ attestations: write
+
+jobs:
+ build:
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
+ runs-on: macos-26
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ # Nothing here pushes; don't leave the token in .git/config where it
+ # could leak into an uploaded artifact (zizmor: artipacked).
+ persist-credentials: false
+
+ # Creates a short-lived keychain isolated to this job. The 6-hour
+ # timeout (21600 s) exceeds any reasonable build duration.
+ - name: Create and unlock temporary macOS keychain
+ run: |
+ KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
+ KEYCHAIN_PATH="$RUNNER_TEMP/signing.keychain-db"
+ security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
+ echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV"
+
+ - name: Import signing certificates
+ env:
+ APP_CERTIFICATE_BASE64: ${{ secrets.APP_APPLE_SIGNING_CERTIFICATE_BASE64 }}
+ APP_CERTIFICATE_PASSWORD: ${{ secrets.APP_APPLE_SIGNING_CERTIFICATE_PASSWORD }}
+ INSTALLER_CERTIFICATE_BASE64: ${{ secrets.PKG_APPLE_SIGNING_CERTIFICATE_BASE64 }}
+ INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.PKG_APPLE_SIGNING_CERTIFICATE_PASSWORD }}
+ run: |
+ APP_CERTIFICATE_PATH="$RUNNER_TEMP/app-certificate.p12"
+ INSTALLER_CERTIFICATE_PATH="$RUNNER_TEMP/installer-certificate.p12"
+
+ echo "$APP_CERTIFICATE_BASE64" | base64 --decode > "$APP_CERTIFICATE_PATH"
+ security import "$APP_CERTIFICATE_PATH" \
+ -k "$KEYCHAIN_PATH" \
+ -P "$APP_CERTIFICATE_PASSWORD" \
+ -T /usr/bin/codesign \
+ -T /usr/bin/pkgbuild \
+ -T /usr/bin/productbuild
+
+ echo "$INSTALLER_CERTIFICATE_BASE64" | base64 --decode > "$INSTALLER_CERTIFICATE_PATH"
+ security import "$INSTALLER_CERTIFICATE_PATH" \
+ -k "$KEYCHAIN_PATH" \
+ -P "$INSTALLER_CERTIFICATE_PASSWORD" \
+ -T /usr/bin/codesign \
+ -T /usr/bin/pkgbuild \
+ -T /usr/bin/productbuild
+ security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain
+ CODESIGN_IDENTITIES=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" || true)
+ BASIC_IDENTITIES=$(security find-identity -v -p basic "$KEYCHAIN_PATH" || true)
+ echo "$CODESIGN_IDENTITIES"
+ echo "$BASIC_IDENTITIES"
+ if ! echo "$CODESIGN_IDENTITIES" | grep -q "Developer ID Application"; then
+ echo "Missing Developer ID Application identity in imported keychain."
+ exit 1
+ fi
+ if ! echo "$BASIC_IDENTITIES" | grep -q "Developer ID Installer"; then
+ echo "Missing Developer ID Installer identity in imported keychain."
+ exit 1
+ fi
+ rm "$APP_CERTIFICATE_PATH" "$INSTALLER_CERTIFICATE_PATH"
+
+ - name: Preflight signing configuration
+ run: |
+ echo "Using app signing identity: Developer ID Application"
+ echo "Using installer signing identity: Developer ID Installer"
+ echo "Using team ID: ${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }}"
+
+ - name: Resolve marketing version
+ run: |
+ VERSION=$(xcodebuild -showBuildSettings \
+ -project Homebrew.xcodeproj \
+ -scheme Brew \
+ -configuration Release \
+ | awk -F' = ' '/ MARKETING_VERSION /{print $2; exit}')
+ if [ -z "$VERSION" ]; then
+ echo "Could not resolve MARKETING_VERSION from build settings."
+ exit 1
+ fi
+ echo "VERSION=$VERSION" >> "$GITHUB_ENV"
+ echo "Building version $VERSION"
+
+ - name: Build and archive
+ run: |
+ # --timestamp requests a secure Apple timestamp (mandatory for
+ # Developer ID signing and notarisation); --options runtime enables
+ # the hardened runtime. Both are pinned here so codesign is
+ # deterministic on CI rather than relying on Xcode defaults.
+ xcodebuild archive \
+ -project Homebrew.xcodeproj \
+ -scheme Brew \
+ -configuration Release \
+ -archivePath "$RUNNER_TEMP/Homebrew.xcarchive" \
+ CODE_SIGN_STYLE=Manual \
+ CODE_SIGN_IDENTITY="Developer ID Application" \
+ DEVELOPMENT_TEAM="${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }}" \
+ OTHER_CODE_SIGN_FLAGS="--keychain $KEYCHAIN_PATH --timestamp --options runtime"
+
+ # Fail fast with a clear message if the archived app did not get a secure
+ # timestamp, rather than surfacing it later at notarisation.
+ - name: Verify archived app signature
+ run: |
+ APP="$RUNNER_TEMP/Homebrew.xcarchive/Products/Applications/Homebrew.app"
+ codesign -dv --verbose=4 "$APP" 2>&1 | tee "$RUNNER_TEMP/archive-signature.txt"
+ if ! grep -q "^Timestamp=" "$RUNNER_TEMP/archive-signature.txt"; then
+ echo "Archived app is missing a secure timestamp."
+ exit 1
+ fi
+
+ - name: Export archive
+ run: |
+ cat > "$RUNNER_TEMP/ExportOptions.plist" << EOF
+
+
+
+
+ method
+ developer-id
+ teamID
+ ${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }}
+ signingStyle
+ manual
+
+
+ EOF
+ xcodebuild -exportArchive \
+ -archivePath "$RUNNER_TEMP/Homebrew.xcarchive" \
+ -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" \
+ -exportPath "$RUNNER_TEMP/export"
+
+ - name: Verify app signature from export
+ run: |
+ APP_BUNDLE_PATH="$RUNNER_TEMP/export/Homebrew.app"
+ xcrun codesign -dv --verbose=4 "$APP_BUNDLE_PATH" 2>&1 | tee "$RUNNER_TEMP/app-signature.txt"
+ if ! grep -q "Authority=Developer ID Application" "$RUNNER_TEMP/app-signature.txt"; then
+ echo "Exported app is not signed with a Developer ID Application authority."
+ exit 1
+ fi
+
+ # Notarise and staple the app bundle itself, before it is packaged. The
+ # cask install path drops a bare Homebrew.app, so it needs its own stapled
+ # ticket — the pkg's notarisation does not travel with the extracted app.
+ # Stapling here also means the copy embedded in the pkg is notarised.
+ # main only — PRs stay a signed-but-not-notarised dry run.
+ - name: Notarize and staple app
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ env:
+ APPLE_ID: ${{ secrets.PKG_APPLE_ID_EMAIL }}
+ APPLE_ID_PASSWORD: ${{ secrets.PKG_APPLE_ID_APP_SPECIFIC_PASSWORD }}
+ run: |
+ APP="$RUNNER_TEMP/export/Homebrew.app"
+ # notarytool needs a container; ditto preserves the bundle's symlinks
+ # and extended attributes where a plain zip would not.
+ ditto -c -k --keepParent "$APP" "$RUNNER_TEMP/notarize-app.zip"
+ xcrun notarytool submit "$RUNNER_TEMP/notarize-app.zip" \
+ --apple-id "$APPLE_ID" \
+ --password "$APPLE_ID_PASSWORD" \
+ --team-id "${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }}" \
+ --wait
+ xcrun stapler staple "$APP"
+
+ # Bundle the official Homebrew CLI installer and run it from a postinstall
+ # script. Homebrew.pkg is a *product archive* (a finished installer with
+ # its own setup scripts), so it cannot be nested as a productbuild
+ # sub-package. Instead we ship the file and invoke Apple's installer on
+ # it, which keeps Homebrew's own install logic and signature intact. This
+ # replaces the previous postinstall that curled install.sh at install
+ # time (needed network, ran as the console user via su, failed silently).
+ # The pkg is staged into the scripts directory so pkgbuild archives it
+ # alongside the postinstall and it is available on disk at install time.
+ - name: Stage Homebrew installer and postinstall script
+ run: |
+ SCRIPTS_DIR="$RUNNER_TEMP/installer-scripts"
+ mkdir -p "$SCRIPTS_DIR"
+ curl -fsSL -o "$SCRIPTS_DIR/Homebrew.pkg" \
+ https://github.com/Homebrew/brew/releases/latest/download/Homebrew.pkg
+ # Verify the download before embedding it inside our own signed .pkg:
+ # pkgutil must report a valid signature AND the cert chain must carry
+ # Homebrew's Developer ID Installer team ID. This catches truncation,
+ # an HTML error page, and — because we pin the team ID — an asset swap
+ # to a different (even validly-signed) installer.
+ #
+ # MAINTENANCE: Homebrew signs with a *maintainer's* Developer ID, which
+ # has rotated before (Mike McQuaid 6248TWFRH6 -> Patrick Linnane
+ # 927JGANW46) and was announced only informally. If a legitimate
+ # Homebrew release starts failing here, confirm the new signer via
+ # https://github.com/orgs/Homebrew/discussions and update this ID.
+ EXPECTED_INSTALLER_TEAM_ID="927JGANW46" # Homebrew: Patrick Linnane
+ if ! sig="$(pkgutil --check-signature "$SCRIPTS_DIR/Homebrew.pkg" 2>&1)" \
+ || ! grep -q "Developer ID Installer: .*(${EXPECTED_INSTALLER_TEAM_ID})" <<<"$sig"; then
+ echo "Homebrew.pkg is not validly signed by the expected team ID (${EXPECTED_INSTALLER_TEAM_ID}) — aborting." >&2
+ echo "$sig" >&2
+ exit 1
+ fi
+ cat > "$SCRIPTS_DIR/postinstall" <<'POSTINSTALL'
+ #!/bin/bash
+ # Installs the bundled Homebrew CLI when it is not already present, by
+ # running the official signed Homebrew.pkg staged next to this script.
+ set -euo pipefail
+
+ if [[ -x /opt/homebrew/bin/brew || -x /usr/local/bin/brew ]]; then
+ echo "postinstall: Homebrew already installed — skipping." >&2
+ exit 0
+ fi
+
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ echo "postinstall: installing bundled Homebrew…" >&2
+ installer -pkg "$SCRIPT_DIR/Homebrew.pkg" -target /
+ echo "postinstall: Homebrew installation complete." >&2
+ exit 0
+ POSTINSTALL
+ chmod +x "$SCRIPTS_DIR/postinstall"
+
+ # Package the app bundle directly with --component rather than --root'ing
+ # the whole export directory: xcodebuild -exportArchive also drops
+ # DistributionSummary.plist, ExportOptions.plist and Packaging.log in
+ # there, and --root would install those alongside Homebrew.app.
+ - name: Build component package
+ run: |
+ pkgbuild \
+ --component "$RUNNER_TEMP/export/Homebrew.app" \
+ --identifier "sh.brew.app" \
+ --version "$VERSION" \
+ --install-location "/Applications" \
+ --scripts "$RUNNER_TEMP/installer-scripts" \
+ --sign "Developer ID Installer" \
+ --keychain "$KEYCHAIN_PATH" \
+ "$RUNNER_TEMP/Homebrew-component.pkg"
+
+ - name: Build distribution package
+ run: |
+ PKG_PATH="$RUNNER_TEMP/Homebrew-${VERSION}.pkg"
+ productbuild \
+ --package "$RUNNER_TEMP/Homebrew-component.pkg" \
+ --sign "Developer ID Installer" \
+ --keychain "$KEYCHAIN_PATH" \
+ "$PKG_PATH"
+ echo "PKG_PATH=$PKG_PATH" >> "$GITHUB_ENV"
+
+ - name: Verify package signature
+ run: |
+ pkgutil --check-signature "$PKG_PATH" | tee "$RUNNER_TEMP/pkg-signature.txt"
+ if ! grep -q "Developer ID Installer" "$RUNNER_TEMP/pkg-signature.txt"; then
+ echo "Package is not signed with a Developer ID Installer authority."
+ exit 1
+ fi
+
+ # Notarisation, stapling and attestation only run for the shippable
+ # artifact built from a push to main. PRs stay a signed-but-not-notarised
+ # dry run so we don't spend notary quota on every proposed change.
+ - name: Notarize package
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ env:
+ APPLE_ID: ${{ secrets.PKG_APPLE_ID_EMAIL }}
+ APPLE_ID_PASSWORD: ${{ secrets.PKG_APPLE_ID_APP_SPECIFIC_PASSWORD }}
+ run: |
+ xcrun notarytool submit "$PKG_PATH" \
+ --apple-id "$APPLE_ID" \
+ --password "$APPLE_ID_PASSWORD" \
+ --team-id "${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }}" \
+ --wait
+
+ - name: Staple notarization ticket to package
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ run: xcrun stapler staple "$PKG_PATH"
+
+ # Zip the notarised, stapled app for the cask install path. ditto (not
+ # zip) is used so the bundle's symlinks and extended attributes — and the
+ # stapled ticket — survive the round trip.
+ - name: Create app zip for cask
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ run: |
+ APP_ZIP="$RUNNER_TEMP/Homebrew-${VERSION}.zip"
+ ditto -c -k --keepParent "$RUNNER_TEMP/export/Homebrew.app" "$APP_ZIP"
+ echo "APP_ZIP=$APP_ZIP" >> "$GITHUB_ENV"
+
+ - name: Generate build attestation
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
+ with:
+ subject-path: |
+ ${{ env.PKG_PATH }}
+ ${{ env.APP_ZIP }}
+
+ # Publish both deliverables as fixed-name artifacts. The release (promote)
+ # workflow downloads the most recent successful run of this workflow on
+ # main and attaches these to the GitHub Release: the .pkg is the website
+ # download (bundles Homebrew); the .zip is the bare app for the cask.
+ - name: Upload package artifact
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: Homebrew-pkg
+ path: ${{ env.PKG_PATH }}
+ retention-days: 90
+
+ - name: Upload app artifact
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: Homebrew-app
+ path: ${{ env.APP_ZIP }}
+ retention-days: 90
+
+ - name: Delete temporary keychain
+ if: always()
+ run: security delete-keychain "$KEYCHAIN_PATH"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 42f3e8ed..6d701a36 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,207 +1,138 @@
name: Release
on:
- push:
- tags:
- - 'v*.*.*'
- branches:
- - 'release-test'
- - 'release-workflow/**'
+ # Manual trigger used to promote the most recent main build to a release.
workflow_dispatch:
- inputs:
- version:
- description: 'Version number (e.g. 1.0.0)'
- required: true
+ # Dry run whenever the workflow itself changes, to catch breakage early.
+ push:
+ paths:
+ - .github/workflows/release.yml
+
+permissions:
+ contents: write # push the tag and create the release
+ actions: read # download the build artifact from the Build workflow
+ pull-requests: write # open the version bump PR
+
+concurrency:
+ group: release
+ cancel-in-progress: false
jobs:
release:
runs-on: macos-26
- permissions:
- contents: write
- id-token: write
- attestations: write
-
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ fetch-depth: 0
+ # Don't persist the token in .git/config (zizmor: artipacked). The
+ # pushes below authenticate with GH_TOKEN inline instead.
+ persist-credentials: false
- # Creates a short-lived keychain isolated to this job. The 6-hour
- # timeout (21600 s) exceeds any reasonable build duration.
- - name: Create and unlock temporary macOS keychain
+ - name: Resolve version and tag
run: |
- KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
- KEYCHAIN_PATH="$RUNNER_TEMP/signing.keychain-db"
- security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
- security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
- security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
- echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV"
+ VERSION=$(awk -F'= ' '/^MARKETING_VERSION/{gsub(/ /,"",$2); print $2; exit}' Configurations/Version.xcconfig)
+ if [ -z "$VERSION" ]; then
+ echo "Could not read MARKETING_VERSION from Configurations/Version.xcconfig"
+ exit 1
+ fi
+ echo "VERSION=$VERSION" >> "$GITHUB_ENV"
+ echo "TAG=v$VERSION" >> "$GITHUB_ENV"
+ echo "Releasing version $VERSION (tag v$VERSION)"
- - name: Import signing certificates
- env:
- APP_CERTIFICATE_BASE64: ${{ secrets.APP_APPLE_SIGNING_CERTIFICATE_BASE64 }}
- APP_CERTIFICATE_PASSWORD: ${{ secrets.APP_APPLE_SIGNING_CERTIFICATE_PASSWORD }}
- INSTALLER_CERTIFICATE_BASE64: ${{ secrets.PKG_APPLE_SIGNING_CERTIFICATE_BASE64 }}
- INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.PKG_APPLE_SIGNING_CERTIFICATE_PASSWORD }}
+ # Immutable-tag safety: never try to overwrite an existing tag. If this
+ # version was already released, the version bump has not landed yet.
+ - name: Ensure tag does not already exist
run: |
- APP_CERTIFICATE_PATH="$RUNNER_TEMP/app-certificate.p12"
- INSTALLER_CERTIFICATE_PATH="$RUNNER_TEMP/installer-certificate.p12"
-
- echo "$APP_CERTIFICATE_BASE64" | base64 --decode > "$APP_CERTIFICATE_PATH"
- security import "$APP_CERTIFICATE_PATH" \
- -k "$KEYCHAIN_PATH" \
- -P "$APP_CERTIFICATE_PASSWORD" \
- -T /usr/bin/codesign \
- -T /usr/bin/pkgbuild \
- -T /usr/bin/productbuild
+ if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
+ echo "Tag $TAG already exists on origin. Bump the marketing version before releasing."
+ exit 1
+ fi
- echo "$INSTALLER_CERTIFICATE_BASE64" | base64 --decode > "$INSTALLER_CERTIFICATE_PATH"
- security import "$INSTALLER_CERTIFICATE_PATH" \
- -k "$KEYCHAIN_PATH" \
- -P "$INSTALLER_CERTIFICATE_PASSWORD" \
- -T /usr/bin/codesign \
- -T /usr/bin/pkgbuild \
- -T /usr/bin/productbuild
- security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
- security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain
- CODESIGN_IDENTITIES=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" || true)
- BASIC_IDENTITIES=$(security find-identity -v -p basic "$KEYCHAIN_PATH" || true)
- echo "$CODESIGN_IDENTITIES"
- echo "$BASIC_IDENTITIES"
- if ! echo "$CODESIGN_IDENTITIES" | grep -q "Developer ID Application"; then
- echo "Missing Developer ID Application identity in imported keychain."
+ # Only a real (dispatch) release touches artifacts. The push dry-run must
+ # stay artifact-free so it can pass before any main build exists (e.g. on
+ # this workflow's own introduction, and on the first merge to main).
+ - name: Download most recent main build artifacts
+ if: github.event_name == 'workflow_dispatch'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ RUN_ID=$(gh run list --workflow=build.yml --branch=main --status=success \
+ --limit 1 --json databaseId --jq '.[0].databaseId')
+ if [ -z "$RUN_ID" ]; then
+ echo "No successful Build workflow run found on main. Run Build first."
exit 1
fi
- if ! echo "$BASIC_IDENTITIES" | grep -q "Developer ID Installer"; then
- echo "Missing Developer ID Installer identity in imported keychain."
+ echo "Using build run $RUN_ID"
+ # The .pkg is the website download; the .zip is the bare app for casks.
+ gh run download "$RUN_ID" --name Homebrew-pkg --dir dist
+ gh run download "$RUN_ID" --name Homebrew-app --dir dist
+ shopt -s nullglob
+ pkgs=(dist/Homebrew-*.pkg)
+ zips=(dist/Homebrew-*.zip)
+ if [ ${#pkgs[@]} -eq 0 ]; then
+ echo "No Homebrew-*.pkg found in the downloaded artifacts."
exit 1
fi
- rm "$APP_CERTIFICATE_PATH" "$INSTALLER_CERTIFICATE_PATH"
-
- - name: Preflight signing configuration
- run: |
- echo "Using app signing identity: Developer ID Application"
- echo "Using installer signing identity: Developer ID Installer"
- echo "Using team ID: ${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }}"
-
- - name: Build and archive
- run: |
- xcodebuild archive \
- -project Homebrew.xcodeproj \
- -scheme Brew \
- -configuration Release \
- -archivePath "$RUNNER_TEMP/Brew.xcarchive" \
- CODE_SIGN_STYLE=Manual \
- CODE_SIGN_IDENTITY="Developer ID Application" \
- DEVELOPMENT_TEAM="${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }}" \
- OTHER_CODE_SIGN_FLAGS="--keychain $KEYCHAIN_PATH"
-
- - name: Export archive
- run: |
- cat > "$RUNNER_TEMP/ExportOptions.plist" << EOF
-
-
-
-
- method
- developer-id
- teamID
- ${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }}
- signingStyle
- manual
-
-
- EOF
- xcodebuild -exportArchive \
- -archivePath "$RUNNER_TEMP/Brew.xcarchive" \
- -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" \
- -exportPath "$RUNNER_TEMP/export"
-
- - name: Verify app signature from export
- run: |
- APP_BUNDLE_PATH="$RUNNER_TEMP/export/Brew.app"
- xcrun codesign -dv --verbose=4 "$APP_BUNDLE_PATH" 2>&1 | tee "$RUNNER_TEMP/app-signature.txt"
- if ! grep -q "Authority=Developer ID Application" "$RUNNER_TEMP/app-signature.txt"; then
- echo "Exported app is not signed with a Developer ID Application authority."
+ if [ ${#zips[@]} -eq 0 ]; then
+ echo "No Homebrew-*.zip found in the downloaded artifacts."
exit 1
fi
+ echo "PKG_PATH=${pkgs[0]}" >> "$GITHUB_ENV"
+ echo "APP_ZIP=${zips[0]}" >> "$GITHUB_ENV"
- # Ensure the postinstall script is executable — pkgbuild will reject it
- # otherwise. This is a safety net; the file should be committed +x.
- - name: Mark installer scripts executable
- run: chmod +x scripts/postinstall
-
- - name: Build component package
- run: |
- # On a tag push, derive the version from the tag (strips the leading
- # 'v'). On a manual dispatch or branch push, use the workflow input.
- INPUT_VERSION="${{ github.event.inputs.version }}"
- VERSION="${INPUT_VERSION:-${GITHUB_REF_NAME#v}}"
- echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- pkgbuild \
- --root "$RUNNER_TEMP/export" \
- --identifier "sh.brew.app" \
- --version "$VERSION" \
- --install-location "/Applications" \
- --scripts scripts \
- --sign "Developer ID Installer" \
- --keychain "$KEYCHAIN_PATH" \
- "$RUNNER_TEMP/BrewUI-component.pkg"
-
- - name: Build distribution package
+ # Guard against promoting a stale build whose baked-in version no longer
+ # matches Configurations/Version.xcconfig.
+ - name: Verify artifacts match version
+ if: github.event_name == 'workflow_dispatch'
run: |
- PKG_PATH="$RUNNER_TEMP/BrewUI-${VERSION}.pkg"
- productbuild \
- --package "$RUNNER_TEMP/BrewUI-component.pkg" \
- --sign "Developer ID Installer" \
- --keychain "$KEYCHAIN_PATH" \
- "$PKG_PATH"
- echo "PKG_PATH=$PKG_PATH" >> "$GITHUB_ENV"
-
- - name: Verify package signature
+ for artifact in "$PKG_PATH" "$APP_ZIP"; do
+ case "$artifact" in
+ *"$VERSION"*) echo "Artifact $artifact matches version $VERSION" ;;
+ *) echo "Artifact $artifact does not match version $VERSION"; exit 1 ;;
+ esac
+ done
+
+ # On the push dry run, exercise permissions without creating anything.
+ - name: Dry run
+ if: github.event_name == 'push'
+ env:
+ GH_TOKEN: ${{ github.token }}
run: |
- pkgutil --check-signature "$PKG_PATH" | tee "$RUNNER_TEMP/pkg-signature.txt"
- if ! grep -q "Developer ID Installer" "$RUNNER_TEMP/pkg-signature.txt"; then
- echo "Package is not signed with a Developer ID Installer authority."
- exit 1
- fi
-
- - name: Notarize package
- if: startsWith(github.ref, 'refs/tags/')
+ echo "Dry run — verifying permissions without side effects."
+ gh release list
+ git ls-remote --tags origin | head
+
+ # Let gh create the tag as part of release creation (--target points it at
+ # the released commit). The tag and Release are created together, so a
+ # failed release never leaves a dangling tag on the remote.
+ - name: Create release
+ if: github.event_name == 'workflow_dispatch'
env:
- APPLE_ID: ${{ secrets.PKG_APPLE_ID_EMAIL }}
- APPLE_ID_PASSWORD: ${{ secrets.PKG_APPLE_ID_APP_SPECIFIC_PASSWORD }}
+ GH_TOKEN: ${{ github.token }}
run: |
- xcrun notarytool submit "$PKG_PATH" \
- --apple-id "$APPLE_ID" \
- --password "$APPLE_ID_PASSWORD" \
- --team-id "${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }}" \
- --wait
-
- - name: Staple notarization ticket to package
- if: startsWith(github.ref, 'refs/tags/')
- run: xcrun stapler staple "$PKG_PATH"
-
- - name: Generate build attestation
- if: startsWith(github.ref, 'refs/tags/')
- uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
- with:
- subject-path: ${{ env.PKG_PATH }}
-
- - name: Upload package artifact (non-release runs)
- if: "!startsWith(github.ref, 'refs/tags/')"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: BrewUI-${{ env.VERSION }}.pkg
- path: ${{ env.PKG_PATH }}
- retention-days: 5
-
- - name: Create GitHub release
- if: startsWith(github.ref, 'refs/tags/')
+ gh release create "$TAG" "$PKG_PATH" "$APP_ZIP" \
+ --target "$GITHUB_SHA" --generate-notes
+
+ # Bump the marketing version for the next cycle. main is branch-protected
+ # so GITHUB_TOKEN cannot push to it directly; open a PR instead. The
+ # default bump increments the minor and zeroes the patch (0.2.0 -> 0.3.0);
+ # do patch/major bumps by hand.
+ - name: Open version bump PR
+ if: github.event_name == 'workflow_dispatch'
env:
- GITHUB_TOKEN: ${{ github.token }}
- run: gh release create "$GITHUB_REF_NAME" "$PKG_PATH" --generate-notes
-
- - name: Delete temporary keychain
- if: always()
- run: security delete-keychain "$KEYCHAIN_PATH"
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ IFS=. read -r major minor _ <<< "$VERSION"
+ NEXT="$major.$((minor + 1)).0"
+ sed -i '' -E "s/^MARKETING_VERSION = .*/MARKETING_VERSION = $NEXT/" Configurations/Version.xcconfig
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ BRANCH="version-bump-$NEXT"
+ git switch -C "$BRANCH"
+ git commit -am "Bump marketing version to $NEXT"
+ git push --force "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH"
+ gh pr view "$BRANCH" >/dev/null 2>&1 ||
+ gh pr create --base main --head "$BRANCH" \
+ --title "Bump marketing version to $NEXT" \
+ --body "Automated version bump after releasing $TAG."
diff --git a/Configurations/Version.xcconfig b/Configurations/Version.xcconfig
index 28ecd716..ed38b2bc 100644
--- a/Configurations/Version.xcconfig
+++ b/Configurations/Version.xcconfig
@@ -10,5 +10,5 @@
// CURRENT_PROJECT_VERSION -> CFBundleVersion (the build number)
// so the standard "About Homebrew" panel picks these up automatically.
-MARKETING_VERSION = 1.0
+MARKETING_VERSION = 0.2.0
CURRENT_PROJECT_VERSION = 1
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
new file mode 100644
index 00000000..5469722d
--- /dev/null
+++ b/docs/RELEASING.md
@@ -0,0 +1,100 @@
+# Releasing BrewUI
+
+BrewUI uses a two-stage release pipeline so that nothing is ever released
+without having already been built and notarised successfully.
+
+1. **Build (`.github/workflows/build.yml`)** — runs on every push to `main`.
+ It builds, signs, **notarises** and staples the app, packages it into a
+ signed `.pkg`, generates a build attestation, and uploads fixed-name artifacts
+ (`Homebrew-pkg` for the website download and `Homebrew-app` for the cask). Pull
+ requests run the same build as a signed-but-not-notarised dry run (no upload)
+ to save notary quota.
+
+2. **Release (`.github/workflows/release.yml`)** — a manual **"Run workflow"**
+ step that promotes the most recent successful `main` build to a release.
+
+The marketing version lives in `Configurations/Version.xcconfig`
+(`MARKETING_VERSION`), which is the single source of truth for the version
+baked into every build.
+
+---
+
+## Cutting a release
+
+1. Make sure the change you want to release is merged to `main` and its **Build**
+ run went green (that produced the notarised artifact).
+2. Go to **Actions → Release → Run workflow** and run it against `main`.
+
+> **First release after this pipeline lands.** The Release workflow downloads an
+> existing successful **Build** artifact from `main` — it never builds. So the
+> very first time, the order is: merge → wait for **Build** on `main` to go green
+> → *then* run **Release**. There is no `main` artifact to promote until that
+> first build finishes.
+
+The Release workflow then:
+
+1. Reads the version from `Version.xcconfig` and forms the tag `v`
+ (fails if that tag already exists — see immutable tags below).
+2. Downloads the most recent successful **Build** artifact from `main` and
+ checks its filename matches the version.
+3. Creates the GitHub Release with `gh release create --target`, which creates
+ the tag `v` at the released commit **as part of** creating the
+ Release, and attaches the notarised `.pkg` and app zip with auto-generated
+ notes. Because the tag and Release are created together, a failed run never
+ leaves a dangling tag.
+4. Opens a **version bump PR** against `main` bumping `Version.xcconfig` to the
+ next minor version (e.g. `0.2.0` → `0.3.0`). `main` is branch-protected, so this
+ is a PR rather than a direct push — review and merge it.
+5. Opens a **`brew bump-cask-pr`** PR against `Homebrew/homebrew-cask` bumping
+ the `homebrew-app` cask to the released version with the app zip's `sha256`.
+
+Editing `release.yml` triggers a **dry run** (on `push`) that resolves the
+version, checks the tag is free, and exercises permissions — without
+downloading an artifact or creating a tag or release. Because the dry run never
+touches artifacts, it passes even before any `main` build exists (e.g. on this
+workflow's own introducing PR and on the first merge to `main`).
+
+---
+
+## Required repository secrets
+
+Signing and notarisation (already configured, used by `build.yml`):
+
+- `APP_APPLE_SIGNING_CERTIFICATE_BASE64` / `APP_APPLE_SIGNING_CERTIFICATE_PASSWORD`
+- `PKG_APPLE_SIGNING_CERTIFICATE_BASE64` / `PKG_APPLE_SIGNING_CERTIFICATE_PASSWORD`
+- `PKG_APPLE_DEVELOPER_TEAM_ID`
+- `PKG_APPLE_ID_EMAIL`
+- `PKG_APPLE_ID_APP_SPECIFIC_PASSWORD`
+
+Cask PR (used by `release.yml`):
+
+- `HOMEBREW_GITHUB_API_TOKEN` — a personal access token that can fork
+ `Homebrew/homebrew-cask` and open a pull request. The default `GITHUB_TOKEN`
+ cannot push to another repository, so this must be a separate PAT.
+
+---
+
+## Notes and caveats
+
+- **Immutable tags.** Enable immutable tags on the repository. Creating the tag
+ together with the Release (via `gh release create --target`) already avoids
+ leaving behind broken tags; never delete and recreate a tag — Git and
+ Homebrew both handle that badly.
+- **First-time cask.** `brew bump-cask-pr` only updates an *existing* cask. The
+ very first `homebrew-app` submission to `homebrew-cask` is a manual one-off; every
+ release after that is automated.
+- **Version format.** `Version.xcconfig` uses three-part semver (e.g. `0.2.0`).
+ The automated bump increments the minor and zeroes the patch
+ (`0.2.0` → `0.3.0`); do patch and major bumps by hand.
+- **Artifact retention.** Build artifacts are kept for 90 days. If you promote
+ more than 90 days after the last `main` build, re-run **Build** (via its
+ "Run workflow" button) first.
+
+---
+
+## Future: patch releases
+
+Patch releases (hotfixes) are not yet automated. The intended shape: branch off
+the last minor release tag, apply the fix, bump the **patch** component of
+`Version.xcconfig` on that branch, and run the same promote path from there. A
+dedicated patch-release workflow is a follow-up.
diff --git a/scripts/postinstall b/scripts/postinstall
deleted file mode 100755
index 041f7766..00000000
--- a/scripts/postinstall
+++ /dev/null
@@ -1,50 +0,0 @@
-#!/bin/bash
-#
-# postinstall — runs as root after the .pkg installs Brew.app
-#
-# Installs Homebrew for the current console user if it is not already present.
-# Because pkg scripts run as root, we delegate to the logged-in user so that
-# Homebrew ends up owned by them (in /opt/homebrew on Apple Silicon, or
-# /usr/local on Intel), not by root.
-
-set -euo pipefail
-
-# ---------------------------------------------------------------------------
-# Identify the console user. On a normal interactive install this will be the
-# person sitting at the machine; it will be empty on headless/MDM installs.
-# ---------------------------------------------------------------------------
-CURRENT_USER=$(stat -f "%Su" /dev/console 2>/dev/null || true)
-
-if [[ -z "$CURRENT_USER" || "$CURRENT_USER" == "root" ]]; then
- echo "postinstall: could not determine console user — skipping Homebrew installation." >&2
- exit 0
-fi
-
-# ---------------------------------------------------------------------------
-# Check whether Homebrew is already installed for this user.
-# We check the two canonical prefix locations rather than relying on PATH,
-# which may not be set correctly when running under the installer daemon.
-# ---------------------------------------------------------------------------
-HOMEBREW_PREFIX_ARM="/opt/homebrew"
-HOMEBREW_PREFIX_INTEL="/usr/local"
-
-if [[ -x "$HOMEBREW_PREFIX_ARM/bin/brew" || -x "$HOMEBREW_PREFIX_INTEL/bin/brew" ]]; then
- echo "postinstall: Homebrew already installed — skipping." >&2
- exit 0
-fi
-
-# ---------------------------------------------------------------------------
-# Install Homebrew as the console user.
-#
-# TODO: Consider whether you want to run this non-interactively. The official
-# install script supports NONINTERACTIVE=1 to skip prompts and accept all
-# defaults, which is usually appropriate for a bundled installer. Remove the
-# variable if you want the script to prompt the user normally.
-# ---------------------------------------------------------------------------
-echo "postinstall: installing Homebrew for user '$CURRENT_USER'…" >&2
-
-su - "$CURRENT_USER" -c \
- 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
-
-echo "postinstall: Homebrew installation complete." >&2
-exit 0