diff --git a/src/Application/Release/Port/ReleaseFinalizationRepository.php b/src/Application/Release/Port/ReleaseFinalizationRepository.php index 2e9a09a..89fd794 100644 --- a/src/Application/Release/Port/ReleaseFinalizationRepository.php +++ b/src/Application/Release/Port/ReleaseFinalizationRepository.php @@ -14,6 +14,8 @@ public function pullRequest(string $repository, int $number): FinalizedPullReque public function branchHead(string $repository, string $branch): string; + public function isAncestor(string $repository, string $ancestorSha, string $descendantSha): bool; + /** @return list */ public function branches(string $repository): array; diff --git a/src/Application/Release/ReleaseFinalizer.php b/src/Application/Release/ReleaseFinalizer.php index 1375ef7..8b3a045 100644 --- a/src/Application/Release/ReleaseFinalizer.php +++ b/src/Application/Release/ReleaseFinalizer.php @@ -60,7 +60,7 @@ public function finalize( $finalSha = $pullRequest->mergeCommitSha; $branchHead = $this->github->branchHead($preparation->repository, $preparation->targetBranch); - if ($branchHead !== $finalSha && !$this->git->isAncestor($finalSha, $branchHead)) { + if ($branchHead !== $finalSha && !$this->github->isAncestor($preparation->repository, $finalSha, $branchHead)) { throw new DomainException(sprintf( 'Release branch no longer contains merged release %s; current head is %s.', $finalSha, diff --git a/src/Infrastructure/GitHub/GitHubReleaseFinalizationRepository.php b/src/Infrastructure/GitHub/GitHubReleaseFinalizationRepository.php index 00d3c18..9a2d5e0 100644 --- a/src/Infrastructure/GitHub/GitHubReleaseFinalizationRepository.php +++ b/src/Infrastructure/GitHub/GitHubReleaseFinalizationRepository.php @@ -101,6 +101,26 @@ 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; + + return $status === 'ahead' || $status === 'identical'; + } + /** @return list */ public function branches(string $repository): array { diff --git a/tests/Fixtures/Release/InMemoryReleaseFinalizationRepository.php b/tests/Fixtures/Release/InMemoryReleaseFinalizationRepository.php index ce3a81c..7c84e27 100644 --- a/tests/Fixtures/Release/InMemoryReleaseFinalizationRepository.php +++ b/tests/Fixtures/Release/InMemoryReleaseFinalizationRepository.php @@ -18,6 +18,8 @@ public function __construct( private readonly ?HistorySynchronization $publishedHistory = null, /** @var array */ private readonly array $files = [], + /** @var array> */ + private readonly array $ancestors = [], ) { } @@ -36,6 +38,12 @@ 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); + } + /** @return list */ public function branches(string $repository): array { diff --git a/tests/Unit/Application/Release/ReleaseFinalizerTest.php b/tests/Unit/Application/Release/ReleaseFinalizerTest.php index 686f806..191193f 100644 --- a/tests/Unit/Application/Release/ReleaseFinalizerTest.php +++ b/tests/Unit/Application/Release/ReleaseFinalizerTest.php @@ -173,6 +173,7 @@ public function testAllowsDelayedFinalizationWhenMergedReleaseRemainsAncestor(): array_map(static fn (FileChange $change): string => $change->path, $this->preparation()->fileChanges), ), ['stable35' => $advanced, 'main' => self::MAIN], + ancestors: [$advanced => [self::FINAL]], ); $prepared = (new ReleaseFinalizer( diff --git a/tests/Unit/Infrastructure/GitHub/GitHubReleaseFinalizationRepositoryTest.php b/tests/Unit/Infrastructure/GitHub/GitHubReleaseFinalizationRepositoryTest.php index 78fdf67..0dcc741 100644 --- a/tests/Unit/Infrastructure/GitHub/GitHubReleaseFinalizationRepositoryTest.php +++ b/tests/Unit/Infrastructure/GitHub/GitHubReleaseFinalizationRepositoryTest.php @@ -38,6 +38,32 @@ public function testReadsMergedPullRequestAndChangedFiles(): void self::assertSame(['appinfo/info.xml', 'package.json'], $result->changedFiles); } + public function testChecksAncestryThroughGitHubCompareApi(): void + { + $descendant = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + $client = new MockHttpClient([ + $this->json(['status' => 'ahead']), + ], 'https://api.github.test'); + + $result = (new GitHubReleaseFinalizationRepository('token', $client, 'https://api.github.test')) + ->isAncestor('LibreSign/libresign', self::SHA, $descendant); + + self::assertTrue($result); + } + + public function testRejectsDivergedHistoryThroughGitHubCompareApi(): void + { + $descendant = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + $client = new MockHttpClient([ + $this->json(['status' => 'diverged']), + ], 'https://api.github.test'); + + $result = (new GitHubReleaseFinalizationRepository('token', $client, 'https://api.github.test')) + ->isAncestor('LibreSign/libresign', self::SHA, $descendant); + + self::assertFalse($result); + } + public function testReadsFileAtExactCommitThroughGitHubContentsApi(): void { $content = "# Changelog\n\n## 13.4.2 - 2026-09-21\n";