Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
393236b
Add build.yml: build, notarise and package on merge to main
graeme Jul 7, 2026
c3972fd
Rewrite release.yml as a promote-only workflow
graeme Jul 7, 2026
8da17c6
Open a version bump PR after releasing
graeme Jul 7, 2026
1cb934c
Open a Homebrew cask PR on release
graeme Jul 7, 2026
79e9e5e
Document the release process
graeme Jul 7, 2026
88696f8
Package only Brew.app, not the whole export directory
graeme Jul 7, 2026
a30cf55
Bundle Homebrew.pkg as a sub-package instead of a postinstall script
graeme Jul 7, 2026
ef213c9
Fix broken internal link in RELEASING docs
graeme Jul 7, 2026
a3507a1
Reference the built app as Homebrew.app
graeme Jul 7, 2026
ac795ff
Rebrand the installer, artifact and cask to Homebrew
graeme Jul 7, 2026
033940d
Avoid ls in release.yml artifact lookup (shellcheck SC2012)
graeme Jul 7, 2026
5c53713
Install bundled Homebrew via a postinstall, not a nested sub-package
graeme Jul 8, 2026
622c2f1
Produce a notarised app zip artifact for the cask
graeme Jul 8, 2026
ef355db
Attach both the pkg and app zip to the release
graeme Jul 8, 2026
ca04927
Downgrade pre-release to non-stable
graeme Jul 8, 2026
31d61c7
Point the cask PR at the app zip, not the pkg
graeme Jul 8, 2026
c4bfb0a
Use homebrew-app as the cask token
graeme Jul 8, 2026
bca7728
THROWAWAY: run the real build+deploy on this branch to prove it
graeme Jul 8, 2026
e21aaa9
Force secure timestamp and hardened runtime when archiving
graeme Jul 8, 2026
9302e01
THROWAWAY: emit a push event on this branch so build steps run
graeme Jul 8, 2026
a89df13
Bump version number
graeme Jul 9, 2026
453d345
Make the version bump three-part semver aware
graeme Jul 9, 2026
ec2fe91
Don't persist checkout credentials in release workflows
graeme Jul 9, 2026
09cfec1
Update releasing doc with details about app
graeme Jul 9, 2026
d059908
Include PR creation permissions
graeme Jul 9, 2026
9948664
Create the release tag via gh release create --target
graeme Jul 9, 2026
8356971
Revert "THROWAWAY: emit a push event on this branch so build steps run"
graeme Jul 9, 2026
2da53d4
Revert "THROWAWAY: run the real build+deploy on this branch to prove it"
graeme Jul 9, 2026
6d0c012
Skip signing cert import for forks
graeme Jul 9, 2026
36304b9
Remove cask PR step for now
graeme Jul 9, 2026
cdb9a34
Verify downloaded Homebrew.pkg is signed before embedding
graeme Jul 9, 2026
0b6816d
Pin Homebrew.pkg signature check to Homebrew's team ID
graeme Jul 9, 2026
488b242
Keep the release dry run artifact-free
graeme Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
328 changes: 328 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
name: Build

on:
push:
branches:
- main
pull_request:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
id-token: write
attestations: write

jobs:
build:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: macos-26
Comment thread
Copilot marked this conversation as resolved.
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.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
- name: Create and unlock temporary macOS keychain
run: |
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
KEYCHAIN_PATH="$RUNNER_TEMP/signing.keychain-db"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV"

- name: Import signing certificates
env:
APP_CERTIFICATE_BASE64: ${{ secrets.APP_APPLE_SIGNING_CERTIFICATE_BASE64 }}
APP_CERTIFICATE_PASSWORD: ${{ secrets.APP_APPLE_SIGNING_CERTIFICATE_PASSWORD }}
INSTALLER_CERTIFICATE_BASE64: ${{ secrets.PKG_APPLE_SIGNING_CERTIFICATE_BASE64 }}
INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.PKG_APPLE_SIGNING_CERTIFICATE_PASSWORD }}
run: |
APP_CERTIFICATE_PATH="$RUNNER_TEMP/app-certificate.p12"
INSTALLER_CERTIFICATE_PATH="$RUNNER_TEMP/installer-certificate.p12"

echo "$APP_CERTIFICATE_BASE64" | base64 --decode > "$APP_CERTIFICATE_PATH"
security import "$APP_CERTIFICATE_PATH" \
-k "$KEYCHAIN_PATH" \
-P "$APP_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/pkgbuild \
-T /usr/bin/productbuild

echo "$INSTALLER_CERTIFICATE_BASE64" | base64 --decode > "$INSTALLER_CERTIFICATE_PATH"
security import "$INSTALLER_CERTIFICATE_PATH" \
-k "$KEYCHAIN_PATH" \
-P "$INSTALLER_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/pkgbuild \
-T /usr/bin/productbuild
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain
CODESIGN_IDENTITIES=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" || true)
BASIC_IDENTITIES=$(security find-identity -v -p basic "$KEYCHAIN_PATH" || true)
echo "$CODESIGN_IDENTITIES"
echo "$BASIC_IDENTITIES"
if ! echo "$CODESIGN_IDENTITIES" | grep -q "Developer ID Application"; then
echo "Missing Developer ID Application identity in imported keychain."
exit 1
fi
if ! echo "$BASIC_IDENTITIES" | grep -q "Developer ID Installer"; then
echo "Missing Developer ID Installer identity in imported keychain."
exit 1
fi
rm "$APP_CERTIFICATE_PATH" "$INSTALLER_CERTIFICATE_PATH"

- name: Preflight signing configuration
run: |
echo "Using app signing identity: Developer ID Application"
echo "Using installer signing identity: Developer ID Installer"
echo "Using team ID: ${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }}"

- name: Resolve marketing version
run: |
VERSION=$(xcodebuild -showBuildSettings \
-project Homebrew.xcodeproj \
-scheme Brew \
-configuration Release \
| awk -F' = ' '/ MARKETING_VERSION /{print $2; exit}')
if [ -z "$VERSION" ]; then
echo "Could not resolve MARKETING_VERSION from build settings."
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "Building version $VERSION"

- name: Build and archive
run: |
# --timestamp requests a secure Apple timestamp (mandatory for
# Developer ID signing and notarisation); --options runtime enables
# the hardened runtime. Both are pinned here so codesign is
# deterministic on CI rather than relying on Xcode defaults.
xcodebuild archive \
-project Homebrew.xcodeproj \
-scheme Brew \
-configuration Release \
-archivePath "$RUNNER_TEMP/Homebrew.xcarchive" \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Developer ID Application" \
DEVELOPMENT_TEAM="${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }}" \
OTHER_CODE_SIGN_FLAGS="--keychain $KEYCHAIN_PATH --timestamp --options runtime"

# Fail fast with a clear message if the archived app did not get a secure
# timestamp, rather than surfacing it later at notarisation.
- name: Verify archived app signature
run: |
APP="$RUNNER_TEMP/Homebrew.xcarchive/Products/Applications/Homebrew.app"
codesign -dv --verbose=4 "$APP" 2>&1 | tee "$RUNNER_TEMP/archive-signature.txt"
if ! grep -q "^Timestamp=" "$RUNNER_TEMP/archive-signature.txt"; then
echo "Archived app is missing a secure timestamp."
exit 1
fi

- name: Export archive
run: |
cat > "$RUNNER_TEMP/ExportOptions.plist" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>developer-id</string>
<key>teamID</key>
<string>${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }}</string>
<key>signingStyle</key>
<string>manual</string>
</dict>
</plist>
EOF
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Homebrew.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" \
-exportPath "$RUNNER_TEMP/export"

- name: Verify app signature from export
run: |
APP_BUNDLE_PATH="$RUNNER_TEMP/export/Homebrew.app"
xcrun codesign -dv --verbose=4 "$APP_BUNDLE_PATH" 2>&1 | tee "$RUNNER_TEMP/app-signature.txt"
if ! grep -q "Authority=Developer ID Application" "$RUNNER_TEMP/app-signature.txt"; then
echo "Exported app is not signed with a Developer ID Application authority."
exit 1
fi

# Notarise and staple the app bundle itself, before it is packaged. The
# cask install path drops a bare Homebrew.app, so it needs its own stapled
# ticket — the pkg's notarisation does not travel with the extracted app.
# Stapling here also means the copy embedded in the pkg is notarised.
# main only — PRs stay a signed-but-not-notarised dry run.
- name: Notarize and staple app
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
APPLE_ID: ${{ secrets.PKG_APPLE_ID_EMAIL }}
APPLE_ID_PASSWORD: ${{ secrets.PKG_APPLE_ID_APP_SPECIFIC_PASSWORD }}
run: |
APP="$RUNNER_TEMP/export/Homebrew.app"
# notarytool needs a container; ditto preserves the bundle's symlinks
# and extended attributes where a plain zip would not.
ditto -c -k --keepParent "$APP" "$RUNNER_TEMP/notarize-app.zip"
xcrun notarytool submit "$RUNNER_TEMP/notarize-app.zip" \
--apple-id "$APPLE_ID" \
--password "$APPLE_ID_PASSWORD" \
--team-id "${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }}" \
--wait
xcrun stapler staple "$APP"

# Bundle the official Homebrew CLI installer and run it from a postinstall
# script. Homebrew.pkg is a *product archive* (a finished installer with
# its own setup scripts), so it cannot be nested as a productbuild
# sub-package. Instead we ship the file and invoke Apple's installer on
# it, which keeps Homebrew's own install logic and signature intact. This
# replaces the previous postinstall that curled install.sh at install
# time (needed network, ran as the console user via su, failed silently).
# The pkg is staged into the scripts directory so pkgbuild archives it
# alongside the postinstall and it is available on disk at install time.
- name: Stage Homebrew installer and postinstall script
run: |
SCRIPTS_DIR="$RUNNER_TEMP/installer-scripts"
mkdir -p "$SCRIPTS_DIR"
curl -fsSL -o "$SCRIPTS_DIR/Homebrew.pkg" \
https://github.com/Homebrew/brew/releases/latest/download/Homebrew.pkg
Comment on lines +196 to +197
# Verify the download before embedding it inside our own signed .pkg:
# pkgutil must report a valid signature AND the cert chain must carry
# Homebrew's Developer ID Installer team ID. This catches truncation,
# an HTML error page, and — because we pin the team ID — an asset swap
# to a different (even validly-signed) installer.
#
# MAINTENANCE: Homebrew signs with a *maintainer's* Developer ID, which
# has rotated before (Mike McQuaid 6248TWFRH6 -> Patrick Linnane
# 927JGANW46) and was announced only informally. If a legitimate
# Homebrew release starts failing here, confirm the new signer via
# https://github.com/orgs/Homebrew/discussions and update this ID.
EXPECTED_INSTALLER_TEAM_ID="927JGANW46" # Homebrew: Patrick Linnane
if ! sig="$(pkgutil --check-signature "$SCRIPTS_DIR/Homebrew.pkg" 2>&1)" \
|| ! grep -q "Developer ID Installer: .*(${EXPECTED_INSTALLER_TEAM_ID})" <<<"$sig"; then
echo "Homebrew.pkg is not validly signed by the expected team ID (${EXPECTED_INSTALLER_TEAM_ID}) — aborting." >&2
echo "$sig" >&2
exit 1
fi
cat > "$SCRIPTS_DIR/postinstall" <<'POSTINSTALL'
#!/bin/bash
# Installs the bundled Homebrew CLI when it is not already present, by
# running the official signed Homebrew.pkg staged next to this script.
set -euo pipefail

if [[ -x /opt/homebrew/bin/brew || -x /usr/local/bin/brew ]]; then
echo "postinstall: Homebrew already installed — skipping." >&2
exit 0
fi

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "postinstall: installing bundled Homebrew…" >&2
installer -pkg "$SCRIPT_DIR/Homebrew.pkg" -target /
echo "postinstall: Homebrew installation complete." >&2
exit 0
POSTINSTALL
chmod +x "$SCRIPTS_DIR/postinstall"

# Package the app bundle directly with --component rather than --root'ing
# the whole export directory: xcodebuild -exportArchive also drops
# DistributionSummary.plist, ExportOptions.plist and Packaging.log in
# there, and --root would install those alongside Homebrew.app.
- name: Build component package
run: |
pkgbuild \
--component "$RUNNER_TEMP/export/Homebrew.app" \
--identifier "sh.brew.app" \
--version "$VERSION" \
--install-location "/Applications" \
--scripts "$RUNNER_TEMP/installer-scripts" \
--sign "Developer ID Installer" \
--keychain "$KEYCHAIN_PATH" \
"$RUNNER_TEMP/Homebrew-component.pkg"

- name: Build distribution package
run: |
PKG_PATH="$RUNNER_TEMP/Homebrew-${VERSION}.pkg"
productbuild \
--package "$RUNNER_TEMP/Homebrew-component.pkg" \
--sign "Developer ID Installer" \
--keychain "$KEYCHAIN_PATH" \
"$PKG_PATH"
echo "PKG_PATH=$PKG_PATH" >> "$GITHUB_ENV"

- name: Verify package signature
run: |
pkgutil --check-signature "$PKG_PATH" | tee "$RUNNER_TEMP/pkg-signature.txt"
if ! grep -q "Developer ID Installer" "$RUNNER_TEMP/pkg-signature.txt"; then
echo "Package is not signed with a Developer ID Installer authority."
exit 1
fi

# Notarisation, stapling and attestation only run for the shippable
# artifact built from a push to main. PRs stay a signed-but-not-notarised
# dry run so we don't spend notary quota on every proposed change.
- name: Notarize package
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
APPLE_ID: ${{ secrets.PKG_APPLE_ID_EMAIL }}
APPLE_ID_PASSWORD: ${{ secrets.PKG_APPLE_ID_APP_SPECIFIC_PASSWORD }}
run: |
xcrun notarytool submit "$PKG_PATH" \
--apple-id "$APPLE_ID" \
--password "$APPLE_ID_PASSWORD" \
--team-id "${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }}" \
--wait

- name: Staple notarization ticket to package
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: xcrun stapler staple "$PKG_PATH"

# Zip the notarised, stapled app for the cask install path. ditto (not
# zip) is used so the bundle's symlinks and extended attributes — and the
# stapled ticket — survive the round trip.
- name: Create app zip for cask
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
APP_ZIP="$RUNNER_TEMP/Homebrew-${VERSION}.zip"
ditto -c -k --keepParent "$RUNNER_TEMP/export/Homebrew.app" "$APP_ZIP"
echo "APP_ZIP=$APP_ZIP" >> "$GITHUB_ENV"

- name: Generate build attestation
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-path: |
${{ env.PKG_PATH }}
${{ env.APP_ZIP }}

# Publish both deliverables as fixed-name artifacts. The release (promote)
# workflow downloads the most recent successful run of this workflow on
# main and attaches these to the GitHub Release: the .pkg is the website
# download (bundles Homebrew); the .zip is the bare app for the cask.
- name: Upload package artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Homebrew-pkg
path: ${{ env.PKG_PATH }}
retention-days: 90

- name: Upload app artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Homebrew-app
path: ${{ env.APP_ZIP }}
retention-days: 90

- name: Delete temporary keychain
if: always()
run: security delete-keychain "$KEYCHAIN_PATH"
Loading