Skip to content

Commit fb8b093

Browse files
authored
Embed TeamIdentifier in macOS CodeDirectory via anchore/quill (#392)
The goreleaser/quill fork (embedded in GoReleaser v2.14.x) never populates the TeamIdentifier field in the CodeDirectory during signing (anchore/quill#147). Replace the built-in notarize block with direct anchore/quill v0.7.1 CLI calls: - Build hook (scripts/sign-darwin.sh) signs darwin binaries before archiving so archives, checksums, and tap manifests are correct. Fails closed in CI when QUILL_SIGN_P12 is set; skips in local dev. - Notarization runs as a separate workflow step after GoReleaser publishes using --notary-* flags. Password via QUILL_SIGN_PASSWORD env var. - Post-release macos-verify job asserts TeamIdentifier and hardened runtime for both darwin/amd64 and darwin/arm64 on a macOS runner. Notarization status is best-effort telemetry (ticket propagation can lag). - Credentials written to $RUNNER_TEMP with umask 077, cleaned up via if: always(). Quill added to $GITHUB_PATH explicitly. Revert path: #393 tracks reverting when goreleaser/quill syncs the fix.
1 parent 3d60171 commit fb8b093

File tree

4 files changed

+166
-20
lines changed

4 files changed

+166
-20
lines changed

.github/workflows/release.yml

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,20 @@ jobs:
225225
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
226226
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
227227

228+
- name: Install quill for macOS signing
229+
run: |
230+
go install github.com/anchore/quill/cmd/quill@v0.7.1
231+
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
232+
233+
- name: Prepare signing credentials
234+
env:
235+
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
236+
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
237+
run: |
238+
umask 077
239+
echo "$MACOS_SIGN_P12" | base64 -d > "$RUNNER_TEMP/codesign.p12"
240+
echo "$MACOS_NOTARY_KEY" | base64 -d > "$RUNNER_TEMP/notary.p8"
241+
228242
- name: Install GoReleaser
229243
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
230244
with:
@@ -237,18 +251,33 @@ jobs:
237251
CHANGELOG_FILE: ${{ steps.changelog.outputs.file }}
238252
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
239253
HOMEBREW_TAP_TOKEN: ${{ steps.sdk-token.outputs.token }}
240-
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
241-
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
242-
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
243-
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
244-
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
254+
QUILL_SIGN_P12: ${{ runner.temp }}/codesign.p12
255+
QUILL_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
245256
run: |
246257
RELEASE_CHANGELOG=""
247258
if [ -n "$CHANGELOG_FILE" ] && [ -f "$CHANGELOG_FILE" ]; then
248259
RELEASE_CHANGELOG=$(cat "$CHANGELOG_FILE")
249260
fi
250261
export RELEASE_CHANGELOG
251-
goreleaser release --clean
262+
goreleaser release --clean --skip=notarize
263+
264+
- name: Notarize macOS binaries
265+
env:
266+
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
267+
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
268+
run: |
269+
for bin in dist/basecamp_darwin_*/basecamp; do
270+
echo "Notarizing $bin..."
271+
quill notarize "$bin" \
272+
--notary-issuer "$MACOS_NOTARY_ISSUER_ID" \
273+
--notary-key-id "$MACOS_NOTARY_KEY_ID" \
274+
--notary-key "$RUNNER_TEMP/notary.p8" \
275+
--wait
276+
done
277+
278+
- name: Clean up signing credentials
279+
if: always()
280+
run: rm -f "$RUNNER_TEMP/codesign.p12" "$RUNNER_TEMP/notary.p8"
252281

253282
- name: Attest build provenance
254283
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
@@ -268,6 +297,59 @@ jobs:
268297
git config --global user.email "dev@37signals.com"
269298
scripts/publish-aur.sh "$VERSION"
270299
300+
macos-verify:
301+
name: Verify macOS signing
302+
needs: [release]
303+
if: startsWith(github.ref, 'refs/tags/v')
304+
runs-on: macos-latest
305+
timeout-minutes: 10
306+
permissions:
307+
contents: read
308+
strategy:
309+
matrix:
310+
arch: [amd64, arm64]
311+
steps:
312+
- name: Download release binary
313+
env:
314+
GH_TOKEN: ${{ github.token }}
315+
run: |
316+
mkdir -p /tmp/verify
317+
gh release download "$GITHUB_REF_NAME" \
318+
--repo "$GITHUB_REPOSITORY" \
319+
--pattern "basecamp_*_darwin_${{ matrix.arch }}.tar.gz" \
320+
--dir /tmp/verify
321+
tar -xzf /tmp/verify/basecamp_*_darwin_${{ matrix.arch }}.tar.gz -C /tmp/verify
322+
323+
- name: Verify TeamIdentifier
324+
run: |
325+
TEAM_ID=$(codesign -dv --verbose=4 /tmp/verify/basecamp 2>&1 | grep TeamIdentifier= | cut -d= -f2)
326+
echo "TeamIdentifier (${{ matrix.arch }}): $TEAM_ID"
327+
if [ "$TEAM_ID" != "2WNYUYRS7G" ]; then
328+
echo "::error::TeamIdentifier mismatch (${{ matrix.arch }}): expected 2WNYUYRS7G, got $TEAM_ID"
329+
exit 1
330+
fi
331+
332+
- name: Verify hardened runtime
333+
run: |
334+
FLAGS=$(codesign -dv --verbose=4 /tmp/verify/basecamp 2>&1 | grep 'flags=' | head -1)
335+
echo "Flags (${{ matrix.arch }}): $FLAGS"
336+
if ! echo "$FLAGS" | grep -q 'runtime'; then
337+
echo "::error::Hardened runtime flag not set (${{ matrix.arch }}): $FLAGS"
338+
exit 1
339+
fi
340+
341+
- name: Check notarization status
342+
run: |
343+
# Best-effort: notarization ticket propagation can lag, so this is
344+
# telemetry, not a gate. TeamIdentifier and hardened runtime above
345+
# are the hard assertions.
346+
spctl -a -vvv -t install /tmp/verify/basecamp 2>&1 | tee /tmp/verify/spctl.out
347+
if grep -q 'accepted\|Notarized Developer ID' /tmp/verify/spctl.out; then
348+
echo "Notarization accepted for ${{ matrix.arch }}"
349+
else
350+
echo "::warning::Notarization not yet accepted for ${{ matrix.arch }} (ticket propagation may lag)"
351+
fi
352+
271353
nix-verify:
272354
name: Verify Nix flake
273355
needs: [release]

.goreleaser.yaml

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ builds:
3030
- -X github.com/basecamp/basecamp-cli/internal/version.Version={{.Version}}
3131
- -X github.com/basecamp/basecamp-cli/internal/version.Commit={{.Commit}}
3232
- -X github.com/basecamp/basecamp-cli/internal/version.Date={{.Date}}
33+
hooks:
34+
post:
35+
- cmd: scripts/sign-darwin.sh "{{ .Os }}" "{{ .Path }}"
36+
output: true
3337

3438
archives:
3539
- id: default
@@ -94,21 +98,13 @@ signs:
9498
artifacts: checksum
9599
output: true
96100

97-
# Sign and notarize macOS binaries (cross-platform via embedded quill)
101+
# Signing handled by scripts/sign-darwin.sh via build hook using anchore/quill
102+
# CLI directly. The goreleaser/quill fork lacks the TeamIdentifier fix from
103+
# anchore/quill v0.7.0 (https://github.com/anchore/quill/issues/147).
104+
# Notarization runs as a separate workflow step after GoReleaser publishes.
98105
notarize:
99106
macos:
100-
- enabled: '{{ and (ne .Env.MACOS_SIGN_P12 "") (ne .Env.MACOS_SIGN_PASSWORD "") (ne .Env.MACOS_NOTARY_KEY "") (ne .Env.MACOS_NOTARY_KEY_ID "") (ne .Env.MACOS_NOTARY_ISSUER_ID "") }}'
101-
ids:
102-
- basecamp
103-
sign:
104-
certificate: "{{.Env.MACOS_SIGN_P12}}"
105-
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
106-
notarize:
107-
issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}"
108-
key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}"
109-
key: "{{.Env.MACOS_NOTARY_KEY}}"
110-
wait: true
111-
timeout: 20m
107+
- enabled: 'false'
112108

113109
changelog:
114110
# Use GitHub's auto-generated release notes (categories configured in .github/release.yml)

RELEASING.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ make release VERSION=0.2.0 DRY_RUN=1
2525
- Generates AI changelog from commit history
2626
- Builds binaries for all platforms (darwin, linux, windows, freebsd, openbsd × amd64/arm64)
2727
- Builds `.deb`, `.rpm`, `.apk` Linux packages (amd64 + arm64)
28-
- Signs and notarizes macOS binaries (Developer ID via GoReleaser/quill)
28+
- Signs macOS binaries via build hook (`anchore/quill` CLI), notarizes post-publish (see [tradeoff](#macos-signing-tradeoffs))
2929
- Signs checksums with cosign (keyless via Sigstore OIDC)
3030
- Generates SBOM for supply chain transparency
3131
- Updates Homebrew cask (`basecamp-cli`) in `basecamp/homebrew-tap`
@@ -94,3 +94,29 @@ make update-nix-hash
9494
| deb/rpm/apk packages | GitHub Release assets | GoReleaser (nfpm) |
9595
| Nix flake | `flake.nix` in repo | Self-serve (`nix profile install github:basecamp/basecamp-cli`) |
9696
| go install | `go install github.com/basecamp/basecamp-cli/cmd/basecamp@latest` | Go module proxy |
97+
98+
## macOS signing tradeoffs
99+
100+
**Signing** uses `anchore/quill` CLI directly (not GoReleaser's embedded fork)
101+
because the `goreleaser/quill` fork does not populate the `TeamIdentifier` field
102+
in the CodeDirectory ([anchore/quill#147](https://github.com/anchore/quill/issues/147)).
103+
The build hook signs darwin binaries before archiving, so archives, checksums,
104+
and tap manifests all contain correctly signed binaries.
105+
106+
**Notarization** runs after GoReleaser publishes. This means there is a brief
107+
window where the GitHub release and Homebrew/Scoop manifests point at binaries
108+
Apple has not yet accepted. This is accepted release debt, not a bug:
109+
- The binary bytes are final — notarization for bare Mach-O is purely server-side
110+
- GateKeeper checks Apple's servers at runtime, not a stapled ticket
111+
- Bare Mach-O executables cannot be stapled (Apple limitation)
112+
- This matches the previous GoReleaser/quill behavior
113+
114+
The `macos-verify` post-release job runs on a real macOS runner for both amd64
115+
and arm64. TeamIdentifier and hardened runtime are hard assertions that fail
116+
the workflow. The notarization check is best-effort telemetry — ticket
117+
propagation can lag, so it warns rather than gates.
118+
119+
**Reverting**: when `goreleaser/quill` syncs the fix from anchore/quill v0.7.0,
120+
revert to the built-in notarize block: remove the build hook, re-enable
121+
`notarize.macos`, remove the quill install/notarize workflow steps, and delete
122+
`scripts/sign-darwin.sh`.

scripts/sign-darwin.sh

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env bash
2+
# Build hook: sign macOS binaries with anchore/quill CLI.
3+
# Called by GoReleaser after each build.
4+
#
5+
# Two modes:
6+
# CI (QUILL_SIGN_P12 is set): signing is mandatory, missing deps = hard fail
7+
# Local dev (QUILL_SIGN_P12 unset): silently skip
8+
#
9+
# Inputs (env):
10+
# QUILL_SIGN_P12 - path to .p12 certificate file (set = CI mode)
11+
# QUILL_SIGN_PASSWORD - .p12 unlock password (quill reads this natively)
12+
# Args:
13+
# $1 - target OS (e.g. "darwin", "linux")
14+
# $2 - path to built binary
15+
set -euo pipefail
16+
17+
os="$1"
18+
path="$2"
19+
20+
# Non-darwin targets: always skip
21+
[ "$os" = "darwin" ] || exit 0
22+
23+
# No cert path configured: local dev, skip silently
24+
[ -n "${QUILL_SIGN_P12:-}" ] || exit 0
25+
26+
# From here, CI has opted in to signing. Missing deps are errors.
27+
if [ ! -f "$QUILL_SIGN_P12" ]; then
28+
echo "ERROR: QUILL_SIGN_P12 set but file not found: $QUILL_SIGN_P12" >&2
29+
exit 1
30+
fi
31+
32+
if [ -z "${QUILL_SIGN_PASSWORD:-}" ]; then
33+
echo "ERROR: QUILL_SIGN_PASSWORD is not set" >&2
34+
exit 1
35+
fi
36+
37+
if ! command -v quill >/dev/null; then
38+
echo "ERROR: quill not found in PATH" >&2
39+
exit 1
40+
fi
41+
42+
quill sign "$path" --p12 "$QUILL_SIGN_P12"

0 commit comments

Comments
 (0)