diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75b7467..fff4cbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: ["main"] pull_request: +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest @@ -39,3 +42,24 @@ jobs: with: version: latest args: --timeout=3m + + macos-release: + runs-on: macos-15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: true + - name: Check release metadata and deployment-target regressions + run: | + ./scripts/check-release-metadata + python3 -m unittest discover -s scripts -p 'test_*.py' + - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 + with: + version: '~> v2' + install-only: true + - name: Check and build release matrix (including macOS 13 gate) + run: | + goreleaser check + goreleaser build --snapshot --clean diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 24f69a5..1f5f3f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,105 +1,51 @@ name: release on: - push: - tags: - - "v*" workflow_dispatch: inputs: - tag: - description: "Tag to (re)release (e.g. v0.1.0)" + version: + description: SemVer to release (for example 0.2.1) required: true type: string -permissions: - contents: write +permissions: {} jobs: - goreleaser: + preflight: runs-on: ubuntu-latest + permissions: + contents: read steps: - - name: Checkout - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Setup Go - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - cache: true - - - name: Stash GoReleaser config - run: cp .goreleaser.yaml /tmp/.goreleaser.yaml - - - name: Checkout release tag - if: ${{ github.event_name == 'workflow_dispatch' }} - run: git checkout ${{ inputs.tag }} - - - name: GoReleaser - uses: goreleaser/goreleaser-action@v7 - with: - distribution: goreleaser - version: latest - args: release --clean --config /tmp/.goreleaser.yaml - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - update-homebrew-tap: - runs-on: ubuntu-latest - needs: goreleaser - steps: - - name: Resolve release tag - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "RELEASE_TAG=${{ inputs.tag }}" >> "$GITHUB_ENV" - else - echo "RELEASE_TAG=${{ github.ref_name }}" >> "$GITHUB_ENV" - fi - - - name: Dispatch tap formula update + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check release metadata 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="ordercli-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - expected_title="Update ordercli for ${RELEASE_TAG} (${request_id})" - - gh workflow run update-formula.yml \ - --repo steipete/homebrew-tap \ - --ref main \ - -f formula=ordercli \ - -f tag="$RELEASE_TAG" \ - -f repository=steipete/ordercli \ - -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 + RELEASE_VERSION: ${{ inputs.version }} + run: ./scripts/check-release-metadata "$RELEASE_VERSION" + + release: + needs: preflight + 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 + build-runner: macos + darwin-universal: disabled + checksum-filename: checksums.txt + archive-files: '["CHANGELOG.md", "LICENSE", "README.md"]' + homebrew-tap: steipete/homebrew-tap + homebrew-formula: ordercli + 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/.gitignore b/.gitignore index 8c8fac3..0ed5fc0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /foodcli /foodoracli /ordercli +/dist/ # keep source under cmd/ !cmd/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 49a62f6..460a9d5 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -11,6 +11,11 @@ builds: binary: ordercli env: - CGO_ENABLED=0 + - MACOSX_DEPLOYMENT_TARGET=13.0 + hooks: + post: + - cmd: ./scripts/check-macos-target {{ .Path }} --goos {{ .Os }} + output: true targets: - darwin_amd64 - darwin_arm64 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cfaa7c..c9b08c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Release binaries for macOS are now Developer ID signed and notarized, so direct downloads pass Gatekeeper. + ## 0.2.0 (2026-09-13) **Highlights:** Foodora gains Sweden and Czech Republic presets, Chrome cookie imports work with npm 12 native builds, and macOS binaries now require macOS 13 or newer. diff --git a/README.md b/README.md index ba44742..2479458 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ brew install steipete/tap/ordercli ordercli --version ``` -Prebuilt archives for macOS, Linux, and Windows are available on the [GitHub releases page](https://github.com/steipete/ordercli/releases). The macOS binaries require macOS 13 or newer. +Prebuilt archives for macOS, Linux, and Windows are available on the [GitHub releases page](https://github.com/steipete/ordercli/releases). The macOS binaries require macOS 13 or newer. Starting with v0.2.1, macOS binaries are Developer ID signed and notarized by Apple, so direct downloads pass Gatekeeper. ## Build diff --git a/docs/RELEASING.md b/docs/RELEASING.md index b9f252b..d0b70c1 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -4,30 +4,39 @@ summary: 'Release checklist for ordercli (GitHub release + Homebrew tap)' # Releasing ordercli -Releases use an annotated `v` tag and `.github/workflows/release.yml`, as in 0.1.0. GoReleaser publishes the binary archives and `checksums.txt`; the dependent job updates `steipete/homebrew-tap` and waits for its result. Title GitHub releases as `ordercli `. +Dispatch `.github/workflows/release.yml` on the current protected `main` with a SemVer `version`. The caller pins the shared Go CLI archetype at v1.9.0 (`f613cbfed2b043159c850c353e7facb8c89833b0`). It freezes the source, creates the annotated `v` tag, builds with GoReleaser, signs and notarizes macOS binaries, verifies on native arm64 and Intel runners, then publishes and updates `steipete/homebrew-tap`. + +The signing policy is `personal`: Developer ID Application: Peter Steinberger, team `Y5PE65HELJ`, identifier `com.steipete.ordercli.ordercli`. Native archives retain `ordercli___` names, tar.gz/Windows ZIP formats, and the README, license, and changelog. The release includes `checksums.txt`, `ASSET-INVENTORY.json`, `SIGNING-MANIFEST.json`, and `RELEASE-NOTES.md`; independent verifier attestations bind publication to the frozen source and exact signed bytes. + +Repository setup requires protected main with required CI checks, Actions `default_workflow_permissions=write`, and `can_approve_pull_request_reviews=true` for the closeout PR. Workflows declare their own least-privilege permissions. Provision `MACOS_SIGN_P12`, `MACOS_SIGN_P12_PASSWORD`, `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_PRIVATE_KEY`, and `HOMEBREW_TAP_TOKEN` as repository secrets. The caller maps these to the shared workflow's signing, notary, and tap secrets; no credentials belong in source. ## Prepare and validate - Start with a clean, current `main` and prepare the release on a task branch. - Update `internal/version/version.go` and finalize `CHANGELOG.md` with the local release date, preserving contributor credits. +- Run `./scripts/check-release-metadata ` to check the requested version against the Go constant and latest finalized changelog section. - Run `go build ./cmd/ordercli`, `go test ./... -coverprofile=cover.out`, `go tool cover -func=cover.out` (at least 75% total), `make lint`, and `goreleaser check`. Check formatting with `gofumpt -l .`. - Run the Docker build and smoke commands in `.github/workflows/docker.yml`. -- Review and land the release commit. Wait for CI and Docker checks on that exact commit before tagging. +- Run `actionlint`, `python3 -m unittest discover -s scripts -p 'test_*.py'`, and `goreleaser build --snapshot --clean` on macOS. The same build runs in CI. +- Review and land the release commit. Wait for CI, macOS release-build, and Docker checks on that exact commit before dispatching. + +The release builds on macOS so the GoReleaser post-build hook can inspect each actual Darwin binary with `otool -arch all -l`. Every `LC_BUILD_VERSION` must have `minos 13.0`, matching the README. GoReleaser sets `MACOSX_DEPLOYMENT_TARGET=13.0` and `CGO_ENABLED=0`; the Go 1.27 linker supplies this minimum for pure Go. If cgo is introduced, also set explicit `-mmacosx-version-min=13.0` in both `CGO_CFLAGS` and `CGO_LDFLAGS`; the environment variable alone is not sufficient proof. ## Publish - Confirm the version does not already exist as a local/remote tag or GitHub release. -- Create an annotated tag: `git tag -a v -m "Release "`. -- Push only that tag: `git push origin v`. +- Dispatch `gh workflow run release.yml --ref main -f version=`. The workflow owns annotated tag creation; do not create or push a tag manually. - Watch the release workflow, including its Homebrew handoff, to completion. -- Set the GitHub release title to `ordercli ` and use the finalized changelog section as its body, with Highlights first and links to the downloads, checksums, and Homebrew formula. Supply the body using a file and `--notes-file`. +- The shared workflow publishes the finalized changelog section as both the release body and `RELEASE-NOTES.md`. Check it against `./scripts/release-notes v`; preserve those exact notes after publication. -The release workflow can also be dispatched with the existing tag for recovery. Inspect partial uploads before retrying; do not move an already published tag. Release credentials stay in GitHub Actions secrets. +For recovery, rerun failed jobs on the original run. The shared workflow also supports redispatch from current main with an existing annotated version tag, reusing its frozen commit; the caller's current metadata must still match that version. Inspect partial uploads before retrying and never move an existing tag or rebuild an already published release. Release credentials stay in GitHub Actions secrets. ## Verify and finish - Read the GitHub release and asset inventory back, download an archive and `checksums.txt`, and verify its SHA-256. -- Check both macOS archives with `codesign`, `spctl`, and `otool`. GoReleaser cross-compiles these binaries on Linux without Developer ID signing or notarization; the Go linker may apply an ad-hoc signature. Their minimum macOS version must match the README (macOS 13). +- Download both macOS archives freshly with `curl -fL`, verify their hashes, and extract them. curl does not normally apply quarantine: explicitly mark each downloaded archive and extracted binary with `xattr -w com.apple.quarantine "0083;$(printf '%x' "$(date +%s)");ordercli-release-verification;" ` and show it with `xattr -l`. +- Require `codesign -dvv ` to show Peter Steinberger's Developer ID and `TeamIdentifier=Y5PE65HELJ`, then run `codesign --verify --deep --strict --verbose=4 ` and `spctl -a -vv -t open --context context:primary-signature ` (accepted, Notarized Developer ID). +- Run `./scripts/check-macos-target ` and inspect `otool -l ` for `LC_BUILD_VERSION` / `minos 13.0`. Run the quarantined native binary with `--version`; the shared verifier jobs cover execution on both architectures. - Verify `go list -m github.com/steipete/ordercli@v` through the Go proxy. - Verify the Homebrew formula version and checksums, install or upgrade it, run `brew test steipete/tap/ordercli`, and check `ordercli --version`. See [the Homebrew playbook](releasing-homebrew.md). -- Open the next empty `## Unreleased` section, review and commit it, then leave `main` clean and synchronized with `origin/main`. +- Review and merge the shared workflow's next empty `## Unreleased` PR (or open it if recovery requires it), then leave `main` clean and synchronized with `origin/main`. diff --git a/docs/releasing-homebrew.md b/docs/releasing-homebrew.md index 2dde122..bf8a6d3 100644 --- a/docs/releasing-homebrew.md +++ b/docs/releasing-homebrew.md @@ -1,10 +1,10 @@ # ordercli Homebrew Release Playbook -The release workflow updates `steipete/homebrew-tap` automatically after GoReleaser publishes the archives. The formula installs prebuilt binaries for macOS and Linux on Intel and ARM; it does not use the source archive. +The shared release workflow updates `steipete/homebrew-tap` automatically after independently verified archives are published. The formula installs prebuilt binaries for macOS and Linux on Intel and ARM; starting with v0.2.1, macOS binaries are Developer ID signed and notarized. ## Automated handoff -`.github/workflows/release.yml` dispatches `update-formula.yml` in the tap with the release tag, repository, and artifact template `{formula}_{version}_{target}.tar.gz`. `HOMEBREW_TAP_TOKEN` must have workflow access to the tap. The release job waits for the exact dispatched run and fails if the update fails. +`.github/workflows/release.yml` selects `homebrew-tap: steipete/homebrew-tap` and `homebrew-formula: ordercli`. The shared workflow dispatches `update-formula.yml` with the release tag, repository, and exact inventory-derived asset names and SHA-256 values. Names remain `ordercli__.tar.gz`. `HOMEBREW_TAP_TOKEN` maps to `TAP_TOKEN` and needs Contents read plus Actions write on the tap. The handoff waits for the correlated run and verifies every resulting formula URL and checksum against the independently verified release. ## Verify the formula @@ -21,4 +21,4 @@ ordercli --version ordercli --help ``` -If the release assets are complete but the handoff fails, fix the reported cause and rerun only the failed Homebrew job. The legacy `scripts/release-homebrew.sh` prints source archive fields and is not used for the binary formula. +If the release assets are complete but the handoff fails, fix the reported cause and rerun the failed jobs on the original release run. Do not rebuild or replace published signed assets. diff --git a/scripts/check-macos-target b/scripts/check-macos-target new file mode 100755 index 0000000..e0f4017 --- /dev/null +++ b/scripts/check-macos-target @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Reject release binaries whose deployment target differs from macOS 13.""" + +import argparse +import re +import subprocess + + +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 or command[1] != "LC_BUILD_VERSION": + continue + 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") + target = re.search(r"(?m)^\s*minos (\d+\.\d+(?:\.\d+)?)\s*$", block) + if not target or tuple(map(int, target[1].split("."))) not in ((13, 0), (13, 0, 0)): + raise ValueError("LC_BUILD_VERSION minos must equal documented minimum 13.0") + targets.append(target[1]) + if not targets: + raise ValueError("missing macOS LC_BUILD_VERSION deployment target") + return targets + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("binary") + parser.add_argument("--goos", choices=("darwin", "linux", "windows"), default="darwin") + args = parser.parse_args() + if args.goos != "darwin": + return + try: + result = subprocess.run( + ["otool", "-arch", "all", "-l", args.binary], + 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"{args.binary}: macOS minos {', '.join(targets)} == 13.0") + + +if __name__ == "__main__": + main() diff --git a/scripts/check-release-metadata b/scripts/check-release-metadata new file mode 100755 index 0000000..2c001a0 --- /dev/null +++ b/scripts/check-release-metadata @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""Keep the built-in CLI version, changelog, and requested release aligned.""" + +import re +import sys +from pathlib import Path + +source = Path("internal/version/version.go").read_text() +match = re.search(r'(?m)^const Version = "([0-9]+\.[0-9]+\.[0-9]+)"$', source) +if not match: + raise SystemExit("could not resolve internal/version/version.go") +version = match[1] +changelog = re.search(r"(?m)^## ([0-9]+\.[0-9]+\.[0-9]+) \([0-9]{4}-[0-9]{2}-[0-9]{2}\)$", Path("CHANGELOG.md").read_text()) +if not changelog or changelog[1] != version: + raise SystemExit(f"latest finalized changelog must match CLI version {version}") +if len(sys.argv) > 2 or (len(sys.argv) == 2 and sys.argv[1].removeprefix("v") != version): + raise SystemExit(f"requested release must match CLI version {version}") +print(f"release metadata matches v{version}") diff --git a/scripts/release-homebrew.sh b/scripts/release-homebrew.sh deleted file mode 100755 index b85ec02..0000000 --- a/scripts/release-homebrew.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -version="${1:-}" -if [[ -z "${version}" ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -url="https://github.com/steipete/ordercli/archive/refs/tags/v${version}.tar.gz" -tmp="/tmp/ordercli-${version}.tar.gz" - -curl -L -o "${tmp}" "${url}" >/dev/null -sha256="$(shasum -a 256 "${tmp}" | awk '{print $1}')" - -cat <" >&2 + exit 2 +fi + +version=${1#v} +changelog=${CHANGELOG_FILE:-CHANGELOG.md} + +awk -v version="$version" ' + function is_target(line) { + return line == "## " version || index(line, "## " version " ") == 1 + } + is_target($0) { found = 1 } + found && /^## / && !is_target($0) { exit } + found { print } + END { + if (!found) { + exit 3 + } + } +' "$changelog" diff --git a/scripts/test_macos_target.py b/scripts/test_macos_target.py new file mode 100644 index 0000000..ddd6dba --- /dev/null +++ b/scripts/test_macos_target.py @@ -0,0 +1,28 @@ +"""Regression coverage for the actual release artifact deployment gate.""" + +import runpy +import unittest +from pathlib import Path + +check_target = runpy.run_path(str(Path(__file__).with_name("check-macos-target")))["check_target"] + + +def load_command(version="13.0", platform="MACOS"): + return f"Load command 1\n cmd LC_BUILD_VERSION\n platform {platform}\n minos {version}\n" + + +class MacOSTargetTest(unittest.TestCase): + def test_accepts_macos_13(self): + self.assertEqual(check_target(load_command()), ["13.0"]) + self.assertEqual(check_target(load_command("13.0.0", "1")), ["13.0.0"]) + + def test_rejects_mismatched_or_missing_metadata(self): + for commands in ("", load_command("14.0"), load_command("12.0"), + load_command(platform="IOS"), load_command("invalid"), + load_command() + load_command("15.0")): + with self.subTest(commands=commands), self.assertRaises(ValueError): + check_target(commands) + + +if __name__ == "__main__": + unittest.main()