Skip to content

Merge pull request #137 from Homebrew/fix-in-place-download-ticks #158

Merge pull request #137 from Homebrew/fix-in-place-download-ticks

Merge pull request #137 from Homebrew/fix-in-place-download-ticks #158

Workflow file for this run

name: Build
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
# Reusable entry point for the Release workflow for minor and majors: build a specific tagged
# commit and notarise/upload it. Triggers stay off tags, so a release build is
# never re-triggered by the tag the Release workflow pushes.
workflow_call:
inputs:
ref:
description: Git ref (tag or SHA) to build. Defaults to the triggering ref.
required: false
type: string
version:
description: Marketing version to stamp instead of the git-derived one (set by the release workflow so it can build before the tag exists).
required: false
type: string
default: ""
notarize:
description: Notarise, staple and upload artifacts (set by the release workflow).
required: false
type: boolean
default: false
secrets:
APP_APPLE_SIGNING_CERTIFICATE_BASE64:
required: true
APP_APPLE_SIGNING_CERTIFICATE_PASSWORD:
required: true
PKG_APPLE_SIGNING_CERTIFICATE_BASE64:
required: true
PKG_APPLE_SIGNING_CERTIFICATE_PASSWORD:
required: true
PKG_APPLE_DEVELOPER_TEAM_ID:
required: true
PKG_APPLE_ID_EMAIL:
required: true
PKG_APPLE_ID_APP_SPECIFIC_PASSWORD:
required: true
concurrency:
group: ${{ github.workflow }}-${{ inputs.ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build:
# Dependabot PRs don't get the Actions signing secrets (they only see the
# separate Dependabot secrets store), so a signed build can't run for them —
# skip the job entirely rather than fail on an empty certificate import.
if: >-
github.actor != 'dependabot[bot]' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)
runs-on: macos-26
env:
NOTARIZE: ${{ inputs.notarize == true || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
# Full history so the version/build number can be derived from git
# (nearest v* tag + distance, and the commit count); a shallow clone
# would collapse to the bootstrap fallback and ship wrong numbers.
fetch-depth: 0
# Nothing here pushes; don't leave the token in .git/config where it
# could leak into an uploaded artifact (zizmor: artipacked).
persist-credentials: false
# fetch-depth: 0 already retrieves tags, but re-fetch defensively so
# git describe in the derivation scripts always sees every v* tag.
- name: Fetch tags
run: git fetch --tags --force
# 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 and build number
env:
VERSION_OVERRIDE: ${{ inputs.version }}
run: |
VERSION="${VERSION_OVERRIDE:-$(scripts/derive-version)}"
BUILD=$(scripts/derive-build-number)
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "BUILD=$BUILD" >> "$GITHUB_ENV"
echo "Building version $VERSION (build $BUILD)"
- name: Build and archive
# The stamp build phase reads MARKETING_VERSION_OVERRIDE (a release build
# passes the version it is releasing; other builds leave it empty and
# derive from git).
env:
MARKETING_VERSION_OVERRIDE: ${{ inputs.version }}
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. The
# version/build are injected by the stamp build phase, not here.
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
# Safety net: confirm the stamp build phase actually baked the derived
# version into the app. If it silently no-op'd (e.g. script sandboxing was
# re-enabled), the app would ship the xcconfig placeholder while the
# artifacts/release claim $VERSION — fail loudly instead.
- name: Verify app version matches derived version
run: |
APP_BUNDLE_PATH="$RUNNER_TEMP/export/Homebrew.app"
BUNDLE_VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
"$APP_BUNDLE_PATH/Contents/Info.plist")
if [ "$BUNDLE_VERSION" != "$VERSION" ]; then
echo "App version $BUNDLE_VERSION does not match derived version $VERSION."
exit 1
fi
echo "App version $BUNDLE_VERSION matches derived version $VERSION."
- name: Notarize and staple app
if: env.NOTARIZE == 'true'
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"
# 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.
- name: Stage Homebrew installer and postinstall script
run: |
SCRIPTS_DIR="$RUNNER_TEMP/installer-scripts"
mkdir -p "$SCRIPTS_DIR"
curl -fsSL -o "$SCRIPTS_DIR/Homebrew.pkg" \
https://github.com/Homebrew/brew/releases/latest/download/Homebrew.pkg
# Verify the download before embedding it inside our own signed .pkg:
# pkgutil must report a valid signature AND the cert chain must carry
# Homebrew's Developer ID Installer team ID. This catches truncation,
# an HTML error page, and — because we pin the team ID — an asset swap
# to a different (even validly-signed) installer.
#
# MAINTENANCE: Homebrew signs with a *maintainer's* Developer ID, which
# has rotated before (Mike McQuaid 6248TWFRH6 -> Patrick Linnane
# 927JGANW46) and was announced only informally. If a legitimate
# Homebrew release starts failing here, confirm the new signer via
# https://github.com/orgs/Homebrew/discussions and update this ID.
EXPECTED_INSTALLER_TEAM_ID="927JGANW46" # Homebrew: Patrick Linnane
if ! sig="$(pkgutil --check-signature "$SCRIPTS_DIR/Homebrew.pkg" 2>&1)" \
|| ! grep -q "Developer ID Installer: .*(${EXPECTED_INSTALLER_TEAM_ID})" <<<"$sig"; then
echo "Homebrew.pkg is not validly signed by the expected team ID (${EXPECTED_INSTALLER_TEAM_ID}) — aborting." >&2
echo "$sig" >&2
exit 1
fi
cat > "$SCRIPTS_DIR/postinstall" <<'POSTINSTALL'
#!/bin/bash
# Installs the bundled Homebrew CLI when it is not already present, by
# running the official signed Homebrew.pkg staged next to this script.
set -euo pipefail
if [[ -x /opt/homebrew/bin/brew || -x /usr/local/bin/brew ]]; then
echo "postinstall: Homebrew already installed — skipping." >&2
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "postinstall: installing bundled Homebrew…" >&2
installer -pkg "$SCRIPT_DIR/Homebrew.pkg" -target /
echo "postinstall: Homebrew installation complete." >&2
exit 0
POSTINSTALL
chmod +x "$SCRIPTS_DIR/postinstall"
- 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
- name: Notarize package
if: env.NOTARIZE == 'true'
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: env.NOTARIZE == 'true'
run: xcrun stapler staple "$PKG_PATH"
- name: Create app zip
if: env.NOTARIZE == 'true'
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: Zip dSYMs
if: env.NOTARIZE == 'true'
run: |
DSYM_ZIP="$RUNNER_TEMP/Homebrew-${VERSION}.dSYMs.zip"
DSYM_DIR="$RUNNER_TEMP/Homebrew.xcarchive/dSYMs"
if [ -z "$(ls -A "$DSYM_DIR" 2>/dev/null)" ]; then
echo "No dSYMs in archive — a release build must produce debug symbols."
exit 1
fi
ditto -c -k --keepParent "$DSYM_DIR" "$DSYM_ZIP"
echo "DSYM_ZIP=$DSYM_ZIP" >> "$GITHUB_ENV"
- name: Upload package artifact
if: env.NOTARIZE == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Homebrew-pkg
path: ${{ env.PKG_PATH }}
retention-days: 90
- name: Upload app artifact
if: env.NOTARIZE == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Homebrew-app
path: ${{ env.APP_ZIP }}
retention-days: 90
- name: Upload dSYMs artifact
if: env.NOTARIZE == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Homebrew-dsyms
path: ${{ env.DSYM_ZIP }}
retention-days: 90
- name: Delete temporary keychain
if: always()
run: security delete-keychain "$KEYCHAIN_PATH"