diff --git a/CHANGELOG.md b/CHANGELOG.md index d9c13b9..ca7fe4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Homebrew: install through a Formula (`brew install openclaw/tap/goplaces`) instead of the retired Cask; quarantine is preserved. + ## 0.4.11 - 2026-09-13 - Release tooling: fix the Developer ID signing canary under the isolated producer home so signed macOS binaries publish again; v0.4.10 was tagged but shipped no binaries, and 0.4.11 contains everything listed under 0.4.10. diff --git a/README.md b/README.md index 2b86315..f14da1e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![GitHub release](https://img.shields.io/github/v/release/openclaw/goplaces?style=flat-square)](https://github.com/openclaw/goplaces/releases/latest) [![Go](https://img.shields.io/badge/Go-1.26.8-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev/dl/) [![License](https://img.shields.io/github/license/openclaw/goplaces?style=flat-square)](LICENSE) -[![Homebrew](https://img.shields.io/badge/Homebrew-openclaw%2Ftap-FBB040?style=flat-square&logo=homebrew&logoColor=black)](https://github.com/openclaw/homebrew-tap/blob/main/Casks/goplaces.rb) +[![Homebrew](https://img.shields.io/badge/Homebrew-openclaw%2Ftap-FBB040?style=flat-square&logo=homebrew&logoColor=black)](https://github.com/openclaw/homebrew-tap/blob/main/Formula/goplaces.rb) [![Docs](https://img.shields.io/badge/docs-goplaces.sh-3b82f6?style=flat-square)](https://goplaces.sh) `goplaces` is a Go client and CLI for Google Places API (New) and selected Routes API workflows. It is for terminal use, shell automation, agents, and Go programs that need typed requests and JSON output without hand-building field masks or request payloads. @@ -16,9 +16,11 @@ Homebrew installs the published binary on macOS or Linux: ```sh -brew install --cask openclaw/tap/goplaces +brew install openclaw/tap/goplaces ``` +If you installed the retired Cask, run `brew uninstall --cask goplaces` first, then install the Formula. + With Go 1.26.8 or newer: ```sh diff --git a/docs/index.html b/docs/index.html index 8573663..b8633fe 100644 --- a/docs/index.html +++ b/docs/index.html @@ -39,7 +39,7 @@

Places &
routes, in Go.

$ - brew install --cask openclaw/tap/goplaces + brew install openclaw/tap/goplaces
@@ -199,11 +199,11 @@

Three steps to your first call

-
shell
# Install via the OpenClaw tap
-$ brew install --cask openclaw/tap/goplaces
+$ brew install openclaw/tap/goplaces
 $ export GOOGLE_PLACES_API_KEY="..."
 $ goplaces search "bookstore"
diff --git a/docs/releasing-homebrew.md b/docs/releasing-homebrew.md index 53b090e..27dc55b 100644 --- a/docs/releasing-homebrew.md +++ b/docs/releasing-homebrew.md @@ -1,17 +1,19 @@ # goplaces Homebrew Release Playbook -This is the required `verified-hashes-v1` handoff contract for a published, notarized goplaces release. It is not currently runnable; see [Current Blocker](#current-blocker). Do not mutate `openclaw/homebrew-tap` or change public install instructions until that blocker is resolved under a serialized gate. +This is the required `verified-hashes-v1` Formula handoff contract for a published, notarized goplaces release. Run `scripts/release-local homebrew vX.Y.Z` under the serialized release gate before release closeout advances `main`. The command targets `Formula/goplaces.rb`; local release preparation must not generate a Cask or publish Homebrew metadata. ## Frozen Tap Trust Anchor - Repository: `openclaw/homebrew-tap`. -- Pinned base commit: `45b93a0b3de27e46b636a0cef819fb1ecef25bcd`. +- Pinned base commit: `104616d9828cf28202bccff19c0738f179c2a3f8`. - Workflow: `.github/workflows/update-formula.yml`. - Workflow numeric ID: `220664022`. - Updater: `.github/scripts/update_formula.py`. - Required updater marker: `# verified-hashes-v1`. -The protected default branch, workflow bytes, updater bytes, and marker must match this pinned contract before dispatch. A newer tap commit is not implicitly trusted. +This base includes the [Formula migration](https://github.com/openclaw/homebrew-tap/pull/56) and the [literal-URL updater fix](https://github.com/openclaw/homebrew-tap/pull/57). Verified updates must retain literal release URLs so Homebrew can infer the version without an explicit version declaration. + +The protected default branch, workflow bytes, updater bytes (including `.github/scripts/formula_text.py` at the pinned commit), and marker must match this pinned contract before dispatch. A newer tap commit is not implicitly trusted. ## Required Handoff @@ -21,7 +23,7 @@ Only begin after the GitHub release is published and the newest protected-main A 2. Recheck the goplaces signed tag object, peeled commit, protected `main`, release ID, asset IDs, sizes, digests, and newest verifier run. 3. Dispatch the pinned tap workflow from protected current default `main` with GitHub API version `2026-03-10`. Supply `formula=goplaces`, the release tag, source repository, explicit artifact template, all four platform hashes, source tag object, source tag commit, and a unique request ID. 4. Require the live tap workflow’s numeric ID to remain exactly `220664022`. Require the run record’s canonical path to equal `.github/workflows/update-formula.yml`; pin the protected tap branch and head SHA separately. Snapshot existing run IDs, consume the response’s numeric `workflow_run_id`, reject any pre-existing or concurrent substitution, watch that exact run, and recheck the same canonical path after it completes. -5. Require the workflow head SHA to equal protected current tap default, and require exactly one new tap commit. That commit must be the direct child of the pinned base and carry the exact updater-defined provenance trailers for the source repository, tag object, tag commit, and request ID. The four verified hashes remain bound by the dispatch record and the exact Formula bytes checked next. +5. Require the workflow head SHA to equal protected current tap default, and require exactly one new tap commit. That commit must be the direct child of the pinned base and carry the exact updater-defined provenance trailers for the source repository, tag object, tag commit, and request ID. The four verified hashes remain bound by the dispatch record and the exact Formula bytes checked next. An already-current Formula still receives one provenance commit with an unchanged tree; otherwise only `Formula/goplaces.rb` may change. 6. Read `Formula/goplaces.rb` from that exact tap commit, not from a moving branch. Require the exact version, four release URLs, and four verified hashes. The tap must not rebuild, resign, repackage, mirror, or substitute release assets. 7. Install from a clean checkout pinned to that exact tap commit. Installed binary bytes must equal the corresponding verified release archive member. Verify architecture, reported version, signing identity, Team ID, canonical designated requirement, hardened runtime, secure timestamp, and online notarization. 8. Run the package test, then recheck the goplaces tag refs, source default branch, tap base, tap commit, workflow/updater bytes, and exact live tap default-branch head as the final external closeout action. Any movement fails closed. @@ -32,27 +34,30 @@ Each gated attempt persists mode-`0400` dispatch intent, bound-run, tap-result, An unbound intent with no exact run after the bounded, fully paginated reconciliation window fails closed and sends no second dispatch. Keep the serialized gate held and preserve the state. Do not delete the intent merely because the API is empty; a manual reset is allowed only after an independent review proves the POST never occurred and records that proof. Otherwise leave the intent intact and escalate the release as blocked. -## Current Blocker - -At pinned tap commit `45b93a0b3de27e46b636a0cef819fb1ecef25bcd`, `Formula/goplaces.rb` does not exist. The generic `verified-hashes-v1` updater accepts Formula input and therefore cannot update the existing `Casks/goplaces.rb`. - -The current published Cask also strips `com.apple.quarantine`. Removing that generator behavior from local goplaces release preparation is necessary, but it does not rewrite the already-published tap entry and does not make the generic Formula handoff work. +## Installation and Migration -Therefore: - -- do not dispatch the tap workflow for goplaces; -- do not claim exact-run, exact-commit, install-byte, signature, or notarization proof through Homebrew; -- do not change README or website installation text away from the currently published Cask command; -- resolve the tap side under separate review. Either the compatible Formula/Cask change must occur within the one authorized direct-child handoff commit, or the reviewed tap change must establish a new pinned base and updated trust anchor before dispatch. Never insert a pre-dispatch tap commit while continuing to claim the old pinned base. - -After the blocker is resolved and the full handoff passes, public install text can change to: +The retired `Casks/goplaces.rb` is absent. Install the Formula with: ```bash brew install openclaw/tap/goplaces ``` -Until then, the existing published-install documentation remains: +For an existing Cask installation, remove it before installing the Formula: ```bash -brew install --cask openclaw/tap/goplaces +brew uninstall --cask goplaces +brew install openclaw/tap/goplaces ``` + +## Handoff After Release Closeout + +The producer currently requires the release tag commit to equal protected current `main`, and its draft-to-published identity comparison still includes GitHub's changing `browser_download_url`. The v0.4.11 publication recovery recorded both limitations before this Formula migration. Do not move the tag, overwrite frozen release records, republish assets, or claim that the producer command passed when it did not. + +For an already-published release such as v0.4.11, an equivalent serialized handoff must retain the checks above, with these explicit bindings: + +- Freeze current protected source `main` independently from the original signed release commit; verify raw ancestry and that the release verifier workflow, signer policy, and verification scripts are unchanged since the successful published-asset proof. +- Revalidate the newest successful published-asset run at its original protected-main SHA, both native proof markers, and the exact frozen numeric asset inventory. Validate canonical URLs for each state, allowing only the documented draft-to-tag URL transition when comparing the original draft with the published record. +- Download and verify all published assets twice, derive all four handoff hashes from the second copy, then dispatch the pinned updater once with a unique request ID and bind the returned numeric run ID. Preserve read-only intent, run, result, installation, and completion receipts beside the original release state. +- Verify one direct-child provenance commit, exact Formula bytes, an install from that commit, binary equality, package test, signature and notarization, and final unchanged source/tag/tap identities. A missing or ambiguous dispatch response requires reconciliation, never a blind retry. + +This recovery is a Homebrew handoff only. The original Apple-silicon and Intel proofs remain bound to their original release commit; they are not represented as fresh CI runs on the later documentation commit. diff --git a/docs/releasing.md b/docs/releasing.md index f1683df..45f60a4 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -81,9 +81,9 @@ After publication: - verify every public archive and checksum against the frozen release record; - verify `go install github.com/steipete/goplaces/cmd/goplaces@vX.Y.Z` reports `X.Y.Z`, then retry `@latest` after the module proxy catches up; -- complete the Homebrew handoff only if the separate gate in [releasing-homebrew.md](releasing-homebrew.md) is unblocked; +- complete the Formula handoff under the separate gate in [releasing-homebrew.md](releasing-homebrew.md); - reopen the next patch section as `Unreleased`, commit that closeout separately, pull with `--ff-only`, and finish on clean synchronized `main`. ## Homebrew Status -The pinned `verified-hashes-v1` tap handoff is currently blocked for goplaces. Do not run or claim Homebrew handoff proof until [the documented blocker](releasing-homebrew.md#current-blocker) is resolved under its own serialized gate. Until then, public installation text remains the already-published Cask command. +The pinned `verified-hashes-v1` handoff updates `Formula/goplaces.rb` in `openclaw/homebrew-tap`. The Formula installs published archives without rebuilding or stripping quarantine. GoReleaser does not generate or publish Homebrew metadata. Follow [the Homebrew playbook](releasing-homebrew.md), including its recovery procedure when release closeout has already advanced `main`. diff --git a/scripts/release-local b/scripts/release-local index 0c51880..f817a11 100755 --- a/scripts/release-local +++ b/scripts/release-local @@ -13,7 +13,7 @@ readonly EXPECTED_GO_VERSION="go1.26.8" readonly VERIFIER_WORKFLOW=".github/workflows/release-assets.yml" readonly VERIFIER_WORKFLOW_ID="311062804" readonly TAP_REPOSITORY="openclaw/homebrew-tap" -readonly TAP_BASE="45b93a0b3de27e46b636a0cef819fb1ecef25bcd" +readonly TAP_BASE="104616d9828cf28202bccff19c0738f179c2a3f8" readonly TAP_WORKFLOW=".github/workflows/update-formula.yml" readonly TAP_WORKFLOW_ID="220664022" readonly TAP_UPDATER=".github/scripts/update_formula.py" @@ -2452,7 +2452,6 @@ tap_trust_preflight() { decode_api_content "$current_updater" "$updater_bytes" grep -Fqx '# verified-hashes-v1' "$updater_bytes" || die "tap updater lacks the exact verified-hashes-v1 marker" - # Current contract is expected to stop here until the gated Formula seed lands. fetch_content_record "$TAP_REPOSITORY" Formula/goplaces.rb "$tap_head" "$current_formula" || die "Homebrew blocked: Formula/goplaces.rb is absent at protected current tap" formula_file="${output_dir}/goplaces.rb" decode_api_content "$current_formula" "$formula_file" @@ -2772,16 +2771,37 @@ formula_pair_count() { ' "$file" } +validate_formula_version() { + # Tokenize without executing the Formula or treating strings/comments as DSL calls. + /usr/bin/env -i PATH="$SECRET_SCOPE_SYSTEM_PATH" /usr/bin/ruby --disable-gems -rripper - "$1" "$2" <<'RUBY' || die "tap Formula has an invalid or duplicate version declaration" +source = File.read(ARGV.fetch(0)) +abort "invalid Formula Ruby syntax" unless Ripper.sexp(source) +expected = %(version "#{ARGV.fetch(1)}") +ignored = [:on_sp, :on_nl, :on_ignored_nl, :on_comment] +tokens = Ripper.lex(source).reject { |token| ignored.include?(token[1]) } +count = 0 +tokens.each_with_index do |token, index| + next unless token[1] == :on_ident && token[2] == "version" + following = tokens[index + 1] + next if following && following[1] == :on_period && following[2] == "." + previous = index.positive? ? tokens[index - 1] : nil + abort "noncanonical Formula version call" if previous && previous[1] == :on_period + abort "noncanonical Formula version call" unless source.lines[token[0][0] - 1].strip == expected + count += 1 +end +abort "duplicate Formula version calls" if count > 1 +RUBY +} + validate_formula_content() { - local file="$1" tag="$2" darwin_amd64="$3" darwin_arm64="$4" linux_amd64="$5" linux_arm64="$6" version url_count digest_count version_count + local file="$1" tag="$2" darwin_amd64="$3" darwin_arm64="$4" linux_amd64="$5" linux_arm64="$6" version url_count digest_count local darwin_amd64_url darwin_arm64_url linux_amd64_url linux_arm64_url version="$(version_from_tag "$tag")" darwin_amd64_url="https://github.com/${REPOSITORY}/releases/download/${tag}/goplaces_${version}_darwin_amd64.tar.gz" darwin_arm64_url="https://github.com/${REPOSITORY}/releases/download/${tag}/goplaces_${version}_darwin_arm64.tar.gz" linux_amd64_url="https://github.com/${REPOSITORY}/releases/download/${tag}/goplaces_${version}_linux_amd64.tar.gz" linux_arm64_url="https://github.com/${REPOSITORY}/releases/download/${tag}/goplaces_${version}_linux_arm64.tar.gz" - version_count="$(/usr/bin/awk -v expected="version \"${version}\"" '{line=$0; sub(/[[:space:]]*#.*/, "", line); sub(/^[[:space:]]+/, "", line); sub(/[[:space:]]+$/, "", line); if (line == expected) count++} END {print count+0}' "$file")" - [[ "$version_count" == 1 ]] || die "tap Formula lacks one exact active version declaration" + validate_formula_version "$file" "$version" url_count="$(/usr/bin/awk '{line=$0; sub(/[[:space:]]*#.*/, "", line); sub(/^[[:space:]]+/, "", line); if (line ~ /^url "/) count++} END {print count+0}' "$file")" digest_count="$(/usr/bin/awk '{line=$0; sub(/[[:space:]]*#.*/, "", line); sub(/^[[:space:]]+/, "", line); if (line ~ /^sha256 "/) count++} END {print count+0}' "$file")" [[ "$url_count" == 4 && "$digest_count" == 4 ]] || die "tap Formula must contain exactly four active URL/SHA stanzas" @@ -2804,6 +2824,18 @@ validate_tap_commit_record() { [[ "$message" == "$expected_message" ]] || die "tap commit message differs from the pinned updater contract" } +validate_tap_compare_record() { + local file="$1" old_head="$2" new_head="$3" + jq -e --arg old "$old_head" --arg new "$new_head" ' + select(.status == "ahead" and .ahead_by == 1 and .behind_by == 0 and .total_commits == 1) | + select(.base_commit.sha == $old and .merge_base_commit.sha == $old and .head_commit.sha == $new) | + select((.commits | length) == 1 and .commits[0].sha == $new) | + select((.files | type) == "array") | + select((.files | length) == 0 or + ((.files | length) == 1 and .files[0].filename == "Formula/goplaces.rb" and (.files[0].status == "added" or .files[0].status == "modified"))) + ' "$file" >/dev/null || die "tap result is not one exact direct-child Formula commit" +} + verify_tap_commit() { local work="$1" tag="$2" request_id="$3" old_head="$4" darwin_amd64="$5" darwin_arm64="$6" linux_amd64="$7" linux_arm64="$8" local branch_json new_head compare_json commit_json expected_message formula_json result_formula @@ -2820,12 +2852,7 @@ verify_tap_commit() { [[ "$new_head" != "$old_head" ]] || die "tap workflow did not create a commit" gh_api "repos/${TAP_REPOSITORY}/compare/${old_head}...${new_head}" > "$compare_json" - jq -e --arg old "$old_head" --arg new "$new_head" ' - select(.status == "ahead" and .ahead_by == 1 and .behind_by == 0 and .total_commits == 1) | - select(.base_commit.sha == $old and .merge_base_commit.sha == $old and .head_commit.sha == $new) | - select((.commits | length) == 1 and .commits[0].sha == $new) | - select((.files | length) == 1 and .files[0].filename == "Formula/goplaces.rb" and (.files[0].status == "added" or .files[0].status == "modified")) - ' "$compare_json" >/dev/null || die "tap result is not one exact direct-child Formula commit" + validate_tap_compare_record "$compare_json" "$old_head" "$new_head" gh_api "repos/${TAP_REPOSITORY}/commits/${new_head}" > "$commit_json" expected_message="goplaces: update formula for ${tag} diff --git a/scripts/test-release-local.sh b/scripts/test-release-local.sh index e99a977..13e5fda 100755 --- a/scripts/test-release-local.sh +++ b/scripts/test-release-local.sh @@ -202,7 +202,7 @@ EOF export GOPLACES_RELEASE_LOCAL_TESTING=1 GOPLACES_RELEASE_LOCAL_SOURCE_ONLY=1 source "$1"; homebrew_command --debug install --formula hostile.rb ' _ "$release_script" - grep -Fq 'readonly TAP_BASE="45b93a0b3de27e46b636a0cef819fb1ecef25bcd"' "$release_script" || die "tap trust base is not pinned" + grep -Fq 'readonly TAP_BASE="104616d9828cf28202bccff19c0738f179c2a3f8"' "$release_script" || die "tap trust base is not pinned" grep -Fq 'readonly TAP_WORKFLOW_ID="220664022"' "$release_script" || die "tap workflow numeric identity is not pinned" grep -Fq 'readonly TAR_BIN="/usr/bin/bsdtar"' "$release_script" || die "system tar does not name the canonical nonsymlink executable" grep -Fq 'HOMEBREW_NO_INSTALL_FROM_API=1' "$release_script" || die "Homebrew package inventory can trigger API installation" @@ -1007,7 +1007,7 @@ test_publish_is_resumable() { } test_formula_pairs_and_tap_commit_record() { - local scratch formula hostile commit_record bad old_head new_head expected_message + local scratch formula hostile commit_record bad old_head new_head expected_message version_call local darwin_amd64 darwin_arm64 linux_amd64 linux_arm64 scratch="$(mktemp -d "${TMPDIR:-/tmp}/goplaces-formula-contract.XXXXXX")" formula="${scratch}/goplaces.rb" @@ -1047,6 +1047,28 @@ EOF source_release validate_formula_content "$formula" v0.4.5 "$darwin_amd64" "$darwin_arm64" "$linux_amd64" "$linux_arm64" ) + sed '/^ version /d' "$formula" > "$hostile" + (source_release; validate_formula_content "$hostile" v0.4.5 "$darwin_amd64" "$darwin_arm64" "$linux_amd64" "$linux_arm64") + sed 's/version "0.4.5"/version "9.9.9"/' "$formula" > "$hostile" + if (source_release; validate_formula_content "$hostile" v0.4.5 "$darwin_amd64" "$darwin_arm64" "$linux_amd64" "$linux_arm64") >/dev/null 2>&1; then + die "wrong explicit Formula version was accepted" + fi + sed 's/version "0.4.5"/version("9.9.9")/' "$formula" > "$hostile" + if (source_release; validate_formula_content "$hostile" v0.4.5 "$darwin_amd64" "$darwin_arm64" "$linux_amd64" "$linux_arm64") >/dev/null 2>&1; then + die "parenthesized wrong Formula version was accepted" + fi + for version_call in 'self.version("9.9.9")' 'desc "x"; version("9.9.9")'; do + sed "s/version \"0.4.5\"/${version_call}/" "$formula" > "$hostile" + if (source_release; validate_formula_content "$hostile" v0.4.5 "$darwin_amd64" "$darwin_arm64" "$linux_amd64" "$linux_arm64") >/dev/null 2>&1; then + die "noncanonical Formula version call was accepted: $version_call" + fi + done + { printf '# version("9.9.9")\n'; sed 's/version "0.4.5"/desc "version(9.9.9)"/' "$formula"; } > "$hostile" + (source_release; validate_formula_content "$hostile" v0.4.5 "$darwin_amd64" "$darwin_arm64" "$linux_amd64" "$linux_arm64") + { printf ' version "0.4.5"\n'; cat "$formula"; } > "$hostile" + if (source_release; validate_formula_content "$hostile" v0.4.5 "$darwin_amd64" "$darwin_arm64" "$linux_amd64" "$linux_arm64") >/dev/null 2>&1; then + die "duplicate explicit Formula versions were accepted" + fi cat > "$hostile" < "$good" + (source_release; validate_tap_compare_record "$good" "$SHA" "$TAG_OBJECT") + jq '.files = [{filename:"Formula/goplaces.rb",status:"modified"}]' "$good" > "$candidate" + (source_release; validate_tap_compare_record "$candidate" "$SHA" "$TAG_OBJECT") + for change in \ + 'del(.files)' \ + '.files = {}' \ + '.files = [{filename:"Formula/other.rb",status:"modified"}]' \ + '.files = [{filename:"Formula/goplaces.rb",status:"removed"}]' \ + '.files = [{filename:"Formula/goplaces.rb",status:"modified"},{filename:"README.md",status:"modified"}]' \ + '.ahead_by = 2' \ + '.total_commits = 0' \ + '.head_commit.sha = .base_commit.sha'; do + jq "$change" "$good" > "$candidate" + if (source_release; validate_tap_compare_record "$candidate" "$SHA" "$TAG_OBJECT") >/dev/null 2>&1; then + die "invalid provenance-only tap comparison was accepted: $change" + fi + done + rm -rf "$scratch" +} + main() { command -v jq >/dev/null 2>&1 || die "jq is required" command -v shellcheck >/dev/null 2>&1 || die "shellcheck is required" @@ -3213,6 +3265,7 @@ main() { test_draft_intent_is_crash_resumable test_publish_is_resumable test_formula_pairs_and_tap_commit_record + test_tap_compare_accepts_provenance_only_commit test_trusted_ancestry_rejects_graph_overrides test_post_manifest_source_recheck test_production_git_is_pinned