Skip to content
Open
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
101 changes: 101 additions & 0 deletions .github/SIGNING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Release signing & notarization

Release binaries are code-signed with a Developer ID Application certificate
and notarized with Apple, from the Linux release runner (goreleaser's
`notarize` block, quill under the hood — no macOS runner involved). This
document covers the credentials that make that work: what they are, how to
(re)create them, and what happens if they leak.

## Secrets

All five live in the `release` GitHub environment (protected, `v*` tags only),
and are exposed only to the goreleaser step of `release.yml`. A preflight step
fails the release if they're missing, so a misconfiguration can't silently
ship unsigned binaries.

| Secret | Contents |
| --- | --- |
| `MACOS_SIGN_P12` | base64 of the Developer ID Application cert + private key (`.p12`) |
| `MACOS_SIGN_PASSWORD` | password protecting the `.p12` |
| `MACOS_NOTARY_ISSUER_ID` | App Store Connect API issuer ID (UUID) |
| `MACOS_NOTARY_KEY_ID` | App Store Connect API key ID |
| `MACOS_NOTARY_KEY` | base64 of the App Store Connect API key (`.p8`) |

## Setup / rotation

Team ID: `N58LV5U4Q3` (individual membership, "RYAN ANDREW LEWIS").

### 1. Developer ID Application certificate (.p12)

The cert lives in the login keychain (created via Xcode → Settings → Accounts
→ Manage Certificates). To export:

1. Keychain Access → login keychain → My Certificates.
2. Expand "Developer ID Application: RYAN ANDREW LEWIS (N58LV5U4Q3)", select
the certificate **and** its private key, File → Export Items → `.p12`.
3. Choose a strong password; store it (and ideally the `.p12` itself) in the
password manager.

If notarization later fails with an incomplete-chain error, re-export making
sure the Developer ID intermediate CA is included (quill normally
reconstructs the Apple chain itself, so this is unlikely).

### 2. App Store Connect API key (.p8)

Needed because quill authenticates to the notary API with an ASC API key —
Apple-ID + app-specific-password (the `notarytool store-credentials` route
used for local signing) does not work here.

1. [App Store Connect](https://appstoreconnect.apple.com) → Users and Access
→ Integrations → App Store Connect API → Team Keys → Generate API Key.
2. Role: **Developer** — the least-privileged role that can notarize. Do not
use Admin.
3. Download the `.p8` (single chance), note the **Key ID** and the page-level
**Issuer ID**. Store the `.p8` in the password manager.

### 3. Load the GitHub secrets

```sh
REPO=ryanlewis/things-cli
base64 -i DeveloperID.p12 | gh secret set MACOS_SIGN_P12 --env release --repo "$REPO"
gh secret set MACOS_SIGN_PASSWORD --env release --repo "$REPO" # prompts
gh secret set MACOS_NOTARY_ISSUER_ID --env release --repo "$REPO" --body "<issuer-uuid>"
gh secret set MACOS_NOTARY_KEY_ID --env release --repo "$REPO" --body "<key-id>"
base64 -i AuthKey_<key-id>.p8 | gh secret set MACOS_NOTARY_KEY --env release --repo "$REPO"
```

Then delete any loose `.p12`/`.p8` copies from disk — the password manager
holds the canonical copies.

### 4. Verify after the first signed release

```sh
curl -fsSL https://raw.githubusercontent.com/ryanlewis/things-cli/main/install.sh | INSTALL_DIR=/tmp/things-verify sh
codesign -dvv /tmp/things-verify/things # expect the Developer ID identity + Team ID
codesign --verify --strict /tmp/things-verify/things
xcrun notarytool history --key AuthKey_<key-id>.p8 --key-id <key-id> --issuer <issuer-uuid> # status: Accepted
```

Note: bare Mach-O binaries can't have the notarization ticket stapled
(stapling is app/dmg/pkg only), so Gatekeeper's first-run check does an online
lookup. That's fine for Homebrew/curl installs; it only bites a fully-offline
first run.

## Threat model — if a secret leaks

The release job is the exposure point (this is why its egress is locked down
and the secrets are step-scoped):

- **`.p12` + password**: an attacker can sign arbitrary software as
"RYAN ANDREW LEWIS" until revoked. Revoke the certificate at
[developer.apple.com → Certificates](https://developer.apple.com/account/resources/certificates/list),
then mint a new one and rotate `MACOS_SIGN_P12`/`MACOS_SIGN_PASSWORD`.
Apple can also revoke notarization tickets for known-malicious binaries.
- **ASC API key (Developer role)**: can submit notarizations under the
account (no cert access, can't sign). Revoke it in App Store Connect →
Integrations, generate a fresh key, rotate the three `MACOS_NOTARY_*`
secrets.
- **`HOMEBREW_TAP_GITHUB_TOKEN`**: can push casks to `ryanlewis/homebrew-tap`
only (fine-grained PAT). Revoke/rotate in GitHub settings.

None of these grant access to this repository's code or tags.
57 changes: 51 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,34 @@ jobs:
id-token: write
attestations: write
steps:
# Egress monitoring: this job holds the tap token, which can push
# formulae. Audit mode records every outbound connection per step
# (report linked from the run); flip to egress-policy: block with an
# allowed-endpoints list once a few releases have mapped the baseline.
# Egress lockdown: this job holds code-signing keys and the tap token,
# making it the prime exfiltration target — a compromised dependency or
# action can only talk to the endpoints below. If a legitimate step
# starts failing on a blocked connection, the harden-runner report
# linked from the run names the endpoint; add it here deliberately
# rather than reverting to audit.
- uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
egress-policy: block
allowed-endpoints: >
api.github.com:443
appstoreconnect.apple.com:443
codeload.github.com:443
dl.google.com:443
fulcio.githubapp.com:443
github.com:443
go.dev:443
objects.githubusercontent.com:443
proxy.golang.org:443
raw.githubusercontent.com:443
release-assets.githubusercontent.com:443
storage.googleapis.com:443
sum.golang.org:443
timestamp.githubapp.com:443
uploads.github.com:443
*.actions.githubusercontent.com:443
*.s3.amazonaws.com:443
*.s3.us-west-2.amazonaws.com:443

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -36,14 +57,38 @@ jobs:
# another ref would flow straight into published artifacts.
cache: false

# goreleaser only signs when MACOS_SIGN_P12 is set (so local snapshot
# builds work without secrets) — which means a missing secret in CI
# would silently publish unsigned binaries and a Homebrew cask with no
# de-quarantine hook. Fail loudly instead.
- name: Ensure signing secrets are present
env:
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
run: |
if [ -z "$MACOS_SIGN_P12" ] || [ -z "$MACOS_NOTARY_KEY" ]; then
echo "::error::signing/notarization secrets missing from the release environment — refusing to publish unsigned binaries. See .github/SIGNING.md"
exit 1
fi

- uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
# --timeout raised from the 30m default: notarization waits on
# Apple's queue, which can be slow for a young developer account.
args: release --clean --timeout 60m
# Secrets are exposed to this step only — never job-wide — so other
# steps (and anything they download) can't read them from the
# environment.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}

- name: Attest build provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
Expand Down
46 changes: 36 additions & 10 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,37 @@ archives:
- README.md
- LICENSE*

# Sign and notarize the darwin binaries in place before archiving, so the
# tarballs (and therefore checksums and provenance attestations) contain the
# signed Mach-O. Uses quill under the hood — runs fine on the Linux runner, no
# macOS runner or keychain needed. Skipped when MACOS_SIGN_P12 is unset so
# local snapshot builds still work; CI enforces the secrets separately (see
# the preflight step in .github/workflows/release.yml).
notarize:
macos:
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
# Must match the build id above. The default filter is the project name
# (things-cli), which matches no build here and would silently sign
# nothing.
ids:
- things
sign:
certificate: "{{ .Env.MACOS_SIGN_P12 }}"
password: "{{ .Env.MACOS_SIGN_PASSWORD }}"
notarize:
issuer_id: "{{ .Env.MACOS_NOTARY_ISSUER_ID }}"
key_id: "{{ .Env.MACOS_NOTARY_KEY_ID }}"
key: "{{ .Env.MACOS_NOTARY_KEY }}"
# Block until Apple accepts the submission. This runs before archiving
# and publishing, so a rejected or timed-out notarization fails the
# whole release cleanly — nothing partial is published and the job can
# simply be re-run.
wait: true
# First-ever submissions on a new developer account can sit in Apple's
# queue for a while; the workflow raises goreleaser's global --timeout
# to match.
timeout: 30m

checksum:
name_template: "checksums.txt"

Expand Down Expand Up @@ -78,16 +109,11 @@ homebrew_casks:
name: goreleaserbot
email: bot@goreleaser.com
commit_msg_template: "chore(brew): {{ .ProjectName }} {{ .Tag }}"
# De-quarantine in preflight (before artifact install) rather than postflight,
# so the binary is runnable when generate_completions_from_executable invokes
# it during install — otherwise Gatekeeper blocks the unsigned binary and no
# completion files are written.
hooks:
pre:
install: |
if OS.mac?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/things"]
end
# No de-quarantine hook: the binary is signed and notarized (see the
# notarize block above), so Gatekeeper passes it — including when
# generate_completions_from_executable runs it at install time. Bare
# Mach-O binaries can't have a ticket stapled, so the first run needs
# network for Gatekeeper's online notarization lookup.
zap:
trash:
- ~/Library/Caches/things-cli
Loading