From 4681b899795555eda921e62799586f611e3edd43 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 13 Aug 2026 18:49:37 -0700 Subject: [PATCH 1/3] feat(release): resolve Sparkle keys from 1Password --- skills/release-mac-app/SKILL.md | 10 +- .../scripts/lib/mac_release.sh | 177 +++++++++++++++--- .../scripts/mac-release.test.sh | 107 ++++++++++- 3 files changed, 264 insertions(+), 30 deletions(-) diff --git a/skills/release-mac-app/SKILL.md b/skills/release-mac-app/SKILL.md index dc7e4de..472d764 100644 --- a/skills/release-mac-app/SKILL.md +++ b/skills/release-mac-app/SKILL.md @@ -14,7 +14,8 @@ Use for BlackBar, RepoBar, CodexBar, Trimmy, and similar Sparkle-updated macOS a - Use `scripts/mac-release` from this skill for shared release/appcast/verify work. - Keep app-specific build/package/sign behavior in repo scripts unless it is already manifest-driven. - Never print private key material. -- Prefer Keychain Sparkle signing. `SPARKLE_PRIVATE_KEY_FILE` is an explicit override only. +- Prefer `MAC_RELEASE_SPARKLE_OP_REF` for headless release signing when the key is in 1Password; otherwise prefer + Keychain Sparkle signing. `SPARKLE_PRIVATE_KEY_FILE` is an explicit local override only. ## Commands @@ -60,6 +61,11 @@ Common optional: - `MAC_RELEASE_SPARKLE_CHANNEL` - `MAC_RELEASE_GENERATE_APPCAST_ARGS` - `MAC_RELEASE_RUN_SPARKLE_UPDATE_TEST` +- `MAC_RELEASE_SPARKLE_OP_REF` — exact `op://Vault/Item/field` reference for the Sparkle EdDSA private key. The + helper resolves it inside the shared `op-work` tmux session, writes only a mode-0600 temporary key file, validates + its public key against `SUPublicEDKey`, and removes it on success or failure. +- `MAC_RELEASE_SPARKLE_OP_ACCOUNT` and `MAC_RELEASE_SPARKLE_OP_USE_SERVICE_ACCOUNT` override the primary 1Password + account/service-account mode for the Sparkle reference. Molty refs should set service-account mode to `1`. - `MAC_RELEASE_SIGNING_KEY_FILE` (local fallback path only; Keychain is used when the file is absent) - `MAC_RELEASE_EXTRA_ASSET_PATTERNS` - `MAC_RELEASE_EXTRA_ASSET_WAIT_SECONDS` @@ -75,6 +81,8 @@ Common optional: - Prefer already-exported env vars first; no `op` call if all `MAC_RELEASE_OP_FIELDS` are present. - If fields are missing, read configured package and codesign items in one tmux command for the whole release. +- Resolve `MAC_RELEASE_SPARKLE_OP_REF` without exposing the private key in the generated environment file or logs; + only the temporary file path crosses the helper boundary. - Use service-account mode only with an explicit vault or `MAC_RELEASE_OP_USE_SERVICE_ACCOUNT=1`. - Do not retry `op` reads in a fresh shell; rerun only from the same tmux session after explicit user direction. - Never allow a release to reach app packaging with an unprepared Developer ID keychain. No SecurityAgent password windows during release; fail the signing canary first. diff --git a/skills/release-mac-app/scripts/lib/mac_release.sh b/skills/release-mac-app/scripts/lib/mac_release.sh index dad53b2..458f158 100644 --- a/skills/release-mac-app/scripts/lib/mac_release.sh +++ b/skills/release-mac-app/scripts/lib/mac_release.sh @@ -1,5 +1,11 @@ #!/usr/bin/env bash +# Temp-key ownership is intentionally process-local. Never inherit or export it: +# child release helpers may use the key path but must not delete their parent's file. +unset _MAC_RELEASE_OWNED_SPARKLE_RAW_FILE _MAC_RELEASE_OWNED_SPARKLE_CLEAN_FILE +_MAC_RELEASE_OWNED_SPARKLE_RAW_FILE= +_MAC_RELEASE_OWNED_SPARKLE_CLEAN_FILE= + mac_release_die() { echo "ERROR: $*" >&2 exit 1 @@ -50,9 +56,9 @@ mac_release_tmux_quote() { mac_release_load_1password_env() { set +vx local mode=${1:-all} codesign_passwordless=${MAC_RELEASE_CODESIGN_PASSWORDLESS:-0} - local primary_missing=0 codesign_missing=0 env_refs_missing=0 release_op_field + local primary_missing=0 codesign_missing=0 env_refs_missing=0 sparkle_missing=0 release_op_field local env_ref_entry env_ref_name - [[ "$mode" == "all" || "$mode" == "codesign-only" ]] || + [[ "$mode" == "all" || "$mode" == "codesign-only" || "$mode" == "sparkle-only" ]] || mac_release_die "Unknown 1Password load mode: $mode" if [[ "$codesign_passwordless" == "1" ]]; then unset MAC_RELEASE_CODESIGN_KEYCHAIN_PASSWORD @@ -65,7 +71,7 @@ mac_release_load_1password_env() { [[ -n "${!release_op_field:-}" ]] || primary_missing=1 done fi - if [[ -n "${MAC_RELEASE_CODESIGN_OP_ITEM:-}" ]]; then + if [[ "$mode" != "sparkle-only" && -n "${MAC_RELEASE_CODESIGN_OP_ITEM:-}" ]]; then [[ -n "${MAC_RELEASE_CODESIGN_KEYCHAIN:-}" ]] || codesign_missing=1 if [[ "$codesign_passwordless" != "1" ]]; then [[ -n "${MAC_RELEASE_CODESIGN_KEYCHAIN_PASSWORD:-}" ]] || codesign_missing=1 @@ -84,7 +90,14 @@ mac_release_load_1password_env() { [[ -n "${!env_ref_name:-}" ]] || env_refs_missing=1 done < <(tr ';' '\n' <<<"${MAC_RELEASE_OP_ENV_REFS}") fi - if [[ "$primary_missing" != "1" && "$codesign_missing" != "1" && "$env_refs_missing" != "1" ]]; then + if [[ "$mode" != "codesign-only" && -n "${MAC_RELEASE_SPARKLE_OP_REF:-}" && + ! -f "${SPARKLE_PRIVATE_KEY_FILE:-}" ]]; then + [[ "$MAC_RELEASE_SPARKLE_OP_REF" == op://* ]] || + mac_release_die "MAC_RELEASE_SPARKLE_OP_REF must be an op:// reference" + sparkle_missing=1 + fi + if [[ "$primary_missing" != "1" && "$codesign_missing" != "1" && + "$env_refs_missing" != "1" && "$sparkle_missing" != "1" ]]; then if [[ "$mode" == "all" ]]; then for release_op_field in ${MAC_RELEASE_OP_FIELDS:-}; do export "${release_op_field?}" @@ -104,7 +117,7 @@ mac_release_load_1password_env() { require_bin tmux op node local account vault socket_dir socket session op_window work_dir script runner env_file log_file status_file - local service_account_token_file needs_service_account=0 + local service_account_token_file sparkle_temp_key_file needs_service_account=0 account=${MAC_RELEASE_OP_ACCOUNT:-my.1password.com} vault=${MAC_RELEASE_OP_VAULT:-} socket_dir=${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets} @@ -120,14 +133,37 @@ mac_release_load_1password_env() { env_file="$work_dir/secrets.env" log_file="$work_dir/op.log" status_file="$work_dir/status" + sparkle_temp_key_file= + if [[ "$sparkle_missing" == "1" ]]; then + sparkle_temp_key_file=$(mktemp /tmp/mac-release-sparkle-key.XXXXXX) + chmod 600 "$sparkle_temp_key_file" + fi local old_exit_trap old_exit_trap=$(trap -p EXIT || true) # shellcheck disable=SC2329 # invoked via traps while this function is active cleanup_1password_env() { - [[ -z "${work_dir:-}" ]] || rm -rf "$work_dir" # Kill only our task window; the op-work session stays for other flows. - [[ -z "${op_window:-}" ]] || tmux -S "$socket" kill-window -t "$op_window" 2>/dev/null || true + local pane_pid= + if [[ -n "${op_window:-}" ]]; then + pane_pid=$(tmux -S "$socket" display-message -p -t "$op_window" '#{pane_pid}' 2>/dev/null || true) + tmux -S "$socket" kill-window -t "$op_window" 2>/dev/null || true + if [[ -n "$pane_pid" ]]; then + local wait_count=0 + while kill -0 "$pane_pid" 2>/dev/null && ((wait_count < 40)); do + sleep 0.05 + wait_count=$((wait_count + 1)) + done + if kill -0 "$pane_pid" 2>/dev/null; then + kill -TERM "$pane_pid" 2>/dev/null || true + sleep 0.1 + fi + fi + fi op_window= + # The producer is gone before either its destination or generated scripts + # are unlinked, so a late op read cannot recreate the private-key file. + [[ -z "${sparkle_temp_key_file:-}" ]] || rm -f "$sparkle_temp_key_file" + [[ -z "${work_dir:-}" ]] || rm -rf "$work_dir" } restore_1password_traps() { if [[ -n "$old_exit_trap" ]]; then @@ -150,6 +186,10 @@ mac_release_load_1password_env() { if [[ "$env_refs_missing" == "1" && "${MAC_RELEASE_OP_USE_SERVICE_ACCOUNT:-0}" == "1" ]]; then needs_service_account=1 fi + if [[ "$sparkle_missing" == "1" && + "${MAC_RELEASE_SPARKLE_OP_USE_SERVICE_ACCOUNT-${MAC_RELEASE_OP_USE_SERVICE_ACCOUNT:-0}}" == "1" ]]; then + needs_service_account=1 + fi service_account_token_file= if [[ "$needs_service_account" == "1" ]]; then [[ -n "${OP_SERVICE_ACCOUNT_TOKEN:-}" ]] || { @@ -178,6 +218,10 @@ codesign_path_field=${MAC_RELEASE_CODESIGN_OP_PATH_FIELD:-keychain_path} codesign_password_field=${MAC_RELEASE_CODESIGN_OP_PASSWORD_FIELD:-keychain_password} codesign_passwordless=${MAC_RELEASE_CODESIGN_PASSWORDLESS:-0} read_codesign=${MAC_RELEASE_CODESIGN_OP_READ:-0} +sparkle_ref=${MAC_RELEASE_SPARKLE_OP_REF:-} +sparkle_account=${MAC_RELEASE_SPARKLE_OP_ACCOUNT:-$account} +sparkle_key_file=${MAC_RELEASE_SPARKLE_KEY_FILE:-} +read_sparkle=${MAC_RELEASE_SPARKLE_OP_READ:-0} env_file=${MAC_RELEASE_OP_ENV_FILE:?} log_file=${MAC_RELEASE_OP_LOG_FILE:?} work_dir=$(mktemp -d /tmp/mac-release-op-json.XXXXXX) @@ -282,6 +326,23 @@ if [[ "${MAC_RELEASE_OP_ENV_REFS_READ:-0}" == "1" && -n "${MAC_RELEASE_OP_ENV_RE done 3< <(tr ';' '\n' <<<"${MAC_RELEASE_OP_ENV_REFS}") fi +if [[ "$read_sparkle" == "1" ]]; then + [[ "$sparkle_ref" == op://* ]] || { echo "invalid Sparkle 1Password reference" >&2; exit 1; } + [[ -n "$sparkle_key_file" ]] || { echo "missing Sparkle temp-key path" >&2; exit 1; } + sparkle_value=$(run_op \ + "${MAC_RELEASE_SPARKLE_OP_USE_SERVICE_ACCOUNT-${MAC_RELEASE_OP_USE_SERVICE_ACCOUNT:-0}}" \ + "$sparkle_account" read "$sparkle_ref" 2>>"$log_file") || { + echo "Sparkle key read failed" >&2 + exit 1 + } + [[ -n "$sparkle_value" ]] || { echo "empty Sparkle private key" >&2; exit 1; } + (umask 077; printf '%s\n' "$sparkle_value" >"$sparkle_key_file") + chmod 600 "$sparkle_key_file" + unset sparkle_value + printf 'export SPARKLE_PRIVATE_KEY_FILE=%q\n' "$sparkle_key_file" >>"$env_file" + echo "Sparkle private key prepared in a mode-0600 temporary file" >&2 +fi + chmod 600 "$env_file" echo "1Password fields exported: $(wc -l <"$env_file" | tr -d ' ')" SCRIPT @@ -308,6 +369,11 @@ SCRIPT printf 'export MAC_RELEASE_CODESIGN_OP_READ=%q\n' "$codesign_missing" printf 'export MAC_RELEASE_OP_ENV_REFS=%q\n' "${MAC_RELEASE_OP_ENV_REFS:-}" printf 'export MAC_RELEASE_OP_ENV_REFS_READ=%q\n' "$env_refs_missing" + printf 'export MAC_RELEASE_SPARKLE_OP_REF=%q\n' "${MAC_RELEASE_SPARKLE_OP_REF:-}" + printf 'export MAC_RELEASE_SPARKLE_OP_ACCOUNT=%q\n' "${MAC_RELEASE_SPARKLE_OP_ACCOUNT:-$account}" + printf 'export MAC_RELEASE_SPARKLE_OP_USE_SERVICE_ACCOUNT=%q\n' "${MAC_RELEASE_SPARKLE_OP_USE_SERVICE_ACCOUNT-${MAC_RELEASE_OP_USE_SERVICE_ACCOUNT:-0}}" + printf 'export MAC_RELEASE_SPARKLE_OP_READ=%q\n' "$sparkle_missing" + printf 'export MAC_RELEASE_SPARKLE_KEY_FILE=%q\n' "$sparkle_temp_key_file" printf 'export MAC_RELEASE_OP_ENV_FILE=%q\n' "$env_file" printf 'export MAC_RELEASE_OP_LOG_FILE=%q\n' "$log_file" printf 'export MAC_RELEASE_OP_SERVICE_ACCOUNT_TOKEN_FILE=%q\n' "$service_account_token_file" @@ -360,13 +426,24 @@ RUNNER if [[ -n "${MAC_RELEASE_CODESIGN_KEYCHAIN_PASSWORD:-}" ]]; then export -n MAC_RELEASE_CODESIGN_KEYCHAIN_PASSWORD fi - if [[ -n "${MAC_RELEASE_CODESIGN_OP_ITEM:-}" ]]; then + if [[ "$mode" != "sparkle-only" && -n "${MAC_RELEASE_CODESIGN_OP_ITEM:-}" ]]; then export MAC_RELEASE_CODESIGN_KEYCHAIN [[ -n "${MAC_RELEASE_CODESIGN_KEYCHAIN:-}" ]] || mac_release_die "1Password did not populate MAC_RELEASE_CODESIGN_KEYCHAIN" if [[ "$codesign_passwordless" != "1" ]]; then [[ -n "${MAC_RELEASE_CODESIGN_KEYCHAIN_PASSWORD:-}" ]] || mac_release_die "1Password did not populate MAC_RELEASE_CODESIGN_KEYCHAIN_PASSWORD" fi fi + if [[ "$sparkle_missing" == "1" ]]; then + [[ -f "${SPARKLE_PRIVATE_KEY_FILE:-}" ]] || + mac_release_die "1Password did not prepare SPARKLE_PRIVATE_KEY_FILE" + local sparkle_key_mode + sparkle_key_mode=$(stat -f '%Lp' "$SPARKLE_PRIVATE_KEY_FILE" 2>/dev/null || stat -c '%a' "$SPARKLE_PRIVATE_KEY_FILE") + [[ "$sparkle_key_mode" == "600" ]] || + mac_release_die "Sparkle private key temp file must have mode 0600" + _MAC_RELEASE_OWNED_SPARKLE_RAW_FILE=$SPARKLE_PRIVATE_KEY_FILE + export SPARKLE_PRIVATE_KEY_FILE + sparkle_temp_key_file= + fi sed -n '1,80p' "$log_file" >&2 || true cleanup_1password_env restore_1password_traps @@ -491,7 +568,8 @@ require_clean_worktree() { } clean_key() { - local keyfile=${1:?"key file required"} + local out_var=${1:?"output variable required"} + local keyfile=${2:?"key file required"} [[ -f "$keyfile" ]] || mac_release_die "Sparkle key file not found: $keyfile" local lines lines=$(grep -v '^[[:space:]]*#' "$keyfile" | sed '/^[[:space:]]*$/d') @@ -499,9 +577,24 @@ clean_key() { mac_release_die "Sparkle key must be a single base64 line (no comments/blank lines)." fi local tmp - tmp=$(mktemp) + tmp=$(umask 077; mktemp /tmp/mac-release-sparkle-clean.XXXXXX) + printf -v "$out_var" '%s' "$tmp" + _MAC_RELEASE_OWNED_SPARKLE_CLEAN_FILE=$tmp printf "%s\n" "$lines" >"$tmp" - echo "$tmp" + chmod 600 "$tmp" +} + +mac_release_cleanup_temp_sparkle_key() { + [[ -z "${_MAC_RELEASE_OWNED_SPARKLE_RAW_FILE:-}" ]] || rm -f "$_MAC_RELEASE_OWNED_SPARKLE_RAW_FILE" + [[ -z "${_MAC_RELEASE_OWNED_SPARKLE_CLEAN_FILE:-}" ]] || rm -f "$_MAC_RELEASE_OWNED_SPARKLE_CLEAN_FILE" + _MAC_RELEASE_OWNED_SPARKLE_RAW_FILE= + _MAC_RELEASE_OWNED_SPARKLE_CLEAN_FILE= +} + +mac_release_resolve_sparkle_op_key() { + if [[ -z "${SPARKLE_PRIVATE_KEY_FILE:-}" && -n "${MAC_RELEASE_SPARKLE_OP_REF:-}" ]]; then + mac_release_load_1password_env sparkle-only + fi } probe_sparkle_key() { @@ -601,21 +694,38 @@ mac_release_default_key_source() { printf 'keychain\n' } -mac_release_sparkle_key_status() { - mac_release_load - local source=${1:-$(mac_release_default_key_source)} - local label_source=$source - local expected actual cleaned_source - # shellcheck disable=SC2329 # invoked via RETURN trap +# shellcheck disable=SC2030,SC2031 # ownership clearing is intentionally local to this subshell +mac_release_sparkle_key_status() ( + local requested_source=${1:-} + local source label_source expected actual cleaned_source resolved_temp + # A subshell copies unexported variables. Discard copied ownership without + # deleting the caller's files, then own only files created by this run. + _MAC_RELEASE_OWNED_SPARKLE_RAW_FILE= + _MAC_RELEASE_OWNED_SPARKLE_CLEAN_FILE= + # shellcheck disable=SC2329 # invoked by the EXIT trap below cleanup_key_status() { [[ -z "${cleaned_source:-}" ]] || rm -f "$cleaned_source" + mac_release_cleanup_temp_sparkle_key } + trap cleanup_key_status EXIT + + mac_release_load + if [[ -z "$requested_source" ]]; then + mac_release_resolve_sparkle_op_key + fi + source=${requested_source:-$(mac_release_default_key_source)} + label_source=$source + resolved_temp=${_MAC_RELEASE_OWNED_SPARKLE_RAW_FILE:-} + if [[ -z "$requested_source" && -n "$resolved_temp" ]]; then + label_source="1Password reference ${MAC_RELEASE_SPARKLE_OP_REF}" + fi if [[ "$source" != "keychain" ]]; then source=$(mac_release_expand "$source") - label_source=$source - source=$(clean_key "$source") + [[ -n "$resolved_temp" ]] || label_source=$source + if ! clean_key source "$source"; then + mac_release_die "Could not normalize Sparkle signing key" + fi cleaned_source=$source - trap cleanup_key_status RETURN fi expected=$(mac_release_expected_public_key) actual=$(mac_release_public_key_for_source "$source") @@ -623,20 +733,21 @@ mac_release_sparkle_key_status() { printf 'embedded SUPublicEDKey: %s\n' "$expected" printf 'signing source: %s\n' "$(mac_release_key_source_label "$label_source")" printf 'signing public key: %s\n' "$actual" - cleanup_key_status - trap - RETURN if [[ "$actual" == "$expected" ]]; then printf 'status: match\n' else printf 'status: mismatch\n' >&2 return 1 fi -} +) mac_release_key_args_and_validate() { local out_var=${1:?"out var"} local key_file_var=${2:?"key file var"} - local key_source source cleaned_key_file actual expected + local key_source source cleaned_key_file actual expected resolved_temp + mac_release_resolve_sparkle_op_key + # shellcheck disable=SC2031 # status clears only its subshell copy + resolved_temp=${_MAC_RELEASE_OWNED_SPARKLE_RAW_FILE:-} key_source=${SPARKLE_PRIVATE_KEY_FILE:-} if [[ -z "$key_source" && -n "${MAC_RELEASE_SIGNING_KEY_FILE:-}" ]]; then local manifest_key_source @@ -645,7 +756,16 @@ mac_release_key_args_and_validate() { fi if [[ -n "$key_source" ]]; then key_source=$(mac_release_expand "$key_source") - cleaned_key_file=$(clean_key "$key_source") + if ! clean_key cleaned_key_file "$key_source"; then + mac_release_cleanup_temp_sparkle_key + mac_release_die "Could not normalize Sparkle signing key" + fi + if [[ -n "$resolved_temp" ]]; then + rm -f "$resolved_temp" + _MAC_RELEASE_OWNED_SPARKLE_RAW_FILE= + SPARKLE_PRIVATE_KEY_FILE=$cleaned_key_file + export SPARKLE_PRIVATE_KEY_FILE + fi source="$cleaned_key_file" eval "$key_file_var=\"\$cleaned_key_file\"" eval "$out_var=(--ed-key-file \"\$cleaned_key_file\")" @@ -1494,6 +1614,9 @@ mac_release_codesign_run() { mac_release_die "Usage: mac-release codesign-run [--with-package-secrets] -- [args...]" mac_release_load_codesign_config + # Arm cleanup before credential resolution so an early failure cannot leave + # a resolved Sparkle private-key file behind. + trap 'mac_release_cleanup_temp_sparkle_key' EXIT mac_release_load_1password_env "$load_mode" if [[ "$load_mode" == "codesign-only" ]]; then local release_op_field @@ -1506,6 +1629,7 @@ mac_release_codesign_run() { # shellcheck disable=SC2329 # invoked via EXIT trap cleanup_codesign_run() { local rc=$? + mac_release_cleanup_temp_sparkle_key if ! mac_release_restore_codesign_keychains; then sleep 1 mac_release_restore_codesign_keychains || true @@ -1527,6 +1651,7 @@ mac_release_codesign_run() { else return "$cleanup_rc" fi + mac_release_cleanup_temp_sparkle_key return "$command_rc" } @@ -1542,6 +1667,7 @@ mac_release_release() { pre_release_head=$(git rev-parse HEAD) ensure_changelog_finalized "$MARKETING_VERSION" ensure_appcast_monotonic "$APPCAST" "$MARKETING_VERSION" "$BUILD_NUMBER" + trap 'mac_release_cleanup_temp_sparkle_key' EXIT mac_release_load_1password_env mac_release_run_cmd "precheck" "${MAC_RELEASE_PRECHECK:-}" KEY_ARGS=() @@ -1549,6 +1675,7 @@ mac_release_release() { # shellcheck disable=SC2329 # invoked via EXIT trap cleanup_release() { local rc=$? + mac_release_cleanup_temp_sparkle_key if ! mac_release_restore_codesign_keychains; then sleep 1 if ! mac_release_restore_codesign_keychains; then diff --git a/skills/release-mac-app/scripts/mac-release.test.sh b/skills/release-mac-app/scripts/mac-release.test.sh index b3c06dd..78d0d36 100755 --- a/skills/release-mac-app/scripts/mac-release.test.sh +++ b/skills/release-mac-app/scripts/mac-release.test.sh @@ -21,6 +21,7 @@ case " $* " in exit 1 ;; *" new-window "*) printf '@7\n' ;; + *" display-message "*) printf '999999\n' ;; *" send-keys "*) command_text= previous= @@ -52,8 +53,21 @@ case " $* " in env -u OP_SERVICE_ACCOUNT_TOKEN -u MOLTY_OP_SERVICE_ACCOUNT_TOKEN \ bash --noprofile --norc -c "$command_text" <"$MAC_RELEASE_TEST_ROOT/pane-input" [[ ! -e "$work_dir/service-account-token" ]] + sparkle_path_line=$(sed -n 's/^export MAC_RELEASE_SPARKLE_KEY_FILE=//p' "$runner_path" | tail -1) + if [[ -n "$sparkle_path_line" ]]; then + eval "sparkle_path=$sparkle_path_line" + [[ -z "$sparkle_path" ]] || printf '%s\n' "$sparkle_path" >"$MAC_RELEASE_TEST_ROOT/last-sparkle-path" + fi + ;; + *" kill-window "*) + if [[ -f "$MAC_RELEASE_TEST_ROOT/last-sparkle-path" ]]; then + sparkle_path=$(<"$MAC_RELEASE_TEST_ROOT/last-sparkle-path") + [[ -z "$sparkle_path" || -f "$sparkle_path" ]] || { + echo 'Sparkle temp key was removed before its tmux producer stopped' >&2 + exit 1 + } + fi ;; - *" kill-window "*) ;; *) echo "unexpected tmux call" >&2 exit 1 @@ -106,7 +120,11 @@ printf '\n' >>"$MAC_RELEASE_TEST_ROOT/op.log" if [[ "$1 $2" == "item get" ]]; then printf '%s\n' '{"fields":[{"label":"TEST_SECRET","value":"loaded-value"},{"label":"keychain_path","value":"/tmp/release.keychain-db"},{"label":"keychain_password","value":"password-value"}]}' elif [[ "$1" == "read" ]]; then - printf '%s\n' 'ref-value' + if [[ "$2" == "op://Molty/Peekaboo Sparkle EdDSA/private key" ]]; then + printf '%s\n' "${MAC_RELEASE_TEST_SPARKLE_KEY:?}" + else + printf '%s\n' 'ref-value' + fi else echo "unexpected op call" >&2 exit 1 @@ -114,10 +132,31 @@ fi OP chmod +x "$test_root/bin/op" +cat >"$test_root/bin/swift" <<'SWIFT' +#!/usr/bin/env bash +set -euo pipefail +key_file=${2:?key file} +[[ -f "$key_file" ]] +[[ "$(<"$key_file")" == "${MAC_RELEASE_TEST_SPARKLE_KEY:?}" ]] +printf '%s\n' 'test-public-key' +SWIFT +chmod +x "$test_root/bin/swift" + +cat >"$test_root/bin/sign_update" <<'SIGN_UPDATE' +#!/usr/bin/env bash +set -euo pipefail +[[ "$1" == "--ed-key-file" ]] +key_file=$2 +[[ -f "$key_file" ]] +[[ "$(<"$key_file")" == "${MAC_RELEASE_TEST_SPARKLE_KEY:?}" ]] +SIGN_UPDATE +chmod +x "$test_root/bin/sign_update" + # shellcheck source=lib/mac_release.sh source "$script_dir/lib/mac_release.sh" service_token='service-token-that-must-never-appear' +sparkle_test_key='AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' service_output="$test_root/service.output" ( trap - EXIT @@ -125,6 +164,7 @@ service_output="$test_root/service.output" export MAC_RELEASE_TEST_ROOT="$test_root" export MAC_RELEASE_TEST_MODE=service export MAC_RELEASE_TEST_TOKEN="$service_token" + export MAC_RELEASE_TEST_SPARKLE_KEY="$sparkle_test_key" export OP_SERVICE_ACCOUNT_TOKEN="$service_token" export MAC_RELEASE_OP_ITEM='Release credentials' export MAC_RELEASE_OP_FIELDS=TEST_SECRET @@ -132,6 +172,8 @@ service_output="$test_root/service.output" export MAC_RELEASE_OP_USE_SERVICE_ACCOUNT=1 export MAC_RELEASE_OP_VAULT=Molty export MAC_RELEASE_OP_ENV_REFS='EXTRA_SECRET=op://Molty/Release credentials/extra' + export MAC_RELEASE_SPARKLE_OP_REF='op://Molty/Peekaboo Sparkle EdDSA/private key' + export MAC_RELEASE_SPARKLE_OP_USE_SERVICE_ACCOUNT=1 export MAC_RELEASE_CODESIGN_OP_ITEM='Signing keychain' unset MAC_RELEASE_CODESIGN_OP_VAULT @@ -140,15 +182,67 @@ service_output="$test_root/service.output" [[ "$EXTRA_SECRET" == "ref-value" ]] [[ "$MAC_RELEASE_CODESIGN_KEYCHAIN" == "/tmp/release.keychain-db" ]] [[ "$MAC_RELEASE_CODESIGN_KEYCHAIN_PASSWORD" == "password-value" ]] + resolved_sparkle_key=$SPARKLE_PRIVATE_KEY_FILE + [[ -f "$resolved_sparkle_key" ]] + sparkle_mode=$(stat -f '%Lp' "$resolved_sparkle_key" 2>/dev/null || stat -c '%a' "$resolved_sparkle_key") + [[ "$sparkle_mode" == "600" ]] + [[ "$(<"$resolved_sparkle_key")" == "$sparkle_test_key" ]] + + # shellcheck disable=SC2329 # invoked indirectly by mac_release_key_args_and_validate + mac_release_expected_public_key() { printf '%s\n' 'test-public-key'; } + KEY_ARGS=() + sparkle_key_file= + mac_release_key_args_and_validate KEY_ARGS sparkle_key_file + [[ "${KEY_ARGS[0]}" == "--ed-key-file" ]] + [[ "${KEY_ARGS[1]}" == "$sparkle_key_file" ]] + [[ -f "$sparkle_key_file" ]] + [[ ! -e "$resolved_sparkle_key" ]] + cleaned_mode=$(stat -f '%Lp' "$sparkle_key_file" 2>/dev/null || stat -c '%a' "$sparkle_key_file") + [[ "$cleaned_mode" == "600" ]] + [[ "$_MAC_RELEASE_OWNED_SPARKLE_CLEAN_FILE" == "$sparkle_key_file" ]] + [[ "$(bash -c 'printf %s "${_MAC_RELEASE_OWNED_SPARKLE_RAW_FILE:-unset}:${_MAC_RELEASE_OWNED_SPARKLE_CLEAN_FILE:-unset}"')" == unset:unset ]] + rm -f "$sparkle_key_file" + _MAC_RELEASE_OWNED_SPARKLE_CLEAN_FILE= + + export MAC_RELEASE_TEST_SPARKLE_KEY=$'invalid\nmultiline' + unset SPARKLE_PRIVATE_KEY_FILE + _MAC_RELEASE_OWNED_SPARKLE_RAW_FILE= + _MAC_RELEASE_OWNED_SPARKLE_CLEAN_FILE= + ( + # shellcheck disable=SC2329 # invoked by mac_release_sparkle_key_status + mac_release_load() { :; } + if mac_release_sparkle_key_status; then + echo 'malformed Sparkle status key unexpectedly validated' >&2 + exit 1 + fi + ) + malformed_status_key=$(<"$test_root/last-sparkle-path") + [[ ! -e "$malformed_status_key" ]] + + parent_owned_key=$(mktemp /tmp/mac-release-sparkle-parent-test.XXXXXX) + printf '%s\n%s\n' invalid multiline >"$parent_owned_key" + export SPARKLE_PRIVATE_KEY_FILE=$parent_owned_key + _MAC_RELEASE_OWNED_SPARKLE_RAW_FILE=$parent_owned_key + ( + # shellcheck disable=SC2329 # invoked by mac_release_sparkle_key_status + mac_release_load() { :; } + if mac_release_sparkle_key_status; then + echo 'parent-owned malformed Sparkle key unexpectedly validated' >&2 + exit 1 + fi + ) + [[ -f "$parent_owned_key" ]] + rm -f "$parent_owned_key" + _MAC_RELEASE_OWNED_SPARKLE_RAW_FILE= ) >"$service_output" 2>&1 grep -Fq 'has-session' "$test_root/tmux.log" -[[ "$(grep -c 'new-window' "$test_root/tmux.log")" == "1" ]] +[[ "$(grep -c 'new-window' "$test_root/tmux.log")" == "2" ]] if grep -Fq 'new-session' "$test_root/tmux.log"; then echo "pre-existing op-work session was replaced" >&2 exit 1 fi -[[ "$(grep -c '^mode=service ' "$test_root/op.log")" == "3" ]] +[[ "$(grep -c '^mode=service ' "$test_root/op.log")" == "5" ]] if grep -Fq -- '--account' "$test_root/op.log"; then echo "service-account op command included --account" >&2 exit 1 @@ -159,9 +253,14 @@ if grep -Fq "$service_token" "$service_output" "$test_root/tmux.log" "$test_root echo "service-account token was disclosed" >&2 exit 1 fi +if grep -Fq "$sparkle_test_key" "$service_output" "$test_root/tmux.log" "$test_root/op.log"; then + echo "Sparkle private key was disclosed" >&2 + exit 1 +fi : >"$test_root/tmux.log" : >"$test_root/op.log" +rm -f "$test_root/last-sparkle-path" interactive_output="$test_root/interactive.output" ( trap - EXIT From a52e05f59e6fe6482486c45fac1b3894c601472f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 13 Aug 2026 18:57:23 -0700 Subject: [PATCH 2/3] fix(release): redact Sparkle credential locators --- skills/release-mac-app/SKILL.md | 3 ++- skills/release-mac-app/scripts/lib/mac_release.sh | 3 ++- skills/release-mac-app/scripts/mac-release.test.sh | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/skills/release-mac-app/SKILL.md b/skills/release-mac-app/SKILL.md index 472d764..e199039 100644 --- a/skills/release-mac-app/SKILL.md +++ b/skills/release-mac-app/SKILL.md @@ -65,7 +65,8 @@ Common optional: helper resolves it inside the shared `op-work` tmux session, writes only a mode-0600 temporary key file, validates its public key against `SUPublicEDKey`, and removes it on success or failure. - `MAC_RELEASE_SPARKLE_OP_ACCOUNT` and `MAC_RELEASE_SPARKLE_OP_USE_SERVICE_ACCOUNT` override the primary 1Password - account/service-account mode for the Sparkle reference. Molty refs should set service-account mode to `1`. + account/service-account mode for the Sparkle reference. Service-account-backed refs should set service-account mode + to `1`. - `MAC_RELEASE_SIGNING_KEY_FILE` (local fallback path only; Keychain is used when the file is absent) - `MAC_RELEASE_EXTRA_ASSET_PATTERNS` - `MAC_RELEASE_EXTRA_ASSET_WAIT_SECONDS` diff --git a/skills/release-mac-app/scripts/lib/mac_release.sh b/skills/release-mac-app/scripts/lib/mac_release.sh index 458f158..2dab783 100644 --- a/skills/release-mac-app/scripts/lib/mac_release.sh +++ b/skills/release-mac-app/scripts/lib/mac_release.sh @@ -717,7 +717,8 @@ mac_release_sparkle_key_status() ( label_source=$source resolved_temp=${_MAC_RELEASE_OWNED_SPARKLE_RAW_FILE:-} if [[ -z "$requested_source" && -n "$resolved_temp" ]]; then - label_source="1Password reference ${MAC_RELEASE_SPARKLE_OP_REF}" + # Report the credential source without echoing private vault/item inventory. + label_source="configured 1Password Sparkle key" fi if [[ "$source" != "keychain" ]]; then source=$(mac_release_expand "$source") diff --git a/skills/release-mac-app/scripts/mac-release.test.sh b/skills/release-mac-app/scripts/mac-release.test.sh index 78d0d36..66fcac2 100755 --- a/skills/release-mac-app/scripts/mac-release.test.sh +++ b/skills/release-mac-app/scripts/mac-release.test.sh @@ -120,7 +120,7 @@ printf '\n' >>"$MAC_RELEASE_TEST_ROOT/op.log" if [[ "$1 $2" == "item get" ]]; then printf '%s\n' '{"fields":[{"label":"TEST_SECRET","value":"loaded-value"},{"label":"keychain_path","value":"/tmp/release.keychain-db"},{"label":"keychain_password","value":"password-value"}]}' elif [[ "$1" == "read" ]]; then - if [[ "$2" == "op://Molty/Peekaboo Sparkle EdDSA/private key" ]]; then + if [[ "$2" == "op://Release/Test Sparkle Key/private key" ]]; then printf '%s\n' "${MAC_RELEASE_TEST_SPARKLE_KEY:?}" else printf '%s\n' 'ref-value' @@ -172,7 +172,7 @@ service_output="$test_root/service.output" export MAC_RELEASE_OP_USE_SERVICE_ACCOUNT=1 export MAC_RELEASE_OP_VAULT=Molty export MAC_RELEASE_OP_ENV_REFS='EXTRA_SECRET=op://Molty/Release credentials/extra' - export MAC_RELEASE_SPARKLE_OP_REF='op://Molty/Peekaboo Sparkle EdDSA/private key' + export MAC_RELEASE_SPARKLE_OP_REF='op://Release/Test Sparkle Key/private key' export MAC_RELEASE_SPARKLE_OP_USE_SERVICE_ACCOUNT=1 export MAC_RELEASE_CODESIGN_OP_ITEM='Signing keychain' unset MAC_RELEASE_CODESIGN_OP_VAULT From db2acffebfadd96012788f7a047f6c96a94b60c3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 13 Aug 2026 18:58:32 -0700 Subject: [PATCH 3/3] docs: note headless Sparkle signing --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c8d826..3981e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ summary: Timeline of guardrail helper changes mirrored from Sweetistics and rela ## Unreleased +- Added headless Sparkle signing through scoped 1Password references, with public-key validation, mode-0600 temporary files, and cleanup on success or failure. - Corrected GitHub secret provisioning to omit `--body` for stdin and added a skill validation guard against the literal-dash trap. - Taught the `clawsweeper-status` snapshot to report queue handoff health, the ready/admissible split, backoff and parked reasons, and shed-since-reset, so exact-review items parked on retry exhaustion are no longer invisible behind a `healthy` verdict. - Added the `project-structure` skill: a TypeScript symbol-map generator that compresses a repository into one context-loadable file with dense/skeleton/exports tiers, plugin-boundary listings, and measured token budgets.