OSX flat packages

OSX flat packages are single installer files with .pkg file extensions.

They appear to have originated with OSX 10.5. You can’t install these files on OSX < 10.5.

The previous packaging formats were bundles rather than single files:

bundle
A structured directory hierarchy that stores files in a way that facilitates their retrieval (see glossary in the software delivery legacy guide)

See OSX legacy packaging redux for details.

As far as I know there is no Apple document describing the flat package format.

You may find these other pages useful as background:

About the examples on this page

I wrote this page in reStructuredText (reST) for Sphinx, with some custom Sphinx extensions. The extensions pick up the code fragments in this page and run them on my laptop as part of the build process for the web pages. They also write out the example files listed on this page. The combination means that, at the time I last built the website, I confirmed that the code ran on my laptop, and gave the output you see here.

Flat packages in general

Flat packages are xar archives (see the xar man page).

To see the contents of a flat package, the most convenient command is pkgutil --expand, as in:

$ pkgutil --expand my.pkg an_output_dir

This will unpack product archive (meta-packages) and their component packages, and allows you to futz with the directory contents in an_output_dir before reconstituting the package with:

$ pkgutil --flatten an_output_dir my.pkg

On OSX, tar will unpack xar archives, and will automatically detect that the archive is in xar format with tar xf my.pkg, if you really want to do the unpacking without pkgutil.

Different package types

Flat packages can be of type:

  • product archive – a meta-package format containing one or more component packages with an XML file specifying the behavior of the installer, including system and volume requirement checks giving informative error messages. See the productbuild man page for more detail.
  • component package – an installer to install one component. Generally included as part of a product archive but can be used as an installer in its own right. A component package installer cannot control the behavior of the installer, but can abort the install or raise a post-install error via preinstall and postinstall scripts.

Component installer

A component installer goes through these stages:

  1. Runs preinstall script if present. An exit code other than zero aborts the installation.
  2. Writes payload to one or more locations on the target volume
  3. Runs postinstall script if present. An exit code other then zero gives an error message.

We start with some component packages that only have scripts and no payload, to show how they work.

Component installer scripts

$ mkdir pkg_examples
$ cd pkg_examples
$ mkdir scripts
Contents of scripts/preinstall
#!/bin/sh
echo "Running preinstall" > /tmp/my_preinstall.log
exit 0 # all good
Contents of scripts/postinstall
#!/bin/sh
echo "Running postinstall" > /tmp/my_postinstall.log
exit 0 # all good

The scripts need to be executable:

$ chmod u+x scripts/*

Each package needs a package identifier to identify it to the database of installed packages on the target system.

You can list the recorded installed packages on target / with:

pkgutil --pkgs /

Build the package with a fake identifier:

$ pkgbuild --nopayload --scripts scripts --identifier my.fake.pkg my_package.pkg
pkgbuild: Adding top-level preinstall script
pkgbuild: Adding top-level postinstall script
pkgbuild: Wrote package to my_package.pkg

Install the package:

$ sudo installer -pkg my_package.pkg -target /
installer: Package name is my_package
installer: Installing at base path /
installer: The install was successful.

Check the scripts ran by looking for output:

$ cat /tmp/my_preinstall.log
Running preinstall
$ cat /tmp/my_postinstall.log
Running postinstall

Exit code other than zero causes the installer to give an error message:

Contents of scripts/postinstall
#!/bin/sh
exit 1 # not so good
$ pkgbuild --nopayload --scripts scripts --identifier my.fake.pkg my_package.pkg
pkgbuild: Adding top-level preinstall script
pkgbuild: Adding top-level postinstall script
pkgbuild: Wrote package to my_package.pkg
$ sudo installer -pkg my_package.pkg -target /
installer: Package name is my_package
installer: Installing at base path /
installer: The install failed (The Installer encountered an error that caused the installation to fail. Contact the software manufacturer for assistance.)

Fixed if the script is not run:

$ rm scripts/postinstall
$ pkgbuild --nopayload --scripts scripts --identifier my.fake.pkg my_package.pkg
pkgbuild: Adding top-level preinstall script
pkgbuild: Wrote package to my_package.pkg
$ sudo installer -pkg my_package.pkg -target /
installer: Package name is my_package
installer: Installing at base path /
installer: The install was successful.

There are some useful environment variables available to the preinstall, postinstall scripts:

$ # Get the default environment when not in preinstall
$ sudo env > /tmp/my_sudo_envs.log
Contents of scripts/preinstall
#!/bin/sh
env > /tmp/my_preinstall_envs.log
echo "Positional arguments" $@ >> /tmp/my_preinstall_envs.log
exit 0
$ chmod u+x scripts/preinstall
$ pkgbuild --nopayload --scripts scripts --identifier my.fake.pkg my_package.pkg
pkgbuild: Adding top-level preinstall script
pkgbuild: Wrote package to my_package.pkg
$ sudo installer -pkg my_package.pkg -target /
installer: Package name is my_package
installer: Installing at base path /
installer: The install was successful.

Here are the new environment variables inserted by the installer:

$ diff /tmp/my_sudo_envs.log /tmp/my_preinstall_envs.log --old-line-format="" --unchanged-line-format=""
INSTALLER_TEMP=/private/tmp/PKInstallSandbox.ihgwFN/tmp
DSTVOLUME=/
TMPDIR=/private/tmp/PKInstallSandbox.ihgwFN/tmp
DSTROOT=/
USER=root
SCRIPT_NAME=preinstall
SHARED_INSTALLER_TEMP=/var/folders/zz/zyxvpxvq6csfxvn_n0000000000000/C/PKInstallSandboxManager-shared-tmp
INSTALLER_SECURE_TEMP=/var/folders/zz/zyxvpxvq6csfxvn_n0000000000000/C/PKInstallSandboxManager/52D185A5-181E-41F8-832A-7C5E31B4A3E4.activeSandbox/136FEC37-93BE-4C71-A891-6A5E63455E02
PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/libexec
PWD=/private/tmp/PKInstallSandbox.ihgwFN/Scripts/my.fake.pkg.CiIISN
INSTALL_PKG_SESSION_ID=my.fake.pkg
PACKAGE_PATH=/Users/mb312/dev_trees/docosx/pkg_examples/my_package.pkg
SHLVL=1
COMMAND_LINE_INSTALL=1
_=/usr/bin/env
Positional arguments /Users/mb312/dev_trees/docosx/pkg_examples/my_package.pkg / / /

These appear to be a superset of the environment variables listed for the old bundle installers at install operations.

No payload, no receipt

These “scripts only” installers have no payload, and write no package receipt to the package database:

$ pkgutil --pkgs / | grep my.fake.pkg

Payload

A flat-package component installer can install one or more bundles to given output locations on the target filesystem.

There are two ways of specifying payload with target location:

  • Using a destination root, using --root flag to pkgbuild. This analyzes a given directory root-path taking this directory to represent the root / directory of the target system. The installer will copy each directory at root-path to the target system at the same relative filesystem location.
  • By individual bundle component, using --component flag to pkgbuild. Specify directories to copy, and give their output locations separately using the --install-location flag.

Destination root

$ mkdir -p my_root/tmp/dir1 my_root/tmp/dir2
$ echo "Interesting info" > my_root/tmp/dir1/file1
$ echo "Compelling story" > my_root/tmp/dir2/file2
$ tree -a my_root
my_root
└── tmp
    ├── dir1
    │   └── file1
    └── dir2
        └── file2

3 directories, 2 files
$ pkgbuild --root my_root --identifier my.fake.pkg my_package.pkg
pkgbuild: Inferring bundle components from contents of my_root
pkgbuild: Wrote package to my_package.pkg

Do the install:

$ sudo installer -pkg my_package.pkg -target /
installer: Package name is my_package
installer: Installing at base path /
installer: The install was successful.
$ cat /tmp/dir1/file1
Interesting info
$ cat /tmp/dir2/file2
Compelling story

This install did write a package receipt:

$ pkgutil --pkgs / | grep my.fake.pkg
my.fake.pkg

Explicit component(s)

We point to a bundle to install and (usually) specify the install location.

In this case the bundle must be a properly formed bundle of some sort.

I’ll make a debug symbols bundle by doing a tiny compilation with clang:

$ echo "int main(int argc, char** argv) { }" > aprogram.c
$ clang -g aprogram.c -o aprogram
$ tree aprogram.dSYM
aprogram.dSYM
└── Contents
    ├── Info.plist
    └── Resources
        └── DWARF
            └── aprogram

3 directories, 2 files

Build, install the package:

$ pkgbuild --component aprogram.dSYM --install-location /tmp my_package.pkg
pkgbuild: Adding component at /Users/mb312/dev_trees/docosx/pkg_examples/aprogram.dSYM
pkgbuild: Wrote package to my_package.pkg
$ sudo installer -pkg my_package.pkg -target /
installer: Package name is my_package
installer: Upgrading at base path /
installer: The upgrade was successful.

The installer has written the expected output files:

$ tree /tmp/aprogram.dSYM
/tmp/aprogram.dSYM
└── Contents
    ├── Info.plist
    └── Resources
        └── DWARF
            └── aprogram

3 directories, 2 files

You can add more than one bundle to a single component installer.

Here I make another bundle with a different name:

$ echo "int main(int argc, char** argv) { }" > aprogram2.c
$ clang -g aprogram2.c -o aprogram2
$ tree aprogram2.dSYM
aprogram2.dSYM
└── Contents
    ├── Info.plist
    └── Resources
        └── DWARF
            └── aprogram2

3 directories, 2 files

When you add more than one component, you need to give a package identifier, because pkgbuild will not know which component to get an identifier from.

$ pkgbuild --component aprogram.dSYM --install-location /tmp \
$     --component aprogram2.dSYM --install-location /tmp \
$     --identifier my.fake.pkg \
$     my_package.pkg
pkgbuild: Adding component at /Users/mb312/dev_trees/docosx/pkg_examples/aprogram.dSYM
pkgbuild: Adding component at /Users/mb312/dev_trees/docosx/pkg_examples/aprogram2.dSYM
pkgbuild: Wrote package to my_package.pkg
$ sudo installer -pkg my_package.pkg -target /
installer: Package name is my_package
installer: Upgrading at base path /
installer: The upgrade was successful.
$ tree /tmp/aprogram.dSYM
/tmp/aprogram.dSYM
└── Contents
    ├── Info.plist
    └── Resources
        └── DWARF
            └── aprogram

3 directories, 2 files
$ tree /tmp/aprogram2.dSYM
/tmp/aprogram2.dSYM
└── Contents
    ├── Info.plist
    └── Resources
        └── DWARF
            └── aprogram2

3 directories, 2 files

Product archive

Component packages give a very basic default install.

Product archives allow you to wrap one or more component installs with an install configuration that includes:

  • custom welcome, readme, license and conclusion screens;
  • custom system requirement and volume requirement checks with informative error messages using Javascript code;
  • ability to customize choices of component packages to install using Javascript code.

An XML file named Distribution specifies these options (see distribution definition file).

Building product archives with productarchive

productarchive has five modes of action:

Archive for “in-app” content

This appears to be something to do with the App Store. Use the --content flag for this one.

Archive from destination root

Build an archive from a directory where the paths of the files relative to the --root directory give the output install location on the target volume (see Destination root).

$ productbuild --root my_root my_archive.pkg
productbuild: Wrote product to my_archive.pkg

Archive from component bundle(s) or package(s)

Build a product archive from one or more components by passing:

  • bundle path(s) with one or more uses of the --component flag (see Explicit component(s)) and / or;
  • package paths(s) with one or more uses of the --package flag.

Build product archive with two component bundles:

$ productbuild --component aprogram.dSYM /tmp --component aprogram2.dSYM /tmp my_archive.pkg
productbuild: Adding component at /Users/mb312/dev_trees/docosx/pkg_examples/aprogram.dSYM
productbuild: Adding component at /Users/mb312/dev_trees/docosx/pkg_examples/aprogram2.dSYM
productbuild: Wrote product to my_archive.pkg

Make bundles into component packages:

$ pkgbuild --component aprogram.dSYM --install-location /tmp aprogram.pkg
pkgbuild: Adding component at /Users/mb312/dev_trees/docosx/pkg_examples/aprogram.dSYM
pkgbuild: Wrote package to aprogram.pkg
$ pkgbuild --component aprogram2.dSYM --install-location /tmp aprogram2.pkg
pkgbuild: Adding component at /Users/mb312/dev_trees/docosx/pkg_examples/aprogram2.dSYM
pkgbuild: Wrote package to aprogram2.pkg

Build product archives from the component packages:

$ productbuild --package aprogram.pkg --package aprogram2.pkg my_archive.pkg
productbuild: Wrote product to my_archive.pkg

Build a Distribution file from component bundle(s) / packages(s)

This builds a template Distribution XML file by analyzing one or more components in the form of bundles or .pkg files. Use the --synthesize flag.

Analyze two component bundles to give a Distribution file:

$ productbuild --synthesize --component aprogram.dSYM --component aprogram2.dSYM Distribution-1
productbuild: Adding component at /Users/mb312/dev_trees/docosx/pkg_examples/aprogram.dSYM
productbuild: Inferred install-location of /Users/mb312/dev_trees/docosx/pkg_examples
productbuild: Adding component at /Users/mb312/dev_trees/docosx/pkg_examples/aprogram2.dSYM
productbuild: Inferred install-location of /Users/mb312/dev_trees/docosx/pkg_examples
productbuild: Wrote synthesized distribution to Distribution-1

Analyze two component packages to give a Distribution file:

$ productbuild --synthesize --package aprogram.pkg --package aprogram2.pkg Distribution-2
productbuild: Wrote synthesized distribution to Distribution-2
$ cat Distribution-2
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<installer-gui-script minSpecVersion="1">
    <pkg-ref id="com.apple.xcode.dsym.aprogram"/>
    <pkg-ref id="com.apple.xcode.dsym.aprogram2"/>
    <options customize="never" require-scripts="false"/>
    <choices-outline>
        <line choice="default">
            <line choice="com.apple.xcode.dsym.aprogram"/>
            <line choice="com.apple.xcode.dsym.aprogram2"/>
        </line>
    </choices-outline>
    <choice id="default"/>
    <choice id="com.apple.xcode.dsym.aprogram" visible="false">
        <pkg-ref id="com.apple.xcode.dsym.aprogram"/>
    </choice>
    <pkg-ref id="com.apple.xcode.dsym.aprogram" version="1.0.0" onConclusion="none">aprogram.pkg</pkg-ref>
    <choice id="com.apple.xcode.dsym.aprogram2" visible="false">
        <pkg-ref id="com.apple.xcode.dsym.aprogram2"/>
    </choice>
    <pkg-ref id="com.apple.xcode.dsym.aprogram2" version="1.0.0" onConclusion="none">aprogram2.pkg</pkg-ref>
</installer-gui-script>

Archive from Distribution file

Build a product archive from a pre-existing Distribution file, using the --distribution flag. The Distribution file refers to pre-existing component .pkg files. If these are not in the current directory, you can give paths to find .pkg files with the --package-path flag.

$ productbuild --distribution Distribution-2 my_archive.pkg
productbuild: Wrote product to my_archive.pkg

Customizing the Distribution file

See: distribution definition file and installer Javascript reference.

Adding system and volume checks

Contents of distro_check.xml
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<installer-gui-script minSpecVersion="1">
    <pkg-ref id="com.apple.xcode.dsym.aprogram"/>
    <pkg-ref id="com.apple.xcode.dsym.aprogram2"/>
    <choices-outline>
        <line choice="default">
            <line choice="com.apple.xcode.dsym.aprogram"/>
            <line choice="com.apple.xcode.dsym.aprogram2"/>
        </line>
    </choices-outline>
    <choice id="default"/>
    <choice id="com.apple.xcode.dsym.aprogram" visible="false">
        <pkg-ref id="com.apple.xcode.dsym.aprogram"/>
    </choice>
    <pkg-ref id="com.apple.xcode.dsym.aprogram" version="1.0.0" onConclusion="none">aprogram.pkg</pkg-ref>
    <choice id="com.apple.xcode.dsym.aprogram2" visible="false">
        <pkg-ref id="com.apple.xcode.dsym.aprogram2"/>
    </choice>
    <pkg-ref id="com.apple.xcode.dsym.aprogram2" version="1.0.0" onConclusion="none">aprogram2.pkg</pkg-ref>
    <installation-check script="installation_check()"/>
    <volume-check script="volume_check()"/>
    <script><![CDATA[

function installation_check () {
    // Check 64 bit
    if (! system.sysctl('hw.cpu64bit_capable')) {
        // need to set error type
        my.result.type = "Fatal"
        my.result.message = "Installer needs 64 bit"
        return false
    }
    return true
}

function volume_check () {
    // check installed file exists
    log_file = my.target.mountpoint + 'tmp/my_postinstall.log'
    if (! system.files.fileExistsAtPath(log_file)) {
        // need to set error type
        my.result.type = "Fatal"
        my.result.message = "No my_postinstall file on volume"
        return false
    }
    return true
}

    ]]></script>
</installer-gui-script>
$ productbuild --distribution distro_check.xml my_archive.pkg
productbuild: Wrote product to my_archive.pkg
$ sudo installer -pkg my_archive.pkg -target /
installer: Package name is 
installer: Upgrading at base path /
installer: The upgrade was successful.

Now make the volume check fail:

$ sudo rm /tmp/my_postinstall.log
$ sudo installer -pkg my_archive.pkg -target /
installer: Cannot install on volume / because it is disabled.
installer: No my_postinstall file on volume

The volume check gets run on every candidate volume; each volume that passes the check is eligible for the install.

Debugging Distribution Javascript with system.log

Getting the Javascript right can be tricky because it is running in a highly specific environment. system.log() can help.

Contents of distro_debug.xml
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<installer-gui-script minSpecVersion="1">
    <pkg-ref id="com.apple.xcode.dsym.aprogram"/>
    <pkg-ref id="com.apple.xcode.dsym.aprogram2"/>
    <choices-outline>
        <line choice="default">
            <line choice="com.apple.xcode.dsym.aprogram"/>
            <line choice="com.apple.xcode.dsym.aprogram2"/>
        </line>
    </choices-outline>
    <choice id="default"/>
    <choice id="com.apple.xcode.dsym.aprogram" visible="false">
        <pkg-ref id="com.apple.xcode.dsym.aprogram"/>
    </choice>
    <pkg-ref id="com.apple.xcode.dsym.aprogram" version="1.0.0" onConclusion="none">aprogram.pkg</pkg-ref>
    <choice id="com.apple.xcode.dsym.aprogram2" visible="false">
        <pkg-ref id="com.apple.xcode.dsym.aprogram2"/>
    </choice>
    <pkg-ref id="com.apple.xcode.dsym.aprogram2" version="1.0.0" onConclusion="none">aprogram2.pkg</pkg-ref>
    <installation-check script="installation_check()"/>
    <script><![CDATA[

function installation_check () {
    // Some debug prints
    system.log('my: ' + system.propertiesOf(my))
    system.log('system: ' + system.propertiesOf(system))
    system.log('system.applications: ' +
        system.propertiesOf(system.applications))
    return true
}

    ]]></script>
</installer-gui-script>
$ productbuild --distribution distro_debug.xml my_archive.pkg
productbuild: Wrote product to my_archive.pkg

Use the -dumplog flag to the installer to see the log.

$ sudo installer -dumplog -pkg my_archive.pkg -target /
installer: Package name is 
installer: Upgrading at base path /
installer: The upgrade was successful.
Mar 24 15:18:04 shanghai installer[67824] <Debug>: JS: my: choice,target,search,product,result
Mar 24 15:18:04 shanghai installer[67824] <Debug>: JS: system: supportedSpecVersion,bundles,openCL,defaults,search,ioregistry,env,files,hasWindowServer,users,applications,version,localizedStandardStringWithFormat,gestalt,localizedStringWithFormat,run,sysctl,propertiesOf,log,numericalCompare,runOnce,compareVersions,localizedString,localizedStandardString
Mar 24 15:18:04 shanghai installer[67824] <Debug>: JS: system.applications: fromIdentifier,all,fromPID
Mar 24 15:18:05 shanghai installer[67824] <Debug>: Product archive /Users/mb312/dev_trees/docosx/pkg_examples/my_archive.pkg trustLevel=100
Mar 24 15:18:06 shanghai installer[67824] <Debug>: -[IFDInstallController(Private) _buildInstallPlan]: location = file://localhost
Mar 24 15:18:06 shanghai installer[67824] <Debug>: -[IFDInstallController(Private) _buildInstallPlan]: file://localhost/Users/mb312/dev_trees/docosx/pkg_examples/my_archive.pkg#aprogram.pkg
Mar 24 15:18:06 shanghai installer[67824] <Debug>: -[IFDInstallController(Private) _buildInstallPlan]: file://localhost/Users/mb312/dev_trees/docosx/pkg_examples/my_archive.pkg#aprogram2.pkg
Mar 24 15:18:06 shanghai installer[67824] <Debug>: Set authorization level to root for session
Mar 24 15:18:06 shanghai installer[67824] <Debug>: Will use PK session
Mar 24 15:18:06 shanghai installer[67824] <Info>: Starting installation:
Mar 24 15:18:06 shanghai installer[67824] <Notice>: Configuring volume "SSD"
Mar 24 15:18:06 shanghai installer[67824] <Info>: Preparing disk for local booted install.
Mar 24 15:18:06 shanghai installer[67824] <Notice>: Free space on "SSD": 8.93 GB (8929431552 bytes).
Mar 24 15:18:06 shanghai installer[67824] <Notice>: Create temporary directory "/var/folders/zz/zyxvpxvq6csfxvn_n0000000000000/T//Install.67824ePvO0B"
Mar 24 15:18:06 shanghai installer[67824] <Notice>: IFPKInstallElement (2 packages)
Mar 24 15:18:06 shanghai installer[67824] <Debug>: Using authorization level of root for IFPKInstallElement
Mar 24 15:18:06 shanghai installer[67824] <Notice>: PackageKit: Enqueuing install with boosting
Mar 24 15:18:07 shanghai installer[67824] <Notice>: Running install actions
Mar 24 15:18:07 shanghai installer[67824] <Notice>: Removing temporary directory "/var/folders/zz/zyxvpxvq6csfxvn_n0000000000000/T//Install.67824ePvO0B"
Mar 24 15:18:07 shanghai installer[67824] <Notice>: Finalize disk "SSD"
Mar 24 15:18:07 shanghai installer[67824] <Notice>: Notifying system of updated components
Mar 24 15:18:07 shanghai installer[67824] <Notice>: 
Mar 24 15:18:07 shanghai installer[67824] <Notice>: **** Summary Information ****
Mar 24 15:18:07 shanghai installer[67824] <Notice>:   Operation      Elapsed time
Mar 24 15:18:07 shanghai installer[67824] <Notice>: -----------------------------
Mar 24 15:18:07 shanghai installer[67824] <Notice>:        disk      0.02 seconds
Mar 24 15:18:07 shanghai installer[67824] <Notice>:      script      0.00 seconds
Mar 24 15:18:07 shanghai installer[67824] <Notice>:        zero      0.01 seconds
Mar 24 15:18:07 shanghai installer[67824] <Notice>:     install      1.03 seconds
Mar 24 15:18:07 shanghai installer[67824] <Notice>:     -total-      1.06 seconds
Mar 24 15:18:07 shanghai installer[67824] <Notice>: 

Adding custom script checks

You can add custom executable scripts or binaries to run via the Javascript functions such as the volume check and the system check.

You first have to enable the allow-external-scripts option in the Distribution file XML:

<options allow-external-scripts="yes"/>

This will cause the installer GUI to ask if you want to allow an external script to check if the software can be installed.

You can then call external programs via the Javascript system.run() function. The programs you call with system.run either need to be on the system PATH that the installer uses, or provided in the installer, usually via the --scripts flag to productbuild. Here’s an example of a command already on the path:

Contents of distro_system_run.xml
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<installer-gui-script minSpecVersion="1">
    <pkg-ref id="com.apple.xcode.dsym.aprogram"/>
    <pkg-ref id="com.apple.xcode.dsym.aprogram2"/>
    <options allow-external-scripts="yes"/>
    <choices-outline>
        <line choice="default">
            <line choice="com.apple.xcode.dsym.aprogram"/>
            <line choice="com.apple.xcode.dsym.aprogram2"/>
        </line>
    </choices-outline>
    <choice id="default"/>
    <choice id="com.apple.xcode.dsym.aprogram" visible="false">
        <pkg-ref id="com.apple.xcode.dsym.aprogram"/>
    </choice>
    <pkg-ref id="com.apple.xcode.dsym.aprogram" version="1.0.0" onConclusion="none">aprogram.pkg</pkg-ref>
    <choice id="com.apple.xcode.dsym.aprogram2" visible="false">
        <pkg-ref id="com.apple.xcode.dsym.aprogram2"/>
    </choice>
    <pkg-ref id="com.apple.xcode.dsym.aprogram2" version="1.0.0" onConclusion="none">aprogram2.pkg</pkg-ref>
    <installation-check script="installation_check()"/>
    <script><![CDATA[

function installation_check () {
    // Run some sh code
    exit_code = system.run('/bin/sh', '-c',
        'echo "Command ran" > /tmp/my_system_run.log; exit 1')
    if (exit_code != 0) {
        // need to set error type
        my.result.type = "Fatal"
        my.result.message = "Test function failed by design"
        return false
    }
    return true
}

    ]]></script>
</installer-gui-script>
$ productbuild --distribution distro_system_run.xml my_archive.pkg
productbuild: Wrote product to my_archive.pkg
$ sudo installer -pkg my_archive.pkg -target /
installer: Error - Test function failed by design

Check the script ran:

$ cat /tmp/my_system_run.log
Command ran

Here’s an example of a custom script:

Contents of distro_custom_run.xml
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<installer-gui-script minSpecVersion="1">
    <pkg-ref id="com.apple.xcode.dsym.aprogram"/>
    <pkg-ref id="com.apple.xcode.dsym.aprogram2"/>
    <options allow-external-scripts="yes"/>
    <choices-outline>
        <line choice="default">
            <line choice="com.apple.xcode.dsym.aprogram"/>
            <line choice="com.apple.xcode.dsym.aprogram2"/>
        </line>
    </choices-outline>
    <choice id="default"/>
    <choice id="com.apple.xcode.dsym.aprogram" visible="false">
        <pkg-ref id="com.apple.xcode.dsym.aprogram"/>
    </choice>
    <pkg-ref id="com.apple.xcode.dsym.aprogram" version="1.0.0" onConclusion="none">aprogram.pkg</pkg-ref>
    <choice id="com.apple.xcode.dsym.aprogram2" visible="false">
        <pkg-ref id="com.apple.xcode.dsym.aprogram2"/>
    </choice>
    <pkg-ref id="com.apple.xcode.dsym.aprogram2" version="1.0.0" onConclusion="none">aprogram2.pkg</pkg-ref>
    <installation-check script="installation_check()"/>
    <script><![CDATA[

function installation_check () {
    // Run a custom script
    exit_code = system.run('my_test_cmd.sh', 'args', 'I', 'passed');
    if (exit_code != 0) {
        // need to set error type
        my.result.type = "Fatal"
        my.result.message = "Test function failed by design"
        return false
    }
    return true
}

    ]]></script>
</installer-gui-script>

Make a directory to contain the test script:

$ mkdir archive_scripts

Use test script to look for any interesting environment variables available to the custom script:

Contents of archive_scripts/my_test_cmd.sh
#!/bin/sh
env > /tmp/my_custom_envs.log
echo "Positional arguments" $@ >> /tmp/my_custom_envs.log
exit 2

The script must be executable:

$ chmod u+x archive_scripts/my_test_cmd.sh

Build the product archive with --scripts flag:

$ productbuild --distribution distro_custom_run.xml --scripts archive_scripts my_archive.pkg
productbuild: Wrote product to my_archive.pkg
$ sudo installer -pkg my_archive.pkg -target /
installer: Error - Test function failed by design

Check the environment variables available to the custom script. We already have /tmp/my_sudo_envs.log with the standard environment variables available as root, so look for the difference:

$ diff /tmp/my_sudo_envs.log /tmp/my_custom_envs.log --old-line-format="" --unchanged-line-format=""
SHELL=/bin/bash
USER=root
SUDO_USER=mb312
SUDO_UID=501
__CF_USER_TEXT_ENCODING=0x0:0:0
USERNAME=root
MAIL=/var/mail/root
PWD=/private/var/folders/zz/zyxvpxvq6csfxvn_n0000000000000/T/com.apple.install.PGDGIdg6
SHLVL=1
SUDO_COMMAND=/usr/sbin/installer -pkg my_archive.pkg -target /
COMMAND_LINE_INSTALL=1
DISPLAY=/tmp/launch-bIbO3P/org.macosforge.xquartz:0
_=/usr/bin/env
Positional arguments args I passed

There do not seem to be any environment variables to tell us where the installer .pkg file is. Here we can work out which volume the installer is installing to with the SUDO_COMMAND variable, but this will only work for command line installs - the installer GUI does not set this variable.

Customizing the installer pages

See distribution definition file for details.

You can customize these pages:

  • Welcome (<welcome ... /> element)
  • Readme (<readme ... />)
  • License (<license ... />)
  • Conclusion (<conclusion ... />)

To do this, you specify the filename containing the custom text, and the format of the file. For example:

<welcome file="welcome.html" mime-type="text/html"/>

The file should be in the Resources/<language>.lproj directory of the installer .pkg, where <language> is a language like “English”, “French” or “Spanish”, or shorthand for these such as “en”, “fr”, “es”. You can provide a Resources directory with the --resources flag to productbuild.

$ mkdir -p my_resources/English.lproj
Contents of my_resources/English.lproj/welcome.html
<html lang="en">
<head>
    <meta http-equiv="content-type" content="text/html; charset=iso-8859-1">
    <title>Install my_archive </title>
</head>
<body>
<font face="Helvetica">
<b>Welcome to the <i>my_archive</i> installer</b>
<p>
This installer will install <i>my_archive</i> on your computer.
</font>
</ul>
</body>
</html>
Contents of distro_welcome.xml
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<installer-gui-script minSpecVersion="1">
    <welcome file="welcome.html" mime-type="text/html"/>
    <pkg-ref id="com.apple.xcode.dsym.aprogram"/>
    <pkg-ref id="com.apple.xcode.dsym.aprogram2"/>
    <choices-outline>
        <line choice="default">
            <line choice="com.apple.xcode.dsym.aprogram"/>
            <line choice="com.apple.xcode.dsym.aprogram2"/>
        </line>
    </choices-outline>
    <choice id="default"/>
    <choice id="com.apple.xcode.dsym.aprogram" visible="false">
        <pkg-ref id="com.apple.xcode.dsym.aprogram"/>
    </choice>
    <pkg-ref id="com.apple.xcode.dsym.aprogram" version="1.0.0" onConclusion="none">aprogram.pkg</pkg-ref>
    <choice id="com.apple.xcode.dsym.aprogram2" visible="false">
        <pkg-ref id="com.apple.xcode.dsym.aprogram2"/>
    </choice>
    <pkg-ref id="com.apple.xcode.dsym.aprogram2" version="1.0.0" onConclusion="none">aprogram2.pkg</pkg-ref>
</installer-gui-script>

We use the --resources flag to add the resources directory:

$ productbuild --distribution distro_welcome.xml --resources my_resources my_archive.pkg
productbuild: Wrote product to my_archive.pkg

The welcome won’t show up in the command line install, so you need to run this installer via the GUI to see the new text.

I used an HTML file here, and that works as expected, for me at least. There are many installers that use .rtf files instead of HTML, but I heard a rumor that productbuild does not deal with these correctly. If you want to use rich text format files with productbuild, you might have to do some post-processing of your installer with – pkgutil --expand my_archive.pkg out_dir; (futz); pkgutil --flatten out_dir my_archive.pkg.