From ea4116f0bdfb21aa05a1b8ff1a4c13856c8cadc7 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 23 Sep 2026 20:13:09 +0000 Subject: [PATCH 1/9] release: strip resource forks and xattrs from notarized zips ditto -c -k stores the built binary's xattrs (notably com.apple.provenance, which modern macOS stamps on every built file) as AppleDouble ._* entries inside the zip. Archive Utility on current macOS extracts those as literal files, a pattern already observed breaking Gatekeeper assessment of Finder-extracted downloads elsewhere. The binaries are bare Mach-O: no resource forks or xattrs need preserving, so --norsrc --noextattr removes the pollution at the source. --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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"; \ From e74e708a1af6eb94b441453c9564a9c0ce4fc273 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 23 Sep 2026 20:22:26 +0000 Subject: [PATCH 2/9] release: sign and notarize macOS builds in CI The certificate lives on one laptop, so every release depends on that machine being around and awake. With the certificate and notarization credentials in GitHub secrets, a macOS job can run the same Makefile targets the local flow uses: setup-macos-signing.sh builds a temporary keychain (adapted from the unmerged APT-331 branch), then release-mac-notarized and upload-mac produce and publish the signed zips. One implementation of asset replacement keeps CI and local identical. Tradeoff: GoReleaser still publishes unsigned darwin tarballs first, and they sit on the release until upload-mac replaces them, the length of one notarization. Cutting darwin from the goreleaser config would close that window but fork the local and CI release definitions. Revisit if anyone ships a workaround that scrapes the tarballs in that gap. Not exercised end to end until the secrets exist and a tag is pushed; the Makefile path itself is the one that produced v0.0.13. --- .github/workflows/release.yaml | 48 ++++++++++++++++++++++++++++++ scripts/setup-macos-signing.sh | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100755 scripts/setup-macos-signing.sh diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a03d41e..e3c56c6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -53,3 +53,51 @@ jobs: args: ${{ steps.goreleaser-args.outputs.args }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # GoReleaser publishes unsigned darwin tarballs; this job rebuilds, signs + # and notarizes the darwin binaries with the certificate in GitHub secrets + # and replaces those assets with the signed zips, rewriting checksums.txt. + # It runs the same Makefile targets as the local flow so both paths produce + # identical artifacts. Skipped on PR dry runs: no secrets, nothing to sign. + macos-sign: + name: Sign and notarize macOS builds + needs: goreleaser + if: github.event_name != 'pull_request' + runs-on: macos-latest + timeout-minutes: 60 + env: + TAG: ${{ github.event.inputs.tag || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + ref: ${{ github.event.inputs.tag || github.ref }} + - + name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: false + - + 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: Build, sign and notarize + run: >- + make release-mac-notarized VERSION="$TAG" NOTARY_PROFILE=ci-notary + SIGN_IDENTITY='Developer ID Application: Tailscale Inc. (W5364U7YZB)' + - + name: Replace unsigned darwin assets on the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: make upload-mac VERSION="$TAG" + - + name: Tear down signing keychain + if: always() + run: security delete-keychain "$SIGNING_KEYCHAIN" || true diff --git a/scripts/setup-macos-signing.sh b/scripts/setup-macos-signing.sh new file mode 100755 index 0000000..b445b32 --- /dev/null +++ b/scripts/setup-macos-signing.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# setup-macos-signing.sh prepares a GitHub macOS runner for +# `make release-mac-notarized`: 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 find-identity and codesign see it, and +# stores the notarization credentials under NOTARY_PROFILE in the login +# keychain, which is where `notarytool --keychain-profile` looks. +# +# 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) + +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 +rm "$workdir/certificate.p12" +# 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" + +echo "Signing identity: Developer ID Application: Tailscale Inc. ($TEAM_ID)" +echo "Notary profile: $NOTARY_PROFILE" +if [ -n "${GITHUB_ENV:-}" ]; then + echo "SIGNING_KEYCHAIN=$keychain" >> "$GITHUB_ENV" +fi From b4a97e7c7376b2d7ac47afd43e65726f58bff534 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 23 Sep 2026 20:22:26 +0000 Subject: [PATCH 3/9] release: add curl installer and document the macOS install paths Browser downloads set the quarantine attribute, and managed Macs can fail Gatekeeper's online notarization lookup even for properly notarized binaries: reproduced on a corp Mac with terraform's own signed release. So the README needs two things the go install line never had: a quarantine-free install path and the Open Anyway instructions for people who do click the zip in a browser. install.sh resolves the latest tag, downloads the matching asset and verifies it against the release checksums.txt before installing. Verified on linux amd64 against v0.0.13: latest-tag resolution, checksum accept, checksum reject on a corrupted download, and -version on the installed binary. The darwin branch is untested here (no Mac in the sandbox); the zip layout it expects was confirmed against the published v0.0.13 archive. --- README.md | 21 ++++++++++++++++ install.sh | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100755 install.sh diff --git a/README.md b/README.md index f6ecbf1..82fa4c9 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 zip in a browser and double-click it instead, macOS may block the binary anyway (common on managed Macs): open System Settings > Privacy & Security and click **Open Anyway** next to the blocked entry. + ## Usage ```sh @@ -110,6 +120,17 @@ make install # install to $GOPATH/bin make clean # remove built binary ``` +## Releasing + +Push a tag. The release workflow builds and publishes the Linux assets with GoReleaser, then a macOS job imports the Developer ID certificate from GitHub secrets into a temporary keychain, signs and notarizes the macOS builds, and replaces the unsigned darwin assets on the release with the signed zips, rewriting checksums.txt to match. 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: + +```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 +``` + ## 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..35bfa91 --- /dev/null +++ b/install.sh @@ -0,0 +1,74 @@ +#!/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" ;; + darwin) asset="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)" +curl -fsSL -o "$tmp/$asset" "$base/$asset" +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" From d5ed2d634b1bbc94941ff4e983e3b53a4473cc2f Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 23 Sep 2026 20:49:09 +0000 Subject: [PATCH 4/9] release: preserve published history for macOS asset changes The box recreated two commits after origin already held their signed versions. sand replayed the replacements over the published additions and hit a workflow conflict. Keep the published commits and record the intended changes as one follow-up. Its tree is identical to 9686877. Rebase would repeat the original additions; replacing origin would discard signed history. Revisit only if the published commits themselves must be removed. --- .github/workflows/release.yaml | 10 +++++----- .goreleaser.yaml | 4 +++- Makefile | 24 ++++++++---------------- README.md | 2 +- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e3c56c6..2c72568 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -54,11 +54,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # GoReleaser publishes unsigned darwin tarballs; this job rebuilds, signs - # and notarizes the darwin binaries with the certificate in GitHub secrets - # and replaces those assets with the signed zips, rewriting checksums.txt. - # It runs the same Makefile targets as the local flow so both paths produce - # identical artifacts. Skipped on PR dry runs: no secrets, nothing to sign. + # GoReleaser publishes linux assets only; this job builds, signs and + # notarizes the darwin binaries with the certificate in GitHub secrets and + # adds the signed zips and their checksums to the release. It runs the + # same Makefile targets as the local flow so both paths produce identical + # artifacts. Skipped on PR dry runs: no secrets, nothing to sign. macos-sign: name: Sign and notarize macOS builds needs: goreleaser diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 523622b..a1b26dd 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -15,9 +15,11 @@ builds: - -X main.buildDate={{.Date}} env: - CGO_ENABLED=0 + # Linux only: darwin assets are built, signed and notarized by the + # Makefile flow (release-mac-notarized) so nothing unsigned ever + # reaches the release. goos: - linux - - darwin goarch: - amd64 - arm64 diff --git a/Makefile b/Makefile index 29b7d99..a7b488a 100644 --- a/Makefile +++ b/Makefile @@ -78,6 +78,7 @@ lint: # racing a control plane, so a data race is the failure this project actually # has, and `go test` will not find one. check: lint build + GOOS=darwin go build ./... go test -race ./... install: @@ -195,31 +196,22 @@ release-mac-notarized: release-mac notarize-mac verify-mac echo " next, once the tag's goreleaser run has published:"; \ echo " make upload-mac VERSION=$(VERSION)" -# Replace the unsigned darwin assets on the GitHub release for VERSION with -# the signed, notarized archives, and rewrite checksums.txt to match: the -# goreleaser assets are named aperture_darwin_.tar.gz while the signed -# ones are zips, so an upload alone would leave both on the release and the -# checksums pointing at the unsigned tarballs. Run only after the tag's -# goreleaser workflow has published. -R is explicit so this works from a -# checkout whose origin is not github.com. +# Add the signed, notarized darwin archives to the GitHub release for +# VERSION and extend checksums.txt with their sums. goreleaser publishes +# linux assets only, so there is nothing unsigned to remove; the grep guard +# stays as a tripwire in case darwin ever re-enters the goreleaser config. +# Run only after the tag's goreleaser workflow has published. -R is explicit +# so this works from a checkout whose origin is not github.com. upload-mac: @set -euo pipefail; \ test -f "$(ARM64_ARCHIVE)"; \ test -f "$(AMD64_ARCHIVE)"; \ test -f "$(CHECKSUMS)"; \ REPO=tailscale/aperture-cli; \ - echo "==> Deleting unsigned darwin assets from $(VERSION)"; \ - while read -r asset; do \ - case "$$asset" in \ - *darwin*) \ - echo " delete: $$asset"; \ - gh release delete-asset "$(VERSION)" "$$asset" -R "$$REPO" --yes ;; \ - esac; \ - done < <(gh release view "$(VERSION)" -R "$$REPO" --json assets -q '.assets[].name'); \ echo "==> Uploading signed archives"; \ gh release upload "$(VERSION)" -R "$$REPO" \ "$(ARM64_ARCHIVE)" "$(AMD64_ARCHIVE)"; \ - echo "==> Rewriting checksums.txt"; \ + echo "==> Extending checksums.txt"; \ WORK="$$(mktemp -d /tmp/aperture-checksums.XXXXXX)"; \ trap 'rm -rf "$$WORK"' EXIT; \ gh release download "$(VERSION)" -R "$$REPO" \ diff --git a/README.md b/README.md index 82fa4c9..4860ec2 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ make clean # remove built binary ## Releasing -Push a tag. The release workflow builds and publishes the Linux assets with GoReleaser, then a macOS job imports the Developer ID certificate from GitHub secrets into a temporary keychain, signs and notarizes the macOS builds, and replaces the unsigned darwin assets on the release with the signed zips, rewriting checksums.txt to match. Required secrets: `APPLE_CERT_P12` (the base64-encoded .p12), `APPLE_CERT_PASSWORD`, `APPLE_ID` and `APPLE_ID_PASSWORD` (an app-specific password). +Push a tag. The release workflow builds and publishes the Linux assets with GoReleaser, then a macOS job imports the Developer ID certificate from GitHub secrets into a temporary keychain, signs and notarizes the macOS builds, and adds the signed zips to the release, extending checksums.txt. GoReleaser publishes Linux assets only, so nothing unsigned ever lands on the release. 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: From d556f5c63df2ef39fb377cfeaeb25a93ec82ec72 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 23 Sep 2026 21:23:25 +0000 Subject: [PATCH 5/9] release: sign and notarize darwin builds inside the goreleaser run The two-job design published unsigned darwin tarballs and replaced them afterwards, so for minutes every release carried binaries Gatekeeper would block, and a failed signing job left them there permanently. Move signing into the pipeline: one macOS job runs goreleaser with a build post-hook (scripts/sign-macos.sh) that codesigns and notarizes each darwin binary before archiving, failing the run on anything but Accepted. Linux cross-compiles with CGO off. GoReleaser's native notarization is Pro-only, which is why this is a hook script. PR dry runs get no keychain setup, so the hook passes binaries through unsigned. The keychain prep and the fake-Apple-tool hook test are adapted from the unmerged APT-331 branch, with the keychain created once per job instead of once per binary. upload-mac stays as the local fallback for a release the workflow could not sign. --- .github/workflows/release.yaml | 61 +++++----------- .goreleaser.yaml | 11 ++- Makefile | 24 ++++--- scripts/setup-macos-signing.sh | 13 ++-- scripts/sign-macos.sh | 42 +++++++++++ scripts/sign_macos_test.go | 128 +++++++++++++++++++++++++++++++++ 6 files changed, 218 insertions(+), 61 deletions(-) create mode 100755 scripts/sign-macos.sh create mode 100644 scripts/sign_macos_test.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2c72568..6da3cdc 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -21,8 +21,14 @@ permissions: contents: write jobs: + # One macOS job builds everything: codesign and notarytool only exist on + # macOS, and the linux binaries cross-compile with CGO off. The build + # post-hook signs and notarizes each darwin binary before archiving, so + # nothing unsigned is ever published. PR dry runs skip the keychain setup + # and the hook passes binaries through unsigned. goreleaser: - runs-on: ubuntu-latest + runs-on: macos-latest + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -44,43 +50,9 @@ jobs: else echo "args=release --clean" >> $GITHUB_OUTPUT fi - - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a #v6.4.0 - with: - distribution: goreleaser - version: '~> v2' - args: ${{ steps.goreleaser-args.outputs.args }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # GoReleaser publishes linux assets only; this job builds, signs and - # notarizes the darwin binaries with the certificate in GitHub secrets and - # adds the signed zips and their checksums to the release. It runs the - # same Makefile targets as the local flow so both paths produce identical - # artifacts. Skipped on PR dry runs: no secrets, nothing to sign. - macos-sign: - name: Sign and notarize macOS builds - needs: goreleaser - if: github.event_name != 'pull_request' - runs-on: macos-latest - timeout-minutes: 60 - env: - TAG: ${{ github.event.inputs.tag || github.ref_name }} - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - ref: ${{ github.event.inputs.tag || github.ref }} - - - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 - with: - go-version-file: go.mod - cache: false - name: Set up signing keychain and notary credentials + if: github.event_name != 'pull_request' env: APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} @@ -88,16 +60,15 @@ jobs: APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} run: bash scripts/setup-macos-signing.sh - - name: Build, sign and notarize - run: >- - make release-mac-notarized VERSION="$TAG" NOTARY_PROFILE=ci-notary - SIGN_IDENTITY='Developer ID Application: Tailscale Inc. (W5364U7YZB)' - - - name: Replace unsigned darwin assets on the release + name: Run GoReleaser + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a #v6.4.0 + with: + distribution: goreleaser + version: '~> v2' + args: ${{ steps.goreleaser-args.outputs.args }} env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: make upload-mac VERSION="$TAG" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Tear down signing keychain - if: always() + if: always() && github.event_name != 'pull_request' run: security delete-keychain "$SIGNING_KEYCHAIN" || true diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a1b26dd..f210090 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -15,14 +15,19 @@ builds: - -X main.buildDate={{.Date}} env: - CGO_ENABLED=0 - # Linux only: darwin assets are built, signed and notarized by the - # Makefile flow (release-mac-notarized) so nothing unsigned ever - # reaches the release. goos: - linux + - darwin goarch: - amd64 - arm64 + # Sign and notarize each darwin binary before archiving, so nothing + # unsigned ever reaches the release. The hook passes linux builds and + # runs without a prepared keychain (PR dry runs) through unsigned. + hooks: + post: + - cmd: 'bash scripts/sign-macos.sh "{{ .Path }}" "{{ .Target }}"' + output: true archives: - formats: diff --git a/Makefile b/Makefile index a7b488a..29b7d99 100644 --- a/Makefile +++ b/Makefile @@ -78,7 +78,6 @@ lint: # racing a control plane, so a data race is the failure this project actually # has, and `go test` will not find one. check: lint build - GOOS=darwin go build ./... go test -race ./... install: @@ -196,22 +195,31 @@ release-mac-notarized: release-mac notarize-mac verify-mac echo " next, once the tag's goreleaser run has published:"; \ echo " make upload-mac VERSION=$(VERSION)" -# Add the signed, notarized darwin archives to the GitHub release for -# VERSION and extend checksums.txt with their sums. goreleaser publishes -# linux assets only, so there is nothing unsigned to remove; the grep guard -# stays as a tripwire in case darwin ever re-enters the goreleaser config. -# Run only after the tag's goreleaser workflow has published. -R is explicit -# so this works from a checkout whose origin is not github.com. +# Replace the unsigned darwin assets on the GitHub release for VERSION with +# the signed, notarized archives, and rewrite checksums.txt to match: the +# goreleaser assets are named aperture_darwin_.tar.gz while the signed +# ones are zips, so an upload alone would leave both on the release and the +# checksums pointing at the unsigned tarballs. Run only after the tag's +# goreleaser workflow has published. -R is explicit so this works from a +# checkout whose origin is not github.com. upload-mac: @set -euo pipefail; \ test -f "$(ARM64_ARCHIVE)"; \ test -f "$(AMD64_ARCHIVE)"; \ test -f "$(CHECKSUMS)"; \ REPO=tailscale/aperture-cli; \ + echo "==> Deleting unsigned darwin assets from $(VERSION)"; \ + while read -r asset; do \ + case "$$asset" in \ + *darwin*) \ + echo " delete: $$asset"; \ + gh release delete-asset "$(VERSION)" "$$asset" -R "$$REPO" --yes ;; \ + esac; \ + done < <(gh release view "$(VERSION)" -R "$$REPO" --json assets -q '.assets[].name'); \ echo "==> Uploading signed archives"; \ gh release upload "$(VERSION)" -R "$$REPO" \ "$(ARM64_ARCHIVE)" "$(AMD64_ARCHIVE)"; \ - echo "==> Extending checksums.txt"; \ + echo "==> Rewriting checksums.txt"; \ WORK="$$(mktemp -d /tmp/aperture-checksums.XXXXXX)"; \ trap 'rm -rf "$$WORK"' EXIT; \ gh release download "$(VERSION)" -R "$$REPO" \ diff --git a/scripts/setup-macos-signing.sh b/scripts/setup-macos-signing.sh index b445b32..9026b4e 100755 --- a/scripts/setup-macos-signing.sh +++ b/scripts/setup-macos-signing.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash -# setup-macos-signing.sh prepares a GitHub macOS runner for -# `make release-mac-notarized`: 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 find-identity and codesign see it, and -# stores the notarization credentials under NOTARY_PROFILE in the login +# 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 login # keychain, which is where `notarytool --keychain-profile` looks. # # Required environment: @@ -51,4 +51,7 @@ echo "Signing identity: Developer ID Application: Tailscale Inc. ($TEAM_ID)" echo "Notary profile: $NOTARY_PROFILE" if [ -n "${GITHUB_ENV:-}" ]; then echo "SIGNING_KEYCHAIN=$keychain" >> "$GITHUB_ENV" + # The goreleaser hook signs only when this is set, so PR dry runs and + # local snapshots pass binaries through unsigned. + echo "APERTURE_SIGNING_READY=1" >> "$GITHUB_ENV" fi diff --git a/scripts/sign-macos.sh b/scripts/sign-macos.sh new file mode 100755 index 0000000..041d768 --- /dev/null +++ b/scripts/sign-macos.sh @@ -0,0 +1,42 @@ +#!/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 and build target as +# arguments. Linux builds and runs without a prepared keychain (PR dry runs, +# local snapshots) pass through unsigned. A failed signature or a notary +# status other than Accepted fails the hook, which fails the goreleaser run +# before anything is archived or published. +set -euo pipefail + +binary=$1 +target=$2 + +case "$target" in + darwin_*) ;; + *) exit 0 ;; +esac + +# Set by scripts/setup-macos-signing.sh once the temporary keychain holds +# the certificate and the notary profile. +if [ "${APERTURE_SIGNING_READY:-}" != "1" ]; then + echo "signing keychain not prepared, leaving $target unsigned" + exit 0 +fi + +identity="${SIGN_IDENTITY:-Developer ID Application: Tailscale Inc. (W5364U7YZB)}" +profile="${NOTARY_PROFILE:-ci-notary}" + +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" +zip -j -q "$submission" "$binary" +result=$(xcrun notarytool submit "$submission" \ + --keychain-profile "$profile" --wait --output-format json || true) +rm -f "$submission" +echo "$result" +if ! printf '%s' "$result" | grep -q '"status":"Accepted"'; 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..dab77de --- /dev/null +++ b/scripts/sign_macos_test.go @@ -0,0 +1,128 @@ +package scripts + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// 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 + status string + failure string + wantError bool + } + tests := []testCase{ + {name: "linux", target: "linux_amd64_v1"}, + {name: "keychain not prepared", target: "darwin_arm64_v8.0"}, + {name: "accepted", ready: true, status: "Accepted"}, + {name: "rejected", ready: true, status: "Invalid", wantError: true}, + {name: "pending", ready: true, status: "In Progress", wantError: true}, + {name: "missing status", ready: true, status: "", wantError: true}, + {name: "notary failure", ready: true, failure: "notary", 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" + } + cmd := exec.Command("bash", "sign-macos.sh", binary, target) + 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, + ) + if tt.ready { + cmd.Env = append(cmd.Env, "APERTURE_SIGNING_READY=1") + } + 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 !tt.ready { + if commands != "" { + t.Errorf("unsigned 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 tt.failure == "sign" || tt.failure == "verify" { + if strings.Contains(commands, "xcrun") { + t.Errorf("submitted a binary after %s failed", tt.failure) + } + } + if tt.status == "Accepted" && tt.failure == "" { + 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", "--wait"} { + if !strings.Contains(commands, flag) { + t.Errorf("missing signing requirement %q in:\n%s", flag, commands) + } + } + } + if _, err := os.Stat(binary + ".zip"); !os.IsNotExist(err) { + t.Errorf("submission zip was not cleaned up: %v", err) + } + }) + } +} + +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" + printf '{"id":"test-submission","status":"%s"}\n' "$TEST_STATUS" + ;; +esac +` From 5600ed2da34724e2c4b9209092cb97238fbbec8b Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 23 Sep 2026 21:24:19 +0000 Subject: [PATCH 6/9] release: teach the installer the CI asset layout CI now ships darwin as goreleaser tarballs like linux, so the installer looks for aperture-cli_darwin_.tar.gz first and falls back to the zips the local upload-mac flow still produces. The README releasing section describes the hook-based workflow and marks the Makefile flow as the fallback. Verified end to end on linux and against the v0.0.13 darwin zip fallback: download, checksum, extract, install, -version. --- README.md | 8 +++++--- install.sh | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4860ec2..46b87ab 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ 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 zip in a browser and double-click it instead, macOS may block the binary anyway (common on managed Macs): open System Settings > Privacy & Security and click **Open Anyway** next to the blocked entry. +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 @@ -122,15 +122,17 @@ make clean # remove built binary ## Releasing -Push a tag. The release workflow builds and publishes the Linux assets with GoReleaser, then a macOS job imports the Developer ID certificate from GitHub secrets into a temporary keychain, signs and notarizes the macOS builds, and adds the signed zips to the release, extending checksums.txt. GoReleaser publishes Linux assets only, so nothing unsigned ever lands on the release. Required secrets: `APPLE_CERT_P12` (the base64-encoded .p12), `APPLE_CERT_PASSWORD`, `APPLE_ID` and `APPLE_ID_PASSWORD` (an app-specific password). +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: +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 index 35bfa91..b687c1d 100755 --- a/install.sh +++ b/install.sh @@ -29,7 +29,12 @@ fi case "$os" in linux) asset="aperture-cli_linux_$arch.tar.gz" ;; - darwin) asset="aperture_${APERTURE_VERSION}_darwin_$arch.zip" ;; + # 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 @@ -38,7 +43,12 @@ trap 'rm -rf "$tmp"' EXIT base="https://github.com/$REPO/releases/download/$APERTURE_VERSION" echo "==> Downloading $asset ($APERTURE_VERSION)" -curl -fsSL -o "$tmp/$asset" "$base/$asset" +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" From 58b54a449873babe01237a65bc1aec1a2febf748 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 23 Sep 2026 21:41:49 +0000 Subject: [PATCH 7/9] release: fail closed on unsigned and unaccepted macOS builds Missing signing setup let direct GoReleaser releases publish unsigned Darwin binaries. An exact JSON grep also rejected valid Accepted responses while discarding notarytool failures. Use the snapshot flag to permit unsigned builds only when GoReleaser cannot publish. Require a successful submission and one Accepted JSON object through jq. Widening the grep would still accept nested or malformed results. The exit trap removes submissions on failure too. Keep the hook gate until the release tool provides equivalent built-in signing and notarization checks. Regression tests failed before the fix; make check and the fresh security review now pass. Real GoReleaser snapshots package all four targets, while a non-snapshot run without signing setup stops before archiving. Apple signing still needs the credentialed macOS run. --- .goreleaser.yaml | 7 ++-- scripts/sign-macos.sh | 27 ++++++++------- scripts/sign_macos_test.go | 68 +++++++++++++++++++++++++++----------- 3 files changed, 67 insertions(+), 35 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index f210090..93599d1 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -21,12 +21,11 @@ builds: goarch: - amd64 - arm64 - # Sign and notarize each darwin binary before archiving, so nothing - # unsigned ever reaches the release. The hook passes linux builds and - # runs without a prepared keychain (PR dry runs) through unsigned. + # 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 }}"' + - cmd: 'bash scripts/sign-macos.sh "{{ .Path }}" "{{ .Target }}" "{{ .IsSnapshot }}"' output: true archives: diff --git a/scripts/sign-macos.sh b/scripts/sign-macos.sh index 041d768..3e0040e 100755 --- a/scripts/sign-macos.sh +++ b/scripts/sign-macos.sh @@ -1,10 +1,9 @@ #!/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 and build target as -# arguments. Linux builds and runs without a prepared keychain (PR dry runs, -# local snapshots) pass through unsigned. A failed signature or a notary -# status other than Accepted fails the hook, which fails the goreleaser run -# before anything is archived or published. +# 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 @@ -15,13 +14,17 @@ case "$target" in *) exit 0 ;; esac -# Set by scripts/setup-macos-signing.sh once the temporary keychain holds -# the certificate and the notary profile. -if [ "${APERTURE_SIGNING_READY:-}" != "1" ]; then - echo "signing keychain not prepared, leaving $target unsigned" +# 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}" @@ -31,12 +34,12 @@ 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" --wait --output-format json || true) -rm -f "$submission" + --keychain-profile "$profile" --wait --output-format json) echo "$result" -if ! printf '%s' "$result" | grep -q '"status":"Accepted"'; then +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 index dab77de..c36fbc4 100644 --- a/scripts/sign_macos_test.go +++ b/scripts/sign_macos_test.go @@ -19,18 +19,31 @@ func TestSignMacOS(t *testing.T) { name string target string ready bool + snapshot string status string + response string failure string wantError bool } tests := []testCase{ - {name: "linux", target: "linux_amd64_v1"}, - {name: "keychain not prepared", target: "darwin_arm64_v8.0"}, - {name: "accepted", ready: true, status: "Accepted"}, - {name: "rejected", ready: true, status: "Invalid", wantError: true}, - {name: "pending", ready: true, status: "In Progress", wantError: true}, - {name: "missing status", ready: true, status: "", wantError: true}, - {name: "notary failure", ready: true, failure: "notary", status: "Accepted", wantError: true}, + {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: "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}, } @@ -55,16 +68,25 @@ func TestSignMacOS(t *testing.T) { if target == "" { target = "darwin_amd64_v1" } - cmd := exec.Command("bash", "sign-macos.sh", binary, target) + 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" + } 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_BINARY="+binary, + "TEST_FAILURE="+tt.failure, + "TEST_STATUS="+tt.status, + "TEST_RESPONSE="+tt.response, + "APERTURE_SIGNING_READY="+readyValue, ) - if tt.ready { - cmd.Env = append(cmd.Env, "APERTURE_SIGNING_READY=1") - } output, err := cmd.CombinedOutput() if (err != nil) != tt.wantError { t.Fatalf("hook error = %v, want error %v\n%s", err, tt.wantError, output) @@ -74,9 +96,12 @@ func TestSignMacOS(t *testing.T) { t.Fatal(err) } commands := string(log) - if !tt.ready { + 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("unsigned run must not call Apple tools, got:\n%s", 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" { @@ -84,12 +109,15 @@ func TestSignMacOS(t *testing.T) { } return } + if commands == "" { + 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.status == "Accepted" && 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) @@ -100,9 +128,6 @@ func TestSignMacOS(t *testing.T) { } } } - if _, err := os.Stat(binary + ".zip"); !os.IsNotExist(err) { - t.Errorf("submission zip was not cleaned up: %v", err) - } }) } } @@ -122,7 +147,12 @@ case "$tool $1" in "xcrun notarytool") [[ "$TEST_FAILURE" != notary ]] unzip -p "$3" aperture > "$TEST_ARCHIVE_CONTENTS" - printf '{"id":"test-submission","status":"%s"}\n' "$TEST_STATUS" + 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 ` From ac2dfc520e03e88617bd89e35ce5c665b8658dac Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 23 Sep 2026 22:15:49 +0000 Subject: [PATCH 8/9] release: keep release builds off pull requests Workflow edits started a full macOS snapshot build on PRs even though Linux CI already runs make check. Release execution belongs to tag pushes and explicit release dispatches. Removing the PR trigger avoids conditional secret handling and snapshot branches in the release workflow. Local snapshots remain available. Revisit separate packaging validation only if the existing PR gate misses a concrete packaging regression. The regression test failed before the change. make check, actionlint and an independent security review pass. Hosted Actions execution remains unverified from this sandbox. --- .github/workflows/release.yaml | 25 +++---------------------- scripts/setup-macos-signing.sh | 3 +-- scripts/sign_macos_test.go | 10 ++++++++++ 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6da3cdc..e0be99d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -12,20 +12,11 @@ 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: codesign and notarytool only exist on - # macOS, and the linux binaries cross-compile with CGO off. The build - # post-hook signs and notarizes each darwin binary before archiving, so - # nothing unsigned is ever published. PR dry runs skip the keychain setup - # and the hook passes binaries through unsigned. + # 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: macos-latest timeout-minutes: 60 @@ -41,18 +32,8 @@ jobs: with: 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 - if: github.event_name != 'pull_request' env: APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }} APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} @@ -65,10 +46,10 @@ jobs: 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() && github.event_name != 'pull_request' + if: always() run: security delete-keychain "$SIGNING_KEYCHAIN" || true diff --git a/scripts/setup-macos-signing.sh b/scripts/setup-macos-signing.sh index 9026b4e..3f48545 100755 --- a/scripts/setup-macos-signing.sh +++ b/scripts/setup-macos-signing.sh @@ -51,7 +51,6 @@ echo "Signing identity: Developer ID Application: Tailscale Inc. ($TEAM_ID)" echo "Notary profile: $NOTARY_PROFILE" if [ -n "${GITHUB_ENV:-}" ]; then echo "SIGNING_KEYCHAIN=$keychain" >> "$GITHUB_ENV" - # The goreleaser hook signs only when this is set, so PR dry runs and - # local snapshots pass binaries through unsigned. + # Darwin releases require this; local snapshots may remain unsigned. echo "APERTURE_SIGNING_READY=1" >> "$GITHUB_ENV" fi diff --git a/scripts/sign_macos_test.go b/scripts/sign_macos_test.go index c36fbc4..c83abfe 100644 --- a/scripts/sign_macos_test.go +++ b/scripts/sign_macos_test.go @@ -8,6 +8,16 @@ import ( "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) { From 0f58f44b790f4edf62c9726c8e0469f8c0ffd055 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 23 Sep 2026 22:52:18 +0000 Subject: [PATCH 9/9] release: scope signing credentials to the temporary keychain Setup failures could leave certificate material without an exported cleanup path, and notarization credentials stayed in the login keychain outside teardown. Store both in the temporary keychain and remove the exported certificate before setup succeeds. The workflow teardown now reports deletion failure instead of hiding it. Setup failure cleanup still suppresses secondary deletion errors so the original setup failure remains visible. Revisit the local fallback separately; it intentionally uses the developer login keychain. Regression tests cover the hook requiring the temporary keychain and passing it to notarytool. shellcheck, make check and a fresh independent security review pass. Apple credential storage and hosted teardown remain unverified until the credentialed macOS run. --- .github/workflows/release.yaml | 2 +- scripts/setup-macos-signing.sh | 27 ++++++++++++++++++++------- scripts/sign-macos.sh | 4 +++- scripts/sign_macos_test.go | 28 +++++++++++++++++++--------- 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e0be99d..e173a9d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -52,4 +52,4 @@ jobs: - name: Tear down signing keychain if: always() - run: security delete-keychain "$SIGNING_KEYCHAIN" || true + run: security delete-keychain "$SIGNING_KEYCHAIN" diff --git a/scripts/setup-macos-signing.sh b/scripts/setup-macos-signing.sh index 3f48545..736c0f6 100755 --- a/scripts/setup-macos-signing.sh +++ b/scripts/setup-macos-signing.sh @@ -3,8 +3,8 @@ # 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 login -# keychain, which is where `notarytool --keychain-profile` looks. +# 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 @@ -27,13 +27,20 @@ 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 -rm "$workdir/certificate.p12" # 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: \ @@ -45,12 +52,18 @@ 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" + --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 - echo "SIGNING_KEYCHAIN=$keychain" >> "$GITHUB_ENV" - # Darwin releases require this; local snapshots may remain unsigned. - echo "APERTURE_SIGNING_READY=1" >> "$GITHUB_ENV" + # 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 index 3e0040e..7417d39 100755 --- a/scripts/sign-macos.sh +++ b/scripts/sign-macos.sh @@ -27,6 +27,7 @@ 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" @@ -37,7 +38,8 @@ submission="$binary.zip" trap 'rm -f "$submission"' EXIT zip -j -q "$submission" "$binary" result=$(xcrun notarytool submit "$submission" \ - --keychain-profile "$profile" --wait --output-format json) + --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 diff --git a/scripts/sign_macos_test.go b/scripts/sign_macos_test.go index c83abfe..845b27b 100644 --- a/scripts/sign_macos_test.go +++ b/scripts/sign_macos_test.go @@ -26,14 +26,15 @@ func TestSignMacOS(t *testing.T) { t.Fatal(err) } type testCase struct { - name string - target string - ready bool - snapshot string - status string - response string - failure string - wantError bool + 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}, @@ -41,6 +42,7 @@ func TestSignMacOS(t *testing.T) { {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}, @@ -87,6 +89,10 @@ func TestSignMacOS(t *testing.T) { 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"), @@ -96,6 +102,7 @@ func TestSignMacOS(t *testing.T) { "TEST_STATUS="+tt.status, "TEST_RESPONSE="+tt.response, "APERTURE_SIGNING_READY="+readyValue, + "NOTARY_KEYCHAIN="+notaryKeychain, ) output, err := cmd.CombinedOutput() if (err != nil) != tt.wantError { @@ -120,6 +127,9 @@ func TestSignMacOS(t *testing.T) { return } if commands == "" { + if tt.noKeychain { + return + } t.Fatal("expected Apple tools to be called") } if tt.failure == "sign" || tt.failure == "verify" { @@ -132,7 +142,7 @@ func TestSignMacOS(t *testing.T) { 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", "--wait"} { + 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) }