Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/guides/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,5 +121,23 @@ Commands the build runs (`npm run build`, Composer) are silent unless they fail.
`--verbose` (`wptoolkit-build package --verbose`), set `"verbose": true` in `wptoolkit.json`, or in a
`build.php` call `->verbose()` for every command or `->runVerbose('npm run build')` for one.

A `build.php` gives you the rest:

```php
Build::plugin(__DIR__)->run('npm run build')->zip(); // dist/{slug}-{version}.zip + .sha256
Build::plugin(__DIR__)->run('npm run build')->toDirectory('dist/{slug}'); // the same build, as a folder
$files = Build::plugin(__DIR__)->dryRun(); // what the exclude rules leave; runs nothing
```

- `zip()` and `toDirectory()` **print the summary themselves**; don't `echo $report->summary()` as well.
The returned report is for scripts that need the numbers (`$report->sha256`, `->bytes`, `->files`).
- `->quiet()` (`--quiet`, `"quiet": true`) prints only warnings; failures are still exceptions.
- `toDirectory()` (`--dir=PATH`) runs every step and the same verification as `zip()`. Use it to rsync or
commit to SVN, to mount the built plugin in a test site, or to inspect what ships. The folder is
**replaced**, so it must be for output only: the project itself, a folder above it, or an existing
folder that is neither empty nor an earlier build of this project is refused.
- `dryRun()` (`--dry-run`) runs no commands and no verification, so files that steps create (built
assets, `vendor/`) are not in its list. For "what exactly ships", use `toDirectory()`.

Next: [routes and security](routes-and-security.md), [data](data.md),
[admin and front end](admin-and-front-end.md).
4 changes: 3 additions & 1 deletion docs/phases/track-p-packager.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ php wptoolkit/bin/wptoolkit package # standalone install (no Composer)
Flags override config: `--out`, `--zip-name`, `--no-build`, `--no-composer`, `--no-scope`, `--pot`,
`--json` (machine-readable report for CI). `--verbose` (added 2026-10-12) prints each command's
output as it runs; `"verbose": true` and `Build::verbose()` / `runVerbose()` do the same from config
and from a build script.
and from a build script. `--quiet` / `Build::quiet()` print only warnings. `--dir=PATH` /
`Build::toDirectory()` (added 2026-10-12) write a folder instead of a zip, with the same steps and
verification; the target is replaced only if it is empty or an earlier build of the same project.

**Configuration** is one optional `wptoolkit.json` at the project root. It's JSON so PHP reads it
without dependencies, and it has a JSON Schema so editors autocomplete it:
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/work-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,6 @@ One dated entry per working session: what landed, what was learned, what's next.
which ADR-0014 promised and 0.x's `Autoloader::init()` used to do. The E2E fixtures now rely on it.
- **Landed:** `Build::verbose()`, `runVerbose()`, `--verbose` and `"verbose": true`: command output streamed as it
runs (`StreamingCommandRunner`); a failure after streaming repeats only the last 20 lines.
- **Landed:** `Build::toDirectory()` (`--dir`) and `Build::quiet()` (`--quiet`); packaging moved out of `Build` into
`ZipPackager` / `DirectoryPackager`. **Learned:** a folder target compared as a string let `.` and a
different letter case through as "not the project" — paths are now resolved and compared without case.
15 changes: 12 additions & 3 deletions packages/build/bin/wptoolkit-build
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
/**
* wptoolkit-build — package a WordPress plugin or theme from wptoolkit.json (or defaults).
*
* vendor/bin/wptoolkit-build package [--project=DIR] [--zip=PATH] [--dry-run] [--verbose]
* vendor/bin/wptoolkit-build package [--project=DIR] [--zip=PATH | --dir=PATH] [--dry-run] [--verbose] [--quiet]
* vendor/bin/wptoolkit-build verify FILE.zip
* vendor/bin/wptoolkit-build doctor [--project=DIR]
* vendor/bin/wptoolkit-build init [--project=DIR]
Expand Down Expand Up @@ -60,6 +60,13 @@ try {
if (in_array('--verbose', $argv, true)) {
$build->verbose();
}
if (in_array('--quiet', $argv, true)) {
$build->quiet();
}
if (isset($options['dir'])) {
$build->toDirectory($options['dir']);
exit(0);
}
$build->zip($options['zip'] ?? $zip);
exit(0);

Expand Down Expand Up @@ -101,8 +108,10 @@ try {

default:
fwrite(STDOUT, "Usage:\n"
. " wptoolkit-build package [--project=DIR] [--zip=PATH] [--dry-run] [--verbose]\n"
. " --verbose print the output of every command as it runs\n"
. " wptoolkit-build package [--project=DIR] [--zip=PATH | --dir=PATH] [--dry-run] [--verbose] [--quiet]\n"
. " --dir=PATH write a folder instead of a zip (replaced if it is an earlier build)\n"
. " --verbose print the output of every command as it runs\n"
. " --quiet print only warnings and errors\n"
. " wptoolkit-build verify FILE.zip\n"
. " wptoolkit-build doctor [--project=DIR]\n"
. " wptoolkit-build init [--project=DIR]\n");
Expand Down
136 changes: 67 additions & 69 deletions packages/build/src/Build.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,6 @@
*/
final class Build
{
/** 1980-01-01, the earliest time a zip entry can hold. */
private const FIXED_MTIME = 315532800;

/** Never copied into staging. */
private const NEVER_STAGED = ['.git', 'node_modules', '.svn'];

Expand All @@ -67,6 +64,8 @@ final class Build

private bool $verbose = false;

private bool $quiet = false;

/** @var list<string|PatternSource> */
private array $excludes = [];

Expand Down Expand Up @@ -151,6 +150,17 @@ public function verbose(bool $verbose = true): self
return $this;
}

/**
* Print nothing but warnings: no progress lines and no summary. The report is still returned,
* and a failure is still an exception. Wins over verbose().
*/
public function quiet(bool $quiet = true): self
{
$this->quiet = $quiet;

return $this;
}

/**
* Reinstall Composer dependencies in staging; `noDev` drops dev packages.
*/
Expand Down Expand Up @@ -240,29 +250,69 @@ public function withOutput(Closure $output): self

/**
* Run the build and write the zip. `{slug}` and `{version}` are replaced; a relative path is
* relative to the project. A `.sha256` file is written next to it.
* relative to the project. A `.sha256` file is written next to it. The summary is printed for
* you (see quiet()); the returned report is for scripts that need the numbers.
*/
public function zip(string $target = 'dist/{slug}-{version}.zip'): BuildReport
{
if (!class_exists(ZipArchive::class)) {
throw new BuildException('The PHP zip extension is required to build packages.');
}

$version = ($this->version ?? Version::fromHeader())->resolve($this->project, $this->runner);
$version = $this->resolveVersion();
$zip = $this->absolute(strtr($target, ['{slug}' => $this->project->slug, '{version}' => $version]));
$staging = sys_get_temp_dir() . '/wptoolkit-build-' . bin2hex(random_bytes(6)) . '/' . $this->project->slug;

$context = new BuildContext($this->project, $staging, $version, $this->runner, $this->output, $this->verbose);
return $this->produce($version, [dirname($zip)], static fn (BuildContext $context): BuildReport => (new ZipPackager())->write($context, $zip));
}

/**
* Run the same build and verification, and write the result as a folder instead of a zip — to
* rsync or commit to SVN, to mount in a test site, or to look at exactly what ships.
*
* The folder is replaced, so it must be for output only: a target that is the project or a
* folder above it is refused, and so is an existing folder that is neither empty nor an
* earlier build of this project. Nothing is written if a step or verification fails.
*/
public function toDirectory(string $target = 'dist/{slug}'): BuildReport
{
$version = $this->resolveVersion();
$directory = DirectoryPackager::normalize($this->absolute(strtr($target, ['{slug}' => $this->project->slug, '{version}' => $version])));
$packager = new DirectoryPackager();
$packager->assertSafe($this->project, $directory); // before any command runs

$skip = [$directory];
if (dirname($directory) !== $this->project->root) {
$skip[] = dirname($directory); // e.g. dist/, which may hold earlier zips and builds
}

return $this->produce($version, $skip, static fn (BuildContext $context): BuildReport => $packager->write($context, $directory));
}

private function resolveVersion(): string
{
return ($this->version ?? Version::fromHeader())->resolve($this->project, $this->runner);
}

/**
* The pipeline both outputs share: Prepare → stage → Transform → prune → Verify → write.
*
* @param list<string> $notStaged Absolute paths left out of the staging copy (the output's own location).
* @param Closure(BuildContext): BuildReport $write
*/
private function produce(string $version, array $notStaged, Closure $write): BuildReport
{
$staging = sys_get_temp_dir() . '/wptoolkit-build-' . bin2hex(random_bytes(6)) . '/' . $this->project->slug;
$context = new BuildContext($this->project, $staging, $version, $this->runner, $this->output, $this->verbose, $this->quiet);
$context->say(sprintf('Building %s %s (%s)', $this->project->slug, $version, $this->project->type));

try {
$this->runPhase(Phase::Prepare, $context);
$this->stage($staging, $zip);
$this->stage($staging, $notStaged);
$this->runPhase(Phase::Transform, $context);
$this->prune($staging);
$this->runPhase(Phase::Verify, $context, [new Verify($this->forbid, $this->maxSizeMb)]);

$report = $this->package($context, $zip);
$report = $write($context);
$context->say($report->summary());

return $report;
Expand All @@ -282,7 +332,7 @@ public function dryRun(): array
$staging = sys_get_temp_dir() . '/wptoolkit-dryrun-' . bin2hex(random_bytes(6)) . '/' . $this->project->slug;

try {
$this->stage($staging, $this->project->root . '/dist/.dry-run.zip');
$this->stage($staging, [$this->project->root . '/dist']);
$this->prune($staging);

$files = [];
Expand Down Expand Up @@ -324,18 +374,21 @@ private function runPhase(Phase $phase, BuildContext $context, array $extra = []
}
}

private function stage(string $staging, string $zip): void
/**
* @param list<string> $notStaged Absolute paths to leave out.
*/
private function stage(string $staging, array $notStaged): void
{
$root = $this->project->root;
$outputDirectory = str_replace('\\', '/', dirname($zip));
$skipped = array_map(static fn (string $path): string => rtrim(str_replace('\\', '/', $path), '/'), $notStaged);
mkdir($staging, 0777, true);

$items = new RecursiveIteratorIterator(
new \RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
static function (\SplFileInfo $item) use ($outputDirectory): bool {
static function (\SplFileInfo $item) use ($skipped): bool {
$path = str_replace('\\', '/', $item->getPathname());
return !in_array($item->getFilename(), self::NEVER_STAGED, true) && $path !== $outputDirectory;
return !in_array($item->getFilename(), self::NEVER_STAGED, true) && !in_array($path, $skipped, true);
}
),
RecursiveIteratorIterator::SELF_FIRST
Expand Down Expand Up @@ -387,61 +440,6 @@ private function expand(array $patterns): array
return $expanded;
}

private function package(BuildContext $context, string $zipPath): BuildReport
{
if (!is_dir(dirname($zipPath))) {
mkdir(dirname($zipPath), 0777, true);
}
if (is_file($zipPath)) {
unlink($zipPath);
}

$files = [];
$sizes = [];
$total = 0;
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($context->staging, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
$relative = substr(str_replace('\\', '/', $file->getPathname()), strlen($context->staging) + 1);
$files[$relative] = $file->getPathname();
$top = explode('/', $relative)[0] . (str_contains($relative, '/') ? '/' : '');
$sizes[$top] = ($sizes[$top] ?? 0) + $file->getSize();
$total += $file->getSize();
}
ksort($files, SORT_STRING); // deterministic order

$archive = new ZipArchive();
if ($archive->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new BuildException(sprintf('Cannot write %s.', $zipPath));
}

// A single folder named after the slug, the way WordPress expects plugin/theme zips.
// Fixed timestamps and permissions: the same sources produce the same bytes, so a checksum
// proves what was released. libzip also reads the file's own mtime, hence the touch().
$prefix = $this->project->slug . '/';
foreach ($files as $relative => $absolute) {
$name = $prefix . $relative;
touch($absolute, self::FIXED_MTIME);
$archive->addFile($absolute, $name);
$archive->setMtimeName($name, self::FIXED_MTIME);
$archive->setExternalAttributesName($name, ZipArchive::OPSYS_UNIX, 0100644 << 16);
}
$archive->close();

$sha256 = (string) hash_file('sha256', $zipPath);
file_put_contents($zipPath . '.sha256', $sha256 . ' ' . basename($zipPath) . PHP_EOL);
arsort($sizes);

return new BuildReport(
$zipPath,
$sha256,
$context->version,
$total,
array_map(static fn (string $f): string => $prefix . $f, array_keys($files)),
$sizes,
$context->warnings
);
}

private function absolute(string $path): string
{
$path = str_replace('\\', '/', $path);
Expand Down
15 changes: 12 additions & 3 deletions packages/build/src/BuildContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public function __construct(
public readonly string $version,
public readonly CommandRunner $runner,
private readonly Closure $output,
public readonly bool $verbose = false
public readonly bool $verbose = false,
public readonly bool $quiet = false
) {
}

Expand Down Expand Up @@ -83,15 +84,23 @@ private static function lines(string $output): array
return $output === '' ? [] : explode("\n", $output);
}

/**
* Progress output. Nothing is printed for a quiet build.
*/
public function say(string $line): void
{
($this->output)($line);
if (!$this->quiet) {
($this->output)($line);
}
}

/**
* Something a person should read. Recorded on the report and printed even for a quiet build.
*/
public function warn(string $line): void
{
$this->warnings[] = $line;
$this->say(' ! ' . $line);
($this->output)(' ! ' . $line);
}

/**
Expand Down
19 changes: 15 additions & 4 deletions packages/build/src/BuildReport.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@
final class BuildReport
{
/**
* @param list<string> $files Paths inside the zip (under the top-level folder), sorted.
* @param string $zip The zip written, or '' for a directory build.
* @param string $sha256 The zip's checksum, or '' for a directory build.
* @param list<string> $files Paths inside the zip (under its top-level folder), or relative to the
* directory for a directory build; sorted.
* @param array<string, int> $largestDirectories Top-level directory => bytes, largest first.
* @param list<string> $warnings
* @param string $directory The folder written by `toDirectory()`, or '' for a zip build.
*/
public function __construct(
public readonly string $zip,
Expand All @@ -25,17 +29,24 @@ public function __construct(
public readonly int $bytes,
public readonly array $files,
public readonly array $largestDirectories,
public readonly array $warnings
public readonly array $warnings,
public readonly string $directory = ''
) {
}

/**
* What `zip()` and `toDirectory()` print when they finish; you don't need to echo it yourself.
*/
public function summary(): string
{
$lines = [sprintf('%s %s, %d files, %.1f KB', basename($this->zip), $this->version, count($this->files), $this->bytes / 1024)];
$name = $this->directory !== '' ? basename($this->directory) . '/' : basename($this->zip);
$lines = [sprintf('%s %s, %d files, %.1f KB', $name, $this->version, count($this->files), $this->bytes / 1024)];
foreach (array_slice($this->largestDirectories, 0, 5, true) as $directory => $bytes) {
$lines[] = sprintf(' %-30s %8.1f KB', $directory, $bytes / 1024);
}
$lines[] = 'sha256 ' . $this->sha256;
if ($this->sha256 !== '') {
$lines[] = 'sha256 ' . $this->sha256;
}

return implode(PHP_EOL, $lines);
}
Expand Down
Loading
Loading