diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 122550d..51fb8b6 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -17,8 +17,8 @@ concurrency: jobs: release: - runs-on: ubuntu-latest - timeout-minutes: 10 + runs-on: macos-latest + timeout-minutes: 30 steps: - name: Checkout Repository @@ -73,6 +73,30 @@ jobs: GOOS=darwin GOARCH=amd64 go build -trimpath -o dist/embed-code-macos main.go chmod +x dist/embed-code-linux dist/embed-code-macos + # Sign the macOS binary with a Developer ID certificate stored in GitHub + # secrets. The certificate must be exported as a base64-encoded .p12 file. + - name: Sign macOS Binary + if: steps.release.outputs.publish == 'true' + shell: bash + env: + MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }} + run: | + scripts/release/sign-macos-binary.sh dist/embed-code-macos + + # Notarize the macOS ZIP that will be published as the release asset. + - name: Notarize macOS Binary + if: steps.release.outputs.publish == 'true' + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + scripts/release/notarize-macos-zip.sh dist/embed-code-macos dist/embed-code-macos.zip + rm dist/embed-code-macos + # Create the release for the current commit and attach all generated binaries. - name: Publish GitHub Release if: steps.release.outputs.publish == 'true' diff --git a/PROJECT.md b/PROJECT.md index f2076c5..fc120d9 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -30,6 +30,8 @@ them inside code fences, and checks whether existing snippets are up-to-date. - `type/`: YAML-compatible string and named-path list types. The import path segment is `type`, but the Go package identifier is `_type` because `type` is a Go keyword. +- `scripts/release/`: helper scripts used by release workflows for signing and + notarizing macOS binaries. - `test/resources/`: parser, embedding, configuration, and source-code fixtures. - `showcase/`: executable user guide and end-to-end example suite. @@ -56,7 +58,8 @@ This repository is configured with these GitHub workflows: - `check`: runs linting, the normal Go test suite, and the showcase end-to-end tests across supported platforms. - `release-binaries`: reads `VERSION`, builds Linux, macOS, and Windows - binaries, and creates the matching GitHub Release on pushes to `master`. + binaries, signs and notarizes the macOS ZIP, and creates the matching GitHub + Release on pushes to `master`. The release tag is `v` from `VERSION`. When the release already exists, the workflow emits a warning and finishes successfully without rebuilding or diff --git a/README.md b/README.md index 7f070e0..d20fac6 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,12 @@ On Linux, for example: > chmod +x embed-code-linux > ``` -> Since binary file for macOS is not signed, it may be necessary -> to change its attributes to allow execution: -> ```bash -> xattr -d com.apple.quarantine embed-code-macos -> ``` +On macOS, download `embed-code-macos.zip`, unzip it, and run the binary: + +```bash +unzip embed-code-macos.zip +./embed-code-macos -mode=check -config-path=showcase/embedding/embed-code.yml +``` Or run it with Go: diff --git a/scripts/release/README.md b/scripts/release/README.md new file mode 100644 index 0000000..3755ffb --- /dev/null +++ b/scripts/release/README.md @@ -0,0 +1,47 @@ +# Release Scripts + +These scripts support the `release-binaries` GitHub workflow. +They are intended for the macOS release job. + +## Signing + +Use `sign-macos-binary.sh` to import the Developer ID certificate into a +temporary keychain and sign the macOS binary. + +Required environment variables: + +- `MACOS_CERTIFICATE_P12_BASE64`: base64-encoded `.p12` export of the Developer + ID Application certificate and private key. +- `MACOS_CERTIFICATE_PASSWORD`: password used when exporting the `.p12` file. +- `MACOS_CODESIGN_IDENTITY`: full Developer ID Application identity, such as + `Developer ID Application: Company Name (TEAMID)`. + +Optional environment variable: + +- `MACOS_KEYCHAIN_PASSWORD`: password for the temporary keychain. When omitted, + the script generates one. + +Example: + +```bash +scripts/release/sign-macos-binary.sh dist/embed-code-macos +``` + +## Notarization + +Use `notarize-macos-zip.sh` to package the signed CLI binary as a ZIP archive +and submit that archive to Apple notarization. + +Required environment variables: + +- `APPLE_ID`: Apple Account email used for notarization. +- `APPLE_TEAM_ID`: 10-character Apple Developer Team ID. +- `APPLE_APP_SPECIFIC_PASSWORD`: app-specific password for the Apple Account. + +Example: + +```bash +scripts/release/notarize-macos-zip.sh \ + dist/embed-code-macos \ + dist/embed-code-macos.zip +``` diff --git a/scripts/release/notarize-macos-zip.sh b/scripts/release/notarize-macos-zip.sh new file mode 100755 index 0000000..957050e --- /dev/null +++ b/scripts/release/notarize-macos-zip.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +# Packages and notarizes the signed macOS CLI binary. +# +# Usage: +# scripts/release/notarize-macos-zip.sh \ +# dist/embed-code-macos \ +# dist/embed-code-macos.zip +# +# The script expects these environment variables: +# APPLE_ID +# APPLE_TEAM_ID +# APPLE_APP_SPECIFIC_PASSWORD +# +# Apple notarization accepts an archive/package submission, so the CLI binary is +# wrapped in a ZIP instead of being uploaded as a loose executable. +# +# The script writes the ZIP path passed as its second argument and waits for +# Apple to accept or reject the notarization submission. +set -euo pipefail + +# Verifies, that secrets are set. +required_vars=( + APPLE_ID + APPLE_TEAM_ID + APPLE_APP_SPECIFIC_PASSWORD +) + +missing=() +for var_name in "${required_vars[@]}"; do + if [[ -z "${!var_name:-}" ]]; then + missing+=("$var_name") + fi +done +if (( ${#missing[@]} > 0 )); then + printf 'Missing required environment variable: %s\n' "${missing[@]}" >&2 + exit 1 +fi + +if (( $# != 2 )); then + echo "Usage: $0 " >&2 + exit 1 +fi + +binary_path="$1" +zip_path="$2" + +if [[ ! -f "$binary_path" ]]; then + echo "Signed macOS binary does not exist: $binary_path" >&2 + exit 1 +fi + +binary_dir="$(cd "$(dirname "$binary_path")" && pwd)" +binary_name="$(basename "$binary_path")" +zip_dir="$(dirname "$zip_path")" +zip_name="$(basename "$zip_path")" + +# Resolve the ZIP path before changing directories. +mkdir -p "$zip_dir" +zip_dir="$(cd "$zip_dir" && pwd)" +zip_path="$zip_dir/$zip_name" + +pushd "$binary_dir" >/dev/null +ditto -c -k --keepParent "$binary_name" "$zip_path" +popd >/dev/null + +# Wait for the notarization result before publishing. +# ZIP archives do not support stapling, so the release publishes the accepted archive as-is. +xcrun notarytool submit "$zip_path" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + --wait diff --git a/scripts/release/sign-macos-binary.sh b/scripts/release/sign-macos-binary.sh new file mode 100755 index 0000000..91cebe2 --- /dev/null +++ b/scripts/release/sign-macos-binary.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Signs the macOS CLI binary with a Developer ID Application certificate. +# +# Usage: +# scripts/release/sign-macos-binary.sh dist/embed-code-macos +# +# The script expects these environment variables: +# MACOS_CERTIFICATE_P12_BASE64 +# MACOS_CERTIFICATE_PASSWORD +# MACOS_CODESIGN_IDENTITY +# +# The certificate value must be a base64-encoded `.p12` export that contains the +# Developer ID Application certificate and private key. +# `MACOS_CODESIGN_IDENTITY` is the full identity printed by +# `security find-identity`, such as: +# +# Developer ID Application: Company Name (TEAMID) +# +# For local diagnostics, `MACOS_KEYCHAIN_PASSWORD` can be set to reuse a known +# temporary keychain password. +# +# The script writes temporary certificate and keychain files under RUNNER_TEMP, +# or TMPDIR when RUNNER_TEMP is not set. It signs the binary in place and verifies +# the resulting signature. +set -euo pipefail + +# Verifies, that secrets are set. +required_vars=( + MACOS_CERTIFICATE_P12_BASE64 + MACOS_CERTIFICATE_PASSWORD + MACOS_CODESIGN_IDENTITY +) + +missing=() +for var_name in "${required_vars[@]}"; do + if [[ -z "${!var_name:-}" ]]; then + missing+=("$var_name") + fi +done +if (( ${#missing[@]} > 0 )); then + printf 'Missing required environment variable: %s\n' "${missing[@]}" >&2 + exit 1 +fi + +if (( $# != 1 )); then + echo "Usage: $0 " >&2 + exit 1 +fi + +binary_path="$1" +if [[ ! -f "$binary_path" ]]; then + echo "macOS binary does not exist: $binary_path" >&2 + exit 1 +fi + +temp_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +keychain_path="$temp_dir/embed-code-signing.keychain-db" +certificate_path="$temp_dir/embed-code-signing-certificate.p12" +keychain_password="${MACOS_KEYCHAIN_PASSWORD:-$(uuidgen)}" + +# Decode the certificate at runtime so only the temporary runner filesystem ever +# contains the `.p12`. +if printf '%s' "$MACOS_CERTIFICATE_P12_BASE64" | base64 --decode > "$certificate_path" 2>/dev/null; then + : +else + printf '%s' "$MACOS_CERTIFICATE_P12_BASE64" | base64 -D > "$certificate_path" +fi + +security create-keychain -p "$keychain_password" "$keychain_path" +security set-keychain-settings -lut 21600 "$keychain_path" +security unlock-keychain -p "$keychain_password" "$keychain_path" + +# Import Apple's public certificate chain into the temporary keychain. +# Some runners do not have the current Developer ID intermediate certificates. +curl -fsSL https://www.apple.com/certificateauthority/AppleRootCA-G3.cer \ + -o "$temp_dir/AppleRootCA-G3.cer" +curl -fsSL https://www.apple.com/certificateauthority/AppleWWDRCAG6.cer \ + -o "$temp_dir/AppleWWDRCAG6.cer" +curl -fsSL https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer \ + -o "$temp_dir/DeveloperIDG2CA.cer" + +security import "$temp_dir/AppleRootCA-G3.cer" -k "$keychain_path" +security import "$temp_dir/AppleWWDRCAG6.cer" -k "$keychain_path" +security import "$temp_dir/DeveloperIDG2CA.cer" -k "$keychain_path" + +# Import the private signing identity and allow Apple signing tools to access +# the key non-interactively. +security import "$certificate_path" \ + -P "$MACOS_CERTIFICATE_PASSWORD" \ + -A \ + -t cert \ + -f pkcs12 \ + -k "$keychain_path" +security list-keychains -d user -s "$keychain_path" +security set-key-partition-list \ + -S apple-tool:,apple: \ + -s \ + -k "$keychain_password" \ + "$keychain_path" +security find-identity -v -p codesigning "$keychain_path" + +# Use the hardened runtime and a trusted timestamp so Gatekeeper can evaluate +# the signature after the Developer ID certificate eventually expires. +codesign \ + --force \ + --options runtime \ + --timestamp \ + --sign "$MACOS_CODESIGN_IDENTITY" \ + "$binary_path" +codesign --verify --verbose=4 "$binary_path"