Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/steps/setup-macos-signing/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Setup macOS code signing
description: >-
Import the Apple Developer ID certificate into a throwaway keychain and
export KEYCHAIN_PATH / SIGNING_IDENTITY for later steps. Mirrors the setup
portion of .github/steps/sign-macos-package in openframe-oss-tenant; the
actual signing happens in scripts/sign-binary.sh, driven by GoReleaser
build hooks. Delete the keychain afterwards with an `if: always()` step.
inputs:
apple_certificate_p12:
description: 'Base64 encoded Apple Developer certificate (.p12)'
required: true
apple_certificate_password:
description: 'Password for the .p12 certificate'
required: true

runs:
using: "composite"
steps:
- name: Import certificate into throwaway keychain
shell: bash
env:
APPLE_CERTIFICATE_P12: ${{ inputs.apple_certificate_p12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ inputs.apple_certificate_password }}
run: |
CERTIFICATE_PATH="$RUNNER_TEMP/certificate.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/signing.keychain-db"

# Decode certificate and create keychain
echo "$APPLE_CERTIFICATE_P12" | base64 --decode > "$CERTIFICATE_PATH"
security create-keychain -p "$APPLE_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH"
security default-keychain -s "$KEYCHAIN_PATH"
security unlock-keychain -p "$APPLE_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH"

# Import certificate and let codesign use the key non-interactively
security import "$CERTIFICATE_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -s -k "$APPLE_CERTIFICATE_PASSWORD" "$KEYCHAIN_PATH"

security find-identity -v -p codesigning "$KEYCHAIN_PATH"
rm "$CERTIFICATE_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"

SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep "Developer ID Application" | head -1 | awk -F'"' '{print $2}')
if [[ -z "$SIGNING_IDENTITY" ]]; then
echo "::error::No Developer ID Application identity found in keychain"
exit 1
fi
echo "Found signing identity: $SIGNING_IDENTITY"
echo "SIGNING_IDENTITY=$SIGNING_IDENTITY" >> "$GITHUB_ENV"
161 changes: 158 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ jobs:
release:
name: "Release"
needs: [version]
runs-on: ubuntu-latest
# macOS runner: binaries are code-signed in GoReleaser build hooks before
# archiving (checksums must cover the signed bits), and codesign/notarytool
# only exist on macOS. Windows Authenticode uses jsign, which runs anywhere.
runs-on: macos-latest
if: github.event_name == 'workflow_dispatch'
steps:
# The self-updater pins the cosign signing identity to
Expand Down Expand Up @@ -94,6 +97,25 @@ jobs:
- name: Install syft (SBOM generator)
uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0

# --- Code signing setup (used by scripts/sign-binary.sh via GoReleaser
# build hooks). Mirrors openframe-oss-tenant's sign-macos-package /
# sign-windows-package steps and uses the same org secrets.
- name: Setup macOS code signing
uses: ./.github/steps/setup-macos-signing
with:
apple_certificate_p12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
apple_certificate_password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}

- name: Install jsign (Azure Trusted Signing client)
env:
JSIGN_VERSION: "7.5"
JSIGN_SHA256: "602a51c3545a6dc4fb99bd2ea7152b26d1345916d0c93ddfbd5936cb735af91c"
run: |
curl -fsSL -o "$RUNNER_TEMP/jsign.jar" \
"https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar"
echo "${JSIGN_SHA256} $RUNNER_TEMP/jsign.jar" | shasum -a 256 -c -
echo "JSIGN_JAR=$RUNNER_TEMP/jsign.jar" >> "$GITHUB_ENV"

- name: Create and push tag
id: tag
env:
Expand All @@ -110,6 +132,19 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GORELEASER_CURRENT_TAG: ${{ needs.version.outputs.version }}
# Enables scripts/sign-binary.sh in the build hooks. KEYCHAIN_PATH,
# SIGNING_IDENTITY and JSIGN_JAR arrive via GITHUB_ENV from the
# signing setup steps above.
OPENFRAME_SIGN: "1"
APPLE_ID_USERNAME: ${{ secrets.APPLE_ID_USERNAME }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_CODE_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_CODE_SIGNING_ACCOUNT_NAME }}
AZURE_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }}
run: |
goreleaser release --clean

Expand All @@ -123,8 +158,11 @@ jobs:
echo "==================================================================="
# Guards the ldflags injection (-X cmd.version): a broken injection
# ships binaries reporting "dev", which disables self-update.
BIN="$(find dist -type f -name openframe -path '*linux*amd64*' | head -1)"
if [ -z "$BIN" ]; then echo "::error::no linux/amd64 binary found in dist/"; find dist -type f | head -20; exit 1; fi
# Runner is macOS/arm64, so exercise the darwin/arm64 binary — this
# also proves the signed, notarized binary still executes.
BIN="$(find dist -type f -name openframe -path '*darwin*arm64*' | head -1)"
if [ -z "$BIN" ]; then echo "::error::no darwin/arm64 binary found in dist/"; find dist -type f | head -20; exit 1; fi
codesign --verify --strict --verbose=2 "$BIN"
got="$("$BIN" --version)"
echo "$got"
if ! echo "$got" | grep -qF "$VERSION"; then
Expand Down Expand Up @@ -169,3 +207,120 @@ jobs:
echo "==================================================================="
gh release delete "${VERSION}" --repo "${GITHUB_REPOSITORY}" --yes && echo "deleted release ${VERSION}" || echo "no release ${VERSION} to delete"
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" ":refs/tags/${VERSION}" && echo "deleted tag ${VERSION}"

- name: 'Cleanup: delete signing keychain'
if: always()
run: |
if [[ -n "${KEYCHAIN_PATH:-}" && -f "${KEYCHAIN_PATH}" ]]; then
security delete-keychain "$KEYCHAIN_PATH"
fi

# ---------------------------------------------------------------------------
# Post-publish signature verification: download the PUBLISHED assets on real
# Windows/macOS runners and verify what users actually get — the OS trust
# store checks here can't run on the release runner. No secrets needed: the
# pinned identities below are public facts, visible in any signed binary.
# A verification failure triggers cleanup-on-failed-verification, which
# yanks the release and tag (same philosophy as the release job's own
# cleanup: never leave a bad release visible as latest).
verify-windows-signature:
name: "Verify: Windows Authenticode"
needs: [version, release]
runs-on: windows-latest
steps:
- name: Download and verify published Windows binaries
shell: pwsh
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
$ErrorActionPreference = "Stop"
foreach ($arch in @("amd64", "arm64")) {
$url = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$env:VERSION/openframe-cli_windows_$arch.zip"
Write-Host "Verifying $url"
Invoke-WebRequest -Uri $url -OutFile "openframe_$arch.zip" -MaximumRetryCount 5 -RetryIntervalSec 5
Expand-Archive "openframe_$arch.zip" -DestinationPath "extracted_$arch"
$sig = Get-AuthenticodeSignature -FilePath "extracted_$arch/openframe.exe"

Write-Host " Status: $($sig.Status)"
if ($sig.SignerCertificate) {
Write-Host " Signer: $($sig.SignerCertificate.Subject)"
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
}
if ($sig.Status -ne "Valid") {
Write-Error "Signature verification failed for ${arch}: $($sig.Status) — $($sig.StatusMessage)"
exit 1
}
# "Valid" only proves SOME trusted publisher signed it — pin ours.
# Subject of the Azure Trusted Signing cert (the verified legal
# entity; stable across the short-lived certs the service issues):
# CN=Flamingo AI, Inc., O=Flamingo AI, Inc., L=Miami, S=Florida, C=US
if ($sig.SignerCertificate.Subject -notlike "*Flamingo AI, Inc.*") {
Write-Error "Unexpected signer for ${arch}: $($sig.SignerCertificate.Subject)"
exit 1
}
if (-not $sig.TimeStamperCertificate) {
Write-Error "Signature for ${arch} is not timestamped"
exit 1
}
Write-Host " Timestamp Authority: $($sig.TimeStamperCertificate.Subject)"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Write-Host "All Windows signatures verified"

verify-macos-signature:
name: "Verify: macOS signature & notarization"
needs: [version, release]
runs-on: macos-latest
steps:
- name: Download and verify published macOS binaries
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
for arch in amd64 arm64; do
url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${VERSION}/openframe-cli_darwin_${arch}.tar.gz"
echo "Verifying $url"
curl -fsSL --retry 3 -o "openframe_${arch}.tar.gz" "$url"
mkdir -p "extracted_${arch}"
tar -xzf "openframe_${arch}.tar.gz" -C "extracted_${arch}"
bin="extracted_${arch}/openframe"

codesign --verify --strict --verbose=2 "$bin"
info="$(codesign -dvv "$bin" 2>&1)"
echo "$info"
if ! echo "$info" | grep -q "Authority=Developer ID Application"; then
echo "::error::${arch} binary is not signed with a Developer ID Application certificate"
exit 1
fi
# The Authority check only proves the certificate class — pin the
# signer to our Apple team (Flamingo AI, Inc.).
if ! echo "$info" | grep -q "TeamIdentifier=F7LDSU8JPJ"; then
echo "::error::${arch} binary signed by an unexpected team: $(echo "$info" | grep TeamIdentifier || echo 'TeamIdentifier missing')"
exit 1
fi
# Notarization check is best-effort: spctl needs quarantine context
# and the ticket can lag right after publication (bare binaries
# can't be stapled), so log the assessment without failing.
spctl --assess --type open --context context:primary-signature --verbose "$bin" || true
done
echo "All macOS signatures verified"

# A bad signature must not stay visible as `latest`. The release job's own
# cleanup only covers its own failure, so failed verification yanks the
# release + tag here. Gated on release success: if the release job failed it
# already rolled itself back (verification is then skipped anyway).
cleanup-on-failed-verification:
name: "Cleanup: yank release on failed verification"
needs: [version, release, verify-windows-signature, verify-macos-signature]
if: failure() && needs.release.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Delete release and tag
env:
VERSION: ${{ needs.version.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "==================================================================="
echo "=== CLEANUP: yanking release ${VERSION} after failed verification"
echo "==================================================================="
gh release delete "${VERSION}" --repo "${GITHUB_REPOSITORY}" --yes && echo "deleted release ${VERSION}" || echo "no release ${VERSION} to delete"
gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/tags/${VERSION}" && echo "deleted tag ${VERSION}" || echo "no tag ${VERSION} to delete"
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ jobs:
shell: bash
run: |
echo "==================================================================="
echo "=== TEST: unit tests (root, cmd, internal, tests/testutil)"
echo "=== TEST: unit tests (root, cmd, internal, tests/testutil, tests/scripts)"
echo "==================================================================="
make test-unit

Expand Down
7 changes: 7 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ builds:
- -X github.com/flamingo-stack/openframe-cli/cmd.version={{.Version}}
- -X github.com/flamingo-stack/openframe-cli/cmd.commit={{.Commit}}
- -X github.com/flamingo-stack/openframe-cli/cmd.date={{.Date}}
# Release-only code signing (macOS: codesign + notarytool; Windows: Azure
# Trusted Signing). Must run before archiving so archives/checksums cover
# the signed binaries. No-op unless OPENFRAME_SIGN=1 — see the script.
hooks:
post:
- cmd: ./scripts/sign-binary.sh {{ .Os }} {{ .Arch }} "{{ .Path }}"
output: true

archives:
- id: openframe-cli-archive
Expand Down
7 changes: 4 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ GOARCH := $(shell go env GOARCH)
BINARY_SUFFIX := $(if $(filter windows,$(GOOS)),.exe,)

# Unit-test package set. Includes the root package (main_test.go — the only
# exit-code fidelity tests) and tests/testutil, which `./cmd/... ./internal/...`
# silently skipped. Deliberately excludes ./tests/integration/... (real clusters).
UNIT_PKGS := . ./cmd/... ./internal/... ./tests/testutil/...
# exit-code fidelity tests), tests/testutil and tests/scripts (release script
# tests), which `./cmd/... ./internal/...` silently skipped. Deliberately
# excludes ./tests/integration/... (real clusters).
UNIT_PKGS := . ./cmd/... ./internal/... ./tests/testutil/... ./tests/scripts/...

# Default target
all: build
Expand Down
1 change: 1 addition & 0 deletions docs/development/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Guides for building, running, and contributing to OpenFrame CLI.
- **[Architecture Overview](architecture/README.md)** - High-level design, components, and data flows
- **[Environment Setup](setup/environment.md)** - IDE, tools, and development dependencies
- **[Local Development](setup/local-development.md)** - Clone, build, run, test, and debug locally
- **[Release Signing](release-signing.md)** - How release binaries are signed and notarized (macOS/Windows)

## Key Technologies

Expand Down
94 changes: 94 additions & 0 deletions docs/development/release-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Release Signing

Release binaries are code-signed during `goreleaser release` (see
[release.yml](../../.github/workflows/release.yml)), **before archiving** — so
the published archives, `checksums.txt`, and the cosign bundle all cover the
signed binaries.

| Platform | Mechanism |
|----------|-----------|
| macOS | `codesign` (Developer ID Application, hardened runtime, timestamp) + `notarytool` notarization |
| Windows | Authenticode via Azure Trusted Signing (SHA-256, RFC3161 timestamp from `timestamp.acs.microsoft.com`) |
| Linux | Unsigned; integrity via `checksums.txt` + cosign bundle |

The flow mirrors the `sign-macos-package` / `sign-windows-package` composite
steps in [openframe-oss-tenant](https://github.com/flamingo-stack/openframe-oss-tenant)
and uses the same certificates and secrets. One deviation: the release job runs
on a single macOS runner (signing must happen before GoReleaser packs the
archives), so Windows signing uses [jsign](https://ebourg.github.io/jsign/)
instead of `azure/trusted-signing-action` (signtool is Windows-only) — same
Azure Trusted Signing account, endpoint and certificate profile.

## How it's wired

- A GoReleaser build post-hook calls `scripts/sign-binary.sh <os> <arch> <path>`
for every built binary. `linux` is a pass-through.
- The script is a no-op unless `OPENFRAME_SIGN=1`, which only the release
workflow sets — local builds and CI compile checks never attempt to sign.
- [.github/steps/setup-macos-signing](../../.github/steps/setup-macos-signing/action.yml)
imports the Developer ID certificate into a throwaway keychain and exports
`KEYCHAIN_PATH` / `SIGNING_IDENTITY`; the keychain is deleted in an
`if: always()` cleanup step.
- The workflow downloads a version-pinned, checksum-verified jsign jar and
exports `JSIGN_JAR`. The script fetches a fresh AAD client-credentials token
per Windows binary (notarization waits can outlive a token fetched up-front).

## Required secrets

Same names as `openframe-oss-tenant`, so org-level secrets cover both repos.

| Secret | Used for |
|--------|----------|
| `APPLE_CERTIFICATE_P12` | Base64-encoded Developer ID Application certificate (.p12) |
| `APPLE_CERTIFICATE_PASSWORD` | Password for the .p12 |
| `APPLE_ID_USERNAME` / `APPLE_ID_PASSWORD` | Notarization (app-specific password) |
| `APPLE_TEAM_ID` | Apple Developer Team ID |
| `AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` | AAD token for Trusted Signing |
| `AZURE_SIGNING_ENDPOINT` | e.g. `https://eus.codesigning.azure.net` |
| `AZURE_CODE_SIGNING_ACCOUNT_NAME` | Trusted Signing account |
| `AZURE_CERTIFICATE_PROFILE_NAME` | Certificate profile |

## Testing

Two layers, neither needing certificates locally:

- **Unit tests** — `tests/scripts/sign_binary_test.go` (part of
`make test-unit`) runs `scripts/sign-binary.sh` with PATH stubs for
`codesign`/`xcrun`/`java`/`curl`/`jq` that record their argv. They pin the
`OPENFRAME_SIGN` gate, the per-OS dispatch, fail-fast on missing env, and the
exact flags passed to codesign/notarytool/jsign (identity, hardened runtime,
endpoint scheme-stripping, alias, timestamp URL, call ordering).
- **Post-publish verification** — the `verify-windows-signature` /
`verify-macos-signature` jobs in the release workflow download the published
assets on real Windows/macOS runners and verify them against the OS trust
stores (`Get-AuthenticodeSignature` incl. timestamp; `codesign --verify` +
Developer ID authority check, best-effort `spctl` notarization assessment).
Both checks pin the signer identity — Authenticode subject
`Flamingo AI, Inc.`, Apple `TeamIdentifier=F7LDSU8JPJ` — so a binary signed
by *some* trusted-but-wrong publisher still fails. On verification failure
the `cleanup-on-failed-verification` job yanks the release and tag.

## Verifying a released binary

macOS:

```bash
codesign --verify --strict --verbose=2 openframe
spctl --assess --type open --context context:primary-signature -v openframe
```

Windows (PowerShell):

```powershell
Get-AuthenticodeSignature .\openframe.exe
```

Any platform — release provenance (covers Linux too), see the `signs` block in
[.goreleaser.yml](../../.goreleaser.yml):

```bash
cosign verify-blob --bundle checksums.txt.bundle \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github.com/flamingo-stack/openframe-cli/\.github/workflows/release\.yml@.*$' \
checksums.txt
```
Loading