Release pipeline#85
Conversation
Split the build/sign/package machinery out of release.yml into a dedicated Build workflow that runs on every push to main (and as a signed-but-not-notarised dry run on pull requests). On main it produces a fully signed, notarised and stapled .pkg and uploads it as a fixed-name `BrewUI-pkg` artifact so the promote workflow can attach the most recent successful build to a release, without ever rebuilding at release time. The marketing version is read from the project build settings (MARKETING_VERSION, sourced from Configurations/Version.xcconfig) rather than from a tag input. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the fragile "create tag then build" release with the robust pattern from the blogpost: the binary is already built and notarised by build.yml on merge to main, and Release just promotes it. On the manual "Run workflow" trigger it reads the marketing version from Configurations/Version.xcconfig, creates the tag locally, downloads the most recent successful main build artifact, verifies it matches the version, then pushes the tag and creates the GitHub Release with the notarised .pkg attached. Pushing the tag happens last, so a failed run never leaves a dangling tag on GitHub. Pushing a change to this workflow triggers a dry run that exercises permissions without creating a tag or release. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After a successful release, bump the marketing version in Configurations/Version.xcconfig (minor by default, e.g. 1.0 -> 1.1) and open a PR against main. main is branch-protected, so GITHUB_TOKEN cannot push to it directly; a PR is the supported path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After the GitHub Release exists, run `brew bump-cask-pr` to open a PR against Homebrew/homebrew-cask bumping the brewui cask to the released version, pinning the notarised package's sha256. Requires a HOMEBREW_GITHUB_API_TOKEN PAT that can fork homebrew-cask and open a PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/RELEASING.md covering the build-on-merge + manual-promote pipeline: how to cut a release, the required secrets (including the new HOMEBREW_GITHUB_API_TOKEN), immutable-tag and first-time-cask caveats, and a stub for the future patch-release workflow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pkgbuild --root'd the entire xcodebuild export directory, which also contains the exporter's own DistributionSummary.plist, ExportOptions.plist and Packaging.log — so the installed .pkg dropped those stray files into /Applications next to Brew.app. Use --component on the app bundle instead, so only Brew.app is packaged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The postinstall script installed Homebrew by curling install.sh at install time: it needs network access during installation, runs as the console user via su, and fails silently on headless/MDM installs. Instead, download the official Homebrew.pkg and ship it as a nested sub-package in the distribution: productbuild --synthesize builds a distribution that installs Homebrew first, then Brew.app. This drops the --scripts postinstall from pkgbuild and deletes scripts/postinstall. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Version.xcconfig is a repo source file, not part of the published docs site, so the relative link failed htmlproofer's internal link check. Reference it as inline code instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The app target is named Homebrew, so PRODUCT_NAME builds Homebrew.app, not Brew.app. The packaging steps inherited the stale Brew.app paths from the old workflow, which would have failed the signature check and pkgbuild. Point --component and the signature verification at export/Homebrew.app, and name the archive Homebrew.xcarchive to match. The scheme name (Brew) and bundle identifier (sh.brew.app) are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match the Homebrew.app rename across the deliverables: the component and distribution packages become Homebrew-component.pkg / Homebrew-<version>.pkg, the build artifact becomes Homebrew-pkg, and the cask bumped by bump-cask-pr becomes homebrew. Left unchanged: the BrewUITests target and the Tools/BrewUILint linter (unrelated to the app bundle), and the repo/project name in docs prose. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace `ls dist/Homebrew-*.pkg | head` with a nullglob array so the package is located without parsing ls output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
productbuild cannot nest Homebrew.pkg: it is a product archive (a finished installer with its own scripts), and productbuild only assembles component packages, so --synthesize failed with "is a product archive, not a component package". Instead, stage the official signed Homebrew.pkg into the pkgbuild scripts directory and run it from a postinstall via `installer -pkg` when Homebrew is not already present. This preserves Homebrew's own install logic and signature, and is still offline (no curl | bash). The distribution package goes back to wrapping the single app component. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cask install path drops a bare Homebrew.app, which needs its own stapled notarisation ticket — the pkg's ticket does not travel with the extracted app. Notarise and staple the app bundle before packaging (so the copy embedded in the pkg is stapled too), then ditto the stapled app into Homebrew-<version>.zip and upload it as a second fixed-name artifact (Homebrew-app) alongside the pkg. Attestation now covers both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The promote workflow now downloads both build artifacts, checks each matches the version, and attaches both to the GitHub Release: the .pkg (website download) and the .zip (bare app, for the future cask PR). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cask installs the bare app (the zip), so bump-cask-pr must pin the zip's sha256, not the pkg's. Hash $APP_ZIP instead of $PKG_PATH. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Confirmed with a Homebrew lead maintainer that the cask will be named homebrew-app. Update the bump-cask-pr token and the docs to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Do not merge — revert this commit before opening for review. - build.yml: notarise/staple/zip/attest/upload also fire on this branch, so it produces real artifacts here (normally main-only). - release.yml: triggers on pushes to this branch, downloads this branch's build artifacts, and runs the tag/release/version-bump steps for real. - The cask PR step stays dispatch-only, so it is skipped here. Every change is tagged with a THROWAWAY comment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The archive step failed with "A timestamp was expected but was not found" during codesign of Homebrew.app. Developer ID signing requires a secure Apple timestamp, and notarisation requires the hardened runtime; neither was pinned, so codesign behaviour on CI was non-deterministic. Add --timestamp --options runtime to OTHER_CODE_SIGN_FLAGS, and verify the archived app carries a Timestamp= line so this fails fast with a clear message instead of surfacing later at notarisation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The notarise/staple/upload steps are guarded by event_name == 'push', but build.yml's push trigger was scoped to main, so feature-branch runs came through the pull_request event and the guards were skipped. Add this branch to the push trigger so it fires a real push event and the existing guards run — the same push-event path main uses. Revert before review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Version.xcconfig is now three-part semver (0.2.0). The bump logic was written for the old two-part 1.0 default and produced a two-part next version (0.2.0 -> 0.3), silently dropping the patch component. Increment the minor and zero the patch instead (0.2.0 -> 0.3.0), and update the stale two-part note and example in RELEASING.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a two-stage GitHub Actions release pipeline for BrewUI: a Build workflow that archives/signs/notarises and uploads fixed-name artifacts, and a manual Release workflow that promotes the latest successful build into a GitHub Release and opens downstream PRs.
Changes:
- Introduces
.github/workflows/build.ymlto produce notarised.pkgand.zipartifacts onmain. - Reworks
.github/workflows/release.ymlto readMARKETING_VERSION, create a tag/release from the latest build artifacts, and open version bump + cask PRs. - Adds release-process documentation and updates
MARKETING_VERSIONto0.2.0.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 20 comments.
Show a summary per file
| File | Description |
|---|---|
.github/workflows/build.yml |
New build/sign/notarise pipeline that uploads Homebrew-pkg and Homebrew-app artifacts. |
.github/workflows/release.yml |
New promote workflow that creates a Release from the latest build artifacts and opens follow-up PRs. |
docs/RELEASING.md |
New documentation describing the two-stage release process and required secrets. |
Configurations/Version.xcconfig |
Updates MARKETING_VERSION to 0.2.0. |
scripts/postinstall |
Removes the old postinstall script (now generated/staged by the build workflow). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
zizmor's artipacked audit flags actions/checkout for leaving the token in .git/config, where it can leak into uploaded artifacts. Set persist-credentials: false on both checkouts and authenticate the two release pushes inline via GH_TOKEN instead. Anonymous ls-remote reads still work since the repo is public. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Previously the workflow pushed the tag and then ran gh release create as two steps, so a failed release could leave a remote tag with no Release — contradicting the "no dangling tags" guarantee. Drop the separate local tag + push and let gh create the tag as part of release creation, pointed at the released commit. Update the docs to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This reverts commit 9302e01.
This reverts commit bca7728.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
The release .pkg embeds Homebrew's installer downloaded from releases/latest. curl -fsSL only guards against HTTP errors, so a truncated download, an HTML error page, or a tampered asset would be silently wrapped inside our own notarised, Developer-ID-signed .pkg and shipped under our identity. Gate on pkgutil --check-signature reporting a valid Developer ID installer so a bad download fails the build loudly. Latest-Homebrew is intentional (bootstrap installer), so the source is not pinned. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous check only asserted "some Developer ID", which an asset swap to any validly-signed installer would pass. Pin the Developer ID Installer team ID (927JGANW46, Homebrew's Patrick Linnane) so the embedded installer must actually come from Homebrew, and fail on an invalid/absent signature too. Homebrew's signer is maintainer-tied and has rotated before, so the step documents how to update the ID when it legitimately changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The push dry run ran the artifact download unconditionally, so it hard-failed whenever no successful main Build existed yet — including on this workflow's own introducing PR and on the first merge to main. Guard the download and verify steps to workflow_dispatch so the dry run only resolves the version, checks the tag is free, and exercises permissions. Document the first-release sequencing (merge, wait for Build, then dispatch Release). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| 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) |
There was a problem hiding this comment.
Is it possible to have this version e.g. inferred from git describe so a human doesn't need to remember to manually bump a file?
| # Don't persist the token in .git/config (zizmor: artipacked). The | ||
| # pushes below authenticate with GH_TOKEN inline instead. |
There was a problem hiding this comment.
Can remove a bunch of these AI comments
| gh run download "$RUN_ID" --name Homebrew-pkg --dir dist | ||
| gh run download "$RUN_ID" --name Homebrew-app --dir dist |
There was a problem hiding this comment.
So is the flow that you need to bump the version, commit it and then make a new release after that previous PR is merged?
There was a problem hiding this comment.
The idea is that e.g releasing version 0.2.0 just takes an existing artefact (which is already set to 0.2.0) from the last main merge, promotes it to a GitHub release, then opens a PR to immediately bump the marketing version to 0.3.0 so that all subsequent builds have that marketing version. Build number should be set during build and not checked in. That's missing from this, my bad.
| # 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' |
There was a problem hiding this comment.
I'd consider pulling this logic out for now until it can be tested so it doesn't blow up on first release
| 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." |
There was a problem hiding this comment.
Anything here (i.e. probably everything except push and create PR) that can be done on push should be done in a previous step. This helps test more of the release pipeline outside of release time. Worth auditing previous steps and doing that there too.
PR: Automated build, notarise and release pipeline
Summary
Adds a two-stage macOS release pipeline. Every merge to
mainbuilds, signs and notarises the app; a manual "Run workflow" step promotes the latest build to a GitHub Release and opens the downstream PRs. With the version now at0.2.0, the first tag will bev0.2.0.Changes
build.yml(new) — on merge tomain: archive with Developer ID signing (secure timestamp + hardened runtime), notarise and staple both the app and the.pkg, then upload two fixed-name artifacts:Homebrew-<version>.pkg— website download; bundles the official signedHomebrew.pkgand runs it from a postinstall (offline, nocurl | bash).Homebrew-<version>.zip— the bare, stapledHomebrew.appfor the cask.release.yml(rewritten) — manualworkflow_dispatchpromote: reads the version fromVersion.xcconfig, tags locally, downloads the latestmainartifacts, pushes the tag only on success, creates the Release with both assets and opens a version-bump PR.docs/RELEASING.md(new) — process, required secrets, and caveats.Why this split
Building and notarising on merge means nothing ships that hasn't already built successfully — the promote step never rebuilds, so a late failure can't leave a broken tag.
Testing
bash -n.homebrew-appcask to exist and theHOMEBREW_GITHUB_API_TOKENsecret.PR checklist
Follow-ups
HOMEBREW_GITHUB_API_TOKENsecret (a PAT that can forkhomebrew-cask).homebrew-appcask submission is manual;bump-cask-prautomates every bump after that.