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
30 changes: 15 additions & 15 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
6 changes: 6 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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"; \
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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
Expand Down Expand Up @@ -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).
84 changes: 84 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
@@ -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"
69 changes: 69 additions & 0 deletions scripts/setup-macos-signing.sh
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions scripts/sign-macos.sh
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading