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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
40 changes: 40 additions & 0 deletions .github/workflows/release-unified.yml
Original file line number Diff line number Diff line change
@@ -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 }}
84 changes: 0 additions & 84 deletions .github/workflows/release.yml

This file was deleted.

9 changes: 4 additions & 5 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -39,8 +42,4 @@ checksum:
name_template: checksums.txt

changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
disable: true
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
95 changes: 95 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -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_<version>_<os>_<arch>.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 `## <next-patch> (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.
53 changes: 53 additions & 0 deletions scripts/check_macos_target.py
Original file line number Diff line number Diff line change
@@ -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()
36 changes: 36 additions & 0 deletions scripts/test_check_macos_target.py
Original file line number Diff line number Diff line change
@@ -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()