When a team deploys command-line tools, background agents, or internal applications to multiple Macs, copying files directly soon creates three problems: inconsistent destination paths, stale files left behind during upgrades, and installation results that cannot be audited. A macOS PKG can bundle the payload, permissions, installation scripts, and version receipts into a traceable artifact—but only if the development workspace is not passed directly to pkgbuild. The following example packages a command-line component installed under /Library/Application Support/AcmeTool and establishes a build and validation workflow suitable for cloud Mac CI.
Define the installation contract first
Before packaging, define the destination path, owner, permissions, package identifier, and upgrade rules. The package identifier should remain stable after release, while the version must increase monotonically. Otherwise, system receipts cannot reliably determine which installation supersedes another.
| Item | Example | Acceptance requirement |
|---|---|---|
| Installation root | /Library/Application Support/AcmeTool |
Must not write to a user’s home directory |
| Executable | bin/acmetool |
root:wheel, mode 0755 |
| Configuration template | config/default.json |
Mode 0644, contains no secrets |
| Package identifier | com.example.acmetool.pkg |
Must not vary by branch |
| Version | 1.4.0 |
Must match the release tag |
The project directory on the build machine is not part of the installation contract. .git, test reports, download caches, temporary logs, and locally generated extended attributes must not be included in the PKG.
The most reliable packaging input is not the “current repository,” but a freshly created staging directory whose contents can be fully enumerated.
Create a clean payload
At the start of every job, remove the previous staging area, explicitly copy only the files approved for delivery, and then normalize their permissions. Avoid broad commands such as cp -R ., because they allow newly added files to enter the installer unnoticed.
set -euo pipefail
ROOT="$PWD"
STAGE="$ROOT/build/stage"
PKGDIR="$ROOT/build/packages"
INSTALL_DIR="$STAGE/Library/Application Support/AcmeTool"
rm -rf "$STAGE" "$PKGDIR"
mkdir -p "$INSTALL_DIR/bin" "$INSTALL_DIR/config" "$PKGDIR"
install -m 0755 "$ROOT/dist/acmetool" "$INSTALL_DIR/bin/acmetool"
install -m 0644 "$ROOT/packaging/default.json" \
"$INSTALL_DIR/config/default.json"
xattr -cr "$STAGE"
find "$STAGE" -type d -exec chmod 0755 {} +
find "$STAGE" -type f ! -path '*/bin/acmetool' -exec chmod 0644 {} +
Next, generate a manifest. It supports code review and makes it possible to compare the actual build inputs when the job finishes:
find "$STAGE" -print0 |
sort -z |
xargs -0 stat -f '%Sp %Su:%Sg %N' > "$PKGDIR/payload-manifest.txt"
grep -Eq '/(\.git|DerivedData|node_modules)(/|$)' \
"$PKGDIR/payload-manifest.txt" && exit 1 || true
Extended attributes are removed before permissions are applied so that download-origin markers or ACLs from the development machine do not leak into the artifact. The configuration template should contain defaults only; runtime tokens must be injected during deployment.
Build the component package with pkgbuild
Keep installation scripts in a separate directory and ensure they are executable. Scripts should be idempotent, fast, and noninteractive. They must not read terminal input or assume that a particular user is logged in.
mkdir -p packaging/scripts
cat > packaging/scripts/postinstall <<'SH'
#!/bin/sh
set -eu
TARGET="/Library/Application Support/AcmeTool"
test -x "$TARGET/bin/acmetool"
chown -R root:wheel "$TARGET"
chmod 0755 "$TARGET" "$TARGET/bin" "$TARGET/bin/acmetool"
chmod 0644 "$TARGET/config/default.json"
exit 0
SH
chmod 0755 packaging/scripts/postinstall
pkgbuild \
--root "$STAGE" \
--identifier "com.example.acmetool.pkg" \
--version "1.4.0" \
--install-location "/" \
--scripts packaging/scripts \
"$PKGDIR/AcmeTool-component.pkg"
For a single component, the component package is already installable. If you need a shared welcome page, license text, or multiple components, use productbuild to create a distribution package:
productbuild \
--package "$PKGDIR/AcmeTool-component.pkg" \
"$PKGDIR/AcmeTool-1.4.0.pkg"
When signing is required, retrieve the Installer signing identity in a protected CI environment and build the production package with --sign "$INSTALLER_IDENTITY". Unsigned artifacts can still be used for preliminary testing, but they must not be presented as final deliverables.
Perform static validation before installation
The first validation layer does not modify the system. Start by checking the package identifier, version, and payload, then expand the distribution package to inspect its scripts and metadata:
PKG="$PKGDIR/AcmeTool-1.4.0.pkg"
EXPANDED="$PKGDIR/expanded"
pkgutil --check-signature "$PKG" || true
pkgutil --payload-files "$PKGDIR/AcmeTool-component.pkg"
rm -rf "$EXPANDED"
pkgutil --expand-full "$PKG" "$EXPANDED"
grep -R "com.example.acmetool.pkg" "$EXPANDED"
grep -R "1.4.0" "$EXPANDED"
find "$EXPANDED" -type f -name postinstall -exec sh -n {} \;
If the production package must be signed, do not tolerate failures from pkgutil --check-signature; a nonzero exit code should stop the pipeline immediately. The checks should also reject absolute build paths, fragments of private keys, and workspace usernames:
if grep -R -E "$HOME|BEGIN (RSA |EC )?PRIVATE KEY" "$EXPANDED"; then
echo "sensitive build data found" >&2
exit 1
fi
A common mistake is to compare only package file sizes. A stable size does not imply stable contents. The manifest, identifier, version, script syntax, and signature state must each be checked independently.
Test the installation in isolation and verify the receipt
Final acceptance testing must run in a rollback-capable test environment because installer writes to system paths and installation receipts. The test node should be restored to a known state before each job and must not be shared with routine development sessions.
sudo installer -pkg "$PKG" -target /
test -x "/Library/Application Support/AcmeTool/bin/acmetool"
test "$(stat -f '%Sp' \
'/Library/Application Support/AcmeTool/bin/acmetool')" = "-rwxr-xr-x"
pkgutil --pkg-info "com.example.acmetool.pkg"
pkgutil --files "com.example.acmetool.pkg" |
sort > "$PKGDIR/installed-files.txt"
"/Library/Application Support/AcmeTool/bin/acmetool" --version
A successful installation only means that the script returned zero. You should also verify that the executable starts, the configuration template parses correctly, and the receipt reports the expected version, then preserve the installation log and file manifest as CI artifacts. On failure, first save the relevant time range from /var/log/install.log, then restore the test environment instead of immediately rerunning the job and overwriting the evidence.
The final checklist can be standardized around five items: no unexpected files in the staging directory, permissions and ownership match the contract, the package version increases monotonically, the production artifact passes signature validation, and both the command and receipt can be verified after an isolated installation. This turns a PKG from a “double-clickable archive” into an auditable, reproducible engineering deliverable that supports regression testing.
Frequently asked questions
Should pkgbuild point directly at the repository root?
No. Create a separate staging tree containing only the files intended for the target system so source files, caches, logs, and local metadata cannot enter the package.
Can the PKG workflow be tested without an Installer signing certificate?
Yes. Validate an unsigned package for structure, permissions, scripts, and isolated installation first. Sign the release artifact later and confirm it with pkgutil --check-signature.
Configure a cloud Mac for your next build or inference task
Choose from three Apple Silicon configurations, six nodes, and fixed rental terms. Actual availability is shown in real time in the console.