diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 122550d..287e9a7 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: [ self-hosted, macOS, ARM64 ] + timeout-minutes: 30 steps: - name: Checkout Repository @@ -50,17 +50,25 @@ jobs: # If current app version already exist in release, emits a warning, but flow counts as passed. - name: Check Release Version id: release - shell: bash + uses: actions/github-script@v8 env: - GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ steps.version.outputs.tag }} - run: | - if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - echo "::warning title=Release already exists::Release $RELEASE_TAG already exists. Bump VERSION to publish a new release." - echo "publish=false" >> "$GITHUB_OUTPUT" - else - echo "publish=true" >> "$GITHUB_OUTPUT" - fi + with: + script: | + try { + await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag: process.env.RELEASE_TAG, + }); + core.warning(`Release ${process.env.RELEASE_TAG} already exists. Bump VERSION to publish a new release.`); + core.setOutput('publish', 'false'); + } catch (error) { + if (error.status !== 404) { + throw error; + } + core.setOutput('publish', 'true'); + } # Build binaries only for new releases. - name: Build Binaries @@ -70,19 +78,76 @@ jobs: mkdir -p dist GOOS=windows GOARCH=amd64 go build -trimpath -o dist/embed-code-windows.exe main.go GOOS=linux GOARCH=amd64 go build -trimpath -o dist/embed-code-linux main.go - GOOS=darwin GOARCH=amd64 go build -trimpath -o dist/embed-code-macos main.go - chmod +x dist/embed-code-linux dist/embed-code-macos + GOOS=darwin GOARCH=arm64 go build -trimpath -o dist/embed-code-macos-arm64 main.go + GOOS=darwin GOARCH=amd64 go build -trimpath -o dist/embed-code-macos-x64 main.go + chmod +x dist/embed-code-linux dist/embed-code-macos-arm64 dist/embed-code-macos-x64 + + # Sign the macOS binaries with a Developer ID certificate stored in GitHub + # secrets. The certificate must be exported as a base64-encoded .p12 file. + - name: Sign macOS Binaries + 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-arm64 \ + dist/embed-code-macos-x64 + + # Notarize the macOS ZIPs that will be published as release assets. + - name: Notarize macOS Binaries + 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-arm64 dist/embed-code-macos-arm64.zip + scripts/release/notarize-macos-zip.sh dist/embed-code-macos-x64 dist/embed-code-macos-x64.zip + rm dist/embed-code-macos-arm64 dist/embed-code-macos-x64 # Create the release for the current commit and attach all generated binaries. - name: Publish GitHub Release if: steps.release.outputs.publish == 'true' - shell: bash + uses: actions/github-script@v8 env: - GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ steps.version.outputs.tag }} - run: | - release_notes="$(printf 'Embed Code %s\n\nThis release contains pre-built Embed Code binaries for macOS, Linux, and Windows.' "$RELEASE_TAG")" - gh release create "$RELEASE_TAG" dist/* \ - --target "$GITHUB_SHA" \ - --title "embed-code $RELEASE_TAG" \ - --notes "$release_notes" + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const releaseTag = process.env.RELEASE_TAG; + const releaseNotes = `Embed Code ${releaseTag}\n\nThis release contains pre-built Embed Code binaries for macOS ARM64, macOS x64, Linux, and Windows.`; + const release = await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: releaseTag, + target_commitish: context.sha, + name: `embed-code ${releaseTag}`, + body: releaseNotes, + }); + + for (const assetName of fs.readdirSync('dist').sort()) { + const assetPath = path.join('dist', assetName); + const assetData = fs.readFileSync(assetPath); + const sizeMb = (assetData.length / 1024 / 1024).toFixed(2); + const startedAt = Date.now(); + core.info(`Uploading ${assetName} (${sizeMb} MB).`); + await github.rest.repos.uploadReleaseAsset({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.data.id, + name: assetName, + headers: { + 'content-type': 'application/octet-stream', + 'content-length': assetData.length, + }, + data: assetData, + }); + const durationSeconds = ((Date.now() - startedAt) / 1000).toFixed(1); + core.info(`Uploaded ${assetName} in ${durationSeconds}s.`); + } diff --git a/PROJECT.md b/PROJECT.md index f2076c5..ad07a74 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,9 @@ 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 ARM64 and x64 ZIPs, and creates the + matching GitHub Release on pushes to `master`. It runs on a self-hosted macOS + ARM64 runner because Apple signing and notarization require macOS tooling. 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 7cacf03..614d546 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,13 @@ 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-arm64.zip` for Apple silicon or +`embed-code-macos-x64.zip` for Intel Macs. Then unzip it and run the binary: + +```bash +unzip embed-code-macos-arm64.zip +./embed-code-macos-arm64 -mode=check -config-path=showcase/embedding/embed-code.yml +``` Or run it with Go: diff --git a/VERSION b/VERSION index 0495c4a..e8ea05d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.3 +1.2.4 diff --git a/scripts/release/README.md b/scripts/release/README.md new file mode 100644 index 0000000..006f6f9 --- /dev/null +++ b/scripts/release/README.md @@ -0,0 +1,52 @@ +# Release Scripts + +These scripts support the `release-binaries` GitHub workflow. They are intended +for the self-hosted macOS ARM64 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-arm64 \ + dist/embed-code-macos-x64 +``` + +## 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-arm64 \ + dist/embed-code-macos-arm64.zip +scripts/release/notarize-macos-zip.sh \ + dist/embed-code-macos-x64 \ + dist/embed-code-macos-x64.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..6713d97 --- /dev/null +++ b/scripts/release/sign-macos-binary.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash + +# Signs macOS CLI binaries with a Developer ID Application certificate. +# +# Usage: +# scripts/release/sign-macos-binary.sh \ +# dist/embed-code-macos-arm64 \ +# dist/embed-code-macos-x64 +# +# 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 each binary in place and +# verifies the resulting signatures. +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 (( $# == 0 )); then + echo "Usage: $0 [macos-binary-path...]" >&2 + exit 1 +fi + +binary_paths=("$@") +for binary_path in "${binary_paths[@]}"; do + if [[ ! -f "$binary_path" ]]; then + echo "macOS binary does not exist: $binary_path" >&2 + exit 1 + fi +done + +temp_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +signing_id="$(uuidgen)" +keychain_path="$temp_dir/embed-code-signing-$signing_id.keychain-db" +certificate_path="$temp_dir/embed-code-signing-certificate-$signing_id.p12" +keychain_password="${MACOS_KEYCHAIN_PASSWORD:-$(uuidgen)}" +apple_root_certificate_path="$temp_dir/AppleRootCA-G3-$signing_id.cer" +apple_wwdr_certificate_path="$temp_dir/AppleWWDRCAG6-$signing_id.cer" +developer_id_certificate_path="$temp_dir/DeveloperIDG2CA-$signing_id.cer" +original_keychains=() + +while IFS= read -r keychain; do + keychain="${keychain#\"}" + keychain="${keychain%\"}" + original_keychains+=("$keychain") +done < <(security list-keychains -d user) + +cleanup() { + if (( ${#original_keychains[@]} > 0 )); then + security list-keychains -d user -s "${original_keychains[@]}" >/dev/null 2>&1 || true + fi + security delete-keychain "$keychain_path" >/dev/null 2>&1 || true + rm -f \ + "$certificate_path" \ + "$apple_root_certificate_path" \ + "$apple_wwdr_certificate_path" \ + "$developer_id_certificate_path" +} +trap cleanup EXIT + +# 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" +security list-keychains -d user -s "$keychain_path" "${original_keychains[@]}" + +# 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 "$apple_root_certificate_path" +curl -fsSL https://www.apple.com/certificateauthority/AppleWWDRCAG6.cer \ + -o "$apple_wwdr_certificate_path" +curl -fsSL https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer \ + -o "$developer_id_certificate_path" + +security import "$apple_root_certificate_path" -k "$keychain_path" +security import "$apple_wwdr_certificate_path" -k "$keychain_path" +security import "$developer_id_certificate_path" -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 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. +for binary_path in "${binary_paths[@]}"; do + codesign \ + --force \ + --options runtime \ + --timestamp \ + --sign "$MACOS_CODESIGN_IDENTITY" \ + "$binary_path" + codesign --verify --verbose=4 "$binary_path" +done