MacOSUpdater is the initial Swift Package Manager foundation for the
MarlonJD/macos-updater-swift repository. It provides reusable manifest,
release-tooling, and installer-planning primitives for a native macOS updater
without Sparkle or third-party updater frameworks.
MacOSUpdaterManifest: signed manifest models, P-256 ECDSA manifest signing and verification helpers, SHA-256 hashing, compressed payload metadata, and SemVer/build-number release state models.MacOSUpdaterRelease: release-side helpers for Conventional Commits changelog drafts and S3/CloudFront key planning with dry-run output.MacOSUpdaterCore: client-side policy and installer dry-run planning primitives that remain independent of the host app.macos-updater-release: local release utility for changelog drafts and distribution key dry-runs.macos-update-installer: helper executable scaffold that validates and prints installer dry-run plans.
This package intentionally excludes host-app signing, entitlements, embedding, privileged helper registration, and UI integration. The consuming macOS app owns those concerns. The package also does not adopt Sparkle or any third-party updater framework.
Add the package to a Swift Package Manager project:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "YourApp",
platforms: [
.macOS(.v13)
],
dependencies: [
.package(
name: "MacOSUpdater",
url: "https://github.com/MarlonJD/macos-updater-swift.git",
branch: "main"
)
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "MacOSUpdaterCore", package: "MacOSUpdater"),
.product(name: "MacOSUpdaterManifest", package: "MacOSUpdater")
]
)
]
)Import only the products needed by each target:
import MacOSUpdaterCore
import MacOSUpdaterManifest
import MacOSUpdaterReleaseThis package implements the reusable updater foundation: signed manifest
models, SHA-256 hashing, SemVer/build-number release state, Conventional
Commits changelog drafts, target file manifests, delta manifests,
content-addressed LZFSE payload generation, full signed/notarized/stapled
.zip archive metadata, S3/CloudFront dry-run planning, runtime staging from
delta payloads or full archive fallback, and transactional installer helper
logic.
The package still intentionally leaves host-app embedding, entitlements, privileged helper registration, update UI, telemetry wiring, and production release credentials to the consuming macOS app.
Generate a changelog draft from Conventional Commit subjects:
cat > commits.txt <<'EOF'
feat(updater): add signed manifest envelope
fix(core): reject stale build numbers
docs: update README
perf(release): sort payload uploads
EOF
swift run macos-updater-release changelog \
--version 1.4.3 \
--commits-file commits.txtPreview S3/CloudFront object keys without uploading:
swift run macos-updater-release plan-keys \
--channel stable \
--version 1.4.3 \
--build 1848 \
--base-build 1847 \
--payload-sha256 abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 \
--full-archive-sha256 bbbbbb0123456789abcdef0123456789abcdef0123456789abcdef0123456789Validate an installer dry-run request:
{
"installedAppPath": "/Applications/emsi_macos.app",
"stagedAppPath": "/Users/me/Library/Caches/emsi/update/staged.app",
"backupAppPath": "/Users/me/Library/Caches/emsi/update/backup.app",
"mainAppPID": 1234,
"bundleIdentifier": "com.radlof.emsi-swift",
"teamIdentifier": "UPK4SC93AN",
"targetReleaseID": {
"version": "1.4.3",
"buildNumber": 1848
},
"launchToken": "signed-launch-token",
"logDirectoryPath": "/Users/me/Library/Application Support/emsi/Updates/Logs"
}swift run macos-update-installer --dry-run --request installer-request.jsonThe dry-run command prints the planned transactional steps only. It does not replace an app bundle.
To perform an install, pass a signed request envelope and the pinned request public key:
swift run macos-update-installer --perform \
--signed-request signed-install-request.json \
--public-key-x963-base64 "$INSTALL_REQUEST_PUBLIC_KEY_X963_BASE64" \
--key-id stable-2026Hash data or files with SHA-256:
import Foundation
import MacOSUpdaterManifest
let dataDigest = SHA256Digest.hex(for: Data("payload".utf8))
let fileDigest = try SHA256Digest.hex(forFileAt: payloadURL)Decode and verify a signed release manifest with a pinned P-256 public key:
import CryptoKit
import Foundation
import MacOSUpdaterManifest
let manifestData = try Data(contentsOf: releaseManifestURL)
let signedManifest = try ManifestCoding.decoder().decode(
SignedManifest<ReleaseManifest>.self,
from: manifestData
)
let publicKey = try P256.Signing.PublicKey(x963Representation: pinnedKeyData)
let verifier = ManifestVerifier(
publicKeysByID: ["stable-2026": publicKey],
requiredKeyIDs: ["stable-2026"]
)
try verifier.verify(signedManifest)
let releaseManifest = signedManifest.manifestCheck whether a release is eligible for an installed app:
import MacOSUpdaterCore
import MacOSUpdaterManifest
let policy = UpdateEligibilityPolicy(
channel: .stable,
bundleIdentifier: "com.radlof.emsi-swift",
teamIdentifier: "UPK4SC93AN",
architecture: .arm64
)
let installedApp = InstalledAppState(
releaseID: ReleaseID(version: try SemanticVersion("1.4.2"), buildNumber: 1847),
buildNumber: 1847,
bundleIdentifier: "com.radlof.emsi-swift",
teamIdentifier: "UPK4SC93AN",
platform: .macOS,
architecture: .arm64
)
let decision = try policy.validate(
releaseManifest: releaseManifest,
installedApp: installedApp
)Create a release-state value and validate monotonic build numbers:
import MacOSUpdaterManifest
let state = DesktopReleaseState(
lastBuildNumber: 1847,
lastStableVersion: try SemanticVersion("1.4.2"),
lastBetaVersion: try SemanticVersion("1.5.0-beta.1")
)
let nextBuild = state.nextBuildNumber()
try state.validateNextBuildNumber(nextBuild)Generate a changelog draft in Swift:
import MacOSUpdaterManifest
import MacOSUpdaterRelease
let changelog = ChangelogDraftGenerator().generate(
version: try SemanticVersion("1.4.3"),
commits: [
"feat(updater): add signed manifest envelope",
"fix(core): reject stale build numbers",
"docs: update README"
]
)Build a dry-run distribution plan:
import MacOSUpdaterManifest
import MacOSUpdaterRelease
let plan = DistributionKeyPlanner(bucketName: "emsi-updates-prod").plan(
channel: .stable,
version: try SemanticVersion("1.4.3"),
buildNumber: 1848,
baseBuildNumber: 1847,
payloadSHA256Values: [
"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
],
fullArchiveSHA256: "bbbbbb0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
)
print(plan.dryRunText())Stage an update from a delta manifest, falling back to the full archive when no safe delta is available:
import MacOSUpdaterCore
import MacOSUpdaterManifest
let core = UpdaterCore(
policy: UpdateEligibilityPolicy(
channel: .stable,
bundleIdentifier: "com.radlof.emsi-swift",
teamIdentifier: "UPK4SC93AN",
architecture: .arm64
),
stateStore: UpdateDownloadStateStore(stateURL: updateStateURL)
)
let staged = try await core.stageUpdate(
releaseManifest: releaseManifest,
installedApp: installedApp,
baseAppURL: installedAppURL,
deltaManifest: deltaManifest,
targetManifest: targetManifest,
assetLocator: UpdateAssetLocator(baseURL: updateBaseURL),
stagingDirectory: stagingDirectory
)The default verifier requires the staged app to pass codesign --verify --deep --strict, spctl, xcrun stapler validate, bundle identifier verification,
and team identifier verification before replacement. This gate is used for both
delta payload staging and full archive fallback staging.
The consuming macOS app is responsible for:
- embedding any helper executable;
- configuring signing, hardened runtime, sandboxing, and entitlements;
- pinning update public keys;
- storing updater state and downloaded payloads;
- presenting localized update UI;
- performing final code-signing and Gatekeeper checks before install;
- deciding whether privileged installation support is required.
This section describes the intended end-to-end release flow from app changes to
a published update. The release format uses content-addressed per-file
compressed payloads plus one content-addressed full signed, notarized, and
stapled .zip fallback archive. Do not upload a raw .app tree.
Create releases from a clean branch that contains only the changes intended for the next app update.
git status --short
git pull --ff-only
swift testFor the consuming macOS app, also run the app's release verification commands, including signing, notarization, and bundle validation checks. This package does not replace the host app's release pipeline.
Use Conventional Commit subjects as the input for the release notes draft.
mkdir -p release
git log <previous-release-tag>..HEAD \
--pretty=format:'%s' \
> release/commits-1.4.3+1848.txtRecommended commit types:
feat: user-visible featurefix: bug fixperf: performance improvementsecurity: security fixtype!: breaking change
The default changelog generator includes feat, fix, perf, security,
and breaking-change commits. It excludes docs, test, chore, and
refactor unless they are marked as breaking changes.
Use SemVer for the visible app version and a monotonically increasing build number for update ordering.
CFBundleShortVersionString = 1.4.3
CFBundleVersion = 1848
releaseID = 1.4.3+1848
The local release state file records the last published build and channel versions:
release/desktop-release-state.json
Example:
{
"lastBuildNumber": 1848,
"lastStableVersion": "1.4.3",
"lastBetaVersion": "1.5.0-beta.1"
}This repository includes release/desktop-release-state.example.json as the
starter template. Copy it to release/desktop-release-state.json in the
release-owning repository when real app releases are prepared.
Update this file only after choosing the release version. Commit it with the release-preparation change so the next release owner can compute the next build number from source control.
Run the release CLI against the collected Conventional Commit subjects:
swift run macos-updater-release changelog \
--version 1.4.3 \
--commits-file release/commits-1.4.3+1848.txt \
> release/changelog-1.4.3+1848.mdReview and edit the draft before publishing. The reviewed text is copied into
the signed release.json manifest and into any human-facing release notes.
Build the package executables in release mode:
swift build -c release --product macos-updater-release
swift build -c release --product macos-update-installerThe executable paths are:
.build/release/macos-updater-release
.build/release/macos-update-installer
macos-updater-release is a local maintainer tool. It is not embedded in the
customer app.
macos-update-installer is the helper executable scaffold. The consuming app is
responsible for embedding, signing, entitlements, sandbox behavior, and launch
integration before it is used in production.
In the consuming macOS app repository, build the target .app bundle with the
selected version values:
CFBundleShortVersionString = 1.4.3
CFBundleVersion = 1848
Then sign and notarize the full target app bundle using the host app's Developer ID workflow. Before generating update metadata, verify the final bundle:
codesign --verify --deep --strict --verbose=4 /path/to/emsi_macos.app
spctl -a -t exec -vv /path/to/emsi_macos.app
xcrun stapler validate /path/to/emsi_macos.appDo not generate delta metadata from an unsigned, unstapled, or mutable app bundle.
The intended immutable release directory for a published update is:
desktop/macos/<channel>/releases/<releaseID>/
For a stable 1.4.3+1848 release:
desktop/macos/stable/releases/1.4.3+1848/
release.json
target-file-manifest.json
delta-from-1847-to-1848.json
payloads/
ab/cd/<compressed-payload-sha256>.lzfse
archives/
<full-notarized-zip-sha256>.zip
The target file manifest describes the final signed .app bundle. The delta
manifest describes how to transform a verified base build into the target
build. Payload objects contain compressed changed files. The archive is the
full signed, notarized, and stapled fallback artifact.
Generate the target manifest from the signed and stapled app bundle:
swift run macos-updater-release target-manifest \
--app /path/to/emsi_macos.app \
--output release/target-file-manifest-1.4.3+1848.json \
--version 1.4.3 \
--build 1848 \
--bundle-id com.radlof.emsi-swift \
--team-id UPK4SC93ANGenerate the delta manifest and content-addressed payloads:
swift run macos-updater-release delta \
--base-manifest release/target-file-manifest-1.4.2+1847.json \
--target-manifest release/target-file-manifest-1.4.3+1848.json \
--target-app /path/to/emsi_macos.app \
--output-dir release/cdn \
--channel stableGenerate the full fallback archive:
swift run macos-updater-release full-archive \
--app /path/to/emsi_macos.app \
--output-dir release/cdn \
--channel stable \
--version 1.4.3 \
--build 1848Full fallback archives are created with /usr/bin/ditto -c -k --keepParent --sequesterRsrc. Do not use the default /usr/bin/zip -r path for .app
bundles because it can dereference framework symlinks into regular files or
directories. Do not unzip and re-zip release artifacts in CI, object storage
automation, CDN tooling, or manual recovery steps. S3 and CloudFront should
serve the archive bytes produced by the release tool without transformation.
Symlinks are represented as manifest entries, not compressed file payloads. The
release manifest generator rejects symlink destinations that are absolute,
empty, contain ., contain .., or otherwise rely on path traversal. Runtime
staging repeats that validation before creating or accepting symlinks, then
verifies the staged app with hashes, file kinds, modes, codesign, spctl,
xcrun stapler validate, bundle identifier, and team identifier checks before
replacement.
Generate the release manifest after the full archive exists:
swift run macos-updater-release release-manifest \
--target-manifest release/target-file-manifest-1.4.3+1848.json \
--full-archive release/cdn/desktop/macos/stable/releases/1.4.3+1848/archives/<full-notarized-zip-sha256>.zip \
--delta-manifest release/cdn/desktop/macos/stable/releases/1.4.3+1848/delta-from-1847-to-1848.json \
--output release/cdn/desktop/macos/stable/releases/1.4.3+1848/release.json \
--channel stable \
--version 1.4.3 \
--build 1848 \
--minimum-system-version 13.0 \
--minimum-supported-build 1847 \
--commit-sha "$(git rev-parse HEAD)" \
--changelog-file release/changelog-1.4.3+1848.md \
--architecture universalBefore upload, preview the S3 keys and CloudFront invalidation paths:
swift run macos-updater-release plan-keys \
--channel stable \
--version 1.4.3 \
--build 1848 \
--base-build 1847 \
--payload-sha256 abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 \
--full-archive-sha256 bbbbbb0123456789abcdef0123456789abcdef0123456789abcdef0123456789 \
--bucket emsi-updates-prodThe dry-run output shows the safe upload order:
- payload objects;
- full notarized fallback archive;
- target file manifest;
- delta manifest;
- release manifest;
- mutable channel pointer.
Upload immutable release objects first. Do not update latest.json until every
referenced object exists and has the expected SHA-256 hash.
s3://emsi-updates-prod/
desktop/macos/stable/releases/1.4.3+1848/payloads/...
desktop/macos/stable/releases/1.4.3+1848/archives/<full-notarized-zip-sha256>.zip
desktop/macos/stable/releases/1.4.3+1848/target-file-manifest.json
desktop/macos/stable/releases/1.4.3+1848/delta-from-1847-to-1848.json
desktop/macos/stable/releases/1.4.3+1848/release.json
desktop/macos/stable/latest.json
latest.json is the only mutable channel pointer. Clients read it to discover
which release is currently published for a channel.
There are three records of the published version:
release/desktop-release-state.jsonin source control records the last build number and channel versions for the next release owner.desktop/macos/<channel>/latest.jsonin the update bucket records the currently published release for clients.desktop/macos/<channel>/releases/<releaseID>/release.jsonin the update bucket records the immutable signed metadata for that exact release.
Use a git tag for the source revision that produced the release:
git tag macos-v1.4.3+1848
git push origin macos-v1.4.3+1848The source-control state, CDN latest.json, immutable release.json, and git
tag must all point to the same release ID.
After publishing latest.json, verify the public CloudFront URLs:
curl -fsS https://<cloudfront-domain>/desktop/macos/stable/latest.json
curl -fsS https://<cloudfront-domain>/desktop/macos/stable/releases/1.4.3+1848/release.jsonThen run a canary update against an installed base build. Confirm that the client requests only the changed payload URLs, stages the target bundle, verifies hashes and signatures, and refuses the update if any signed manifest or payload hash is wrong.
Also run a full-archive fallback canary by withholding the base-specific delta
manifest. Confirm that the updater downloads the .zip archive, expands it,
and applies the same staged-app verification gate before replacement.
If release objects were uploaded but latest.json was not updated, clients
will not see the partial release. Fix or delete the incomplete immutable release
directory before publishing.
If latest.json was updated incorrectly, publish a corrected signed
latest.json that points back to the last known-good release or to a signed
rollback release. Do not mutate objects under an existing
releases/<releaseID>/ directory.
Copyright (C) 2026 Burak Karahan.
Licensed under the GNU Lesser General Public License v3.0 or later
(LGPL-3.0-or-later).
Run the test suite:
swift test