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
4 changes: 3 additions & 1 deletion docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
83 changes: 81 additions & 2 deletions docs/distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,82 @@ 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.<binary>" and certificate leaf = H"<cert hash>"`
— 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`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low (harness-engineering:harness-knowledge-reviewer): The §2A invariants block states the cert is 'generated once and reused forever' but does not address cert expiration. Self-signed certs have a finite validity period; expiry simultaneously breaks every user's 'Always Allow' Keychain grant for every signed tool — a larger blast radius than the per-upgrade ad-hoc rehash problem §2A is solving. The invariants section should note the recommended cert lifetime (e.g. 10–25 years) and the migration path (re-release all tools, expect one re-prompt per user) so operators can plan for eventual rotation.

Reply to this thread when addressed.


**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.
- **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.<binary>`, 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"<MACOS_CERT_LEAF_SHA>"` and `identifier
"org.open-cli-collective.<binary>"` 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
`Casks/<canonical_cask>.rb` (the manifest's `packages.homebrew.canonical_cask`;
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
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
Expand Down Expand Up @@ -194,12 +261,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
Expand Down
7 changes: 4 additions & 3 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading