diff --git a/composer.lock b/composer.lock index eafac3a..80798a0 100644 --- a/composer.lock +++ b/composer.lock @@ -209,16 +209,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.8.0", + "version": "v3.8.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + "reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/feb1edf4e546af3b85c5caebe8359a20db230ca3", + "reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3", "shasum": "" }, "require": { @@ -231,7 +231,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "3.8-dev" } }, "autoload": { @@ -256,7 +256,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.8.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.8.1" }, "funding": [ { @@ -276,20 +276,20 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-09-16T14:15:10+00:00" }, { "name": "symfony/http-client", - "version": "v7.4.19", + "version": "v7.4.23", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7" + "reference": "ed012bfca0f7b45d97016efcdf9b0639c52a1722" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/3a523f38dc399337ec45b2ab9628e8c37ceb23c7", - "reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7", + "url": "https://api.github.com/repos/symfony/http-client/zipball/ed012bfca0f7b45d97016efcdf9b0639c52a1722", + "reference": "ed012bfca0f7b45d97016efcdf9b0639c52a1722", "shasum": "" }, "require": { @@ -357,7 +357,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.19" + "source": "https://github.com/symfony/http-client/tree/v7.4.23" }, "funding": [ { @@ -377,20 +377,20 @@ "type": "tidelift" } ], - "time": "2026-09-08T13:28:43+00:00" + "time": "2026-11-04T08:27:22+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.7.3", + "version": "v3.8.1", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f" + "reference": "f9f94260f787446c6fd430b2ae1969fe41ae1ba8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f", - "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/f9f94260f787446c6fd430b2ae1969fe41ae1ba8", + "reference": "f9f94260f787446c6fd430b2ae1969fe41ae1ba8", "shasum": "" }, "require": { @@ -403,7 +403,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "3.8-dev" } }, "autoload": { @@ -439,7 +439,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.8.1" }, "funding": [ { @@ -459,7 +459,7 @@ "type": "tidelift" } ], - "time": "2026-08-04T08:41:16+00:00" + "time": "2026-10-04T15:04:25+00:00" }, { "name": "symfony/polyfill-ctype", @@ -798,16 +798,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.41.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", - "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce", "shasum": "" }, "require": { @@ -854,7 +854,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0" }, "funding": [ { @@ -874,7 +874,7 @@ "type": "tidelift" } ], - "time": "2026-07-01T12:47:55+00:00" + "time": "2026-09-25T15:50:05+00:00" }, { "name": "symfony/process", @@ -943,16 +943,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.8.0", + "version": "v3.8.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + "reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", - "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/052852a96fd5e5ebdb617dba22031e41a3300fb5", + "reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5", "shasum": "" }, "require": { @@ -970,7 +970,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "3.8-dev" } }, "autoload": { @@ -1006,7 +1006,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.8.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.8.1" }, "funding": [ { @@ -1026,7 +1026,7 @@ "type": "tidelift" } ], - "time": "2026-07-27T15:39:01+00:00" + "time": "2026-10-20T17:38:25+00:00" }, { "name": "symfony/string", @@ -1121,16 +1121,16 @@ }, { "name": "symfony/yaml", - "version": "v7.4.20", + "version": "v7.4.23", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7" + "reference": "1e601207ffe627a2e621faa9d661f1d32dcf1cec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/0222bf4dfaa1e3ceb8fff579635d655f2950d5a7", - "reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7", + "url": "https://api.github.com/repos/symfony/yaml/zipball/1e601207ffe627a2e621faa9d661f1d32dcf1cec", + "reference": "1e601207ffe627a2e621faa9d661f1d32dcf1cec", "shasum": "" }, "require": { @@ -1173,7 +1173,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.20" + "source": "https://github.com/symfony/yaml/tree/v7.4.23" }, "funding": [ { @@ -1193,7 +1193,7 @@ "type": "tidelift" } ], - "time": "2026-09-26T06:31:10+00:00" + "time": "2026-11-04T08:27:22+00:00" } ], "packages-dev": [ @@ -7942,16 +7942,16 @@ }, { "name": "symfony/config", - "version": "v7.4.17", + "version": "v7.4.23", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e" + "reference": "8ef666abf731e85e59cf478929dda6c7cb2fa9c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/696e12da8eea497a1a3808d714b43ff67a7df43e", - "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e", + "url": "https://api.github.com/repos/symfony/config/zipball/8ef666abf731e85e59cf478929dda6c7cb2fa9c4", + "reference": "8ef666abf731e85e59cf478929dda6c7cb2fa9c4", "shasum": "" }, "require": { @@ -7997,7 +7997,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.4.17" + "source": "https://github.com/symfony/config/tree/v7.4.23" }, "funding": [ { @@ -8017,7 +8017,7 @@ "type": "tidelift" } ], - "time": "2026-08-20T09:55:18+00:00" + "time": "2026-11-04T08:27:22+00:00" }, { "name": "symfony/dependency-injection", @@ -8270,16 +8270,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.21", + "version": "v7.4.23", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8" + "reference": "53d609da66578dad5a39fe290e8f924ec8f05e93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/fd0e3462be6d32a0d130e8423e4940a7688162d8", - "reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/53d609da66578dad5a39fe290e8f924ec8f05e93", + "reference": "53d609da66578dad5a39fe290e8f924ec8f05e93", "shasum": "" }, "require": { @@ -8316,7 +8316,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.21" + "source": "https://github.com/symfony/filesystem/tree/v7.4.23" }, "funding": [ { @@ -8336,7 +8336,7 @@ "type": "tidelift" } ], - "time": "2026-10-06T20:05:19+00:00" + "time": "2026-11-04T08:27:22+00:00" }, { "name": "symfony/finder", diff --git a/src/Application/Release/Port/ReleaseDraftRepository.php b/src/Application/Release/Port/ReleaseDraftRepository.php index afdafce..24decc0 100644 --- a/src/Application/Release/Port/ReleaseDraftRepository.php +++ b/src/Application/Release/Port/ReleaseDraftRepository.php @@ -10,6 +10,8 @@ interface ReleaseDraftRepository { public function branchHead(string $repository, string $branch): string; + public function isAncestor(string $repository, string $ancestorSha, string $descendantSha): bool; + public function tagTarget(string $repository, string $tag): ?string; public function releaseByTag(string $repository, string $tag): ?ReleaseDraftInfo; diff --git a/src/Application/Release/ReleaseDrafter.php b/src/Application/Release/ReleaseDrafter.php index 3f8271b..0e1fe7f 100644 --- a/src/Application/Release/ReleaseDrafter.php +++ b/src/Application/Release/ReleaseDrafter.php @@ -34,7 +34,11 @@ public function prepare( } $branchHead = $this->github->branchHead($prepared->repository, $prepared->branch); - if ($branchHead !== $prepared->finalSha && !$this->git->isAncestor($prepared->finalSha, $branchHead)) { + if ($branchHead !== $prepared->finalSha && !$this->github->isAncestor( + $prepared->repository, + $prepared->finalSha, + $branchHead, + )) { throw new DomainException(sprintf( 'Release branch no longer contains finalized release %s; current head is %s.', $prepared->finalSha, diff --git a/src/Infrastructure/GitHub/GitHubReleaseDraftRepository.php b/src/Infrastructure/GitHub/GitHubReleaseDraftRepository.php index 2715c48..94df529 100644 --- a/src/Infrastructure/GitHub/GitHubReleaseDraftRepository.php +++ b/src/Infrastructure/GitHub/GitHubReleaseDraftRepository.php @@ -55,6 +55,29 @@ public function branchHead(string $repository, string $branch): string return $sha; } + public function isAncestor(string $repository, string $ancestorSha, string $descendantSha): bool + { + if ($ancestorSha === $descendantSha) { + return true; + } + + $data = $this->request( + 'GET', + sprintf( + '/repos/%s/compare/%s...%s', + $repository, + rawurlencode($ancestorSha), + rawurlencode($descendantSha), + ), + ); + $status = $data['status'] ?? null; + if (!in_array($status, ['ahead', 'behind', 'diverged', 'identical'], true)) { + throw new DomainException('GitHub returned an invalid commit comparison status.'); + } + + return $status === 'ahead' || $status === 'identical'; + } + public function tagTarget(string $repository, string $tag): ?string { $response = $this->client->request('GET', sprintf( diff --git a/tests/Fixtures/Release/InMemoryReleaseDraftRepository.php b/tests/Fixtures/Release/InMemoryReleaseDraftRepository.php index 9c754c8..d9731ca 100644 --- a/tests/Fixtures/Release/InMemoryReleaseDraftRepository.php +++ b/tests/Fixtures/Release/InMemoryReleaseDraftRepository.php @@ -9,7 +9,11 @@ final class InMemoryReleaseDraftRepository implements ReleaseDraftRepository { - /** @param array $branchHeads @param array $permissions */ + /** + * @param array $branchHeads + * @param array $permissions + * @param array> $ancestors + */ public function __construct( private readonly array $branchHeads, private readonly string $merger = 'maintainer', @@ -17,6 +21,7 @@ public function __construct( private readonly ?string $tagTarget = null, private readonly array $pullRequestContributors = [], public ?ReleaseDraftInfo $release = null, + private readonly array $ancestors = [], ) { } @@ -28,6 +33,11 @@ public function branchHead(string $repository, string $branch): string return $this->branchHeads[$branch]; } + public function isAncestor(string $repository, string $ancestorSha, string $descendantSha): bool + { + return $ancestorSha === $descendantSha || in_array($ancestorSha, $this->ancestors[$descendantSha] ?? [], true); + } + public function tagTarget(string $repository, string $tag): ?string { return $this->tagTarget; diff --git a/tests/Unit/Application/Release/ReleaseDrafterTest.php b/tests/Unit/Application/Release/ReleaseDrafterTest.php index 9bf9e9b..1e5fd7f 100644 --- a/tests/Unit/Application/Release/ReleaseDrafterTest.php +++ b/tests/Unit/Application/Release/ReleaseDrafterTest.php @@ -181,14 +181,16 @@ public function testInsufficientMergerPermissionFailsClosed(): void (new ReleaseDrafter($this->git($prepared), $github))->prepare($this->config(), $prepared, $this->milestone()); } - public function testAllowsBranchAdvanceWhenFinalizedReleaseRemainsAncestor(): void + public function testAllowsBranchAdvanceNotFetchedIntoLocalCheckout(): void { $prepared = $this->prepared(); $advanced = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; $github = new InMemoryReleaseDraftRepository( ['stable35' => $advanced], + ancestors: [$advanced => [self::SHA]], ); - $draft = (new ReleaseDrafter($this->git($prepared, $advanced), $github))->prepare( + // The checkout does not know the new head, but GitHub can verify its ancestry. + $draft = (new ReleaseDrafter($this->git($prepared), $github))->prepare( $this->config(), $prepared, $this->milestone(), diff --git a/tests/Unit/Infrastructure/GitHub/GitHubReleaseDraftRepositoryTest.php b/tests/Unit/Infrastructure/GitHub/GitHubReleaseDraftRepositoryTest.php index 64a4d40..88ea824 100644 --- a/tests/Unit/Infrastructure/GitHub/GitHubReleaseDraftRepositoryTest.php +++ b/tests/Unit/Infrastructure/GitHub/GitHubReleaseDraftRepositoryTest.php @@ -28,6 +28,59 @@ public function testMissingTagReturnsNull(): void self::assertNull($repo->tagTarget('LibreSign/libresign', 'v15.0.4')); } + public function testBranchAdvanceChecksGitHubAncestry(): void + { + $requestedUrl = null; + $client = new MockHttpClient( + function (string $method, string $url) use (&$requestedUrl): MockResponse { + $requestedUrl = $url; + self::assertSame('GET', $method); + + return $this->json(['status' => 'ahead']); + }, + 'https://api.github.test', + ); + $repository = new GitHubReleaseDraftRepository('token', $client, 'https://api.github.test'); + $from = str_repeat('a', 40); + $to = str_repeat('b', 40); + + self::assertTrue($repository->isAncestor('LibreSign/libresign', $from, $to)); + self::assertSame( + 'https://api.github.test/repos/LibreSign/libresign/compare/' . $from . '...' . $to, + $requestedUrl, + ); + } + + public function testDivergedBranchDoesNotPassAncestryCheck(): void + { + $client = new MockHttpClient([$this->json(['status' => 'diverged'])], 'https://api.github.test'); + $repository = new GitHubReleaseDraftRepository('token', $client, 'https://api.github.test'); + + self::assertFalse($repository->isAncestor('LibreSign/libresign', str_repeat('a', 40), str_repeat('b', 40))); + } + + public function testUnavailableComparisonFailsClosed(): void + { + $client = new MockHttpClient([$this->json(['message' => 'Not Found'], 404)], 'https://api.github.test'); + $repository = new GitHubReleaseDraftRepository('token', $client, 'https://api.github.test'); + + $this->expectException(\DomainException::class); + $this->expectExceptionMessage('GitHub API failed to GET'); + + $repository->isAncestor('LibreSign/libresign', str_repeat('a', 40), str_repeat('b', 40)); + } + + public function testUnexpectedComparisonStatusFailsClosed(): void + { + $client = new MockHttpClient([$this->json(['status' => 'unknown'])], 'https://api.github.test'); + $repository = new GitHubReleaseDraftRepository('token', $client, 'https://api.github.test'); + + $this->expectException(\DomainException::class); + $this->expectExceptionMessage('invalid commit comparison status'); + + $repository->isAncestor('LibreSign/libresign', str_repeat('a', 40), str_repeat('b', 40)); + } + public function testCreateDraftNeverPublishesOrGeneratesNotes(): void { $captured = null;