-
Notifications
You must be signed in to change notification settings - Fork 2
Release pipeline #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
graeme
wants to merge
33
commits into
main
Choose a base branch
from
worktree-release-pipeline
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Release pipeline #85
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 c3972fd
Rewrite release.yml as a promote-only workflow
graeme 8da17c6
Open a version bump PR after releasing
graeme 1cb934c
Open a Homebrew cask PR on release
graeme 79e9e5e
Document the release process
graeme 88696f8
Package only Brew.app, not the whole export directory
graeme a30cf55
Bundle Homebrew.pkg as a sub-package instead of a postinstall script
graeme ef213c9
Fix broken internal link in RELEASING docs
graeme a3507a1
Reference the built app as Homebrew.app
graeme ac795ff
Rebrand the installer, artifact and cask to Homebrew
graeme 033940d
Avoid ls in release.yml artifact lookup (shellcheck SC2012)
graeme 5c53713
Install bundled Homebrew via a postinstall, not a nested sub-package
graeme 622c2f1
Produce a notarised app zip artifact for the cask
graeme ef355db
Attach both the pkg and app zip to the release
graeme ca04927
Downgrade pre-release to non-stable
graeme 31d61c7
Point the cask PR at the app zip, not the pkg
graeme c4bfb0a
Use homebrew-app as the cask token
graeme bca7728
THROWAWAY: run the real build+deploy on this branch to prove it
graeme e21aaa9
Force secure timestamp and hardened runtime when archiving
graeme 9302e01
THROWAWAY: emit a push event on this branch so build steps run
graeme a89df13
Bump version number
graeme 453d345
Make the version bump three-part semver aware
graeme ec2fe91
Don't persist checkout credentials in release workflows
graeme 09cfec1
Update releasing doc with details about app
graeme d059908
Include PR creation permissions
graeme 9948664
Create the release tag via gh release create --target
graeme 8356971
Revert "THROWAWAY: emit a push event on this branch so build steps run"
graeme 2da53d4
Revert "THROWAWAY: run the real build+deploy on this branch to prove it"
graeme 6d0c012
Skip signing cert import for forks
graeme 36304b9
Remove cask PR step for now
graeme cdb9a34
Verify downloaded Homebrew.pkg is signed before embedding
graeme 0b6816d
Pin Homebrew.pkg signature check to Homebrew's team ID
graeme 488b242
Keep the release dry run artifact-free
graeme File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| 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 | ||
| 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. | ||
|
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" | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.