From c6f2adc06855002273d3ae33febce05711e1e8d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:41:11 +0000 Subject: [PATCH 1/7] ci: Sign and notarize the macOS standalone binaries Build the darwin binaries on a macOS runner so they can be code signed with the Developer ID Application certificate under the hardened runtime, then notarized in a single notarytool submission covering both architectures. The compiled binaries carry their JavaScript bundle in a __BUN segment with no data trailing the Mach-O, so a signature lays out cleanly, and the job proves it by verifying the signature and running the signed binary. Signing is skipped, with a warning, when the Apple secrets are unset: the binaries then only get an ad-hoc signature, which keeps pull request builds working while exercising the same code path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoPNEdpFrKuJsfnDGcb33w --- .github/workflows/_build.yml | 338 +++++++++++++++++++++++++++++++++- .github/workflows/publish.yml | 2 + README.md | 55 +++++- 3 files changed, 390 insertions(+), 5 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index a061017d..1b8cebc1 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -9,13 +9,59 @@ on: type: string required: false default: '24' + bun_version: + description: The Bun version. + type: string + required: false + default: 1.2.14 + secrets: + apple_certificate: + description: >- + The Developer ID Application certificate and its private key, + as a base64 encoded PKCS#12 bundle including the certificate chain. + When unset, the macOS binaries only get an ad-hoc signature. + required: false + apple_certificate_password: + description: The password protecting the PKCS#12 bundle. + required: false + apple_signing_identity: + description: >- + The codesign identity to use, e.g., + Developer ID Application: Example, Inc. (XXXXXXXXXX). + Defaults to the Developer ID Application identity in the certificate. + required: false + apple_api_key: + description: >- + The base64 encoded App Store Connect API private key (.p8) + used to authenticate with the notary service. + required: false + apple_api_key_id: + description: The App Store Connect API key id. + required: false + apple_api_issuer_id: + description: The App Store Connect API issuer id. + required: false + apple_id: + description: >- + The Apple ID used to authenticate with the notary service. + Only used when no App Store Connect API key is set. + required: false + apple_app_specific_password: + description: The app-specific password for the Apple ID. + required: false + apple_team_id: + description: The Apple Developer team id. + required: false outputs: artifact_name: description: The artifact name. value: build-${{ github.sha }} + # Enables running this workflow by hand, e.g., to check the macOS signing + # credentials without cutting a release. + workflow_dispatch: {} jobs: - build: + package: name: Package runs-on: ubuntu-latest timeout-minutes: 30 @@ -25,21 +71,23 @@ jobs: - name: Setup uses: ./.github/actions/setup with: - node_version: ${{ inputs.node_version }} + node_version: ${{ inputs.node_version || '24' }} - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.2.14 + bun-version: ${{ inputs.bun_version || '1.2.14' }} - name: Build run: npm run build - name: Package run: npm pack - name: Build standalone binaries + # The macOS binaries are built and signed by the macos job. run: | + set -euo pipefail version=$(jq --raw-output '.version' package.json) rm -rf release mkdir -p release - for platform in linux-x64 linux-arm64 darwin-x64 darwin-arm64 windows-x64; do + for platform in linux-x64 linux-arm64 windows-x64; do binary="release/seam-v${version}-${platform}" if [[ "$platform" == windows-* ]]; then binary="${binary}.exe" @@ -51,7 +99,289 @@ jobs: run: | version=$(jq --raw-output '.version' package.json) tar -czf "release/seam-completions-v${version}.tar.gz" -C completions seam.bash seam.fish seam.zsh + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: build-${{ github.sha }}-package + if-no-files-found: error + path: | + *.tgz + release/* + + macos: + name: macOS binaries + runs-on: macos-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup + uses: ./.github/actions/setup + with: + node_version: ${{ inputs.node_version || '24' }} + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ inputs.bun_version || '1.2.14' }} + - name: Inject generated values + # The package job gets these from npm pack. Without them a binary + # reports the placeholder version instead of its own. + run: npm run prepack + - name: Build standalone binaries + run: | + set -euo pipefail + version=$(jq --raw-output '.version' package.json) + rm -rf release + mkdir -p release + for platform in darwin-x64 darwin-arm64; do + bun build src/bin/cli.ts --compile --minify \ + --target="bun-${platform}" --outfile="release/seam-v${version}-${platform}" + done + - name: Detect signing credentials + id: credentials + env: + APPLE_CERTIFICATE: ${{ secrets.apple_certificate }} + APPLE_API_KEY: ${{ secrets.apple_api_key }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.apple_app_specific_password }} + run: | + set -euo pipefail + + notarize=none + if [[ -n "${APPLE_CERTIFICATE}" ]]; then + sign=true + if [[ -n "${APPLE_API_KEY}" ]]; then + notarize=api-key + elif [[ -n "${APPLE_APP_SPECIFIC_PASSWORD}" ]]; then + notarize=apple-id + fi + else + sign=false + fi + + echo "sign=${sign}" >> "$GITHUB_OUTPUT" + echo "notarize=${notarize}" >> "$GITHUB_OUTPUT" + + if [[ "${sign}" != true ]]; then + echo '::warning title=Unsigned macOS binaries::No apple_certificate secret: signing the macOS binaries ad-hoc instead. Gatekeeper rejects an ad-hoc signature on any other machine, so these binaries are not fit to release.' + elif [[ "${notarize}" == none ]]; then + echo '::warning title=Unnotarized macOS binaries::No notary service credentials: skipping notarization. Gatekeeper blocks a binary downloaded through a browser until it is notarized.' + fi + - name: Create entitlements + # A compiled binary embeds the Bun runtime, so under the hardened + # runtime JavaScriptCore needs the JIT entitlements to start at all. + run: | + set -euo pipefail + cat > "${RUNNER_TEMP}/entitlements.plist" <<'PLIST' + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + + PLIST + - name: Import signing certificate + id: certificate + if: steps.credentials.outputs.sign == 'true' + env: + APPLE_CERTIFICATE: ${{ secrets.apple_certificate }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.apple_certificate_password }} + APPLE_SIGNING_IDENTITY: ${{ secrets.apple_signing_identity }} + run: | + set -euo pipefail + + keychain="${RUNNER_TEMP}/signing.keychain-db" + keychain_password=$(openssl rand -base64 24) + certificate="${RUNNER_TEMP}/certificate.p12" + + printf '%s' "${APPLE_CERTIFICATE}" | base64 --decode > "$certificate" + + security create-keychain -p "$keychain_password" "$keychain" + # Keep the keychain unlocked for the whole job: codesign cannot prompt. + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$certificate" -k "$keychain" \ + -P "${APPLE_CERTIFICATE_PASSWORD}" \ + -T /usr/bin/codesign + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain" > /dev/null + # codesign only searches the keychains on the user search list. + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + rm -f "$certificate" + + identity="${APPLE_SIGNING_IDENTITY}" + if [[ -z "$identity" ]]; then + identity=$(security find-identity -v -p codesigning "$keychain" | + awk '/Developer ID Application/ { print $2; exit }') + fi + if [[ -z "$identity" ]]; then + security find-identity -v -p codesigning "$keychain" + echo '::error title=Missing signing identity::The certificate holds no Developer ID Application identity.' + exit 1 + fi + + echo "keychain=${keychain}" >> "$GITHUB_OUTPUT" + echo "identity=${identity}" >> "$GITHUB_OUTPUT" + - name: Sign binaries + env: + # An ad-hoc signature keeps the credential-less build on the same code + # path, and is enough to run the binary on this runner. + IDENTITY: ${{ steps.certificate.outputs.identity || '-' }} + KEYCHAIN: ${{ steps.certificate.outputs.keychain }} + run: | + set -euo pipefail + + args=( + --sign "${IDENTITY}" + --force + --options runtime + --identifier com.getseam.cli + --entitlements "${RUNNER_TEMP}/entitlements.plist" + ) + if [[ -n "${KEYCHAIN}" ]]; then + # The notary service requires a secure timestamp, + # which an ad-hoc signature cannot carry. + args+=(--keychain "${KEYCHAIN}" --timestamp) + else + args+=(--timestamp=none) + fi + + for binary in release/seam-v*-darwin-*; do + # Bun signs its own output ad-hoc, and the darwin-x64 signature it + # writes reserves more room than the file holds. Drop it so codesign + # lays out the real signature from scratch. + codesign --remove-signature "$binary" || true + codesign "${args[@]}" "$binary" + done + - name: Notarize binaries + if: steps.credentials.outputs.notarize != 'none' + env: + NOTARIZE: ${{ steps.credentials.outputs.notarize }} + APPLE_API_KEY: ${{ secrets.apple_api_key }} + APPLE_API_KEY_ID: ${{ secrets.apple_api_key_id }} + APPLE_API_ISSUER_ID: ${{ secrets.apple_api_issuer_id }} + 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 + + key="${RUNNER_TEMP}/api-key.p8" + trap 'rm -f "$key"' EXIT + + case "${NOTARIZE}" in + api-key) + printf '%s' "${APPLE_API_KEY}" | base64 --decode > "$key" + auth=(--key "$key" --key-id "${APPLE_API_KEY_ID}" --issuer "${APPLE_API_ISSUER_ID}") + ;; + apple-id) + auth=( + --apple-id "${APPLE_ID}" + --password "${APPLE_APP_SPECIFIC_PASSWORD}" + --team-id "${APPLE_TEAM_ID}" + ) + ;; + *) + echo "::error::Unknown notarization method ${NOTARIZE}." + exit 1 + ;; + esac + + # notarytool only takes an archive, and the notary service notarizes + # every eligible binary inside it, so one submission covers both + # architectures. The ticket cannot be stapled to a bare executable: + # Gatekeeper looks it up online by the signature cdhash instead. + archive="${RUNNER_TEMP}/notarize.zip" + ditto -c -k --keepParent release "$archive" + + # notarytool exits non-zero on a rejected submission, + # which still reports why it was rejected. + submission=$(xcrun notarytool submit "$archive" "${auth[@]}" --wait --output-format json) || true + echo "$submission" + + id= + status= + if [[ -n "$submission" ]]; then + id=$(jq --raw-output '.id // empty' <<< "$submission") + status=$(jq --raw-output '.status // empty' <<< "$submission") + fi + + if [[ "$status" != Accepted ]]; then + if [[ -n "$id" ]]; then + xcrun notarytool log "$id" "${auth[@]}" || true + fi + echo "::error title=Notarization failed::Submission ${id:-unknown} finished as ${status:-unknown}." + exit 1 + fi + - name: Verify binaries + env: + NOTARIZE: ${{ steps.credentials.outputs.notarize }} + run: | + set -euo pipefail + version=$(jq --raw-output '.version' package.json) + + for binary in release/seam-v*-darwin-*; do + codesign --verify --strict --verbose=2 "$binary" + codesign --display --verbose=2 --entitlements - "$binary" + done + + binary="release/seam-v${version}-darwin-arm64" + + # How Gatekeeper sees the binary. Reported rather than enforced: the + # notary service already accepted it, and the ticket is fetched over + # the network, so a rejection here is not conclusive. + if [[ "${NOTARIZE}" != none ]]; then + spctl --assess --type exec --verbose=4 "$binary" || + echo "::warning title=Gatekeeper assessment failed::spctl rejected ${binary}." + fi + + # Signing rewrites a binary that also carries the compiled JavaScript + # bundle, so run it to prove the bundle survived. + "$binary" wizard --version + reported=$("$binary" --version) + if [[ "$reported" != "$version" ]]; then + echo "::error title=Wrong version::${binary} reports ${reported}, expected ${version}." + exit 1 + fi + - name: Delete keychain + if: always() && steps.certificate.outputs.keychain != '' + env: + KEYCHAIN: ${{ steps.certificate.outputs.keychain }} + run: | + security list-keychains -d user -s login.keychain-db + security delete-keychain "${KEYCHAIN}" + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: build-${{ github.sha }}-macos + if-no-files-found: error + path: release/* + + bundle: + name: Bundle + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: + - package + - macos + steps: + - name: Download package artifact + uses: actions/download-artifact@v8 + with: + name: build-${{ github.sha }}-package + path: . + - name: Download macOS artifact + uses: actions/download-artifact@v8 + with: + name: build-${{ github.sha }}-macos + path: release - name: Generate checksums + # Signing changes the macOS binaries, so checksum everything after it. working-directory: release run: sha256sum seam-* > checksums.txt - name: Upload artifact diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8b25d438..40ffff0a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,6 +12,8 @@ jobs: build: name: Build uses: ./.github/workflows/_build.yml + # Passes the Apple credentials used to sign and notarize the macOS binaries. + secrets: inherit release: name: GitHub Releases runs-on: ubuntu-latest diff --git a/README.md b/README.md index c04d8622..c8993ac5 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ $ npm install --global @seamapi/cli ``` Alternatively, download a standalone binary for your platform from the -[latest GitHub release]. +[latest GitHub release]. The macOS binaries are signed with the Seam Labs, Inc. +Apple Developer ID and notarized by Apple, so macOS runs them without +a Gatekeeper prompt. On Arch Linux, install the [`seam-bin`][aur] package from the AUR with @@ -396,6 +398,57 @@ The following repository secrets must be set on [GitHub Actions]: [GitHub Actions]: https://github.com/features/actions [GPG private key]: https://github.com/marketplace/actions/import-gpg#prerequisites +### Signing the macOS binaries + +The standalone macOS binaries are code signed and notarized while they are +built, so that Gatekeeper lets them run on machines other than the one that +built them. Both steps need an [Apple Developer Program] membership and are +skipped, with a warning, when their secrets are unset: the binaries then only +carry an ad-hoc signature and are not fit to release. + +Set these repository secrets to code sign: + +- `APPLE_CERTIFICATE`: A base64 encoded PKCS#12 (`.p12`) bundle holding a + [Developer ID Application] certificate, its private key, and its certificate + chain. Export it from Keychain Access, then encode it with + `base64 --input certificate.p12 | pbcopy`. +- `APPLE_CERTIFICATE_PASSWORD`: The password set while exporting the bundle. +- `APPLE_SIGNING_IDENTITY`: Optional. The identity to sign with, e.g., + `Developer ID Application: Seam Labs, Inc. (XXXXXXXXXX)`. Only needed when the + bundle holds more than one Developer ID Application certificate. + +Set these repository secrets to notarize, using either an +[App Store Connect API key] (preferred) or an Apple ID: + +- `APPLE_API_KEY`: The base64 encoded API private key (`.p8`). +- `APPLE_API_KEY_ID`: The API key id. +- `APPLE_API_ISSUER_ID`: The API key issuer id. + +or + +- `APPLE_ID`: The Apple ID of an account in the team. +- `APPLE_APP_SPECIFIC_PASSWORD`: An [app-specific password] for that Apple ID. +- `APPLE_TEAM_ID`: The Apple Developer team id. + +Notarization tickets cannot be stapled to a bare executable, so Gatekeeper +looks them up online the first time a downloaded binary runs. + +Check the credentials without cutting a release by triggering +[a build workflow_dispatch on GitHub Actions], on the web or with + +``` +$ gh workflow run _build.yml --ref +``` + +Then confirm the run's macOS binaries job signed and notarized: it verifies the +signature, assesses the binary the way Gatekeeper does, and runs it. + +[app-specific password]: https://support.apple.com/en-us/102654 +[App Store Connect API key]: https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api +[Apple Developer Program]: https://developer.apple.com/programs/ +[Developer ID Application]: https://developer.apple.com/help/account/reference/certificate-types/ +[a build workflow_dispatch on GitHub Actions]: https://github.com/seamapi/cli/actions?query=workflow%3A_build + ## Contributing > If using squash merge, edit and ensure the commit message follows the [Angular Commit Message Conventions] specification. From 4f2e028eefff7ec1163632dfe25aa7d253d0e33f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 20:28:20 +0000 Subject: [PATCH 2/7] ci: Import the macOS signing certificate with an action Replace the hand-rolled keychain setup with apple-actions/import-codesign-certs, which runs the same security commands and deletes the temporary keychain after the job, leaving only the codesign and notarytool calls as scripts. Drop the Apple ID and app-specific password notarization path: the notary service is authenticated with an App Store Connect API key alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoPNEdpFrKuJsfnDGcb33w --- .github/workflows/_build.yml | 105 ++++++++--------------------------- README.md | 11 +--- 2 files changed, 26 insertions(+), 90 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 1b8cebc1..c1d1f008 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -41,17 +41,6 @@ on: apple_api_issuer_id: description: The App Store Connect API issuer id. required: false - apple_id: - description: >- - The Apple ID used to authenticate with the notary service. - Only used when no App Store Connect API key is set. - required: false - apple_app_specific_password: - description: The app-specific password for the Apple ID. - required: false - apple_team_id: - description: The Apple Developer team id. - required: false outputs: artifact_name: description: The artifact name. @@ -142,20 +131,16 @@ jobs: env: APPLE_CERTIFICATE: ${{ secrets.apple_certificate }} APPLE_API_KEY: ${{ secrets.apple_api_key }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.apple_app_specific_password }} run: | set -euo pipefail - notarize=none + sign=false + notarize=false if [[ -n "${APPLE_CERTIFICATE}" ]]; then sign=true if [[ -n "${APPLE_API_KEY}" ]]; then - notarize=api-key - elif [[ -n "${APPLE_APP_SPECIFIC_PASSWORD}" ]]; then - notarize=apple-id + notarize=true fi - else - sign=false fi echo "sign=${sign}" >> "$GITHUB_OUTPUT" @@ -163,8 +148,8 @@ jobs: if [[ "${sign}" != true ]]; then echo '::warning title=Unsigned macOS binaries::No apple_certificate secret: signing the macOS binaries ad-hoc instead. Gatekeeper rejects an ad-hoc signature on any other machine, so these binaries are not fit to release.' - elif [[ "${notarize}" == none ]]; then - echo '::warning title=Unnotarized macOS binaries::No notary service credentials: skipping notarization. Gatekeeper blocks a binary downloaded through a browser until it is notarized.' + elif [[ "${notarize}" != true ]]; then + echo '::warning title=Unnotarized macOS binaries::No apple_api_key secret: skipping notarization. Gatekeeper blocks a binary downloaded through a browser until it is notarized.' fi - name: Create entitlements # A compiled binary embeds the Bun runtime, so under the hardened @@ -186,53 +171,38 @@ jobs: PLIST - name: Import signing certificate - id: certificate + # Imports into a temporary keychain on the search list, + # and deletes it after the job. + if: steps.credentials.outputs.sign == 'true' + uses: apple-actions/import-codesign-certs@v7 + with: + p12-file-base64: ${{ secrets.apple_certificate }} + p12-password: ${{ secrets.apple_certificate_password }} + - name: Resolve signing identity + id: identity if: steps.credentials.outputs.sign == 'true' env: - APPLE_CERTIFICATE: ${{ secrets.apple_certificate }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.apple_certificate_password }} APPLE_SIGNING_IDENTITY: ${{ secrets.apple_signing_identity }} run: | set -euo pipefail - keychain="${RUNNER_TEMP}/signing.keychain-db" - keychain_password=$(openssl rand -base64 24) - certificate="${RUNNER_TEMP}/certificate.p12" - - printf '%s' "${APPLE_CERTIFICATE}" | base64 --decode > "$certificate" - - security create-keychain -p "$keychain_password" "$keychain" - # Keep the keychain unlocked for the whole job: codesign cannot prompt. - security set-keychain-settings -lut 21600 "$keychain" - security unlock-keychain -p "$keychain_password" "$keychain" - security import "$certificate" -k "$keychain" \ - -P "${APPLE_CERTIFICATE_PASSWORD}" \ - -T /usr/bin/codesign - security set-key-partition-list \ - -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain" > /dev/null - # codesign only searches the keychains on the user search list. - security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') - rm -f "$certificate" - identity="${APPLE_SIGNING_IDENTITY}" if [[ -z "$identity" ]]; then - identity=$(security find-identity -v -p codesigning "$keychain" | + identity=$(security find-identity -v -p codesigning | awk '/Developer ID Application/ { print $2; exit }') fi if [[ -z "$identity" ]]; then - security find-identity -v -p codesigning "$keychain" + security find-identity -v -p codesigning echo '::error title=Missing signing identity::The certificate holds no Developer ID Application identity.' exit 1 fi - echo "keychain=${keychain}" >> "$GITHUB_OUTPUT" echo "identity=${identity}" >> "$GITHUB_OUTPUT" - name: Sign binaries env: # An ad-hoc signature keeps the credential-less build on the same code # path, and is enough to run the binary on this runner. - IDENTITY: ${{ steps.certificate.outputs.identity || '-' }} - KEYCHAIN: ${{ steps.certificate.outputs.keychain }} + IDENTITY: ${{ steps.identity.outputs.identity || '-' }} run: | set -euo pipefail @@ -243,12 +213,12 @@ jobs: --identifier com.getseam.cli --entitlements "${RUNNER_TEMP}/entitlements.plist" ) - if [[ -n "${KEYCHAIN}" ]]; then + if [[ "${IDENTITY}" == - ]]; then # The notary service requires a secure timestamp, # which an ad-hoc signature cannot carry. - args+=(--keychain "${KEYCHAIN}" --timestamp) - else args+=(--timestamp=none) + else + args+=(--timestamp) fi for binary in release/seam-v*-darwin-*; do @@ -259,38 +229,18 @@ jobs: codesign "${args[@]}" "$binary" done - name: Notarize binaries - if: steps.credentials.outputs.notarize != 'none' + if: steps.credentials.outputs.notarize == 'true' env: - NOTARIZE: ${{ steps.credentials.outputs.notarize }} APPLE_API_KEY: ${{ secrets.apple_api_key }} APPLE_API_KEY_ID: ${{ secrets.apple_api_key_id }} APPLE_API_ISSUER_ID: ${{ secrets.apple_api_issuer_id }} - 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 key="${RUNNER_TEMP}/api-key.p8" trap 'rm -f "$key"' EXIT - - case "${NOTARIZE}" in - api-key) - printf '%s' "${APPLE_API_KEY}" | base64 --decode > "$key" - auth=(--key "$key" --key-id "${APPLE_API_KEY_ID}" --issuer "${APPLE_API_ISSUER_ID}") - ;; - apple-id) - auth=( - --apple-id "${APPLE_ID}" - --password "${APPLE_APP_SPECIFIC_PASSWORD}" - --team-id "${APPLE_TEAM_ID}" - ) - ;; - *) - echo "::error::Unknown notarization method ${NOTARIZE}." - exit 1 - ;; - esac + printf '%s' "${APPLE_API_KEY}" | base64 --decode > "$key" + auth=(--key "$key" --key-id "${APPLE_API_KEY_ID}" --issuer "${APPLE_API_ISSUER_ID}") # notarytool only takes an archive, and the notary service notarizes # every eligible binary inside it, so one submission covers both @@ -335,7 +285,7 @@ jobs: # How Gatekeeper sees the binary. Reported rather than enforced: the # notary service already accepted it, and the ticket is fetched over # the network, so a rejection here is not conclusive. - if [[ "${NOTARIZE}" != none ]]; then + if [[ "${NOTARIZE}" == true ]]; then spctl --assess --type exec --verbose=4 "$binary" || echo "::warning title=Gatekeeper assessment failed::spctl rejected ${binary}." fi @@ -348,13 +298,6 @@ jobs: echo "::error title=Wrong version::${binary} reports ${reported}, expected ${version}." exit 1 fi - - name: Delete keychain - if: always() && steps.certificate.outputs.keychain != '' - env: - KEYCHAIN: ${{ steps.certificate.outputs.keychain }} - run: | - security list-keychains -d user -s login.keychain-db - security delete-keychain "${KEYCHAIN}" - name: Upload artifact uses: actions/upload-artifact@v7 with: diff --git a/README.md b/README.md index c8993ac5..61946153 100644 --- a/README.md +++ b/README.md @@ -417,19 +417,13 @@ Set these repository secrets to code sign: `Developer ID Application: Seam Labs, Inc. (XXXXXXXXXX)`. Only needed when the bundle holds more than one Developer ID Application certificate. -Set these repository secrets to notarize, using either an -[App Store Connect API key] (preferred) or an Apple ID: +Set these repository secrets to notarize, from an +[App Store Connect API key]: - `APPLE_API_KEY`: The base64 encoded API private key (`.p8`). - `APPLE_API_KEY_ID`: The API key id. - `APPLE_API_ISSUER_ID`: The API key issuer id. -or - -- `APPLE_ID`: The Apple ID of an account in the team. -- `APPLE_APP_SPECIFIC_PASSWORD`: An [app-specific password] for that Apple ID. -- `APPLE_TEAM_ID`: The Apple Developer team id. - Notarization tickets cannot be stapled to a bare executable, so Gatekeeper looks them up online the first time a downloaded binary runs. @@ -443,7 +437,6 @@ $ gh workflow run _build.yml --ref Then confirm the run's macOS binaries job signed and notarized: it verifies the signature, assesses the binary the way Gatekeeper does, and runs it. -[app-specific password]: https://support.apple.com/en-us/102654 [App Store Connect API key]: https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api [Apple Developer Program]: https://developer.apple.com/programs/ [Developer ID Application]: https://developer.apple.com/help/account/reference/certificate-types/ From d15b67131f6d4191e9a1224e0fb0f6cbe967aa31 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 20:34:28 +0000 Subject: [PATCH 3/7] ci: Add a manual entrypoint for the build workflow Move the manual trigger out of _build.yml and into a caller, following the _publish.yml and publish.yml pair. A reusable workflow only ever populates inputs from workflow_call, so serving a workflow_dispatch from the same file meant defaulting every input again at each use. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoPNEdpFrKuJsfnDGcb33w --- .github/workflows/_build.yml | 15 ++++++--------- .github/workflows/build.yml | 14 ++++++++++++++ README.md | 2 +- 3 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index c1d1f008..be265088 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -45,12 +45,9 @@ on: artifact_name: description: The artifact name. value: build-${{ github.sha }} - # Enables running this workflow by hand, e.g., to check the macOS signing - # credentials without cutting a release. - workflow_dispatch: {} jobs: - package: + build: name: Package runs-on: ubuntu-latest timeout-minutes: 30 @@ -60,11 +57,11 @@ jobs: - name: Setup uses: ./.github/actions/setup with: - node_version: ${{ inputs.node_version || '24' }} + node_version: ${{ inputs.node_version }} - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: ${{ inputs.bun_version || '1.2.14' }} + bun-version: ${{ inputs.bun_version }} - name: Build run: npm run build - name: Package @@ -107,11 +104,11 @@ jobs: - name: Setup uses: ./.github/actions/setup with: - node_version: ${{ inputs.node_version || '24' }} + node_version: ${{ inputs.node_version }} - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: ${{ inputs.bun_version || '1.2.14' }} + bun-version: ${{ inputs.bun_version }} - name: Inject generated values # The package job gets these from npm pack. Without them a binary # reports the placeholder version instead of its own. @@ -310,7 +307,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 needs: - - package + - build - macos steps: - name: Download package artifact diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..fad31e1d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,14 @@ +--- +name: Build + +run-name: Build ${{ github.ref_name }} + +on: + workflow_dispatch: {} + +jobs: + build: + name: Build + uses: ./.github/workflows/_build.yml + # Checks the macOS signing credentials without cutting a release. + secrets: inherit diff --git a/README.md b/README.md index 61946153..f81f1fd8 100644 --- a/README.md +++ b/README.md @@ -431,7 +431,7 @@ Check the credentials without cutting a release by triggering [a build workflow_dispatch on GitHub Actions], on the web or with ``` -$ gh workflow run _build.yml --ref +$ gh workflow run build.yml --ref ``` Then confirm the run's macOS binaries job signed and notarized: it verifies the From 540120ebab17696fda631b1b4b2c3b438a182107 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 20:40:55 +0000 Subject: [PATCH 4/7] ci: Never sign the macOS binaries ad-hoc Gatekeeper rejects an ad-hoc signature on any machine but the one that made it, so falling back to one only produced binaries that look signed. Take a sign input instead: when it is set, every Apple credential is required and the job fails without them, and when it is not, the binaries are built but left alone and reported as unreleasable. Anything that releases signs. A check opts out, since a pull request from a fork cannot read the credentials. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoPNEdpFrKuJsfnDGcb33w --- .github/workflows/_build.yml | 103 ++++++++++++++++++----------------- .github/workflows/check.yml | 4 ++ README.md | 13 +++-- 3 files changed, 65 insertions(+), 55 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index be265088..171c25a1 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -14,12 +14,21 @@ on: type: string required: false default: 1.2.14 + sign: + description: >- + Code sign and notarize the macOS binaries. + Every credential below is then required: the build fails rather than + release a binary that is not properly signed. Pass false to only + build them, e.g., for a check on a pull request from a fork, + which cannot read the credentials. + type: boolean + required: false + default: true secrets: apple_certificate: description: >- The Developer ID Application certificate and its private key, as a base64 encoded PKCS#12 bundle including the certificate chain. - When unset, the macOS binaries only get an ad-hoc signature. required: false apple_certificate_password: description: The password protecting the PKCS#12 bundle. @@ -99,6 +108,29 @@ jobs: runs-on: macos-latest timeout-minutes: 60 steps: + - name: Check signing credentials + # Fails the job before it builds anything rather than fall back to a + # signature that Gatekeeper rejects. + if: inputs.sign + env: + APPLE_CERTIFICATE: ${{ secrets.apple_certificate }} + APPLE_API_KEY: ${{ secrets.apple_api_key }} + APPLE_API_KEY_ID: ${{ secrets.apple_api_key_id }} + APPLE_API_ISSUER_ID: ${{ secrets.apple_api_issuer_id }} + run: | + set -euo pipefail + + missing= + for name in APPLE_CERTIFICATE APPLE_API_KEY APPLE_API_KEY_ID APPLE_API_ISSUER_ID; do + if [[ -z "${!name}" ]]; then + missing="${missing} ${name}" + fi + done + + if [[ -n "${missing}" ]]; then + echo "::error title=Missing Apple credentials::Set the${missing} repository secret, or call this workflow with sign: false to build the macOS binaries without signing them." + exit 1 + fi - name: Checkout uses: actions/checkout@v7 - name: Setup @@ -123,34 +155,10 @@ jobs: bun build src/bin/cli.ts --compile --minify \ --target="bun-${platform}" --outfile="release/seam-v${version}-${platform}" done - - name: Detect signing credentials - id: credentials - env: - APPLE_CERTIFICATE: ${{ secrets.apple_certificate }} - APPLE_API_KEY: ${{ secrets.apple_api_key }} - run: | - set -euo pipefail - - sign=false - notarize=false - if [[ -n "${APPLE_CERTIFICATE}" ]]; then - sign=true - if [[ -n "${APPLE_API_KEY}" ]]; then - notarize=true - fi - fi - - echo "sign=${sign}" >> "$GITHUB_OUTPUT" - echo "notarize=${notarize}" >> "$GITHUB_OUTPUT" - - if [[ "${sign}" != true ]]; then - echo '::warning title=Unsigned macOS binaries::No apple_certificate secret: signing the macOS binaries ad-hoc instead. Gatekeeper rejects an ad-hoc signature on any other machine, so these binaries are not fit to release.' - elif [[ "${notarize}" != true ]]; then - echo '::warning title=Unnotarized macOS binaries::No apple_api_key secret: skipping notarization. Gatekeeper blocks a binary downloaded through a browser until it is notarized.' - fi - name: Create entitlements # A compiled binary embeds the Bun runtime, so under the hardened # runtime JavaScriptCore needs the JIT entitlements to start at all. + if: inputs.sign run: | set -euo pipefail cat > "${RUNNER_TEMP}/entitlements.plist" <<'PLIST' @@ -170,14 +178,14 @@ jobs: - name: Import signing certificate # Imports into a temporary keychain on the search list, # and deletes it after the job. - if: steps.credentials.outputs.sign == 'true' + if: inputs.sign uses: apple-actions/import-codesign-certs@v7 with: p12-file-base64: ${{ secrets.apple_certificate }} p12-password: ${{ secrets.apple_certificate_password }} - name: Resolve signing identity id: identity - if: steps.credentials.outputs.sign == 'true' + if: inputs.sign env: APPLE_SIGNING_IDENTITY: ${{ secrets.apple_signing_identity }} run: | @@ -196,10 +204,9 @@ jobs: echo "identity=${identity}" >> "$GITHUB_OUTPUT" - name: Sign binaries + if: inputs.sign env: - # An ad-hoc signature keeps the credential-less build on the same code - # path, and is enough to run the binary on this runner. - IDENTITY: ${{ steps.identity.outputs.identity || '-' }} + IDENTITY: ${{ steps.identity.outputs.identity }} run: | set -euo pipefail @@ -208,15 +215,10 @@ jobs: --force --options runtime --identifier com.getseam.cli + # The notary service requires a secure timestamp. + --timestamp --entitlements "${RUNNER_TEMP}/entitlements.plist" ) - if [[ "${IDENTITY}" == - ]]; then - # The notary service requires a secure timestamp, - # which an ad-hoc signature cannot carry. - args+=(--timestamp=none) - else - args+=(--timestamp) - fi for binary in release/seam-v*-darwin-*; do # Bun signs its own output ad-hoc, and the darwin-x64 signature it @@ -226,7 +228,7 @@ jobs: codesign "${args[@]}" "$binary" done - name: Notarize binaries - if: steps.credentials.outputs.notarize == 'true' + if: inputs.sign env: APPLE_API_KEY: ${{ secrets.apple_api_key }} APPLE_API_KEY_ID: ${{ secrets.apple_api_key_id }} @@ -267,24 +269,25 @@ jobs: fi - name: Verify binaries env: - NOTARIZE: ${{ steps.credentials.outputs.notarize }} + SIGN: ${{ inputs.sign }} run: | set -euo pipefail version=$(jq --raw-output '.version' package.json) - - for binary in release/seam-v*-darwin-*; do - codesign --verify --strict --verbose=2 "$binary" - codesign --display --verbose=2 --entitlements - "$binary" - done - binary="release/seam-v${version}-darwin-arm64" - # How Gatekeeper sees the binary. Reported rather than enforced: the - # notary service already accepted it, and the ticket is fetched over - # the network, so a rejection here is not conclusive. - if [[ "${NOTARIZE}" == true ]]; then + if [[ "${SIGN}" == true ]]; then + for signed in release/seam-v*-darwin-*; do + codesign --verify --strict --verbose=2 "$signed" + codesign --display --verbose=2 --entitlements - "$signed" + done + + # How Gatekeeper sees the binary. Reported rather than enforced: the + # notary service already accepted it, and the ticket is fetched over + # the network, so a rejection here is not conclusive. spctl --assess --type exec --verbose=4 "$binary" || echo "::warning title=Gatekeeper assessment failed::spctl rejected ${binary}." + else + echo '::warning title=Unsigned macOS binaries::Built with sign: false. These binaries are not signed by Seam and must not be released.' fi # Signing rewrites a binary that also carries the compiled JavaScript diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index f84279d3..3e53945d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -56,6 +56,10 @@ jobs: build: name: Build uses: ./.github/workflows/_build.yml + with: + # A pull request from a fork cannot read the signing credentials, + # and a check never releases what it builds. + sign: false install: name: Install (Node.js v${{ matrix.node }} on ${{ matrix.os_name }}) runs-on: ${{ matrix.os }} diff --git a/README.md b/README.md index f81f1fd8..16aaea9a 100644 --- a/README.md +++ b/README.md @@ -402,9 +402,13 @@ The following repository secrets must be set on [GitHub Actions]: The standalone macOS binaries are code signed and notarized while they are built, so that Gatekeeper lets them run on machines other than the one that -built them. Both steps need an [Apple Developer Program] membership and are -skipped, with a warning, when their secrets are unset: the binaries then only -carry an ad-hoc signature and are not fit to release. +built them. Both need an [Apple Developer Program] membership. + +Anything that releases signs, and fails rather than publish a binary that +macOS refuses to run. A check builds the macOS binaries with `sign: false` +instead: a pull request from a fork cannot read the credentials, and nothing a +check builds is ever released. There is no ad-hoc signature fallback, since +Gatekeeper rejects one anywhere but the machine that made it. Set these repository secrets to code sign: @@ -417,8 +421,7 @@ Set these repository secrets to code sign: `Developer ID Application: Seam Labs, Inc. (XXXXXXXXXX)`. Only needed when the bundle holds more than one Developer ID Application certificate. -Set these repository secrets to notarize, from an -[App Store Connect API key]: +And these to notarize, from an [App Store Connect API key]: - `APPLE_API_KEY`: The base64 encoded API private key (`.p8`). - `APPLE_API_KEY_ID`: The API key id. From e216aeb61d93a4608ba5d20e6e086d21c6bc942a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 20:49:00 +0000 Subject: [PATCH 5/7] ci: Name the build jobs after what they build linux builds the Linux and Windows binaries alongside the npm package, macos builds and signs the macOS ones, and build assembles the artifact that both feed. The job display names, and so the check names, stay as they were. Drop the comments that only restate the line below them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoPNEdpFrKuJsfnDGcb33w --- .github/workflows/_build.yml | 7 +++---- .github/workflows/build.yml | 1 - .github/workflows/check.yml | 2 -- .github/workflows/publish.yml | 1 - 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 171c25a1..321e71e0 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -56,7 +56,7 @@ on: value: build-${{ github.sha }} jobs: - build: + linux: name: Package runs-on: ubuntu-latest timeout-minutes: 30 @@ -305,12 +305,12 @@ jobs: if-no-files-found: error path: release/* - bundle: + build: name: Bundle runs-on: ubuntu-latest timeout-minutes: 30 needs: - - build + - linux - macos steps: - name: Download package artifact @@ -324,7 +324,6 @@ jobs: name: build-${{ github.sha }}-macos path: release - name: Generate checksums - # Signing changes the macOS binaries, so checksum everything after it. working-directory: release run: sha256sum seam-* > checksums.txt - name: Upload artifact diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fad31e1d..e530b969 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,5 +10,4 @@ jobs: build: name: Build uses: ./.github/workflows/_build.yml - # Checks the macOS signing credentials without cutting a release. secrets: inherit diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3e53945d..2a64b8b3 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -57,8 +57,6 @@ jobs: name: Build uses: ./.github/workflows/_build.yml with: - # A pull request from a fork cannot read the signing credentials, - # and a check never releases what it builds. sign: false install: name: Install (Node.js v${{ matrix.node }} on ${{ matrix.os_name }}) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 40ffff0a..80112643 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,7 +12,6 @@ jobs: build: name: Build uses: ./.github/workflows/_build.yml - # Passes the Apple credentials used to sign and notarize the macOS binaries. secrets: inherit release: name: GitHub Releases From 3e6f710faeb1d537af749efb38bca9f42ca2f4d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 21:44:30 +0000 Subject: [PATCH 6/7] docs: Note that the signing identity is normally unset The build resolves the one Developer ID Application identity in the imported certificate, so the secret only matters when there is more than one to choose between. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoPNEdpFrKuJsfnDGcb33w --- .github/workflows/_build.yml | 3 ++- README.md | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 321e71e0..f0a9984e 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -37,7 +37,8 @@ on: description: >- The codesign identity to use, e.g., Developer ID Application: Example, Inc. (XXXXXXXXXX). - Defaults to the Developer ID Application identity in the certificate. + Normally unset: it defaults to the one Developer ID Application + identity in the certificate. required: false apple_api_key: description: >- diff --git a/README.md b/README.md index 16aaea9a..65cddcc3 100644 --- a/README.md +++ b/README.md @@ -417,9 +417,10 @@ Set these repository secrets to code sign: chain. Export it from Keychain Access, then encode it with `base64 --input certificate.p12 | pbcopy`. - `APPLE_CERTIFICATE_PASSWORD`: The password set while exporting the bundle. -- `APPLE_SIGNING_IDENTITY`: Optional. The identity to sign with, e.g., - `Developer ID Application: Seam Labs, Inc. (XXXXXXXXXX)`. Only needed when the - bundle holds more than one Developer ID Application certificate. +- `APPLE_SIGNING_IDENTITY`: Normally left unset. The build signs with the one + Developer ID Application identity in the bundle, so this is only needed to + choose between several, e.g., + `Developer ID Application: Seam Labs, Inc. (XXXXXXXXXX)`. And these to notarize, from an [App Store Connect API key]: From d0bf3042aa2af7fcd762a7effdec0bd994a97fca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 21:48:54 +0000 Subject: [PATCH 7/7] docs: Note when the manual build workflow becomes available GitHub only offers a workflow_dispatch for a workflow on the default branch, so the credentials cannot be checked this way until the workflow is merged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoPNEdpFrKuJsfnDGcb33w --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 65cddcc3..756dafc2 100644 --- a/README.md +++ b/README.md @@ -438,6 +438,10 @@ Check the credentials without cutting a release by triggering $ gh workflow run build.yml --ref ``` +GitHub only offers a workflow_dispatch for a workflow already on the default +branch, so the build workflow must be merged first. It then runs against any +branch. + Then confirm the run's macOS binaries job signed and notarized: it verifies the signature, assesses the binary the way Gatekeeper does, and runs it.