Skip to content

Release pipeline#85

Open
graeme wants to merge 33 commits into
mainfrom
worktree-release-pipeline
Open

Release pipeline#85
graeme wants to merge 33 commits into
mainfrom
worktree-release-pipeline

Conversation

@graeme

@graeme graeme commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

PR: Automated build, notarise and release pipeline

Summary

Adds a two-stage macOS release pipeline. Every merge to main builds, 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 at 0.2.0, the first tag will be v0.2.0.

Changes

  • build.yml (new) — on merge to main: 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 signed Homebrew.pkg and runs it from a postinstall (offline, no curl | bash).
    • Homebrew-<version>.zip — the bare, stapled Homebrew.app for the cask.
  • release.yml (rewritten) — manual workflow_dispatch promote: reads the version from Version.xcconfig, tags locally, downloads the latest main artifacts, 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

  • Both workflows validated as YAML; the generated postinstall syntax-checked with bash -n.
  • Build → promote exercised on-branch via temporary triggers (reverted before review) to produce a real notarised build, a Release with both assets, and the version-bump PR.
  • Cask PR step needs the homebrew-app cask to exist and the HOMEBREW_GITHUB_API_TOKEN secret.

PR checklist

  • Have you followed this repository's contribution and workflow guidance?
  • Have you explained what changed and why this should land now?
  • Have you run relevant local checks for the changed scope?
  • Are changes scoped and free of unrelated modifications?

  • AI was used to generate or assist with generating this PR.
  • Claude drafted the workflows and docs; I reviewed every step, verified the signing/notarisation flags, and drove the pipeline on-branch to confirm behaviour.

Follow-ups

  • Set the new HOMEBREW_GITHUB_API_TOKEN secret (a PAT that can fork homebrew-cask).
  • The first homebrew-app cask submission is manual; bump-cask-pr automates every bump after that.

graeme and others added 7 commits July 7, 2026 22:38
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>
Comment thread .github/workflows/build.yml Fixed
graeme and others added 4 commits July 8, 2026 08:50
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>
@graeme graeme changed the title Worktree release pipeline Release pipeline improvements Jul 8, 2026
@graeme graeme changed the title Release pipeline improvements Release pipeline Jul 8, 2026
graeme and others added 11 commits July 8, 2026 19:08
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.yml to produce notarised .pkg and .zip artifacts on main.
  • Reworks .github/workflows/release.yml to read MARKETING_VERSION, create a tag/release from the latest build artifacts, and open version bump + cask PRs.
  • Adds release-process documentation and updates MARKETING_VERSION to 0.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.

Comment thread .github/workflows/build.yml
Comment thread .github/workflows/build.yml Outdated
Comment thread .github/workflows/build.yml Outdated
Comment thread .github/workflows/build.yml Outdated
Comment thread .github/workflows/build.yml Outdated
Comment thread .github/workflows/release.yml Outdated
Comment thread .github/workflows/release.yml Outdated
Comment thread .github/workflows/release.yml
Comment thread docs/RELEASING.md Outdated
Comment thread docs/RELEASING.md Outdated
graeme and others added 2 commits July 9, 2026 18:51
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>
graeme and others added 4 commits July 9, 2026 21:38
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>
@graeme graeme requested a review from MikeMcQuaid July 9, 2026 12:11
graeme and others added 5 commits July 9, 2026 22:13
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>
@graeme graeme marked this pull request as ready for review July 9, 2026 13:23

@MikeMcQuaid MikeMcQuaid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, few comments!

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment on lines +28 to +29
# Don't persist the token in .git/config (zizmor: artipacked). The
# pushes below authenticate with GH_TOKEN inline instead.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can remove a bunch of these AI comments

Comment on lines +68 to +69
gh run download "$RUN_ID" --name Homebrew-pkg --dir dist
gh run download "$RUN_ID" --name Homebrew-app --dir dist

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd consider pulling this logic out for now until it can be tested so it doesn't blow up on first release

Comment on lines +126 to +138
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."

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants