From 393236bb147918399ec3c35abd6036d0c7cab2e5 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Tue, 7 Jul 2026 22:38:18 +1000 Subject: [PATCH 01/33] Add build.yml: build, notarise and package on merge to main Split the build/sign/package machinery out of release.yml into a dedicated Build workflow that runs on every push to main (and as a signed-but-not-notarised dry run on pull requests). On main it produces a fully signed, notarised and stapled .pkg and uploads it as a fixed-name `BrewUI-pkg` artifact so the promote workflow can attach the most recent successful build to a release, without ever rebuilding at release time. The marketing version is read from the project build settings (MARKETING_VERSION, sourced from Configurations/Version.xcconfig) rather than from a tag input. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 214 ++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..286c9a3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,214 @@ +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: + runs-on: macos-26 + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # 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: | + 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." + exit 1 + fi + + # 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: | + 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 + 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 + 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" + + - 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 }} + + # Publish the notarised package as a fixed-name artifact. The release + # (promote) workflow downloads the most recent successful run of this + # workflow on main and attaches this exact file to the GitHub Release. + - name: Upload package artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: BrewUI-pkg + path: ${{ env.PKG_PATH }} + retention-days: 90 + + - name: Delete temporary keychain + if: always() + run: security delete-keychain "$KEYCHAIN_PATH" From c3972fd8b91a3957d8b53cc2c28fb62b2c6d80c1 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Tue, 7 Jul 2026 22:39:17 +1000 Subject: [PATCH 02/33] Rewrite release.yml as a promote-only workflow Replace the fragile "create tag then build" release with the robust pattern from the blogpost: the binary is already built and notarised by build.yml on merge to main, and Release just promotes it. On the manual "Run workflow" trigger it reads the marketing version from Configurations/Version.xcconfig, creates the tag locally, downloads the most recent successful main build artifact, verifies it matches the version, then pushes the tag and creates the GitHub Release with the notarised .pkg attached. Pushing the tag happens last, so a failed run never leaves a dangling tag on GitHub. Pushing a change to this workflow triggers a dry run that exercises permissions without creating a tag or release. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 247 ++++++++++------------------------ 1 file changed, 69 insertions(+), 178 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42f3e8e..6f996ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,207 +1,98 @@ 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 + +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 - # 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" - - - 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." + 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 - 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" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "TAG=v$VERSION" >> "$GITHUB_ENV" + echo "Releasing version $VERSION (tag v$VERSION)" - - name: Verify app signature from export + # 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_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 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 - # 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 - 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" + # Create the tag locally now; it is only pushed once everything below has + # succeeded, so a failed run never leaves a dangling tag on GitHub. + - name: Create the tag locally + if: github.event_name == 'workflow_dispatch' + run: git tag "$TAG" - - name: Verify package signature + - name: Download most recent main build artifact + 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." + 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 + echo "Using build run $RUN_ID" + gh run download "$RUN_ID" --name BrewUI-pkg --dir dist + PKG_PATH=$(ls dist/BrewUI-*.pkg 2>/dev/null | head -1) + if [ -z "$PKG_PATH" ]; then + echo "No BrewUI-*.pkg found in the downloaded artifact." exit 1 fi + echo "PKG_PATH=$PKG_PATH" >> "$GITHUB_ENV" - - name: Notarize package - if: startsWith(github.ref, 'refs/tags/') + # Guard against promoting a stale build whose baked-in version no longer + # matches Configurations/Version.xcconfig. + - name: Verify artifact matches version + run: | + case "$PKG_PATH" in + *"$VERSION"*) echo "Artifact $PKG_PATH matches version $VERSION" ;; + *) echo "Artifact $PKG_PATH does not match version $VERSION"; exit 1 ;; + esac + + # On the push dry run, exercise permissions without creating anything. + - name: Dry run + if: github.event_name == 'push' 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 }} + echo "Dry run — verifying permissions without side effects." + gh release list + git ls-remote --tags origin | head - - 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/') + - name: Push tag and create release + 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: | + git push origin "$TAG" + gh release create "$TAG" "$PKG_PATH" --generate-notes From 8da17c67404eab9e7bb3fd138ccbbc212318a498 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Tue, 7 Jul 2026 22:39:40 +1000 Subject: [PATCH 03/33] Open a version bump PR after releasing After a successful release, bump the marketing version in Configurations/Version.xcconfig (minor by default, e.g. 1.0 -> 1.1) and open a PR against main. main is branch-protected, so GITHUB_TOKEN cannot push to it directly; a PR is the supported path. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f996ce..a4df3db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,3 +96,25 @@ jobs: run: | git push origin "$TAG" gh release create "$TAG" "$PKG_PATH" --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 is minor (1.0 -> 1.1); do patch/major by hand. + - name: Open version bump PR + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + run: | + IFS=. read -r major minor _ <<< "$VERSION" + NEXT="$major.$((minor + 1))" + 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 origin "$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." From 1cb934ce4fd0ea1e6a8463488dc97103408bb975 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Tue, 7 Jul 2026 22:40:02 +1000 Subject: [PATCH 04/33] Open a Homebrew cask PR on release After the GitHub Release exists, run `brew bump-cask-pr` to open a PR against Homebrew/homebrew-cask bumping the brewui cask to the released version, pinning the notarised package's sha256. Requires a HOMEBREW_GITHUB_API_TOKEN PAT that can fork homebrew-cask and open a PR. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4df3db..5a6fc33 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,3 +118,15 @@ jobs: gh pr create --base main --head "$BRANCH" \ --title "Bump marketing version to $NEXT" \ --body "Automated version bump after releasing $TAG." + + # Open a PR against Homebrew/homebrew-cask bumping the brewui cask to the + # just-released version. bump-cask-pr derives the download URL from the + # cask template and pins the notarised package's sha256. Needs a PAT that + # can fork homebrew-cask (GITHUB_TOKEN cannot push to another repo). + - name: Open Homebrew cask PR + if: github.event_name == 'workflow_dispatch' + env: + HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} + run: | + SHA=$(shasum -a 256 "$PKG_PATH" | cut -d' ' -f1) + brew bump-cask-pr --version "$VERSION" --sha256 "$SHA" brewui From 79e9e5ebdb3cfaf7dd7412753a9b4e12bbc67666 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Tue, 7 Jul 2026 22:40:43 +1000 Subject: [PATCH 05/33] Document the release process Add docs/RELEASING.md covering the build-on-merge + manual-promote pipeline: how to cut a release, the required secrets (including the new HOMEBREW_GITHUB_API_TOKEN), immutable-tag and first-time-cask caveats, and a stub for the future patch-release workflow. Co-Authored-By: Claude Opus 4.8 --- docs/RELEASING.md | 89 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/RELEASING.md diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..6ad709f --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,89 @@ +# 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 the package as a + fixed-name `BrewUI-pkg` artifact. 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`](../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`. + +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. Creates the tag **locally**. +3. Downloads the most recent successful **Build** artifact from `main` and + checks its filename matches the version. +4. **Pushes the tag** and creates the GitHub Release with the notarised `.pkg` + attached and auto-generated notes. (Pushing the tag happens last, so a + failed run never leaves a dangling tag.) +5. Opens a **version bump PR** against `main` bumping `Version.xcconfig` to the + next minor version (e.g. `1.0` → `1.1`). `main` is branch-protected, so this + is a PR rather than a direct push — review and merge it. +6. Opens a **`brew bump-cask-pr`** PR against `Homebrew/homebrew-cask` bumping + the `brewui` cask to the released version with the package's `sha256`. + +Editing `release.yml` triggers a **dry run** (on `push`) that exercises +permissions without creating a tag or release. + +--- + +## 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. The + local-tag-then-push design 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 `brewui` submission to `homebrew-cask` is a manual one-off; every + release after that is automated. +- **Version format.** `Version.xcconfig` currently uses the 2-part form `1.0`; + the bump keeps that shape (`1.0` → `1.1`). To move to semver (`1.0.0`), edit + `Version.xcconfig` once and adjust the bump logic in `release.yml`. +- **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. From 88696f8e060449e71aef4d92545da70d82af746c Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Tue, 7 Jul 2026 22:52:00 +1000 Subject: [PATCH 06/33] Package only Brew.app, not the whole export directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pkgbuild --root'd the entire xcodebuild export directory, which also contains the exporter's own DistributionSummary.plist, ExportOptions.plist and Packaging.log — so the installed .pkg dropped those stray files into /Applications next to Brew.app. Use --component on the app bundle instead, so only Brew.app is packaged. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 286c9a3..d17b465 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -143,10 +143,14 @@ jobs: - name: Mark installer scripts executable run: chmod +x scripts/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 Brew.app. - name: Build component package run: | pkgbuild \ - --root "$RUNNER_TEMP/export" \ + --component "$RUNNER_TEMP/export/Brew.app" \ --identifier "sh.brew.app" \ --version "$VERSION" \ --install-location "/Applications" \ From a30cf555ecb7e9a83de0f33283615beaf6a8c6b4 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Tue, 7 Jul 2026 22:53:00 +1000 Subject: [PATCH 07/33] Bundle Homebrew.pkg as a sub-package instead of a postinstall script The postinstall script installed Homebrew by curling install.sh at install time: it needs network access during installation, runs as the console user via su, and fails silently on headless/MDM installs. Instead, download the official Homebrew.pkg and ship it as a nested sub-package in the distribution: productbuild --synthesize builds a distribution that installs Homebrew first, then Brew.app. This drops the --scripts postinstall from pkgbuild and deletes scripts/postinstall. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 27 ++++++++++++++------ scripts/postinstall | 50 ------------------------------------- 2 files changed, 19 insertions(+), 58 deletions(-) delete mode 100755 scripts/postinstall diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d17b465..c4f884f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -138,32 +138,43 @@ jobs: exit 1 fi - # 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 - # 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 Brew.app. - name: Build component package run: | + mkdir -p "$RUNNER_TEMP/pkgs" pkgbuild \ --component "$RUNNER_TEMP/export/Brew.app" \ --identifier "sh.brew.app" \ --version "$VERSION" \ --install-location "/Applications" \ - --scripts scripts \ --sign "Developer ID Installer" \ --keychain "$KEYCHAIN_PATH" \ - "$RUNNER_TEMP/BrewUI-component.pkg" + "$RUNNER_TEMP/pkgs/BrewUI-component.pkg" + + # Bundle the official Homebrew installer as a sub-package rather than + # curling install.sh from a postinstall script (which needs network at + # install time, runs as the console user via su, and fails silently). + - name: Download Homebrew installer package + run: | + curl -fsSL -o "$RUNNER_TEMP/pkgs/Homebrew.pkg" \ + https://github.com/Homebrew/brew/releases/latest/download/Homebrew.pkg - name: Build distribution package run: | PKG_PATH="$RUNNER_TEMP/BrewUI-${VERSION}.pkg" + # --synthesize reads each sub-package's identifier and version, so we + # don't have to hardcode Homebrew's package identifier. Homebrew is + # listed first so it installs before Brew.app. + productbuild --synthesize \ + --package "$RUNNER_TEMP/pkgs/Homebrew.pkg" \ + --package "$RUNNER_TEMP/pkgs/BrewUI-component.pkg" \ + "$RUNNER_TEMP/distribution.xml" productbuild \ - --package "$RUNNER_TEMP/BrewUI-component.pkg" \ + --distribution "$RUNNER_TEMP/distribution.xml" \ + --package-path "$RUNNER_TEMP/pkgs" \ --sign "Developer ID Installer" \ --keychain "$KEYCHAIN_PATH" \ "$PKG_PATH" diff --git a/scripts/postinstall b/scripts/postinstall deleted file mode 100755 index 041f776..0000000 --- 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 From ef213c917013aa1759e5248dd996804bfb6e723d Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Wed, 8 Jul 2026 08:50:56 +1000 Subject: [PATCH 08/33] Fix broken internal link in RELEASING docs Version.xcconfig is a repo source file, not part of the published docs site, so the relative link failed htmlproofer's internal link check. Reference it as inline code instead. Co-Authored-By: Claude Opus 4.8 --- docs/RELEASING.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 6ad709f..3fc5a52 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -12,8 +12,7 @@ without having already been built and notarised successfully. 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`](../Configurations/Version.xcconfig) +The marketing version lives in `Configurations/Version.xcconfig` (`MARKETING_VERSION`), which is the single source of truth for the version baked into every build. From a3507a1a8bedbdaeead0f078f0d5211975ae961e Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Wed, 8 Jul 2026 08:53:54 +1000 Subject: [PATCH 09/33] Reference the built app as Homebrew.app The app target is named Homebrew, so PRODUCT_NAME builds Homebrew.app, not Brew.app. The packaging steps inherited the stale Brew.app paths from the old workflow, which would have failed the signature check and pkgbuild. Point --component and the signature verification at export/Homebrew.app, and name the archive Homebrew.xcarchive to match. The scheme name (Brew) and bundle identifier (sh.brew.app) are unchanged. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c4f884f..8eeb855 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -102,7 +102,7 @@ jobs: -project Homebrew.xcodeproj \ -scheme Brew \ -configuration Release \ - -archivePath "$RUNNER_TEMP/Brew.xcarchive" \ + -archivePath "$RUNNER_TEMP/Homebrew.xcarchive" \ CODE_SIGN_STYLE=Manual \ CODE_SIGN_IDENTITY="Developer ID Application" \ DEVELOPMENT_TEAM="${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }}" \ @@ -125,13 +125,13 @@ jobs: EOF xcodebuild -exportArchive \ - -archivePath "$RUNNER_TEMP/Brew.xcarchive" \ + -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/Brew.app" + 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." @@ -141,12 +141,12 @@ jobs: # 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 Brew.app. + # there, and --root would install those alongside Homebrew.app. - name: Build component package run: | mkdir -p "$RUNNER_TEMP/pkgs" pkgbuild \ - --component "$RUNNER_TEMP/export/Brew.app" \ + --component "$RUNNER_TEMP/export/Homebrew.app" \ --identifier "sh.brew.app" \ --version "$VERSION" \ --install-location "/Applications" \ @@ -167,7 +167,7 @@ jobs: PKG_PATH="$RUNNER_TEMP/BrewUI-${VERSION}.pkg" # --synthesize reads each sub-package's identifier and version, so we # don't have to hardcode Homebrew's package identifier. Homebrew is - # listed first so it installs before Brew.app. + # listed first so it installs before Homebrew.app. productbuild --synthesize \ --package "$RUNNER_TEMP/pkgs/Homebrew.pkg" \ --package "$RUNNER_TEMP/pkgs/BrewUI-component.pkg" \ From ac795ff7b44a7b4386bb449067500cfe96fd7636 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Wed, 8 Jul 2026 08:58:17 +1000 Subject: [PATCH 10/33] Rebrand the installer, artifact and cask to Homebrew Match the Homebrew.app rename across the deliverables: the component and distribution packages become Homebrew-component.pkg / Homebrew-.pkg, the build artifact becomes Homebrew-pkg, and the cask bumped by bump-cask-pr becomes homebrew. Left unchanged: the BrewUITests target and the Tools/BrewUILint linter (unrelated to the app bundle), and the repo/project name in docs prose. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 8 ++++---- .github/workflows/release.yml | 10 +++++----- docs/RELEASING.md | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8eeb855..0e8ccc8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -152,7 +152,7 @@ jobs: --install-location "/Applications" \ --sign "Developer ID Installer" \ --keychain "$KEYCHAIN_PATH" \ - "$RUNNER_TEMP/pkgs/BrewUI-component.pkg" + "$RUNNER_TEMP/pkgs/Homebrew-component.pkg" # Bundle the official Homebrew installer as a sub-package rather than # curling install.sh from a postinstall script (which needs network at @@ -164,13 +164,13 @@ jobs: - name: Build distribution package run: | - PKG_PATH="$RUNNER_TEMP/BrewUI-${VERSION}.pkg" + PKG_PATH="$RUNNER_TEMP/Homebrew-${VERSION}.pkg" # --synthesize reads each sub-package's identifier and version, so we # don't have to hardcode Homebrew's package identifier. Homebrew is # listed first so it installs before Homebrew.app. productbuild --synthesize \ --package "$RUNNER_TEMP/pkgs/Homebrew.pkg" \ - --package "$RUNNER_TEMP/pkgs/BrewUI-component.pkg" \ + --package "$RUNNER_TEMP/pkgs/Homebrew-component.pkg" \ "$RUNNER_TEMP/distribution.xml" productbuild \ --distribution "$RUNNER_TEMP/distribution.xml" \ @@ -220,7 +220,7 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: BrewUI-pkg + name: Homebrew-pkg path: ${{ env.PKG_PATH }} retention-days: 90 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a6fc33..4e16a62 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,10 +62,10 @@ jobs: exit 1 fi echo "Using build run $RUN_ID" - gh run download "$RUN_ID" --name BrewUI-pkg --dir dist - PKG_PATH=$(ls dist/BrewUI-*.pkg 2>/dev/null | head -1) + gh run download "$RUN_ID" --name Homebrew-pkg --dir dist + PKG_PATH=$(ls dist/Homebrew-*.pkg 2>/dev/null | head -1) if [ -z "$PKG_PATH" ]; then - echo "No BrewUI-*.pkg found in the downloaded artifact." + echo "No Homebrew-*.pkg found in the downloaded artifact." exit 1 fi echo "PKG_PATH=$PKG_PATH" >> "$GITHUB_ENV" @@ -119,7 +119,7 @@ jobs: --title "Bump marketing version to $NEXT" \ --body "Automated version bump after releasing $TAG." - # Open a PR against Homebrew/homebrew-cask bumping the brewui cask to the + # Open a PR against Homebrew/homebrew-cask bumping the homebrew cask to the # just-released version. bump-cask-pr derives the download URL from the # cask template and pins the notarised package's sha256. Needs a PAT that # can fork homebrew-cask (GITHUB_TOKEN cannot push to another repo). @@ -129,4 +129,4 @@ jobs: HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} run: | SHA=$(shasum -a 256 "$PKG_PATH" | cut -d' ' -f1) - brew bump-cask-pr --version "$VERSION" --sha256 "$SHA" brewui + brew bump-cask-pr --version "$VERSION" --sha256 "$SHA" homebrew diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 3fc5a52..7417a43 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -6,7 +6,7 @@ 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 the package as a - fixed-name `BrewUI-pkg` artifact. Pull requests run the same build as a + fixed-name `Homebrew-pkg` artifact. 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"** @@ -38,7 +38,7 @@ The Release workflow then: next minor version (e.g. `1.0` → `1.1`). `main` is branch-protected, so this is a PR rather than a direct push — review and merge it. 6. Opens a **`brew bump-cask-pr`** PR against `Homebrew/homebrew-cask` bumping - the `brewui` cask to the released version with the package's `sha256`. + the `homebrew` cask to the released version with the package's `sha256`. Editing `release.yml` triggers a **dry run** (on `push`) that exercises permissions without creating a tag or release. @@ -69,7 +69,7 @@ Cask PR (used by `release.yml`): local-tag-then-push design 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 `brewui` submission to `homebrew-cask` is a manual one-off; every + very first `homebrew` submission to `homebrew-cask` is a manual one-off; every release after that is automated. - **Version format.** `Version.xcconfig` currently uses the 2-part form `1.0`; the bump keeps that shape (`1.0` → `1.1`). To move to semver (`1.0.0`), edit From 033940dc35525cf5d96cea4fee723f918ebd6b60 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Wed, 8 Jul 2026 09:01:37 +1000 Subject: [PATCH 11/33] Avoid ls in release.yml artifact lookup (shellcheck SC2012) Replace `ls dist/Homebrew-*.pkg | head` with a nullglob array so the package is located without parsing ls output. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e16a62..d2a192d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,11 +63,13 @@ jobs: fi echo "Using build run $RUN_ID" gh run download "$RUN_ID" --name Homebrew-pkg --dir dist - PKG_PATH=$(ls dist/Homebrew-*.pkg 2>/dev/null | head -1) - if [ -z "$PKG_PATH" ]; then + shopt -s nullglob + pkgs=(dist/Homebrew-*.pkg) + if [ ${#pkgs[@]} -eq 0 ]; then echo "No Homebrew-*.pkg found in the downloaded artifact." exit 1 fi + PKG_PATH="${pkgs[0]}" echo "PKG_PATH=$PKG_PATH" >> "$GITHUB_ENV" # Guard against promoting a stale build whose baked-in version no longer From 5c537136ced846bb9471597ab43353470d89ca0c Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Wed, 8 Jul 2026 19:01:58 +1000 Subject: [PATCH 12/33] Install bundled Homebrew via a postinstall, not a nested sub-package productbuild cannot nest Homebrew.pkg: it is a product archive (a finished installer with its own scripts), and productbuild only assembles component packages, so --synthesize failed with "is a product archive, not a component package". Instead, stage the official signed Homebrew.pkg into the pkgbuild scripts directory and run it from a postinstall via `installer -pkg` when Homebrew is not already present. This preserves Homebrew's own install logic and signature, and is still offline (no curl | bash). The distribution package goes back to wrapping the single app component. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 56 ++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0e8ccc8..d6fba8c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -138,43 +138,61 @@ jobs: exit 1 fi + # 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 + 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: | - mkdir -p "$RUNNER_TEMP/pkgs" 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/pkgs/Homebrew-component.pkg" - - # Bundle the official Homebrew installer as a sub-package rather than - # curling install.sh from a postinstall script (which needs network at - # install time, runs as the console user via su, and fails silently). - - name: Download Homebrew installer package - run: | - curl -fsSL -o "$RUNNER_TEMP/pkgs/Homebrew.pkg" \ - https://github.com/Homebrew/brew/releases/latest/download/Homebrew.pkg + "$RUNNER_TEMP/Homebrew-component.pkg" - name: Build distribution package run: | PKG_PATH="$RUNNER_TEMP/Homebrew-${VERSION}.pkg" - # --synthesize reads each sub-package's identifier and version, so we - # don't have to hardcode Homebrew's package identifier. Homebrew is - # listed first so it installs before Homebrew.app. - productbuild --synthesize \ - --package "$RUNNER_TEMP/pkgs/Homebrew.pkg" \ - --package "$RUNNER_TEMP/pkgs/Homebrew-component.pkg" \ - "$RUNNER_TEMP/distribution.xml" productbuild \ - --distribution "$RUNNER_TEMP/distribution.xml" \ - --package-path "$RUNNER_TEMP/pkgs" \ + --package "$RUNNER_TEMP/Homebrew-component.pkg" \ --sign "Developer ID Installer" \ --keychain "$KEYCHAIN_PATH" \ "$PKG_PATH" From 622c2f106567ec61e3ae6d7e2eb7842b472c5ef1 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Wed, 8 Jul 2026 19:15:48 +1000 Subject: [PATCH 13/33] Produce a notarised app zip artifact for the cask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cask install path drops a bare Homebrew.app, which needs its own stapled notarisation ticket — the pkg's ticket does not travel with the extracted app. Notarise and staple the app bundle before packaging (so the copy embedded in the pkg is stapled too), then ditto the stapled app into Homebrew-.zip and upload it as a second fixed-name artifact (Homebrew-app) alongside the pkg. Attestation now covers both. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 51 ++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d6fba8c..1280360 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -138,6 +138,28 @@ jobs: 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 @@ -225,15 +247,28 @@ jobs: 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 }} + subject-path: | + ${{ env.PKG_PATH }} + ${{ env.APP_ZIP }} - # Publish the notarised package as a fixed-name artifact. The release - # (promote) workflow downloads the most recent successful run of this - # workflow on main and attaches this exact file to the GitHub Release. + # 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 @@ -242,6 +277,14 @@ jobs: 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" From ef355db36cb49e14f00748a63474a4f0098fc4a9 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Wed, 8 Jul 2026 19:15:48 +1000 Subject: [PATCH 14/33] Attach both the pkg and app zip to the release The promote workflow now downloads both build artifacts, checks each matches the version, and attaches both to the GitHub Release: the .pkg (website download) and the .zip (bare app, for the future cask PR). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2a192d..1fd2eaf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: if: github.event_name == 'workflow_dispatch' run: git tag "$TAG" - - name: Download most recent main build artifact + - name: Download most recent main build artifacts env: GH_TOKEN: ${{ github.token }} run: | @@ -62,24 +62,33 @@ jobs: exit 1 fi 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 artifact." + echo "No Homebrew-*.pkg found in the downloaded artifacts." exit 1 fi - PKG_PATH="${pkgs[0]}" - echo "PKG_PATH=$PKG_PATH" >> "$GITHUB_ENV" + 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" # Guard against promoting a stale build whose baked-in version no longer # matches Configurations/Version.xcconfig. - - name: Verify artifact matches version + - name: Verify artifacts match version run: | - case "$PKG_PATH" in - *"$VERSION"*) echo "Artifact $PKG_PATH matches version $VERSION" ;; - *) echo "Artifact $PKG_PATH does not match version $VERSION"; exit 1 ;; - esac + 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 @@ -97,7 +106,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | git push origin "$TAG" - gh release create "$TAG" "$PKG_PATH" --generate-notes + gh release create "$TAG" "$PKG_PATH" "$APP_ZIP" --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 From ca049277c04f8479bf364b5e46e7aa697e913567 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Wed, 8 Jul 2026 19:20:04 +1000 Subject: [PATCH 15/33] Downgrade pre-release to non-stable --- Configurations/Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Configurations/Version.xcconfig b/Configurations/Version.xcconfig index 28ecd71..37436e3 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.1.0 CURRENT_PROJECT_VERSION = 1 From 31d61c7c901aa79df6efb7fbddc41c010951eb2d Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Wed, 8 Jul 2026 21:14:19 +1000 Subject: [PATCH 16/33] Point the cask PR at the app zip, not the pkg The cask installs the bare app (the zip), so bump-cask-pr must pin the zip's sha256, not the pkg's. Hash $APP_ZIP instead of $PKG_PATH. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1fd2eaf..58218c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,13 +131,14 @@ jobs: --body "Automated version bump after releasing $TAG." # Open a PR against Homebrew/homebrew-cask bumping the homebrew cask to the - # just-released version. bump-cask-pr derives the download URL from the - # cask template and pins the notarised package's sha256. Needs a PAT that - # can fork homebrew-cask (GITHUB_TOKEN cannot push to another repo). + # just-released version. The cask installs the app zip, so pin the zip's + # sha256; bump-cask-pr derives the download URL from the cask template. + # Needs a PAT that can fork homebrew-cask (GITHUB_TOKEN cannot push to + # another repo). - name: Open Homebrew cask PR if: github.event_name == 'workflow_dispatch' env: HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} run: | - SHA=$(shasum -a 256 "$PKG_PATH" | cut -d' ' -f1) + SHA=$(shasum -a 256 "$APP_ZIP" | cut -d' ' -f1) brew bump-cask-pr --version "$VERSION" --sha256 "$SHA" homebrew From c4bfb0ac5aab1a1b5732335ad4a7e260eed2b404 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Wed, 8 Jul 2026 21:26:48 +1000 Subject: [PATCH 17/33] Use homebrew-app as the cask token Confirmed with a Homebrew lead maintainer that the cask will be named homebrew-app. Update the bump-cask-pr token and the docs to match. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 4 ++-- docs/RELEASING.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58218c6..09225c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -130,7 +130,7 @@ jobs: --title "Bump marketing version to $NEXT" \ --body "Automated version bump after releasing $TAG." - # Open a PR against Homebrew/homebrew-cask bumping the homebrew cask to the + # Open a PR against Homebrew/homebrew-cask bumping the homebrew-app cask to the # just-released version. The cask installs the app zip, so pin the zip's # sha256; bump-cask-pr derives the download URL from the cask template. # Needs a PAT that can fork homebrew-cask (GITHUB_TOKEN cannot push to @@ -141,4 +141,4 @@ jobs: HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} run: | SHA=$(shasum -a 256 "$APP_ZIP" | cut -d' ' -f1) - brew bump-cask-pr --version "$VERSION" --sha256 "$SHA" homebrew + brew bump-cask-pr --version "$VERSION" --sha256 "$SHA" homebrew-app diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 7417a43..5bf89a6 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -38,7 +38,7 @@ The Release workflow then: next minor version (e.g. `1.0` → `1.1`). `main` is branch-protected, so this is a PR rather than a direct push — review and merge it. 6. Opens a **`brew bump-cask-pr`** PR against `Homebrew/homebrew-cask` bumping - the `homebrew` cask to the released version with the package's `sha256`. + the `homebrew-app` cask to the released version with the app zip's `sha256`. Editing `release.yml` triggers a **dry run** (on `push`) that exercises permissions without creating a tag or release. @@ -69,7 +69,7 @@ Cask PR (used by `release.yml`): local-tag-then-push design 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` submission to `homebrew-cask` is a manual one-off; every + very first `homebrew-app` submission to `homebrew-cask` is a manual one-off; every release after that is automated. - **Version format.** `Version.xcconfig` currently uses the 2-part form `1.0`; the bump keeps that shape (`1.0` → `1.1`). To move to semver (`1.0.0`), edit From bca77285eb1e9f92ddbc524c8c90a7bd4917fd77 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Wed, 8 Jul 2026 21:32:53 +1000 Subject: [PATCH 18/33] THROWAWAY: run the real build+deploy on this branch to prove it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Do not merge — revert this commit before opening for review. - build.yml: notarise/staple/zip/attest/upload also fire on this branch, so it produces real artifacts here (normally main-only). - release.yml: triggers on pushes to this branch, downloads this branch's build artifacts, and runs the tag/release/version-bump steps for real. - The cask PR step stays dispatch-only, so it is skipped here. Every change is tagged with a THROWAWAY comment. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 14 +++++++------- .github/workflows/release.yml | 17 +++++++++-------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1280360..ae1a5dd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -144,7 +144,7 @@ jobs: # 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' + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/worktree-release-pipeline') # THROWAWAY: revert before review env: APPLE_ID: ${{ secrets.PKG_APPLE_ID_EMAIL }} APPLE_ID_PASSWORD: ${{ secrets.PKG_APPLE_ID_APP_SPECIFIC_PASSWORD }} @@ -232,7 +232,7 @@ jobs: # 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' + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/worktree-release-pipeline') # THROWAWAY: revert before review env: APPLE_ID: ${{ secrets.PKG_APPLE_ID_EMAIL }} APPLE_ID_PASSWORD: ${{ secrets.PKG_APPLE_ID_APP_SPECIFIC_PASSWORD }} @@ -244,21 +244,21 @@ jobs: --wait - name: Staple notarization ticket to package - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/worktree-release-pipeline') # THROWAWAY: revert before review 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' + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/worktree-release-pipeline') # THROWAWAY: revert before review 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' + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/worktree-release-pipeline') # THROWAWAY: revert before review uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-path: | @@ -270,7 +270,7 @@ jobs: # 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' + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/worktree-release-pipeline') # THROWAWAY: revert before review uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Homebrew-pkg @@ -278,7 +278,7 @@ jobs: retention-days: 90 - name: Upload app artifact - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/worktree-release-pipeline') # THROWAWAY: revert before review uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Homebrew-app diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09225c9..17666a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,10 +3,11 @@ name: Release on: # Manual trigger used to promote the most recent main build to a release. workflow_dispatch: - # Dry run whenever the workflow itself changes, to catch breakage early. + # THROWAWAY: run the real deploy on this branch to prove it works. + # Revert to `paths: [.github/workflows/release.yml]` before review. push: - paths: - - .github/workflows/release.yml + branches: + - worktree-release-pipeline permissions: contents: write # push the tag and create the release @@ -48,15 +49,15 @@ jobs: # Create the tag locally now; it is only pushed once everything below has # succeeded, so a failed run never leaves a dangling tag on GitHub. - name: Create the tag locally - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/worktree-release-pipeline' # THROWAWAY: revert before review run: git tag "$TAG" - name: Download most recent main build artifacts 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') + RUN_ID=$(gh run list --workflow=build.yml --branch=worktree-release-pipeline --status=success \ + --limit 1 --json databaseId --jq '.[0].databaseId') # THROWAWAY: --branch=main before review if [ -z "$RUN_ID" ]; then echo "No successful Build workflow run found on main. Run Build first." exit 1 @@ -101,7 +102,7 @@ jobs: git ls-remote --tags origin | head - name: Push tag and create release - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/worktree-release-pipeline' # THROWAWAY: revert before review env: GH_TOKEN: ${{ github.token }} run: | @@ -112,7 +113,7 @@ jobs: # so GITHUB_TOKEN cannot push to it directly; open a PR instead. The # default bump is minor (1.0 -> 1.1); do patch/major by hand. - name: Open version bump PR - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/worktree-release-pipeline' # THROWAWAY: revert before review env: GH_TOKEN: ${{ github.token }} run: | From e21aaa918cf42d541cc11ddea27babf23b87470d Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Wed, 8 Jul 2026 21:46:11 +1000 Subject: [PATCH 19/33] Force secure timestamp and hardened runtime when archiving The archive step failed with "A timestamp was expected but was not found" during codesign of Homebrew.app. Developer ID signing requires a secure Apple timestamp, and notarisation requires the hardened runtime; neither was pinned, so codesign behaviour on CI was non-deterministic. Add --timestamp --options runtime to OTHER_CODE_SIGN_FLAGS, and verify the archived app carries a Timestamp= line so this fails fast with a clear message instead of surfacing later at notarisation. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ae1a5dd..925c70b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -98,6 +98,10 @@ jobs: - 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 \ @@ -106,7 +110,18 @@ jobs: 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" + 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: | From 9302e01b4be0360679e5f4d866fa0d7f4a775569 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Thu, 9 Jul 2026 09:58:34 +1000 Subject: [PATCH 20/33] THROWAWAY: emit a push event on this branch so build steps run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notarise/staple/upload steps are guarded by event_name == 'push', but build.yml's push trigger was scoped to main, so feature-branch runs came through the pull_request event and the guards were skipped. Add this branch to the push trigger so it fires a real push event and the existing guards run — the same push-event path main uses. Revert before review. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 925c70b..54cb125 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - worktree-release-pipeline # THROWAWAY: emit a push event on this branch; revert before review pull_request: workflow_dispatch: From a89df13894ae13203fa8278a4b281cbd8b3d5022 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Thu, 9 Jul 2026 10:12:21 +1000 Subject: [PATCH 21/33] Bump version number --- Configurations/Version.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Configurations/Version.xcconfig b/Configurations/Version.xcconfig index 37436e3..ed38b2b 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 = 0.1.0 +MARKETING_VERSION = 0.2.0 CURRENT_PROJECT_VERSION = 1 From 453d345fcd8ce1a3b2bfe35772243b2e88adc83d Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Thu, 9 Jul 2026 10:43:18 +1000 Subject: [PATCH 22/33] Make the version bump three-part semver aware Version.xcconfig is now three-part semver (0.2.0). The bump logic was written for the old two-part 1.0 default and produced a two-part next version (0.2.0 -> 0.3), silently dropping the patch component. Increment the minor and zero the patch instead (0.2.0 -> 0.3.0), and update the stale two-part note and example in RELEASING.md. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 5 +++-- docs/RELEASING.md | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 17666a3..581bebc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,14 +111,15 @@ jobs: # 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 is minor (1.0 -> 1.1); do patch/major by hand. + # 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' || github.ref == 'refs/heads/worktree-release-pipeline' # THROWAWAY: revert before review env: GH_TOKEN: ${{ github.token }} run: | IFS=. read -r major minor _ <<< "$VERSION" - NEXT="$major.$((minor + 1))" + 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" diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 5bf89a6..e3afd01 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -35,7 +35,7 @@ The Release workflow then: attached and auto-generated notes. (Pushing the tag happens last, so a failed run never leaves a dangling tag.) 5. Opens a **version bump PR** against `main` bumping `Version.xcconfig` to the - next minor version (e.g. `1.0` → `1.1`). `main` is branch-protected, so this + 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. 6. 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`. @@ -71,9 +71,9 @@ Cask PR (used by `release.yml`): - **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` currently uses the 2-part form `1.0`; - the bump keeps that shape (`1.0` → `1.1`). To move to semver (`1.0.0`), edit - `Version.xcconfig` once and adjust the bump logic in `release.yml`. +- **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. From ec2fe91dc3cbeb32eca128d2b2c516f5902d4765 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Thu, 9 Jul 2026 18:47:48 +1000 Subject: [PATCH 23/33] Don't persist checkout credentials in release workflows zizmor's artipacked audit flags actions/checkout for leaving the token in .git/config, where it can leak into uploaded artifacts. Set persist-credentials: false on both checkouts and authenticate the two release pushes inline via GH_TOKEN instead. Anonymous ls-remote reads still work since the repo is public. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 4 ++++ .github/workflows/release.yml | 7 +++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 54cb125..e9b9099 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,10 @@ jobs: 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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 581bebc..42cb968 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,9 @@ jobs: 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 - name: Resolve version and tag run: | @@ -106,7 +109,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - git push origin "$TAG" + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$TAG" gh release create "$TAG" "$PKG_PATH" "$APP_ZIP" --generate-notes # Bump the marketing version for the next cycle. main is branch-protected @@ -126,7 +129,7 @@ jobs: BRANCH="version-bump-$NEXT" git switch -C "$BRANCH" git commit -am "Bump marketing version to $NEXT" - git push --force origin "$BRANCH" + 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" \ From 09cfec157f63cc32919a33526dc0d9c61bfe3ab3 Mon Sep 17 00:00:00 2001 From: Graeme Arthur <2030310+graeme@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:38:15 +1000 Subject: [PATCH 24/33] Update releasing doc with details about app Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/RELEASING.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index e3afd01..ee286ab 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -5,9 +5,10 @@ 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 the package as a - fixed-name `Homebrew-pkg` artifact. Pull requests run the same build as a - signed-but-not-notarised dry run (no upload) to save notary quota. + 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. From d059908dbe237dcbd9f9f58d2a03520e4ee26b10 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Thu, 9 Jul 2026 19:41:43 +1000 Subject: [PATCH 25/33] Include PR creation permissions --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42cb968..d201552 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,7 @@ on: 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 From 99486649092ee2e8288261bab05d2bdff8ac07d7 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Thu, 9 Jul 2026 19:36:46 +1000 Subject: [PATCH 26/33] Create the release tag via gh release create --target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the workflow pushed the tag and then ran gh release create as two steps, so a failed release could leave a remote tag with no Release — contradicting the "no dangling tags" guarantee. Drop the separate local tag + push and let gh create the tag as part of release creation, pointed at the released commit. Update the docs to match. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 15 ++++++--------- docs/RELEASING.md | 22 ++++++++++++---------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d201552..8bd8e5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,12 +50,6 @@ jobs: exit 1 fi - # Create the tag locally now; it is only pushed once everything below has - # succeeded, so a failed run never leaves a dangling tag on GitHub. - - name: Create the tag locally - if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/worktree-release-pipeline' # THROWAWAY: revert before review - run: git tag "$TAG" - - name: Download most recent main build artifacts env: GH_TOKEN: ${{ github.token }} @@ -105,13 +99,16 @@ jobs: gh release list git ls-remote --tags origin | head - - name: Push tag and create release + # 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' || github.ref == 'refs/heads/worktree-release-pipeline' # THROWAWAY: revert before review env: GH_TOKEN: ${{ github.token }} run: | - git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$TAG" - gh release create "$TAG" "$PKG_PATH" "$APP_ZIP" --generate-notes + 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 diff --git a/docs/RELEASING.md b/docs/RELEASING.md index ee286ab..0d5d86e 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -29,16 +29,17 @@ 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. Creates the tag **locally**. -3. Downloads the most recent successful **Build** artifact from `main` and +2. Downloads the most recent successful **Build** artifact from `main` and checks its filename matches the version. -4. **Pushes the tag** and creates the GitHub Release with the notarised `.pkg` - attached and auto-generated notes. (Pushing the tag happens last, so a - failed run never leaves a dangling tag.) -5. Opens a **version bump PR** against `main` bumping `Version.xcconfig` to the +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. -6. Opens a **`brew bump-cask-pr`** PR against `Homebrew/homebrew-cask` bumping +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 exercises @@ -66,9 +67,10 @@ Cask PR (used by `release.yml`): ## Notes and caveats -- **Immutable tags.** Enable immutable tags on the repository. The - local-tag-then-push design already avoids leaving behind broken tags; never - delete and recreate a tag — Git and Homebrew both handle that badly. +- **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. From 83569711b85948bd94c2055e89c1e70686f80247 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Thu, 9 Jul 2026 22:04:02 +1000 Subject: [PATCH 27/33] Revert "THROWAWAY: emit a push event on this branch so build steps run" This reverts commit 9302e01b4be0360679e5f4d866fa0d7f4a775569. --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e9b9099..0d387c9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,6 @@ on: push: branches: - main - - worktree-release-pipeline # THROWAWAY: emit a push event on this branch; revert before review pull_request: workflow_dispatch: From 2da53d43361cfe64da0a5395dd3338d36758db39 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Thu, 9 Jul 2026 22:06:19 +1000 Subject: [PATCH 28/33] Revert "THROWAWAY: run the real build+deploy on this branch to prove it" This reverts commit bca77285eb1e9f92ddbc524c8c90a7bd4917fd77. --- .github/workflows/build.yml | 14 +++++++------- .github/workflows/release.yml | 15 +++++++-------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0d387c9..4f80ff3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -163,7 +163,7 @@ jobs: # 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' || github.ref == 'refs/heads/worktree-release-pipeline') # THROWAWAY: revert before review + 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 }} @@ -251,7 +251,7 @@ jobs: # 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' || github.ref == 'refs/heads/worktree-release-pipeline') # THROWAWAY: revert before review + 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 }} @@ -263,21 +263,21 @@ jobs: --wait - name: Staple notarization ticket to package - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/worktree-release-pipeline') # THROWAWAY: revert before review + 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' || github.ref == 'refs/heads/worktree-release-pipeline') # THROWAWAY: revert before review + 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' || github.ref == 'refs/heads/worktree-release-pipeline') # THROWAWAY: revert before review + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-path: | @@ -289,7 +289,7 @@ jobs: # 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' || github.ref == 'refs/heads/worktree-release-pipeline') # THROWAWAY: revert before review + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Homebrew-pkg @@ -297,7 +297,7 @@ jobs: retention-days: 90 - name: Upload app artifact - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/worktree-release-pipeline') # THROWAWAY: revert before review + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Homebrew-app diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bd8e5e..54dcb1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,11 +3,10 @@ name: Release on: # Manual trigger used to promote the most recent main build to a release. workflow_dispatch: - # THROWAWAY: run the real deploy on this branch to prove it works. - # Revert to `paths: [.github/workflows/release.yml]` before review. + # Dry run whenever the workflow itself changes, to catch breakage early. push: - branches: - - worktree-release-pipeline + paths: + - .github/workflows/release.yml permissions: contents: write # push the tag and create the release @@ -54,8 +53,8 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - RUN_ID=$(gh run list --workflow=build.yml --branch=worktree-release-pipeline --status=success \ - --limit 1 --json databaseId --jq '.[0].databaseId') # THROWAWAY: --branch=main before review + 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 @@ -103,7 +102,7 @@ jobs: # 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' || github.ref == 'refs/heads/worktree-release-pipeline' # THROWAWAY: revert before review + if: github.event_name == 'workflow_dispatch' env: GH_TOKEN: ${{ github.token }} run: | @@ -115,7 +114,7 @@ jobs: # 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' || github.ref == 'refs/heads/worktree-release-pipeline' # THROWAWAY: revert before review + if: github.event_name == 'workflow_dispatch' env: GH_TOKEN: ${{ github.token }} run: | From 6d0c012540b1377359948d564b089e0048ee3148 Mon Sep 17 00:00:00 2001 From: Graeme Arthur <2030310+graeme@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:13:52 +1000 Subject: [PATCH 29/33] Skip signing cert import for forks Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4f80ff3..366e1ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,6 +18,7 @@ permissions: 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 From 36304b9c8b6de9a1f5db55dc77a8045384cd959c Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Thu, 9 Jul 2026 22:36:52 +1000 Subject: [PATCH 30/33] Remove cask PR step for now --- .github/workflows/release.yml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54dcb1f..678f0ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,16 +131,3 @@ jobs: gh pr create --base main --head "$BRANCH" \ --title "Bump marketing version to $NEXT" \ --body "Automated version bump after releasing $TAG." - - # Open a PR against Homebrew/homebrew-cask bumping the homebrew-app cask to the - # just-released version. The cask installs the app zip, so pin the zip's - # sha256; bump-cask-pr derives the download URL from the cask template. - # Needs a PAT that can fork homebrew-cask (GITHUB_TOKEN cannot push to - # another repo). - - name: Open Homebrew cask PR - if: github.event_name == 'workflow_dispatch' - env: - HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} - run: | - SHA=$(shasum -a 256 "$APP_ZIP" | cut -d' ' -f1) - brew bump-cask-pr --version "$VERSION" --sha256 "$SHA" homebrew-app From cdb9a34b8717aa36fe2e0dc06d0108b9d0858145 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Thu, 9 Jul 2026 22:25:05 +1000 Subject: [PATCH 31/33] Verify downloaded Homebrew.pkg is signed before embedding The release .pkg embeds Homebrew's installer downloaded from releases/latest. curl -fsSL only guards against HTTP errors, so a truncated download, an HTML error page, or a tampered asset would be silently wrapped inside our own notarised, Developer-ID-signed .pkg and shipped under our identity. Gate on pkgutil --check-signature reporting a valid Developer ID installer so a bad download fails the build loudly. Latest-Homebrew is intentional (bootstrap installer), so the source is not pinned. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 366e1ea..e15036e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -195,6 +195,15 @@ jobs: mkdir -p "$SCRIPTS_DIR" curl -fsSL -o "$SCRIPTS_DIR/Homebrew.pkg" \ https://github.com/Homebrew/brew/releases/latest/download/Homebrew.pkg + # Fail loudly if the download isn't a validly-signed installer (catches + # truncation, an HTML error page, or a tampered asset) before it gets + # embedded inside our own signed .pkg and shipped under our identity. + if ! pkgutil --check-signature "$SCRIPTS_DIR/Homebrew.pkg" \ + | grep -q 'Status: signed by .*Developer ID'; then + echo "Downloaded Homebrew.pkg is not a valid signed installer — aborting." >&2 + pkgutil --check-signature "$SCRIPTS_DIR/Homebrew.pkg" >&2 || true + exit 1 + fi cat > "$SCRIPTS_DIR/postinstall" <<'POSTINSTALL' #!/bin/bash # Installs the bundled Homebrew CLI when it is not already present, by From 0b6816d0acd11e339cd3ce89b8a65d2a7297c9f2 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Thu, 9 Jul 2026 22:30:47 +1000 Subject: [PATCH 32/33] Pin Homebrew.pkg signature check to Homebrew's team ID The previous check only asserted "some Developer ID", which an asset swap to any validly-signed installer would pass. Pin the Developer ID Installer team ID (927JGANW46, Homebrew's Patrick Linnane) so the embedded installer must actually come from Homebrew, and fail on an invalid/absent signature too. Homebrew's signer is maintainer-tied and has rotated before, so the step documents how to update the ID when it legitimately changes. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build.yml | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e15036e..fb15873 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -195,13 +195,22 @@ jobs: mkdir -p "$SCRIPTS_DIR" curl -fsSL -o "$SCRIPTS_DIR/Homebrew.pkg" \ https://github.com/Homebrew/brew/releases/latest/download/Homebrew.pkg - # Fail loudly if the download isn't a validly-signed installer (catches - # truncation, an HTML error page, or a tampered asset) before it gets - # embedded inside our own signed .pkg and shipped under our identity. - if ! pkgutil --check-signature "$SCRIPTS_DIR/Homebrew.pkg" \ - | grep -q 'Status: signed by .*Developer ID'; then - echo "Downloaded Homebrew.pkg is not a valid signed installer — aborting." >&2 - pkgutil --check-signature "$SCRIPTS_DIR/Homebrew.pkg" >&2 || true + # 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' From 488b24251fb9a2ab8a1c19ff9e7be8442a52d7d0 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Thu, 9 Jul 2026 23:17:48 +1000 Subject: [PATCH 33/33] Keep the release dry run artifact-free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The push dry run ran the artifact download unconditionally, so it hard-failed whenever no successful main Build existed yet — including on this workflow's own introducing PR and on the first merge to main. Guard the download and verify steps to workflow_dispatch so the dry run only resolves the version, checks the tag is free, and exercises permissions. Document the first-release sequencing (merge, wait for Build, then dispatch Release). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 5 +++++ docs/RELEASING.md | 13 +++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 678f0ea..6d701a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,11 @@ jobs: exit 1 fi + # 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: | @@ -80,6 +84,7 @@ jobs: # 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: | for artifact in "$PKG_PATH" "$APP_ZIP"; do case "$artifact" in diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 0d5d86e..5469722 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -25,6 +25,12 @@ baked into every 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` @@ -42,8 +48,11 @@ The Release workflow then: 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 exercises -permissions without creating a tag or release. +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`). ---