From d2d9319e14641c4e6ffb92e762bb76cd7fc0b911 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 14 Sep 2026 08:09:39 -0700 Subject: [PATCH] fix(release): sign and notarize macOS downloads --- .github/workflows/ci.yml | 19 ++++++ .github/workflows/release-unified.yml | 40 +++++++++++ .github/workflows/release.yml | 84 ----------------------- .goreleaser.yaml | 9 ++- CHANGELOG.md | 2 + README.md | 2 +- docs/releasing.md | 95 +++++++++++++++++++++++++++ scripts/check_macos_target.py | 53 +++++++++++++++ scripts/test_check_macos_target.py | 36 ++++++++++ 9 files changed, 250 insertions(+), 90 deletions(-) create mode 100644 .github/workflows/release-unified.yml delete mode 100644 .github/workflows/release.yml create mode 100644 docs/releasing.md create mode 100644 scripts/check_macos_target.py create mode 100644 scripts/test_check_macos_target.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e09fe7e..8cbdb5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,3 +55,22 @@ jobs: run: go install golang.org/x/vuln/cmd/govulncheck@latest - name: govulncheck run: govulncheck ./... + + release-build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: true + - name: Test macOS deployment target gate + run: python3 -B -m unittest discover -s scripts -p 'test_*.py' + - uses: goreleaser/goreleaser-action@v7 + with: + version: '~> v2' + args: build --snapshot --clean + - name: Smoke test native release binary + run: dist/blu_darwin_arm64_v8.0/blu --version diff --git a/.github/workflows/release-unified.yml b/.github/workflows/release-unified.yml new file mode 100644 index 0000000..22f97fe --- /dev/null +++ b/.github/workflows/release-unified.yml @@ -0,0 +1,40 @@ +name: Release (unified) + +on: + workflow_dispatch: + inputs: + version: + description: SemVer to release + required: true + type: string + +permissions: {} + +jobs: + release: + permissions: + actions: read + checks: read + contents: write + pull-requests: write + statuses: read + uses: openclaw/release-workflows/.github/workflows/release-go-cli.yml@f613cbfed2b043159c850c353e7facb8c89833b0 # v1.9.0 + with: + version: ${{ inputs.version }} + repository-type: personal + homebrew-tap: steipete/homebrew-tap + homebrew-formula: blucli + archive-files: '["LICENSE","README.md"]' + checksum-filename: checksums.txt + darwin-universal: disabled + # Run the GoReleaser post-build otool gate on the actual release binaries. + build-runner: macos + strict-checks: true + ci-check-events: '["push","pull_request"]' + secrets: + MACOS_SIGNING_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGNING_P12_PASSWORD: ${{ secrets.MACOS_SIGN_P12_PASSWORD }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_PRIVATE_KEY_P8: ${{ secrets.ASC_PRIVATE_KEY }} + TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 326db20..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: release - -on: - push: - tags: - - "v*" - -permissions: - contents: write - -jobs: - goreleaser: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - cache: true - - - uses: goreleaser/goreleaser-action@v7 - with: - distribution: goreleaser - version: latest - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - update-homebrew-tap: - runs-on: ubuntu-latest - needs: goreleaser - steps: - - name: Resolve release tag - run: echo "RELEASE_TAG=${{ github.ref_name }}" >> "$GITHUB_ENV" - - - name: Dispatch tap formula update - env: - GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - run: | - if [ -z "$GH_TOKEN" ]; then - echo "::error::Set HOMEBREW_TAP_TOKEN with workflow access to steipete/homebrew-tap" - exit 1 - fi - - request_id="blucli-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - expected_title="Update blucli for ${RELEASE_TAG} (${request_id})" - - gh workflow run update-formula.yml \ - --repo steipete/homebrew-tap \ - --ref main \ - -f formula=blucli \ - -f tag="$RELEASE_TAG" \ - -f repository=steipete/blucli \ - -f artifact_template="{formula}_{version}_{target}.tar.gz" \ - -f request_id="$request_id" - - run_id="" - for _ in {1..30}; do - run_id=$(gh run list \ - --repo steipete/homebrew-tap \ - --workflow update-formula.yml \ - --branch main \ - --event workflow_dispatch \ - --limit 20 \ - --json databaseId,displayTitle \ - --jq ".[] | select(.displayTitle == \"$expected_title\") | .databaseId" | head -n1) - if [ -n "$run_id" ]; then - break - fi - sleep 5 - done - - if [ -z "$run_id" ]; then - echo "::error::Could not find tap workflow run with title: $expected_title" - exit 1 - fi - - gh run watch "$run_id" \ - --repo steipete/homebrew-tap \ - --exit-status \ - --interval 10 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 46773d1..35c8ef2 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -11,6 +11,9 @@ builds: binary: blu env: - CGO_ENABLED=0 + - MACOSX_DEPLOYMENT_TARGET=12.0 + hooks: + post: python3 scripts/check_macos_target.py {{ .Path }} {{ .Os }} ldflags: - -s -w -X main.version={{.Version}} targets: @@ -39,8 +42,4 @@ checksum: name_template: checksums.txt changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" + disable: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 25384a7..7069281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 0.1.7 (Unreleased) +- Release binaries for macOS are now Developer ID signed and notarized, so direct downloads pass Gatekeeper. + ## 0.1.6 (2026-09-13) - Build: use Go 1.26.8 for CI, releases, and Docker while retaining Go 1.25 source compatibility and macOS 12 support. diff --git a/README.md b/README.md index fdc33f8..209f5a2 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ With Go 1.25 or newer: go install github.com/steipete/blucli/cmd/blu@latest ``` -Prebuilt macOS, Linux, and Windows archives are available from [GitHub Releases](https://github.com/steipete/blucli/releases/latest). For a container-based setup, see the [Docker guide](docs/usage.md#docker). +Prebuilt macOS, Linux, and Windows archives are available from [GitHub Releases](https://github.com/steipete/blucli/releases/latest). macOS release binaries require macOS 12 or newer and are Developer ID signed by Peter Steinberger and notarized by Apple, so direct downloads pass Gatekeeper. See the [release guide](docs/releasing.md) for checksum and signature verification. For a container-based setup, see the [Docker guide](docs/usage.md#docker). ## Quick start diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..ac69476 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,95 @@ +# Releasing blucli + +Releases use `.github/workflows/release-unified.yml`, pinned to +`openclaw/release-workflows` v1.9.0 at +`f613cbfed2b043159c850c353e7facb8c89833b0`. The shared Go CLI archetype +builds with GoReleaser, signs and notarizes the macOS binaries, and verifies +an immutable artifact independently on Intel and Apple Silicon before publishing. + +## Release contract + +- macOS 12.0 or newer; both `darwin_amd64` and `darwin_arm64`. +- Developer ID Application: Peter Steinberger (Y5PE65HELJ), with hardened runtime, + trusted timestamp, and signing identifier `com.steipete.blucli.blu`. +- The executable remains `blu` (`blu.exe` on Windows). +- Archives remain `blucli___.tar.gz` (Windows: `.zip`), + including `LICENSE` and `README.md`. There is no universal archive. +- `checksums.txt` covers the published payload and provenance controls: + `ASSET-INVENTORY.json`, `SIGNING-MANIFEST.json`, and `RELEASE-NOTES.md`. +- The handoff updates `steipete/homebrew-tap` formula `blucli` with the exact + verified asset names and SHA-256 values, then verifies the resulting formula. + +The release builds on macOS so the GoReleaser post-build hook can inspect every +Darwin binary with `otool -arch all -l`. `CGO_ENABLED=0` and +`MACOSX_DEPLOYMENT_TARGET=12.0` are explicit; the hook rejects a deployment target +other than 12.0, including toolchain-default changes. If cgo is introduced, also +set matching `CGO_CFLAGS` and `CGO_LDFLAGS` with `-mmacosx-version-min=12.0`; +the environment variable alone does not reliably constrain external linking. +CI exercises the same build configuration and target gate without Apple secrets. + +## Repository setup + +Protect `main` with required CI checks. Enable Actions read/write workflow +permissions and `can_approve_pull_request_reviews` so closeout can create its PR. +All workflows still declare their own limited permissions. + +The caller maps these repository secrets to the shared workflow: + +| Repository secret | Shared workflow secret | +| --- | --- | +| `MACOS_SIGN_P12` | `MACOS_SIGNING_P12` | +| `MACOS_SIGN_P12_PASSWORD` | `MACOS_SIGNING_P12_PASSWORD` | +| `ASC_KEY_ID` | `ASC_KEY_ID` | +| `ASC_ISSUER_ID` | `ASC_ISSUER_ID` | +| `ASC_PRIVATE_KEY` | `ASC_PRIVATE_KEY_P8` | +| `HOMEBREW_TAP_TOKEN` | `TAP_TOKEN` | + +The tap token needs Contents read and Actions write on `steipete/homebrew-tap`. +The signing job checks the personal identity and cleans up its temporary keychain. +The independent verifiers do not receive signing or release-write credentials. + +## Ship a patch + +1. Check the current tags and published releases. Finalize the versioned + Unreleased changelog section with a date and Highlights; update the source + version in `cmd/blu/main.go` to the new version. GoReleaser overrides it with + the release tag, while source installs report the checked-in version. +2. Run `actionlint`, `go test -race ./...`, `golangci-lint run --timeout=5m`, + `python3 -B -m unittest discover -s scripts -p 'test_*.py'`, and + `goreleaser build --snapshot --clean` on macOS. Review and merge the PR, then + wait for CI on the exact `main` commit to pass. +3. Recheck that the new tag/release does not exist. Dispatch from current `main`: + + ```sh + gh workflow run release-unified.yml --ref main -f version=0.1.7 + ``` + + The workflow creates the annotated tag at the frozen protected commit. Do not + create or move the tag manually. Retries reuse that tag; investigate failures + before rerunning. The release body is the exact dated changelog section. +4. Verify the release assets, checksums, macOS signatures and execution, Go proxy, + and Homebrew formula. Review and merge the generated closeout PR, naming the + next section `## (Unreleased)` to match this repository. + +## Verify a download + +Download an archive and `checksums.txt` from the same release. Verify its SHA-256 +before extracting it. For example, for an Apple Silicon download: + +```sh +shasum -a 256 blucli_0.1.7_darwin_arm64.tar.gz +# Compare with that exact filename in checksums.txt. +tar -xzf blucli_0.1.7_darwin_arm64.tar.gz +codesign -dvv ./blu +codesign --verify --deep --strict --verbose=4 ./blu +codesign --verify --strict --check-notarization -R=notarized ./blu +spctl -a -vv -t open --context context:primary-signature ./blu +python3 scripts/check_macos_target.py ./blu +./blu --version +``` + +`codesign` must report the identity and Team ID above; Gatekeeper must accept it. +Use `otool -l ./blu` outside a source checkout and inspect `LC_BUILD_VERSION`: +`minos` must be `12.0`. Test a quarantined download without removing its quarantine +attribute. Command-line `curl` does not normally add quarantine on macOS, so a +manual Gatekeeper test must explicitly add `com.apple.quarantine` before launch. diff --git a/scripts/check_macos_target.py b/scripts/check_macos_target.py new file mode 100644 index 0000000..e2266d1 --- /dev/null +++ b/scripts/check_macos_target.py @@ -0,0 +1,53 @@ +"""Require the documented macOS 12.0 minimum in every Mach-O slice.""" + +import re +import subprocess +import sys + + +def check_target(load_commands): + targets = [] + for block in re.split(r"(?m)^Load command \d+\s*$", load_commands)[1:]: + command = re.search(r"(?m)^\s*cmd (\S+)\s*$", block) + if not command: + raise ValueError("missing Mach-O load command") + if command[1] == "LC_BUILD_VERSION": + platform = re.search(r"(?m)^\s*platform (\S+)\s*$", block) + if not platform or platform[1] not in ("1", "MACOS"): + raise ValueError("expected a macOS platform") + field = "minos" + elif command[1] == "LC_VERSION_MIN_MACOSX": + field = "version" + else: + continue + match = re.search(rf"(?m)^\s*{field} (\d+\.\d+(?:\.\d+)?)\s*$", block) + if not match: + raise ValueError("missing or malformed macOS deployment target") + version = tuple(map(int, match[1].split("."))) + version += (0,) * (3 - len(version)) + if version != (12, 0, 0): + raise ValueError(f"macOS {match[1]} does not match documented minimum 12.0") + targets.append(match[1]) + if not targets: + raise ValueError("no macOS deployment target found") + return targets + + +def main(): + if len(sys.argv) not in (2, 3): + raise SystemExit("usage: check_macos_target.py BINARY [GOOS]") + if len(sys.argv) == 3 and sys.argv[2] != "darwin": + return + try: + result = subprocess.run( + ["otool", "-arch", "all", "-l", sys.argv[1]], + check=True, capture_output=True, text=True, + ) + targets = check_target(result.stdout) + except (OSError, subprocess.CalledProcessError, ValueError) as error: + raise SystemExit(f"macOS release gate failed: {error}") from error + print(f"macOS release gate passed: minos {', '.join(targets)} == 12.0") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_check_macos_target.py b/scripts/test_check_macos_target.py new file mode 100644 index 0000000..8bf6041 --- /dev/null +++ b/scripts/test_check_macos_target.py @@ -0,0 +1,36 @@ +import unittest + +from check_macos_target import check_target + + +def commands(version="12.0", platform="1"): + return f"""Load command 0 + cmd LC_BUILD_VERSION + cmdsize 32 + platform {platform} + minos {version} + sdk 15.0 +""" + + +class MacOSTargetTests(unittest.TestCase): + def test_supported_native_and_universal(self): + self.assertEqual(check_target(commands()), ["12.0"]) + self.assertEqual(check_target(commands() + commands("12.0.0")), ["12.0", "12.0.0"]) + + def test_rejects_newer_older_and_mixed_slices(self): + for source in (commands("15.0"), commands("11.0"), commands() + commands("15.0")): + with self.subTest(source=source), self.assertRaises(ValueError): + check_target(source) + + def test_rejects_missing_malformed_and_wrong_platform(self): + for source in ("", commands("invalid"), commands(platform="2"), commands().replace("minos", "other")): + with self.subTest(source=source), self.assertRaises(ValueError): + check_target(source) + + def test_legacy_macos_command(self): + self.assertEqual(check_target("Load command 0\n cmd LC_VERSION_MIN_MACOSX\n version 12.0\n"), ["12.0"]) + + +if __name__ == "__main__": + unittest.main()