From ff1ae370b4b1073bef09341eea3053c6b99e5e7f Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Wed, 3 Jun 2026 15:07:24 -0400 Subject: [PATCH 1/4] docs: document the macOS code-signing identity convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add distribution.md §2A (macOS code-signing identity): the ad-hoc/cdhash re-prompt rationale, the org.open-cli-collective. identifier invariant, the centralized goreleaser hooks.post snippet, and the check-signature acceptance gate. Extend §6 with the four MACOS_CERT_* secrets and the explicit four-line secrets: pass-through rule (never inherit). Cross-reference from release.md §4 and ci.md §4. Closes #51 --- docs/ci.md | 4 ++- docs/distribution.md | 73 ++++++++++++++++++++++++++++++++++++++++++++ docs/release.md | 7 +++-- 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/docs/ci.md b/docs/ci.md index 9a58a74..5307b30 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -120,7 +120,9 @@ A CLI that skips macOS-CGO can ship a darwin binary with no Keychain support; a CLI that skips Windows can ship a binary that doesn't compile — and not notice until a user's release breaks. The release-time Mach-O verification gate (`distribution.md` §2) is the second line of defense for darwin; CI is the -first, and the *only* one for Windows. **Today only `cli-common` runs Windows in +first, and the *only* one for Windows. (That same release-time darwin path also +code-signs each binary with a stable identity and gates on the designated +requirement — `distribution.md` §2A.) **Today only `cli-common` runs Windows in CI** — every shipping CLI builds Windows solely at release via goreleaser, so a Windows-only break is invisible pre-release (§8). diff --git a/docs/distribution.md b/docs/distribution.md index 472883a..5f91f61 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -74,6 +74,67 @@ using credstore's Keychain backend MUST gate the release on both. --- +## §2A macOS code-signing identity + +macOS Keychain "Always Allow" grants do **not** trust a file — they store a +**designated requirement (DR)** in the Keychain item's ACL and re-check the calling +binary against it on every access. An **ad-hoc-signed** binary (Go's linker +ad-hoc-signs arm64; Homebrew ad-hoc-signs on install) has the DR `cdhash H"…"` — the +literal content hash — so every `brew upgrade` produces a new hash, the DR no longer +matches, and the user is re-prompted. Any CLI whose `keychain_probe` (§8) selects the +macOS Keychain backend has this problem. + +**Standard: sign every darwin binary at release time with the family's single +self-signed code-signing cert and a constant per-tool identifier.** That makes the DR +`identifier "org.open-cli-collective." and certificate leaf = H""` +— no `cdhash` — so the grant survives rebuilds **as long as the cert and identifier +never change.** No Apple Developer account, no notarization: Homebrew binaries aren't +quarantined and we establish a local identity, not Gatekeeper trust — so `codesign` +MUST omit `--timestamp` and `--options runtime`. + +**Invariants (unforgiving):** + +- The signing **cert is generated once and reused forever.** Never generate a cert in + CI — a fresh cert changes the leaf hash and re-breaks every existing grant. +- The **identifier is `org.open-cli-collective.`, constant per tool across all + versions.** Changing it changes the DR. + +**Where the logic lives (centralized — a repo adds one line).** Signing MUST run inside +goreleaser's build lifecycle, in the per-build `hooks.post` (after each binary is +linked, **before** it is archived/checksummed — signing the loose `dist/` binary +afterward would leave the shipped tarball unsigned). The script, cert import, identifier +derivation, and DR verification are centralized in `open-cli-collective/.github`: +`actions/macos-codesign-setup` writes the canonical `codesign-darwin.sh` and exports +`CODESIGN_DARWIN_SCRIPT`; `actions/darwin-gate check-signature` enforces the DR. Each +darwin build adds exactly one byte-identical hook: + +```yaml + hooks: + post: + - cmd: bash -c 'f="${CODESIGN_DARWIN_SCRIPT:-}"; [ -n "$f" ] && [ -x "$f" ] && exec "$f" "$0" "$1"; echo "skip codesign (no CODESIGN_DARWIN_SCRIPT)"' "{{ .Path }}" "{{ .Os }}" +``` + +The hook uses the **absolute** `$CODESIGN_DARWIN_SCRIPT` (a build hook's CWD is the +build's `dir:`, e.g. `tools/cfl`, so a repo-relative path would miss) and no-ops in +local builds where the env is unset. Signing setup and `check-signature` enforcement +are both **self-gated on whether the cert secrets were passed** (`secrets.*` is not +available in `if:`, so the gate lives inside the composites, keyed on their inputs): +both off for a caller that hasn't opted in, both on the moment it passes the four +secrets — which keeps a rolling `@v1` bump from breaking untouched callers. + +**Verification (the acceptance criterion).** `check-signature` parses **only** the +`designated => …` requirement line of `codesign -d -r-` and fails the release unless it +pins `certificate leaf = H""` and `identifier +"org.open-cli-collective."` with **no** `cdhash`. Never grep the whole verbose +dump — a valid signature still prints `CDHash=` metadata, which is *not* the +requirement-language `cdhash`. + +**Expect one final re-prompt per machine per tool** the first time a stable-signed build +replaces the old ad-hoc one (the Keychain item still holds the old ad-hoc DR); the user +grants once more and it then sticks. Note this in the release notes. + +--- + ## §3 macOS — Homebrew cask - Published to the shared tap **`open-cli-collective/homebrew-tap`** as @@ -194,12 +255,24 @@ listed in its README. | `CHOCOLATEY_API_KEY` | chocolatey publish (§4.2) | | `WINGET_GITHUB_TOKEN` | winget-pkgs submission (§4.1) | | `LINUX_PACKAGES_DISPATCH_TOKEN` | the §5.2 `repository_dispatch` | +| `MACOS_CERT_P12` | base64 of the code-signing cert+key `.p12` (§2A) | +| `MACOS_CERT_PASSWORD` | the `.p12` export password (§2A) | +| `MACOS_CERT_CN` | the cert Common Name = the `codesign --sign` identity (§2A) | +| `MACOS_CERT_LEAF_SHA` | the leaf SHA-1 the DR must pin, asserted by `check-signature` (§2A) | The GPG signing keys (`LINUX_PACKAGES_GPG_*`) live in the `linux-packages` repo, not in the CLI repos. `TAP_GITHUB_TOKEN` is intentionally scoped to Homebrew tap publishing; auto-release tag pushes use `RELEASE_TAG_TOKEN` or a GitHub App token and MUST NOT reuse the tap credential. +The four `MACOS_CERT_*` secrets (§2A) are **org-level** — one self-signed cert shared +by every keychain-backed tool. Each caller forwards them to the reusable workflow with +an **explicit four-line `secrets:` pass-through** (`macos-cert-p12|password|cn|leaf-sha`), +**never** `secrets: inherit`: the existing `secrets:` blocks remap names (e.g. +`TAP_GITHUB_TOKEN`→`homebrew-tap-token`), and `inherit` can't remap, so it would blank +those inputs and break the release. `_CN`/`_LEAF_SHA` aren't truly secret (both are +embedded in every signed binary) but are kept as secrets for uniform pass-through wiring. + --- ## §7 Snap — decommissioned diff --git a/docs/release.md b/docs/release.md index fa67c10..7d50881 100644 --- a/docs/release.md +++ b/docs/release.md @@ -138,9 +138,10 @@ and reserve `TAP_GITHUB_TOKEN` for Homebrew tap pushes (§7). `.github/workflows/release.yml` triggers on tag push matching `v*`. It runs goreleaser to build and publish. **The build matrix, the CGO-darwin -verification gate, and every publish channel are owned by `distribution.md`** — -`release.md`'s responsibility is only that a correctly-formed tag exists and -`release.yml` fires on it. +verification gate, the macOS code-signing identity + `check-signature` gate +(`distribution.md` §2A), and every publish channel are owned by +`distribution.md`** — `release.md`'s responsibility is only that a +correctly-formed tag exists and `release.yml` fires on it. ### §4.1 Release recovery and idempotency From 0e27ad5c9780fbd51d1e9f8eae5f88b72fe4eccb Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Wed, 3 Jun 2026 15:10:06 -0400 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20correct=20stale=20'unsigned'=20word?= =?UTF-8?q?ing=20in=20=C2=A73=20cask=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §2A now makes release-time signing the standard, so the cask's quarantine removal applies to the non-notarized (not unsigned) binary. --- docs/distribution.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/distribution.md b/docs/distribution.md index 5f91f61..2644ae6 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -142,7 +142,8 @@ grants once more and it then sticks. Note this in the release notes. new CLIs set it to the binary short name, e.g. `slck` — grandfathered tools may differ). - A **cask, not a formula** — we ship a prebuilt binary, not a source build. The - cask also handles Gatekeeper quarantine removal for the unsigned binary. The + cask also handles Gatekeeper quarantine removal for the non-notarized binary (signed per §2A, but not +notarized — Gatekeeper still quarantines downloads). The tap's `Formula/` directory is **deprecated** (cask-only since 2026-01-16); new CLIs MUST NOT add a formula, and the surviving `Formula/*.rb` are legacy remnants (§10). From 4e8f9a57d7d92ef386c219135e815da9edd2e3f2 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Wed, 3 Jun 2026 15:16:26 -0400 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20add=20cert-expiry=20invariant=20to?= =?UTF-8?q?=20=C2=A72A;=20fix=20=C2=A73=20list=20indentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document that the DR pins the leaf hash (not expiry) so an expired cert doesn't break existing grants, but new builds can't be signed with it. Reflow the §3 cask continuation lines to keep the CommonMark bullet indentation. --- docs/distribution.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/distribution.md b/docs/distribution.md index 2644ae6..463592d 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -96,6 +96,11 @@ MUST omit `--timestamp` and `--options runtime`. - The signing **cert is generated once and reused forever.** Never generate a cert in CI — a fresh cert changes the leaf hash and re-breaks every existing grant. +- **Cert validity is long but finite.** The DR pins the leaf *hash*, not expiry, so an + expired cert does **not** break existing grants — but `codesign` refuses to sign new + builds with an expired cert. Replacing the cert is its only expiry remedy, and that + new leaf hash re-breaks every grant (one re-prompt per user), so pick a long life up + front and don't let it lapse mid-life (current cert expires 2036). - The **identifier is `org.open-cli-collective.`, constant per tool across all versions.** Changing it changes the DR. @@ -142,9 +147,9 @@ grants once more and it then sticks. Note this in the release notes. new CLIs set it to the binary short name, e.g. `slck` — grandfathered tools may differ). - A **cask, not a formula** — we ship a prebuilt binary, not a source build. The - cask also handles Gatekeeper quarantine removal for the non-notarized binary (signed per §2A, but not -notarized — Gatekeeper still quarantines downloads). The - tap's `Formula/` directory is **deprecated** (cask-only since 2026-01-16); new + cask also handles Gatekeeper quarantine removal for the non-notarized binary + (signed per §2A, but not notarized — Gatekeeper still quarantines downloads). + The tap's `Formula/` directory is **deprecated** (cask-only since 2026-01-16); new CLIs MUST NOT add a formula, and the surviving `Formula/*.rb` are legacy remnants (§10). - **Standard: goreleaser `homebrew_casks`.** goreleaser owns the canonical cask From 0864efd554195990f0a180c479971f38c6962c08 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Wed, 3 Jun 2026 15:17:59 -0400 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20simplify=20=C2=A73=20quarantine=20w?= =?UTF-8?q?ording=20to=20avoid=20=C2=A72A=20contradiction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State the cask removes any quarantine metadata for the non-notarized binary, without the Gatekeeper-quarantines claim that read as contradicting §2A. --- docs/distribution.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/distribution.md b/docs/distribution.md index 463592d..82570f6 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -147,9 +147,9 @@ grants once more and it then sticks. Note this in the release notes. new CLIs set it to the binary short name, e.g. `slck` — grandfathered tools may differ). - A **cask, not a formula** — we ship a prebuilt binary, not a source build. The - cask also handles Gatekeeper quarantine removal for the non-notarized binary - (signed per §2A, but not notarized — Gatekeeper still quarantines downloads). - The tap's `Formula/` directory is **deprecated** (cask-only since 2026-01-16); new + cask also removes any quarantine metadata for the non-notarized binary (signed + per §2A, but not notarized). The tap's `Formula/` directory is **deprecated** + (cask-only since 2026-01-16); new CLIs MUST NOT add a formula, and the surviving `Formula/*.rb` are legacy remnants (§10). - **Standard: goreleaser `homebrew_casks`.** goreleaser owns the canonical cask