From 4f85f38132073d4656d5d48cc14af161df9646d1 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 01:35:29 +0100 Subject: [PATCH 01/10] Document Sparkle update contract --- docs/INSTALLATION.md | 7 ++ docs/IN_APP_UPDATES.md | 120 ++++++++++++++++++++++++ docs/SECURITY_POSTURE_AND_TESTING.md | 7 ++ packaging/sparkle/appcast.template.xml | 23 +++++ scripts/ci/run-fast-checks.sh | 1 + scripts/ci/validate-appcast-contract.sh | 55 +++++++++++ 6 files changed, 213 insertions(+) create mode 100644 docs/IN_APP_UPDATES.md create mode 100644 packaging/sparkle/appcast.template.xml create mode 100755 scripts/ci/validate-appcast-contract.sh diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 62283d7..dad892a 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -67,6 +67,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`. +## In-app updates + +APW.app's planned in-app update channel uses a signed Sparkle appcast. The +contract, managed disable key, security-update marker, and release validation +requirements are documented in [IN_APP_UPDATES.md](IN_APP_UPDATES.md). + ## Homebrew ### Local formula smoke test @@ -225,6 +231,7 @@ cargo test --manifest-path rust/Cargo.toml --all-targets cargo test --manifest-path rust/Cargo.toml --test native_app_e2e cargo build --manifest-path rust/Cargo.toml --release ./scripts/build-native-app.sh +./scripts/ci/validate-appcast-contract.sh ``` Optional parity and release helpers: diff --git a/docs/IN_APP_UPDATES.md b/docs/IN_APP_UPDATES.md new file mode 100644 index 0000000..0a42805 --- /dev/null +++ b/docs/IN_APP_UPDATES.md @@ -0,0 +1,120 @@ +# In-app update contract + +APW.app will use Sparkle 2 for in-app updates. The release channel is security +sensitive because the app broker mediates credential access, so APW uses the +standard macOS updater instead of a custom downloader and swapper. + +Issue: #57 + +## Decision + +Use Sparkle 2 as the updater framework for APW.app. + +Rationale: + +- Sparkle is the established macOS updater for Developer ID distributed apps. +- Sparkle supports EdDSA-signed update archives and Apple code signing checks. +- Sparkle can mark critical updates distinctly from ordinary maintenance + updates. +- Sparkle keeps the update installer and relaunch behavior out of APW broker + code, reducing the amount of security-sensitive custom code. + +APW should not add a custom updater unless Sparkle cannot satisfy a release +blocker that is documented with a replacement threat model. + +## Stable feed + +The production appcast URL is: + +```text +https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml +``` + +This URL is controlled by the project repository and resolves to the appcast +asset attached to the latest GitHub release. APW.app should set this URL in +`Info.plist` with `SUFeedURL` once Sparkle is linked into the native app. + +The appcast contract is represented by +`packaging/sparkle/appcast.template.xml`. The template is not a production +appcast and must not be uploaded with placeholder signatures or lengths. + +## Required Sparkle settings + +When the runtime integration lands, APW.app must set these keys: + +```text +SUFeedURL=https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml +SUPublicEDKey= +SUVerifyUpdateBeforeExtraction=true +SURequireSignedFeed=true +SUEnableAutomaticChecks=true +SUAllowsAutomaticUpdates=false +SUAutomaticallyUpdate=false +``` + +`SUVerifyUpdateBeforeExtraction` requires EdDSA signing and validates the update +archive before extraction. `SURequireSignedFeed` requires Sparkle 2.9 or newer +and ensures the appcast and release notes are signed before update metadata is +trusted. + +## Release signing requirements + +Every APW.app update must be published as a Developer ID signed and notarized +archive. Before publishing the appcast, the release job must verify: + +```bash +codesign --deep --strict --verify APW.app +spctl --assess --type execute --verbose APW.app +xcrun stapler validate APW.app +``` + +The release archive, release notes, and appcast must be signed with Sparkle's +EdDSA key. The private EdDSA key must stay in release automation secrets or a +release keychain and must never be committed to this repository. + +## Managed update control + +Enterprise administrators can disable user-driven update checks with this +managed preference: + +```text +Domain: dev.omt.apw +Key: com.omt.apw.updatesDisabled +Type: Boolean +``` + +When this key is `true`, APW.app must not start Sparkle automatic checks or +manual user-driven update checks. The broker should still report its installed +version through `apw status --json` and `apw doctor --json` so fleet tooling can +inventory stale installations. + +This managed key is part of the APW configuration contract and should be wired +through the managed-config roadmap work before the updater runtime is enabled +by default. + +## Security update surfacing + +Security updates must be distinct from cosmetic or maintenance updates. + +Use all of the following for security releases: + +- title starts with `APW Security Update` +- appcast item contains top-level `sparkle:criticalUpdate` +- release notes contain a `Security` section before other changes +- appcast item links to the GitHub release notes for the exact tag + +Critical update status is reserved for credential-broker security fixes, +signing/notarization failures, or vulnerabilities that can affect credential +confidentiality, integrity, or update trust. + +## Validation + +Run the contract check with: + +```bash +./scripts/ci/validate-appcast-contract.sh +``` + +The fast PR check runs the same validator so changes to the appcast template, +security-update wording, MDM key, or Sparkle security settings fail before +release automation drifts. diff --git a/docs/SECURITY_POSTURE_AND_TESTING.md b/docs/SECURITY_POSTURE_AND_TESTING.md index 9b5a30f..bb0a036 100644 --- a/docs/SECURITY_POSTURE_AND_TESTING.md +++ b/docs/SECURITY_POSTURE_AND_TESTING.md @@ -62,8 +62,14 @@ cargo test --manifest-path rust/Cargo.toml --test legacy_parity cargo test --manifest-path rust/Cargo.toml --test native_app_e2e cargo build --manifest-path rust/Cargo.toml --release ./scripts/build-native-app.sh +./scripts/ci/validate-appcast-contract.sh ``` +In-app updates must follow the signed Sparkle appcast contract in +[IN_APP_UPDATES.md](IN_APP_UPDATES.md). Release automation must not publish an +appcast until the APW.app archive passes code-signing, Gatekeeper, and +notarization staple validation. + ## Security-focused regression coverage The Rust test suite covers: @@ -77,6 +83,7 @@ The Rust test suite covers: - native app diagnostics and `APW_DEMO=1` bootstrap credential file initialization - end-to-end v2 app install, launch, status, doctor, and login flows - direct-exec fallback, unsupported-domain handling, denial handling, and malformed broker response mapping +- signed appcast contract requirements for the future APW.app in-app update channel ## Archive policy diff --git a/packaging/sparkle/appcast.template.xml b/packaging/sparkle/appcast.template.xml new file mode 100644 index 0000000..1e74632 --- /dev/null +++ b/packaging/sparkle/appcast.template.xml @@ -0,0 +1,23 @@ + + + + APW.app Updates + https://github.com/OMT-Global/apw-cli/releases + Signed APW.app update feed. + en + + APW 2.0.0 Security Update + https://github.com/OMT-Global/apw-cli/releases/tag/v2.0.0 + 2.0.0 + 2.0.0 + https://github.com/OMT-Global/apw-cli/releases/tag/v2.0.0 + Tue, 01 Jan 2030 00:00:00 +0000 + + + + + diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index 587f6d2..6d957ea 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -36,5 +36,6 @@ while IFS= read -r -d '' script; do done < <(find .github/scripts scripts -type f -name '*.sh' -print0) ./scripts/test-render-homebrew-formula.sh +./scripts/ci/validate-appcast-contract.sh echo "APW fast checks passed." diff --git a/scripts/ci/validate-appcast-contract.sh b/scripts/ci/validate-appcast-contract.sh new file mode 100755 index 0000000..cd2ee7e --- /dev/null +++ b/scripts/ci/validate-appcast-contract.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DOC_PATH="$ROOT_DIR/docs/IN_APP_UPDATES.md" +TEMPLATE_PATH="$ROOT_DIR/packaging/sparkle/appcast.template.xml" +FEED_URL="https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml" +MDM_KEY="com.omt.apw.updatesDisabled" + +require_file() { + if [ ! -f "$1" ]; then + echo "Missing required appcast contract file: $1" >&2 + exit 1 + fi +} + +require_pattern() { + file="$1" + pattern="$2" + description="$3" + if ! grep -Eq "$pattern" "$file"; then + echo "Missing appcast contract requirement in $file: $description" >&2 + exit 1 + fi +} + +require_file "$DOC_PATH" +require_file "$TEMPLATE_PATH" + +require_pattern "$DOC_PATH" "Sparkle 2" "Sparkle 2 decision" +require_pattern "$DOC_PATH" "$FEED_URL" "stable project-controlled feed URL" +require_pattern "$DOC_PATH" "SUFeedURL" "Info.plist feed key" +require_pattern "$DOC_PATH" "SUPublicEDKey" "Sparkle EdDSA public key" +require_pattern "$DOC_PATH" "SUVerifyUpdateBeforeExtraction=true" "pre-extraction update verification" +require_pattern "$DOC_PATH" "SURequireSignedFeed=true" "signed appcast enforcement" +require_pattern "$DOC_PATH" "$MDM_KEY" "managed preference to disable updates" +require_pattern "$DOC_PATH" "codesign --deep --strict --verify APW\\.app" "codesign release gate" +require_pattern "$DOC_PATH" "spctl --assess --type execute --verbose APW\\.app" "Gatekeeper release gate" +require_pattern "$DOC_PATH" "xcrun stapler validate APW\\.app" "notarization staple release gate" +require_pattern "$DOC_PATH" "sparkle:criticalUpdate" "security update appcast marker" + +require_pattern "$TEMPLATE_PATH" "xmlns:sparkle=\"http://www\\.andymatuschak\\.org/xml-namespaces/sparkle\"" "Sparkle namespace" +require_pattern "$TEMPLATE_PATH" "APW [0-9]+\\.[0-9]+\\.[0-9]+ Security Update" "security update title" +require_pattern "$TEMPLATE_PATH" "[0-9]+\\.[0-9]+\\.[0-9]+" "machine version" +require_pattern "$TEMPLATE_PATH" "sparkle:releaseNotesLink sparkle:edSignature=" "signed release notes link" +require_pattern "$TEMPLATE_PATH" "/dev/null 2>&1; then + xmllint --noout "$TEMPLATE_PATH" +fi + +echo "Appcast contract validation passed." From 11d3be093bd44c7f8a1462816d4c3e6575612048 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 02:03:44 +0100 Subject: [PATCH 02/10] Add Sparkle appcast preparation helper --- docs/IN_APP_UPDATES.md | 21 ++++- scripts/ci/run-fast-checks.sh | 1 + scripts/ci/validate-appcast-contract.sh | 11 +++ scripts/prepare-sparkle-appcast.sh | 108 ++++++++++++++++++++++++ scripts/test-prepare-sparkle-appcast.sh | 52 ++++++++++++ 5 files changed, 191 insertions(+), 2 deletions(-) create mode 100755 scripts/prepare-sparkle-appcast.sh create mode 100755 scripts/test-prepare-sparkle-appcast.sh diff --git a/docs/IN_APP_UPDATES.md b/docs/IN_APP_UPDATES.md index 0a42805..11e794f 100644 --- a/docs/IN_APP_UPDATES.md +++ b/docs/IN_APP_UPDATES.md @@ -72,6 +72,22 @@ The release archive, release notes, and appcast must be signed with Sparkle's EdDSA key. The private EdDSA key must stay in release automation secrets or a release keychain and must never be committed to this repository. +Sparkle appcast preparation should use the checked helper: + +```bash +./scripts/prepare-sparkle-appcast.sh \ + --archive dist/APW.app.zip \ + --release-notes dist/APW.app.release.md \ + --updates-dir dist/sparkle-updates \ + --generate-appcast /path/to/Sparkle/bin/generate_appcast +``` + +The helper copies the signed/notarized archive and release notes into the +updates directory, runs Sparkle's `generate_appcast`, and fails if the resulting +appcast does not contain EdDSA signatures or does not reference the release +archive. Private EdDSA key material stays with Sparkle's configured signing +environment, such as Keychain-backed release automation. + ## Managed update control Enterprise administrators can disable user-driven update checks with this @@ -113,8 +129,9 @@ Run the contract check with: ```bash ./scripts/ci/validate-appcast-contract.sh +./scripts/test-prepare-sparkle-appcast.sh ``` The fast PR check runs the same validator so changes to the appcast template, -security-update wording, MDM key, or Sparkle security settings fail before -release automation drifts. +security-update wording, MDM key, Sparkle security settings, or appcast +preparation helper fail before release automation drifts. diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index 6d957ea..cc20c6f 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -36,6 +36,7 @@ while IFS= read -r -d '' script; do done < <(find .github/scripts scripts -type f -name '*.sh' -print0) ./scripts/test-render-homebrew-formula.sh +./scripts/test-prepare-sparkle-appcast.sh ./scripts/ci/validate-appcast-contract.sh echo "APW fast checks passed." diff --git a/scripts/ci/validate-appcast-contract.sh b/scripts/ci/validate-appcast-contract.sh index cd2ee7e..d8c0270 100755 --- a/scripts/ci/validate-appcast-contract.sh +++ b/scripts/ci/validate-appcast-contract.sh @@ -4,6 +4,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" DOC_PATH="$ROOT_DIR/docs/IN_APP_UPDATES.md" TEMPLATE_PATH="$ROOT_DIR/packaging/sparkle/appcast.template.xml" +PREPARE_SCRIPT="$ROOT_DIR/scripts/prepare-sparkle-appcast.sh" +PREPARE_TEST="$ROOT_DIR/scripts/test-prepare-sparkle-appcast.sh" FEED_URL="https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml" MDM_KEY="com.omt.apw.updatesDisabled" @@ -26,6 +28,8 @@ require_pattern() { require_file "$DOC_PATH" require_file "$TEMPLATE_PATH" +require_file "$PREPARE_SCRIPT" +require_file "$PREPARE_TEST" require_pattern "$DOC_PATH" "Sparkle 2" "Sparkle 2 decision" require_pattern "$DOC_PATH" "$FEED_URL" "stable project-controlled feed URL" @@ -38,6 +42,8 @@ require_pattern "$DOC_PATH" "codesign --deep --strict --verify APW\\.app" "codes require_pattern "$DOC_PATH" "spctl --assess --type execute --verbose APW\\.app" "Gatekeeper release gate" require_pattern "$DOC_PATH" "xcrun stapler validate APW\\.app" "notarization staple release gate" require_pattern "$DOC_PATH" "sparkle:criticalUpdate" "security update appcast marker" +require_pattern "$DOC_PATH" "prepare-sparkle-appcast\\.sh" "release appcast preparation helper" +require_pattern "$DOC_PATH" "generate_appcast" "Sparkle appcast generation tool" require_pattern "$TEMPLATE_PATH" "xmlns:sparkle=\"http://www\\.andymatuschak\\.org/xml-namespaces/sparkle\"" "Sparkle namespace" require_pattern "$TEMPLATE_PATH" "APW [0-9]+\\.[0-9]+\\.[0-9]+ Security Update" "security update title" @@ -48,6 +54,11 @@ require_pattern "$TEMPLATE_PATH" "url=\"https://github\\.com/OMT-Global/apw-cli/ require_pattern "$TEMPLATE_PATH" "sparkle:edSignature=" "signed archive enclosure" require_pattern "$TEMPLATE_PATH" "length=\"[0-9]+\"" "archive length" +require_pattern "$PREPARE_SCRIPT" "generate_appcast" "Sparkle appcast generation invocation" +require_pattern "$PREPARE_SCRIPT" "sparkle:edSignature=" "signed appcast output enforcement" +require_pattern "$PREPARE_SCRIPT" "Do not pass private keys" "private key handling guardrail" +require_pattern "$PREPARE_TEST" "Sparkle appcast preparation test passed" "helper regression test" + if command -v xmllint >/dev/null 2>&1; then xmllint --noout "$TEMPLATE_PATH" fi diff --git a/scripts/prepare-sparkle-appcast.sh b/scripts/prepare-sparkle-appcast.sh new file mode 100755 index 0000000..332efb9 --- /dev/null +++ b/scripts/prepare-sparkle-appcast.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: ./scripts/prepare-sparkle-appcast.sh --archive PATH --release-notes PATH --updates-dir DIR --generate-appcast PATH [--feed-url URL] + +Prepare a Sparkle updates directory and run Sparkle's generate_appcast tool. +The tool is expected to sign archives, release notes, and the appcast using +Sparkle's configured EdDSA key material. Do not pass private keys on this +script's command line. + +Options: + --archive PATH Signed/notarized APW.app update archive. + --release-notes PATH Markdown release notes for this archive. + --updates-dir DIR Directory holding Sparkle update archives. + --generate-appcast PATH Path to Sparkle's generate_appcast executable. + --feed-url URL Feed URL; default is APW's production appcast URL. + -h, --help Show this help. +USAGE +} + +FEED_URL="https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml" +ARCHIVE_PATH="" +RELEASE_NOTES_PATH="" +UPDATES_DIR="" +GENERATE_APPCAST="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --archive) + ARCHIVE_PATH="${2:-}" + shift 2 + ;; + --release-notes) + RELEASE_NOTES_PATH="${2:-}" + shift 2 + ;; + --updates-dir) + UPDATES_DIR="${2:-}" + shift 2 + ;; + --generate-appcast) + GENERATE_APPCAST="${2:-}" + shift 2 + ;; + --feed-url) + FEED_URL="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +fail() { + echo "prepare-sparkle-appcast: $*" >&2 + exit 1 +} + +[ -n "$ARCHIVE_PATH" ] || fail "--archive is required" +[ -n "$RELEASE_NOTES_PATH" ] || fail "--release-notes is required" +[ -n "$UPDATES_DIR" ] || fail "--updates-dir is required" +[ -n "$GENERATE_APPCAST" ] || fail "--generate-appcast is required" + +case "$FEED_URL" in + https://*) ;; + *) fail "--feed-url must be an https URL" ;; +esac + +[ -f "$ARCHIVE_PATH" ] || fail "archive not found: $ARCHIVE_PATH" +[ -f "$RELEASE_NOTES_PATH" ] || fail "release notes not found: $RELEASE_NOTES_PATH" +[ -x "$GENERATE_APPCAST" ] || fail "generate_appcast is not executable: $GENERATE_APPCAST" + +archive_name="$(basename "$ARCHIVE_PATH")" +case "$archive_name" in + *.zip|*.dmg|*.tar|*.tar.gz|*.tar.xz|*.aar) ;; + *) fail "archive must be a Sparkle-supported update archive: $archive_name" ;; +esac + +feed_file="$(basename "$FEED_URL")" +[ -n "$feed_file" ] || fail "unable to derive appcast file name from --feed-url" + +mkdir -p "$UPDATES_DIR" +cp "$ARCHIVE_PATH" "$UPDATES_DIR/$archive_name" +cp "$RELEASE_NOTES_PATH" "$UPDATES_DIR/$archive_name.md" + +"$GENERATE_APPCAST" "$UPDATES_DIR" + +appcast_path="$UPDATES_DIR/$feed_file" +[ -f "$appcast_path" ] || fail "generate_appcast did not create $appcast_path" + +if ! grep -q 'sparkle:edSignature=' "$appcast_path"; then + fail "$appcast_path does not contain Sparkle EdDSA signatures" +fi + +if ! grep -q "$archive_name" "$appcast_path"; then + fail "$appcast_path does not reference $archive_name" +fi + +echo "Prepared signed Sparkle appcast: $appcast_path" diff --git a/scripts/test-prepare-sparkle-appcast.sh b/scripts/test-prepare-sparkle-appcast.sh new file mode 100755 index 0000000..0cae3e7 --- /dev/null +++ b/scripts/test-prepare-sparkle-appcast.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/apw-sparkle-test.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT + +archive="$WORK_DIR/APW.app.zip" +notes="$WORK_DIR/APW.app.release.md" +updates="$WORK_DIR/updates" +fake_generate="$WORK_DIR/generate_appcast" + +printf 'fake notarized archive\n' >"$archive" +cat >"$notes" <<'NOTES' +# APW 2.0.0 Security Update + +## Security + +- Exercise signed Sparkle appcast generation. +NOTES + +cat >"$fake_generate" <<'FAKE' +#!/usr/bin/env bash +set -euo pipefail + +updates_dir="$1" +cat >"$updates_dir/appcast.xml" < + + + + APW 2.0.0 Security Update + + + + +XML +FAKE +chmod +x "$fake_generate" + +"$ROOT_DIR/scripts/prepare-sparkle-appcast.sh" \ + --archive "$archive" \ + --release-notes "$notes" \ + --updates-dir "$updates" \ + --generate-appcast "$fake_generate" + +[ -f "$updates/APW.app.zip" ] +[ -f "$updates/APW.app.zip.md" ] +[ -f "$updates/appcast.xml" ] +grep -q 'sparkle:edSignature="signed"' "$updates/appcast.xml" + +echo "Sparkle appcast preparation test passed." From 095fca4a90cd0a294054327dac6de7b56cc745cb Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 04:10:11 +0100 Subject: [PATCH 03/10] Wire Sparkle appcast publication into release --- .github/workflows/release.yml | 38 +++++++++++++++++++++++++ docs/IN_APP_UPDATES.md | 8 ++++++ scripts/ci/validate-appcast-contract.sh | 7 +++++ 3 files changed, 53 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1aeac12..e40c2a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,6 +142,36 @@ jobs: - name: Package release archive run: ./scripts/package-release-archive.sh "${GITHUB_REF_NAME}" + - name: Prepare signed Sparkle appcast + id: sparkle_appcast + env: + SPARKLE_GENERATE_APPCAST: ${{ vars.SPARKLE_GENERATE_APPCAST || '' }} + run: | + if [ -z "$SPARKLE_GENERATE_APPCAST" ]; then + echo "::warning::SPARKLE_GENERATE_APPCAST is not configured; skipping Sparkle appcast publication." + echo "available=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + app_zip="dist/APW.app-${GITHUB_REF_NAME}.zip" + release_notes="dist/APW.app-${GITHUB_REF_NAME}.md" + + ditto -c -k --keepParent native-app/dist/APW.app "$app_zip" + { + echo "# APW ${GITHUB_REF_NAME}" + echo + echo "See the GitHub release notes for this tag." + } > "$release_notes" + + ./scripts/prepare-sparkle-appcast.sh \ + --archive "$app_zip" \ + --release-notes "$release_notes" \ + --updates-dir dist/sparkle-updates \ + --generate-appcast "$SPARKLE_GENERATE_APPCAST" + + cp dist/sparkle-updates/appcast.xml dist/appcast.xml + echo "available=true" >> "$GITHUB_OUTPUT" + - name: Validate release archive contents run: | archive="dist/apw-macos-${GITHUB_REF_NAME}.tar.gz" @@ -155,6 +185,14 @@ jobs: with: files: dist/apw-macos-${{ github.ref_name }}.tar.gz + - name: Publish Sparkle appcast assets + if: steps.sparkle_appcast.outputs.available == 'true' + uses: softprops/action-gh-release@v2 + with: + files: | + dist/APW.app-${{ github.ref_name }}.zip + dist/appcast.xml + publish-homebrew-tap: name: Publish Homebrew tap PR needs: release diff --git a/docs/IN_APP_UPDATES.md b/docs/IN_APP_UPDATES.md index 11e794f..b3eef6a 100644 --- a/docs/IN_APP_UPDATES.md +++ b/docs/IN_APP_UPDATES.md @@ -88,6 +88,14 @@ appcast does not contain EdDSA signatures or does not reference the release archive. Private EdDSA key material stays with Sparkle's configured signing environment, such as Keychain-backed release automation. +Tagged releases run this helper when the release runner has the +`SPARKLE_GENERATE_APPCAST` repository variable set to Sparkle's +`generate_appcast` executable. When configured, the release attaches +`appcast.xml` and the signed `APW.app` update archive to the GitHub release so +the stable `/releases/latest/download/appcast.xml` feed URL resolves to a +signed appcast. When the variable is absent, release automation emits a warning +and skips appcast publication rather than inventing unsigned update metadata. + ## Managed update control Enterprise administrators can disable user-driven update checks with this diff --git a/scripts/ci/validate-appcast-contract.sh b/scripts/ci/validate-appcast-contract.sh index d8c0270..7e32c5c 100755 --- a/scripts/ci/validate-appcast-contract.sh +++ b/scripts/ci/validate-appcast-contract.sh @@ -6,6 +6,7 @@ DOC_PATH="$ROOT_DIR/docs/IN_APP_UPDATES.md" TEMPLATE_PATH="$ROOT_DIR/packaging/sparkle/appcast.template.xml" PREPARE_SCRIPT="$ROOT_DIR/scripts/prepare-sparkle-appcast.sh" PREPARE_TEST="$ROOT_DIR/scripts/test-prepare-sparkle-appcast.sh" +RELEASE_WORKFLOW="$ROOT_DIR/.github/workflows/release.yml" FEED_URL="https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml" MDM_KEY="com.omt.apw.updatesDisabled" @@ -30,6 +31,7 @@ require_file "$DOC_PATH" require_file "$TEMPLATE_PATH" require_file "$PREPARE_SCRIPT" require_file "$PREPARE_TEST" +require_file "$RELEASE_WORKFLOW" require_pattern "$DOC_PATH" "Sparkle 2" "Sparkle 2 decision" require_pattern "$DOC_PATH" "$FEED_URL" "stable project-controlled feed URL" @@ -44,6 +46,7 @@ require_pattern "$DOC_PATH" "xcrun stapler validate APW\\.app" "notarization sta require_pattern "$DOC_PATH" "sparkle:criticalUpdate" "security update appcast marker" require_pattern "$DOC_PATH" "prepare-sparkle-appcast\\.sh" "release appcast preparation helper" require_pattern "$DOC_PATH" "generate_appcast" "Sparkle appcast generation tool" +require_pattern "$DOC_PATH" "SPARKLE_GENERATE_APPCAST" "release runner generate_appcast configuration" require_pattern "$TEMPLATE_PATH" "xmlns:sparkle=\"http://www\\.andymatuschak\\.org/xml-namespaces/sparkle\"" "Sparkle namespace" require_pattern "$TEMPLATE_PATH" "APW [0-9]+\\.[0-9]+\\.[0-9]+ Security Update" "security update title" @@ -58,6 +61,10 @@ require_pattern "$PREPARE_SCRIPT" "generate_appcast" "Sparkle appcast generation require_pattern "$PREPARE_SCRIPT" "sparkle:edSignature=" "signed appcast output enforcement" require_pattern "$PREPARE_SCRIPT" "Do not pass private keys" "private key handling guardrail" require_pattern "$PREPARE_TEST" "Sparkle appcast preparation test passed" "helper regression test" +require_pattern "$RELEASE_WORKFLOW" "prepare-sparkle-appcast\\.sh" "release appcast preparation step" +require_pattern "$RELEASE_WORKFLOW" "SPARKLE_GENERATE_APPCAST" "release appcast generator variable" +require_pattern "$RELEASE_WORKFLOW" "dist/appcast\\.xml" "release appcast asset upload" +require_pattern "$RELEASE_WORKFLOW" "APW\\.app-\\$\\{\\{ github\\.ref_name \\}\\}\\.zip" "release Sparkle app archive upload" if command -v xmllint >/dev/null 2>&1; then xmllint --noout "$TEMPLATE_PATH" From cb11113e3ce97fc951c3da03393e75b79cf55044 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 04:18:20 +0100 Subject: [PATCH 04/10] Render Sparkle update keys in native app plist --- .github/workflows/release.yml | 4 ++ docs/IN_APP_UPDATES.md | 5 ++ scripts/build-native-app.sh | 33 ++------- scripts/ci/run-fast-checks.sh | 1 + scripts/ci/validate-appcast-contract.sh | 12 ++++ scripts/render-native-app-info-plist.sh | 75 ++++++++++++++++++++ scripts/test-render-native-app-info-plist.sh | 40 +++++++++++ 7 files changed, 144 insertions(+), 26 deletions(-) create mode 100755 scripts/render-native-app-info-plist.sh create mode 100755 scripts/test-render-native-app-info-plist.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e40c2a0..761b8dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,6 +48,8 @@ jobs: run: cargo build --manifest-path rust/Cargo.toml --release - name: Build native app bundle + env: + APW_SPARKLE_PUBLIC_ED_KEY: ${{ vars.APW_SPARKLE_PUBLIC_ED_KEY || '' }} run: ./scripts/build-native-app.sh - name: Build local source tarball for Homebrew smoke @@ -100,6 +102,8 @@ jobs: ./rust/target/release/apw status --json - name: Build native app bundle + env: + APW_SPARKLE_PUBLIC_ED_KEY: ${{ vars.APW_SPARKLE_PUBLIC_ED_KEY || '' }} run: ./scripts/build-native-app.sh - name: Homebrew smoke install from source tarball diff --git a/docs/IN_APP_UPDATES.md b/docs/IN_APP_UPDATES.md index b3eef6a..1f3237a 100644 --- a/docs/IN_APP_UPDATES.md +++ b/docs/IN_APP_UPDATES.md @@ -33,6 +33,9 @@ https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml This URL is controlled by the project repository and resolves to the appcast asset attached to the latest GitHub release. APW.app should set this URL in `Info.plist` with `SUFeedURL` once Sparkle is linked into the native app. +`scripts/build-native-app.sh` renders those keys when +`APW_SPARKLE_PUBLIC_ED_KEY` is set, so release automation can package a bundle +with the real public key without committing placeholder update-trust material. The appcast contract is represented by `packaging/sparkle/appcast.template.xml`. The template is not a production @@ -95,6 +98,8 @@ Tagged releases run this helper when the release runner has the the stable `/releases/latest/download/appcast.xml` feed URL resolves to a signed appcast. When the variable is absent, release automation emits a warning and skips appcast publication rather than inventing unsigned update metadata. +Release runners should also set `APW_SPARKLE_PUBLIC_ED_KEY` to the public EdDSA +key paired with the appcast signing key before enabling runtime update checks. ## Managed update control diff --git a/scripts/build-native-app.sh b/scripts/build-native-app.sh index d88ebff..06ce70e 100755 --- a/scripts/build-native-app.sh +++ b/scripts/build-native-app.sh @@ -13,12 +13,18 @@ RESOURCES_DIR="$CONTENTS_DIR/Resources" PLIST_PATH="$CONTENTS_DIR/Info.plist" EXECUTABLE_PATH="$PACKAGE_DIR/.build/release/$EXECUTABLE_NAME" VERSION="$(awk -F ' = ' '$1 == "version" { gsub(/"/, "", $2); print $2; exit }' "$ROOT_DIR/rust/Cargo.toml")" +PLIST_RENDERER="$ROOT_DIR/scripts/render-native-app-info-plist.sh" if [[ -z "$VERSION" ]]; then echo "Unable to determine APW version from rust/Cargo.toml" >&2 exit 1 fi +if [[ ! -x "$PLIST_RENDERER" ]]; then + echo "Expected Info.plist renderer not found or not executable: $PLIST_RENDERER" >&2 + exit 1 +fi + swift build --package-path "$PACKAGE_DIR" -c release rm -rf "$APP_DIR" @@ -31,32 +37,7 @@ if [[ -n "$RESOURCE_BUNDLE" ]]; then cp -R "$RESOURCE_BUNDLE" "$RESOURCES_DIR/$(basename "$RESOURCE_BUNDLE")" fi -cat >"$PLIST_PATH" < - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - $EXECUTABLE_NAME - CFBundleIdentifier - dev.omt.apw - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - APW - CFBundlePackageType - APPL - CFBundleShortVersionString - $VERSION - CFBundleVersion - $VERSION - LSUIElement - - - -EOF +"$PLIST_RENDERER" "$PLIST_PATH" "$VERSION" "$EXECUTABLE_NAME" if command -v codesign >/dev/null 2>&1; then if ! codesign -s - --force --deep "$APP_DIR" 2>/dev/null; then diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index 4d80def..9f3f8c6 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -41,6 +41,7 @@ while IFS= read -r -d '' script; do done < <(find .github/scripts scripts -type f -name '*.sh' -print0) ./scripts/test-render-homebrew-formula.sh +./scripts/test-render-native-app-info-plist.sh ./scripts/test-prepare-sparkle-appcast.sh ./scripts/ci/validate-appcast-contract.sh ./scripts/test-extended-validation-config.sh diff --git a/scripts/ci/validate-appcast-contract.sh b/scripts/ci/validate-appcast-contract.sh index 7e32c5c..fcb9584 100755 --- a/scripts/ci/validate-appcast-contract.sh +++ b/scripts/ci/validate-appcast-contract.sh @@ -6,6 +6,8 @@ DOC_PATH="$ROOT_DIR/docs/IN_APP_UPDATES.md" TEMPLATE_PATH="$ROOT_DIR/packaging/sparkle/appcast.template.xml" PREPARE_SCRIPT="$ROOT_DIR/scripts/prepare-sparkle-appcast.sh" PREPARE_TEST="$ROOT_DIR/scripts/test-prepare-sparkle-appcast.sh" +PLIST_RENDERER="$ROOT_DIR/scripts/render-native-app-info-plist.sh" +PLIST_RENDERER_TEST="$ROOT_DIR/scripts/test-render-native-app-info-plist.sh" RELEASE_WORKFLOW="$ROOT_DIR/.github/workflows/release.yml" FEED_URL="https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml" MDM_KEY="com.omt.apw.updatesDisabled" @@ -31,6 +33,8 @@ require_file "$DOC_PATH" require_file "$TEMPLATE_PATH" require_file "$PREPARE_SCRIPT" require_file "$PREPARE_TEST" +require_file "$PLIST_RENDERER" +require_file "$PLIST_RENDERER_TEST" require_file "$RELEASE_WORKFLOW" require_pattern "$DOC_PATH" "Sparkle 2" "Sparkle 2 decision" @@ -47,6 +51,7 @@ require_pattern "$DOC_PATH" "sparkle:criticalUpdate" "security update appcast ma require_pattern "$DOC_PATH" "prepare-sparkle-appcast\\.sh" "release appcast preparation helper" require_pattern "$DOC_PATH" "generate_appcast" "Sparkle appcast generation tool" require_pattern "$DOC_PATH" "SPARKLE_GENERATE_APPCAST" "release runner generate_appcast configuration" +require_pattern "$DOC_PATH" "APW_SPARKLE_PUBLIC_ED_KEY" "release runner Sparkle public key configuration" require_pattern "$TEMPLATE_PATH" "xmlns:sparkle=\"http://www\\.andymatuschak\\.org/xml-namespaces/sparkle\"" "Sparkle namespace" require_pattern "$TEMPLATE_PATH" "APW [0-9]+\\.[0-9]+\\.[0-9]+ Security Update" "security update title" @@ -61,8 +66,15 @@ require_pattern "$PREPARE_SCRIPT" "generate_appcast" "Sparkle appcast generation require_pattern "$PREPARE_SCRIPT" "sparkle:edSignature=" "signed appcast output enforcement" require_pattern "$PREPARE_SCRIPT" "Do not pass private keys" "private key handling guardrail" require_pattern "$PREPARE_TEST" "Sparkle appcast preparation test passed" "helper regression test" +require_pattern "$PLIST_RENDERER" "SUFeedURL" "native app Sparkle feed plist key" +require_pattern "$PLIST_RENDERER" "SUPublicEDKey" "native app Sparkle public key plist key" +require_pattern "$PLIST_RENDERER" "SUVerifyUpdateBeforeExtraction" "native app pre-extraction verification plist key" +require_pattern "$PLIST_RENDERER" "SURequireSignedFeed" "native app signed feed plist key" +require_pattern "$PLIST_RENDERER" "APW_SPARKLE_PUBLIC_ED_KEY" "native app public key environment guard" +require_pattern "$PLIST_RENDERER_TEST" "Native app Info.plist renderer test passed" "Info.plist renderer regression test" require_pattern "$RELEASE_WORKFLOW" "prepare-sparkle-appcast\\.sh" "release appcast preparation step" require_pattern "$RELEASE_WORKFLOW" "SPARKLE_GENERATE_APPCAST" "release appcast generator variable" +require_pattern "$RELEASE_WORKFLOW" "APW_SPARKLE_PUBLIC_ED_KEY" "release Sparkle public key variable" require_pattern "$RELEASE_WORKFLOW" "dist/appcast\\.xml" "release appcast asset upload" require_pattern "$RELEASE_WORKFLOW" "APW\\.app-\\$\\{\\{ github\\.ref_name \\}\\}\\.zip" "release Sparkle app archive upload" diff --git a/scripts/render-native-app-info-plist.sh b/scripts/render-native-app-info-plist.sh new file mode 100755 index 0000000..0ead87f --- /dev/null +++ b/scripts/render-native-app-info-plist.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: ./scripts/render-native-app-info-plist.sh OUTPUT VERSION EXECUTABLE_NAME + +Render APW.app's Info.plist. When APW_SPARKLE_PUBLIC_ED_KEY is set, the plist +also includes the Sparkle update keys required by docs/IN_APP_UPDATES.md. +USAGE +} + +if [ "$#" -ne 3 ]; then + usage >&2 + exit 2 +fi + +OUTPUT_PATH="$1" +VERSION="$2" +EXECUTABLE_NAME="$3" +SPARKLE_FEED_URL="https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml" +SPARKLE_PUBLIC_ED_KEY="${APW_SPARKLE_PUBLIC_ED_KEY:-}" + +mkdir -p "$(dirname "$OUTPUT_PATH")" + +{ + cat < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $EXECUTABLE_NAME + CFBundleIdentifier + dev.omt.apw + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + APW + CFBundlePackageType + APPL + CFBundleShortVersionString + $VERSION + CFBundleVersion + $VERSION + LSUIElement + +EOF + + if [ -n "$SPARKLE_PUBLIC_ED_KEY" ]; then + cat <SUFeedURL + $SPARKLE_FEED_URL + SUPublicEDKey + $SPARKLE_PUBLIC_ED_KEY + SUVerifyUpdateBeforeExtraction + + SURequireSignedFeed + + SUEnableAutomaticChecks + + SUAllowsAutomaticUpdates + + SUAutomaticallyUpdate + +EOF + fi + + cat <<'EOF' + + +EOF +} >"$OUTPUT_PATH" diff --git a/scripts/test-render-native-app-info-plist.sh b/scripts/test-render-native-app-info-plist.sh new file mode 100755 index 0000000..39c3aa9 --- /dev/null +++ b/scripts/test-render-native-app-info-plist.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +base_plist="$WORK_DIR/base/Info.plist" +sparkle_plist="$WORK_DIR/sparkle/Info.plist" +feed_url="https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml" + +"$ROOT_DIR/scripts/render-native-app-info-plist.sh" "$base_plist" "9.9.9" "APW" +grep -q "CFBundleShortVersionString" "$base_plist" +grep -q "9.9.9" "$base_plist" +if grep -q "SUPublicEDKey" "$base_plist"; then + echo "Sparkle public key must not be rendered without APW_SPARKLE_PUBLIC_ED_KEY." >&2 + exit 1 +fi + +APW_SPARKLE_PUBLIC_ED_KEY="test-ed25519-public-key" \ + "$ROOT_DIR/scripts/render-native-app-info-plist.sh" "$sparkle_plist" "9.9.9" "APW" + +grep -q "SUFeedURL" "$sparkle_plist" +grep -q "$feed_url" "$sparkle_plist" +grep -q "SUPublicEDKey" "$sparkle_plist" +grep -q "test-ed25519-public-key" "$sparkle_plist" +grep -q "SUVerifyUpdateBeforeExtraction" "$sparkle_plist" +grep -q "SURequireSignedFeed" "$sparkle_plist" +grep -q "SUAllowsAutomaticUpdates" "$sparkle_plist" +grep -q "" "$sparkle_plist" + +if command -v plutil >/dev/null 2>&1; then + plutil -lint "$base_plist" >/dev/null + plutil -lint "$sparkle_plist" >/dev/null +fi + +echo "Native app Info.plist renderer test passed." From 53799e4374de22b0eb47df41cc70733e3069c606 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 04:29:33 +0100 Subject: [PATCH 05/10] Harden native host frame reads Retry interrupted native-host socket reads so transient EINTR does not surface as a disconnected native helper while reading frame headers or payloads. Verification: - cargo fmt --manifest-path rust/Cargo.toml -- --check - cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings - cargo test --manifest-path rust/Cargo.toml daemon::tests::start_daemon_native_routes_requests_and_tracks_host_disconnect - cargo test --manifest-path rust/Cargo.toml - bash scripts/ci/run-fast-checks.sh - git diff --check --- rust/src/daemon.rs | 50 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/rust/src/daemon.rs b/rust/src/daemon.rs index a7a4718..e848eb7 100644 --- a/rust/src/daemon.rs +++ b/rust/src/daemon.rs @@ -15,7 +15,7 @@ use serde_json::{json, Value}; use std::collections::HashMap; use std::fs; use std::future::Future; -use std::io::ErrorKind; +use std::io::{Error as IoError, ErrorKind, Result as IoResult}; #[cfg(target_os = "macos")] use std::os::fd::AsRawFd; #[cfg(unix)] @@ -1870,12 +1870,14 @@ async fn start_browser_daemon_inner(options: DaemonOptions, host: String) -> Res async fn read_native_host_frame(stream: &mut UnixStream) -> Result> { let mut length = [0_u8; 4]; - stream.read_exact(&mut length).await.map_err(|error| { - APWError::new( - Status::ProcessNotRunning, - format!("Failed reading native host frame header: {error}"), - ) - })?; + read_native_host_exact(stream, &mut length) + .await + .map_err(|error| { + APWError::new( + Status::ProcessNotRunning, + format!("Failed reading native host frame header: {error}"), + ) + })?; let payload_length = u32::from_le_bytes(length) as usize; if payload_length == 0 || payload_length > MAX_HELPER_PAYLOAD { return Err(APWError::new( @@ -1885,15 +1887,37 @@ async fn read_native_host_frame(stream: &mut UnixStream) -> Result> { } let mut payload = vec![0_u8; payload_length]; - stream.read_exact(&mut payload).await.map_err(|error| { - APWError::new( - Status::ProcessNotRunning, - format!("Failed reading native host frame: {error}"), - ) - })?; + read_native_host_exact(stream, &mut payload) + .await + .map_err(|error| { + APWError::new( + Status::ProcessNotRunning, + format!("Failed reading native host frame: {error}"), + ) + })?; Ok(payload) } +async fn read_native_host_exact(stream: &mut UnixStream, mut buffer: &mut [u8]) -> IoResult<()> { + while !buffer.is_empty() { + match stream.read(buffer).await { + Ok(0) => { + return Err(IoError::new( + ErrorKind::UnexpectedEof, + "native host stream closed", + )); + } + Ok(bytes_read) => { + let (_, remaining) = buffer.split_at_mut(bytes_read); + buffer = remaining; + } + Err(error) if error.kind() == ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } + Ok(()) +} + async fn write_native_host_frame(stream: &mut UnixStream, payload: &[u8]) -> Result<()> { if payload.len() > MAX_HELPER_PAYLOAD { return Err(APWError::new( From ae2336de568e7676fa2102ed92e4a28e35618f04 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 04:37:12 +0100 Subject: [PATCH 06/10] Stabilize daemon response test reads Retry interrupted UDP receives in the daemon test helper so Linux CI does not fail when recv returns EINTR while waiting for the daemon response. Verification: - cargo fmt --manifest-path rust/Cargo.toml -- --check - cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings - cargo test --manifest-path rust/Cargo.toml daemon::tests::start_daemon_native_routes_requests_and_tracks_host_disconnect - cargo test --manifest-path rust/Cargo.toml - bash scripts/ci/run-fast-checks.sh - git diff --check --- rust/src/daemon.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rust/src/daemon.rs b/rust/src/daemon.rs index e848eb7..6444268 100644 --- a/rust/src/daemon.rs +++ b/rust/src/daemon.rs @@ -2623,7 +2623,13 @@ mod tests { socket.send_to(&payload, ("127.0.0.1", port)).unwrap(); let mut buffer = vec![0_u8; 4096]; - let size = socket.recv(&mut buffer).unwrap(); + let size = loop { + match socket.recv(&mut buffer) { + Ok(size) => break size, + Err(error) if error.kind() == ErrorKind::Interrupted => continue, + Err(error) => panic!("failed receiving daemon response: {error}"), + } + }; serde_json::from_slice(&buffer[..size]).unwrap() } From bc7d435867584d5a30f3eb9dbbe1ff0b808f6fb4 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 04:48:49 +0100 Subject: [PATCH 07/10] Report managed in-app update policy Wire the documented Sparkle managed-update disable key into APW.app status and doctor payloads so the runtime can enforce enterprise update policy before enabling Sparkle checks. Also silence the AuthenticationServices error mapping warning by retaining a stable unknown fallback for newer SDK cases. Verification: - swift test --package-path native-app - bash scripts/build-native-app.sh - bash scripts/ci/validate-appcast-contract.sh - bash scripts/ci/run-fast-checks.sh - git diff --check --- docs/IN_APP_UPDATES.md | 7 +-- .../AuthenticationServicesBroker.swift | 2 +- .../Sources/NativeAppLib/BrokerCore.swift | 22 +++++++- .../NativeAppTests/BrokerCoreTests.swift | 54 ++++++++++++++++++- scripts/ci/validate-appcast-contract.sh | 8 +++ 5 files changed, 86 insertions(+), 7 deletions(-) diff --git a/docs/IN_APP_UPDATES.md b/docs/IN_APP_UPDATES.md index 1f3237a..a32d203 100644 --- a/docs/IN_APP_UPDATES.md +++ b/docs/IN_APP_UPDATES.md @@ -117,9 +117,10 @@ manual user-driven update checks. The broker should still report its installed version through `apw status --json` and `apw doctor --json` so fleet tooling can inventory stale installations. -This managed key is part of the APW configuration contract and should be wired -through the managed-config roadmap work before the updater runtime is enabled -by default. +APW.app reads this managed key at runtime and reports the current +`updatesDisabled` state plus the configured feed URL in the `inAppUpdates` +status payload. Sparkle startup must consult this same helper before automatic +or manual checks are enabled. ## Security update surfacing diff --git a/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift b/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift index 76f28fe..0e8c171 100644 --- a/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift +++ b/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift @@ -154,7 +154,7 @@ public protocol CredentialBroker { return .notHandled case .unknown: return .unknown - @unknown default: + default: return .unknown } } diff --git a/native-app/Sources/NativeAppLib/BrokerCore.swift b/native-app/Sources/NativeAppLib/BrokerCore.swift index d04e461..8f0974a 100644 --- a/native-app/Sources/NativeAppLib/BrokerCore.swift +++ b/native-app/Sources/NativeAppLib/BrokerCore.swift @@ -9,6 +9,9 @@ private let maxBrokerBytes = 32 * 1024 private let appSocketName = "broker.sock" private let statusFileName = "status.json" private let credentialsFileName = "credentials.json" +let appcastFeedURL = "https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml" +let managedUpdatePreferenceDomain = "dev.omt.apw" +let updatesDisabledPreferenceKey = "com.omt.apw.updatesDisabled" /// Wall-clock timeout for a single broker IPC exchange (read or write half) /// between the Swift app broker and the Rust CLI. The Rust client mirrors @@ -51,6 +54,13 @@ let demoEnvVar = "APW_DEMO" func demoModeEnabled() -> Bool { ProcessInfo.processInfo.environment[demoEnvVar] == "1" } + +func managedUpdatesDisabled( + defaults: UserDefaults = UserDefaults(suiteName: managedUpdatePreferenceDomain) ?? .standard +) -> Bool { + defaults.bool(forKey: updatesDisabledPreferenceKey) +} + protocol ApprovalPrompter { func prompt(url: String, username: String) -> Bool } @@ -272,15 +282,19 @@ final class BrokerServer { private let startedAt = ISO8601DateFormatter().string(from: Date()) private let approvalPrompter: ApprovalPrompter private let credentialBroker: CredentialBroker? + private let updatePolicyDefaults: UserDefaults init( paths: AppPaths, approvalPrompter: ApprovalPrompter = SystemApprovalPrompter(), - credentialBroker: CredentialBroker? = defaultCredentialBroker() + credentialBroker: CredentialBroker? = defaultCredentialBroker(), + updatePolicyDefaults: UserDefaults = + UserDefaults(suiteName: managedUpdatePreferenceDomain) ?? .standard ) { self.paths = paths self.approvalPrompter = approvalPrompter self.credentialBroker = credentialBroker + self.updatePolicyDefaults = updatePolicyDefaults } func run() throws -> Never { @@ -395,6 +409,12 @@ final class BrokerServer { "socketPath": paths.socketPath.path, "supportedDomains": supportedDomains(), "authenticationServicesLinked": true, + "inAppUpdates": [ + "feedURL": appcastFeedURL, + "managedPreferenceDomain": managedUpdatePreferenceDomain, + "managedDisableKey": updatesDisabledPreferenceKey, + "updatesDisabled": managedUpdatesDisabled(defaults: updatePolicyDefaults), + ], ] } diff --git a/native-app/Tests/NativeAppTests/BrokerCoreTests.swift b/native-app/Tests/NativeAppTests/BrokerCoreTests.swift index ad42d85..8c3c604 100644 --- a/native-app/Tests/NativeAppTests/BrokerCoreTests.swift +++ b/native-app/Tests/NativeAppTests/BrokerCoreTests.swift @@ -36,15 +36,24 @@ final class BrokerCoreTests: XCTestCase { private func makeServer( root: URL, decision: Bool = true, - credentialBroker: CredentialBroker? = nil + credentialBroker: CredentialBroker? = nil, + updatePolicyDefaults: UserDefaults = .standard ) -> BrokerServer { BrokerServer( paths: makePaths(root), approvalPrompter: StubApprovalPrompter(decision: decision), - credentialBroker: credentialBroker + credentialBroker: credentialBroker, + updatePolicyDefaults: updatePolicyDefaults ) } + private func makeUpdatePolicyDefaults() throws -> UserDefaults { + let suiteName = "dev.omt.apw.tests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + private func writeCredentials( at path: URL, mode: Int = 0o600, @@ -244,6 +253,47 @@ final class BrokerCoreTests: XCTestCase { XCTAssertFalse(guidance?.contains(where: { $0.contains("APW_NATIVE_APP_AUTO_APPROVE") }) ?? true) } + func testStatusReportsManagedInAppUpdatePolicy() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let defaults = try makeUpdatePolicyDefaults() + defaults.set(true, forKey: updatesDisabledPreferenceKey) + let server = makeServer(root: root, updatePolicyDefaults: defaults) + + let response = try server.dispatch(request: RequestEnvelope( + requestId: "status", + command: "status", + payload: nil + )) + let updates = try XCTUnwrap(response.payload?["inAppUpdates"]?.value as? [String: Any]) + + XCTAssertEqual(updates["feedURL"] as? String, appcastFeedURL) + XCTAssertEqual(updates["managedPreferenceDomain"] as? String, managedUpdatePreferenceDomain) + XCTAssertEqual(updates["managedDisableKey"] as? String, updatesDisabledPreferenceKey) + XCTAssertEqual(updates["updatesDisabled"] as? Bool, true) + } + + func testDoctorReportsManagedInAppUpdatePolicy() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let defaults = try makeUpdatePolicyDefaults() + defaults.set(true, forKey: updatesDisabledPreferenceKey) + let server = makeServer(root: root, updatePolicyDefaults: defaults) + + let payload = server.doctorPayload() + let broker = try XCTUnwrap(payload["broker"] as? [String: Any]) + let updates = try XCTUnwrap(broker["inAppUpdates"] as? [String: Any]) + + XCTAssertEqual(updates["updatesDisabled"] as? Bool, true) + XCTAssertEqual(updates["managedDisableKey"] as? String, updatesDisabledPreferenceKey) + } + + func testManagedUpdatesDefaultToEnabledWhenPreferenceUnset() throws { + let defaults = try makeUpdatePolicyDefaults() + + XCTAssertEqual(managedUpdatesDisabled(defaults: defaults), false) + } + // MARK: - AuthenticationServices broker routing (issue #13) func testLoginRoutesToCredentialBrokerOnSuccess() throws { diff --git a/scripts/ci/validate-appcast-contract.sh b/scripts/ci/validate-appcast-contract.sh index fcb9584..82dd0c2 100755 --- a/scripts/ci/validate-appcast-contract.sh +++ b/scripts/ci/validate-appcast-contract.sh @@ -9,8 +9,10 @@ PREPARE_TEST="$ROOT_DIR/scripts/test-prepare-sparkle-appcast.sh" PLIST_RENDERER="$ROOT_DIR/scripts/render-native-app-info-plist.sh" PLIST_RENDERER_TEST="$ROOT_DIR/scripts/test-render-native-app-info-plist.sh" RELEASE_WORKFLOW="$ROOT_DIR/.github/workflows/release.yml" +BROKER_CORE="$ROOT_DIR/native-app/Sources/NativeAppLib/BrokerCore.swift" FEED_URL="https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml" MDM_KEY="com.omt.apw.updatesDisabled" +MDM_DOMAIN="dev.omt.apw" require_file() { if [ ! -f "$1" ]; then @@ -36,6 +38,7 @@ require_file "$PREPARE_TEST" require_file "$PLIST_RENDERER" require_file "$PLIST_RENDERER_TEST" require_file "$RELEASE_WORKFLOW" +require_file "$BROKER_CORE" require_pattern "$DOC_PATH" "Sparkle 2" "Sparkle 2 decision" require_pattern "$DOC_PATH" "$FEED_URL" "stable project-controlled feed URL" @@ -44,6 +47,7 @@ require_pattern "$DOC_PATH" "SUPublicEDKey" "Sparkle EdDSA public key" require_pattern "$DOC_PATH" "SUVerifyUpdateBeforeExtraction=true" "pre-extraction update verification" require_pattern "$DOC_PATH" "SURequireSignedFeed=true" "signed appcast enforcement" require_pattern "$DOC_PATH" "$MDM_KEY" "managed preference to disable updates" +require_pattern "$DOC_PATH" "$MDM_DOMAIN" "managed preference domain" require_pattern "$DOC_PATH" "codesign --deep --strict --verify APW\\.app" "codesign release gate" require_pattern "$DOC_PATH" "spctl --assess --type execute --verbose APW\\.app" "Gatekeeper release gate" require_pattern "$DOC_PATH" "xcrun stapler validate APW\\.app" "notarization staple release gate" @@ -72,6 +76,10 @@ require_pattern "$PLIST_RENDERER" "SUVerifyUpdateBeforeExtraction" "native app p require_pattern "$PLIST_RENDERER" "SURequireSignedFeed" "native app signed feed plist key" require_pattern "$PLIST_RENDERER" "APW_SPARKLE_PUBLIC_ED_KEY" "native app public key environment guard" require_pattern "$PLIST_RENDERER_TEST" "Native app Info.plist renderer test passed" "Info.plist renderer regression test" +require_pattern "$BROKER_CORE" "$MDM_DOMAIN" "native app managed preference domain" +require_pattern "$BROKER_CORE" "$MDM_KEY" "native app managed disable key" +require_pattern "$BROKER_CORE" "managedUpdatesDisabled" "native app managed update policy helper" +require_pattern "$BROKER_CORE" "inAppUpdates" "native app update policy status payload" require_pattern "$RELEASE_WORKFLOW" "prepare-sparkle-appcast\\.sh" "release appcast preparation step" require_pattern "$RELEASE_WORKFLOW" "SPARKLE_GENERATE_APPCAST" "release appcast generator variable" require_pattern "$RELEASE_WORKFLOW" "APW_SPARKLE_PUBLIC_ED_KEY" "release Sparkle public key variable" From 9858ed5a7c76843a512f55652f5d2e065fba42a1 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 05:04:06 +0100 Subject: [PATCH 08/10] Link Sparkle update runtime Add Sparkle as the native app update framework, start SPUStandardUpdaterController behind the managed update-disable policy, and package Sparkle.framework into APW.app with the correct runtime search path. Verification: - swift test --package-path native-app - bash scripts/build-native-app.sh - native-app/dist/APW.app/Contents/MacOS/APW doctor - test -d native-app/dist/APW.app/Contents/Frameworks/Sparkle.framework && otool -l native-app/dist/APW.app/Contents/MacOS/APW | rg -q '@loader_path/\.\./Frameworks' - codesign --verify --deep --strict --verbose=2 native-app/dist/APW.app - bash scripts/ci/run-fast-checks.sh - git diff --check --- docs/IN_APP_UPDATES.md | 5 +- native-app/Package.resolved | 14 ++++ native-app/Package.swift | 6 ++ .../Sources/NativeAppLib/BrokerCore.swift | 18 +++-- .../NativeAppLib/InAppUpdateRuntime.swift | 70 +++++++++++++++++++ .../NativeAppTests/BrokerCoreTests.swift | 42 ++++++++++- scripts/build-native-app.sh | 20 ++++++ scripts/ci/validate-appcast-contract.sh | 14 ++++ 8 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 native-app/Package.resolved create mode 100644 native-app/Sources/NativeAppLib/InAppUpdateRuntime.swift diff --git a/docs/IN_APP_UPDATES.md b/docs/IN_APP_UPDATES.md index a32d203..395887d 100644 --- a/docs/IN_APP_UPDATES.md +++ b/docs/IN_APP_UPDATES.md @@ -119,8 +119,9 @@ inventory stale installations. APW.app reads this managed key at runtime and reports the current `updatesDisabled` state plus the configured feed URL in the `inAppUpdates` -status payload. Sparkle startup must consult this same helper before automatic -or manual checks are enabled. +status payload. APW.app links Sparkle through Swift Package Manager and starts +`SPUStandardUpdaterController` only after this managed policy allows update +checks. ## Security update surfacing diff --git a/native-app/Package.resolved b/native-app/Package.resolved new file mode 100644 index 0000000..d4c2188 --- /dev/null +++ b/native-app/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b", + "version" : "2.9.2" + } + } + ], + "version" : 2 +} diff --git a/native-app/Package.swift b/native-app/Package.swift index 5121c38..f2b16f5 100644 --- a/native-app/Package.swift +++ b/native-app/Package.swift @@ -11,9 +11,15 @@ let package = Package( .library(name: "NativeAppLib", targets: ["NativeAppLib"]), .executable(name: "APW", targets: ["APW"]), ], + dependencies: [ + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"), + ], targets: [ .target( name: "NativeAppLib", + dependencies: [ + .product(name: "Sparkle", package: "Sparkle"), + ], path: "Sources/NativeAppLib", resources: [ .process("Resources"), diff --git a/native-app/Sources/NativeAppLib/BrokerCore.swift b/native-app/Sources/NativeAppLib/BrokerCore.swift index 8f0974a..8cc1af2 100644 --- a/native-app/Sources/NativeAppLib/BrokerCore.swift +++ b/native-app/Sources/NativeAppLib/BrokerCore.swift @@ -56,7 +56,7 @@ func demoModeEnabled() -> Bool { } func managedUpdatesDisabled( - defaults: UserDefaults = UserDefaults(suiteName: managedUpdatePreferenceDomain) ?? .standard + defaults: UserDefaults = .standard ) -> Bool { defaults.bool(forKey: updatesDisabledPreferenceKey) } @@ -283,18 +283,20 @@ final class BrokerServer { private let approvalPrompter: ApprovalPrompter private let credentialBroker: CredentialBroker? private let updatePolicyDefaults: UserDefaults + private let updateRuntime: InAppUpdateRuntime init( paths: AppPaths, approvalPrompter: ApprovalPrompter = SystemApprovalPrompter(), credentialBroker: CredentialBroker? = defaultCredentialBroker(), - updatePolicyDefaults: UserDefaults = - UserDefaults(suiteName: managedUpdatePreferenceDomain) ?? .standard + updatePolicyDefaults: UserDefaults = .standard, + updateRuntime: InAppUpdateRuntime? = nil ) { self.paths = paths self.approvalPrompter = approvalPrompter self.credentialBroker = credentialBroker self.updatePolicyDefaults = updatePolicyDefaults + self.updateRuntime = updateRuntime ?? APWInAppUpdateRuntime(defaults: updatePolicyDefaults) } func run() throws -> Never { @@ -311,7 +313,7 @@ final class BrokerServer { "serviceStatus": "running", "pid": getpid(), "transport": "unix_socket", - ]) + ].merging(startUpdateRuntimeStatus(), uniquingKeysWith: { _, new in new })) while true { let client = accept(descriptor, nil, nil) @@ -653,7 +655,13 @@ final class BrokerServer { } } - private func writeStatus(extra: [String: Any]) throws { + func startUpdateRuntimeStatus() -> [String: Any] { + [ + "updateRuntimeState": updateRuntime.startIfAllowed().rawValue, + ] + } + + func writeStatus(extra: [String: Any]) throws { var payload = statusPayload() for (key, value) in extra { payload[key] = value diff --git a/native-app/Sources/NativeAppLib/InAppUpdateRuntime.swift b/native-app/Sources/NativeAppLib/InAppUpdateRuntime.swift new file mode 100644 index 0000000..bbceee2 --- /dev/null +++ b/native-app/Sources/NativeAppLib/InAppUpdateRuntime.swift @@ -0,0 +1,70 @@ +import Foundation + +#if canImport(Sparkle) + import Sparkle +#endif + +enum InAppUpdateRuntimeState: String { + case disabledByManagedPolicy = "disabled_by_managed_policy" + case sparkleUnavailable = "sparkle_unavailable" + case starting = "starting" +} + +protocol InAppUpdateRuntime { + func startIfAllowed() -> InAppUpdateRuntimeState +} + +final class APWInAppUpdateRuntime: InAppUpdateRuntime { + private let defaults: UserDefaults + + init( + defaults: UserDefaults = .standard + ) { + self.defaults = defaults + } + + func startIfAllowed() -> InAppUpdateRuntimeState { + guard !managedUpdatesDisabled(defaults: defaults) else { + return .disabledByManagedPolicy + } + + #if canImport(Sparkle) + SparkleRuntimeController.shared.start() + return .starting + #else + return .sparkleUnavailable + #endif + } +} + +#if canImport(Sparkle) + private final class SparkleRuntimeController { + static let shared = SparkleRuntimeController() + + @MainActor private var updaterController: SPUStandardUpdaterController? + + private init() {} + + func start() { + if Thread.isMainThread { + MainActor.assumeIsolated { + self.startOnMainActor() + } + } else { + DispatchQueue.main.async { + self.startOnMainActor() + } + } + } + + @MainActor private func startOnMainActor() { + if updaterController == nil { + updaterController = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) + } + } + } +#endif diff --git a/native-app/Tests/NativeAppTests/BrokerCoreTests.swift b/native-app/Tests/NativeAppTests/BrokerCoreTests.swift index 8c3c604..edd8e08 100644 --- a/native-app/Tests/NativeAppTests/BrokerCoreTests.swift +++ b/native-app/Tests/NativeAppTests/BrokerCoreTests.swift @@ -19,6 +19,20 @@ private struct StubCredentialBroker: CredentialBroker { } } +private final class StubUpdateRuntime: InAppUpdateRuntime { + private(set) var startCount = 0 + let state: InAppUpdateRuntimeState + + init(state: InAppUpdateRuntimeState = .starting) { + self.state = state + } + + func startIfAllowed() -> InAppUpdateRuntimeState { + startCount += 1 + return state + } +} + final class BrokerCoreTests: XCTestCase { private func makePaths(_ root: URL) -> AppPaths { AppPaths( @@ -37,13 +51,15 @@ final class BrokerCoreTests: XCTestCase { root: URL, decision: Bool = true, credentialBroker: CredentialBroker? = nil, - updatePolicyDefaults: UserDefaults = .standard + updatePolicyDefaults: UserDefaults = .standard, + updateRuntime: InAppUpdateRuntime? = nil ) -> BrokerServer { BrokerServer( paths: makePaths(root), approvalPrompter: StubApprovalPrompter(decision: decision), credentialBroker: credentialBroker, - updatePolicyDefaults: updatePolicyDefaults + updatePolicyDefaults: updatePolicyDefaults, + updateRuntime: updateRuntime ) } @@ -294,6 +310,28 @@ final class BrokerCoreTests: XCTestCase { XCTAssertEqual(managedUpdatesDisabled(defaults: defaults), false) } + func testRunStartsUpdateRuntimeAndPersistsState() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let updateRuntime = StubUpdateRuntime(state: .starting) + let server = makeServer(root: root, updateRuntime: updateRuntime) + + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true, + attributes: nil + ) + try server.writeStatus(extra: [ + "serviceStatus": "running", + ].merging(server.startUpdateRuntimeStatus(), uniquingKeysWith: { _, new in new })) + + let data = try Data(contentsOf: makePaths(root).statusPath) + let payload = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertEqual(updateRuntime.startCount, 1) + XCTAssertEqual(payload["updateRuntimeState"] as? String, "starting") + } + // MARK: - AuthenticationServices broker routing (issue #13) func testLoginRoutesToCredentialBrokerOnSuccess() throws { diff --git a/scripts/build-native-app.sh b/scripts/build-native-app.sh index 06ce70e..5fedd26 100755 --- a/scripts/build-native-app.sh +++ b/scripts/build-native-app.sh @@ -10,6 +10,7 @@ APP_DIR="$DIST_DIR/$APP_NAME" CONTENTS_DIR="$APP_DIR/Contents" MACOS_DIR="$CONTENTS_DIR/MacOS" RESOURCES_DIR="$CONTENTS_DIR/Resources" +FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks" PLIST_PATH="$CONTENTS_DIR/Info.plist" EXECUTABLE_PATH="$PACKAGE_DIR/.build/release/$EXECUTABLE_NAME" VERSION="$(awk -F ' = ' '$1 == "version" { gsub(/"/, "", $2); print $2; exit }' "$ROOT_DIR/rust/Cargo.toml")" @@ -37,6 +38,25 @@ if [[ -n "$RESOURCE_BUNDLE" ]]; then cp -R "$RESOURCE_BUNDLE" "$RESOURCES_DIR/$(basename "$RESOURCE_BUNDLE")" fi +if otool -L "$MACOS_DIR/$EXECUTABLE_NAME" | grep -q '@rpath/Sparkle.framework/'; then + SPARKLE_FRAMEWORK="$(find "$PACKAGE_DIR/.build" -path '*/release/Sparkle.framework' -type d | head -n 1 || true)" + if [[ -z "$SPARKLE_FRAMEWORK" ]]; then + echo "APW links Sparkle.framework but SwiftPM did not produce a release framework." >&2 + exit 1 + fi + mkdir -p "$FRAMEWORKS_DIR" + if command -v ditto >/dev/null 2>&1; then + ditto "$SPARKLE_FRAMEWORK" "$FRAMEWORKS_DIR/Sparkle.framework" + else + cp -R "$SPARKLE_FRAMEWORK" "$FRAMEWORKS_DIR/" + fi + if command -v install_name_tool >/dev/null 2>&1; then + if ! otool -l "$MACOS_DIR/$EXECUTABLE_NAME" | grep -q '@loader_path/../Frameworks'; then + install_name_tool -add_rpath '@loader_path/../Frameworks' "$MACOS_DIR/$EXECUTABLE_NAME" + fi + fi +fi + "$PLIST_RENDERER" "$PLIST_PATH" "$VERSION" "$EXECUTABLE_NAME" if command -v codesign >/dev/null 2>&1; then diff --git a/scripts/ci/validate-appcast-contract.sh b/scripts/ci/validate-appcast-contract.sh index 82dd0c2..0251ef5 100755 --- a/scripts/ci/validate-appcast-contract.sh +++ b/scripts/ci/validate-appcast-contract.sh @@ -10,6 +10,9 @@ PLIST_RENDERER="$ROOT_DIR/scripts/render-native-app-info-plist.sh" PLIST_RENDERER_TEST="$ROOT_DIR/scripts/test-render-native-app-info-plist.sh" RELEASE_WORKFLOW="$ROOT_DIR/.github/workflows/release.yml" BROKER_CORE="$ROOT_DIR/native-app/Sources/NativeAppLib/BrokerCore.swift" +UPDATE_RUNTIME="$ROOT_DIR/native-app/Sources/NativeAppLib/InAppUpdateRuntime.swift" +NATIVE_PACKAGE="$ROOT_DIR/native-app/Package.swift" +BUILD_NATIVE_APP="$ROOT_DIR/scripts/build-native-app.sh" FEED_URL="https://github.com/OMT-Global/apw-cli/releases/latest/download/appcast.xml" MDM_KEY="com.omt.apw.updatesDisabled" MDM_DOMAIN="dev.omt.apw" @@ -39,6 +42,9 @@ require_file "$PLIST_RENDERER" require_file "$PLIST_RENDERER_TEST" require_file "$RELEASE_WORKFLOW" require_file "$BROKER_CORE" +require_file "$UPDATE_RUNTIME" +require_file "$NATIVE_PACKAGE" +require_file "$BUILD_NATIVE_APP" require_pattern "$DOC_PATH" "Sparkle 2" "Sparkle 2 decision" require_pattern "$DOC_PATH" "$FEED_URL" "stable project-controlled feed URL" @@ -56,6 +62,7 @@ require_pattern "$DOC_PATH" "prepare-sparkle-appcast\\.sh" "release appcast prep require_pattern "$DOC_PATH" "generate_appcast" "Sparkle appcast generation tool" require_pattern "$DOC_PATH" "SPARKLE_GENERATE_APPCAST" "release runner generate_appcast configuration" require_pattern "$DOC_PATH" "APW_SPARKLE_PUBLIC_ED_KEY" "release runner Sparkle public key configuration" +require_pattern "$DOC_PATH" "SPUStandardUpdaterController" "runtime Sparkle updater controller" require_pattern "$TEMPLATE_PATH" "xmlns:sparkle=\"http://www\\.andymatuschak\\.org/xml-namespaces/sparkle\"" "Sparkle namespace" require_pattern "$TEMPLATE_PATH" "APW [0-9]+\\.[0-9]+\\.[0-9]+ Security Update" "security update title" @@ -80,6 +87,13 @@ require_pattern "$BROKER_CORE" "$MDM_DOMAIN" "native app managed preference doma require_pattern "$BROKER_CORE" "$MDM_KEY" "native app managed disable key" require_pattern "$BROKER_CORE" "managedUpdatesDisabled" "native app managed update policy helper" require_pattern "$BROKER_CORE" "inAppUpdates" "native app update policy status payload" +require_pattern "$BROKER_CORE" "startUpdateRuntimeStatus" "native app Sparkle startup status" +require_pattern "$UPDATE_RUNTIME" "SPUStandardUpdaterController" "native app Sparkle controller startup" +require_pattern "$UPDATE_RUNTIME" "managedUpdatesDisabled" "native app Sparkle managed policy guard" +require_pattern "$NATIVE_PACKAGE" "https://github\\.com/sparkle-project/Sparkle" "Sparkle SwiftPM dependency" +require_pattern "$NATIVE_PACKAGE" "product\\(name: \"Sparkle\"" "Sparkle target product" +require_pattern "$BUILD_NATIVE_APP" "Sparkle\\.framework" "native app Sparkle framework embedding" +require_pattern "$BUILD_NATIVE_APP" "@loader_path/\\.\\./Frameworks" "native app Sparkle runtime search path" require_pattern "$RELEASE_WORKFLOW" "prepare-sparkle-appcast\\.sh" "release appcast preparation step" require_pattern "$RELEASE_WORKFLOW" "SPARKLE_GENERATE_APPCAST" "release appcast generator variable" require_pattern "$RELEASE_WORKFLOW" "APW_SPARKLE_PUBLIC_ED_KEY" "release Sparkle public key variable" From b5c3b389800213a2444d0326c3ac7ffed6c61226 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 06:12:29 +0100 Subject: [PATCH 09/10] Tighten Sparkle appcast publication checks --- scripts/prepare-sparkle-appcast.sh | 14 ++++++- scripts/test-prepare-sparkle-appcast.sh | 55 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/scripts/prepare-sparkle-appcast.sh b/scripts/prepare-sparkle-appcast.sh index 332efb9..866110d 100755 --- a/scripts/prepare-sparkle-appcast.sh +++ b/scripts/prepare-sparkle-appcast.sh @@ -97,8 +97,18 @@ cp "$RELEASE_NOTES_PATH" "$UPDATES_DIR/$archive_name.md" appcast_path="$UPDATES_DIR/$feed_file" [ -f "$appcast_path" ] || fail "generate_appcast did not create $appcast_path" -if ! grep -q 'sparkle:edSignature=' "$appcast_path"; then - fail "$appcast_path does not contain Sparkle EdDSA signatures" +if ! grep -Eq ']*sparkle:edSignature=' "$appcast_path"; then + fail "$appcast_path does not contain a signed Sparkle update enclosure" +fi + +if grep -q 'sparkle:releaseNotesLink' "$appcast_path" && + ! grep -Eq 'sparkle:releaseNotesLink[^>]*sparkle:edSignature=' "$appcast_path"; then + fail "$appcast_path contains unsigned Sparkle release notes" +fi + +if grep -q '"$updates_dir/appcast.xml" < APW 2.0.0 Security Update + https://github.com/OMT-Global/apw-cli/releases/tag/v2.0.0 + @@ -48,5 +50,58 @@ chmod +x "$fake_generate" [ -f "$updates/APW.app.zip.md" ] [ -f "$updates/appcast.xml" ] grep -q 'sparkle:edSignature="signed"' "$updates/appcast.xml" +grep -q 'sparkle:edSignature="notes-signed"' "$updates/appcast.xml" + +unsigned_notes_generate="$WORK_DIR/generate_unsigned_notes_appcast" +cat >"$unsigned_notes_generate" <<'FAKE' +#!/usr/bin/env bash +set -euo pipefail + +updates_dir="$1" +cat >"$updates_dir/appcast.xml" < + + + + APW 2.0.0 Security Update + https://github.com/OMT-Global/apw-cli/releases/tag/v2.0.0 + + + + +XML +FAKE +chmod +x "$unsigned_notes_generate" + +if "$ROOT_DIR/scripts/prepare-sparkle-appcast.sh" \ + --archive "$archive" \ + --release-notes "$notes" \ + --updates-dir "$WORK_DIR/unsigned-notes" \ + --generate-appcast "$unsigned_notes_generate" \ + >"$WORK_DIR/unsigned-notes.out" 2>"$WORK_DIR/unsigned-notes.err"; then + echo "prepare-sparkle-appcast accepted unsigned release notes." >&2 + exit 1 +fi +grep -q "unsigned Sparkle release notes" "$WORK_DIR/unsigned-notes.err" + +missing_security_notes="$WORK_DIR/APW.app.no-security.md" +cat >"$missing_security_notes" <<'NOTES' +# APW 2.0.0 Update + +## Changes + +- Missing the security section required for critical updates. +NOTES + +if "$ROOT_DIR/scripts/prepare-sparkle-appcast.sh" \ + --archive "$archive" \ + --release-notes "$missing_security_notes" \ + --updates-dir "$WORK_DIR/missing-security" \ + --generate-appcast "$fake_generate" \ + >"$WORK_DIR/missing-security.out" 2>"$WORK_DIR/missing-security.err"; then + echo "prepare-sparkle-appcast accepted critical update notes without a Security section." >&2 + exit 1 +fi +grep -q "critical Sparkle updates require a Security section" "$WORK_DIR/missing-security.err" echo "Sparkle appcast preparation test passed." From 11ab36642bd7d36b0d0c1975ab08d64a3dfb97ab Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 07:04:19 +0100 Subject: [PATCH 10/10] Validate appcast enclosure attributes Parse the Sparkle template XML and verify the archive URL, EdDSA signature, and length directly on the enclosure element so unrelated attributes cannot satisfy the contract. --- scripts/ci/validate-appcast-contract.sh | 29 ++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/scripts/ci/validate-appcast-contract.sh b/scripts/ci/validate-appcast-contract.sh index 0251ef5..b609cfa 100755 --- a/scripts/ci/validate-appcast-contract.sh +++ b/scripts/ci/validate-appcast-contract.sh @@ -69,9 +69,32 @@ require_pattern "$TEMPLATE_PATH" "APW [0-9]+\\.[0-9]+\\.[0-9]+ Security U require_pattern "$TEMPLATE_PATH" "<sparkle:version>[0-9]+\\.[0-9]+\\.[0-9]+</sparkle:version>" "machine version" require_pattern "$TEMPLATE_PATH" "sparkle:releaseNotesLink sparkle:edSignature=" "signed release notes link" require_pattern "$TEMPLATE_PATH" "<sparkle:criticalUpdate" "critical update marker" -require_pattern "$TEMPLATE_PATH" "url=\"https://github\\.com/OMT-Global/apw-cli/releases/download/v[0-9]+\\.[0-9]+\\.[0-9]+/APW\\.app\\.zip\"" "release archive URL" -require_pattern "$TEMPLATE_PATH" "sparkle:edSignature=" "signed archive enclosure" -require_pattern "$TEMPLATE_PATH" "length=\"[0-9]+\"" "archive length" +python3 - "$TEMPLATE_PATH" <<'PY' +import re +import sys +import xml.etree.ElementTree as ET + +sparkle = "{http://www.andymatuschak.org/xml-namespaces/sparkle}" +template = sys.argv[1] +root = ET.parse(template).getroot() +enclosure = root.find("./channel/item/enclosure") +if enclosure is None: + raise SystemExit(f"Missing appcast contract requirement in {template}: archive enclosure") + +url = enclosure.get("url", "") +if not re.fullmatch( + r"https://github\.com/OMT-Global/apw-cli/releases/download/v[0-9]+\.[0-9]+\.[0-9]+/APW\.app\.zip", + url, +): + raise SystemExit(f"Missing appcast contract requirement in {template}: release archive URL") + +if not enclosure.get(f"{sparkle}edSignature"): + raise SystemExit(f"Missing appcast contract requirement in {template}: signed archive enclosure") + +length = enclosure.get("length", "") +if not re.fullmatch(r"[0-9]+", length): + raise SystemExit(f"Missing appcast contract requirement in {template}: archive length") +PY require_pattern "$PREPARE_SCRIPT" "generate_appcast" "Sparkle appcast generation invocation" require_pattern "$PREPARE_SCRIPT" "sparkle:edSignature=" "signed appcast output enforcement"