Merge pull request #136 from Homebrew/dependabot/bundler/bundler-1e24… #154
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |