From d9b8124d219f2cf5afbaffe8d80b1fd4dc56adaa Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Mon, 24 Aug 2026 08:08:27 +0100 Subject: [PATCH] feat: sign and notarize macOS CLI release binaries (ATFS-e3fz) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hack/build-cli-binaries.sh now code-signs and notarizes the two darwin-* binaries with rcodesign (no Xcode/macOS runner needed) when APPLE_CERT_P12_BASE64, APPLE_CERT_PASSWORD and APPLE_API_KEY_JSON_BASE64 are all set as Tangled secrets — refusing a partial set outright, and otherwise building exactly as before. A signed binary can't have a notarization ticket stapled to it (only bundles/dmg/pkg support that), so the signed original ships and a throwaway zip goes to Apple's notary service; Gatekeeper's own online check covers the rest on first launch. Whether a given release's binary actually got signed is threaded through per target — a .signed marker file, into publish-release-images.sh's release manifest, into bake-site-downloads.sh's cli.json, into CliEntry.signed — so the downloads page's quarantine-clearing paragraph renders only when a listed macOS build is actually unsigned. It turns itself off the moment a signed release ships, with no follow-up copy edit needed. publish-sbc-images.yml documents the three secrets (what each is and exactly where to get it) and adds rcodesign + zip to CI's dependencies. The Apple Developer credentials themselves haven't been supplied yet, so this ships inert until they're added to Tangled's secrets store. --- ...otarize-macos-atfs-cli-release-binaries.md | 38 +++++++- .tangled/workflows/publish-sbc-images.yml | 49 ++++++++++ CLAUDE.md | 20 ++++ hack/bake-site-downloads.sh | 9 +- hack/build-cli-binaries.sh | 92 ++++++++++++++++++- hack/publish-release-images.sh | 20 +++- web/src/lib/platform.test.ts | 15 ++- web/src/lib/platform.ts | 12 +++ web/src/routes/downloads/cli/+page.svelte | 22 +++-- 9 files changed, 260 insertions(+), 17 deletions(-) diff --git a/.beans/ATFS-e3fz--sign-notarize-macos-atfs-cli-release-binaries.md b/.beans/ATFS-e3fz--sign-notarize-macos-atfs-cli-release-binaries.md index bb2b56b..d42fcab 100644 --- a/.beans/ATFS-e3fz--sign-notarize-macos-atfs-cli-release-binaries.md +++ b/.beans/ATFS-e3fz--sign-notarize-macos-atfs-cli-release-binaries.md @@ -1,10 +1,11 @@ --- # ATFS-e3fz title: Sign & notarize macOS atfs CLI release binaries -status: todo +status: completed type: feature +priority: normal created_at: 2026-08-24T03:40:47Z -updated_at: 2026-08-24T03:40:47Z +updated_at: 2026-08-24T07:08:17Z parent: ATFS-qchs --- @@ -32,3 +33,36 @@ signing is live. JP offered to provide the Apple Developer credentials (2026-08-24) — not yet supplied. Credentials belong directly in Tangled's secrets store, never pasted through a chat transcript. + + + +## Summary of Changes + +Implemented the signing/notarization pipeline itself, gated on three +optional Tangled secrets (APPLE_CERT_P12_BASE64, APPLE_CERT_PASSWORD, +APPLE_API_KEY_JSON_BASE64) that JP has not supplied yet — the code is +ready and inert until they're added to Tangled's secrets store. + +- hack/build-cli-binaries.sh: signs + notarizes the two darwin-* binaries + with rcodesign when all three secrets are set (refuses a partial set); + unchanged behavior with none set. Writes an atfs-.signed marker + per successfully signed binary. +- .tangled/workflows/publish-sbc-images.yml: documents the three secrets + (what each is and exactly where to get it) and adds rcodesign + zip to + CI's nix dependencies. +- hack/publish-release-images.sh: reads the .signed marker and records + signed: true/false per CLI target in the release manifest. +- hack/bake-site-downloads.sh: threads signed (defaulting false for older + manifests) into site/downloads/cli.json. +- web/src/lib/platform.ts (+ test): CliEntry.signed and a + macBuildsNeedQuarantineNotice() helper. +- web/src/routes/downloads/cli/+page.svelte: the quarantine-clearing + paragraph only renders when at least one listed macOS build is unsigned + — so it disappears on its own once a signed release ships, no follow-up + copy edit required. +- CLAUDE.md: documented the mechanism and the signed-field data flow. + +Verified: go build/vet, pnpm vitest (all passing, new tests included), +svelte-check, and a full `make web` site build all clean; manually +exercised the all/none/partial-secret guard logic and an unsigned +darwin-arm64 build end to end. diff --git a/.tangled/workflows/publish-sbc-images.yml b/.tangled/workflows/publish-sbc-images.yml index 37e22d8..f830db7 100644 --- a/.tangled/workflows/publish-sbc-images.yml +++ b/.tangled/workflows/publish-sbc-images.yml @@ -47,6 +47,43 @@ # the case a domain can't express: reaching an instance at an address that # isn't its canonical domain. CI has no such need. # +# Three further Tangled secrets code-sign and notarize the two darwin-* +# CLI binaries (ATFS-e3fz — see hack/build-cli-binaries.sh's header for the +# mechanics). All three or none: set only some and the CLI build step +# refuses outright rather than guess. Until they're set, darwin binaries +# ship exactly as they always have — unsigned, with the downloads page's +# quarantine-clearing instructions still shown. +# APPLE_CERT_P12_BASE64 — base64 of a Developer ID Application +# certificate + private key, exported as one +# .p12 file. Create the certificate at +# developer.apple.com/account/resources/certificates +# (needs a paid Apple Developer Program +# membership), then export it — cert and key +# together — from Keychain Access as a .p12, +# choosing an export password along the way. +# `base64 -i DeveloperIDApplication.p12` prints +# the value this secret wants. +# APPLE_CERT_PASSWORD — the export password chosen above. +# APPLE_API_KEY_JSON_BASE64 — base64 of the unified API-key JSON +# `rcodesign encode-app-store-connect-api-key` +# produces, built from an App Store Connect +# API key: appstoreconnect.apple.com → +# Users and Access → Integrations → App Store +# Connect API → generate one (the "Developer" +# role is enough), download its .p8 exactly +# once (Apple only offers it at creation +# time), note the Issuer ID shown on that +# page and the Key ID shown next to the key, +# then: +# rcodesign encode-app-store-connect-api-key \ +# -o key.json ISSUER_ID KEY_ID AuthKey_KEYID.p8 +# base64 -i key.json +# (`rcodesign` itself: `cargo install +# apple-codesign`, or nix run +# nixpkgs#rcodesign — it's this workflow's own +# signing tool, so anything that can run it +# works.) +# # Released by tag, same reasoning as image.yml/publish-container-image.yml: # # git tag v0.1.0 && git push origin v0.1.0 @@ -86,6 +123,14 @@ dependencies: - coreutils - curl - glibc + # hack/build-cli-binaries.sh's darwin-* signing step (ATFS-e3fz, see the + # secrets documented above): rcodesign speaks Apple's code-signing and + # Notary API from Linux, zip packages the signed binary for notary + # submission (Apple's notary service accepts only a zip/dmg/pkg, never a + # bare executable). Both sit unused — no error, nothing to install around + # — on any run where the three APPLE_* secrets aren't set. + - rcodesign + - zip environment: # The account this workflow uploads each release's images as. It must be @@ -167,6 +212,10 @@ steps: # which is this runner's own platform, built only to drive the uploads # below. Same reasoning as "gosd build (every board)": all four targets, # no args. + # Also where the two darwin-* binaries get code-signed and notarized, + # when the three APPLE_* secrets documented above are set — no separate + # step, since hack/build-cli-binaries.sh decides that for itself from the + # environment Tangled already injects every repo secret into. - name: "build the atfs CLI release binaries (every platform)" command: | set -euo pipefail diff --git a/CLAUDE.md b/CLAUDE.md index c5b322b..9ed7b3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -620,6 +620,26 @@ by an ingress, downloads never are. CORS-open `listFiles`, so only the uploading account's password is worth hiding. `ATFS_USER` for that upload is `did:web:atfs.dev`, which storage.atfs.dev's own record allowlists. +- **The two darwin-\* CLI release binaries are code-signed and notarized** + by `hack/build-cli-binaries.sh` (ATFS-e3fz) when three Tangled secrets — + `APPLE_CERT_P12_BASE64`, `APPLE_CERT_PASSWORD`, + `APPLE_API_KEY_JSON_BASE64` — are all set (see + `publish-sbc-images.yml`'s header for exactly what each one is and where + to get it); any other subset is refused outright rather than guessed at. + Uses `rcodesign` (the `apple-codesign` Rust crate) to do from Linux what + `codesign`/`notarytool` would do on a Mac, since Tangled's CI has no + macOS runner. A bare executable can be signed directly but Apple will + only staple a notarization ticket to a bundle/dmg/pkg, never to one — so + the signed binary itself ships, a throwaway zip of it is submitted for + notarization and discarded, and Gatekeeper's own online check covers the + rest on first launch. Whether a given release's binary actually got + signed is threaded per-target (`atfs-.signed` marker → + `publish-release-images.sh`'s manifest → `bake-site-downloads.sh`'s + `cli.json` → `CliEntry.signed`) rather than assumed from whether the + secrets exist today, so the downloads page's quarantine-clearing notice + turns itself off the moment a signed release actually ships — no + follow-up copy edit needed. Until the secrets are added, darwin binaries + build exactly as they always have. - The live instance JP runs is a **Radxa Cubie A5E** (the 1GB variant), reachable at `https://atfs.byjp.me` through a Cloudflare tunnel since 2026-08-17 — **down as of 2026-08-21, and no longer what any workflow diff --git a/hack/bake-site-downloads.sh b/hack/bake-site-downloads.sh index 7ace7ea..f1a1bfd 100755 --- a/hack/bake-site-downloads.sh +++ b/hack/bake-site-downloads.sh @@ -351,7 +351,11 @@ jq '{ # so that's the convention mirrored here. The manifest's `cli` field is # optional — an older index baked before the CLI binaries existed carries # no such key at all — `// []` treats a missing field identically to an -# empty array rather than failing. +# empty array rather than failing. `signed` is defaulted `// false` for the +# same reason: an index baked before ATFS-e3fz shipped has no such field, +# and "unknown" must read as "not signed" rather than as true — the +# downloads page uses it to decide whether it still owes visitors the +# quarantine-clearing instructions. jq --arg release "$newest_version" '{ release: $release, cli: [ @@ -362,7 +366,8 @@ jq --arg release "$newest_version" '{ arch: .arch, url: .binary.url, filename: ("atfs-" + .id), - size: .binary.size + size: .binary.size, + signed: (.signed // false) } ] }' <<<"$manifest" >"$setup_dir/cli.json" diff --git a/hack/build-cli-binaries.sh b/hack/build-cli-binaries.sh index 8acc3a6..d8d897c 100755 --- a/hack/build-cli-binaries.sh +++ b/hack/build-cli-binaries.sh @@ -30,6 +30,30 @@ # describe, so a shallow or tagless checkout falls back to a short SHA, # which is fine and honest. A release passes its validated tag explicitly. # +# The two darwin-* binaries are additionally code-signed and notarized +# (ATFS-e3fz) when APPLE_CERT_P12_BASE64, APPLE_CERT_PASSWORD and +# APPLE_API_KEY_JSON_BASE64 are ALL set — see +# .tangled/workflows/publish-sbc-images.yml's header for what each one is +# and where to get it. Any other subset (one or two of the three) is +# refused outright: a half-configured signing setup is far more likely a +# mistake than a deliberate choice, and shipping an unsigned binary while +# believing it's signed is worse than failing loudly. With none set at all, +# darwin binaries build exactly as they always have — this is what keeps +# local dev builds and any release cut before the Apple credentials exist +# working unchanged. +# +# Signing uses rcodesign (the `apple-codesign` Rust crate) rather than +# Xcode's codesign/notarytool, since Tangled's CI has no macOS runner (see +# CLAUDE.md) — rcodesign speaks Apple's signing and Notary API from Linux. +# A standalone executable (not an .app/.dmg/.pkg) can be signed directly, +# but Apple's notary service only accepts a zip/dmg/pkg submission and a +# notarization ticket can only be STAPLED to one of those, never to a bare +# Mach-O binary — so the signed binary itself is what ships, a throwaway +# zip of it is what gets submitted for notarization, and Gatekeeper +# performs its usual online check against Apple's servers the first time a +# downloaded (quarantined) copy is run. That's the same mechanism the +# downloads page's now-conditional quarantine notice describes. +# # Usage: hack/build-cli-binaries.sh [TARGET...] # hack/build-cli-binaries.sh --targets # print the target list, build nothing set -euo pipefail @@ -50,6 +74,65 @@ TARGETS=("$@") VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo unknown)}" +# SIGN_APPLE decides once, up front, whether any darwin-* target this run +# builds gets signed — never decided per-target, so both mac binaries in a +# release always come out the same way. See the header comment above for +# what "some but not all" refusal is guarding against. +n_apple_vars=0 +for var in APPLE_CERT_P12_BASE64 APPLE_CERT_PASSWORD APPLE_API_KEY_JSON_BASE64; do + [ -n "${!var:-}" ] && n_apple_vars=$((n_apple_vars + 1)) +done +case "$n_apple_vars" in + 0) SIGN_APPLE=0 ;; + 3) SIGN_APPLE=1 ;; + *) + echo "APPLE_CERT_P12_BASE64, APPLE_CERT_PASSWORD and APPLE_API_KEY_JSON_BASE64 must be set together or not at all (got $n_apple_vars of 3) — see .tangled/workflows/publish-sbc-images.yml's header" >&2 + exit 1 + ;; +esac + +# Decoded once, however many darwin-* targets this run builds, into a temp +# dir removed on exit whatever happens — these are secrets, not build +# output, and never belong on disk longer than the sign/notarize calls +# that need them. +APPLE_SECRETS_DIR="" +if [ "$SIGN_APPLE" -eq 1 ]; then + APPLE_SECRETS_DIR="$(mktemp -d)" + trap 'rm -rf "$APPLE_SECRETS_DIR"' EXIT + base64 -d <<<"$APPLE_CERT_P12_BASE64" >"$APPLE_SECRETS_DIR/cert.p12" + printf '%s' "$APPLE_CERT_PASSWORD" >"$APPLE_SECRETS_DIR/cert.password" + base64 -d <<<"$APPLE_API_KEY_JSON_BASE64" >"$APPLE_SECRETS_DIR/api-key.json" + chmod 600 "$APPLE_SECRETS_DIR"/* +fi + +# sign_and_notarize code-signs $1 in place with the hardened runtime flag +# notarization requires, zips it into a throwaway submission, and blocks +# (--wait) on Apple's notary service accepting it. A rejection or any +# transport failure exits non-zero, which — same as every other step in +# this script — takes the whole build down: a release is not the place to +# discover a lapsed certificate or a rejected binary by shipping it anyway. +sign_and_notarize() { + local bin="$1" zip="$1.zip" + echo " signing + notarizing…" + rcodesign sign \ + --p12-file "$APPLE_SECRETS_DIR/cert.p12" \ + --p12-password-file "$APPLE_SECRETS_DIR/cert.password" \ + --code-signature-flags runtime \ + "$bin" + rm -f "$zip" + zip -q "$zip" "$bin" + rcodesign notary-submit \ + --api-key-file "$APPLE_SECRETS_DIR/api-key.json" \ + --wait \ + "$zip" + rm -f "$zip" + # A marker, not a modification to $bin itself: hack/publish-release-images.sh + # reads this to record `signed: true` in the release manifest per CLI + # target (ATFS-e3fz), which is how the downloads page knows to stop + # telling visitors to clear the quarantine flag by hand. + touch "$bin.signed" +} + for target in "${TARGETS[@]}"; do case "$target" in linux-amd64) goos=linux; goarch=amd64 ;; @@ -65,10 +148,15 @@ for target in "${TARGETS[@]}"; do echo "=== go build: $target ($VERSION) ===" out="atfs-$target" # Removed first, so a build that dies partway can't leave a previous run's - # binary sitting there for a later step to upload as this one's (same + # binary (or a stale .signed marker claiming a fresh unsigned rebuild is + # signed) sitting there for a later step to upload as this one's (same # reasoning as hack/build-sbc-images.sh's rm -f before each gosd build). - rm -f "$out" + rm -f "$out" "$out.signed" GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 go build \ -ldflags "-X main.version=$VERSION" \ -o "$out" ./cmd/atfs + + if [ "$SIGN_APPLE" -eq 1 ] && [[ "$target" == darwin-* ]]; then + sign_and_notarize "$out" + fi done diff --git a/hack/publish-release-images.sh b/hack/publish-release-images.sh index 02c12b3..415377a 100755 --- a/hack/publish-release-images.sh +++ b/hack/publish-release-images.sh @@ -11,7 +11,10 @@ # each with `atfs upload`, and writes releases/.json: a manifest # recording every uploaded file's dev.atfs.file reference (the same shape # `atfs upload` itself prints — see cmd/atfs/wire.go's fileRef) plus a -# direct-fetch `url` (the documented /ipfs/ gateway path). +# direct-fetch `url` (the documented /ipfs/ gateway path). Each CLI +# entry also carries `signed`, read from that same directory's +# atfs-.signed marker — whether hack/build-cli-binaries.sh +# code-signed and notarized it this run (ATFS-e3fz). # # releases/.json is written locally only because it's the exact # file this script goes on to upload — it is no longer a committed record. @@ -259,10 +262,19 @@ if [ "${#CLI_TARGETS[@]}" -gt 0 ]; then ' "${CLI_TARGETS[@]}" )" - # cli_display is a shell function, same as board_display above. + # cli_display is a shell function, same as board_display above. `signed` + # is grafted on the same way, from hack/build-cli-binaries.sh's own + # atfs-.signed marker (ATFS-e3fz) — true only for a darwin + # target actually code-signed and notarized this run, false for + # everything else (a linux binary, or a darwin one built without the + # Apple secrets set). hack/bake-site-downloads.sh reads this to decide + # whether the downloads page still needs to tell visitors to clear the + # quarantine flag themselves. for target in "${CLI_TARGETS[@]}"; do - cli_json="$(jq --arg id "$target" --arg display "$(cli_display "$target")" \ - 'map(if .id == $id then . + {display: $display} else . end)' <<<"$cli_json")" + signed=false + [ -f "atfs-$target.signed" ] && signed=true + cli_json="$(jq --arg id "$target" --arg display "$(cli_display "$target")" --argjson signed "$signed" \ + 'map(if .id == $id then . + {display: $display, signed: $signed} else . end)' <<<"$cli_json")" done fi diff --git a/web/src/lib/platform.test.ts b/web/src/lib/platform.test.ts index 737b2fe..2a030cc 100644 --- a/web/src/lib/platform.test.ts +++ b/web/src/lib/platform.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; -import { humanSize, parseGpuRendererArch, rankPlatforms } from "./platform.js"; +import { humanSize, macBuildsNeedQuarantineNotice, parseGpuRendererArch, rankPlatforms } from "./platform.js"; const entries = [ { id: "linux-amd64", display: "Linux (x86_64)", os: "linux", arch: "amd64", url: "u1", filename: "atfs-linux-amd64", size: 1 }, @@ -70,6 +70,19 @@ test("parseGpuRendererArch reads Intel/AMD/Nvidia renderer strings as amd64", () expect(parseGpuRendererArch("NVIDIA GeForce GT 750M OpenGL Engine")).toBe("amd64"); }); +test("macBuildsNeedQuarantineNotice is true when any macOS build is unsigned", () => { + expect(macBuildsNeedQuarantineNotice(entries)).toBe(true); +}); + +test("macBuildsNeedQuarantineNotice is false once every macOS build is signed", () => { + const signed = entries.map((e) => (e.os === "darwin" ? { ...e, signed: true } : e)); + expect(macBuildsNeedQuarantineNotice(signed)).toBe(false); +}); + +test("macBuildsNeedQuarantineNotice ignores non-macOS builds entirely", () => { + expect(macBuildsNeedQuarantineNotice(entries.filter((e) => e.os === "linux"))).toBe(false); +}); + test("parseGpuRendererArch treats a masked/generic renderer as unknown", () => { expect(parseGpuRendererArch("Generic Renderer")).toBeNull(); expect(parseGpuRendererArch("Mozilla")).toBeNull(); diff --git a/web/src/lib/platform.ts b/web/src/lib/platform.ts index 00ed3d6..71ccfbe 100644 --- a/web/src/lib/platform.ts +++ b/web/src/lib/platform.ts @@ -130,8 +130,20 @@ export type CliEntry = { url: string; filename: string; size: number; + // Optional: absent on an index baked before ATFS-e3fz shipped, or on any + // non-macOS entry, which never needs signing at all. + signed?: boolean; }; +// True when at least one macOS build in the list isn't code-signed and +// notarized — the downloads page uses this to decide whether it still owes +// visitors the quarantine-clearing instructions. Once every macOS build +// ships signed, Gatekeeper clears it with an online check on first launch +// and there's nothing left to tell anyone to do by hand. +export function macBuildsNeedQuarantineNotice(entries: CliEntry[]): boolean { + return entries.some((e) => e.os === "darwin" && !e.signed); +} + // "exact": os and arch both matched — confident enough to badge as // recommended. "os": the visitor's OS matched but arch didn't (or // couldn't be narrowed further) — worth sorting to the top, never worth diff --git a/web/src/routes/downloads/cli/+page.svelte b/web/src/routes/downloads/cli/+page.svelte index b0e2f0c..3527e6d 100644 --- a/web/src/routes/downloads/cli/+page.svelte +++ b/web/src/routes/downloads/cli/+page.svelte @@ -1,10 +1,18 @@