diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a03d41e..e173a9d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -12,17 +12,14 @@ on: description: 'Tag release (e.g. v1.2.3)' required: true - # allow for testing of PR updating this file - pull_request: - paths: - - ".github/workflows/release.yaml" - permissions: contents: write jobs: + # One macOS job builds everything: the linux binaries cross-compile with CGO off, and the darwin hooks sign and notarize before archiving. goreleaser: - runs-on: ubuntu-latest + runs-on: macos-latest + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -36,20 +33,23 @@ jobs: go-version-file: go.mod cache: false - - name: Set GoReleaser args for PR dry run - id: goreleaser-args - run: | - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - echo "args=release --clean --skip=publish --snapshot" >> $GITHUB_OUTPUT - else - echo "args=release --clean" >> $GITHUB_OUTPUT - fi + name: Set up signing keychain and notary credentials + env: + APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + run: bash scripts/setup-macos-signing.sh - name: Run GoReleaser uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a #v6.4.0 with: distribution: goreleaser version: '~> v2' - args: ${{ steps.goreleaser-args.outputs.args }} + args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - + name: Tear down signing keychain + if: always() + run: security delete-keychain "$SIGNING_KEYCHAIN" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 523622b..93599d1 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -21,6 +21,12 @@ builds: goarch: - amd64 - arm64 + # Darwin releases require signatures and notarization. Only snapshots + # may skip these checks; Linux builds pass through unsigned. + hooks: + post: + - cmd: 'bash scripts/sign-macos.sh "{{ .Path }}" "{{ .Target }}" "{{ .IsSnapshot }}"' + output: true archives: - formats: diff --git a/Makefile b/Makefile index d36da45..29b7d99 100644 --- a/Makefile +++ b/Makefile @@ -131,8 +131,8 @@ notarize-mac: codesign --verify --strict --verbose=4 "$(AMD64_BIN)"; \ echo "==> Creating release archives"; \ rm -f "$(ARM64_ARCHIVE)" "$(AMD64_ARCHIVE)" "$(CHECKSUMS)"; \ - ditto -c -k --keepParent "$(ARM64_BIN)" "$(ARM64_ARCHIVE)"; \ - ditto -c -k --keepParent "$(AMD64_BIN)" "$(AMD64_ARCHIVE)"; \ + ditto -c -k --keepParent --norsrc --noextattr "$(ARM64_BIN)" "$(ARM64_ARCHIVE)"; \ + ditto -c -k --keepParent --norsrc --noextattr "$(AMD64_BIN)" "$(AMD64_ARCHIVE)"; \ echo "==> arm64 archive contents"; \ unzip -l "$(ARM64_ARCHIVE)"; \ echo "==> amd64 archive contents"; \ diff --git a/README.md b/README.md index f6ecbf1..46b87ab 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ A CLI launcher for coding agents preconfigured to work with [Aperture](https://a ## Installation +```sh +curl -fsSL https://raw.githubusercontent.com/tailscale/aperture-cli/main/install.sh | sh +``` + +The script downloads the matching release asset, verifies it against the release checksums and installs `aperture` to `/usr/local/bin`. Set `APERTURE_INSTALL_DIR=$HOME/.local/bin` to skip sudo, `APERTURE_VERSION=v0.0.13` to pin a release. + +With Go: + ```sh go install github.com/tailscale/aperture-cli/cmd/aperture@latest ``` @@ -41,6 +49,8 @@ Or build from source: make build ``` +macOS builds are Developer ID signed and notarized. curl and `go install` never set the quarantine attribute, so Gatekeeper stays out of the way. If you download the archive in a browser and double-click the binary instead, macOS may block it anyway (common on managed Macs): open System Settings > Privacy & Security and click **Open Anyway** next to the blocked entry. + ## Usage ```sh @@ -110,6 +120,19 @@ make install # install to $GOPATH/bin make clean # remove built binary ``` +## Releasing + +Push a tag. The release workflow runs GoReleaser on a macOS runner: it imports the Developer ID certificate from GitHub secrets into a temporary keychain, and a build hook signs and notarizes each darwin binary before archiving, so nothing unsigned is ever published. A failed signature or a rejected notarization fails the run before upload. Required secrets: `APPLE_CERT_P12` (the base64-encoded .p12), `APPLE_CERT_PASSWORD`, `APPLE_ID` and `APPLE_ID_PASSWORD` (an app-specific password). + +Local fallback on a Mac that has the certificate and a stored notary profile, for when the workflow could not sign: + +```sh +make release-mac-notarized VERSION=v0.0.14 # build, sign, notarize, verify +make upload-mac VERSION=v0.0.14 # after the tag's workflow publishes +``` + +The fallback uploads signed zips and removes the unsigned tarballs, which is why the installer accepts either. + ## Contributing To add a new coding agent, see [docs/adding-a-client.md](./docs/adding-a-client.md). diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b687c1d --- /dev/null +++ b/install.sh @@ -0,0 +1,84 @@ +#!/bin/sh +# Install aperture from the latest GitHub release: detect the platform, +# download the matching asset, verify it against the release checksums and +# install it as `aperture`. curl installs never carry the quarantine +# attribute, so Gatekeeper never assesses the result. +# +# curl -fsSL https://raw.githubusercontent.com/tailscale/aperture-cli/main/install.sh | sh +# +# APERTURE_VERSION pins a release tag (default: latest), APERTURE_INSTALL_DIR +# overrides /usr/local/bin. +set -eu + +REPO=tailscale/aperture-cli +INSTALL_DIR="${APERTURE_INSTALL_DIR:-/usr/local/bin}" + +os=$(uname -s | tr '[:upper:]' '[:lower:]') +arch=$(uname -m) +case "$arch" in + x86_64) arch=amd64 ;; + arm64|aarch64) arch=arm64 ;; + *) echo "unsupported architecture: $arch" >&2; exit 1 ;; +esac + +if [ -z "${APERTURE_VERSION:-}" ]; then + redirect=$(curl -fsSI -o /dev/null -w '%{redirect_url}' "https://github.com/$REPO/releases/latest") + [ -n "$redirect" ] || { echo "could not resolve the latest release" >&2; exit 1; } + APERTURE_VERSION=${redirect##*/} +fi + +case "$os" in + linux) asset="aperture-cli_linux_$arch.tar.gz" ;; + # Releases ship tarballs; the local fallback flow (make upload-mac) + # uploads zips instead, so try both. + darwin) + asset="aperture-cli_darwin_$arch.tar.gz" + legacy="aperture_${APERTURE_VERSION}_darwin_$arch.zip" + ;; + *) echo "unsupported OS: $os" >&2; exit 1 ;; +esac + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +base="https://github.com/$REPO/releases/download/$APERTURE_VERSION" +echo "==> Downloading $asset ($APERTURE_VERSION)" +if ! curl -fsSL -o "$tmp/$asset" "$base/$asset"; then + [ -n "${legacy:-}" ] || exit 1 + asset=$legacy + echo "==> Falling back to $asset" + curl -fsSL -o "$tmp/$asset" "$base/$asset" +fi +curl -fsSL -o "$tmp/checksums.txt" "$base/checksums.txt" + +echo "==> Verifying checksum" +if command -v sha256sum >/dev/null 2>&1; then + (cd "$tmp" && grep " ${asset}$" checksums.txt | sha256sum -c -) +else + (cd "$tmp" && grep " ${asset}$" checksums.txt | shasum -a 256 -c -) +fi + +echo "==> Extracting" +mkdir "$tmp/x" +case "$asset" in + *.tar.gz) tar -xzf "$tmp/$asset" -C "$tmp/x" ;; + *.zip) unzip -q "$tmp/$asset" -d "$tmp/x" ;; +esac +bin=$(find "$tmp/x" -type f -name 'aperture*' ! -name '._*' | head -1) +[ -n "$bin" ] || { echo "no aperture binary found in $asset" >&2; exit 1; } + +if [ ! -d "$INSTALL_DIR" ]; then + mkdir -p "$INSTALL_DIR" 2>/dev/null || true +fi +sudo="" +if [ ! -w "$INSTALL_DIR" ]; then + command -v sudo >/dev/null 2>&1 || { + echo "cannot write $INSTALL_DIR; set APERTURE_INSTALL_DIR (e.g. \$HOME/.local/bin)" >&2 + exit 1 + } + sudo="sudo" +fi +$sudo mkdir -p "$INSTALL_DIR" +$sudo install -m 0755 "$bin" "$INSTALL_DIR/aperture" + +echo "==> Installed $("$INSTALL_DIR/aperture" -version | head -1) to $INSTALL_DIR/aperture" diff --git a/scripts/setup-macos-signing.sh b/scripts/setup-macos-signing.sh new file mode 100755 index 0000000..736c0f6 --- /dev/null +++ b/scripts/setup-macos-signing.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# setup-macos-signing.sh prepares a GitHub macOS runner for the goreleaser +# signing hook (scripts/sign-macos.sh): it imports the Developer ID +# Application certificate from APPLE_CERT_P12 into a fresh temporary +# keychain, puts that keychain on the user search list so codesign sees it, +# and stores the notarization credentials under NOTARY_PROFILE in the same +# keychain, which teardown deletes. +# +# Required environment: +# APPLE_CERT_P12 base64-encoded .p12 of the certificate and private key +# APPLE_CERT_PASSWORD password of that .p12 +# APPLE_ID Apple ID used for notarization +# APPLE_ID_PASSWORD app-specific password for that Apple ID +set -euo pipefail + +: "${APPLE_CERT_P12:?set to the base64-encoded Developer ID Application .p12}" +: "${APPLE_CERT_PASSWORD:?set to the .p12 password}" +: "${APPLE_ID:?set to the notarization Apple ID}" +: "${APPLE_ID_PASSWORD:?set to its app-specific password}" + +# Printed on every Developer ID signature; an identifier, not a secret. +TEAM_ID=W5364U7YZB +NOTARY_PROFILE="${NOTARY_PROFILE:-ci-notary}" + +umask 077 +workdir=$(mktemp -d "${TMPDIR:-/tmp}/aperture-signing.XXXXXX") +keychain="$workdir/signing.keychain-db" +keychain_password=$(openssl rand -base64 32) + +cleanup() { + status=$? + rm -f "$workdir/certificate.p12" || true + security delete-keychain "$keychain" >/dev/null 2>&1 || true + return "$status" +} +trap cleanup EXIT + +printf '%s' "$APPLE_CERT_P12" | base64 -d > "$workdir/certificate.p12" +security create-keychain -p "$keychain_password" "$keychain" +security set-keychain-settings -lut 3600 "$keychain" +security unlock-keychain -p "$keychain_password" "$keychain" +security import "$workdir/certificate.p12" -k "$keychain" \ + -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign +# Without this, codesign prompts for the keychain password on first use and +# the headless runner hangs. +security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$keychain_password" "$keychain" +# The Makefile's codesign and find-identity calls take no --keychain flag, so +# the temporary keychain has to sit on the user search list. +# shellcheck disable=SC2046 +security list-keychains -d user \ + -s "$keychain" $(security list-keychains -d user | tr -d '"') + +xcrun notarytool store-credentials "$NOTARY_PROFILE" \ + --apple-id "$APPLE_ID" --password "$APPLE_ID_PASSWORD" --team-id "$TEAM_ID" \ + --keychain "$keychain" + +echo "Signing identity: Developer ID Application: Tailscale Inc. ($TEAM_ID)" +echo "Notary profile: $NOTARY_PROFILE" +if [ -n "${GITHUB_ENV:-}" ]; then + # Darwin releases require readiness; local snapshots may remain unsigned. + { + echo "SIGNING_KEYCHAIN=$keychain" + echo "APERTURE_SIGNING_READY=1" + echo "NOTARY_KEYCHAIN=$keychain" + } >> "$GITHUB_ENV" +fi +rm -f "$workdir/certificate.p12" +trap - EXIT diff --git a/scripts/sign-macos.sh b/scripts/sign-macos.sh new file mode 100755 index 0000000..7417d39 --- /dev/null +++ b/scripts/sign-macos.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# sign-macos.sh signs and notarizes one freshly built goreleaser binary; it +# runs as a build post-hook with the binary path, build target and snapshot +# flag. Linux builds and explicit snapshots pass through unsigned. Darwin +# releases require a prepared keychain and an Accepted notarization result. +# A failure stops the release before GoReleaser archives or publishes it. +set -euo pipefail + +binary=$1 +target=$2 + +case "$target" in + darwin_*) ;; + *) exit 0 ;; +esac + +# GoReleaser snapshots cannot publish, so only they may skip Darwin signing. +if [ "${3:-false}" = "true" ]; then + exit 0 +fi + +# Setup sets this after it imports the certificate and notary credentials. +if [ "${APERTURE_SIGNING_READY:-}" != "1" ]; then + echo "signing keychain not prepared for $target release" >&2 + exit 1 +fi + +identity="${SIGN_IDENTITY:-Developer ID Application: Tailscale Inc. (W5364U7YZB)}" +profile="${NOTARY_PROFILE:-ci-notary}" +notary_keychain="${NOTARY_KEYCHAIN:?setup must export the temporary keychain}" + +codesign --sign "$identity" --options runtime --timestamp --force "$binary" +codesign --verify --strict --verbose=2 "$binary" + +# Bare executables cannot be stapled; Apple serves the ticket by cdhash, so +# the zip only carries the binary to the notary and is never shipped. +submission="$binary.zip" +trap 'rm -f "$submission"' EXIT +zip -j -q "$submission" "$binary" +result=$(xcrun notarytool submit "$submission" \ + --keychain-profile "$profile" --keychain "$notary_keychain" \ + --wait --output-format json) +echo "$result" +if ! printf '%s' "$result" | jq -e -s 'length == 1 and (.[0] | type == "object" and .status == "Accepted")' >/dev/null; then + echo "notarization was not accepted for $target" >&2 + exit 1 +fi diff --git a/scripts/sign_macos_test.go b/scripts/sign_macos_test.go new file mode 100644 index 0000000..845b27b --- /dev/null +++ b/scripts/sign_macos_test.go @@ -0,0 +1,178 @@ +package scripts + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestReleaseWorkflowExcludesPullRequests(t *testing.T) { + workflow, err := os.ReadFile("../.github/workflows/release.yaml") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(workflow), "pull_request") { + t.Fatal("release workflow must not handle pull requests") + } +} + +// Exercise the release hook with fake Apple tools. Signing and notarization +// still need a credentialed macOS run; these checks cover publication gating. +func TestSignMacOS(t *testing.T) { + // Go's test cache must track the script, which is otherwise read by Bash. + if _, err := os.ReadFile("sign-macos.sh"); err != nil { + t.Fatal(err) + } + type testCase struct { + name string + target string + ready bool + snapshot string + status string + response string + failure string + noKeychain bool + wantError bool + } + tests := []testCase{ + {name: "linux", target: "linux_amd64_v1", ready: true}, + {name: "darwin missing readiness omitted snapshot", wantError: true}, + {name: "darwin missing readiness explicit false", snapshot: "false", wantError: true}, + {name: "snapshot true missing readiness", snapshot: "true"}, + {name: "snapshot true with readiness", ready: true, snapshot: "true"}, + {name: "missing keychain", ready: true, noKeychain: true, wantError: true}, + {name: "accepted compact", ready: true, response: `{"status":"Accepted"}`}, + {name: "accepted pretty", ready: true, snapshot: "false", response: "{\n \"status\" : \"Accepted\"\n}"}, + {name: "invalid", ready: true, response: `{"status":"Invalid"}`, wantError: true}, + {name: "pending", ready: true, response: `{"status":"In Progress"}`, wantError: true}, + {name: "missing status", ready: true, response: `{"id":"test-submission"}`, wantError: true}, + {name: "null status", ready: true, response: `{"status":null}`, wantError: true}, + {name: "wrong-type status", ready: true, response: `{"status":123}`, wantError: true}, + {name: "malformed json", ready: true, response: `{"status":"Accepted"`, wantError: true}, + {name: "top-level array", ready: true, response: `[{"status":"Accepted"}]`, wantError: true}, + {name: "multiple results", ready: true, response: "{\"status\":\"Invalid\"}\n{\"status\":\"Accepted\"}", wantError: true}, + {name: "nested accepted under invalid", ready: true, response: `{"status":"Invalid","details":{"status":"Accepted"}}`, wantError: true}, + {name: "notary failure before output", ready: true, failure: "notary", status: "Accepted", wantError: true}, + {name: "notary failure after output", ready: true, failure: "notary-after", response: `{"status":"Accepted"}`, wantError: true}, + {name: "signing failure", ready: true, failure: "sign", wantError: true}, + {name: "verification failure", ready: true, failure: "verify", wantError: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + binDir := filepath.Join(dir, "tools") + if err := os.Mkdir(binDir, 0700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"codesign", "xcrun"} { + if err := os.WriteFile(filepath.Join(binDir, name), []byte(fakeAppleTool), 0700); err != nil { + t.Fatal(err) + } + } + binary := filepath.Join(dir, "aperture") + if err := os.WriteFile(binary, []byte("unsigned\n"), 0700); err != nil { + t.Fatal(err) + } + target := tt.target + if target == "" { + target = "darwin_amd64_v1" + } + args := []string{"sign-macos.sh", binary, target} + if tt.snapshot != "" { + args = append(args, tt.snapshot) + } + cmd := exec.Command("bash", args...) + readyValue := "" + if tt.ready { + readyValue = "1" + } + notaryKeychain := filepath.Join(dir, "signing.keychain-db") + if tt.noKeychain { + notaryKeychain = "" + } + cmd.Env = append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "TEST_LOG="+filepath.Join(dir, "commands"), + "TEST_ARCHIVE_CONTENTS="+filepath.Join(dir, "archived-binary"), + "TEST_BINARY="+binary, + "TEST_FAILURE="+tt.failure, + "TEST_STATUS="+tt.status, + "TEST_RESPONSE="+tt.response, + "APERTURE_SIGNING_READY="+readyValue, + "NOTARY_KEYCHAIN="+notaryKeychain, + ) + output, err := cmd.CombinedOutput() + if (err != nil) != tt.wantError { + t.Fatalf("hook error = %v, want error %v\n%s", err, tt.wantError, output) + } + log, err := os.ReadFile(filepath.Join(dir, "commands")) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + commands := string(log) + if _, err := os.Stat(binary + ".zip"); !os.IsNotExist(err) { + t.Errorf("submission zip was not cleaned up: %v", err) + } + if !tt.ready || tt.snapshot == "true" || strings.HasPrefix(target, "linux_") { + if commands != "" { + t.Errorf("skipped run must not call Apple tools, got:\n%s", commands) + } + contents, readErr := os.ReadFile(binary) + if readErr != nil || string(contents) != "unsigned\n" { + t.Errorf("binary must pass through untouched: %q, %v", contents, readErr) + } + return + } + if commands == "" { + if tt.noKeychain { + return + } + t.Fatal("expected Apple tools to be called") + } + if tt.failure == "sign" || tt.failure == "verify" { + if strings.Contains(commands, "xcrun") { + t.Errorf("submitted a binary after %s failed", tt.failure) + } + } + if !tt.wantError { + contents, err := os.ReadFile(filepath.Join(dir, "archived-binary")) + if err != nil || string(contents) != "unsigned\nsigned\n" { + t.Errorf("notarization archive must contain the signed binary: %q, %v", contents, err) + } + for _, flag := range []string{"--options runtime", "--timestamp", "W5364U7YZB", "--verify --strict", "--keychain " + filepath.Join(dir, "signing.keychain-db"), "--wait"} { + if !strings.Contains(commands, flag) { + t.Errorf("missing signing requirement %q in:\n%s", flag, commands) + } + } + } + }) + } +} + +const fakeAppleTool = `#!/usr/bin/env bash +set -euo pipefail +tool=$(basename "$0") +printf '%s %s\n' "$tool" "$*" >> "$TEST_LOG" +case "$tool $1" in + "codesign --sign") + [[ "$TEST_FAILURE" != sign ]] + printf 'signed\n' >> "$TEST_BINARY" + ;; + "codesign --verify") + [[ "$TEST_FAILURE" != verify ]] + ;; + "xcrun notarytool") + [[ "$TEST_FAILURE" != notary ]] + unzip -p "$3" aperture > "$TEST_ARCHIVE_CONTENTS" + if [[ -n "${TEST_RESPONSE:-}" ]]; then + printf '%s\n' "$TEST_RESPONSE" + else + printf '{"id":"test-submission","status":"%s"}\n' "$TEST_STATUS" + fi + [[ "$TEST_FAILURE" != notary-after ]] + ;; +esac +`