Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
11 changes: 7 additions & 4 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand All @@ -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.

Expand All @@ -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

Expand Down
47 changes: 35 additions & 12 deletions scripts/homebrew-pr.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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__":
Expand Down
188 changes: 188 additions & 0 deletions scripts/test_homebrew.py
Original file line number Diff line number Diff line change
@@ -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()
Loading