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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
branches: ["main"]
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -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
130 changes: 38 additions & 92 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
/foodcli
/foodoracli
/ordercli
/dist/

# keep source under cmd/
!cmd/
Expand Down
5 changes: 5 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 17 additions & 8 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,39 @@ summary: 'Release checklist for ordercli (GitHub release + Homebrew tap)'

# Releasing ordercli

Releases use an annotated `v<version>` 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 <version>`.
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<version>` 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_<version>_<os>_<arch>` 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 <version>` 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<version> -m "Release <version>"`.
- Push only that tag: `git push origin v<version>`.
- Dispatch `gh workflow run release.yml --ref main -f version=<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 <version>` 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<version>`; 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;" <path>` and show it with `xattr -l`.
- Require `codesign -dvv <binary>` to show Peter Steinberger's Developer ID and `TeamIdentifier=Y5PE65HELJ`, then run `codesign --verify --deep --strict --verbose=4 <binary>` and `spctl -a -vv -t open --context context:primary-signature <binary>` (accepted, Notarized Developer ID).
- Run `./scripts/check-macos-target <binary>` and inspect `otool -l <binary>` 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<version>` 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`.
6 changes: 3 additions & 3 deletions docs/releasing-homebrew.md
Original file line number Diff line number Diff line change
@@ -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_<version>_<target>.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

Expand All @@ -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.
46 changes: 46 additions & 0 deletions scripts/check-macos-target
Original file line number Diff line number Diff line change
@@ -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()
18 changes: 18 additions & 0 deletions scripts/check-release-metadata
Original file line number Diff line number Diff line change
@@ -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}")
20 changes: 0 additions & 20 deletions scripts/release-homebrew.sh

This file was deleted.

24 changes: 24 additions & 0 deletions scripts/release-notes
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/bin/sh
set -eu

if [ "$#" -ne 1 ]; then
echo "usage: $0 <tag>" >&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"
Loading