From 30bee47a4dc4e4f4bc7e9e96706e45c5420d1d50 Mon Sep 17 00:00:00 2001 From: Brad Fair Date: Mon, 20 Apr 2026 22:29:36 -0500 Subject: [PATCH 1/2] ci(release): sign macOS and Windows binaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Darwin: codesign with Developer ID Application, notarize archives via xcrun notarytool after GoReleaser builds but before upload. Keychain is created per-run from APPLE_DISTRIBUTION_PKCS12 and discarded with the runner. Windows: Azure Trusted Signing via Microsoft's `sign` dotnet tool under the shared HPT "Elevate" profile. Linux: intentionally unsigned — distros don't consume Authenticode-style sigs; the sha256 in checksums.txt is the integrity anchor. Release workflow moves to macos-14 so codesign and notarytool are available; linux/windows binaries still cross-compile via CGO_ENABLED=0. goreleaser runs with --skip=publish so archives exist on disk for the notarize step; a follow-up gh release upload attaches the signed, notarized artifacts to the release release-please already created. --- .github/scripts/notarize-archive.sh | 16 ++++++ .github/scripts/sign-binary.sh | 35 ++++++++++++ .github/workflows/release.yml | 87 ++++++++++++++++++++++++++++- .goreleaser.yml | 6 ++ 4 files changed, 142 insertions(+), 2 deletions(-) create mode 100755 .github/scripts/notarize-archive.sh create mode 100755 .github/scripts/sign-binary.sh diff --git a/.github/scripts/notarize-archive.sh b/.github/scripts/notarize-archive.sh new file mode 100755 index 0000000..76d8e7d --- /dev/null +++ b/.github/scripts/notarize-archive.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Notarizes a macOS archive produced by GoReleaser. Invoked from the release +# workflow in a loop over dist/*darwin*.tar.gz after `goreleaser release`. +# +# A .tar.gz cannot be stapled — Gatekeeper verifies the already-notarized +# Mach-O inside on first run (online check). The archive itself only needs +# to be accepted by notarytool. +set -euo pipefail + +archive="$1" + +xcrun notarytool submit "$archive" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait diff --git a/.github/scripts/sign-binary.sh b/.github/scripts/sign-binary.sh new file mode 100755 index 0000000..d652b45 --- /dev/null +++ b/.github/scripts/sign-binary.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Signs a freshly-built binary. Invoked by GoReleaser as a builds.hooks.post +# so the signature is embedded before archiving. +# +# darwin -> codesign with Developer ID Application (keychain imported by +# the release workflow). Archive is later notarized in +# notarize-archive.sh. +# windows -> Azure Trusted Signing via Microsoft's `sign` dotnet tool, +# shared HPT "Elevate" profile under "tooling-and-automation". +# linux -> no-op. Linux distros don't consume Authenticode-style sigs; +# the sha256 in checksums.txt is the integrity anchor. +set -euo pipefail + +path="$1" os="$2" arch="$3" + +case "$os" in + darwin) + codesign --force --timestamp --options=runtime \ + --sign "Developer ID Application" "$path" + codesign --verify --strict "$path" + ;; + windows) + sign code azure-trusted-signing \ + --azure-trusted-signing-endpoint "https://eus.codesigning.azure.net/" \ + --azure-trusted-signing-account "tooling-and-automation" \ + --azure-trusted-signing-certificate-profile "Elevate" \ + "$path" + ;; + linux) + ;; + *) + echo "sign-binary.sh: unexpected os=$os" >&2 + exit 1 + ;; +esac diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be0f13b..3b26c4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,11 @@ permissions: jobs: goreleaser: - runs-on: ubuntu-latest + # macos-14 is required: goreleaser runs `codesign` + `xcrun notarytool` + # against the darwin builds in-place, and those tools only exist on macOS. + # Cross-OS matrix isn't worth the plumbing — a single mac runner can still + # cross-compile linux/windows via CGO_ENABLED=0. + runs-on: macos-14 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: @@ -25,11 +29,90 @@ jobs: - name: Install syft for SBOM generation uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + # Import the Apple Developer ID into a throwaway keychain so codesign + # can find it. The keychain is scoped to this runner and discarded when + # the job finishes. partition-list must be set or codesign will prompt + # for the key (and hang on a headless runner). + - name: Import Apple Developer ID + env: + APPLE_DISTRIBUTION_PKCS12: ${{ secrets.APPLE_DISTRIBUTION_PKCS12 }} + APPLE_DISTRIBUTION_PKCS12_PASSPHRASE: ${{ secrets.APPLE_DISTRIBUTION_PKCS12_PASSPHRASE }} + run: | + set -euo pipefail + KEYCHAIN="$RUNNER_TEMP/ana-release.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -base64 24)" + CERT_PATH="$RUNNER_TEMP/apple-dist.p12" + + printf '%s' "$APPLE_DISTRIBUTION_PKCS12" | base64 --decode > "$CERT_PATH" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security import "$CERT_PATH" -k "$KEYCHAIN" \ + -P "$APPLE_DISTRIBUTION_PKCS12_PASSPHRASE" \ + -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') + + rm -f "$CERT_PATH" + + # Azure Trusted Signing for Windows binaries. Uses the shared HPT + # "Elevate" cert profile under account "tooling-and-automation". + - name: Install .NET for sign tool + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + dotnet-version: "8.0.x" + + - name: Install sign CLI + run: dotnet tool install --global sign --prerelease --version "0.9.1-beta.26179.1" + - name: Run GoReleaser uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 with: distribution: goreleaser version: "~> v2" - args: release --clean + # --skip=publish so archives exist on disk for the notarize step + # before they're uploaded. The follow-up step flips them back + # onto the release after notarization succeeds. + args: release --clean --skip=publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Apple — consumed by sign-binary.sh (codesign, pre-archive). + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Azure — consumed by `sign` tool which reads them from the env. + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + + - name: Notarize darwin archives + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + shopt -s nullglob + for archive in dist/*darwin*.tar.gz; do + echo "notarizing: $archive" + .github/scripts/notarize-archive.sh "$archive" + done + + - name: Publish release artifacts env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # The GitHub release itself is created by release-please when the + # release PR merges. Here we upload the signed + notarized artifacts + # to that release. --clobber handles re-runs without errors. + run: | + set -euo pipefail + shopt -s nullglob + artifacts=( + dist/*.tar.gz + dist/*.zip + dist/checksums.txt + dist/*.sbom.json + ) + gh release upload "$GITHUB_REF_NAME" "${artifacts[@]}" --clobber diff --git a/.goreleaser.yml b/.goreleaser.yml index 7b91dad..753b8b9 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -27,6 +27,12 @@ builds: - -X main.version={{.Version}} - -X main.commit={{.Commit}} - -X main.date={{.Date}} + hooks: + # Sign the binary before archiving so the signature lands inside the + # tar.gz/zip. sign-binary.sh branches on $os: codesign for darwin, + # Azure Trusted Signing for windows, no-op for linux. + post: + - .github/scripts/sign-binary.sh "{{ .Path }}" "{{ .Os }}" "{{ .Arch }}" archives: - id: ana From 5e4811fda1eab3ae27505c7f22b835e03bb76328 Mon Sep 17 00:00:00 2001 From: Brad Fair Date: Mon, 20 Apr 2026 22:44:38 -0500 Subject: [PATCH 2/2] ci(release): address review nits - Quote `security list-keychains` subshell to survive spaces in paths. - Drop APPLE_* from GoReleaser step env; codesign resolves identity from the imported keychain, not env vars. They stay on the notarize step. - Drop unused `arch` parameter from sign-binary.sh; prune {{ .Arch }} from the hook template. - Guard `gh release upload` against empty artifact glob with explicit error rather than a confusing gh error. - Document why `sign` is pinned to a beta (no stable tag ships). --- .github/scripts/sign-binary.sh | 2 +- .github/workflows/release.yml | 16 +++++++++++----- .goreleaser.yml | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/scripts/sign-binary.sh b/.github/scripts/sign-binary.sh index d652b45..9961282 100755 --- a/.github/scripts/sign-binary.sh +++ b/.github/scripts/sign-binary.sh @@ -11,7 +11,7 @@ # the sha256 in checksums.txt is the integrity anchor. set -euo pipefail -path="$1" os="$2" arch="$3" +path="$1" os="$2" case "$os" in darwin) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b26c4c..9e16d42 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,7 @@ jobs: -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple:,codesign: \ -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" - security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') + security list-keychains -d user -s "$KEYCHAIN" "$(security list-keychains -d user | tr -d '"')" rm -f "$CERT_PATH" @@ -64,6 +64,9 @@ jobs: with: dotnet-version: "8.0.x" + # `dotnet/sign` has only ever shipped prereleases (the 0.9.x-beta series); + # there's no stable tag to pin. Pin the exact beta so the toolchain stays + # reproducible and revisit when a non-beta is cut. - name: Install sign CLI run: dotnet tool install --global sign --prerelease --version "0.9.1-beta.26179.1" @@ -78,10 +81,9 @@ jobs: args: release --clean --skip=publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Apple — consumed by sign-binary.sh (codesign, pre-archive). - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # codesign resolves the Developer ID identity from the keychain + # imported above — no Apple env vars needed here. APPLE_* only + # matters in the notarize step. # Azure — consumed by `sign` tool which reads them from the env. AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} @@ -115,4 +117,8 @@ jobs: dist/checksums.txt dist/*.sbom.json ) + if [[ ${#artifacts[@]} -eq 0 ]]; then + echo "no artifacts in dist/ — goreleaser step likely failed" >&2 + exit 1 + fi gh release upload "$GITHUB_REF_NAME" "${artifacts[@]}" --clobber diff --git a/.goreleaser.yml b/.goreleaser.yml index 753b8b9..eb4438a 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -32,7 +32,7 @@ builds: # tar.gz/zip. sign-binary.sh branches on $os: codesign for darwin, # Azure Trusted Signing for windows, no-op for linux. post: - - .github/scripts/sign-binary.sh "{{ .Path }}" "{{ .Os }}" "{{ .Arch }}" + - .github/scripts/sign-binary.sh "{{ .Path }}" "{{ .Os }}" archives: - id: ana