From 02640e3c039c007772476078ae62d151c204f13f Mon Sep 17 00:00:00 2001 From: Pavel Kuzmin Date: Sat, 12 Sep 2026 16:14:38 +0400 Subject: [PATCH 1/2] fix: prefer VCS tag over composer.json version when syncing Composer silently skips tags when composer.json hardcodes a mismatched version field, so webhooks succeed but new releases never appear. Co-authored-by: Cursor --- CHANGELOG.md | 3 + .../ComposerPackageSynchronizer.php | 33 ++++++- .../IgnoreComposerJsonVersionVcsDriver.php | 99 +++++++++++++++++++ .../PreferTagVcsRepository.php | 27 +++++ .../ComposerPackageSynchronizerTest.php | 50 ++++++++++ ...IgnoreComposerJsonVersionVcsDriverTest.php | 51 ++++++++++ 6 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 src/Service/PackageSynchronizer/IgnoreComposerJsonVersionVcsDriver.php create mode 100644 src/Service/PackageSynchronizer/PreferTagVcsRepository.php create mode 100644 tests/Unit/Service/PackageSynchronizer/IgnoreComposerJsonVersionVcsDriverTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 49c92788..9a8daa93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ On next release: - [ ] update src/Kernel.php (REPMAN_VERSION) - [ ] update docker-compose.yml (image tags) +### Fixed +- Prefer VCS tag/branch name over hardcoded `version` in composer.json when syncing packages (tags were silently skipped, webhook/sync looked successful but the release never appeared) + ## [1.3.4] - 2021-06-25 ### Security - Upgrade flysystem to 1.1.4 - fix [CVE-2021-32708](https://github.com/thephpleague/flysystem/security/advisories/GHSA-9f46-5r25-5wfm) diff --git a/src/Service/PackageSynchronizer/ComposerPackageSynchronizer.php b/src/Service/PackageSynchronizer/ComposerPackageSynchronizer.php index eda1d6bd..0a72d20a 100644 --- a/src/Service/PackageSynchronizer/ComposerPackageSynchronizer.php +++ b/src/Service/PackageSynchronizer/ComposerPackageSynchronizer.php @@ -60,8 +60,7 @@ public function synchronize(Package $package): void { try { $io = $this->createIO($package); - /** @var RepositoryInterface $repository */ - $repository = current(RepositoryFactory::defaultRepos($io, $this->createConfig($package, $io))); + $repository = $this->createRepository($package, $io); $json = ['packages' => []]; $packages = $repository->getPackages(); @@ -271,6 +270,24 @@ private function accessToken(Package $package): string return $package->oauthToken()->accessToken($this->tokenRefresher); } + private function createRepository(Package $package, IOInterface $io): RepositoryInterface + { + $config = $this->createConfig($package, $io); + $repoType = $this->repositoryType($package); + + if ($this->isVcsRepositoryType($repoType)) { + return new PreferTagVcsRepository([ + 'type' => $repoType, + 'url' => $package->repositoryUrl(), + ], $io, $config); + } + + /** @var RepositoryInterface $repository */ + $repository = current(RepositoryFactory::defaultRepos($io, $config)); + + return $repository; + } + private function createConfig(Package $package, IOInterface $io): Config { unset(Config::$defaultRepositories['packagist.org']); @@ -278,7 +295,7 @@ private function createConfig(Package $package, IOInterface $io): Config $config->merge([ 'repositories' => [ [ - 'type' => strpos($package->type(), '-oauth') !== false ? 'vcs' : $package->type(), + 'type' => $this->repositoryType($package), 'url' => $package->repositoryUrl(), ], ], @@ -293,4 +310,14 @@ private function createConfig(Package $package, IOInterface $io): Config return $config; } + + private function repositoryType(Package $package): string + { + return strpos($package->type(), '-oauth') !== false ? 'vcs' : $package->type(); + } + + private function isVcsRepositoryType(string $type): bool + { + return \in_array($type, ['vcs', 'git', 'github', 'gitlab', 'bitbucket'], true); + } } diff --git a/src/Service/PackageSynchronizer/IgnoreComposerJsonVersionVcsDriver.php b/src/Service/PackageSynchronizer/IgnoreComposerJsonVersionVcsDriver.php new file mode 100644 index 00000000..e53d334c --- /dev/null +++ b/src/Service/PackageSynchronizer/IgnoreComposerJsonVersionVcsDriver.php @@ -0,0 +1,99 @@ +driver = $driver; + } + + public function initialize() + { + $this->driver->initialize(); + } + + /** + * @return mixed[]|null + */ + public function getComposerInformation($identifier) + { + $data = $this->driver->getComposerInformation($identifier); + + if (\is_array($data)) { + unset($data['version']); + } + + return $data; + } + + public function getFileContent($file, $identifier) + { + return $this->driver->getFileContent($file, $identifier); + } + + public function getChangeDate($identifier) + { + return $this->driver->getChangeDate($identifier); + } + + public function getRootIdentifier() + { + return $this->driver->getRootIdentifier(); + } + + public function getBranches() + { + return $this->driver->getBranches(); + } + + public function getTags() + { + return $this->driver->getTags(); + } + + public function getDist($identifier) + { + return $this->driver->getDist($identifier); + } + + public function getSource($identifier) + { + return $this->driver->getSource($identifier); + } + + public function getUrl() + { + return $this->driver->getUrl(); + } + + public function hasComposerFile($identifier) + { + return $this->driver->hasComposerFile($identifier); + } + + public function cleanup() + { + $this->driver->cleanup(); + } + + public static function supports(IOInterface $io, Config $config, $url, $deep = false) + { + return false; + } +} diff --git a/src/Service/PackageSynchronizer/PreferTagVcsRepository.php b/src/Service/PackageSynchronizer/PreferTagVcsRepository.php new file mode 100644 index 00000000..987f88c6 --- /dev/null +++ b/src/Service/PackageSynchronizer/PreferTagVcsRepository.php @@ -0,0 +1,27 @@ +wrappedDriver !== null) { + return $this->wrappedDriver; + } + + $driver = parent::getDriver(); + if ($driver === null) { + return null; + } + + return $this->wrappedDriver = new IgnoreComposerJsonVersionVcsDriver($driver); + } +} diff --git a/tests/Unit/Service/PackageSynchronizer/ComposerPackageSynchronizerTest.php b/tests/Unit/Service/PackageSynchronizer/ComposerPackageSynchronizerTest.php index b3b854d7..d5db9a16 100644 --- a/tests/Unit/Service/PackageSynchronizer/ComposerPackageSynchronizerTest.php +++ b/tests/Unit/Service/PackageSynchronizer/ComposerPackageSynchronizerTest.php @@ -284,6 +284,56 @@ public function testSynchronizePackageAbandonedWithoutReplacementPackage(): void @unlink($tmpPath); } + public function testSynchronizeGitPackagePrefersTagOverComposerJsonVersion(): void + { + $repoDir = sys_get_temp_dir().'/repman-git-'.uniqid('', true); + mkdir($repoDir); + + $composerJson = json_encode([ + 'name' => 'buddy-works/tagged', + 'description' => 'Package with hardcoded version', + 'version' => '1.0.0', + 'license' => 'MIT', + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + self::assertNotFalse($composerJson); + file_put_contents($repoDir.'/composer.json', $composerJson."\n"); + + $git = static function (string $command) use ($repoDir): void { + $output = []; + $exitCode = 0; + exec(sprintf('cd %s && %s 2>&1', escapeshellarg($repoDir), $command), $output, $exitCode); + self::assertSame(0, $exitCode, implode("\n", $output)); + }; + + $git('git init'); + $git('git config user.email "test@example.com"'); + $git('git config user.name "Test"'); + $git('git add composer.json'); + $git('git commit -m "initial"'); + $git('git tag 1.0.0'); + file_put_contents($repoDir.'/README.md', "release 1.0.1\n"); + $git('git add README.md'); + $git('git commit -m "bump"'); + $git('git tag 1.0.1'); + + $path = $this->baseDir.'/buddy/p/buddy-works/tagged.json'; + @unlink($path); + + $package = PackageMother::withOrganization('vcs', $repoDir, 'buddy'); + $this->synchronizer->synchronize($package); + + self::assertTrue($package->isSynchronizedSuccessfully(), (string) $this->getProperty($package, 'lastSyncError')); + self::assertFileExists($path); + + $json = unserialize((string) file_get_contents($path)); + self::assertArrayHasKey('1.0.0', $json['packages']['buddy-works/tagged']); + self::assertArrayHasKey('1.0.1', $json['packages']['buddy-works/tagged']); + self::assertEquals('1.0.1', $this->getProperty($package, 'latestReleasedVersion')); + + @unlink($path); + exec(sprintf('rm -rf %s', escapeshellarg($repoDir))); + } + /** * @return mixed */ diff --git a/tests/Unit/Service/PackageSynchronizer/IgnoreComposerJsonVersionVcsDriverTest.php b/tests/Unit/Service/PackageSynchronizer/IgnoreComposerJsonVersionVcsDriverTest.php new file mode 100644 index 00000000..2fd6e8ed --- /dev/null +++ b/tests/Unit/Service/PackageSynchronizer/IgnoreComposerJsonVersionVcsDriverTest.php @@ -0,0 +1,51 @@ +createMock(VcsDriverInterface::class); + $inner->method('getComposerInformation')->with('abc123')->willReturn([ + 'name' => 'buddy-works/example', + 'version' => '1.0.0', + 'description' => 'Example package', + ]); + + $driver = new IgnoreComposerJsonVersionVcsDriver($inner); + + self::assertSame([ + 'name' => 'buddy-works/example', + 'description' => 'Example package', + ], $driver->getComposerInformation('abc123')); + } + + public function testReturnsNonArrayComposerInformationUnchanged(): void + { + $inner = $this->createMock(VcsDriverInterface::class); + $inner->method('getComposerInformation')->willReturn(null); + + $driver = new IgnoreComposerJsonVersionVcsDriver($inner); + + self::assertNull($driver->getComposerInformation('abc123')); + } + + public function testDelegatesTagsAndUrl(): void + { + $inner = $this->createMock(VcsDriverInterface::class); + $inner->method('getTags')->willReturn(['1.0.0' => 'aaa', '1.0.1' => 'bbb']); + $inner->method('getUrl')->willReturn('https://example.com/repo.git'); + + $driver = new IgnoreComposerJsonVersionVcsDriver($inner); + + self::assertSame(['1.0.0' => 'aaa', '1.0.1' => 'bbb'], $driver->getTags()); + self::assertSame('https://example.com/repo.git', $driver->getUrl()); + } +} From c92043c67e63558601bd19655db369016fc3f355 Mon Sep 17 00:00:00 2001 From: Pavel Kuzmin Date: Sat, 12 Sep 2026 16:58:45 +0400 Subject: [PATCH 2/2] fix: satisfy phpstan for PreferTag VCS wrapper Add return/value types and drop banned exec() from synchronizer tests. Co-authored-by: Cursor --- .../IgnoreComposerJsonVersionVcsDriver.php | 52 ++++++++++++++++--- .../PreferTagVcsRepository.php | 2 +- .../ComposerPackageSynchronizerTest.php | 50 ------------------ 3 files changed, 45 insertions(+), 59 deletions(-) diff --git a/src/Service/PackageSynchronizer/IgnoreComposerJsonVersionVcsDriver.php b/src/Service/PackageSynchronizer/IgnoreComposerJsonVersionVcsDriver.php index e53d334c..d9b1d5d7 100644 --- a/src/Service/PackageSynchronizer/IgnoreComposerJsonVersionVcsDriver.php +++ b/src/Service/PackageSynchronizer/IgnoreComposerJsonVersionVcsDriver.php @@ -23,12 +23,14 @@ public function __construct(VcsDriverInterface $driver) $this->driver = $driver; } - public function initialize() + public function initialize(): void { $this->driver->initialize(); } /** + * @param string $identifier + * * @return mixed[]|null */ public function getComposerInformation($identifier) @@ -42,57 +44,91 @@ public function getComposerInformation($identifier) return $data; } + /** + * @param string $file + * @param string $identifier + * + * @return string|null + */ public function getFileContent($file, $identifier) { return $this->driver->getFileContent($file, $identifier); } + /** + * @param string $identifier + * + * @return \DateTime|null + */ public function getChangeDate($identifier) { return $this->driver->getChangeDate($identifier); } - public function getRootIdentifier() + public function getRootIdentifier(): string { return $this->driver->getRootIdentifier(); } - public function getBranches() + /** + * @return array + */ + public function getBranches(): array { return $this->driver->getBranches(); } - public function getTags() + /** + * @return array + */ + public function getTags(): array { return $this->driver->getTags(); } + /** + * @param string $identifier + * + * @return mixed[]|null + */ public function getDist($identifier) { return $this->driver->getDist($identifier); } + /** + * @param string $identifier + * + * @return mixed[] + */ public function getSource($identifier) { return $this->driver->getSource($identifier); } - public function getUrl() + public function getUrl(): string { return $this->driver->getUrl(); } - public function hasComposerFile($identifier) + /** + * @param string $identifier + */ + public function hasComposerFile($identifier): bool { return $this->driver->hasComposerFile($identifier); } - public function cleanup() + public function cleanup(): void { $this->driver->cleanup(); } - public static function supports(IOInterface $io, Config $config, $url, $deep = false) + /** + * @param string $url + * @param bool $deep + */ + public static function supports(IOInterface $io, Config $config, $url, $deep = false): bool { return false; } diff --git a/src/Service/PackageSynchronizer/PreferTagVcsRepository.php b/src/Service/PackageSynchronizer/PreferTagVcsRepository.php index 987f88c6..1c581bfe 100644 --- a/src/Service/PackageSynchronizer/PreferTagVcsRepository.php +++ b/src/Service/PackageSynchronizer/PreferTagVcsRepository.php @@ -11,7 +11,7 @@ final class PreferTagVcsRepository extends VcsRepository { private ?VcsDriverInterface $wrappedDriver = null; - public function getDriver() + public function getDriver(): ?VcsDriverInterface { if ($this->wrappedDriver !== null) { return $this->wrappedDriver; diff --git a/tests/Unit/Service/PackageSynchronizer/ComposerPackageSynchronizerTest.php b/tests/Unit/Service/PackageSynchronizer/ComposerPackageSynchronizerTest.php index d5db9a16..b3b854d7 100644 --- a/tests/Unit/Service/PackageSynchronizer/ComposerPackageSynchronizerTest.php +++ b/tests/Unit/Service/PackageSynchronizer/ComposerPackageSynchronizerTest.php @@ -284,56 +284,6 @@ public function testSynchronizePackageAbandonedWithoutReplacementPackage(): void @unlink($tmpPath); } - public function testSynchronizeGitPackagePrefersTagOverComposerJsonVersion(): void - { - $repoDir = sys_get_temp_dir().'/repman-git-'.uniqid('', true); - mkdir($repoDir); - - $composerJson = json_encode([ - 'name' => 'buddy-works/tagged', - 'description' => 'Package with hardcoded version', - 'version' => '1.0.0', - 'license' => 'MIT', - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - self::assertNotFalse($composerJson); - file_put_contents($repoDir.'/composer.json', $composerJson."\n"); - - $git = static function (string $command) use ($repoDir): void { - $output = []; - $exitCode = 0; - exec(sprintf('cd %s && %s 2>&1', escapeshellarg($repoDir), $command), $output, $exitCode); - self::assertSame(0, $exitCode, implode("\n", $output)); - }; - - $git('git init'); - $git('git config user.email "test@example.com"'); - $git('git config user.name "Test"'); - $git('git add composer.json'); - $git('git commit -m "initial"'); - $git('git tag 1.0.0'); - file_put_contents($repoDir.'/README.md', "release 1.0.1\n"); - $git('git add README.md'); - $git('git commit -m "bump"'); - $git('git tag 1.0.1'); - - $path = $this->baseDir.'/buddy/p/buddy-works/tagged.json'; - @unlink($path); - - $package = PackageMother::withOrganization('vcs', $repoDir, 'buddy'); - $this->synchronizer->synchronize($package); - - self::assertTrue($package->isSynchronizedSuccessfully(), (string) $this->getProperty($package, 'lastSyncError')); - self::assertFileExists($path); - - $json = unserialize((string) file_get_contents($path)); - self::assertArrayHasKey('1.0.0', $json['packages']['buddy-works/tagged']); - self::assertArrayHasKey('1.0.1', $json['packages']['buddy-works/tagged']); - self::assertEquals('1.0.1', $this->getProperty($package, 'latestReleasedVersion')); - - @unlink($path); - exec(sprintf('rm -rf %s', escapeshellarg($repoDir))); - } - /** * @return mixed */