diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..fb15873f --- /dev/null +++ b/.github/workflows/build.yml @@ -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. + - 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 + + + + + method + developer-id + teamID + ${{ secrets.PKG_APPLE_DEVELOPER_TEAM_ID }} + signingStyle + manual + + + 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 + # 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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42f3e8ed..6d701a36 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,207 +1,138 @@ 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 + pull-requests: write # open the version bump PR + +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 + # Don't persist the token in .git/config (zizmor: artipacked). The + # pushes below authenticate with GH_TOKEN inline instead. + persist-credentials: false - # 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" + 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 + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "TAG=v$VERSION" >> "$GITHUB_ENV" + echo "Releasing version $VERSION (tag v$VERSION)" - - 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 }} + # 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_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 + 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 - 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." + # 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: | + 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 - if ! echo "$BASIC_IDENTITIES" | grep -q "Developer ID Installer"; then - echo "Missing Developer ID Installer identity in imported keychain." + 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 artifacts." 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" - - - 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." + 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" - # 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 + # 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: | - 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 + 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 + if: github.event_name == 'push' + 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." - exit 1 - fi - - - name: Notarize package - if: startsWith(github.ref, 'refs/tags/') + echo "Dry run — verifying permissions without side effects." + gh release list + git ls-remote --tags origin | head + + # 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' 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 }} - - - 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/') + 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 + # 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' 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: | + IFS=. read -r major minor _ <<< "$VERSION" + 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" + BRANCH="version-bump-$NEXT" + git switch -C "$BRANCH" + git commit -am "Bump marketing version to $NEXT" + 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" \ + --body "Automated version bump after releasing $TAG." diff --git a/Configurations/Version.xcconfig b/Configurations/Version.xcconfig index 28ecd716..ed38b2bc 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.2.0 CURRENT_PROJECT_VERSION = 1 diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 00000000..5469722d --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,100 @@ +# 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 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. + +The marketing version lives in `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`. + +> **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` + (fails if that tag already exists — see immutable tags below). +2. Downloads the most recent successful **Build** artifact from `main` and + checks its filename matches the version. +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. +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 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`). + +--- + +## 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. 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. +- **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. + +--- + +## 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. diff --git a/scripts/postinstall b/scripts/postinstall deleted file mode 100755 index 041f7766..00000000 --- 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