Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,19 @@ jobs:
./rust/target/release/apw --version
./rust/target/release/apw status --json

- name: Build native app bundle
run: ./scripts/build-native-app.sh

- name: Sign and notarize native app bundle
if: startsWith(github.ref, 'refs/tags/v')
env:
APPLE_DEVELOPER_CERT_P12: ${{ secrets.APPLE_DEVELOPER_CERT_P12 }}
APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
APPLE_NOTARY_KEY_ISSUER: ${{ secrets.APPLE_NOTARY_KEY_ISSUER }}
APPLE_NOTARY_PRIVATE_KEY: ${{ secrets.APPLE_NOTARY_PRIVATE_KEY }}
run: bash scripts/notarize-native-app.sh

- name: Homebrew smoke install from source tarball
env:
APW_SRC_TARBALL: ${{ steps.source_tarball.outputs.path }}
Expand Down
6 changes: 6 additions & 0 deletions docs/INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ tar -xzf apw-macos-vX.Y.Z.tar.gz
After `apw app install`, the CLI copies `APW.app` into
`~/.apw/native-app/installed/APW.app`.

Tagged release archives are signed and notarized by
`.github/workflows/release.yml` when the Apple Developer ID and notary secrets
listed in `docs/bootstrap/onboarding.md` are configured. If those optional
secrets are absent, the workflow emits a warning and publishes an unstapled
archive rather than failing unrelated release automation.

## Homebrew

APW uses a **formula-plus-app-install** Homebrew model for the v2 line. The
Expand Down
13 changes: 8 additions & 5 deletions docs/bootstrap/onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,19 @@
| ---------------------------- | ------------------------------------------------------------- |
| `APPLE_DEVELOPER_CERT_P12` | base64-encoded Developer ID Application .p12 (issue #7) |
| `APPLE_CERT_PASSWORD` | passphrase for the .p12 above |
| `APPLE_TEAM_ID` | 10-character Apple Developer Team ID |
| `APPLE_NOTARY_KEY_ID` | App Store Connect API key id used by `notarytool` |
| `APPLE_NOTARY_KEY_ISSUER` | App Store Connect issuer UUID |
| `APPLE_NOTARY_PRIVATE_KEY` | base64-encoded `.p8` private key for `notarytool` |
| `HOMEBREW_TAP_TOKEN` | scoped `contents:write` token on the tap repo (issue #6) |

All Apple credentials are optional — when absent, the workflow emits
a `::warning::` and continues without notarization. The Homebrew tap
job is `continue-on-error` so a missing or rejected token does not
block the release.
On tagged releases, `scripts/notarize-native-app.sh` imports the Developer
ID certificate into a temporary keychain, signs the release CLI and
`APW.app`, submits the app bundle to Apple notary service, staples the
ticket, and verifies Gatekeeper assessment before the archive is packaged.
All Apple credentials are optional — when absent, the workflow emits a
`::warning::` and continues without notarization. The Homebrew tap job is
`continue-on-error` so a missing or rejected token does not block the
release.

## Home Profiles

Expand Down
10 changes: 8 additions & 2 deletions rust/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2596,13 +2596,19 @@ mod tests {
.set_read_timeout(Some(StdDuration::from_secs(1)))
.unwrap();
let payload = serde_json::to_vec(request).unwrap();
socket.send_to(&payload, ("127.0.0.1", port)).unwrap();
loop {
match socket.send_to(&payload, ("127.0.0.1", port)) {
Ok(_) => break,
Err(error) if error.kind() == ErrorKind::Interrupted => continue,
Err(error) => panic!("failed to send daemon test request: {error}"),
}
}

let mut buffer = vec![0_u8; 4096];
let size = loop {
match socket.recv(&mut buffer) {
Ok(size) => break size,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) if error.kind() == ErrorKind::Interrupted => continue,
Err(error) => panic!("failed to receive daemon test response: {error}"),
}
};
Expand Down
1 change: 1 addition & 0 deletions scripts/ci/run-fast-checks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,6 @@ done < <(find .github/scripts scripts -type f -name '*.sh' -print0)
./scripts/test-extended-validation-config.sh
./scripts/test-verify-universal-binaries.sh
./scripts/test-universal-release-config.sh
./scripts/test-notarize-native-app.sh

echo "APW fast checks passed."
116 changes: 116 additions & 0 deletions scripts/notarize-native-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_DIR="${APW_APP_BUNDLE_PATH:-$ROOT_DIR/native-app/dist/APW.app}"
CLI_BIN="${APW_CLI_BINARY_PATH:-$ROOT_DIR/rust/target/release/apw}"
REQUIRED="${APW_NOTARIZE_REQUIRED:-0}"
DRY_RUN="${APW_NOTARIZE_DRY_RUN:-0}"

codesigning_env=(
APPLE_DEVELOPER_CERT_P12
APPLE_CERT_PASSWORD
)

notarytool_env=(
APPLE_NOTARY_KEY_ID
APPLE_NOTARY_KEY_ISSUER
APPLE_NOTARY_PRIVATE_KEY
)

required_env=("${codesigning_env[@]}" "${notarytool_env[@]}")

missing=()
for name in "${required_env[@]}"; do
if [[ -z "${!name:-}" ]]; then
missing+=("$name")
fi
done

if [[ "${#missing[@]}" -gt 0 ]]; then
message="Apple notarization credentials are incomplete; missing: ${missing[*]}"
if [[ "$REQUIRED" == "1" ]]; then
echo "$message" >&2
exit 1
fi
echo "::warning::$message. Skipping notarization."
exit 0
fi

if [[ ! -d "$APP_DIR" ]]; then
echo "APW app bundle not found: $APP_DIR" >&2
exit 1
fi

if [[ "$DRY_RUN" == "1" ]]; then
echo "Notarization inputs are present for $APP_DIR; dry run requested."
exit 0
fi

if [[ "$(uname -s)" != "Darwin" ]]; then
echo "Notarization requires macOS; current platform is $(uname -s)." >&2
exit 1
fi

for tool in codesign ditto security spctl xcrun; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "Required notarization tool not found on PATH: $tool" >&2
exit 1
fi
done

decode_base64() {
local value="$1"
local output="$2"
if ! printf '%s' "$value" | base64 --decode >"$output" 2>/dev/null; then
printf '%s' "$value" | base64 -D >"$output"
fi
}

tmp_dir="$(mktemp -d)"
keychain="$tmp_dir/apw-notarization.keychain-db"
cert_path="$tmp_dir/developer-id.p12"
notary_key_path="$tmp_dir/notary-key.p8"
zip_path="$tmp_dir/APW.app.zip"
keychain_password="$(uuidgen | tr -d '-')"
signing_identity="${APPLE_DEVELOPER_IDENTITY:-Developer ID Application}"

cleanup() {
security delete-keychain "$keychain" >/dev/null 2>&1 || true
rm -rf "$tmp_dir"
}
trap cleanup EXIT

decode_base64 "$APPLE_DEVELOPER_CERT_P12" "$cert_path"
decode_base64 "$APPLE_NOTARY_PRIVATE_KEY" "$notary_key_path"
chmod 0600 "$cert_path" "$notary_key_path"

security create-keychain -p "$keychain_password" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$keychain_password" "$keychain"
security import "$cert_path" -k "$keychain" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain"

existing_keychains=()
while IFS= read -r keychain_path; do
existing_keychains+=("${keychain_path//\"/}")
done < <(security list-keychains -d user)
security list-keychains -d user -s "$keychain" "${existing_keychains[@]}"

if [[ -x "$CLI_BIN" ]]; then
codesign --force --options runtime --timestamp --sign "$signing_identity" "$CLI_BIN"
fi

codesign --force --deep --options runtime --timestamp --sign "$signing_identity" "$APP_DIR"
codesign --verify --deep --strict --verbose=2 "$APP_DIR"

ditto -c -k --keepParent "$APP_DIR" "$zip_path"
xcrun notarytool submit "$zip_path" \
--key "$notary_key_path" \
--key-id "$APPLE_NOTARY_KEY_ID" \
--issuer "$APPLE_NOTARY_KEY_ISSUER" \
--wait
xcrun stapler staple "$APP_DIR"
spctl --assess --type execute --verbose=2 "$APP_DIR"

echo "APW.app signed, notarized, and stapled: $APP_DIR"
44 changes: 44 additions & 0 deletions scripts/test-notarize-native-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
tmp_dir="$(mktemp -d)"
cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT

fake_app="$tmp_dir/APW.app"
mkdir -p "$fake_app/Contents/MacOS"
printf '#!/usr/bin/env sh\nexit 0\n' >"$fake_app/Contents/MacOS/APW"
chmod 0755 "$fake_app/Contents/MacOS/APW"

required_log="$tmp_dir/notary-required.log"
optional_log="$tmp_dir/notary-optional.log"
dry_run_log="$tmp_dir/notary-dry-run.log"

if APW_NOTARIZE_REQUIRED=1 APW_APP_BUNDLE_PATH="$fake_app" "$ROOT_DIR/scripts/notarize-native-app.sh" >"$required_log" 2>&1; then
echo "notarize-native-app accepted missing required credentials." >&2
exit 1
fi
grep -q "APPLE_DEVELOPER_CERT_P12" "$required_log"
if grep -q "APPLE_TEAM_ID" "$ROOT_DIR/scripts/notarize-native-app.sh"; then
echo "notarize-native-app must not require unused Apple Team ID credentials." >&2
exit 1
fi

APW_APP_BUNDLE_PATH="$fake_app" "$ROOT_DIR/scripts/notarize-native-app.sh" >"$optional_log" 2>&1
grep -q "Skipping notarization" "$optional_log"

env \
APW_NOTARIZE_DRY_RUN=1 \
APW_APP_BUNDLE_PATH="$fake_app" \
APPLE_DEVELOPER_CERT_P12=dGVzdA== \
APPLE_CERT_PASSWORD=test \
APPLE_NOTARY_KEY_ID=KEYID12345 \
APPLE_NOTARY_KEY_ISSUER=00000000-0000-0000-0000-000000000000 \
APPLE_NOTARY_PRIVATE_KEY=dGVzdA== \
"$ROOT_DIR/scripts/notarize-native-app.sh" >"$dry_run_log" 2>&1
grep -q "dry run requested" "$dry_run_log"

echo "Notarization script tests passed."
Loading