From 199f2a311fb04a2cf2c5863ec4f7e4b3df009902 Mon Sep 17 00:00:00 2001 From: Ashish Kurmi Date: Tue, 23 Jun 2026 02:43:56 +0530 Subject: [PATCH] ci(macos): automate Developer ID signing + notarization in GitHub Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move macOS Developer ID codesigning and Apple notarization off a local Mac and into GitHub Actions, mirroring the Windows (Azure Trusted Signing) flow, and fix a code-signing identifier regression. New workflows: - release-macos.yml (dispatch-only): downloads the …-darwin_unnotarized binary from the draft release, codesigns it, notarizes it, then cosign-signs and attests the final notarized bytes and uploads them. Kept separate from release.yml so it can be re-run independently when Apple notarization hangs. - check-notarization-status.yml (dispatch-only): inspects a notary submission by id (status + log) for hung builds. - test-macos-signing.yml: end-to-end smoke test (goreleaser snapshot build, then codesign + notarize the test binary), mirroring test-build.yml. Nothing is published. release.yml: stop cosign-signing/attesting the unnotarized darwin bytes; that now happens in release-macos.yml against the bytes users actually download. Fixed code-signing identifier: codesign is invoked with --identifier stepsecurity-dev-machine-guard so the designated requirement stays stable across versions. Previously it defaulted to the versioned filename, which changed every release and broke MDM PPPC/TCC Full Disk Access profiles that match by identifier. test-macos-signing.yml asserts the embedded identifier so this can't recur. Notarization hang handling: the submission id is always printed before a 5-minute bounded wait; on timeout the run fails with the id so it can be checked and re-run without resubmitting. Certificate expiration: both signing workflows print the Developer ID cert expiry, warn at 30 days, and fail if already expired. docs/release-process.md documents the new three-workflow process, the required signing secrets, and the fixed identifier. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workflows/check-notarization-status.yml | 70 ++++++ .github/workflows/release-macos.yml | 229 +++++++++++++++++ .github/workflows/release.yml | 27 +- .github/workflows/test-macos-signing.yml | 237 ++++++++++++++++++ docs/release-process.md | 91 ++++--- 5 files changed, 606 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/check-notarization-status.yml create mode 100644 .github/workflows/release-macos.yml create mode 100644 .github/workflows/test-macos-signing.yml diff --git a/.github/workflows/check-notarization-status.yml b/.github/workflows/check-notarization-status.yml new file mode 100644 index 0000000..a58bbb6 --- /dev/null +++ b/.github/workflows/check-notarization-status.yml @@ -0,0 +1,70 @@ +name: Check Notarization Status + +# Inspect an Apple notary submission by id. Use this when release-macos.yml +# reported that the 5-minute wait elapsed before notarization finished: grab the +# submission id from that run's logs and pass it here to see the current status +# and the full notary log (including any failure reasons). Once it reports +# 'Accepted', re-run release-macos.yml to publish the signed binary. + +on: + workflow_dispatch: + inputs: + submission_id: + description: "Notary submission id (GUID printed by release-macos.yml)" + required: true + type: string + +permissions: {} + +jobs: + check: + name: Query notary submission + runs-on: macos-latest + # notarytool needs the same App Store Connect API key, which lives in the + # `release` environment. + environment: release + permissions: + contents: read + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Query submission status and log + env: + MACOS_NOTARY_API_KEY_P8_BASE64: ${{ secrets.MACOS_NOTARY_API_KEY_P8_BASE64 }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + run: | + set -euo pipefail + submission_id="${{ inputs.submission_id }}" + key="$RUNNER_TEMP/AuthKey.p8" + echo "$MACOS_NOTARY_API_KEY_P8_BASE64" | base64 --decode > "$key" + creds=(--key "$key" --key-id "$MACOS_NOTARY_KEY_ID" --issuer "$MACOS_NOTARY_ISSUER_ID") + + echo "::group::notarytool info" + info_json="$(xcrun notarytool info "$submission_id" "${creds[@]}" --output-format json)" + echo "$info_json" + echo "::endgroup::" + + status="$(echo "$info_json" | jq -r '.status')" + echo "Status: $status" + echo "::notice title=Notarization status::$submission_id -> $status" + + # The log endpoint is only populated once Apple has finished; ignore + # failures while a submission is still 'In Progress'. + echo "::group::notarytool log" + xcrun notarytool log "$submission_id" "${creds[@]}" || echo "Log not available yet (still in progress?)." + echo "::endgroup::" + + case "$status" in + Accepted) + echo "Ready to publish. Re-run release-macos.yml for the release tag." ;; + "In Progress") + echo "::warning::Still in progress. Check again shortly." ;; + *) + echo "::error::Submission ended in status '$status'. See the log above for details." + exit 1 ;; + esac diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml new file mode 100644 index 0000000..a6ee356 --- /dev/null +++ b/.github/workflows/release-macos.yml @@ -0,0 +1,229 @@ +name: Release macOS (codesign, notarize, attest) + +# Codesign + notarize the macOS universal binary, then cosign-sign and attest +# the final notarized bytes, and publish them to an existing draft release. +# +# This is intentionally a SEPARATE, dispatch-only workflow (not part of +# release.yml) because Apple notarization can hang. Keeping it standalone means +# you can re-run it as many times as needed without re-running the whole +# release. release.yml uploads the unsigned `…-darwin_unnotarized` binary to the +# draft release and deliberately does NOT cosign/attest it; this workflow takes +# over from there. +# +# Order to release a version: +# 1. Run "Release" (release.yml) -> builds + drafts, uploads …-darwin_unnotarized +# 2. Run this workflow with the release tag -> publishes the signed …-darwin +# (If notarization hangs, note the submission id printed here and use the +# "Check Notarization Status" workflow, then re-run this workflow.) + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag to sign the macOS binary for (e.g. v1.12.0)" + required: true + type: string + +permissions: {} + +jobs: + macos-sign-and-notarize: + name: Codesign & notarize macOS universal binary + runs-on: macos-latest + # The `release` environment gates access to the signing secrets and requires + # reviewers, matching the Windows signing job in release.yml. + environment: release + permissions: + contents: write + id-token: write + attestations: write + + steps: + - name: Harden the runner (Audit all outbound calls) + # codesign/notarytool reach Apple endpoints (timestamp.apple.com, + # notary-api / appstoreconnect, ocsp/crl). Audit mode logs the full set + # before switching to block mode. + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Resolve version from tag + id: version + run: | + set -euo pipefail + tag="${{ inputs.tag }}" + version="${tag#v}" + if [ -z "$version" ]; then + echo "::error::Could not derive version from tag '${tag}'" + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Download darwin_unnotarized from draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + mkdir -p dist + gh release download "${{ inputs.tag }}" \ + -R "${{ github.repository }}" \ + -p "*-darwin_unnotarized" \ + -D dist + ls -la dist/ + bin="dist/stepsecurity-dev-machine-guard-${{ steps.version.outputs.version }}-darwin_unnotarized" + test -f "$bin" || { echo "::error::Expected $bin not found in release ${{ inputs.tag }}"; exit 1; } + + - name: Import Developer ID certificate into a temporary keychain + env: + MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }} + MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + run: | + set -euo pipefail + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + KEYCHAIN_PWD="$(uuidgen)" + echo "$MACOS_CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12" + + security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" + security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \ + -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign + # Add the build keychain to the search list so codesign can find the + # identity, and authorize codesign to use the key without a UI prompt. + security list-keychains -d user -s "$KEYCHAIN" login.keychain-db + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PWD" "$KEYCHAIN" + security find-identity -v -p codesigning "$KEYCHAIN" + + # Surface the Developer ID certificate's expiration so we renew it + # before it lapses. Warn at 30 days; fail outright if already expired + # (codesign would fail anyway, but a clear message is more actionable). + echo "::group::Certificate expiration" + cert_pem="$RUNNER_TEMP/devid.pem" + security find-certificate -c "Developer ID Application" -p "$KEYCHAIN" > "$cert_pem" + subject="$(openssl x509 -in "$cert_pem" -noout -subject | sed 's/^subject= *//')" + not_after="$(openssl x509 -in "$cert_pem" -noout -enddate | cut -d= -f2)" + echo "Certificate: $subject" + echo "Expires: $not_after" + echo "::notice title=Developer ID cert expiry::$subject expires $not_after" + if ! openssl x509 -in "$cert_pem" -noout -checkend 0; then + echo "::error title=Developer ID cert expired::Certificate expired on $not_after. Renew it before releasing." + exit 1 + elif ! openssl x509 -in "$cert_pem" -noout -checkend $((30 * 24 * 3600)); then + echo "::warning title=Developer ID cert expiring soon::Certificate expires on $not_after (within 30 days). Plan renewal." + fi + echo "::endgroup::" + + - name: Codesign, notarize, and verify + env: + MACOS_NOTARY_API_KEY_P8_BASE64: ${{ secrets.MACOS_NOTARY_API_KEY_P8_BASE64 }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + run: | + set -euo pipefail + version="${{ steps.version.outputs.version }}" + bin="dist/stepsecurity-dev-machine-guard-${version}-darwin_unnotarized" + + echo "::group::Codesign" + # Pin the code-signing identifier with -i so the designated requirement + # stays "stepsecurity-dev-machine-guard" regardless of the on-disk + # filename. Without -i, codesign derives the identifier from the file + # name (e.g. stepsecurity-dev-machine-guard-VERSION-darwin_unnotarized), + # which changes every release and breaks MDM PPPC/TCC profiles that + # grant Full Disk Access by identifier. + codesign --sign "Developer ID Application: Step Security, Inc. (D63S9HLM4L)" \ + --identifier stepsecurity-dev-machine-guard \ + --options runtime --timestamp "$bin" + codesign --verify --deep --strict --verbose=2 "$bin" + # Confirm the embedded identifier is the stable one MDM profiles expect. + codesign -d -r- "$bin" 2>&1 | sed -n 's/^designated => //p' + echo "::endgroup::" + + # App Store Connect API key credentials for notarytool. + key="$RUNNER_TEMP/AuthKey.p8" + echo "$MACOS_NOTARY_API_KEY_P8_BASE64" | base64 --decode > "$key" + creds=(--key "$key" --key-id "$MACOS_NOTARY_KEY_ID" --issuer "$MACOS_NOTARY_ISSUER_ID") + + ditto -c -k --keepParent "$bin" "$RUNNER_TEMP/notarize.zip" + + # Submit WITHOUT --wait first, so we always capture and print the + # submission id even if Apple is slow. The id is what the + # "Check Notarization Status" workflow needs to investigate a hang. + echo "::group::Submit to notary" + submit_json="$(xcrun notarytool submit "$RUNNER_TEMP/notarize.zip" \ + "${creds[@]}" --output-format json)" + echo "$submit_json" + echo "::endgroup::" + + submission_id="$(echo "$submit_json" | jq -r '.id')" + if [ -z "$submission_id" ] || [ "$submission_id" = "null" ]; then + echo "::error::Could not parse notarization submission id" + exit 1 + fi + echo "Notarization submission id: $submission_id" + echo "::notice title=Notarization submission id::$submission_id" + + # Time-bound the wait to 5 minutes. On timeout, notarytool exits + # non-zero; we surface the id so the run can be re-driven without + # resubmitting (Apple keeps processing server-side). + echo "::group::Wait (timeout 5m)" + if ! xcrun notarytool wait "$submission_id" "${creds[@]}" --timeout 5m; then + echo "::error title=Notarization not complete::Submission $submission_id did not reach a terminal state within 5m. Run the 'Check Notarization Status' workflow with id '$submission_id'; once it shows 'Accepted', re-run this workflow." + xcrun notarytool info "$submission_id" "${creds[@]}" || true + exit 1 + fi + echo "::endgroup::" + + status="$(xcrun notarytool info "$submission_id" "${creds[@]}" --output-format json | jq -r '.status')" + echo "Notarization status: $status" + if [ "$status" != "Accepted" ]; then + echo "::error::Notarization status is '$status' (expected 'Accepted'). Fetching log:" + xcrun notarytool log "$submission_id" "${creds[@]}" || true + exit 1 + fi + + # Standalone binaries can't be stapled; Gatekeeper verifies the ticket + # online. spctl confirms the binary is accepted. + spctl -a -vvv -t install "$bin" + + mv "$bin" "dist/stepsecurity-dev-machine-guard-${version}-darwin" + echo "Signed + notarized: dist/stepsecurity-dev-machine-guard-${version}-darwin" + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Sign notarized binary with Sigstore + run: | + set -euo pipefail + version="${{ steps.version.outputs.version }}" + bin="dist/stepsecurity-dev-machine-guard-${version}-darwin" + bundle="dist/stepsecurity-dev-machine-guard-darwin.bundle" + for attempt in 1 2 3; do + if cosign sign-blob "$bin" --bundle "$bundle" --yes; then + break + fi + echo "::warning::Sign attempt $attempt failed for $bin, retrying in 10s..." + sleep 10 + done + test -s "$bundle" || { echo "::error::cosign failed for $bin after 3 attempts"; exit 1; } + + - name: Upload notarized binary and bundle to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + version="${{ steps.version.outputs.version }}" + gh release upload "${{ inputs.tag }}" \ + "dist/stepsecurity-dev-machine-guard-${version}-darwin" \ + "dist/stepsecurity-dev-machine-guard-darwin.bundle" \ + --clobber + + # The unsigned binary should not remain on the release; best-effort + # cleanup so users only see the notarized artifact. + gh release delete-asset "${{ inputs.tag }}" \ + "stepsecurity-dev-machine-guard-${version}-darwin_unnotarized" -y || true + + - name: Attest darwin build provenance + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: dist/stepsecurity-dev-machine-guard-${{ steps.version.outputs.version }}-darwin diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e205869..a6e7e7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,13 @@ jobs: # subdirs without renaming, only the upload uses the templated name). # The windows-sign-and-package job downloads them from the draft release, # so we don't need to locate them locally here. - DARWIN=$(find dist -type f -name '*darwin_unnotarized' | head -1) + # + # The darwin universal binary (…-darwin_unnotarized) is uploaded to the + # draft release by GoReleaser, but it is intentionally NOT cosign-signed + # or attested here: codesigning rewrites the bytes, so signing the + # unnotarized bytes would not match what users download. The separate + # release-macos.yml workflow codesigns + notarizes, then cosign-signs and + # attests the final notarized bytes. Run it after this workflow. LINUX_AMD64=$(find dist -type f -name 'stepsecurity-dev-machine-guard' -path '*linux_amd64*' | head -1) LINUX_ARM64=$(find dist -type f -name 'stepsecurity-dev-machine-guard' -path '*linux_arm64*' | head -1) @@ -105,7 +111,7 @@ jobs: RPM_AMD64=$(find dist -type f -name '*-amd64.rpm' | head -1) RPM_ARM64=$(find dist -type f -name '*-arm64.rpm' | head -1) - for label in "darwin:${DARWIN}" "linux_amd64:${LINUX_AMD64}" "linux_arm64:${LINUX_ARM64}" "deb_amd64:${DEB_AMD64}" "deb_arm64:${DEB_ARM64}" "rpm_amd64:${RPM_AMD64}" "rpm_arm64:${RPM_ARM64}"; do + for label in "linux_amd64:${LINUX_AMD64}" "linux_arm64:${LINUX_ARM64}" "deb_amd64:${DEB_AMD64}" "deb_arm64:${DEB_ARM64}" "rpm_amd64:${RPM_AMD64}" "rpm_arm64:${RPM_ARM64}"; do name="${label%%:*}" path="${label#*:}" if [ -z "$path" ] || [ ! -f "$path" ]; then @@ -115,7 +121,6 @@ jobs: fi done - echo "darwin=$DARWIN" >> "$GITHUB_OUTPUT" echo "linux_amd64=$LINUX_AMD64" >> "$GITHUB_OUTPUT" echo "linux_arm64=$LINUX_ARM64" >> "$GITHUB_OUTPUT" echo "deb_amd64=$DEB_AMD64" >> "$GITHUB_OUTPUT" @@ -143,10 +148,10 @@ jobs: } # Windows .exes are signed in the windows-sign-and-package job after - # Authenticode signing, so the cosign bundles match the bytes users - # download from the published release. - sign_with_retry "${{ steps.binaries.outputs.darwin }}" \ - "dist/stepsecurity-dev-machine-guard-darwin_unnotarized.bundle" + # Authenticode signing, and the darwin binary is signed in + # release-macos.yml after codesigning + notarization, so in both cases + # the cosign bundles match the bytes users download from the published + # release. sign_with_retry "${{ steps.binaries.outputs.linux_amd64 }}" \ "dist/stepsecurity-dev-machine-guard-linux_amd64.bundle" sign_with_retry "${{ steps.binaries.outputs.linux_arm64 }}" \ @@ -164,9 +169,9 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Windows .exe bundles are uploaded by the windows-sign-and-package job. + # Windows .exe bundles are uploaded by the windows-sign-and-package job; + # the darwin bundle is uploaded by release-macos.yml. gh release upload "${{ steps.release.outputs.tag }}" \ - dist/stepsecurity-dev-machine-guard-darwin_unnotarized.bundle \ dist/stepsecurity-dev-machine-guard-linux_amd64.bundle \ dist/stepsecurity-dev-machine-guard-linux_arm64.bundle \ "${{ steps.binaries.outputs.deb_amd64 }}.bundle" \ @@ -177,11 +182,11 @@ jobs: - name: Attest build provenance # Windows .exe and MSI attestations are emitted by the - # windows-sign-and-package job after Authenticode signing. + # windows-sign-and-package job after Authenticode signing; the darwin + # attestation is emitted by release-macos.yml after notarization. uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-path: | - ${{ steps.binaries.outputs.darwin }} ${{ steps.binaries.outputs.linux_amd64 }} ${{ steps.binaries.outputs.linux_arm64 }} ${{ steps.binaries.outputs.deb_amd64 }} diff --git a/.github/workflows/test-macos-signing.yml b/.github/workflows/test-macos-signing.yml new file mode 100644 index 0000000..f838f32 --- /dev/null +++ b/.github/workflows/test-macos-signing.yml @@ -0,0 +1,237 @@ +name: Test macOS Signing (codesign + notarize) + +# End-to-end smoke test for the macOS signing path, without cutting a release. +# A goreleaser snapshot builds the darwin universal binary (mirroring +# test-build.yml), then a macOS job codesigns + notarizes that test binary and +# verifies it with spctl. Nothing is published; the signed binary is uploaded as +# a workflow artifact for inspection. Use this to validate the cert import and +# notarization credentials before relying on release-macos.yml. + +on: + workflow_dispatch: + inputs: + commit_id: + description: "Commit SHA to build the test binary from" + required: true + type: string + +permissions: {} + +jobs: + build: + name: Build snapshot darwin binary (goreleaser) + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout repository at requested commit + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.commit_id }} + fetch-depth: 0 + + - name: Extract version from source + id: version + run: | + version=$(grep -m1 'Version.*=' internal/buildinfo/version.go | sed 's/.*"\(.*\)".*/\1/') + if [ -z "$version" ]; then + echo "::error::Could not extract Version from internal/buildinfo/version.go" + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + + - name: Run GoReleaser (snapshot, no publish) + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 + with: + distribution: goreleaser + version: latest + args: release --snapshot --clean --skip=publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Stage darwin binary with release-style name + run: | + set -euo pipefail + version="${{ steps.version.outputs.version }}" + mkdir -p staging + darwin_src=$(find dist -type f -name '*-darwin_unnotarized' | head -1) + if [ -z "$darwin_src" ] || [ ! -f "$darwin_src" ]; then + echo "::error::No darwin_unnotarized binary found under dist/" + find dist -type f + exit 1 + fi + cp "$darwin_src" "staging/stepsecurity-dev-machine-guard-${version}-darwin_unnotarized" + ls -la staging/ + + - name: Upload macOS binary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: darwin-unnotarized + path: staging/stepsecurity-dev-machine-guard-*-darwin_unnotarized + if-no-files-found: error + + sign-and-notarize: + name: Codesign & notarize test binary + needs: build + runs-on: macos-latest + # Signing secrets live in the `release` environment (same gate as the + # Windows test workflow, test-azure-signing.yml). + environment: release + permissions: + contents: read + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Download test darwin binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: darwin-unnotarized + path: dist + + - name: Show pre-signing state (sanity) + run: | + set -euo pipefail + version="${{ needs.build.outputs.version }}" + bin="dist/stepsecurity-dev-machine-guard-${version}-darwin_unnotarized" + test -f "$bin" || { echo "::error::$bin missing"; ls -la dist; exit 1; } + if codesign --verify --strict "$bin" 2>/dev/null; then + echo "::error::$bin is already signed; aborting so we don't mask a regression." + exit 1 + fi + echo "Unsigned as expected." + + - name: Import Developer ID certificate into a temporary keychain + env: + MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }} + MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + run: | + set -euo pipefail + KEYCHAIN="$RUNNER_TEMP/build.keychain-db" + KEYCHAIN_PWD="$(uuidgen)" + echo "$MACOS_CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12" + + security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" + security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \ + -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign + security list-keychains -d user -s "$KEYCHAIN" login.keychain-db + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PWD" "$KEYCHAIN" + security find-identity -v -p codesigning "$KEYCHAIN" + + # Surface the Developer ID certificate's expiration so we renew it + # before it lapses. Warn at 30 days; fail outright if already expired + # (codesign would fail anyway, but a clear message is more actionable). + echo "::group::Certificate expiration" + cert_pem="$RUNNER_TEMP/devid.pem" + security find-certificate -c "Developer ID Application" -p "$KEYCHAIN" > "$cert_pem" + subject="$(openssl x509 -in "$cert_pem" -noout -subject | sed 's/^subject= *//')" + not_after="$(openssl x509 -in "$cert_pem" -noout -enddate | cut -d= -f2)" + echo "Certificate: $subject" + echo "Expires: $not_after" + echo "::notice title=Developer ID cert expiry::$subject expires $not_after" + if ! openssl x509 -in "$cert_pem" -noout -checkend 0; then + echo "::error title=Developer ID cert expired::Certificate expired on $not_after. Renew it before releasing." + exit 1 + elif ! openssl x509 -in "$cert_pem" -noout -checkend $((30 * 24 * 3600)); then + echo "::warning title=Developer ID cert expiring soon::Certificate expires on $not_after (within 30 days). Plan renewal." + fi + echo "::endgroup::" + + - name: Codesign, notarize, and verify + env: + MACOS_NOTARY_API_KEY_P8_BASE64: ${{ secrets.MACOS_NOTARY_API_KEY_P8_BASE64 }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + run: | + set -euo pipefail + version="${{ needs.build.outputs.version }}" + bin="dist/stepsecurity-dev-machine-guard-${version}-darwin_unnotarized" + + echo "::group::Codesign" + # Pin the code-signing identifier with -i so the designated requirement + # stays "stepsecurity-dev-machine-guard" regardless of the on-disk + # filename. Without -i, codesign derives the identifier from the file + # name (e.g. stepsecurity-dev-machine-guard-VERSION-darwin_unnotarized), + # which changes every release and breaks MDM PPPC/TCC profiles that + # grant Full Disk Access by identifier. + codesign --sign "Developer ID Application: Step Security, Inc. (D63S9HLM4L)" \ + --identifier stepsecurity-dev-machine-guard \ + --options runtime --timestamp "$bin" + codesign --verify --deep --strict --verbose=2 "$bin" + + # Assert the embedded identifier is the stable one MDM profiles target. + # This is the regression the customer hit when the identifier tracked + # the versioned filename, so fail the test loudly if it drifts. + designated="$(codesign -d -r- "$bin" 2>&1 | sed -n 's/^designated => //p')" + echo "Designated requirement: $designated" + case "$designated" in + 'identifier "stepsecurity-dev-machine-guard" '*) echo "Identifier OK." ;; + *) echo "::error::Code-signing identifier is not 'stepsecurity-dev-machine-guard'; MDM PPPC/TCC Full Disk Access profiles rely on it."; exit 1 ;; + esac + echo "::endgroup::" + + key="$RUNNER_TEMP/AuthKey.p8" + echo "$MACOS_NOTARY_API_KEY_P8_BASE64" | base64 --decode > "$key" + creds=(--key "$key" --key-id "$MACOS_NOTARY_KEY_ID" --issuer "$MACOS_NOTARY_ISSUER_ID") + + ditto -c -k --keepParent "$bin" "$RUNNER_TEMP/notarize.zip" + + echo "::group::Submit to notary" + submit_json="$(xcrun notarytool submit "$RUNNER_TEMP/notarize.zip" \ + "${creds[@]}" --output-format json)" + echo "$submit_json" + echo "::endgroup::" + + submission_id="$(echo "$submit_json" | jq -r '.id')" + if [ -z "$submission_id" ] || [ "$submission_id" = "null" ]; then + echo "::error::Could not parse notarization submission id" + exit 1 + fi + echo "Notarization submission id: $submission_id" + echo "::notice title=Notarization submission id::$submission_id" + + echo "::group::Wait (timeout 5m)" + if ! xcrun notarytool wait "$submission_id" "${creds[@]}" --timeout 5m; then + echo "::error title=Notarization not complete::Submission $submission_id did not finish within 5m. Run the 'Check Notarization Status' workflow with id '$submission_id'." + xcrun notarytool info "$submission_id" "${creds[@]}" || true + exit 1 + fi + echo "::endgroup::" + + status="$(xcrun notarytool info "$submission_id" "${creds[@]}" --output-format json | jq -r '.status')" + echo "Notarization status: $status" + if [ "$status" != "Accepted" ]; then + echo "::error::Notarization status is '$status' (expected 'Accepted'). Fetching log:" + xcrun notarytool log "$submission_id" "${creds[@]}" || true + exit 1 + fi + + spctl -a -vvv -t install "$bin" + mv "$bin" "dist/stepsecurity-dev-machine-guard-${version}-darwin" + echo "End-to-end OK: dist/stepsecurity-dev-machine-guard-${version}-darwin" + + - name: Upload signed binary for inspection + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: darwin-signed + path: dist/stepsecurity-dev-machine-guard-*-darwin + if-no-files-found: error diff --git a/docs/release-process.md b/docs/release-process.md index c3c0e37..9843a87 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -8,10 +8,13 @@ This document describes how releases are created, signed, notarized, and verifie ## Overview -Releases are a two-phase process: +Releases are a three-phase process, where each phase is its own GitHub Actions workflow: -1. **CI (automated, gated)** — GitHub Actions builds binaries for all platforms, Authenticode-signs the Windows `.exe`s and `.msi`s via Azure Trusted Signing, Sigstore-signs every artifact (after Authenticode for Windows, so cosign bundles match the bytes users download), creates a **draft** release, and emits SLSA build provenance attestations. The Windows signing job runs in the `release` GitHub Environment, which requires two reviewers and is restricted to `main`. -2. **Apple notarization (manual)** — Download the macOS binary, sign and notarize it with an Apple Developer account, upload the notarized binary to the draft release, and publish. +1. **Build and draft (`release.yml`)**: builds binaries for all platforms, Authenticode-signs the Windows `.exe`s and `.msi`s via Azure Trusted Signing, Sigstore-signs the Linux and Windows artifacts (after Authenticode for Windows, so cosign bundles match the bytes users download), creates a **draft** release with the unsigned macOS binary, and emits SLSA build provenance attestations for every artifact except macOS. The Windows signing job runs in the `release` GitHub Environment, which requires two reviewers and is restricted to `main`. +2. **Sign and notarize macOS (`release-macos.yml`)**: a dispatch-only workflow that downloads the unsigned macOS binary from the draft release, codesigns it with the Apple Developer ID certificate, notarizes it with Apple, then Sigstore-signs and attests the final notarized bytes and uploads them to the draft release. It runs in the `release` environment as well. Because Apple notarization can hang, this is kept separate from `release.yml` so it can be re-run on its own without repeating the build. A companion workflow, `check-notarization-status.yml`, inspects a notarization submission that did not finish in time. +3. **Publish**: mark the draft release as published. + +macOS signing runs entirely in GitHub Actions; no local Mac is required. The signing credentials are stored as encrypted secrets in the `release` environment (see [Signing credentials](#signing-credentials-one-time-setup)). --- @@ -44,36 +47,28 @@ The workflow will: - Upload to a **draft** release - Generate SLSA build provenance attestation -**Approval gate**: the Windows signing job waits at the `release` environment — two reviewers must approve before signing runs. The job won't start until the macOS/Linux portion finishes the draft upload, so the macOS notarization step below can run in parallel with reviewers approving. - -### 3. Apple notarization (manual) - -On a Mac with the Apple Developer certificate installed: +**Approval gate**: the Windows signing job waits at the `release` environment, where two reviewers must approve before signing runs. The job won't start until the macOS/Linux portion finishes the draft upload, so reviewers can approve while you move on to the macOS step below. -```bash -VERSION="1.9.1" +### 3. Sign and notarize the macOS binary -# Download the unnotarized binary -gh release download "v${VERSION}" --repo step-security/dev-machine-guard \ - --pattern "stepsecurity-dev-machine-guard-${VERSION}-darwin_unnotarized" +1. Go to [Actions > Release macOS](https://github.com/step-security/dev-machine-guard/actions/workflows/release-macos.yml) +2. Click **Run workflow** and enter the release tag (e.g. `v1.9.1`) -# Rename for signing -cp "stepsecurity-dev-machine-guard-${VERSION}-darwin_unnotarized" \ - "stepsecurity-dev-machine-guard-${VERSION}-darwin" +The workflow will: +- Download `stepsecurity-dev-machine-guard-VERSION-darwin_unnotarized` from the draft release +- Import the Developer ID Application certificate into a temporary keychain +- Codesign the binary with the hardened runtime, a secure timestamp, and a fixed identifier (`stepsecurity-dev-machine-guard`), so the designated requirement stays stable across versions and MDM PPPC/TCC Full Disk Access profiles keep working +- Submit it to Apple for notarization, printing the submission id and waiting up to 5 minutes +- Verify with `spctl`, rename the binary to `stepsecurity-dev-machine-guard-VERSION-darwin`, then Sigstore-sign and attest the notarized bytes +- Upload the notarized binary and its cosign bundle to the draft release, and remove the unsigned `darwin_unnotarized` asset -# Sign with Apple Developer ID -codesign --sign "Developer ID Application: ()" \ - --options runtime --timestamp "stepsecurity-dev-machine-guard-${VERSION}-darwin" +**Approval gate**: this job also waits at the `release` environment for two reviewers. -# Notarize with Apple (~5 min) -xcrun notarytool submit "stepsecurity-dev-machine-guard-${VERSION}-darwin" \ - --apple-id --team-id \ - --password --wait +**If notarization does not finish within 5 minutes**, the run prints the notary submission id and fails. To recover: -# Upload the notarized binary to the draft release -gh release upload "v${VERSION}" "stepsecurity-dev-machine-guard-${VERSION}-darwin" \ - --repo step-security/dev-machine-guard -``` +1. Go to [Actions > Check Notarization Status](https://github.com/step-security/dev-machine-guard/actions/workflows/check-notarization-status.yml) +2. Click **Run workflow** and enter the submission id from the failed run. It reports the current status and the notary log. +3. Once the status is `Accepted`, re-run the Release macOS workflow for the same tag. Apple keeps processing server-side, so no resubmission is needed. ### 4. Publish the release @@ -84,6 +79,28 @@ gh release edit "v${VERSION}" --repo step-security/dev-machine-guard \ --- +## Signing credentials (one-time setup) + +The macOS workflows read their credentials from secrets in the `release` GitHub Environment. These are configured once and reused for every release. + +| Secret | Description | +|--------|-------------| +| `MACOS_CERT_P12_BASE64` | Base64 of the exported Developer ID Application certificate and private key (`.p12`) | +| `MACOS_CERT_PASSWORD` | Password set when exporting the `.p12` | +| `MACOS_NOTARY_API_KEY_P8_BASE64` | Base64 of the App Store Connect API key (`AuthKey_XXXX.p8`) used by `notarytool` | +| `MACOS_NOTARY_KEY_ID` | App Store Connect API key id | +| `MACOS_NOTARY_ISSUER_ID` | App Store Connect issuer id | + +To export the certificate from a Mac that already has it installed: open Keychain Access, find **Developer ID Application: Step Security, Inc.** under **My Certificates** (it must have the private key nested under it), right-click and **Export** as a `.p12` with a password, then base64-encode it: + +```bash +base64 -i devid.p12 | pbcopy # paste into the MACOS_CERT_P12_BASE64 secret +``` + +Create the App Store Connect API key under **Users and Access > Integrations > Keys** with the **Developer** role, which covers notarization. Base64-encode the downloaded `.p8` the same way. + +--- + ## Release Artifacts Each release includes: @@ -91,8 +108,7 @@ Each release includes: | Artifact | Description | |----------|-------------| | `stepsecurity-dev-machine-guard-VERSION-darwin` | Notarized universal macOS binary (amd64 + arm64) | -| `stepsecurity-dev-machine-guard-VERSION-darwin_unnotarized` | Original CI-built binary (for provenance verification) | -| `stepsecurity-dev-machine-guard-darwin_unnotarized.bundle` | Sigstore cosign bundle for the unnotarized binary | +| `stepsecurity-dev-machine-guard-darwin.bundle` | Sigstore cosign bundle (covers the notarized bytes) | | `stepsecurity-dev-machine-guard-VERSION-windows_amd64.exe` | Authenticode-signed Windows 64-bit agent | | `stepsecurity-dev-machine-guard-windows_amd64.exe.bundle` | Sigstore cosign bundle (covers the signed bytes) | | `stepsecurity-dev-machine-guard-VERSION-windows_arm64.exe` | Authenticode-signed Windows ARM64 agent | @@ -131,20 +147,21 @@ VERSION="1.9.1" # Download release artifacts gh release download "v${VERSION}" --repo step-security/dev-machine-guard \ - --pattern "stepsecurity-dev-machine-guard-${VERSION}-darwin*" + --pattern "stepsecurity-dev-machine-guard-${VERSION}-darwin" \ + --pattern "stepsecurity-dev-machine-guard-darwin.bundle" # Verify Apple signature and notarization codesign --verify --deep --strict "stepsecurity-dev-machine-guard-${VERSION}-darwin" spctl --assess --type execute "stepsecurity-dev-machine-guard-${VERSION}-darwin" -# Verify Sigstore signature on the unnotarized binary -cosign verify-blob "stepsecurity-dev-machine-guard-${VERSION}-darwin_unnotarized" \ - --bundle "stepsecurity-dev-machine-guard-darwin_unnotarized.bundle" \ +# Verify Sigstore signature on the notarized binary +cosign verify-blob "stepsecurity-dev-machine-guard-${VERSION}-darwin" \ + --bundle "stepsecurity-dev-machine-guard-darwin.bundle" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ --certificate-identity-regexp "^https://github.com/step-security/dev-machine-guard/.github/workflows/" # Verify build provenance -gh attestation verify "stepsecurity-dev-machine-guard-${VERSION}-darwin_unnotarized" \ +gh attestation verify "stepsecurity-dev-machine-guard-${VERSION}-darwin" \ --repo step-security/dev-machine-guard ``` @@ -247,11 +264,11 @@ gh attestation verify "stepsecurity-dev-machine-guard-${VERSION}-linux_${ARCH}" ## Immutability Guarantees -1. **Draft → publish flow** — binaries are uploaded to a draft release, notarized manually, then published. Once published, the release is immutable. -2. **Sigstore transparency log** — every artifact's signature is recorded in the public [Rekor](https://rekor.sigstore.dev/) transparency log. Windows cosign bundles cover the post-Authenticode bytes, so they match what users download. +1. **Draft then publish flow**: binaries are uploaded to a draft release, the macOS binary is signed and notarized by the gated `release-macos.yml` workflow, then the release is published. Once published, the release is immutable. +2. **Sigstore transparency log**: every artifact's signature is recorded in the public [Rekor](https://rekor.sigstore.dev/) transparency log. The Windows cosign bundles cover the post-Authenticode bytes and the macOS bundle covers the post-notarization bytes, so they match what users download. 3. **SLSA build provenance** — attestation links the artifact to the exact workflow run, commit SHA, and build environment. 4. **Authenticode + RFC3161 timestamp** — Windows `.exe` and `.msi` signatures from Azure Trusted Signing are timestamped by Microsoft's RFC3161 timestamp server, so they remain verifiable on Windows after the signing certificate expires. -5. **Release environment gate** — the Windows signing job won't run without approval from two reviewers, and only from `main`. +5. **Release environment gate**: the Windows and macOS signing jobs won't run without approval from two reviewers, and only from `main`. 6. **Duplicate tag check** — the release workflow fails if the tag already exists. ---