From ea732c1881b5e49836485aebb55fd07c1c9f4570 Mon Sep 17 00:00:00 2001 From: Jon Waldstein Date: Fri, 5 Jun 2026 09:28:08 -0400 Subject: [PATCH 1/6] Add replace-version command Adds a standalone `pup replace-version ` command that writes a given version number into all files configured under .puprc paths.versions. Previously this only happened implicitly during a dev build/package; this exposes it as its own command for release/zip prep. Supports --dev and --root, and includes docs plus CLI tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/commands.md | 23 +++++++ src/App.php | 1 + src/Commands/ReplaceVersion.php | 80 +++++++++++++++++++++++ tests/cli/Commands/ReplaceVersionCest.php | 78 ++++++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 src/Commands/ReplaceVersion.php create mode 100644 tests/cli/Commands/ReplaceVersionCest.php diff --git a/docs/commands.md b/docs/commands.md index ec94b9b..2bce8d5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -11,6 +11,7 @@ * [`pup i18n`](/docs/commands.md#pup-i18n) * [`pup info`](/docs/commands.md#pup-info) * [`pup package`](/docs/commands.md#pup-package) +* [`pup replace-version`](/docs/commands.md#pup-replace-version) * [`pup workflow`](/docs/commands.md#pup-workflow) * [`pup zip`](/docs/commands.md#pup-zip) * [`pup zip-name`](/docs/commands.md#pup-zip-name) @@ -273,6 +274,28 @@ composer -- pup package | `--root` | **Optional.** Run the command from a different directory from the current. | +## `pup replace-version` +Replaces the version number in all of your project's [version files](https://github.com/stellarwp/pup/blob/main/docs/configuration.md#paths-versions) with the version you provide. + +This command iterates over every entry in your `.puprc` file's [`paths.versions`](/docs/configuration.md#pathsversions) array and rewrites the matched version number using the supplied `version` argument. It is handy when preparing a release or staging a zip, where you want to bump the version in place without running a full `pup package`. + +Unlike `pup package`, this command writes the changes directly to your working files and does **not** restore them afterward. If you want to undo the changes, use your version control system (e.g. `git checkout`). + +### Usage +```bash +pup replace-version [--dev] +# or +composer -- pup replace-version [--dev] +``` + +### Arguments +| Argument | Description | +|-----------|----------------------------------------------------------------------------------------------------------| +| `version` | **Required.** The version number to write into the version files. | +| `--dev` | **Optional.** Append the dev suffix (e.g. `-dev--`) to the provided version. | +| `--root` | **Optional.** Run the command from a different directory from the current. | + + ## `pup workflow` Run a command workflow. diff --git a/src/App.php b/src/App.php index 24f0ab2..832ae76 100644 --- a/src/App.php +++ b/src/App.php @@ -62,6 +62,7 @@ public function __construct( string $version ) { $this->add( new Commands\I18n() ); $this->add( new Commands\Info() ); $this->add( new Commands\Package() ); + $this->add( new Commands\ReplaceVersion() ); $this->add( new Commands\Workflow() ); $this->add( new Commands\Zip() ); $this->add( new Commands\ZipName() ); diff --git a/src/Commands/ReplaceVersion.php b/src/Commands/ReplaceVersion.php new file mode 100644 index 0000000..9d03dad --- /dev/null +++ b/src/Commands/ReplaceVersion.php @@ -0,0 +1,80 @@ +setName( 'replace-version' ) + ->addArgument( 'version', InputArgument::REQUIRED, 'The version to write into the version files.' ) + ->addOption( 'dev', null, InputOption::VALUE_NONE, 'Append the dev suffix to the version.' ) + ->addOption( 'root', null, InputOption::VALUE_REQUIRED, 'Set the root directory for running commands.' ) + ->setDescription( 'Replaces the version in the files defined in .puprc paths.versions.' ) + ->setHelp( 'Replaces the version in the files defined in .puprc paths.versions with the provided version.' ); + } + + /** + * @inheritDoc + */ + protected function execute( InputInterface $input, OutputInterface $output ) { + parent::execute( $input, $output ); + + $config = App::getConfig(); + $version = $input->getArgument( 'version' ); + $version_files = $config->getVersionFiles(); + + if ( empty( $version_files ) ) { + $output->writeln( 'No version files found in .puprc paths.versions.' ); + return 1; + } + + if ( $input->getOption( 'dev' ) ) { + $version .= $this->getDevSuffix(); + } + + $root = $input->getOption( 'root' ); + $root = $root ? DirectoryUtils::trailingSlashIt( $root ) : ''; + + foreach ( $version_files as $file ) { + $contents = file_get_contents( $root . $file->getPath() ); + + if ( ! $contents ) { + throw new BaseException( 'Could not read file: ' . $file->getPath() ); + } + + $contents = preg_replace( '/' . $file->getRegex() . '/', '${1}' . $version, $contents, 1 ); + $results = file_put_contents( $root . $file->getPath(), $contents ); + + if ( false === $results ) { + throw new BaseException( 'Could not write to file: ' . $file->getPath() ); + } + + $output->writeln( "✓ Updated version in {$file->getPath()} to {$version}." ); + } + + return 0; + } + + /** + * @return string + */ + protected function getDevSuffix(): string { + $timestamp = exec( 'git show -s --format=%ct HEAD' ); + $hash = exec( 'git rev-parse --short=8 HEAD' ); + + return "-dev-{$timestamp}-{$hash}"; + } +} diff --git a/tests/cli/Commands/ReplaceVersionCest.php b/tests/cli/Commands/ReplaceVersionCest.php new file mode 100644 index 0000000..6a771bc --- /dev/null +++ b/tests/cli/Commands/ReplaceVersionCest.php @@ -0,0 +1,78 @@ +restore_version_files(); + parent::_after( $I ); + } + + /** + * Restores the git-tracked version fixture files modified during a test. + * + * @return void + */ + protected function restore_version_files(): void { + foreach ( $this->version_files as $file ) { + $path = $this->tests_root . '/_data/fake-project/' . $file; + system( 'git checkout -- ' . escapeshellarg( $path ) ); + } + } + + /** + * @test + */ + public function it_should_replace_the_version_in_version_files( CliTester $I ) { + $this->write_default_puprc(); + + chdir( $this->tests_root . '/_data/fake-project' ); + + $I->runShellCommand( "php {$this->pup} replace-version 2.5.0" ); + $I->seeResultCodeIs( 0 ); + $I->seeInShellOutput( 'bootstrap.php' ); + $I->seeInShellOutput( '2.5.0' ); + + $project = $this->tests_root . '/_data/fake-project'; + $I->assertStringContainsString( "define( 'FAKE_PROJECT_VERSION', '2.5.0' );", (string) file_get_contents( $project . '/bootstrap.php' ) ); + $I->assertStringContainsString( '"version": "2.5.0"', (string) file_get_contents( $project . '/package.json' ) ); + $I->assertStringContainsString( "const VERSION = '2.5.0';", (string) file_get_contents( $project . '/src/Plugin.php' ) ); + + $output = $I->grabShellOutput(); + $this->assertMatchesStringSnapshot( $output ); + } + + /** + * @test + */ + public function it_should_fail_when_no_version_files_are_configured( CliTester $I ) { + $puprc = $this->get_puprc(); + $puprc['paths']['versions'] = []; + $this->write_puprc( $puprc ); + + chdir( $this->tests_root . '/_data/fake-project' ); + + $I->runShellCommand( "php {$this->pup} replace-version 2.5.0", false ); + $I->seeResultCodeIs( 1 ); + $I->seeInShellOutput( 'No version files found' ); + } +} From 73a5c6ddfdbdef912c1672ed8376d4b3841bba90 Mon Sep 17 00:00:00 2001 From: Jon Waldstein Date: Fri, 5 Jun 2026 09:37:29 -0400 Subject: [PATCH 2/6] Address review feedback on replace-version - Guard preg_replace against null return (invalid regex) to avoid silently truncating version files, and skip files where the regex matches nothing instead of reporting a false "Updated". - Extract duplicated getDevSuffix() into a shared DevSuffix trait, used by both replace-version and get-version. - Add a --dev test; drop the snapshot assertion (no committed snapshot; explicit assertions already cover behavior). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Command/DevSuffix.php | 20 ++++++++++++++++ src/Commands/GetVersion.php | 13 +++-------- src/Commands/ReplaceVersion.php | 28 +++++++++++++---------- tests/cli/Commands/ReplaceVersionCest.php | 19 +++++++++++++-- 4 files changed, 56 insertions(+), 24 deletions(-) create mode 100644 src/Command/DevSuffix.php diff --git a/src/Command/DevSuffix.php b/src/Command/DevSuffix.php new file mode 100644 index 0000000..6b35328 --- /dev/null +++ b/src/Command/DevSuffix.php @@ -0,0 +1,20 @@ +-) from the current git HEAD. + * + * @return string + */ + protected function getDevSuffix(): string { + $timestamp = exec( 'git show -s --format=%ct HEAD' ); + $hash = exec( 'git rev-parse --short=8 HEAD' ); + + return "-dev-{$timestamp}-{$hash}"; + } +} diff --git a/src/Commands/GetVersion.php b/src/Commands/GetVersion.php index 2293441..ac1647c 100644 --- a/src/Commands/GetVersion.php +++ b/src/Commands/GetVersion.php @@ -5,11 +5,14 @@ use StellarWP\Pup\App; use StellarWP\Pup\Exceptions\BaseException; use StellarWP\Pup\Command\Command; +use StellarWP\Pup\Command\DevSuffix; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class GetVersion extends Command { + use DevSuffix; + /** * @inheritDoc * @@ -69,14 +72,4 @@ protected function execute( InputInterface $input, OutputInterface $output ) { $output->writeln( $version ); return 0; } - - /** - * @return string - */ - protected function getDevSuffix(): string { - $timestamp = exec( 'git show -s --format=%ct HEAD' ); - $hash = exec( 'git rev-parse --short=8 HEAD' ); - - return "-dev-{$timestamp}-{$hash}"; - } } diff --git a/src/Commands/ReplaceVersion.php b/src/Commands/ReplaceVersion.php index 9d03dad..bf60c24 100644 --- a/src/Commands/ReplaceVersion.php +++ b/src/Commands/ReplaceVersion.php @@ -5,6 +5,7 @@ use StellarWP\Pup\App; use StellarWP\Pup\Exceptions\BaseException; use StellarWP\Pup\Command\Command; +use StellarWP\Pup\Command\DevSuffix; use StellarWP\Pup\Utils\Directory as DirectoryUtils; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -12,6 +13,8 @@ use Symfony\Component\Console\Output\OutputInterface; class ReplaceVersion extends Command { + use DevSuffix; + /** * @inheritDoc * @@ -55,8 +58,19 @@ protected function execute( InputInterface $input, OutputInterface $output ) { throw new BaseException( 'Could not read file: ' . $file->getPath() ); } - $contents = preg_replace( '/' . $file->getRegex() . '/', '${1}' . $version, $contents, 1 ); - $results = file_put_contents( $root . $file->getPath(), $contents ); + $count = 0; + $replaced = preg_replace( '/' . $file->getRegex() . '/', '${1}' . $version, $contents, 1, $count ); + + if ( null === $replaced ) { + throw new BaseException( 'Could not replace version in file (check the regex): ' . $file->getPath() ); + } + + if ( $count === 0 ) { + $output->writeln( "! No version found in {$file->getPath()} matching its regex. Skipping." ); + continue; + } + + $results = file_put_contents( $root . $file->getPath(), $replaced ); if ( false === $results ) { throw new BaseException( 'Could not write to file: ' . $file->getPath() ); @@ -67,14 +81,4 @@ protected function execute( InputInterface $input, OutputInterface $output ) { return 0; } - - /** - * @return string - */ - protected function getDevSuffix(): string { - $timestamp = exec( 'git show -s --format=%ct HEAD' ); - $hash = exec( 'git rev-parse --short=8 HEAD' ); - - return "-dev-{$timestamp}-{$hash}"; - } } diff --git a/tests/cli/Commands/ReplaceVersionCest.php b/tests/cli/Commands/ReplaceVersionCest.php index 6a771bc..e403b43 100644 --- a/tests/cli/Commands/ReplaceVersionCest.php +++ b/tests/cli/Commands/ReplaceVersionCest.php @@ -56,9 +56,24 @@ public function it_should_replace_the_version_in_version_files( CliTester $I ) { $I->assertStringContainsString( "define( 'FAKE_PROJECT_VERSION', '2.5.0' );", (string) file_get_contents( $project . '/bootstrap.php' ) ); $I->assertStringContainsString( '"version": "2.5.0"', (string) file_get_contents( $project . '/package.json' ) ); $I->assertStringContainsString( "const VERSION = '2.5.0';", (string) file_get_contents( $project . '/src/Plugin.php' ) ); + } + + /** + * @test + */ + public function it_should_append_the_dev_suffix_with_the_dev_option( CliTester $I ) { + $this->write_default_puprc(); + + chdir( $this->tests_root . '/_data/fake-project' ); + + $I->runShellCommand( "php {$this->pup} replace-version 2.5.0 --dev" ); + $I->seeResultCodeIs( 0 ); + + $project = $this->tests_root . '/_data/fake-project'; + $contents = (string) file_get_contents( $project . '/bootstrap.php' ); - $output = $I->grabShellOutput(); - $this->assertMatchesStringSnapshot( $output ); + // The suffix is -dev--, so assert the prefix landed in the file. + $I->assertMatchesRegularExpression( "/FAKE_PROJECT_VERSION', '2\.5\.0-dev-\d+-[0-9a-f]+'/", $contents ); } /** From a3818940f455347432550a58b0201036d2571c8d Mon Sep 17 00:00:00 2001 From: Jon Waldstein Date: Fri, 5 Jun 2026 13:13:37 -0400 Subject: [PATCH 3/6] refactor: move command into dedicated traits namespace --- src/Command/{ => Traits}/DevSuffix.php | 2 +- src/Commands/GetVersion.php | 2 +- src/Commands/ReplaceVersion.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/Command/{ => Traits}/DevSuffix.php (91%) diff --git a/src/Command/DevSuffix.php b/src/Command/Traits/DevSuffix.php similarity index 91% rename from src/Command/DevSuffix.php rename to src/Command/Traits/DevSuffix.php index 6b35328..d9c4372 100644 --- a/src/Command/DevSuffix.php +++ b/src/Command/Traits/DevSuffix.php @@ -1,6 +1,6 @@ Date: Fri, 5 Jun 2026 13:39:06 -0400 Subject: [PATCH 4/6] refactor: update package command to call replace-version command --- src/Commands/Package.php | 55 +++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/src/Commands/Package.php b/src/Commands/Package.php index 9c70804..f578f1f 100644 --- a/src/Commands/Package.php +++ b/src/Commands/Package.php @@ -78,8 +78,12 @@ protected function execute( InputInterface $input, OutputInterface $output ) { $zip_filename = "{$full_zip_name}.zip"; $output->writeln( '- Updating version files...' ); - if ( $version !== 'unknown' ) { - $this->updateVersionsInFiles( $version ); + if ( $version !== 'unknown' && ! empty( $config->getVersionFiles() ) ) { + $results = $this->updateVersionsInFiles( $version ); + if ( $results !== 0 ) { + $this->undoChanges(); + return $results; + } } $output->writeln( '✓ Updating version files...Complete.' ); @@ -490,31 +494,42 @@ protected function undoChanges() { } /** - * @param string $version + * Update the version in the configured version files by delegating to the + * `replace-version` command. * - * @return bool + * The provided version is written as-is (the dev suffix, if any, is already + * baked in by the caller via `get-version`), so `--dev` is intentionally + * not passed through. + * + * @param string $version The version to write into the version files. + * + * @throws \Symfony\Component\Console\Exception\ExceptionInterface + * + * @return int */ - protected function updateVersionsInFiles( string $version ): bool { - $root = $this->input->getOption( 'root' ); - $root = $root ? DirectoryUtils::trailingSlashIt( $root ) : ''; - $config = App::getConfig(); - $version_files = $config->getVersionFiles(); + protected function updateVersionsInFiles( string $version ): int { + $application = $this->getApplication(); + if ( ! $application ) { + return 1; + } - foreach ( $version_files as $file ) { - $contents = file_get_contents( $root . $file->getPath() ); + $arguments = [ + 'version' => $version, + ]; - if ( ! $contents ) { - throw new Exceptions\BaseException( 'Could not read file: ' . $file->getPath() ); - } + $root = $this->input->getOption( 'root' ); + if ( $root ) { + $arguments['--root'] = $root; + } - $contents = preg_replace( '/' . $file->getRegex() . '/', '${1}' . $version, $contents, 1 ); - $results = file_put_contents( $root . $file->getPath(), $contents ); + $buffer = new BufferedOutput(); + $command = $application->find( 'replace-version' ); + $results = $command->run( new ArrayInput( $arguments ), $buffer ); - if ( false === $results ) { - return false; - } + if ( $results !== 0 ) { + $this->output->write( $buffer->fetch() ); } - return true; + return $results; } } From d57eced9a79cb8146a777de5ae65714223450763 Mon Sep 17 00:00:00 2001 From: Jon Waldstein Date: Fri, 5 Jun 2026 13:44:29 -0400 Subject: [PATCH 5/6] refactor: call command class directly --- src/Commands/Package.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/Commands/Package.php b/src/Commands/Package.php index f578f1f..a981c1a 100644 --- a/src/Commands/Package.php +++ b/src/Commands/Package.php @@ -495,7 +495,7 @@ protected function undoChanges() { /** * Update the version in the configured version files by delegating to the - * `replace-version` command. + * ReplaceVersion command. * * The provided version is written as-is (the dev suffix, if any, is already * baked in by the caller via `get-version`), so `--dev` is intentionally @@ -508,11 +508,6 @@ protected function undoChanges() { * @return int */ protected function updateVersionsInFiles( string $version ): int { - $application = $this->getApplication(); - if ( ! $application ) { - return 1; - } - $arguments = [ 'version' => $version, ]; @@ -523,7 +518,7 @@ protected function updateVersionsInFiles( string $version ): int { } $buffer = new BufferedOutput(); - $command = $application->find( 'replace-version' ); + $command = new ReplaceVersion(); $results = $command->run( new ArrayInput( $arguments ), $buffer ); if ( $results !== 0 ) { From 9957581ba199944101a6f1acd8d1735b864c24da Mon Sep 17 00:00:00 2001 From: Jon Waldstein Date: Fri, 5 Jun 2026 13:53:42 -0400 Subject: [PATCH 6/6] refactor: move trait to command directory and keep commands DRY --- src/Commands/GetVersion.php | 2 +- src/Commands/ReplaceVersion.php | 2 +- src/{Command => Commands}/Traits/DevSuffix.php | 2 +- src/Commands/ZipName.php | 13 +++---------- 4 files changed, 6 insertions(+), 13 deletions(-) rename src/{Command => Commands}/Traits/DevSuffix.php (91%) diff --git a/src/Commands/GetVersion.php b/src/Commands/GetVersion.php index d3d0180..9f9633b 100644 --- a/src/Commands/GetVersion.php +++ b/src/Commands/GetVersion.php @@ -5,7 +5,7 @@ use StellarWP\Pup\App; use StellarWP\Pup\Exceptions\BaseException; use StellarWP\Pup\Command\Command; -use StellarWP\Pup\Command\Traits\DevSuffix; +use StellarWP\Pup\Commands\Traits\DevSuffix; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; diff --git a/src/Commands/ReplaceVersion.php b/src/Commands/ReplaceVersion.php index 24f5f88..24409f6 100644 --- a/src/Commands/ReplaceVersion.php +++ b/src/Commands/ReplaceVersion.php @@ -5,7 +5,7 @@ use StellarWP\Pup\App; use StellarWP\Pup\Exceptions\BaseException; use StellarWP\Pup\Command\Command; -use StellarWP\Pup\Command\Traits\DevSuffix; +use StellarWP\Pup\Commands\Traits\DevSuffix; use StellarWP\Pup\Utils\Directory as DirectoryUtils; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; diff --git a/src/Command/Traits/DevSuffix.php b/src/Commands/Traits/DevSuffix.php similarity index 91% rename from src/Command/Traits/DevSuffix.php rename to src/Commands/Traits/DevSuffix.php index d9c4372..d90b85b 100644 --- a/src/Command/Traits/DevSuffix.php +++ b/src/Commands/Traits/DevSuffix.php @@ -1,6 +1,6 @@