From a3a0b76f286d4e94d4206f9a937c249f8d7fb965 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Thu, 24 Sep 2026 02:57:20 -0300 Subject: [PATCH 1/9] test: retire Python release reference --- .../ReleaseBehaviorCompatibilityTest.php | 683 ++++++++++++++++++ 1 file changed, 683 insertions(+) create mode 100644 tests/Integration/Compatibility/ReleaseBehaviorCompatibilityTest.php diff --git a/tests/Integration/Compatibility/ReleaseBehaviorCompatibilityTest.php b/tests/Integration/Compatibility/ReleaseBehaviorCompatibilityTest.php new file mode 100644 index 0000000..3058fe9 --- /dev/null +++ b/tests/Integration/Compatibility/ReleaseBehaviorCompatibilityTest.php @@ -0,0 +1,683 @@ + */ + private array $paths = []; + + /** @return iterable */ + public static function authorizationAndStableTargets(): iterable + { + yield 'php' => [ + new PhpReleaseToolTarget(dirname(__DIR__, 3)), + ]; + } + + /** @return iterable */ + public static function artifactTargets(): iterable + { + yield from self::authorizationAndStableTargets(); + } + + protected function tearDown(): void + { + foreach (array_reverse($this->paths) as $path) { + if (is_dir($path)) { + $this->removeDirectory($path); + } else { + @unlink($path); + } + } + } + + #[DataProvider('authorizationAndStableTargets')] + public function testAuthorizationReportsAuthorizedPermissionAsJson(ReleaseCompatibilityTarget $target): void + { + [$server, $apiUrl, $log] = $this->startServer(<<<'PHP' + $_SERVER['REQUEST_URI'], + 'authorization' => $_SERVER['HTTP_AUTHORIZATION'] ?? null, + 'accept' => $_SERVER['HTTP_ACCEPT'] ?? null, + 'version' => $_SERVER['HTTP_X_GITHUB_API_VERSION'] ?? null, +]) . PHP_EOL, FILE_APPEND); +header('Content-Type: application/json'); +echo json_encode(['permission' => 'maintain']); +PHP); + + try { + $process = $target->checkAuthorization( + 'LibreSign/libresign', + 'alice', + 'write', + $apiUrl, + ['GITHUB_TOKEN' => 'test-token'], + ); + + self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + self::assertSame([ + 'actor' => 'alice', + 'repository' => 'LibreSign/libresign', + 'minimum_permission' => 'write', + 'actual_permission' => 'maintain', + 'authorized' => true, + ], json_decode(trim($process->getOutput()), true, 512, JSON_THROW_ON_ERROR)); + + $request = json_decode(trim((string) file_get_contents($log)), true, 512, JSON_THROW_ON_ERROR); + self::assertSame('/repos/LibreSign/libresign/collaborators/alice/permission', $request['uri']); + self::assertSame('Bearer test-token', $request['authorization']); + self::assertSame('application/vnd.github+json', $request['accept']); + self::assertSame('2022-11-28', $request['version']); + } finally { + $server->stop(); + } + } + + #[DataProvider('authorizationAndStableTargets')] + public function testAuthorizationMapsNotFoundToNoneAndExitThree(ReleaseCompatibilityTarget $target): void + { + [$server, $apiUrl] = $this->startServer(<<<'PHP' +checkAuthorization( + 'LibreSign/libresign', + 'outsider', + 'read', + $apiUrl, + ['GITHUB_TOKEN' => 'test-token'], + ); + + self::assertSame(3, $process->getExitCode(), $process->getErrorOutput()); + $payload = json_decode(trim($process->getOutput()), true, 512, JSON_THROW_ON_ERROR); + self::assertSame('none', $payload['actual_permission']); + self::assertFalse($payload['authorized']); + } finally { + $server->stop(); + } + } + + #[DataProvider('authorizationAndStableTargets')] + public function testAuthorizationRejectsMissingTokenBeforeHttp(ReleaseCompatibilityTarget $target): void + { + [$server, $apiUrl, $log] = $this->startServer(<<<'PHP' + 'admin']); +PHP); + + try { + $process = $target->checkAuthorization( + 'LibreSign/libresign', + 'alice', + 'write', + $apiUrl, + ['GITHUB_TOKEN' => ''], + ); + + self::assertSame(2, $process->getExitCode()); + self::assertFileDoesNotExist($log); + } finally { + $server->stop(); + } + } + + #[DataProvider('authorizationAndStableTargets')] + public function testStableSelectionWritesActionOutputsAndSummary(ReleaseCompatibilityTarget $target): void + { + $root = $this->temporaryDirectory('stable-select-'); + $bin = $root . '/bin'; + mkdir($bin); + $git = $bin . '/git'; + file_put_contents($git, <<<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' \ + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/stable14' \ + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/heads/stable15' \ + 'cccccccccccccccccccccccccccccccccccccccc refs/heads/stable9' \ + 'dddddddddddddddddddddddddddddddddddddddd refs/heads/stable0' \ + 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee refs/heads/not-stable' +SH); + chmod($git, 0755); + + $output = $root . '/output'; + $summary = $root . '/summary'; + $process = $target->selectStable( + 'LibreSign/libresign', + 'stable15', + [ + 'GITHUB_OUTPUT' => $output, + 'GITHUB_STEP_SUMMARY' => $summary, + 'PATH' => $bin . PATH_SEPARATOR . (getenv('PATH') ?: ''), + ], + ); + + self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + self::assertStringContainsString('Latest stable branch: stable15', $process->getOutput()); + self::assertStringContainsString('Publish nightly: true', $process->getOutput()); + self::assertSame( + "current_branch=stable15\ncurrent_major=15\nlatest_branch=stable15\nlatest_major=15\nis_latest=true\n", + file_get_contents($output), + ); + self::assertStringContainsString('- Publish nightly: `true`', (string) file_get_contents($summary)); + } + + #[DataProvider('artifactTargets')] + public function testArtifactValidationAcceptsMatchingNextcloudArchive(ReleaseCompatibilityTarget $target): void + { + $root = $this->temporaryDirectory('artifact-valid-'); + $tar = $root . '/libresign-v15.0.4.tar.gz'; + $this->createTarGz($tar, [ + 'libresign/appinfo/info.xml' => '15.0.4', + 'libresign/CHANGELOG.md' => '# Changelog', + ]); + + $process = $target->validateArtifact($tar, 'libresign', '15.0.4'); + + self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + self::assertStringContainsString('Validated release artifact:', $process->getOutput()); + } + + #[DataProvider('artifactTargets')] + public function testArtifactValidationRejectsTraversal(ReleaseCompatibilityTarget $target): void + { + $root = $this->temporaryDirectory('artifact-traversal-'); + $tar = $root . '/unsafe.tar.gz'; + $this->createUnsafeTarGz($tar); + + $process = $target->validateArtifact($tar, 'libresign', '15.0.4'); + + self::assertSame(2, $process->getExitCode()); + self::assertStringContainsStringIgnoringCase('unsafe archive path: ../evil.txt', $process->getErrorOutput()); + } + + #[DataProvider('authorizationAndStableTargets')] + public function testReleaseNotesRejectsMissingTokenBeforeGitOrHttp(ReleaseCompatibilityTarget $target): void + { + $root = $this->temporaryDirectory('release-notes-no-token-'); + $bin = $root . '/bin'; + mkdir($bin); + $gitLog = $root . '/git.log'; + $git = $bin . '/git'; + file_put_contents($git, <<<'SH' +#!/usr/bin/env bash +printf '%s\n' called >> "$GIT_LOG" +exit 2 +SH); + chmod($git, 0755); + + $process = $target->releaseNotes( + 'LibreSign/libresign', + 'stable15', + $root, + 'https://api.github.test', + 'https://github.test', + '', + 'HEAD', + 10, + [ + 'RELEASE_NOTES_GITHUB_TOKEN' => '', + 'GIT_LOG' => $gitLog, + 'PATH' => $bin . PATH_SEPARATOR . (getenv('PATH') ?: ''), + ], + ); + + self::assertSame(1, $process->getExitCode()); + self::assertStringContainsString('::error::github token is required', $process->getOutput()); + self::assertFileDoesNotExist($gitLog); + } + + #[DataProvider('authorizationAndStableTargets')] + public function testReleaseNotesRejectsInvalidFallbackLimitBeforeGitOrHttp(ReleaseCompatibilityTarget $target): void + { + $root = $this->temporaryDirectory('release-notes-'); + $process = $target->releaseNotes( + 'LibreSign/libresign', + 'stable15', + $root, + 'https://api.github.test', + 'https://github.test', + '', + 'HEAD', + 0, + ['RELEASE_NOTES_GITHUB_TOKEN' => 'test-token'], + ); + + self::assertSame(1, $process->getExitCode()); + self::assertStringContainsString('::error::fallback-limit must be greater than zero', $process->getOutput()); + } + + #[DataProvider('artifactTargets')] + public function testArtifactRestoreValidatesRunAndStripsCredentialsOnRedirect(ReleaseCompatibilityTarget $target): void + { + $root = $this->temporaryDirectory('artifact-restore-'); + $zip = $root . '/artifact.zip'; + $this->createZip($zip, ['payload/release.json' => '{"version":"15.0.4"}']); + + [$archiveServer, $archiveUrl, $archiveLog] = $this->startServer( + <<<'PHP' + $_SERVER['HTTP_AUTHORIZATION'] ?? null, + 'accept' => $_SERVER['HTTP_ACCEPT'] ?? null, + 'version' => $_SERVER['HTTP_X_GITHUB_API_VERSION'] ?? null, +]) . PHP_EOL, FILE_APPEND); +header('Content-Type: application/zip'); +readfile(getenv('ARCHIVE_FILE')); +PHP, + ['ARCHIVE_FILE' => $zip], + ); + + [$apiServer, $apiUrl] = $this->startServer( + <<<'PHP' + [[ + 'id' => 11, + 'name' => 'release-package', + 'expired' => false, + 'created_at' => '2026-09-23T12:00:00Z', + 'archive_download_url' => 'http://' . $_SERVER['HTTP_HOST'] . '/artifact-download', + 'workflow_run' => ['id' => 22, 'head_sha' => str_repeat('a', 40)], + ]]]); + return; +} +if ($path === '/repos/LibreSign/libresign/actions/runs/22') { + echo json_encode([ + 'event' => 'workflow_dispatch', + 'path' => '.github/workflows/build.yml', + ]); + return; +} +http_response_code(404); +echo '{}'; +PHP, + ['ARCHIVE_URL' => $archiveUrl . '/artifact.zip'], + ); + + try { + $destination = $root . '/restored'; + $process = $target->restoreArtifact( + 'LibreSign/libresign', + 'release-package', + str_repeat('a', 40), + $destination, + $apiUrl, + 'workflow_dispatch', + '.github/workflows/build.yml', + ['GITHUB_TOKEN' => 'top-secret-test-token'], + ); + + self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + self::assertSame('{"version":"15.0.4"}', file_get_contents($destination . '/payload/release.json')); + $payload = json_decode(trim($process->getOutput()), true, 512, JSON_THROW_ON_ERROR); + self::assertSame(11, $payload['artifact_id']); + self::assertSame(22, $payload['workflow_run_id']); + + $redirectedRequest = json_decode( + trim((string) file_get_contents($archiveLog)), + true, + 512, + JSON_THROW_ON_ERROR, + ); + self::assertNull($redirectedRequest['authorization']); + } finally { + $apiServer->stop(); + $archiveServer->stop(); + } + } + + #[DataProvider('artifactTargets')] + public function testArtifactRestoreRejectsPreexistingSymlinkEscape(ReleaseCompatibilityTarget $target): void + { + if (PHP_OS_FAMILY === 'Windows') { + self::markTestSkipped('Symlink fixture is Unix-oriented.'); + } + + $root = $this->temporaryDirectory('artifact-restore-symlink-'); + $outside = $this->temporaryDirectory('artifact-restore-outside-'); + $destination = $root . '/restored'; + mkdir($destination); + self::assertTrue(symlink($outside, $destination . '/link')); + + $zip = $root . '/symlink-escape.zip'; + $this->createZip($zip, ['link/evil.txt' => 'evil']); + + [$archiveServer, $archiveUrl] = $this->startServer( + <<<'PHP' + $zip], + ); + + [$apiServer, $apiUrl] = $this->startServer( + <<<'PHP' + [[ + 'id' => 31, + 'name' => 'symlink-package', + 'expired' => false, + 'created_at' => '2026-09-23T12:00:00Z', + 'archive_download_url' => getenv('ARCHIVE_URL'), + 'workflow_run' => ['id' => 32, 'head_sha' => str_repeat('c', 40)], + ]]]); + return; +} +echo '{}'; +PHP, + ['ARCHIVE_URL' => $archiveUrl . '/artifact.zip'], + ); + + try { + $process = $target->restoreArtifact( + repository: 'LibreSign/libresign', + name: 'symlink-package', + expectedHeadSha: str_repeat('c', 40), + destination: $destination, + apiUrl: $apiUrl, + environment: ['GITHUB_TOKEN' => 'test-token'], + ); + + self::assertNotSame(0, $process->getExitCode()); + self::assertFileDoesNotExist($outside . '/evil.txt'); + } finally { + $apiServer->stop(); + $archiveServer->stop(); + } + } + + #[DataProvider('artifactTargets')] + public function testArtifactRestoreRejectsZipTraversal(ReleaseCompatibilityTarget $target): void + { + $root = $this->temporaryDirectory('artifact-restore-traversal-'); + $zip = $root . '/unsafe.zip'; + $this->createUnsafeZip($zip); + + [$server, $apiUrl] = $this->startServer( + <<<'PHP' + [[ + 'id' => 31, + 'name' => 'unsafe-package', + 'expired' => false, + 'created_at' => '2026-09-23T12:00:00Z', + 'archive_download_url' => 'http://' . $_SERVER['HTTP_HOST'] . '/artifact-download', + 'workflow_run' => ['id' => 32, 'head_sha' => str_repeat('b', 40)], + ]]]); + return; +} +http_response_code(404); +echo '{}'; +PHP, + ['ARCHIVE_FILE' => $zip], + ); + + try { + $process = $target->restoreArtifact( + 'LibreSign/libresign', + 'unsafe-package', + str_repeat('b', 40), + $root . '/restored', + $apiUrl, + environment: ['GITHUB_TOKEN' => 'test-token'], + ); + + self::assertNotSame(0, $process->getExitCode()); + self::assertStringContainsString('unsafe artifact path: ../evil.txt', $process->getErrorOutput()); + self::assertFileDoesNotExist($root . '/evil.txt'); + } finally { + $server->stop(); + } + } + + #[DataProvider('authorizationAndStableTargets')] + public function testReleaseNotesPreferPullRequestsDeduplicateAndSanitizeContributorText(ReleaseCompatibilityTarget $target): void + { + $root = $this->temporaryDirectory('release-notes-success-'); + $bin = $root . '/bin'; + mkdir($bin); + $git = $bin . '/git'; + file_put_contents($git, <<<'SH' +#!/usr/bin/env bash +set -euo pipefail +if [ "$1" = "rev-list" ]; then + printf '%s\n' sha1111111111111111111111111111111111111 sha2222222222222222222222222222222222222 sha3333333333333333333333333333333333333 + exit 0 +fi +if [ "$1" = "show" ]; then + case "${4:-}" in + sha3333333333333333333333333333333333333) printf '%s\n' 'direct @bob _change_' ;; + *) printf '%s\n' 'unused subject' ;; + esac + exit 0 +fi +exit 2 +SH); + chmod($git, 0755); + + [$server, $apiUrl] = $this->startServer(<<<'PHP' + 10, + 'title' => 'Fix @alice *release*', + 'merged_at' => '2026-09-23T10:00:00Z', + 'base' => ['ref' => 'stable15'], + ]]); + return; +} +if (str_contains($path, '/sha2222222222222222222222222222222222222/pulls')) { + echo json_encode([[ + 'number' => 10, + 'title' => 'Fix @alice *release*', + 'merged_at' => '2026-09-23T10:00:00Z', + 'base' => ['ref' => 'stable15'], + ]]); + return; +} +echo '[]'; +PHP); + + try { + $output = $root . '/output'; + $runnerTemp = $root . '/runner'; + mkdir($runnerTemp); + $process = $target->releaseNotes( + 'LibreSign/libresign', + 'stable15', + $root, + $apiUrl, + 'https://github.example.test', + 'v15.0.3', + 'HEAD', + 10, + [ + 'RELEASE_NOTES_GITHUB_TOKEN' => 'test-token', + 'GITHUB_OUTPUT' => $output, + 'RUNNER_TEMP' => $runnerTemp, + 'PATH' => $bin . PATH_SEPARATOR . (getenv('PATH') ?: ''), + ], + ); + + self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + self::assertStringContainsString('Generated 2 change entries (1 pull requests, 1 direct commits).', $process->getOutput()); + + $outputs = []; + foreach (file($output, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + [$name, $value] = explode('=', $line, 2); + $outputs[$name] = $value; + } + self::assertSame('2', $outputs['change-count']); + self::assertSame('1', $outputs['pull-request-count']); + self::assertSame('1', $outputs['commit-fallback-count']); + + $changes = (string) file_get_contents($outputs['changes-file']); + self::assertStringContainsString('- Fix @​alice \\*release\\* ([#10](https://github.example.test/LibreSign/libresign/pull/10))', $changes); + self::assertStringContainsString('- direct @​bob \\_change\\_ (`sha3333`)', $changes); + self::assertSame(2, substr_count($changes, PHP_EOL)); + } finally { + $server->stop(); + } + } + + /** + * @return array{0: Process, 1: string, 2?: string} + */ + private function startServer(string $router, array $environment = []): array + { + $root = $this->temporaryDirectory('fake-github-'); + $routerPath = $root . '/router.php'; + $log = $root . '/requests.log'; + file_put_contents($routerPath, $router); + + for ($attempt = 0; $attempt < 20; ++$attempt) { + $port = random_int(20000, 45000); + $process = new Process( + ['php', '-S', '127.0.0.1:' . $port, $routerPath], + $root, + ['REQUEST_LOG' => $log] + $environment, + ); + $process->start(); + + for ($probe = 0; $probe < 30; ++$probe) { + $socket = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.05); + if (is_resource($socket)) { + fclose($socket); + + return [$process, 'http://127.0.0.1:' . $port, $log]; + } + usleep(20_000); + } + $process->stop(); + } + + self::fail('Unable to start local fake GitHub API server.'); + } + + /** + * @param array $files + */ + private function createTarGz(string $path, array $files): void + { + $tar = substr($path, 0, -3); + $archive = new PharData($tar); + foreach ($files as $name => $content) { + $archive->addFromString($name, $content); + } + $archive->compress(\Phar::GZ); + unset($archive); + @unlink($tar); + } + + /** + * @param array $files + */ + private function createZip(string $path, array $files): void + { + $payload = json_encode($files, JSON_THROW_ON_ERROR); + $code = <<<'PY' +import json +import sys +import zipfile + +files = json.loads(sys.argv[2]) +with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in files.items(): + archive.writestr(name, content) +PY; + $process = new Process(['python3', '-c', $code, $path, $payload]); + $process->mustRun(); + } + + private function createUnsafeZip(string $path): void + { + $code = <<<'PY' +import sys +import zipfile + +with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("../evil.txt", "evil") +PY; + $process = new Process(['python3', '-c', $code, $path]); + $process->mustRun(); + } + + private function createUnsafeTarGz(string $path): void + { + $code = <<<'PY' +import io +import tarfile +import sys + +with tarfile.open(sys.argv[1], "w:gz") as archive: + info = tarfile.TarInfo("../evil.txt") + payload = b"evil" + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) +PY; + $process = new Process(['python3', '-c', $code, $path]); + $process->mustRun(); + } + + private function temporaryDirectory(string $prefix): string + { + $path = sys_get_temp_dir() . '/' . $prefix . bin2hex(random_bytes(8)); + mkdir($path, 0700, true); + $this->paths[] = $path; + + return $path; + } + + private function removeDirectory(string $path): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $item) { + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + @rmdir($path); + } +} From 620b6dcec80455372276036866585237b7168dbe Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Thu, 24 Sep 2026 02:57:22 -0300 Subject: [PATCH 2/9] test: retire Python release reference --- .../PythonReferenceCompatibilityTest.php | 692 ------------------ 1 file changed, 692 deletions(-) delete mode 100644 tests/Integration/Compatibility/PythonReferenceCompatibilityTest.php diff --git a/tests/Integration/Compatibility/PythonReferenceCompatibilityTest.php b/tests/Integration/Compatibility/PythonReferenceCompatibilityTest.php deleted file mode 100644 index 4b05946..0000000 --- a/tests/Integration/Compatibility/PythonReferenceCompatibilityTest.php +++ /dev/null @@ -1,692 +0,0 @@ - */ - private array $paths = []; - - /** @return iterable */ - public static function authorizationAndStableTargets(): iterable - { - yield 'python' => [ - new PythonReferenceTarget(dirname(__DIR__, 2) . '/Fixtures/PythonReference'), - ]; - yield 'php' => [ - new PhpReleaseToolTarget(dirname(__DIR__, 3)), - ]; - } - - /** @return iterable */ - public static function artifactTargets(): iterable - { - yield from self::authorizationAndStableTargets(); - } - - protected function tearDown(): void - { - foreach (array_reverse($this->paths) as $path) { - if (is_dir($path)) { - $this->removeDirectory($path); - } else { - @unlink($path); - } - } - } - - #[DataProvider('authorizationAndStableTargets')] - public function testAuthorizationReportsAuthorizedPermissionAsJson(ReleaseCompatibilityTarget $target): void - { - [$server, $apiUrl, $log] = $this->startServer(<<<'PHP' - $_SERVER['REQUEST_URI'], - 'authorization' => $_SERVER['HTTP_AUTHORIZATION'] ?? null, - 'accept' => $_SERVER['HTTP_ACCEPT'] ?? null, - 'version' => $_SERVER['HTTP_X_GITHUB_API_VERSION'] ?? null, -]) . PHP_EOL, FILE_APPEND); -header('Content-Type: application/json'); -echo json_encode(['permission' => 'maintain']); -PHP); - - try { - $process = $target->checkAuthorization( - 'LibreSign/libresign', - 'alice', - 'write', - $apiUrl, - ['GITHUB_TOKEN' => 'test-token'], - ); - - self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); - self::assertSame([ - 'actor' => 'alice', - 'repository' => 'LibreSign/libresign', - 'minimum_permission' => 'write', - 'actual_permission' => 'maintain', - 'authorized' => true, - ], json_decode(trim($process->getOutput()), true, 512, JSON_THROW_ON_ERROR)); - - $request = json_decode(trim((string) file_get_contents($log)), true, 512, JSON_THROW_ON_ERROR); - self::assertSame('/repos/LibreSign/libresign/collaborators/alice/permission', $request['uri']); - self::assertSame('Bearer test-token', $request['authorization']); - self::assertSame('application/vnd.github+json', $request['accept']); - self::assertSame('2022-11-28', $request['version']); - } finally { - $server->stop(); - } - } - - #[DataProvider('authorizationAndStableTargets')] - public function testAuthorizationMapsNotFoundToNoneAndExitThree(ReleaseCompatibilityTarget $target): void - { - [$server, $apiUrl] = $this->startServer(<<<'PHP' -checkAuthorization( - 'LibreSign/libresign', - 'outsider', - 'read', - $apiUrl, - ['GITHUB_TOKEN' => 'test-token'], - ); - - self::assertSame(3, $process->getExitCode(), $process->getErrorOutput()); - $payload = json_decode(trim($process->getOutput()), true, 512, JSON_THROW_ON_ERROR); - self::assertSame('none', $payload['actual_permission']); - self::assertFalse($payload['authorized']); - } finally { - $server->stop(); - } - } - - #[DataProvider('authorizationAndStableTargets')] - public function testAuthorizationRejectsMissingTokenBeforeHttp(ReleaseCompatibilityTarget $target): void - { - [$server, $apiUrl, $log] = $this->startServer(<<<'PHP' - 'admin']); -PHP); - - try { - $process = $target->checkAuthorization( - 'LibreSign/libresign', - 'alice', - 'write', - $apiUrl, - ['GITHUB_TOKEN' => ''], - ); - - self::assertSame(2, $process->getExitCode()); - self::assertFileDoesNotExist($log); - } finally { - $server->stop(); - } - } - - #[DataProvider('authorizationAndStableTargets')] - public function testStableSelectionWritesActionOutputsAndSummary(ReleaseCompatibilityTarget $target): void - { - $root = $this->temporaryDirectory('stable-select-'); - $bin = $root . '/bin'; - mkdir($bin); - $git = $bin . '/git'; - file_put_contents($git, <<<'SH' -#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' \ - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/stable14' \ - 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/heads/stable15' \ - 'cccccccccccccccccccccccccccccccccccccccc refs/heads/stable9' \ - 'dddddddddddddddddddddddddddddddddddddddd refs/heads/stable0' \ - 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee refs/heads/not-stable' -SH); - chmod($git, 0755); - - $output = $root . '/output'; - $summary = $root . '/summary'; - $process = $target->selectStable( - 'LibreSign/libresign', - 'stable15', - [ - 'GITHUB_OUTPUT' => $output, - 'GITHUB_STEP_SUMMARY' => $summary, - 'PATH' => $bin . PATH_SEPARATOR . (getenv('PATH') ?: ''), - ], - ); - - self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); - self::assertStringContainsString('Latest stable branch: stable15', $process->getOutput()); - self::assertStringContainsString('Publish nightly: true', $process->getOutput()); - self::assertSame( - "current_branch=stable15\ncurrent_major=15\nlatest_branch=stable15\nlatest_major=15\nis_latest=true\n", - file_get_contents($output), - ); - self::assertStringContainsString('- Publish nightly: `true`', (string) file_get_contents($summary)); - } - - #[DataProvider('artifactTargets')] - public function testArtifactValidationAcceptsMatchingNextcloudArchive(ReleaseCompatibilityTarget $target): void - { - $root = $this->temporaryDirectory('artifact-valid-'); - $tar = $root . '/libresign-v15.0.4.tar.gz'; - $this->createTarGz($tar, [ - 'libresign/appinfo/info.xml' => '15.0.4', - 'libresign/CHANGELOG.md' => '# Changelog', - ]); - - $process = $target->validateArtifact($tar, 'libresign', '15.0.4'); - - self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); - self::assertStringContainsString('Validated release artifact:', $process->getOutput()); - } - - #[DataProvider('artifactTargets')] - public function testArtifactValidationRejectsTraversal(ReleaseCompatibilityTarget $target): void - { - $root = $this->temporaryDirectory('artifact-traversal-'); - $tar = $root . '/unsafe.tar.gz'; - $this->createUnsafeTarGz($tar); - - $process = $target->validateArtifact($tar, 'libresign', '15.0.4'); - - self::assertSame(2, $process->getExitCode()); - self::assertStringContainsStringIgnoringCase('unsafe archive path: ../evil.txt', $process->getErrorOutput()); - } - - #[DataProvider('authorizationAndStableTargets')] - public function testReleaseNotesRejectsMissingTokenBeforeGitOrHttp(ReleaseCompatibilityTarget $target): void - { - $root = $this->temporaryDirectory('release-notes-no-token-'); - $bin = $root . '/bin'; - mkdir($bin); - $gitLog = $root . '/git.log'; - $git = $bin . '/git'; - file_put_contents($git, <<<'SH' -#!/usr/bin/env bash -printf '%s\n' called >> "$GIT_LOG" -exit 2 -SH); - chmod($git, 0755); - - $process = $target->releaseNotes( - 'LibreSign/libresign', - 'stable15', - $root, - 'https://api.github.test', - 'https://github.test', - '', - 'HEAD', - 10, - [ - 'RELEASE_NOTES_GITHUB_TOKEN' => '', - 'GIT_LOG' => $gitLog, - 'PATH' => $bin . PATH_SEPARATOR . (getenv('PATH') ?: ''), - ], - ); - - self::assertSame(1, $process->getExitCode()); - self::assertStringContainsString('::error::github token is required', $process->getOutput()); - self::assertFileDoesNotExist($gitLog); - } - - #[DataProvider('authorizationAndStableTargets')] - public function testReleaseNotesRejectsInvalidFallbackLimitBeforeGitOrHttp(ReleaseCompatibilityTarget $target): void - { - $root = $this->temporaryDirectory('release-notes-'); - $process = $target->releaseNotes( - 'LibreSign/libresign', - 'stable15', - $root, - 'https://api.github.test', - 'https://github.test', - '', - 'HEAD', - 0, - ['RELEASE_NOTES_GITHUB_TOKEN' => 'test-token'], - ); - - self::assertSame(1, $process->getExitCode()); - self::assertStringContainsString('::error::fallback-limit must be greater than zero', $process->getOutput()); - } - - #[DataProvider('artifactTargets')] - public function testArtifactRestoreValidatesRunAndStripsCredentialsOnRedirect(ReleaseCompatibilityTarget $target): void - { - $root = $this->temporaryDirectory('artifact-restore-'); - $zip = $root . '/artifact.zip'; - $this->createZip($zip, ['payload/release.json' => '{"version":"15.0.4"}']); - - [$archiveServer, $archiveUrl, $archiveLog] = $this->startServer( - <<<'PHP' - $_SERVER['HTTP_AUTHORIZATION'] ?? null, - 'accept' => $_SERVER['HTTP_ACCEPT'] ?? null, - 'version' => $_SERVER['HTTP_X_GITHUB_API_VERSION'] ?? null, -]) . PHP_EOL, FILE_APPEND); -header('Content-Type: application/zip'); -readfile(getenv('ARCHIVE_FILE')); -PHP, - ['ARCHIVE_FILE' => $zip], - ); - - [$apiServer, $apiUrl] = $this->startServer( - <<<'PHP' - [[ - 'id' => 11, - 'name' => 'release-package', - 'expired' => false, - 'created_at' => '2026-09-23T12:00:00Z', - 'archive_download_url' => 'http://' . $_SERVER['HTTP_HOST'] . '/artifact-download', - 'workflow_run' => ['id' => 22, 'head_sha' => str_repeat('a', 40)], - ]]]); - return; -} -if ($path === '/repos/LibreSign/libresign/actions/runs/22') { - echo json_encode([ - 'event' => 'workflow_dispatch', - 'path' => '.github/workflows/build.yml', - ]); - return; -} -http_response_code(404); -echo '{}'; -PHP, - ['ARCHIVE_URL' => $archiveUrl . '/artifact.zip'], - ); - - try { - $destination = $root . '/restored'; - $process = $target->restoreArtifact( - 'LibreSign/libresign', - 'release-package', - str_repeat('a', 40), - $destination, - $apiUrl, - 'workflow_dispatch', - '.github/workflows/build.yml', - ['GITHUB_TOKEN' => 'top-secret-test-token'], - ); - - self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); - self::assertSame('{"version":"15.0.4"}', file_get_contents($destination . '/payload/release.json')); - $payload = json_decode(trim($process->getOutput()), true, 512, JSON_THROW_ON_ERROR); - self::assertSame(11, $payload['artifact_id']); - self::assertSame(22, $payload['workflow_run_id']); - - $redirectedRequest = json_decode( - trim((string) file_get_contents($archiveLog)), - true, - 512, - JSON_THROW_ON_ERROR, - ); - self::assertNull($redirectedRequest['authorization']); - } finally { - $apiServer->stop(); - $archiveServer->stop(); - } - } - - #[DataProvider('artifactTargets')] - public function testArtifactRestoreRejectsPreexistingSymlinkEscape(ReleaseCompatibilityTarget $target): void - { - if (PHP_OS_FAMILY === 'Windows') { - self::markTestSkipped('Symlink fixture is Unix-oriented.'); - } - - $root = $this->temporaryDirectory('artifact-restore-symlink-'); - $outside = $this->temporaryDirectory('artifact-restore-outside-'); - $destination = $root . '/restored'; - mkdir($destination); - self::assertTrue(symlink($outside, $destination . '/link')); - - $zip = $root . '/symlink-escape.zip'; - $this->createZip($zip, ['link/evil.txt' => 'evil']); - - [$archiveServer, $archiveUrl] = $this->startServer( - <<<'PHP' - $zip], - ); - - [$apiServer, $apiUrl] = $this->startServer( - <<<'PHP' - [[ - 'id' => 31, - 'name' => 'symlink-package', - 'expired' => false, - 'created_at' => '2026-09-23T12:00:00Z', - 'archive_download_url' => getenv('ARCHIVE_URL'), - 'workflow_run' => ['id' => 32, 'head_sha' => str_repeat('c', 40)], - ]]]); - return; -} -echo '{}'; -PHP, - ['ARCHIVE_URL' => $archiveUrl . '/artifact.zip'], - ); - - try { - $process = $target->restoreArtifact( - repository: 'LibreSign/libresign', - name: 'symlink-package', - expectedHeadSha: str_repeat('c', 40), - destination: $destination, - apiUrl: $apiUrl, - environment: ['GITHUB_TOKEN' => 'test-token'], - ); - - self::assertNotSame(0, $process->getExitCode()); - self::assertFileDoesNotExist($outside . '/evil.txt'); - } finally { - $apiServer->stop(); - $archiveServer->stop(); - } - } - - #[DataProvider('artifactTargets')] - public function testArtifactRestoreRejectsZipTraversal(ReleaseCompatibilityTarget $target): void - { - $root = $this->temporaryDirectory('artifact-restore-traversal-'); - $zip = $root . '/unsafe.zip'; - $this->createUnsafeZip($zip); - - [$server, $apiUrl] = $this->startServer( - <<<'PHP' - [[ - 'id' => 31, - 'name' => 'unsafe-package', - 'expired' => false, - 'created_at' => '2026-09-23T12:00:00Z', - 'archive_download_url' => 'http://' . $_SERVER['HTTP_HOST'] . '/artifact-download', - 'workflow_run' => ['id' => 32, 'head_sha' => str_repeat('b', 40)], - ]]]); - return; -} -http_response_code(404); -echo '{}'; -PHP, - ['ARCHIVE_FILE' => $zip], - ); - - try { - $process = $target->restoreArtifact( - 'LibreSign/libresign', - 'unsafe-package', - str_repeat('b', 40), - $root . '/restored', - $apiUrl, - environment: ['GITHUB_TOKEN' => 'test-token'], - ); - - self::assertNotSame(0, $process->getExitCode()); - self::assertStringContainsString('unsafe artifact path: ../evil.txt', $process->getErrorOutput()); - self::assertFileDoesNotExist($root . '/evil.txt'); - } finally { - $server->stop(); - } - } - - #[DataProvider('authorizationAndStableTargets')] - public function testReleaseNotesPreferPullRequestsDeduplicateAndSanitizeContributorText(ReleaseCompatibilityTarget $target): void - { - $root = $this->temporaryDirectory('release-notes-success-'); - $bin = $root . '/bin'; - mkdir($bin); - $git = $bin . '/git'; - file_put_contents($git, <<<'SH' -#!/usr/bin/env bash -set -euo pipefail -if [ "$1" = "rev-list" ]; then - printf '%s\n' sha1111111111111111111111111111111111111 sha2222222222222222222222222222222222222 sha3333333333333333333333333333333333333 - exit 0 -fi -if [ "$1" = "show" ]; then - case "${4:-}" in - sha3333333333333333333333333333333333333) printf '%s\n' 'direct @bob _change_' ;; - *) printf '%s\n' 'unused subject' ;; - esac - exit 0 -fi -exit 2 -SH); - chmod($git, 0755); - - [$server, $apiUrl] = $this->startServer(<<<'PHP' - 10, - 'title' => 'Fix @alice *release*', - 'merged_at' => '2026-09-23T10:00:00Z', - 'base' => ['ref' => 'stable15'], - ]]); - return; -} -if (str_contains($path, '/sha2222222222222222222222222222222222222/pulls')) { - echo json_encode([[ - 'number' => 10, - 'title' => 'Fix @alice *release*', - 'merged_at' => '2026-09-23T10:00:00Z', - 'base' => ['ref' => 'stable15'], - ]]); - return; -} -echo '[]'; -PHP); - - try { - $output = $root . '/output'; - $runnerTemp = $root . '/runner'; - mkdir($runnerTemp); - $process = $target->releaseNotes( - 'LibreSign/libresign', - 'stable15', - $root, - $apiUrl, - 'https://github.example.test', - 'v15.0.3', - 'HEAD', - 10, - [ - 'RELEASE_NOTES_GITHUB_TOKEN' => 'test-token', - 'GITHUB_OUTPUT' => $output, - 'RUNNER_TEMP' => $runnerTemp, - 'PATH' => $bin . PATH_SEPARATOR . (getenv('PATH') ?: ''), - ], - ); - - self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); - self::assertStringContainsString('Generated 2 change entries (1 pull requests, 1 direct commits).', $process->getOutput()); - - $outputs = []; - foreach (file($output, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { - [$name, $value] = explode('=', $line, 2); - $outputs[$name] = $value; - } - self::assertSame('2', $outputs['change-count']); - self::assertSame('1', $outputs['pull-request-count']); - self::assertSame('1', $outputs['commit-fallback-count']); - - $changes = (string) file_get_contents($outputs['changes-file']); - self::assertStringContainsString('- Fix @​alice \\*release\\* ([#10](https://github.example.test/LibreSign/libresign/pull/10))', $changes); - self::assertStringContainsString('- direct @​bob \\_change\\_ (`sha3333`)', $changes); - self::assertSame(2, substr_count($changes, PHP_EOL)); - } finally { - $server->stop(); - } - } - - private function target(): ReleaseCompatibilityTarget - { - return new PythonReferenceTarget(dirname(__DIR__, 2) . '/Fixtures/PythonReference'); - } - - /** - * @return array{0: Process, 1: string, 2?: string} - */ - private function startServer(string $router, array $environment = []): array - { - $root = $this->temporaryDirectory('fake-github-'); - $routerPath = $root . '/router.php'; - $log = $root . '/requests.log'; - file_put_contents($routerPath, $router); - - for ($attempt = 0; $attempt < 20; ++$attempt) { - $port = random_int(20000, 45000); - $process = new Process( - ['php', '-S', '127.0.0.1:' . $port, $routerPath], - $root, - ['REQUEST_LOG' => $log] + $environment, - ); - $process->start(); - - for ($probe = 0; $probe < 30; ++$probe) { - $socket = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.05); - if (is_resource($socket)) { - fclose($socket); - - return [$process, 'http://127.0.0.1:' . $port, $log]; - } - usleep(20_000); - } - $process->stop(); - } - - self::fail('Unable to start local fake GitHub API server.'); - } - - /** - * @param array $files - */ - private function createTarGz(string $path, array $files): void - { - $tar = substr($path, 0, -3); - $archive = new PharData($tar); - foreach ($files as $name => $content) { - $archive->addFromString($name, $content); - } - $archive->compress(\Phar::GZ); - unset($archive); - @unlink($tar); - } - - /** - * @param array $files - */ - private function createZip(string $path, array $files): void - { - $payload = json_encode($files, JSON_THROW_ON_ERROR); - $code = <<<'PY' -import json -import sys -import zipfile - -files = json.loads(sys.argv[2]) -with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as archive: - for name, content in files.items(): - archive.writestr(name, content) -PY; - $process = new Process(['python3', '-c', $code, $path, $payload]); - $process->mustRun(); - } - - private function createUnsafeZip(string $path): void - { - $code = <<<'PY' -import sys -import zipfile - -with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as archive: - archive.writestr("../evil.txt", "evil") -PY; - $process = new Process(['python3', '-c', $code, $path]); - $process->mustRun(); - } - - private function createUnsafeTarGz(string $path): void - { - $code = <<<'PY' -import io -import tarfile -import sys - -with tarfile.open(sys.argv[1], "w:gz") as archive: - info = tarfile.TarInfo("../evil.txt") - payload = b"evil" - info.size = len(payload) - archive.addfile(info, io.BytesIO(payload)) -PY; - $process = new Process(['python3', '-c', $code, $path]); - $process->mustRun(); - } - - private function temporaryDirectory(string $prefix): string - { - $path = sys_get_temp_dir() . '/' . $prefix . bin2hex(random_bytes(8)); - mkdir($path, 0700, true); - $this->paths[] = $path; - - return $path; - } - - private function removeDirectory(string $path): void - { - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST, - ); - foreach ($iterator as $item) { - $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); - } - @rmdir($path); - } -} From 658367ae61d463b61aef6fe11869198a9bc4f25d Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Thu, 24 Sep 2026 02:57:24 -0300 Subject: [PATCH 3/9] test: retire Python release reference --- .../Compatibility/PythonReferenceTarget.php | 112 ------------------ 1 file changed, 112 deletions(-) delete mode 100644 tests/Support/Compatibility/PythonReferenceTarget.php diff --git a/tests/Support/Compatibility/PythonReferenceTarget.php b/tests/Support/Compatibility/PythonReferenceTarget.php deleted file mode 100644 index 4f8bdee..0000000 --- a/tests/Support/Compatibility/PythonReferenceTarget.php +++ /dev/null @@ -1,112 +0,0 @@ -run( - 'check_release_authorization.py', - ['--repository', $repository, '--actor', $actor, '--minimum', $minimum, '--api-url', $apiUrl], - $environment, - ); - } - - public function selectStable(string $repository, string $branch, array $environment = []): Process - { - return $this->run( - 'release_stable_select.py', - [], - ['INPUT_REPOSITORY' => $repository, 'INPUT_BRANCH' => $branch] + $environment, - ); - } - - public function validateArtifact( - string $artifact, - string $appName, - string $version, - array $environment = [], - ): Process { - return $this->run( - 'validate_release_artifact.py', - ['--artifact', $artifact, '--app-name', $appName, '--version', $version], - $environment, - ); - } - - public function restoreArtifact( - string $repository, - string $name, - string $expectedHeadSha, - string $destination, - string $apiUrl, - ?string $expectedEvent = null, - ?string $expectedWorkflowPath = null, - array $environment = [], - ): Process { - $arguments = [ - '--repository', $repository, - '--name', $name, - '--expected-head-sha', $expectedHeadSha, - '--destination', $destination, - '--api-url', $apiUrl, - ]; - if ($expectedEvent !== null) { - $arguments = [...$arguments, '--expected-event', $expectedEvent]; - } - if ($expectedWorkflowPath !== null) { - $arguments = [...$arguments, '--expected-workflow-path', $expectedWorkflowPath]; - } - - return $this->run('restore_release_artifact.py', $arguments, $environment); - } - - public function releaseNotes( - string $repository, - string $branch, - string $workingDirectory, - string $apiUrl, - string $serverUrl, - string $fromRef, - string $toRef, - int $fallbackLimit, - array $environment = [], - ): Process { - return $this->run('release_notes_from_pull_requests.py', [], [ - 'RELEASE_NOTES_REPOSITORY' => $repository, - 'RELEASE_NOTES_BRANCH' => $branch, - 'RELEASE_NOTES_WORKING_DIRECTORY' => $workingDirectory, - 'RELEASE_NOTES_FROM_REF' => $fromRef, - 'RELEASE_NOTES_TO_REF' => $toRef, - 'RELEASE_NOTES_FALLBACK_LIMIT' => (string) $fallbackLimit, - 'GITHUB_API_URL' => $apiUrl, - 'GITHUB_SERVER_URL' => $serverUrl, - ] + $environment); - } - - /** @param list $arguments @param array $environment */ - private function run(string $script, array $arguments, array $environment): Process - { - $process = new Process(['python3', $this->fixtureDirectory . '/' . $script, ...$arguments], null, $environment); - $process->run(); - - return $process; - } -} From 564cba6b722546f2d8a97256d4067e84697b83ba Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Thu, 24 Sep 2026 02:57:27 -0300 Subject: [PATCH 4/9] test: retire Python release reference --- tests/Fixtures/PythonReference/README.md | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 tests/Fixtures/PythonReference/README.md diff --git a/tests/Fixtures/PythonReference/README.md b/tests/Fixtures/PythonReference/README.md deleted file mode 100644 index f3a9fc9..0000000 --- a/tests/Fixtures/PythonReference/README.md +++ /dev/null @@ -1,24 +0,0 @@ - - -# Temporary Python reference implementation - -These files are test fixtures for the Python-to-PHP compatibility migration tracked in #55. - -They were copied from `LibreCodeCoop/github-workflows` at commit -`41c110486f0c5da331847cbf25c673d5e9378876` and must not be used as production -runtime code. - -| Fixture | Original path | -| --- | --- | -| `check_release_authorization.py` | `scripts/check_release_authorization.py` | -| `restore_release_artifact.py` | `scripts/restore_release_artifact.py` | -| `validate_release_artifact.py` | `scripts/validate_release_artifact.py` | -| `release_notes_from_pull_requests.py` | `actions/release-notes-from-pull-requests/generate.py` | -| `release_stable_select.py` | `actions/release-stable-select/resolve.py` | - -The compatibility tests execute these fixtures as subprocesses and assert observable -contracts. Once equivalent PHP behavior passes the same scenarios, these Python -fixtures should be deleted. From b43be70c7e3b4dcdcdbfff0a2073412e927b9e15 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Thu, 24 Sep 2026 02:57:29 -0300 Subject: [PATCH 5/9] test: retire Python release reference --- .../check_release_authorization.py | 96 ------------------- 1 file changed, 96 deletions(-) delete mode 100644 tests/Fixtures/PythonReference/check_release_authorization.py diff --git a/tests/Fixtures/PythonReference/check_release_authorization.py b/tests/Fixtures/PythonReference/check_release_authorization.py deleted file mode 100644 index 41d99cc..0000000 --- a/tests/Fixtures/PythonReference/check_release_authorization.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -from __future__ import annotations - -import argparse -import json -import os -from urllib.error import HTTPError -from urllib.parse import quote -from urllib.request import Request, urlopen - -PERMISSION_RANK = { - "none": 0, - "read": 1, - "triage": 2, - "write": 3, - "maintain": 4, - "admin": 5, -} - - -def is_authorized(actual: str, minimum: str) -> bool: - if minimum not in PERMISSION_RANK: - raise ValueError(f"unsupported minimum permission: {minimum}") - if actual not in PERMISSION_RANK: - raise ValueError(f"unsupported repository permission: {actual}") - return PERMISSION_RANK[actual] >= PERMISSION_RANK[minimum] - - -def fetch_permission(repository: str, actor: str, token: str, api_url: str = "https://api.github.com") -> str: - if "/" not in repository: - raise ValueError("repository must use owner/name form") - if actor.strip() == "": - raise ValueError("actor must not be empty") - if token.strip() == "": - raise ValueError("GitHub token must not be empty") - - url = f"{api_url.rstrip('/')}/repos/{repository}/collaborators/{quote(actor, safe='')}/permission" - request = Request( - url, - headers={ - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "LibreCodeCoop/github-workflows", - }, - ) - - try: - with urlopen(request, timeout=30) as response: - payload = json.load(response) - except HTTPError as error: - if error.code == 404: - return "none" - raise RuntimeError(f"GitHub permission lookup failed with HTTP {error.code}") from error - - permission = payload.get("permission") if isinstance(payload, dict) else None - if not isinstance(permission, str) or permission not in PERMISSION_RANK: - raise RuntimeError("GitHub returned an invalid repository permission") - return permission - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--repository", required=True) - parser.add_argument("--actor", required=True) - parser.add_argument("--minimum", required=True) - parser.add_argument("--api-url", default=os.environ.get("GITHUB_API_URL", "https://api.github.com")) - args = parser.parse_args() - - try: - actual = fetch_permission( - args.repository, - args.actor, - os.environ.get("GITHUB_TOKEN", ""), - args.api_url, - ) - authorized = is_authorized(actual, args.minimum) - except (RuntimeError, ValueError) as error: - parser.error(str(error)) - - print(json.dumps({ - "actor": args.actor, - "repository": args.repository, - "minimum_permission": args.minimum, - "actual_permission": actual, - "authorized": authorized, - }, separators=(",", ":"))) - - return 0 if authorized else 3 - - -if __name__ == "__main__": - raise SystemExit(main()) From f8249fe2eb85bb729b0b580833583141791ea7c0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Thu, 24 Sep 2026 02:57:32 -0300 Subject: [PATCH 6/9] test: retire Python release reference --- .../release_notes_from_pull_requests.py | 262 ------------------ 1 file changed, 262 deletions(-) delete mode 100644 tests/Fixtures/PythonReference/release_notes_from_pull_requests.py diff --git a/tests/Fixtures/PythonReference/release_notes_from_pull_requests.py b/tests/Fixtures/PythonReference/release_notes_from_pull_requests.py deleted file mode 100644 index fe3d946..0000000 --- a/tests/Fixtures/PythonReference/release_notes_from_pull_requests.py +++ /dev/null @@ -1,262 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -from __future__ import annotations - -import json -import os -import subprocess -import tempfile -import urllib.error -import urllib.request -from pathlib import Path -from typing import Any, Callable - -ApiRequest = Callable[[str], Any] - - -class ActionError(RuntimeError): - pass - - -def sanitize_markdown_text(value: str) -> str: - normalized = " ".join(value.replace("\r", "\n").splitlines()).strip() - for character in ("\\", "`", "*", "_", "{", "}", "[", "]", "<", ">"): - normalized = normalized.replace(character, f"\\{character}") - # PR titles and commit subjects can be contributor-controlled. Keep their - # visible text while preventing them from creating GitHub @mentions. - return normalized.replace("@", "@\u200b") - - -def choose_pull_request( - pull_requests: list[dict[str, Any]], - preferred_branch: str, -) -> dict[str, Any] | None: - merged = [ - pr - for pr in pull_requests - if pr.get("merged_at") and isinstance(pr.get("number"), int) - ] - if not merged: - return None - - preferred = [ - pr - for pr in merged - if isinstance(pr.get("base"), dict) - and pr["base"].get("ref") == preferred_branch - ] - candidates = preferred or merged - return min(candidates, key=lambda pr: int(pr["number"])) - - -def build_api_request(url: str, token: str) -> urllib.request.Request: - request = urllib.request.Request( - url, - method="GET", - headers={ - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, - ) - request.add_unredirected_header("Authorization", f"Bearer {token}") - return request - - -def github_api_get(url: str, token: str) -> Any: - request = build_api_request(url, token) - try: - with urllib.request.urlopen(request, timeout=30) as response: - body = response.read().decode("utf-8") - except urllib.error.HTTPError as error: - body = error.read().decode("utf-8", errors="replace") - raise ActionError( - f"GitHub API request failed ({error.code}): {body}" - ) from error - return json.loads(body) - - -def git_lines(working_directory: Path, *args: str) -> list[str]: - result = subprocess.run( - ["git", *args], - cwd=working_directory, - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - return [line for line in result.stdout.splitlines() if line] - - -def enumerate_commits( - working_directory: Path, - from_ref: str, - to_ref: str, - fallback_limit: int, -) -> list[str]: - if from_ref: - return git_lines( - working_directory, - "rev-list", - "--reverse", - f"{from_ref}..{to_ref}", - ) - return git_lines( - working_directory, - "rev-list", - "--reverse", - f"--max-count={fallback_limit}", - to_ref, - ) - - -def commit_subject(working_directory: Path, sha: str) -> str: - lines = git_lines(working_directory, "show", "-s", "--format=%s", sha) - if not lines: - raise ActionError(f"cannot resolve subject for commit {sha}") - return sanitize_markdown_text(lines[0]) - - -def generate_changes( - *, - commits: list[str], - repository: str, - branch: str, - server_url: str, - api_url: str, - token: str, - subject_lookup: Callable[[str], str], - request: Callable[[str, str], Any] = github_api_get, -) -> tuple[list[str], int, int]: - seen_pull_requests: set[int] = set() - lines: list[str] = [] - pull_request_count = 0 - commit_fallback_count = 0 - - owner, repo = repository.split("/", 1) - clean_server_url = server_url.rstrip("/") - clean_api_url = api_url.rstrip("/") - - for sha in commits: - pull_requests = request( - f"{clean_api_url}/repos/{owner}/{repo}/commits/{sha}/pulls", - token, - ) - if not isinstance(pull_requests, list): - raise ActionError( - f"unexpected pull request response for commit {sha}" - ) - - pull_request = choose_pull_request(pull_requests, branch) - if pull_request is not None: - number = int(pull_request["number"]) - if number in seen_pull_requests: - continue - seen_pull_requests.add(number) - title = sanitize_markdown_text(str(pull_request.get("title") or "")) - if not title: - title = f"Pull request #{number}" - url = f"{clean_server_url}/{repository}/pull/{number}" - lines.append(f"- {title} ([#{number}]({url}))") - pull_request_count += 1 - continue - - subject = subject_lookup(sha) - lines.append(f"- {subject} (`{sha[:7]}`)") - commit_fallback_count += 1 - - return lines, pull_request_count, commit_fallback_count - - -def write_output(name: str, value: str) -> None: - output = os.environ.get("GITHUB_OUTPUT") - if not output: - return - with Path(output).open("a", encoding="utf-8") as handle: - handle.write(f"{name}={value}\n") - - -def main() -> int: - token = os.environ.get("RELEASE_NOTES_GITHUB_TOKEN", "") - repository = os.environ.get("RELEASE_NOTES_REPOSITORY", "") - branch = os.environ.get("RELEASE_NOTES_BRANCH", "") - working_directory = Path( - os.environ.get("RELEASE_NOTES_WORKING_DIRECTORY", ".") - ).resolve() - from_ref = os.environ.get("RELEASE_NOTES_FROM_REF", "").strip() - to_ref = os.environ.get("RELEASE_NOTES_TO_REF", "HEAD").strip() or "HEAD" - fallback_limit_raw = os.environ.get("RELEASE_NOTES_FALLBACK_LIMIT", "10") - server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com") - api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com") - - if not token: - raise ActionError("github token is required") - if repository.count("/") != 1: - raise ActionError("repository must be in owner/name form") - if not branch: - raise ActionError("branch is required") - if not working_directory.is_dir(): - raise ActionError(f"working directory does not exist: {working_directory}") - - try: - fallback_limit = int(fallback_limit_raw) - except ValueError as error: - raise ActionError("fallback-limit must be an integer") from error - if fallback_limit <= 0: - raise ActionError("fallback-limit must be greater than zero") - - commits = enumerate_commits( - working_directory, - from_ref, - to_ref, - fallback_limit, - ) - lines, pull_request_count, commit_fallback_count = generate_changes( - commits=commits, - repository=repository, - branch=branch, - server_url=server_url, - api_url=api_url, - token=token, - subject_lookup=lambda sha: commit_subject(working_directory, sha), - ) - - runner_temp = Path(os.environ.get("RUNNER_TEMP", tempfile.gettempdir())) - runner_temp.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - prefix="release-note-changes-", - suffix=".md", - dir=runner_temp, - delete=False, - ) as handle: - for line in lines: - handle.write(f"{line}\n") - changes_file = Path(handle.name) - - write_output("changes-file", str(changes_file)) - write_output("change-count", str(len(lines))) - write_output("pull-request-count", str(pull_request_count)) - write_output("commit-fallback-count", str(commit_fallback_count)) - - print( - f"Generated {len(lines)} change entries " - f"({pull_request_count} pull requests, " - f"{commit_fallback_count} direct commits)." - ) - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main()) - except ( - ActionError, - OSError, - subprocess.CalledProcessError, - json.JSONDecodeError, - ) as error: - print(f"::error::{error}") - raise SystemExit(1) from error From 53981ec8b903ae2448a29b9c4fa65ac7c78ab852 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Thu, 24 Sep 2026 02:57:34 -0300 Subject: [PATCH 7/9] test: retire Python release reference --- .../PythonReference/release_stable_select.py | 130 ------------------ 1 file changed, 130 deletions(-) delete mode 100644 tests/Fixtures/PythonReference/release_stable_select.py diff --git a/tests/Fixtures/PythonReference/release_stable_select.py b/tests/Fixtures/PythonReference/release_stable_select.py deleted file mode 100644 index fda3161..0000000 --- a/tests/Fixtures/PythonReference/release_stable_select.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -from __future__ import annotations - -import os -import re -import subprocess -from dataclasses import dataclass -from pathlib import Path - -REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") -STABLE_REF_RE = re.compile(r"^refs/heads/(stable([1-9][0-9]*))$") -STABLE_BRANCH_RE = re.compile(r"^stable([1-9][0-9]*)$") - - -@dataclass(frozen=True) -class StableState: - current_branch: str - current_major: int | None - latest_branch: str | None - latest_major: int | None - - @property - def is_latest(self) -> bool: - return self.latest_branch is not None and self.current_branch == self.latest_branch - - -def parse_stable_refs(output: str) -> dict[int, str]: - branches: dict[int, str] = {} - for raw_line in output.splitlines(): - parts = raw_line.strip().split() - if len(parts) != 2: - continue - match = STABLE_REF_RE.fullmatch(parts[1]) - if match is None: - continue - branch = match.group(1) - major = int(match.group(2)) - branches[major] = branch - return branches - - -def resolve_state(current_branch: str, branches: dict[int, str]) -> StableState: - current_match = STABLE_BRANCH_RE.fullmatch(current_branch) - current_major = int(current_match.group(1)) if current_match else None - - if not branches: - return StableState(current_branch, current_major, None, None) - - latest_major = max(branches) - return StableState( - current_branch=current_branch, - current_major=current_major, - latest_branch=branches[latest_major], - latest_major=latest_major, - ) - - -def list_remote_stable_refs(repository: str) -> str: - if REPOSITORY_RE.fullmatch(repository) is None: - raise ValueError(f"invalid repository: {repository!r}") - - result = subprocess.run( - [ - "git", - "ls-remote", - "--heads", - f"https://github.com/{repository}.git", - "refs/heads/stable*", - ], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - return result.stdout - - -def write_output(name: str, value: str) -> None: - output = os.environ.get("GITHUB_OUTPUT") - if output: - with Path(output).open("a", encoding="utf-8") as handle: - handle.write(f"{name}={value}\n") - - -def write_summary(state: StableState) -> None: - summary = os.environ.get("GITHUB_STEP_SUMMARY") - if not summary: - return - - current_major = str(state.current_major) if state.current_major is not None else "n/a" - latest_branch = state.latest_branch or "none" - latest_major = str(state.latest_major) if state.latest_major is not None else "n/a" - with Path(summary).open("a", encoding="utf-8") as handle: - handle.write( - "### Stable release branch selection\n\n" - f"- Current branch: `{state.current_branch}`\n" - f"- Current release line: `{current_major}`\n" - f"- Latest stable branch: `{latest_branch}`\n" - f"- Latest release line: `{latest_major}`\n" - f"- Publish nightly: `{str(state.is_latest).lower()}`\n" - ) - - -def main() -> int: - repository = os.environ.get("INPUT_REPOSITORY") or os.environ.get("GITHUB_REPOSITORY", "") - branch = os.environ.get("INPUT_BRANCH") or os.environ.get("GITHUB_REF_NAME", "") - - refs = parse_stable_refs(list_remote_stable_refs(repository)) - state = resolve_state(branch, refs) - - print(f"Current branch: {state.current_branch}") - print(f"Current release line: {state.current_major if state.current_major is not None else 'n/a'}") - print(f"Latest stable branch: {state.latest_branch or 'none'}") - print(f"Latest release line: {state.latest_major if state.latest_major is not None else 'n/a'}") - print(f"Publish nightly: {str(state.is_latest).lower()}") - - write_output("current_branch", state.current_branch) - write_output("current_major", "" if state.current_major is None else str(state.current_major)) - write_output("latest_branch", state.latest_branch or "") - write_output("latest_major", "" if state.latest_major is None else str(state.latest_major)) - write_output("is_latest", str(state.is_latest).lower()) - write_summary(state) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From 8adab8dbd9d71ccc6385d0ce0dd3761d71eb7f1a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Thu, 24 Sep 2026 02:57:37 -0300 Subject: [PATCH 8/9] test: retire Python release reference --- .../restore_release_artifact.py | 158 ------------------ 1 file changed, 158 deletions(-) delete mode 100644 tests/Fixtures/PythonReference/restore_release_artifact.py diff --git a/tests/Fixtures/PythonReference/restore_release_artifact.py b/tests/Fixtures/PythonReference/restore_release_artifact.py deleted file mode 100644 index e984341..0000000 --- a/tests/Fixtures/PythonReference/restore_release_artifact.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -from __future__ import annotations - -import argparse -import io -import json -import os -from pathlib import Path -from urllib.parse import quote, urlparse -from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen -from zipfile import ZipFile - - - -class CrossHostAuthStrippingRedirectHandler(HTTPRedirectHandler): - def redirect_request(self, req, fp, code, msg, headers, newurl): - redirected = super().redirect_request(req, fp, code, msg, headers, newurl) - if redirected is None: - return None - if urlparse(req.full_url).netloc != urlparse(newurl).netloc: - redirected.remove_header("Authorization") - redirected.remove_header("X-GitHub-Api-Version") - redirected.remove_header("Accept") - return redirected - -def select_artifact(payload: object, name: str, expected_head_sha: str | None) -> dict[str, object]: - if not isinstance(payload, dict) or not isinstance(payload.get("artifacts"), list): - raise RuntimeError("GitHub returned an invalid artifact listing") - - candidates: list[dict[str, object]] = [] - for item in payload["artifacts"]: - if not isinstance(item, dict): - continue - if item.get("name") != name or item.get("expired") is True: - continue - workflow_run = item.get("workflow_run") - if expected_head_sha: - if not isinstance(workflow_run, dict) or workflow_run.get("head_sha") != expected_head_sha: - continue - candidates.append(item) - - if not candidates: - suffix = f" for head {expected_head_sha}" if expected_head_sha else "" - raise RuntimeError(f"Actions artifact {name!r}{suffix} was not found") - - candidates.sort( - key=lambda item: (str(item.get("created_at", "")), int(item.get("id", 0))), - reverse=True, - ) - return candidates[0] - - -def safe_extract_zip(data: bytes, destination: Path) -> None: - destination.mkdir(parents=True, exist_ok=True) - root = destination.resolve() - - with ZipFile(io.BytesIO(data)) as archive: - for entry in archive.infolist(): - relative = Path(entry.filename) - if relative.is_absolute() or ".." in relative.parts: - raise RuntimeError(f"unsafe artifact path: {entry.filename}") - target = (destination / relative).resolve() - try: - target.relative_to(root) - except ValueError as error: - raise RuntimeError(f"unsafe artifact path: {entry.filename}") from error - - archive.extractall(destination) - - -def request_json(url: str, token: str) -> object: - request = Request(url, headers={ - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "LibreCodeCoop/github-workflows", - }) - with urlopen(request, timeout=30) as response: - return json.load(response) - - -def request_bytes(url: str, token: str) -> bytes: - request = Request(url, headers={ - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "LibreCodeCoop/github-workflows", - }) - opener = build_opener(CrossHostAuthStrippingRedirectHandler()) - with opener.open(request, timeout=60) as response: - return response.read() - - -def validate_workflow_run(payload: object, expected_event: str | None, expected_workflow_path: str | None) -> None: - if not isinstance(payload, dict): - raise RuntimeError("GitHub returned an invalid workflow run") - if expected_event and payload.get("event") != expected_event: - raise RuntimeError(f"artifact workflow event {payload.get('event')!r} does not match {expected_event!r}") - if expected_workflow_path and payload.get("path") != expected_workflow_path: - raise RuntimeError(f"artifact workflow path {payload.get('path')!r} does not match {expected_workflow_path!r}") - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--repository", required=True) - parser.add_argument("--name", required=True) - parser.add_argument("--expected-head-sha", default="") - parser.add_argument("--destination", required=True, type=Path) - parser.add_argument("--expected-event", default="") - parser.add_argument("--expected-workflow-path", default="") - parser.add_argument("--api-url", default=os.environ.get("GITHUB_API_URL", "https://api.github.com")) - args = parser.parse_args() - - token = os.environ.get("GITHUB_TOKEN", "") - if token.strip() == "": - parser.error("GITHUB_TOKEN must not be empty") - - listing_url = ( - f"{args.api_url.rstrip('/')}/repos/{args.repository}/actions/artifacts" - f"?name={quote(args.name, safe='')}&per_page=100" - ) - artifact = select_artifact( - request_json(listing_url, token), - args.name, - args.expected_head_sha or None, - ) - workflow_run = artifact.get("workflow_run") - run_id = workflow_run.get("id") if isinstance(workflow_run, dict) else None - if args.expected_event or args.expected_workflow_path: - if not isinstance(run_id, int): - parser.error("GitHub returned an artifact without workflow run identity") - run_url = f"{args.api_url.rstrip('/')}/repos/{args.repository}/actions/runs/{run_id}" - validate_workflow_run( - request_json(run_url, token), - args.expected_event or None, - args.expected_workflow_path or None, - ) - - archive_url = artifact.get("archive_download_url") - if not isinstance(archive_url, str) or archive_url == "": - parser.error("GitHub returned an artifact without archive_download_url") - - safe_extract_zip(request_bytes(archive_url, token), args.destination) - - print(json.dumps({ - "artifact_id": artifact.get("id"), - "workflow_run_id": run_id, - "name": args.name, - "created_at": artifact.get("created_at"), - }, separators=(",", ":"))) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From 80e1a296cfdf93f86fa0b767fd4e19a85508b06a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Thu, 24 Sep 2026 02:57:39 -0300 Subject: [PATCH 9/9] test: retire Python release reference --- .../validate_release_artifact.py | 75 ------------------- 1 file changed, 75 deletions(-) delete mode 100644 tests/Fixtures/PythonReference/validate_release_artifact.py diff --git a/tests/Fixtures/PythonReference/validate_release_artifact.py b/tests/Fixtures/PythonReference/validate_release_artifact.py deleted file mode 100644 index a54566f..0000000 --- a/tests/Fixtures/PythonReference/validate_release_artifact.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -from __future__ import annotations - -import argparse -import tarfile -from pathlib import Path, PurePosixPath -from xml.etree import ElementTree - - -def validate_artifact(artifact: Path, app_name: str, version: str) -> None: - if not artifact.is_file(): - raise ValueError(f"artifact does not exist: {artifact}") - - with tarfile.open(artifact, "r:gz") as archive: - members = archive.getmembers() - if not members: - raise ValueError("artifact is empty") - - top_levels: set[str] = set() - for member in members: - path = PurePosixPath(member.name) - if path.is_absolute() or ".." in path.parts: - raise ValueError(f"unsafe archive path: {member.name}") - if path.parts: - top_levels.add(path.parts[0]) - - if top_levels != {app_name}: - raise ValueError( - f"artifact must contain only the top-level directory {app_name!r}; " - f"found {sorted(top_levels)!r}" - ) - - info_path = f"{app_name}/appinfo/info.xml" - try: - info_member = archive.getmember(info_path) - except KeyError as error: - raise ValueError(f"artifact is missing {info_path}") from error - - stream = archive.extractfile(info_member) - if stream is None: - raise ValueError(f"artifact entry is not a file: {info_path}") - - try: - root = ElementTree.parse(stream).getroot() - except ElementTree.ParseError as error: - raise ValueError(f"cannot parse {info_path}: {error}") from error - - declared = root.findtext("version") - if declared != version: - raise ValueError( - f"{info_path} declares version {declared!r}, expected {version!r}" - ) - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--artifact", required=True, type=Path) - parser.add_argument("--app-name", required=True) - parser.add_argument("--version", required=True) - args = parser.parse_args() - - try: - validate_artifact(args.artifact, args.app_name, args.version) - except (OSError, ValueError, tarfile.TarError) as error: - parser.error(str(error)) - - print(f"Validated release artifact: {args.artifact}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main())