diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index a061017d..f0a9984e 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -9,13 +9,55 @@ on: type: string required: false default: '24' + bun_version: + description: The Bun version. + 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. + 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). + Normally unset: it defaults to the one 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 outputs: artifact_name: description: The artifact name. value: build-${{ github.sha }} jobs: - build: + linux: name: Package runs-on: ubuntu-latest timeout-minutes: 30 @@ -29,17 +71,19 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.2.14 + bun-version: ${{ inputs.bun_version }} - 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,6 +95,235 @@ 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: 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 + uses: ./.github/actions/setup + with: + node_version: ${{ inputs.node_version }} + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + 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. + 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: 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' + + + + + 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 + # Imports into a temporary keychain on the search list, + # and deletes it after the job. + 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: inputs.sign + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.apple_signing_identity }} + run: | + set -euo pipefail + + identity="${APPLE_SIGNING_IDENTITY}" + if [[ -z "$identity" ]]; then + 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 + echo '::error title=Missing signing identity::The certificate holds no Developer ID Application identity.' + exit 1 + fi + + echo "identity=${identity}" >> "$GITHUB_OUTPUT" + - name: Sign binaries + if: inputs.sign + env: + IDENTITY: ${{ steps.identity.outputs.identity }} + run: | + set -euo pipefail + + args=( + --sign "${IDENTITY}" + --force + --options runtime + --identifier com.getseam.cli + # The notary service requires a secure timestamp. + --timestamp + --entitlements "${RUNNER_TEMP}/entitlements.plist" + ) + + 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: inputs.sign + env: + 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 + + key="${RUNNER_TEMP}/api-key.p8" + trap 'rm -f "$key"' EXIT + 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 + # 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: + SIGN: ${{ inputs.sign }} + run: | + set -euo pipefail + version=$(jq --raw-output '.version' package.json) + binary="release/seam-v${version}-darwin-arm64" + + 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 + # 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: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: build-${{ github.sha }}-macos + if-no-files-found: error + path: release/* + + build: + name: Bundle + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: + - linux + - 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 working-directory: release run: sha256sum seam-* > checksums.txt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..e530b969 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,13 @@ +--- +name: Build + +run-name: Build ${{ github.ref_name }} + +on: + workflow_dispatch: {} + +jobs: + build: + name: Build + uses: ./.github/workflows/_build.yml + secrets: inherit diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index f84279d3..2a64b8b3 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -56,6 +56,8 @@ jobs: build: name: Build uses: ./.github/workflows/_build.yml + with: + sign: false install: name: Install (Node.js v${{ matrix.node }} on ${{ matrix.os_name }}) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8b25d438..80112643 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,6 +12,7 @@ jobs: build: name: Build uses: ./.github/workflows/_build.yml + secrets: inherit release: name: GitHub Releases runs-on: ubuntu-latest diff --git a/README.md b/README.md index c04d8622..756dafc2 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,58 @@ 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 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: + +- `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`: 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]: + +- `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. + +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 +``` + +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. + +[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.