macospkg

A cross-platform, self-contained toolkit for macOS installer packages
(.pkg). It reads and writes the xar container, the bill of materials, the
cpio payload, PackageInfo and Distribution directly, without
pkgbuild, productbuild, productsign, pkgutil, notarytool,
stapler or a Mac, so a package can be inspected, built, signed,
notarized and stapled on a Linux or Windows CI runner exactly as on macOS.
$ macospkg build ./root Foo.pkg --identifier com.example.foo --version 1.2.0 --scripts ./scripts
built Foo.pkg: com.example.foo 1.2.0, 14 files, 306 KB installed
$ macospkg sign Foo.pkg Foo-signed.pkg --p12 developer-id.p12 --p12-password-stdin < pw.txt
signed Foo.pkg -> Foo-signed.pkg (Developer ID Installer: Example Ltd (ABCDE12345), timestamped)
$ macospkg notarize Foo-signed.pkg --wait --staple
submission id 2efe2717-52ef-43a5-96dc-0797e4ca1041
2efe2717-52ef-43a5-96dc-0797e4ca1041: Accepted
stapled the notarization ticket to Foo-signed.pkg
$ macospkg verify --online Foo-signed.pkg
Status: valid
Chain: trusted
Timestamp: 2026-08-29T14:48:07Z
Staple: notarization ticket present
Notarized: yes (ticket on record with Apple)
Why
Apple ships no way to build, sign, notarize or staple a .pkg outside of a macOS device.
pkgbuild, productbuild, productsign, pkgutil, notarytool and
stapler are macOS-only, so any pipeline that produces an installer puts macOS
availability on its critical path. For many teams that availability is not a
given at all: plenty of CI environments offer no macOS runners, and
locked-down, on-premise or air-gapped ones cannot bring a Mac in to fix that.
Where macOS is available it is a standing cost and a bottleneck instead, a
hosted runner that bills several times a Linux one, or a self-hosted machine to
provision, patch and hold signing keys on. Either way, for a team whose product
is otherwise built on Linux, that single step is the reason a Mac has to be in
the picture at all.
The formats underneath are not the real obstacle. A .pkg is a xar archive
holding a cpio payload, a bill of materials and XML metadata, signed with CMS
over the table of contents; none of it depends on a macOS kernel or framework.
What was missing was a complete, faithful reimplementation, because the pieces
are under-documented and each one (the BOM layout, the pbzx and LZBITMAP
containers, the exact bytes Apple signs) has to be recovered by measurement
against the real tools.
macospkg is that reimplementation, in one static Go binary with no cgo and no
runtime dependencies. It runs the whole lifecycle, inspect through to staple, on
Linux, Windows or macOS, and aims to produce byte-for-byte the packages Apple's
own tools do, checked continuously against them (see
How it is tested). The payoff is a CI job that ships a
signed, notarized installer on a cheaper Linux runner as every other workload,
and a library other Go programs can embed instead of shelling out to tools that
are available.
Features
|
macOS |
Linux |
Windows |
Inspect: info, list, cat, inspect |
✅ |
✅ |
✅ |
Unpack: expand (pkgutil parity), extract |
✅ |
✅ |
✅ |
Repack: flatten (pkgutil --flatten) |
✅ |
✅ |
✅ |
| Build component packages and product archives, reproducibly |
✅ |
✅ |
✅ |
| Sign with a Developer ID Installer certificate, with Apple timestamps |
✅ |
✅ |
✅ |
| Verify signatures against Apple's roots; team, timestamp, staple |
✅ |
✅ |
✅ |
| Notarize (App Store Connect API key) and staple |
✅ |
✅ |
✅ |
Payloads: read and write gzip cpio, pbzx, pbze, pbzb and --large-payload |
✅ |
✅ |
✅ |
Payloads: read pbz4 and pbzz, which macOS cannot install |
✅ |
✅ |
✅ |
Hard links and extended attributes (._ sidecars), as pkgbuild carries them |
✅ |
✅ |
xattrs as ._ files; links as copies |
See TOOLS_STATUS.md for the exact state of each area.
Payload containers
A Payload is a cpio archive, wrapped in a container its first bytes identify.
The readers and writers are ordinary Go, so every platform behaves identically.
| Container |
What it is |
Support |
| gzip cpio |
the default, and the only container every macOS can install |
read + write |
pbzx |
xz chunks: smaller, but only macOS 12 and later installs it |
read + write (--compression pbzx, also spelled latest) |
pbze, pbzb |
the same container with LZFSE or LZBITMAP chunks. macOS reads both |
read + write (--compression lzfse|lzbitmap) |
pbz4, pbzz |
the same container with Apple-framed LZ4 or zlib chunks |
read only: macOS cannot install either, so build refuses to write one |
--large-payload carries files of 8 GiB and over, which no cpio header can
describe: the file is split into 1 GiB segments under one path and the entry
is named LargeSegmentedPayload. The container is unchanged, so the two flags
compose. Only macOS 12 and later reads one, and --compression pbzx likewise
sets the package's minimum system version to 12.0 unless you ask for a higher
one.
LZBITMAP has no published specification. pkg/lzbitmap is a Go translation of
Corellium's MIT-licensed libzbitmap; NOTICE carries the copyright. Both
directions are judged against Apple's own aa.
Which containers macOS will actually install, and why pbz4 and pbzz are
refused, is measured and recorded in
docs/formats/payload.md.
Install
Download a release archive for your platform from the
releases page,
or build from source:
go build -o macospkg ./cmd/macospkg
Release archives are pure Go binaries with no runtime dependencies. Each
release ships a checksums.txt signed keylessly with cosign:
cosign verify-blob \
--bundle macospkg_<version>_checksums.txt.sigstore.json \
--certificate-identity-regexp 'https://github.com/deploymenttheory/go-macos-pkg/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
macospkg_<version>_checksums.txt
shasum -a 256 -c macospkg_<version>_checksums.txt --ignore-missing
Commands
Every command takes the package first. -o json turns any output into JSON,
one document or one line per entry. docs/cli.md is the
reference: every flag, its default, and when to reach for it.
| Command |
What it does |
info PKG |
Kind, each component's identity, payload, scripts, signature and staple |
list PKG |
The files the package installs, from the bill of materials; --archive lists xar entries instead |
cat PKG ENTRY |
One archive entry to stdout, decoded; --payload PATH for one file out of the payload |
inspect PKG VERB |
One structure as stored: header, toc, bom, signature, cms, rsa, digest, ticket |
expand PKG DIR |
Every entry decoded into a new directory; --full unpacks the payloads too |
flatten DIR OUT.pkg |
The inverse of expand, so one entry can be edited without a rebuild |
extract PKG DIR |
The payload files, as they would land under the install location |
build SRC OUT.pkg |
A component package from a destination root, a bundle, or a build-info.yaml project |
product OUT.pkg |
A product archive from component packages, with a Distribution synthesized or supplied |
sign PKG OUT.pkg |
An RSA and a CMS signature over the table of contents, chain embedded, timestamped |
verify PKG |
Digest, signatures, chain to Apple's roots, team, timestamp, staple, each reported separately |
notarize PKG |
Submit to Apple's notary service; --wait for the verdict, --staple to attach the ticket |
staple PKG|APP |
Fetch the ticket from Apple's database and attach it, to a flat package or a .app bundle; unstaple removes one |
receipts |
What a volume records about the packages installed on it |
Three things are worth knowing before reading the reference.
Signing takes no keychain. The identity is a PKCS#12 file (--p12) or a
PEM pair (--cert and --key), which is what lets the same command run on
Linux and Windows. build and product carry the same flags under a sign-
prefix, and the notary credentials under a notary- prefix, so a whole release
is one run: build ... --sign-p12 devid.p12 --notarize.
Extended attributes and hard links survive a round trip. Attributes travel
as ._ AppleDouble entries, applied to their owners where the host stores them
and left as files where it does not, so expanding a package on Linux and
building it again reproduces it. See
docs/formats/payload.md.
Output is reproducible. Set --source-date-epoch or SOURCE_DATE_EPOCH
and identical input gives identical bytes on every platform. See
docs/reproducible-output.md.
Global flags
| Flag |
Description |
-o, --output text|json|plist |
output format |
-q, --quiet |
suppress progress messages |
--verbose |
diagnostics on stderr |
--source-date-epoch N |
pin every timestamp for reproducible output |
--temp-dir DIR |
where scratch files go while building (default: beside the output) |
These five are the only flags with an environment variable and a config
file key: MACOSPKG_OUTPUT, MACOSPKG_QUIET, MACOSPKG_VERBOSE,
MACOSPKG_SOURCE_DATE_EPOCH and MACOSPKG_TEMP_DIR. Precedence is flag,
then variable, then ~/.config/macospkg/config.yaml. SOURCE_DATE_EPOCH
is the exception: the bare variable outranks MACOSPKG_SOURCE_DATE_EPOCH.
Exit codes
| Code |
Meaning |
| 0 |
success |
| 1 |
error |
| 2 |
usage error |
| 3 |
not a flat package (missing, not a xar, or a xar without PackageInfo/Distribution) |
| 4 |
credentials missing or rejected (PKCS#12 password, key mismatch, notary API key) |
| 5 |
unsupported (a payload container macOS cannot install, ownership on Windows, non-RSA key) |
| 6 |
partial result (some entries skipped) |
| 7 |
signature or ticket check failed, or no ticket available |
| 8 |
notarization rejected |
| 9 |
wait timed out |
The contract lives in pkg/exitcode.
In CI
- uses: actions/checkout@v7
- name: Build, sign, notarize and staple
env:
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }}
APPLE_PRIVATE_KEY_PEM: ${{ secrets.APPLE_PRIVATE_KEY_PEM }}
MACOSPKG_P12_PASSWORD: ${{ secrets.DEVID_P12_PASSWORD }}
SOURCE_DATE_EPOCH: ${{ github.event.head_commit.timestamp }}
run: |
echo "${{ secrets.DEVID_P12_BASE64 }}" | base64 -d > devid.p12
macospkg build ./root Foo-${VERSION}.pkg --identifier com.example.foo --version "$VERSION" \
--scripts ./scripts --sign-p12 devid.p12 --notarize
macospkg verify --require-stapled --online Foo-${VERSION}.pkg
The same job runs on ubuntu-latest, windows-latest and macos-latest.
See docs/notarization.md and
docs/signing.md.
Using it as a library
import "github.com/deploymenttheory/go-macos-pkg/pkg/flatpkg"
p, err := flatpkg.Open("Foo.pkg")
for _, c := range p.Components {
fmt.Println(c.Info.Identifier, c.Info.Version)
}
Key packages: pkg/xar (container), pkg/bom (bill of materials),
pkg/cpio and pkg/pbzx (payloads), pkg/appledouble (the ._ sidecars
that carry extended attributes), pkg/flatpkg (packages, build, expand,
extract), pkg/pkgsign (sign, verify), pkg/notary, pkg/staple.
The format details are written down in docs/formats/.
How it is tested
The binary never calls an Apple tool. Apple's pkgbuild, pkgutil,
lsbom, xar, installer, stapler and spctl are used only by the
acceptance suite on macOS, as independent references: the fixtures in
testdata/cli were produced by Apple's tools once and are committed, so
Linux and Windows test the reader against Apple's bytes; the macOS leg
additionally builds the same tree with pkgbuild and with macospkg and
compares what lsbom, pkgutil and xar say about each, installs our
package with installer, checks our signature with pkgutil --check-signature and openssl cms, and our staple with stapler validate and spctl. A gated job signs and notarizes with a real
Developer ID against Apple's services.
Two real-world packages are the references on every platform: Google's Go
installer (go1.27.0.darwin-arm64.pkg, stapled, no bundles) and
PowerShell (powershell-7.6.1-osx-arm64.pkg, an app bundle with a symlink
and a postinstall script). Each has its signature verified against Apple's
roots and its ticket against Apple's database, then is expanded with
macospkg and rebuilt with macospkg, and the rebuilt package is compared
with the original entry for entry: PackageInfo numbers, every
bill-of-materials entry (17,356 of them for Go), every payload file's
bytes, the Distribution and resources. On macOS, pkgutil, lsbom and
xar compare the two as well, and installer installs the rebuilt
package.
Development
go build ./...
go test ./pkg/... ./internal/...
go test -v ./acceptance/
Fixtures are regenerated with scripts/gen-fixtures.sh on macOS. See
CONTRIBUTING.md.
Licensing
MIT. See LICENSE and NOTICE for the reference
implementations that informed the format code; none is vendored.
Acknowledgements
The flat package format is Apple's and undocumented; this tool stands on
the people who worked it out before: Rob Braun's xar, Fabian Renn's
bomutils, Gregory Szorc's apple-platform-rs, SAS's relic, libarchive, and
Greg Neagle's munki-pkg. See NOTICE.