From 5bb5edbd3c6fbd0a8b9b8960710dee5bdaa9f2a2 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sun, 27 Sep 2026 23:28:29 +0200 Subject: [PATCH] Use the release app for automatic Homebrew updates --- .github/workflows/release.yml | 16 ++- docs/releasing.md | 11 +- scripts/homebrew-pr.py | 47 ++++++--- scripts/test_homebrew.py | 188 ++++++++++++++++++++++++++++++++++ 4 files changed, 243 insertions(+), 19 deletions(-) create mode 100644 scripts/test_homebrew.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6c7666..74137b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -381,11 +381,21 @@ jobs: run: python scripts/release.py tap-gate env: GH_TOKEN: ${{ github.token }} - - name: Check the cask and open a pull request + - name: Create the tap release bot token + if: steps.latest.outputs.eligible == 'true' + id: release-bot + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.LETTERMINT_RELEASE_APP_ID }} + private-key: ${{ secrets.LETTERMINT_RELEASE_APP_PRIVATE_KEY }} + owner: lettermint + repositories: homebrew-tap + permission-contents: write + permission-pull-requests: write + - name: Check the cask and enable automatic merge if: steps.latest.outputs.eligible == 'true' run: python scripts/homebrew-pr.py env: # Authenticate Homebrew's release checks with the read-only job token. HOMEBREW_GITHUB_API_TOKEN: ${{ github.token }} - # Fine-grained token: only homebrew-tap, Contents and Pull requests write. - GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + GH_TOKEN: ${{ steps.release-bot.outputs.token }} diff --git a/docs/releasing.md b/docs/releasing.md index 5fcefbf..439f756 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -31,7 +31,8 @@ Signing is required for a release. Set these values in the `release` environment | Secret | `MACOS_NOTARY_ISSUER_ID` | App Store Connect issuer ID | | Secret | `MACOS_NOTARY_KEY_ID` | App Store Connect key ID | | Secret | `MACOS_NOTARY_KEY` | Base64 App Store Connect private key | -| Secret | `HOMEBREW_TAP_TOKEN` | Token restricted to the Homebrew tap | +| Variable | `LETTERMINT_RELEASE_APP_ID` | Release app ID: `5003223` | +| Secret | `LETTERMINT_RELEASE_APP_PRIVATE_KEY` | Separate private key for the release app | The signing steps receive the signing secrets. Apple settings, submission, and wait steps receive the notarization credentials. Other steps use only public publisher identifiers. Never include a client secret in the executable. The workflow stops if a required setting is missing. Windows signing uses Azure Artifact Signing with OIDC. Configure the Azure application's federated credential with issuer `https://token.actions.githubusercontent.com`, subject `repo:lettermint/lettermint-cli:environment:release`, and audience `api://AzureADTokenExchange`. Assign the Artifact Signing Certificate Profile Signer role at the selected certificate profile. Its verified publisher must be `Lettermint B.V.`. GitHub does not store a Windows private key or Azure client secret. @@ -45,7 +46,9 @@ Each Apple wait has a 60-minute limit. The job has a 75-minute limit to allow ti The workflow copies `scripts/install.sh` into the release assets and sets its public Apple team ID from `MACOS_SIGN_TEAM_ID` before it calculates checksums. Keep the placeholder in the source file. The released file uses LF line endings, including when the build runs on Windows. Its checksum and build provenance are included with the release. The shell script checks the downloaded macOS executable's signature; the script itself has no Authenticode signature. -Create `lettermint/homebrew-tap` with `main` as its default branch. Give `HOMEBREW_TAP_TOKEN` access only to that repository, with Contents and Pull requests write permissions. Do not use a token with access to other private repositories. Require the tap's cask checks and manual review before merge. +Install the Lettermint Release Bot on `lettermint/homebrew-tap`, with Contents and Pull requests write permissions. Keep `main` as the default branch. Enable automatic squash merges. Require `check (macos-15)` and `check (macos-15-intel)`, and require the branch to be current before merge. Keep the required approval count at zero. The bot does not need a branch protection bypass. + +The Homebrew job creates a short-lived app token restricted to `homebrew-tap`. It uses that token for tap API calls and the automatic merge request. Homebrew checks use the normal job token through `HOMEBREW_GITHUB_API_TOKEN`. The app token is revoked when the job ends. Keep the existing `HOMEBREW_TAP_TOKEN` secret during the transition so older release workflows remain retryable. New workflows do not use it. ## Publish a version @@ -55,7 +58,7 @@ Create `lettermint/homebrew-tap` with `main` as its default branch. Give `HOMEBR 4. Monitor the Release workflow. It checks the exact tag and runs CI. GoReleaser then builds, signs, and packages without publishing. The workflow scans, saves, and checks the packages on all six native platforms. It then submits the macOS executables to Apple and waits for acceptance before the final macOS installer checks. 5. Check that all jobs pass. The workflow checks signatures, expected publishers, notarization, version output, installation, replacement, removal, Unicode paths, and both PowerShell versions. It then generates and verifies build provenance. 6. Download and verify the release files. Only six archives, `install.sh`, the signed `install.ps1` and `uninstall.ps1`, `checksums.txt`, and `provenance.jsonl` are attached. Checksums cover all archives and scripts. Provenance covers those files and the checksum file. Build directories and signing material are never release assets. -7. For the newest stable release, review the automatic cask PR in `lettermint/homebrew-tap`. The workflow checks cask style, online audit, installation, replacement, and removal before it opens the PR. Tap CI checks both Mac architectures and rejects an older version. Merge manually after the checks pass. Pre-releases do not update the tap. +7. For the newest stable release, monitor the cask PR in `lettermint/homebrew-tap`. The workflow checks cask style, online audit, installation, replacement, and removal before it opens the PR. It verifies that the PR changes only the expected cask, then requests an automatic squash merge for that commit. Tap CI checks both Mac architectures and rejects an older version. GitHub merges the PR after all required checks pass. Pre-releases do not update the tap. Use a pre-release to test the complete signed process before the first stable release. Test a failed upload and retry. Confirm that the saved files are reused and that the release notes stay unchanged. Also check installation and upgrade from a prior signed version when one exists. First-release CI uses the new package to test replacement and downgrade protection; it cannot test compatibility with a prior signed release that does not yet exist. @@ -71,7 +74,7 @@ If an upload starts but its submission record is lost, the retry stops. The work Uploads add only missing files. Each existing asset must have exactly the same bytes as the saved file. A different file, an expired saved artifact, or missing saved packages when public assets already exist stops the workflow. Do not delete artifacts, move the tag, or replace assets to bypass this check. Use a new version if the original files cannot be recovered. A failure before any packages were saved can restart the build. -A failed cask PR step does not remove published CLI assets. Fix the tap token or tap check, then retry that job. A retry for an older release does not downgrade the stable cask. +A failed cask PR step does not remove published CLI assets. Fix the app credentials, permissions, or tap check, then retry that job. A retry uses an existing release PR and requests automatic merge again. A different cask or changes to other files stop the merge request. A retry for an older release does not downgrade the stable cask. ## Check saved release packages diff --git a/scripts/homebrew-pr.py b/scripts/homebrew-pr.py index 64c1d73..7f3a3a2 100644 --- a/scripts/homebrew-pr.py +++ b/scripts/homebrew-pr.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Check the generated stable cask and propose it in the public tap.""" +"""Check the stable cask and merge its pull request after required checks pass.""" import base64 import json @@ -39,9 +39,32 @@ def cask_version(text): return release.version("v" + match[1])[0] +def enable_auto_merge(number, branch, text): + pr = release.api(f"repos/{TAP}/pulls/{number}") + if (pr["state"] != "open" or pr["draft"] + or pr["base"]["ref"] != "main" or pr["head"]["ref"] != branch + or pr["base"]["repo"]["full_name"] != TAP + or (pr["head"]["repo"] or {}).get("full_name") != TAP): + raise ValueError("The pull request does not match the release branch and tap.") + head = pr["head"]["sha"] + base = pr["base"]["sha"] + # Check immutable commits, then bind the merge request to the same head. + comparison = release.api(f"repos/{TAP}/compare/{base}...{head}") + files = comparison["files"] + if (len(files) != 1 or files[0]["filename"] != CASK + or files[0]["status"] not in ("added", "modified")): + raise ValueError("The pull request must change only the release cask.") + cask = release.api(f"repos/{TAP}/contents/{CASK}?ref={head}") + if cask["type"] != "file" or base64.b64decode(cask["content"]).decode() != text: + raise ValueError("The pull request cask differs from the checked release cask.") + release.run("gh", "pr", "merge", str(number), "--repo", TAP, + "--auto", "--squash", "--match-head-commit", head) + print("Automatic merge requested. GitHub must pass the required checks before merge.") + + def main(): if not os.environ.get("GH_TOKEN"): - raise ValueError("HOMEBREW_TAP_TOKEN is required to open the cask pull request.") + raise ValueError("GH_TOKEN must contain the release app token for the Homebrew tap.") ctx = release.context() root = Path("release-bundle") release.verify_bundle(root, ctx, provenance=True) @@ -92,16 +115,16 @@ def main(): data["sha"] = proposed["sha"] mutate(f"repos/{TAP}/contents/{CASK}", "PUT", data) prs = release.api(f"repos/{TAP}/pulls?state=open&head=lettermint:{branch}&base=main") - if prs: - print(prs[0]["html_url"]) - return - with tempfile.TemporaryDirectory() as directory: - body = Path(directory) / "body.md" - body.write_text(f"Update the cask to [Lettermint {ctx['tag']}](https://github.com/{release.REPOSITORY}/releases/tag/{ctx['tag']}).\n\n" - "The release workflow checked the signed packages, checksums, provenance, cask style, online audit, installation, replacement, and removal.\n\n" - "Review the cask checks before a manual merge.\n") - print(release.run("gh", "pr", "create", "--repo", TAP, "--head", branch, "--base", "main", - "--title", f"Update Lettermint to {ctx['tag']}", "--body-file", str(body))) + if len(prs) > 1: + raise ValueError("More than one pull request matches the release branch.") + pr = prs[0] if prs else mutate(f"repos/{TAP}/pulls", "POST", { + "head": branch, "base": "main", "title": f"Update Lettermint to {ctx['tag']}", + "body": f"Update the cask to [Lettermint {ctx['tag']}](https://github.com/{release.REPOSITORY}/releases/tag/{ctx['tag']}).\n\n" + "The release workflow checked the signed packages, checksums, provenance, cask style, online audit, installation, replacement, and removal.\n\n" + "This pull request will merge automatically after the required cask checks pass.\n", + }) + print(pr["html_url"]) + enable_auto_merge(pr["number"], branch, text) if __name__ == "__main__": diff --git a/scripts/test_homebrew.py b/scripts/test_homebrew.py new file mode 100644 index 0000000..55cd230 --- /dev/null +++ b/scripts/test_homebrew.py @@ -0,0 +1,188 @@ +import base64 +from contextlib import chdir +import copy +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +spec = importlib.util.spec_from_file_location("homebrew_pr", Path(__file__).with_name("homebrew-pr.py")) +homebrew = importlib.util.module_from_spec(spec) +spec.loader.exec_module(homebrew) + + +class HomebrewTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.enterContext(chdir(self.root)) + self.enterContext(patch.dict(os.environ, GH_TOKEN="test-token")) + self.ctx = {"tag": "v1.2.3", "prerelease": False} + self.text = 'cask "lettermint" do\n version "1.2.3"\nend\n' + cask = self.root / "release-bundle/cask/lettermint.rb" + cask.parent.mkdir(parents=True) + cask.write_text(self.text) + self.pr = { + "number": 12, "html_url": f"https://github.com/{homebrew.TAP}/pull/12", + "state": "open", "draft": False, + "base": {"ref": "main", "sha": "a" * 40, "repo": {"full_name": homebrew.TAP}}, + "head": {"ref": "release/v1.2.3", "sha": "b" * 40, "repo": {"full_name": homebrew.TAP}}, + } + self.files = [{"filename": homebrew.CASK, "status": "modified"}] + self.cask = self.content(self.text) + self.existing = [] + self.api = self.enterContext(patch.object(homebrew.release, "api", side_effect=self.api_response)) + self.command = self.enterContext(patch.object(homebrew.release, "run", side_effect=self.run_response)) + self.brew = self.enterContext(patch.object(homebrew.subprocess, "run")) + self.optional = self.enterContext(patch.object(homebrew, "optional", side_effect=[ + self.content(self.text.replace("1.2.3", "1.2.2")), None, None, + ])) + self.mutate = self.enterContext(patch.object(homebrew, "mutate", return_value=self.pr)) + self.enterContext(patch.object(homebrew.release, "context", return_value=self.ctx)) + self.verify = self.enterContext(patch.object(homebrew.release, "verify_bundle")) + + @staticmethod + def content(text): + return {"type": "file", "sha": "c" * 40, "content": base64.b64encode(text.encode()).decode()} + + def api_response(self, endpoint): + prefix = f"repos/{homebrew.TAP}" + if endpoint == f"{prefix}/pulls/12": + return copy.deepcopy(self.pr) + if endpoint == f"{prefix}/compare/{'a' * 40}...{'b' * 40}": + return {"files": self.files} + if endpoint == f"{prefix}/contents/{homebrew.CASK}?ref={'b' * 40}": + return self.cask + if endpoint == f"{prefix}/git/ref/heads/main": + return {"object": {"sha": "a" * 40}} + if endpoint == f"{prefix}/pulls?state=open&head=lettermint:release/v1.2.3&base=main": + return self.existing + self.fail(f"Unexpected API endpoint: {endpoint}") + + def run_response(self, *args): + if args == ("brew", "--repository", "lettermint/tap"): + return str(self.root / "tap") + if args == ("lettermint", "version", "--json"): + return json.dumps({"version": "1.2.3"}) + if args[:3] == ("gh", "pr", "merge"): + return "" + self.fail(f"Unexpected command: {args}") + + def assert_merge_requested(self): + self.command.assert_any_call("gh", "pr", "merge", "12", "--repo", homebrew.TAP, + "--auto", "--squash", "--match-head-commit", "b" * 40) + + def assert_no_merge(self): + self.assertFalse(any(call.args[:3] == ("gh", "pr", "merge") for call in self.command.call_args_list)) + + def test_new_pull_request_requests_automatic_merge(self): + homebrew.main() + self.verify.assert_called_once_with(Path("release-bundle"), self.ctx, provenance=True) + self.assert_merge_requested() + request = self.mutate.call_args_list[-1] + self.assertEqual(request.args[:2], (f"repos/{homebrew.TAP}/pulls", "POST")) + self.assertEqual(request.args[2]["head"], "release/v1.2.3") + self.assertIn("merge automatically", request.args[2]["body"]) + + def test_existing_pull_request_retry_requests_merge_without_duplicate(self): + self.existing = [self.pr] + self.optional.side_effect = [self.content(self.text.replace("1.2.3", "1.2.2")), + {"object": {"sha": "b" * 40}}, self.content(self.text)] + homebrew.main() + self.mutate.assert_not_called() + self.assert_merge_requested() + + def test_same_or_newer_version_on_main_does_nothing(self): + for version in ("1.2.3", "1.3.0"): + with self.subTest(version=version): + self.optional.side_effect = [self.content(self.text.replace("1.2.3", version))] + homebrew.main() + self.mutate.assert_not_called() + self.brew.assert_not_called() + self.assert_no_merge() + + def test_prerelease_stops_before_tap_changes(self): + self.ctx["prerelease"] = True + with self.assertRaisesRegex(ValueError, "Pre-releases"): + homebrew.main() + self.optional.assert_not_called() + self.mutate.assert_not_called() + + def test_different_release_branch_cask_is_not_overwritten(self): + different = self.content(self.text + "# changed\n") + different["sha"] = "d" * 40 + self.optional.side_effect = [self.content(self.text.replace("1.2.3", "1.2.2")), + {"object": {"sha": "b" * 40}}, different] + with self.assertRaisesRegex(ValueError, "different cask"): + homebrew.main() + self.mutate.assert_not_called() + self.assert_no_merge() + + def test_unexpected_paths_and_renames_block_merge(self): + for files in ([], self.files + [{"filename": "README.md", "status": "modified"}], + [{"filename": homebrew.CASK, "status": "renamed"}]): + with self.subTest(files=files): + self.files = files + with self.assertRaisesRegex(ValueError, "only the release cask"): + homebrew.enable_auto_merge(12, "release/v1.2.3", self.text) + self.assert_no_merge() + + def test_changed_content_at_pr_head_blocks_merge(self): + self.cask = self.content(self.text + "# changed\n") + with self.assertRaisesRegex(ValueError, "differs"): + homebrew.enable_auto_merge(12, "release/v1.2.3", self.text) + self.assert_no_merge() + + def test_wrong_pr_target_or_source_blocks_merge(self): + original = copy.deepcopy(self.pr) + for kind in ("closed", "draft", "base", "head", "fork", "deleted-repo"): + with self.subTest(kind=kind): + self.pr = copy.deepcopy(original) + if kind == "closed": + self.pr["state"] = "closed" + elif kind == "draft": + self.pr["draft"] = True + elif kind in ("base", "head"): + self.pr[kind]["ref"] = "unexpected" + elif kind == "fork": + self.pr["head"]["repo"]["full_name"] = "other/homebrew-tap" + else: + self.pr["head"]["repo"] = None + with self.assertRaisesRegex(ValueError, "does not match"): + homebrew.enable_auto_merge(12, "release/v1.2.3", self.text) + self.assert_no_merge() + + def test_merge_request_failure_fails_job_and_retry_reuses_pr(self): + original = self.run_response + + def reject_merge(*args): + if args[:3] == ("gh", "pr", "merge"): + raise subprocess.CalledProcessError(1, args) + return original(*args) + + self.command.side_effect = reject_merge + with self.assertRaises(subprocess.CalledProcessError): + homebrew.main() + self.existing = [self.pr] + self.mutate.reset_mock() + self.optional.side_effect = [self.content(self.text.replace("1.2.3", "1.2.2")), + {"object": {"sha": "b" * 40}}, self.content(self.text)] + self.command.side_effect = original + homebrew.main() + self.mutate.assert_not_called() + self.assert_merge_requested() + + def test_missing_app_token_stops_before_validation(self): + with patch.dict(os.environ, GH_TOKEN=""), self.assertRaisesRegex(ValueError, "release app token"): + homebrew.main() + self.verify.assert_not_called() + + +if __name__ == "__main__": + unittest.main()