diff --git a/include/class.dynamic_forms.php b/include/class.dynamic_forms.php
index 39e73162b9c675f154a554ca3f11abb9bc7dd9e2..c6dbac5f979e4c40393ad435b45ef297bf9a2362 100644
--- a/include/class.dynamic_forms.php
+++ b/include/class.dynamic_forms.php
@@ -1284,6 +1284,7 @@ class DynamicFormEntry extends VerySimpleModel {
             $a->entry = $this;
 
             try {
+                $field->setForm($this);
                 $val = $field->to_database($field->getClean());
             }
             catch (FieldUnchanged $e) {
diff --git a/setup/cli/modules/class.module.php b/setup/cli/modules/class.module.php
index f5f866b0e48196b81b36040edfa0a3601130463b..01f829dddb05dfaa1820b5e4b91a76da21f22e63 100644
--- a/setup/cli/modules/class.module.php
+++ b/setup/cli/modules/class.module.php
@@ -4,10 +4,6 @@ class Option {
 
     var $default = false;
 
-    function Option() {
-        call_user_func_array(array($this, "__construct"), func_get_args());
-    }
-
     function __construct($options=false) {
         list($this->short, $this->long) = array_slice($options, 0, 2);
         $this->help = (isset($options['help'])) ? $options['help'] : "";
@@ -125,10 +121,6 @@ class Module {
     var $_options;
     var $_args;
 
-    function Module() {
-        call_user_func_array(array($this, '__construct'), func_get_args());
-    }
-
     function __construct() {
         $this->options['help'] = array("-h","--help",
             'action'=>'store_true',
@@ -269,21 +261,36 @@ class Module {
     function parseArgs($argv) {
         $options = $args = array();
         $argv = array_slice($argv, 0);
+        $more_opts = true;
         while ($arg = array_shift($argv)) {
             if (strpos($arg, '=') !== false) {
                 list($arg, $value) = explode('=', $arg, 2);
                 array_unshift($argv, $value);
             }
+            if ($arg == '--') {
+                $more_opts = false;
+                continue;
+            }
+            // Allow multiple simple args like -Dvt
+            if ($arg[0] == '-' && strlen($arg) > 2) {
+                foreach (str_split(substr($arg, 2)) as $O)
+                    array_unshift($argv, "-{$O}");
+                $arg = substr($arg, 0, 2);
+            }
             $found = false;
-            foreach ($this->options as $opt) {
-                if ($opt->short == $arg || $opt->long == $arg) {
-                    if ($opt->handleValue($options, $argv))
-                        array_shift($argv);
-                    $found = true;
+            if ($more_opts && $arg[0] == '-') {
+                foreach ($this->options as $opt) {
+                    if ($opt->short == $arg || $opt->long == $arg) {
+                        if ($nargs = $opt->handleValue($options, $argv))
+                            while ($nargs--)
+                                array_shift($argv);
+                        $found = true;
+                    }
                 }
             }
-            if (!$found && $arg[0] != '-')
+            if (!$found && (!$more_opts || $arg[0] != '-'))
                 $args[] = $arg;
+            // XXX else show help if $strict?
         }
         return array($options, $args);
     }
diff --git a/setup/cli/modules/deploy.php b/setup/cli/modules/deploy.php
index 7d0e87ecc21b86cb596e2b20067028a9f8ba7171..c616e321f9a2c5ad253b3e8c52e0d1da6eedce9f 100644
--- a/setup/cli/modules/deploy.php
+++ b/setup/cli/modules/deploy.php
@@ -24,6 +24,10 @@ class Deployment extends Unpacker {
             'action'=>'store_true',
             'help'=>'Remove files from the destination that are no longer
                 included in this repository');
+        $this->options['git'] = array('-g','--git',
+            'action'=>'store_true',
+            'help'=>'Use `git ls-files -s` as files source. Eliminates
+                possibility of deploying untracked files');
         # super(*args);
         call_user_func_array(array('parent', '__construct'), func_get_args());
     }
@@ -87,12 +91,26 @@ class Deployment extends Unpacker {
         }
     }
 
-    function copyFile($src, $dest) {
+    function writeManifest($root) {
+        $lines = array();
+        foreach ($this->manifest as $F=>$H)
+            $lines[] = "$H $F";
+
+        return file_put_contents($this->include_path.'/.MANIFEST', implode("\n", $lines));
+    }
+
+    function hashContents($file) {
+        $md5 = md5($file);
+        $sha1 = sha1($file);
+        return substr($md5, -20) . substr($sha1, -20);
+    }
+
+    function getEditedContents($src) {
         static $short = false;
         static $version = false;
 
         if (substr($src, -4) != '.php')
-            return parent::copyFile($src, $dest);
+            return false;
 
         if (!$short) {
             $hash = exec('git rev-parse HEAD');
@@ -103,7 +121,7 @@ class Deployment extends Unpacker {
             $version = exec('git describe');
 
         if (!$short || !$version)
-            return parent::copyFile($src, $dest);
+            return false;
 
         $source = file_get_contents($src);
         $source = preg_replace(':<script(.*) src="([^"]+)\.js"></script>:',
@@ -125,10 +143,68 @@ class Deployment extends Unpacker {
             "$1ini_set('$2', '0'); // Set by installer",
             $source);
 
-        if (!file_put_contents($dest, $source))
-            die("Unable to apply rewrite rules to ".$dest);
+        return $source;
+    }
+
+    function copyFile($source, $dest, $hash=false, $mode=0644) {
+        $contents = $this->getEditedContents($source);
+        if ($contents === false)
+            // Regular file
+            return parent::copyFile($source, $dest, $hash, $mode);
+
+        if (!file_put_contents($dest, $contents))
+            $this->fail($dest.": Unable to apply rewrite rules");
+
+        $this->updateManifest($source, $hash);
+        return chmod($dest, $mode);
+    }
+
+    function unpackage($folder, $destination, $recurse=0, $exclude=false) {
+        $use_git = $this->getOption('git', false);
+        if (!$use_git)
+            return parent::unpackage($folder, $destination, $recurse, $exclude);
 
-        return true;
+        // Attempt to read from git using `git ls-files` for deployment
+        if (substr($destination, -1) !== '/')
+            $destination .= '/';
+        $source = $this->source;
+        if (substr($source, -1) != '/')
+            $source .= '/';
+        $local = str_replace(array($source, '{,.}*'), array('',''), $folder);
+
+        $pipes = array();
+        $patterns = array();
+        foreach ((array) $exclude as $x) {
+            $patterns[] = str_replace($source, '', $x);
+        }
+        $X = implode(' --exclude-per-directory=', $patterns);
+        chdir($source.$local);
+        if (!($files = proc_open(
+            "git ls-files -zs --exclude-standard --exclude-per-directory=$X -- .",
+            array(1 => array('pipe', 'w')),
+            $pipes
+        ))) {
+            return parent::unpackage($folder, $destination, $recurse, $exclude);
+        }
+
+        $dryrun = $this->getOption('dry-run', false);
+        $verbose = $this->getOption('verbose') || $dryrun;
+        while ($line = stream_get_line($pipes[1], 255, "\x00")) {
+            list($mode, $hash, , $path) = preg_split('/\s+/', $line);
+            $src = $source.$local.$path;
+            if ($this->exclude($exclude, $src))
+                continue;
+            if (!$this->isChanged($src, $hash))
+                continue;
+            $dst = $destination.$path;
+            if ($verbose)
+                $this->stdout->write($dst."\n");
+            if ($dryrun)
+                continue;
+            if (!is_dir(dirname($dst)))
+                mkdir(dirname($dst), 0751, true);
+            $this->copyFile($src, $dst, $hash, octdec($mode));
+        }
     }
 
     function run($args, $options) {
@@ -147,16 +223,19 @@ class Deployment extends Unpacker {
         $include = ($upgrade) ? $this->get_include_dir()
             : ($options['include'] ? $options['include']
                 : rtrim($this->destination, '/')."/include");
-        $include = rtrim($include, '/').'/';
+        $this->include_path = $include = rtrim($include, '/').'/';
 
         # Locate the upload folder
-        $root = $this->find_root_folder();
+        $root = $this->source = $this->find_root_folder();
         $rootPattern = str_replace("\\","\\\\", $root); //need for windows case
 
-        $exclusions = array("$rootPattern/include", "$rootPattern/.git*",
+        # Prime the manifest system
+        $this->readManifest($this->destination.'/.MANIFEST');
+
+        $exclusions = array("$rootPattern/include/*", "$rootPattern/.git*",
             "*.sw[a-z]","*.md", "*.txt");
         if (!$options['setup'])
-            $exclusions[] = "$rootPattern/setup";
+            $exclusions[] = "$rootPattern/setup/*";
 
         # Unpack everything but the include/ folder
         $this->unpackage("$root/{,.}*", $this->destination, -1,
@@ -177,6 +256,8 @@ class Deployment extends Unpacker {
                 array("ost-config.php","settings.php","plugins/",
                 "*/.htaccess"));
         }
+
+        $this->writeManifest($this->destination);
     }
 }
 
diff --git a/setup/cli/modules/package.php b/setup/cli/modules/package.php
new file mode 100644
index 0000000000000000000000000000000000000000..77a86a9446382cd010b9972bcf6e854ee9557367
--- /dev/null
+++ b/setup/cli/modules/package.php
@@ -0,0 +1,121 @@
+<?php
+require_once "deploy.php";
+
+class Packager extends Deployment {
+    var $prologue = "Creates an osTicket distribution ZIP file";
+
+    var $epilog =
+        "Packaging is based on the `deploy` and `test` cli apps. After
+        running the tests, the system is deployed into a temporary staging
+        area using the files tracked in git if supported. Afterwards, the
+        staging area is packaged as a ZIP file.";
+
+    var $options = array(
+        'format' => array('-F','--format',
+            'default'=>'zip',
+            'help'=>'Output the package in this format. Supported formats are
+                "zip" (the default), and "targz"'
+        ),
+        'skip-test' => array('-S','--skip-test',
+            'action'=>'store_true', 'default'=>false,
+            'help'=>'Skip regression testing (NOT RECOMMENDED)',
+        ),
+    );
+    var $arguments = array();
+
+    function __construct() {
+        // Skip options added to the deploy — options and arguments are
+        // forced in this module
+        call_user_func_array(array('Module', '__construct'), func_get_args());
+    }
+
+    function run($args, $options) {
+        // Set some forced args and options
+        $temp = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+        $stage_path = $temp . 'osticket'
+            . substr(md5('osticket-stage'.getmypid().getcwd()), -8);
+        $args['install-path'] = $stage_path . '/upload';
+
+        // Deployment will auto-create the staging area
+
+        // Ensure that the staging path is cleaned up on exit
+        register_shutdown_function(function() use ($stage_path) {
+            $delTree = function($dir) use (&$delTree) {
+                $files = array_diff(scandir($dir), array('.','..'));
+                foreach ($files as $file) {
+                    (is_dir("$dir/$file")) ? $delTree("$dir/$file") : unlink("$dir/$file");
+                }
+                return rmdir($dir);
+            };
+            return $delTree($stage_path);
+        });
+
+        $options['setup'] = true;
+        $options['git'] = true;
+        $options['verbose'] = true;
+
+        $options['clean'] = false;
+        $options['dry-run'] = false;
+        $options['include'] = false;
+
+        $this->_args = $args;
+        $this->_options = $options;
+
+        // TODO: Run the testing applet first
+        $root = $this->find_root_folder();
+        if (!$this->getOption('skip-test') && $this->run_tests($root) > 0)
+            $this->fail("Regression tests failed. Cowardly refusing to package");
+
+        // Run the deployment
+        // NOTE: The deployment will change the working directory
+        parent::run($args, $options);
+
+        // Deploy the `setup/scripts` folder to `/scripts`
+        $root = $this->source;
+        Unpacker::unpackage("$root/setup/scripts/{,.}*", "$stage_path/scripts", -1);
+
+        // Package up the staging area
+        $version = exec('git describe');
+        switch (strtolower($this->getOption('format'))) {
+        case 'zip':
+        default:
+            $this->packageZip("$root/osTicket-$version.zip", $stage_path);
+        }
+    }
+
+    function run_tests($root) {
+        return (require "$root/setup/test/run-tests.php");
+    }
+
+    function packageZip($name, $path) {
+        $zip = new ZipArchive();
+        if (!$zip->open($name, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true)
+            return false;
+
+        $php56 = version_compare(phpversion(), '5.6.0', '>');
+        $addFiles = function($dir) use (&$addFiles, $zip, $path, $php56) {
+            $files = array_diff(scandir($dir), array('.','..'));
+            $path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+            foreach ($files as $file) {
+                $full = "$dir/$file";
+                $local = str_replace($path, '', $full);
+                if (is_dir($full))
+                    $addFiles($full);
+                else
+                    // XXX: AddFile() will keep the file open and run
+                    //      out of OS open file handles
+                    $zip->addFromString($local, file_get_contents($full));
+                    // This only works on PHP >= v5.6
+                    if ($php56) {
+                        // Set the Unix mode of the file
+                        $stat = stat($full);
+                        $zip->setExternalAttributesName($local, ZipArchive::OPSYS_UNIX, $stat['mode']);
+                    }
+            }
+        };
+        $addFiles($path);
+        return $zip->close();
+
+    }
+}
+Module::register('package', 'Packager');
diff --git a/setup/cli/modules/unpack.php b/setup/cli/modules/unpack.php
index ecc6823d85bcd00b9314d477c61d1b434795617f..cc4b9d9d93a4058bfadab56239fd096540004b71 100644
--- a/setup/cli/modules/unpack.php
+++ b/setup/cli/modules/unpack.php
@@ -34,6 +34,10 @@ class Unpacker extends Module {
              main installation path.",
     );
 
+    var $manifest;
+    var $source;
+    var $destination;
+
     function realpath($path) {
         return ($p = realpath($path)) ? $p : $path;
     }
@@ -88,8 +92,52 @@ class Unpacker extends Module {
         return false;
     }
 
-    function copyFile($src, $dest) {
-        return copy($src, $dest);
+    function readManifest($file) {
+        if (isset($this->manifest))
+            return @$this->manifest[$file] ?: null;
+
+        $this->manifest = $lines = array();
+        $path = $this->get_include_dir() . '/.MANIFEST';
+        if (!is_file($path))
+            return null;
+
+        if (!preg_match_all('/^(\w+) (.+)$/mu', file_get_contents($path),
+            $lines, PREG_PATTERN_ORDER)
+        ) {
+            return null;
+        }
+
+        $this->manifest = array_combine($lines[2], $lines[1]);
+        return @$this->manifest[$file] ?: null;
+    }
+
+    function hashFile($file) {
+        static $hashes = array();
+
+        if (!isset($hashes[$file])) {
+            $md5 = md5_file($file);
+            $sha1 = sha1_file($file);
+            $hash = substr($md5, -20) . substr($sha1, -20);
+            $hashes[$file] = $hash;
+        }
+        return $hashes[$file];
+    }
+
+    function isChanged($source, $hash=false) {
+        $local = str_replace($this->source.'/', '', $source);
+        $hash = $hash ?: $this->hashFile($source);
+        return $this->readManifest($local) != $hash;
+    }
+
+    function updateManifest($file, $hash=false) {
+        $hash = $hash ?: $this->hashFile($file);
+        $local = str_replace($this->source.'/', '', $file);
+        $this->manifest[$local] = $hash;
+    }
+
+    function copyFile($src, $dest, $hash=false, $mode=0644) {
+        $this->updateManifest($src, $hash);
+        return copy($src, $dest) && chmod($dest, $mode);
     }
 
     /**
@@ -116,15 +164,17 @@ class Unpacker extends Module {
             if ($this->exclude($exclude, $file))
                 continue;
             if (is_file($file)) {
-                if (!is_dir($destination) && !$dryrun)
-                    mkdir($destination, 0751, true);
                 $target = $destination . basename($file);
-                if (is_file($target) && (md5_file($target) == md5_file($file)))
+                $hash = $this->hashFile($file);
+                if (is_file($target) && !$this->isChanged($file, $hash))
                     continue;
                 if ($verbose)
                     $this->stdout->write($target."\n");
-                if (!$dryrun)
-                    $this->copyFile($file, $target);
+                if ($dryrun)
+                    continue;
+                if (!is_dir($destination))
+                    mkdir($destination, 0751, true);
+                $this->copyFile($file, $target, $hash);
             }
         }
         if ($recurse) {
@@ -144,7 +194,15 @@ class Unpacker extends Module {
     }
 
     function get_include_dir() {
+        static $location;
+
+        if (isset($location))
+            return $location;
+
         $bootstrap_php = $this->destination . '/bootstrap.php';
+        if (!is_file($bootstrap_php))
+            return @$this->include_path ?: '';
+
         $lines = preg_grep("/define\s*\(\s*'INCLUDE_DIR'/",
             explode("\n", file_get_contents($bootstrap_php)));
 
@@ -153,9 +211,9 @@ class Unpacker extends Module {
         if (!defined('ROOT_DIR'))
             define('ROOT_DIR', rtrim($this->destination, '/').'/');
         foreach ($lines as $line)
-            eval($line);
+            @eval($line);
 
-        return rtrim(INCLUDE_DIR, '/').'/';
+        return $location = rtrim(INCLUDE_DIR, '/').'/';
     }
 
     function run($args, $options) {
@@ -169,7 +227,7 @@ class Unpacker extends Module {
         $upgrade = file_exists("{$this->destination}/main.inc.php");
 
         # Locate the upload folder
-        $upload = $this->find_upload_folder();
+        $upload = $this->source = $this->find_upload_folder();
 
         # Unpack the upload folder to the destination, except the include folder
         if ($upgrade)
diff --git a/setup/cli/package.php b/setup/cli/package.php
deleted file mode 100755
index b758f63c6fa3e40f3fab39b5ff8e56c0582cef28..0000000000000000000000000000000000000000
--- a/setup/cli/package.php
+++ /dev/null
@@ -1,162 +0,0 @@
-#!/usr/bin/env php
-<?php
-
-if (php_sapi_name() != 'cli')
-    die("Only command-line packaging is supported");
-
-$stage_folder = "stage";
-$stage_path = dirname(__file__) . '/' . $stage_folder;
-
-function get_osticket_root_path() {
-    # Hop up to the root folder
-    $start = dirname(__file__);
-    for (;;) {
-        if (file_exists($start . '/main.inc.php')) break;
-        $start .= '/..';
-    }
-    return realpath($start);
-}
-
-function run_tests($root) {
-    return (require "$root/setup/test/run-tests.php");
-}
-
-# Check PHP syntax across all php files
-function glob_recursive($pattern, $flags = 0) {
-    $files = glob($pattern, $flags);
-    foreach (glob(dirname($pattern).'/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) {
-        $files = array_merge($files,
-            glob_recursive($dir.'/'.basename($pattern), $flags));
-    }
-    return $files;
-}
-
-$root = get_osticket_root_path();
-
-function exclude($pattern, $match) {
-    if (is_array($pattern)) {
-        foreach ($pattern as $p)
-            if (fnmatch($p, $match))
-                return true;
-    }
-    else
-        return fnmatch($pattern, $match);
-    return false;
-}
-
-function package($pattern, $destination, $recurse=false, $exclude=false) {
-    global $root, $stage_path;
-    $search = $root . '/' . $pattern;
-    echo "Packaging " . $search . "\n";
-    foreach (glob($search, GLOB_BRACE|GLOB_NOSORT) as $file) {
-        if (is_file($file)) {
-            if ($exclude && exclude($exclude, $file))
-                continue;
-            if (!is_dir("$stage_path/$destination"))
-                mkdir("$stage_path/$destination", 0777, true);
-            copy($file, $stage_path . '/' . $destination . '/' . basename($file));
-        }
-    }
-    if ($recurse) {
-        foreach (glob(dirname($search).'/*', GLOB_ONLYDIR|GLOB_NOSORT) as $dir) {
-            if ($exclude && exclude($exclude, $dir))
-                continue;
-            package(dirname($pattern).'/'.basename($dir).'/'.basename($pattern),
-                $destination.'/'.basename($dir),
-                $recurse-1, $exclude);
-        }
-    }
-}
-
-# Run tests before continuing
-if (run_tests($root) > 0)
-    die("Regression tests failed. Cowardly refusing to package\n");
-
-# Create the stage folder for the install files
-if (!is_dir($stage_path))
-    mkdir($stage_path);
-else {
-    $dirs = array();
-    foreach (glob_recursive($stage_path . '/*') as $file)
-        if (is_dir($file))
-            $dirs[] = $file;
-        else
-            unlink($file);
-    sort($dirs);
-    foreach (array_reverse($dirs) as $dir)
-        rmdir($dir);
-}
-
-# Source code goes into 'upload'
-mkdir($stage_path . '/upload');
-
-# Load the root directory files
-package("*.php", 'upload/');
-package("web.config", 'upload/');
-
-# Load the client interface
-foreach (array('assets','css','images','js') as $dir)
-    package("$dir/*", "upload/$dir", -1, "*less");
-
-# Load API and pages
-package('api/{,.}*', 'upload/api');
-package('pages/{,.}*', 'upload/pages');
-
-# Load the knowledgebase
-package("kb/*.php", "upload/kb");
-
-# Load the staff interface
-package("scp/*.php", "upload/scp/", -1);
-foreach (array('css','images','js') as $dir)
-    package("scp/$dir/*", "upload/scp/$dir", -1);
-
-# Load in the scripts
-mkdir("$stage_path/scripts/");
-package("setup/scripts/*", "scripts/", -1, "*stage");
-
-# Load the heart of the system
-package("include/{,.}*", "upload/include", -1, array('*ost-config.php', '*.sw[a-z]','plugins/*'));
-
-# Include the installer
-package("setup/*.{php,txt,html}", "upload/setup", -1, array("*scripts","*test","*stage"));
-foreach (array('css','images','js') as $dir)
-    package("setup/$dir/*", "upload/setup/$dir", -1);
-package("setup/inc/streams/*.sql", "upload/setup/inc/streams", -1);
-
-# Load the license and documentation
-package("*.{txt,md}", "");
-
-#Rename markdown as text TODO: Do html version before rename.
-if(($mds = glob("$stage_path/*.md"))) {
-    foreach($mds as $md)
-        rename($md, preg_replace('/\.md$/', '.txt', $md));
-}
-
-# Make an archive of the stage folder
-$version = exec('git describe');
-$hash = exec('git rev-parse HEAD');
-$short = substr($hash, 0, 7);
-
-$pwd = getcwd();
-chdir($stage_path);
-
-// Replace THIS_VERSION in the stage/ folder
-
-shell_exec("sed -ri -e \"
-    s/( *)define\('THIS_VERSION'.*/\\1define('THIS_VERSION', '$version');/
-    s/( *)define\('GIT_VERSION'.*/\\1define('GIT_VERSION', '$short');/
-    \" upload/bootstrap.php");
-shell_exec("find upload -name '*.php' -print0 | xargs -0 sed -i -e '
-    s:<script\(.*\) src=\"\(.*\).js\"></script>:<script\\1 src=\"\\2.js?$short\"></script>:
-    s:<link\(.*\) href=\"\(.*\)\.css\"\(.*\)*/*>:<link\\1 href=\"\\2.css?$short\"\\3>:
-   '");
-shell_exec("find upload -name '*.php' -print0 | xargs -0 sed -i -e \"
-    s/\( *\)ini_set( *'display_errors'[^])]*);/\\1ini_set('display_errors', 0);/
-    s/\( *\)ini_set( *'display_startup_errors'[^])]*);/\\1ini_set('display_startup_errors', 0);/
-    \"");
-
-shell_exec("tar cjf '$pwd/osTicket-$version.tar.bz2' *");
-shell_exec("zip -r '$pwd/osTicket-$version.zip' *");
-
-chdir($pwd);
-?>
diff --git a/setup/doc/package.md b/setup/doc/package.md
new file mode 100644
index 0000000000000000000000000000000000000000..5570bb60931b910e3281e29ee5d326c938f6eae7
--- /dev/null
+++ b/setup/doc/package.md
@@ -0,0 +1,21 @@
+Creating an osTicket distribution
+=================================
+osTicket is packaged using an included script. Access to the packaging
+system is provided via the `manage.php` CLI app.
+
+The packaging system extends the deployment system in order to remove
+similar code between the two processes. Where possible, the files reported
+to be part of the git repository are used in the packaging process, which
+removes the possibility of experimental files and those ignored by git from
+being added to the distribution.
+
+More information is available via the automated help output.
+
+    php setup/cli/manage.php package --help
+
+Creating the ZIP file
+---------------------
+To package the system using the defaults (as a ZIP file), just invoke the
+packager with no other options.
+
+    php setup/cli/manage.php package
diff --git a/setup/test/tests/stubs.php b/setup/test/tests/stubs.php
index 78078ed919aa21b575ba480d69d46e5c6c92671f..b40053bd26531d1fd4a83a17253e35aef2adb249 100644
--- a/setup/test/tests/stubs.php
+++ b/setup/test/tests/stubs.php
@@ -109,6 +109,7 @@ class ZipArchive {
     function addFromString() {}
     function getFromIndex() {}
     function setCommentName() {}
+    function setExternalAttributesName() {}
 }
 
 class finfo {