feat(HAI-080): add code signing for macOS and Windows release binaries

- Add macOS signing script with codesign, notarization, and stapling support
- Add Windows signing script using signtool with PFX certificate
- Integrate signing steps into release and test-release workflows
- Add signing workflow tests and verification coverage
- Add CODE_SIGNING.md documentation and update README
This commit is contained in:
Dustin Byrne
2026-03-26 01:03:16 -04:00
parent a12550d625
commit d37f887552
7 changed files with 512 additions and 1 deletions

View File

@@ -69,6 +69,42 @@ jobs:
- name: Build standalone binary
run: pnpm --filter hai build:exe -- --target ${{ matrix.target }}
- name: Rename binary with platform and arch
run: |
PLATFORM=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="x64" ;;
aarch64) ARCH="arm64" ;;
esac
cp packages/cli/dist/hai packages/cli/dist/hai-${PLATFORM}-${ARCH}
# Code signing steps — activate when cross-platform matrix is in place (HAI-079)
- name: Sign macOS binaries
if: runner.os == 'macOS'
env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_IDENTITY: ${{ secrets.APPLE_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
run: |
for binary in packages/cli/dist/hai-darwin-*; do
[ -f "$binary" ] && bash scripts/sign-macos.sh "$binary"
done
- name: Sign Windows binaries
if: runner.os == 'Windows'
env:
WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
run: |
Get-ChildItem packages/cli/dist/hai-windows-*.exe | ForEach-Object {
& .\scripts\sign-windows.ps1 $_.FullName
}
shell: pwsh
- name: Generate checksum (Linux)
if: runner.os == 'Linux'
run: |

View File

@@ -49,6 +49,43 @@ jobs:
- name: Build standalone binary
run: pnpm --filter hai build:exe -- --target ${{ matrix.target }}
- name: Rename binary with platform and arch
run: |
PLATFORM=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="x64" ;;
aarch64) ARCH="arm64" ;;
esac
cp packages/cli/dist/hai packages/cli/dist/hai-${PLATFORM}-${ARCH}
# Code signing steps — activate when cross-platform matrix is in place (HAI-079)
# Signing is skipped gracefully when secrets are not configured
- name: Sign macOS binaries
if: runner.os == 'macOS' && env.APPLE_CERTIFICATE_BASE64 != ''
env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_IDENTITY: ${{ secrets.APPLE_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
run: |
for binary in packages/cli/dist/hai-darwin-*; do
[ -f "$binary" ] && bash scripts/sign-macos.sh "$binary"
done
- name: Sign Windows binaries
if: runner.os == 'Windows' && env.WINDOWS_CERTIFICATE_BASE64 != ''
env:
WINDOWS_CERTIFICATE_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_BASE64 }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
run: |
Get-ChildItem packages/cli/dist/hai-windows-*.exe | ForEach-Object {
& .\scripts\sign-windows.ps1 $_.FullName
}
shell: pwsh
- name: Generate checksum (Linux)
if: runner.os == 'Linux'
run: |

View File

@@ -214,6 +214,8 @@ Download the latest binary from the [GitHub Releases](../../releases) page. Each
| macOS x64 (Intel) | `hai-darwin-x64` | `macos-13` |
| Windows x64 | `hai-windows-x64.exe` | `windows-latest` |
macOS and Windows binaries are **code-signed** to avoid OS security warnings (Gatekeeper/SmartScreen). See [docs/CODE_SIGNING.md](docs/CODE_SIGNING.md) for setup details.
### Triggering a release
Releases are automated via [changesets](https://github.com/changesets/changesets). See [RELEASING.md](./RELEASING.md) for the full workflow.

143
docs/CODE_SIGNING.md Normal file
View File

@@ -0,0 +1,143 @@
# Code Signing Setup Guide
This document explains how to configure code signing for `hai` release binaries so they don't trigger OS security warnings on macOS (Gatekeeper) or Windows (SmartScreen).
## Overview
The release workflow automatically signs binaries when the appropriate secrets are configured:
- **macOS**: Codesign with hardened runtime + Apple notarization
- **Windows**: Authenticode signing with timestamp
- **Linux**: No signing (no standard code signing requirement for Linux CLI tools)
Signing is **optional** — if secrets are not configured, the build succeeds and signing steps are skipped.
## Required GitHub Secrets
### macOS Signing
| Secret | Description |
|--------|-------------|
| `APPLE_CERTIFICATE_BASE64` | Base64-encoded `.p12` Developer ID Application certificate |
| `APPLE_CERTIFICATE_PASSWORD` | Password used when exporting the `.p12` certificate |
| `APPLE_IDENTITY` | Signing identity string (e.g., `Developer ID Application: Your Name (TEAMID)`) |
| `APPLE_ID` | Apple ID email address used for notarization |
| `APPLE_TEAM_ID` | Apple Developer Team ID (10-character alphanumeric) |
| `APPLE_APP_PASSWORD` | App-specific password for notarization |
### Windows Signing
| Secret | Description |
|--------|-------------|
| `WINDOWS_CERTIFICATE_BASE64` | Base64-encoded `.pfx` Authenticode code signing certificate |
| `WINDOWS_CERTIFICATE_PASSWORD` | Password for the `.pfx` certificate |
## macOS Setup Instructions
### 1. Obtain a Developer ID Application Certificate
1. Enroll in the [Apple Developer Program](https://developer.apple.com/programs/)
2. In Xcode or the Apple Developer portal, create a **Developer ID Application** certificate
3. Export the certificate from Keychain Access as a `.p12` file with a password
### 2. Encode the Certificate as Base64
```bash
base64 -i certificate.p12 | pbcopy
```
Paste the result as the `APPLE_CERTIFICATE_BASE64` secret.
### 3. Find Your Team ID
Your Team ID is visible at [developer.apple.com/account](https://developer.apple.com/account) under Membership Details. It's a 10-character alphanumeric string (e.g., `ABC1234DEF`).
### 4. Create an App-Specific Password
1. Go to [appleid.apple.com](https://appleid.apple.com/)
2. Sign in and navigate to **Sign-In and Security****App-Specific Passwords**
3. Generate a new password and label it (e.g., "hai notarization")
4. Use this as the `APPLE_APP_PASSWORD` secret
### 5. Determine Your Signing Identity
The signing identity looks like:
```
Developer ID Application: Your Name (TEAMID)
```
You can find it by running:
```bash
security find-identity -v -p codesigning
```
## Windows Setup Instructions
### 1. Obtain an Authenticode Code Signing Certificate
Purchase a code signing certificate from a trusted Certificate Authority:
- DigiCert
- Sectigo (Comodo)
- GlobalSign
- SSL.com
### 2. Export as `.pfx`
Export the certificate with its private key as a `.pfx` (PKCS#12) file. Set a strong password.
### 3. Encode the Certificate as Base64
```powershell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard
```
Or on Linux/macOS:
```bash
base64 -i certificate.pfx
```
Paste the result as the `WINDOWS_CERTIFICATE_BASE64` secret.
## How Signing Works in the Release Flow
1. A tag push (`v*`) triggers the release workflow
2. Each platform job builds the standalone binary
3. **macOS jobs**: `scripts/sign-macos.sh` runs codesign + notarization
4. **Windows jobs**: `scripts/sign-windows.ps1` runs Authenticode signing
5. Checksums are generated **after** signing (so they match the signed binaries)
6. Signed binaries and checksums are uploaded to the GitHub Release
The test-release workflow (`workflow_dispatch`) includes the same signing steps but guards them with secret-availability checks — signing is skipped if secrets are not configured.
## Troubleshooting
### macOS: "The signature of the binary is invalid"
- Ensure the certificate is a **Developer ID Application** certificate (not Developer ID Installer or Mac App Distribution)
- Check that the certificate hasn't expired
- Verify the base64 encoding is correct: `echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode | file -`
### macOS: Notarization fails with "Invalid credentials"
- Verify `APPLE_ID` is your Apple ID email
- Verify `APPLE_APP_PASSWORD` is an app-specific password (not your Apple ID password)
- Verify `APPLE_TEAM_ID` matches the team that issued the certificate
### macOS: Notarization fails with "The software is not signed"
- Ensure the `--options runtime` flag is used during codesign (hardened runtime is required for notarization)
- The `sign-macos.sh` script handles this automatically
### Windows: "signtool not found"
- `signtool.exe` is included in the Windows SDK, which is pre-installed on GitHub Actions Windows runners
- For local testing, install the Windows SDK or Visual Studio Build Tools
### Windows: "The specified PFX password is not correct"
- Double-check the `WINDOWS_CERTIFICATE_PASSWORD` secret matches the password used when exporting the `.pfx`
### Signing step skipped
- In the test-release workflow, signing is intentionally skipped when secrets are not configured
- Verify the secrets are set at the repository level in **Settings → Secrets and variables → Actions**

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll } from "vitest";
import { readFileSync } from "node:fs";
import { readFileSync, accessSync, constants } from "node:fs";
import { join } from "node:path";
import { parse } from "yaml";
@@ -203,3 +203,100 @@ describe("Test Release workflow (.github/workflows/test-release.yml)", () => {
});
});
});
describe("Code signing — Release workflow", () => {
let content: string;
beforeAll(() => {
const result = loadWorkflow("release.yml");
content = result.content;
});
it("contains macOS signing step referencing sign-macos.sh", () => {
expect(content).toContain("sign-macos.sh");
});
it("contains Windows signing step referencing sign-windows.ps1", () => {
expect(content).toContain("sign-windows.ps1");
});
it("macOS signing step is conditioned on runner.os", () => {
expect(content).toMatch(/if:.*runner\.os\s*==\s*'macOS'/);
});
it("Windows signing step is conditioned on runner.os", () => {
expect(content).toMatch(/if:.*runner\.os\s*==\s*'Windows'/);
});
it("references all required Apple secrets", () => {
const requiredSecrets = [
"APPLE_CERTIFICATE_BASE64",
"APPLE_CERTIFICATE_PASSWORD",
"APPLE_ID",
"APPLE_TEAM_ID",
"APPLE_APP_PASSWORD",
];
for (const secret of requiredSecrets) {
expect(content).toContain(`secrets.${secret}`);
}
});
it("references Windows signing secrets", () => {
expect(content).toContain("secrets.WINDOWS_CERTIFICATE_BASE64");
expect(content).toContain("secrets.WINDOWS_CERTIFICATE_PASSWORD");
});
it("checksums step comes after signing steps", () => {
const signMacosIndex = content.indexOf("sign-macos.sh");
const signWindowsIndex = content.indexOf("sign-windows.ps1");
const checksumIndex = content.indexOf("Generate checksums");
expect(signMacosIndex).toBeLessThan(checksumIndex);
expect(signWindowsIndex).toBeLessThan(checksumIndex);
});
});
describe("Code signing — Test-release workflow", () => {
let content: string;
beforeAll(() => {
const result = loadWorkflow("test-release.yml");
content = result.content;
});
it("has macOS signing step with secret-availability guard", () => {
expect(content).toContain("sign-macos.sh");
expect(content).toMatch(/if:.*APPLE_CERTIFICATE_BASE64\s*!=\s*''/);
});
it("has Windows signing step with secret-availability guard", () => {
expect(content).toContain("sign-windows.ps1");
expect(content).toMatch(/if:.*WINDOWS_CERTIFICATE_BASE64\s*!=\s*''/);
});
});
describe("Code signing — Scripts", () => {
const scriptsDir = join(workspaceRoot, "scripts");
it("sign-macos.sh exists and is executable", () => {
const scriptPath = join(scriptsDir, "sign-macos.sh");
expect(() => accessSync(scriptPath, constants.F_OK)).not.toThrow();
expect(() => accessSync(scriptPath, constants.X_OK)).not.toThrow();
});
it("sign-windows.ps1 exists", () => {
const scriptPath = join(scriptsDir, "sign-windows.ps1");
expect(() => accessSync(scriptPath, constants.F_OK)).not.toThrow();
});
it("sign-macos.sh references codesign, notarytool, and security import", () => {
const script = readFileSync(join(scriptsDir, "sign-macos.sh"), "utf-8");
expect(script).toContain("codesign");
expect(script).toContain("notarytool");
expect(script).toContain("security import");
});
it("sign-windows.ps1 references signtool", () => {
const script = readFileSync(join(scriptsDir, "sign-windows.ps1"), "utf-8");
expect(script).toContain("signtool");
});
});

118
scripts/sign-macos.sh Executable file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# sign-macos.sh — Codesign and notarize a macOS binary for Gatekeeper.
#
# Usage:
# APPLE_CERTIFICATE_BASE64=... APPLE_CERTIFICATE_PASSWORD=... \
# APPLE_IDENTITY=... APPLE_ID=... APPLE_TEAM_ID=... APPLE_APP_PASSWORD=... \
# bash scripts/sign-macos.sh path/to/binary
#
# Environment variables (all required):
# APPLE_CERTIFICATE_BASE64 — Base64-encoded .p12 Developer ID Application certificate
# APPLE_CERTIFICATE_PASSWORD — Password for the .p12 certificate
# APPLE_IDENTITY — Signing identity (e.g., "Developer ID Application: Your Name (TEAMID)")
# APPLE_ID — Apple ID email for notarization
# APPLE_TEAM_ID — Apple Developer Team ID
# APPLE_APP_PASSWORD — App-specific password for notarization
#
# The script is idempotent — re-running on an already-signed binary will re-sign it.
set -euo pipefail
# ── Validate arguments ────────────────────────────────────────────────
BINARY="${1:-}"
if [[ -z "$BINARY" ]]; then
echo "ERROR: No binary path provided."
echo "Usage: $0 <path-to-binary>"
exit 1
fi
if [[ ! -f "$BINARY" ]]; then
echo "ERROR: Binary not found: $BINARY"
exit 1
fi
# ── Validate environment variables ────────────────────────────────────
REQUIRED_VARS=(
APPLE_CERTIFICATE_BASE64
APPLE_CERTIFICATE_PASSWORD
APPLE_IDENTITY
APPLE_ID
APPLE_TEAM_ID
APPLE_APP_PASSWORD
)
for var in "${REQUIRED_VARS[@]}"; do
if [[ -z "${!var:-}" ]]; then
echo "ERROR: Required environment variable $var is not set."
exit 1
fi
done
echo "==> Signing macOS binary: $BINARY"
# ── Set up temporary keychain ─────────────────────────────────────────
KEYCHAIN_NAME="signing-$(date +%s).keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -hex 16)"
CERT_FILE="$(mktemp -t cert.XXXXXX).p12"
cleanup() {
echo "==> Cleaning up..."
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
rm -f "$CERT_FILE"
}
trap cleanup EXIT
# Decode certificate from base64
echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$CERT_FILE"
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security set-keychain-settings -lut 900 "$KEYCHAIN_NAME"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# Import certificate into keychain
security import "$CERT_FILE" \
-k "$KEYCHAIN_NAME" \
-P "$APPLE_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/security
# Allow codesign to access the keychain without UI prompt
security set-key-partition-list -S "apple-tool:,apple:,codesign:" \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# Add temporary keychain to the search list
security list-keychains -d user -s "$KEYCHAIN_NAME" $(security list-keychains -d user | tr -d '"')
echo "==> Keychain configured."
# ── Codesign the binary ───────────────────────────────────────────────
echo "==> Codesigning with identity: $APPLE_IDENTITY"
codesign --force --options runtime --sign "$APPLE_IDENTITY" \
--keychain "$KEYCHAIN_NAME" \
"$BINARY"
echo "==> Codesign complete. Verifying..."
codesign --verify --verbose "$BINARY"
# ── Notarize the binary ──────────────────────────────────────────────
echo "==> Preparing for notarization..."
ZIP_FILE="$(mktemp -t notarize.XXXXXX).zip"
trap 'rm -f "$ZIP_FILE"; cleanup' EXIT
# Create a ZIP for notarization submission
ditto -c -k --keepParent "$BINARY" "$ZIP_FILE"
echo "==> Submitting to Apple notarization service..."
xcrun notarytool submit "$ZIP_FILE" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_PASSWORD" \
--wait
echo "==> Notarization complete."
# Clean up ZIP
rm -f "$ZIP_FILE"
echo "==> ✓ Binary signed and notarized: $BINARY"

78
scripts/sign-windows.ps1 Normal file
View File

@@ -0,0 +1,78 @@
# sign-windows.ps1 — Authenticode sign a Windows binary.
#
# Usage:
# $env:WINDOWS_CERTIFICATE_BASE64 = "..."
# $env:WINDOWS_CERTIFICATE_PASSWORD = "..."
# pwsh scripts/sign-windows.ps1 path\to\binary.exe
#
# Environment variables (all required):
# WINDOWS_CERTIFICATE_BASE64 — Base64-encoded .pfx code signing certificate
# WINDOWS_CERTIFICATE_PASSWORD — Password for the .pfx certificate
param(
[Parameter(Mandatory = $true, Position = 0)]
[string]$BinaryPath
)
$ErrorActionPreference = "Stop"
# ── Validate arguments ────────────────────────────────────────────────
if (-not (Test-Path $BinaryPath)) {
Write-Error "ERROR: Binary not found: $BinaryPath"
exit 1
}
# ── Validate environment variables ────────────────────────────────────
$requiredVars = @("WINDOWS_CERTIFICATE_BASE64", "WINDOWS_CERTIFICATE_PASSWORD")
foreach ($var in $requiredVars) {
if (-not [System.Environment]::GetEnvironmentVariable($var)) {
Write-Error "ERROR: Required environment variable $var is not set."
exit 1
}
}
Write-Host "==> Signing Windows binary: $BinaryPath"
# ── Decode certificate to temporary file ──────────────────────────────
$certFile = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "signing-cert-$(Get-Random).pfx")
try {
# Decode base64 certificate
$certBytes = [System.Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_BASE64)
[System.IO.File]::WriteAllBytes($certFile, $certBytes)
Write-Host "==> Certificate decoded to temporary file."
# ── Sign the binary ───────────────────────────────────────────────
Write-Host "==> Signing with signtool..."
& signtool sign `
/f $certFile `
/p $env:WINDOWS_CERTIFICATE_PASSWORD `
/tr http://timestamp.digicert.com `
/td sha256 `
/fd sha256 `
$BinaryPath
if ($LASTEXITCODE -ne 0) {
Write-Error "ERROR: signtool sign failed with exit code $LASTEXITCODE"
exit 1
}
Write-Host "==> Sign complete. Verifying..."
# ── Verify the signature ──────────────────────────────────────────
& signtool verify /pa $BinaryPath
if ($LASTEXITCODE -ne 0) {
Write-Error "ERROR: signtool verify failed with exit code $LASTEXITCODE"
exit 1
}
Write-Host "==> ✓ Binary signed and verified: $BinaryPath"
}
finally {
# ── Clean up temporary certificate file ───────────────────────────
if (Test-Path $certFile) {
Remove-Item -Force $certFile
Write-Host "==> Temporary certificate file removed."
}
}