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
42 changes: 42 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -142,6 +146,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"
Expand All @@ -155,6 +189,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
Expand Down
7 changes: 7 additions & 0 deletions docs/INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

APW uses a **formula-plus-app-install** Homebrew model for the v2 line. The
Expand Down Expand Up @@ -235,6 +241,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:
Expand Down
152 changes: 152 additions & 0 deletions docs/IN_APP_UPDATES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# 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.
`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
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=<release Sparkle EdDSA public key>
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.

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.

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.
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

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.

APW.app reads this managed key at runtime and reports the current
`updatesDisabled` state plus the configured feed URL in the `inAppUpdates`
status payload. APW.app links Sparkle through Swift Package Manager and starts
`SPUStandardUpdaterController` only after this managed policy allows update
checks.

## 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 <version> 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
./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, Sparkle security settings, or appcast
preparation helper fail before release automation drifts.
7 changes: 7 additions & 0 deletions docs/SECURITY_POSTURE_AND_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
- external fallback provider path hardening, including relative paths, `~`, world-writable
executables, and symlink targets
- diagnostic-bundle redaction and fail-closed aborts when staged diagnostics look
Expand Down
14 changes: 14 additions & 0 deletions native-app/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions native-app/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ public protocol CredentialBroker {
return .notHandled
case .unknown:
return .unknown
@unknown default:
default:
return .unknown
}
}
Expand Down
34 changes: 31 additions & 3 deletions native-app/Sources/NativeAppLib/BrokerCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -51,6 +54,13 @@ let demoEnvVar = "APW_DEMO"
func demoModeEnabled() -> Bool {
ProcessInfo.processInfo.environment[demoEnvVar] == "1"
}

func managedUpdatesDisabled(
defaults: UserDefaults = .standard
) -> Bool {
defaults.bool(forKey: updatesDisabledPreferenceKey)
}

protocol ApprovalPrompter {
func prompt(url: String, username: String) -> Bool
}
Expand Down Expand Up @@ -272,15 +282,21 @@ final class BrokerServer {
private let startedAt = ISO8601DateFormatter().string(from: Date())
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()
credentialBroker: CredentialBroker? = defaultCredentialBroker(),
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 {
Expand All @@ -297,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)
Expand Down Expand Up @@ -395,6 +411,12 @@ final class BrokerServer {
"socketPath": paths.socketPath.path,
"supportedDomains": supportedDomains(),
"authenticationServicesLinked": true,
"inAppUpdates": [
"feedURL": appcastFeedURL,
"managedPreferenceDomain": managedUpdatePreferenceDomain,
"managedDisableKey": updatesDisabledPreferenceKey,
"updatesDisabled": managedUpdatesDisabled(defaults: updatePolicyDefaults),
],
]
}

Expand Down Expand Up @@ -633,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
Expand Down
Loading
Loading