Build and Validate macOS PKG Installers on a Cloud Mac

DevOps & CI/CD ·~5 min read

Build and Validate macOS PKG Installers on a Cloud Mac

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.

Dedicated physical node

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.

Configure a cloud Mac