Aspenini/Parcel

A scriptable installer and distribution builder for macOS

★ 0Forks 0SwiftGitHub ↗Compare

Project website ↗

README

Parcel

A scriptable installer and distribution builder for macOS.

parcel.aspenini.com

Parcel turns finished software — applications, command-line tools, frameworks, plugins, support files — into native .pkg and .dmg releases. Instead of wiring together pkgbuild, productbuild, productsign, hdiutil, codesign, notarytool and stapler by hand, you describe the release in a small .parcel file and build it:

parcel build packaging/release.parcel

Parcel starts with compiled software. It is not a compiler, an application bundler, or a build system.

Install

curl -fsSL https://parcel.aspenini.com/install.sh | sh

That builds Parcel from source and puts parcel on your PATH under ~/.parcel. It does not need sudo and does not write to /usr/local/bin. Requires macOS 13 or later and the Swift compiler (Xcode Command Line Tools).

From a checkout already on disk, the same script is enough:

./install.sh

Check that the machine is ready:

parcel doctor

To produce a native installer package instead (after parcel is on PATH, or from the just-built binary):

swift build -c release
.build/release/parcel build packaging/release.parcel

That writes dist/Parcel-0.1.0.pkg, which installs parcel into /usr/local/bin through the macOS Installer.

Configuration files

Configurations use the .parcel extension and may live anywhere. A project can hold as many as it needs, one per packaging target:

MyProject/
├── src/
├── build/
└── packaging/
    ├── app-dmg.parcel
    ├── full-installer.parcel
    └── cli-tools.parcel

Paths inside a configuration resolve relative to the configuration file, not the working directory, so parcel build packaging/release.parcel behaves the same from anywhere in the project.

[Package]
Name=Quark Downloader
Identifier=com.example.quark
Version=1.2.0
OutputDirectory=../dist

[Application]
Source=../build/Quark Downloader.app

[Files]
Source=../build/quark
Destination=/usr/local/bin/quark
Mode=755
Sign=true

[Signing]
Enabled=true
TeamID=ABCDE12345

[Notarization]
Enabled=true
Profile=parcel-release

[PKG]
Enabled=true

[DMG]
Enabled=true
Contents=PKG
VolumeName=Quark Downloader

Commands

Command Purpose
parcel build <config> Stage, sign, package, notarize and staple
parcel check <config> Validate without building
parcel sign <config> Stage and sign the payload only
parcel notarize <config> Notarize artifacts a previous build produced
parcel clean <config> Remove build intermediates (--all also removes artifacts)
parcel doctor Check the toolchain, certificates and notarization tools
parcel credentials add <name> Store a notarization profile in the keychain

Useful options: --dry-run reports what a build would do, --verbose shows the native commands and their output, --no-notarize builds and signs but skips Apple, and --define NAME=VALUE supplies a {Variable}.

The configuration argument may be omitted when the project contains exactly one .parcel file in the current directory, packaging/, or installer/.

Sections

[Package]

Key Meaning
Name Product name, used for the installer title and artifact names
Identifier Reverse-DNS package identifier
Version Product version
OutputDirectory Where artifacts are written (default dist)

[Application]

Convenience for installing a bundle into /Applications:

[Application]
Source=../build/MyApp.app

Destination overrides the install path and Sign=false opts out of signing. The section is shorthand — the same thing can be written as a [Files] entry.

[Files]

Arbitrary payloads. Each entry starts at Source:

[Files]
Source=../build/mytool
Destination=/usr/local/bin/mytool
Mode=755
Sign=true

Source=../config/default.conf
Destination=/Library/Application Support/MyTool/default.conf
Mode=644

Source=../resources/*
Destination=/Library/Application Support/MyTool/Resources/

A wildcard source requires a destination ending in /. No .app bundle is required anywhere: command-line tools, frameworks, launch daemons and plain configuration files are all first-class payloads.

[Component "Name"]

Splits the installer into pieces the user can choose between. Each component becomes its own component package inside the product archive.

[Component "Application"]
Source=../build/MyApp.app
Destination=/Applications/MyApp.app
Required=true

[Component "CLI"]
Source=../build/myapp
Destination=/usr/local/bin/myapp
Mode=755
Required=false
Default=true

Required=true components are listed but cannot be deselected. Components accept the same Source/Destination/Mode/Sign entries as [Files].

[Scripts] and [Installer]

[Scripts]
PreInstall=scripts/preinstall.sh
PostInstall=scripts/postinstall.sh

[Installer]
Welcome=resources/welcome.md
Readme=resources/readme.md
License=../LICENSE
Conclusion=resources/finished.md
Background=resources/installer-background.png

Installer pages may be Markdown, plain text, RTF or HTML. Markdown is converted to HTML on the way in, because that is what Apple's Installer understands. Parcel uses the standard macOS Installer rather than shipping its own runtime.

[Signing]

[Signing]
Enabled=true
TeamID=ABCDE12345

Parcel finds the Developer ID Application and Developer ID Installer certificates itself, narrowing by team when one is given. ApplicationIdentity and InstallerIdentity override the choice, and Entitlements, HardenedRuntime and Timestamp are available when needed.

Bundles are signed from the inside out — nested frameworks, XPC services, helpers and libraries first, the bundle last. Standalone executables are equally valid signing targets. Sources are never modified: Parcel signs the staged copy.

[Notarization]

[Notarization]
Enabled=true
Profile=parcel-release

Secrets never belong in a .parcel file. Create a keychain profile once:

parcel credentials add parcel-release

notarytool prompts for the credentials and stores them in the keychain; Parcel never sees them. The configuration only names the profile, which keeps it safe to commit.

When both outputs are enabled, the package is notarized and stapled before the disk image is built around it, so a package copied out of the image carries its own ticket.

[PKG] and [DMG]

Either output can be enabled on its own, or both together:

[PKG]
Enabled=true

[DMG]
Enabled=true
Contents=PKG
VolumeName=MyApp

Contents may be PKG, Application or Payload. The drag-to-Applications layout is the default for an application image:

[DMG]
Enabled=true
VolumeName=MyApp
Layout=DragToApplications
Background=resources/background.png
WindowWidth=720
WindowHeight=420
AppPosition=180,200
ApplicationsPosition=540,200

Icon positions and a background picture require mounting the image and driving the Finder. On a build machine that withholds Automation permission Parcel says so and produces an image with the default layout rather than failing.

If neither section appears, Parcel builds a package. A [DMG] section on its own means a disk image only.

Variables

Values may reference {Variable} placeholders, supplied with --define or through PARCEL_VAR_* environment variables. {{ and }} are literal braces.

[Package]
Name={CargoPackageName}
Version={CargoPackageVersion}

[Files]
Source={CargoBinary}
Destination=/usr/local/bin/{CargoPackageName}
Mode=755
parcel build packaging/macos.parcel \
  -D CargoPackageName=quark \
  -D CargoPackageVersion=1.2.0 \
  -D CargoBinary=target/release/quark

This is the hook build-system integrations use to avoid duplicating metadata a project already records elsewhere.

{ConfigurationDirectory} and {ConfigurationName} are always available.

Rust projects

Parcel is build-system independent and needs no integration to be useful. For Rust projects there is a companion Cargo subcommand, cargo-parcel, which supplies the metadata Cargo already records:

cargo install parcel-cargo
cargo parcel pkg --release

Cargo provides the package name, version, binary and target directory; only things it cannot know, such as a signing team or a reverse-DNS identifier, go in Cargo.toml:

[package.metadata.parcel]
identifier = "com.example.quark"
team-id = "ABCDE12345"
sign = true
notarize = true
profile = "parcel-release"

Projects wanting full control keep their own .parcel file and still get the Cargo values, as {CargoPackageName}, {CargoBinary} and friends:

cargo parcel packaging/macos.parcel

Validation

parcel check reports everything wrong at once, in file order, with the line that caused it:

release.parcel:7: error: Destination 'usr/local/bin/thing' is not an absolute install path.
       hint: Install paths start at the root of the target volume, for example /usr/local/bin/mytool.
release.parcel:11: error: Destination '/usr/bin/quark' is inside /usr/bin/, which is protected by the system.
       hint: Command-line tools normally install into /usr/local/bin.

It checks syntax, identifiers, versions, missing sources, install paths, duplicate destinations, executable permissions, malformed .app bundles, scripts, installer resources, certificate availability and disk image settings.

Layout

Sources/
├── parcel/                  Executable entry point
└── ParcelKit/
    ├── CLI/                 Argument parsing, commands, build pipeline, doctor
    ├── Configuration/       .parcel parsing, decoding, validation
    ├── Payload/             Staging the install layout
    ├── PKG/                 pkgbuild, distribution files, installer resources
    ├── DMG/                 hdiutil and Finder layout
    ├── Signing/             Identity discovery and codesign
    ├── Notarization/        notarytool, stapler, keychain profiles
    └── System/              Process execution, toolchain discovery, filesystem
Tests/ParcelKitTests/

Run the tests with swift test. The pipeline tests drive the real native tools and build actual packages and disk images.

Contributors

Aspenini

Issues