From a900d48a2bd6af60b75d779af00ee517c6d5acd1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:41:40 +0000 Subject: [PATCH 1/2] fix(release): verify draft ancestry using remote branch history The release branch can advance between workflow checkout and draft creation. Use the GitHub compare API to validate containment against the live branch without requiring its new head to be fetched locally. Reject divergent or unverifiable histories and cover the race with focused regression tests. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Release/Port/ReleaseDraftRepository.php | 2 + src/Application/Release/ReleaseDrafter.php | 6 ++- .../GitHub/GitHubReleaseDraftRepository.php | 23 ++++++++ .../InMemoryReleaseDraftRepository.php | 12 ++++- .../Release/ReleaseDrafterTest.php | 6 ++- .../GitHubReleaseDraftRepositoryTest.php | 53 +++++++++++++++++++ 6 files changed, 98 insertions(+), 4 deletions(-) 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; From 43711aafd73a33c9125fd9c5015eda906a6bd931 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:57:04 -0300 Subject: [PATCH 2/2] fix(deps): update Symfony security patches Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- composer.lock | 114 +++++++++++++++++++++++++------------------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/composer.lock b/composer.lock index 5748620..c6c1776 100644 --- a/composer.lock +++ b/composer.lock @@ -209,16 +209,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.1", + "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.7.1" + "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", @@ -713,16 +713,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.43.0", + "version": "v1.43.3", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", - "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/122889ed24dc000165bcbfd77ec412f15cbdfc6a", + "reference": "122889ed24dc000165bcbfd77ec412f15cbdfc6a", "shasum": "" }, "require": { @@ -774,7 +774,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.3" }, "funding": [ { @@ -794,20 +794,20 @@ "type": "tidelift" } ], - "time": "2026-09-24T15:19:04+00:00" + "time": "2026-10-28T09:35:49+00:00" }, { "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.7.3", + "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.7.3" + "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": [ @@ -7935,16 +7935,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": { @@ -7990,7 +7990,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": [ { @@ -8010,7 +8010,7 @@ "type": "tidelift" } ], - "time": "2026-08-20T09:55:18+00:00" + "time": "2026-11-04T08:27:22+00:00" }, { "name": "symfony/dependency-injection", @@ -8263,16 +8263,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": { @@ -8309,7 +8309,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": [ { @@ -8329,7 +8329,7 @@ "type": "tidelift" } ], - "time": "2026-10-06T20:05:19+00:00" + "time": "2026-11-04T08:27:22+00:00" }, { "name": "symfony/finder",