From 062aa4800fa6ae14a829287f69c11e0173aca366 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:51:06 +0000 Subject: [PATCH 1/3] feat(build): inspect app metadata and package engines in tested PHP service Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Application/Build/BuildMetadata.php | 30 +++++ .../Build/BuildMetadataInspector.php | 81 ++++++++++++ .../Build/Port/BuildFileReader.php | 10 ++ .../Build/LocalBuildFileReader.php | 26 ++++ .../Build/BuildMetadataInspectorTest.php | 122 ++++++++++++++++++ 5 files changed, 269 insertions(+) create mode 100644 src/Application/Build/BuildMetadata.php create mode 100644 src/Application/Build/BuildMetadataInspector.php create mode 100644 src/Application/Build/Port/BuildFileReader.php create mode 100644 src/Infrastructure/Build/LocalBuildFileReader.php create mode 100644 tests/Unit/Application/Build/BuildMetadataInspectorTest.php diff --git a/src/Application/Build/BuildMetadata.php b/src/Application/Build/BuildMetadata.php new file mode 100644 index 0000000..a7ea6cd --- /dev/null +++ b/src/Application/Build/BuildMetadata.php @@ -0,0 +1,30 @@ + 1, + 'version' => $this->version, + 'nextcloud_min_version' => $this->nextcloudMinVersion, + 'node_version' => $this->nodeVersion, + 'npm_version' => $this->npmVersion, + 'has_package_json' => $this->hasPackageJson, + ]; + } +} diff --git a/src/Application/Build/BuildMetadataInspector.php b/src/Application/Build/BuildMetadataInspector.php new file mode 100644 index 0000000..49c095b --- /dev/null +++ b/src/Application/Build/BuildMetadataInspector.php @@ -0,0 +1,81 @@ +reader->read($root, 'appinfo/info.xml'); + if ($xmlContent === null) { + throw new DomainException('Missing appinfo/info.xml in application checkout.'); + } + + $xml = @simplexml_load_string($xmlContent, SimpleXMLElement::class, LIBXML_NONET); + if ($xml === false) { + throw new DomainException('Invalid appinfo/info.xml XML.'); + } + + $version = trim((string) ($xml->version ?? '')); + $nextcloud = $xml->dependencies->nextcloud ?? null; + $minimum = $nextcloud === null ? '' : trim((string) ($nextcloud['min-version'] ?? '')); + if ($version === '' || preg_match('/[\x00-\x1f\x7f]/', $version) === 1) { + throw new DomainException('appinfo/info.xml is missing a safe version.'); + } + if (preg_match('/^[1-9][0-9]*$/', $minimum) !== 1) { + throw new DomainException('appinfo/info.xml is missing a numeric Nextcloud min-version.'); + } + + $json = $this->reader->read($root, 'package.json'); + if ($json === null) { + return new BuildMetadata($version, $minimum, '', '', false); + } + + try { + $package = json_decode($json, false, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new DomainException('Invalid package.json JSON.', 0, $exception); + } + if (!$package instanceof \stdClass) { + throw new DomainException('package.json must be an object.'); + } + $engines = $package->engines ?? new \stdClass(); + if (!$engines instanceof \stdClass) { + throw new DomainException('package.json engines must be an object.'); + } + + return new BuildMetadata( + $version, + $minimum, + $this->engine($engines, 'node', '^24'), + $this->engine($engines, 'npm', '^11.3'), + true, + ); + } + + /** @param \stdClass $engines */ + private function engine(\stdClass $engines, string $name, string $fallback): string + { + $value = $engines->{$name} ?? ''; + if (!is_string($value)) { + throw new DomainException(sprintf('package.json engines.%s must be a string.', $name)); + } + $value = trim($value); + if (strlen($value) > 128 || preg_match('/[\x00-\x1f\x7f]/', $value) === 1) { + throw new DomainException(sprintf('package.json engines.%s contains invalid control characters.', $name)); + } + + return $value !== '' ? $value : $fallback; + } +} diff --git a/src/Application/Build/Port/BuildFileReader.php b/src/Application/Build/Port/BuildFileReader.php new file mode 100644 index 0000000..58778d7 --- /dev/null +++ b/src/Application/Build/Port/BuildFileReader.php @@ -0,0 +1,10 @@ +15.1.2'; + + public function testReadsVersionCompatibilityAndDeclaredEngines(): void + { + $result = $this->inspect([ + 'appinfo/info.xml' => self::XML, + 'package.json' => '{"engines":{"node":">=22 <25","npm":"^11.4"}}', + ]); + + self::assertSame('15.1.2', $result->version); + self::assertSame('35', $result->nextcloudMinVersion); + self::assertSame('>=22 <25', $result->nodeVersion); + self::assertSame('^11.4', $result->npmVersion); + self::assertTrue($result->hasPackageJson); + } + + public function testMissingPackageJsonSkipsNodeBuild(): void + { + $result = $this->inspect(['appinfo/info.xml' => self::XML]); + self::assertFalse($result->hasPackageJson); + self::assertSame('', $result->nodeVersion); + self::assertSame('', $result->npmVersion); + } + + public function testPresentPackageWithoutEnginesUsesFallbacks(): void + { + foreach (['{}', '{"engines":{}}', '{"engines":{"node":"","npm":""}}'] as $json) { + $result = $this->inspect(['appinfo/info.xml' => self::XML, 'package.json' => $json]); + self::assertTrue($result->hasPackageJson); + self::assertSame('^24', $result->nodeVersion); + self::assertSame('^11.3', $result->npmVersion); + } + } + + public function testMissingXmlFails(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Missing appinfo/info.xml'); + $this->inspect([]); + } + + public function testMalformedXmlFails(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Invalid appinfo/info.xml'); + $this->inspect(['appinfo/info.xml' => '1']); + } + + public function testMissingCompatibilityFails(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('numeric Nextcloud min-version'); + $this->inspect(['appinfo/info.xml' => '15.1.2']); + } + + public function testMalformedPackageJsonFails(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Invalid package.json'); + $this->inspect(['appinfo/info.xml' => self::XML, 'package.json' => '{']); + } + + public function testPackageJsonMustBeObject(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('package.json must be an object'); + $this->inspect(['appinfo/info.xml' => self::XML, 'package.json' => '[]']); + } + + public function testEnginesMustBeObject(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('engines must be an object'); + $this->inspect(['appinfo/info.xml' => self::XML, 'package.json' => '{"engines":[]}']); + } + + public function testEngineVersionMustBeString(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('engines.node must be a string'); + $this->inspect(['appinfo/info.xml' => self::XML, 'package.json' => '{"engines":{"node":24}}']); + } + + public function testNewlineInEngineIsRejectedForGithubOutputs(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('control characters'); + $this->inspect(['appinfo/info.xml' => self::XML, 'package.json' => '{"engines":{"npm":"11.0\nmalicious=yes"}}']); + } + + /** @param array $files */ + private function inspect(array $files): BuildMetadata + { + $reader = new class($files) implements BuildFileReader { + /** @param array $files */ + public function __construct(private array $files) + { + } + + public function read(string $root, string $path): ?string + { + return $this->files[$path] ?? null; + } + }; + + return (new BuildMetadataInspector($reader))->inspect('.'); + } +} From 7a370bdc8022b77d4ddf0178c30f8bc6c37f7387 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:51:17 +0000 Subject: [PATCH 2/3] feat(actions): expose build inspection and permission adapters Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- actions/authorization-check/action.yml | 43 ++++++++++++ actions/build-inspect/action.yml | 49 +++++++++++++ bin/release-tool | 3 + docs/github-actions.md | 7 ++ .../Console/ApplicationFactory.php | 7 ++ .../Console/Command/BuildInspectCommand.php | 68 +++++++++++++++++++ tests/Acceptance/ReleaseActionsTest.php | 12 +++- .../Build/BuildInspectCommandTest.php | 67 ++++++++++++++++++ 8 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 actions/authorization-check/action.yml create mode 100644 actions/build-inspect/action.yml create mode 100644 src/Application/Console/Command/BuildInspectCommand.php create mode 100644 tests/Unit/Application/Build/BuildInspectCommandTest.php diff --git a/actions/authorization-check/action.yml b/actions/authorization-check/action.yml new file mode 100644 index 0000000..de395ca --- /dev/null +++ b/actions/authorization-check/action.yml @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Check repository actor authorization +description: Validate repository permission using the release-tool CLI. + +inputs: + repository: + description: Repository in owner/name form. + required: true + actor: + description: GitHub actor to authorize. + required: true + minimum: + description: Minimum repository permission. + required: false + default: write + github-token: + description: Token for GitHub permission lookup. + required: true + +runs: + using: composite + steps: + - id: setup + name: Set up release-tool + shell: bash + run: bash "${GITHUB_ACTION_PATH}/../_internal/setup.sh" + + - name: Check authorization + shell: bash + env: + RELEASE_TOOL_PATH: ${{ steps.setup.outputs.path }} + GITHUB_TOKEN: ${{ inputs.github-token }} + RELEASE_REPOSITORY: ${{ inputs.repository }} + RELEASE_ACTOR: ${{ inputs.actor }} + RELEASE_MINIMUM: ${{ inputs.minimum }} + run: | + set -euo pipefail + php "${RELEASE_TOOL_PATH}" release:authorization \ + --repository "${RELEASE_REPOSITORY}" \ + --actor "${RELEASE_ACTOR}" \ + --minimum "${RELEASE_MINIMUM}" diff --git a/actions/build-inspect/action.yml b/actions/build-inspect/action.yml new file mode 100644 index 0000000..728f08d --- /dev/null +++ b/actions/build-inspect/action.yml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Inspect application build metadata +description: Read Nextcloud compatibility and optional Node/npm engines using release-tool. + +inputs: + root: + description: Checked-out application directory. + required: false + default: . + +outputs: + version: + description: Version from appinfo/info.xml. + value: ${{ steps.inspect.outputs.version }} + nextcloud-min-version: + description: Minimum supported Nextcloud major. + value: ${{ steps.inspect.outputs.nextcloud-min-version }} + node-version: + description: Node engine, or ^24 when package.json exists without a Node engine. + value: ${{ steps.inspect.outputs.node-version }} + npm-version: + description: npm engine, or ^11.3 when package.json exists without an npm engine. + value: ${{ steps.inspect.outputs.npm-version }} + has-package-json: + description: Whether the consumer has package.json. + value: ${{ steps.inspect.outputs.has-package-json }} + +runs: + using: composite + steps: + - id: setup + name: Set up release-tool + shell: bash + run: bash "${GITHUB_ACTION_PATH}/../_internal/setup.sh" + + - id: inspect + name: Inspect application build metadata + shell: bash + env: + RELEASE_TOOL_PATH: ${{ steps.setup.outputs.path }} + APPLICATION_ROOT: ${{ inputs.root }} + run: | + set -euo pipefail + php "${RELEASE_TOOL_PATH}" build:inspect \ + --root "${APPLICATION_ROOT}" \ + --github-output "${GITHUB_OUTPUT}" \ + --json diff --git a/bin/release-tool b/bin/release-tool index e4ffe8b..8a18df1 100755 --- a/bin/release-tool +++ b/bin/release-tool @@ -6,6 +6,7 @@ declare(strict_types=1); use LibreCode\ReleaseTool\Application\Artifact\ArtifactRestorer; use LibreCode\ReleaseTool\Application\Artifact\ArtifactValidator; use LibreCode\ReleaseTool\Application\Artifact\NextcloudPackageValidator; +use LibreCode\ReleaseTool\Application\Build\BuildMetadataInspector; use LibreCode\ReleaseTool\Application\Console\ApplicationFactory; use LibreCode\ReleaseTool\Application\Publication\AppStorePublicationWaiter; use LibreCode\ReleaseTool\Application\Publication\PublicationVerifier; @@ -22,6 +23,7 @@ use LibreCode\ReleaseTool\Application\Release\StableBranchSelector; use LibreCode\ReleaseTool\Application\ReleaseNotes\ReleaseNotesGenerator; use LibreCode\ReleaseTool\Application\Security\RepositoryAuthorizationChecker; use LibreCode\ReleaseTool\Infrastructure\AppStore\NextcloudAppStoreRepository; +use LibreCode\ReleaseTool\Infrastructure\Build\LocalBuildFileReader; use LibreCode\ReleaseTool\Infrastructure\Archive\PharArchiveReaderFactory; use LibreCode\ReleaseTool\Infrastructure\Archive\SafeZipExtractor; use LibreCode\ReleaseTool\Infrastructure\Configuration\LocalConsumerConfigContextValidator; @@ -90,4 +92,5 @@ exit(ApplicationFactory::create( new ReleasePreflight(GitHubReleasePreflightRepository::fromEnvironment()), new ReleaseIdentityValidator($git, new GitReleaseMetadataReader($git)), new ReleaseResumeContextResolver($finalizationRepository), + new BuildMetadataInspector(new LocalBuildFileReader()), )->run()); diff --git a/docs/github-actions.md b/docs/github-actions.md index 3fba553..75a76df 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -93,3 +93,10 @@ These properties are part of the reference integration and should not be weakene ## Reference `LibreCodeCoop/release-tool` is the source of truth for release behavior and its public release Actions. `LibreCodeCoop/.github` owns LibreCode's managed workflow templates that orchestrate those Actions. LibreSign's `.github/workflows/prepare-release.yml` is the reference consumer and should be materialized from that catalog rather than becoming a second implementation. + +Build consumers can use the `actions/build-inspect` adapter for validated appinfo +version, Nextcloud minimum major, and optional Node/npm engines. When a +package.json is absent, the Action reports `has-package-json=false` and empty +engine values; when present without engine declarations, it reports defaults +Node `^24` and npm `^11.3`. The `actions/authorization-check` adapter +reuses the tested PHP permission policy instead of separate GitHub API calls. diff --git a/src/Application/Console/ApplicationFactory.php b/src/Application/Console/ApplicationFactory.php index 0f4bc44..240a8d0 100644 --- a/src/Application/Console/ApplicationFactory.php +++ b/src/Application/Console/ApplicationFactory.php @@ -7,6 +7,7 @@ use LibreCode\ReleaseTool\Application\Artifact\ArtifactRestorer; use LibreCode\ReleaseTool\Application\Artifact\ArtifactValidator; use LibreCode\ReleaseTool\Application\Artifact\NextcloudPackageValidator; +use LibreCode\ReleaseTool\Application\Build\BuildMetadataInspector; use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigContextValidator; use LibreCode\ReleaseTool\Application\Configuration\NoopConsumerConfigContextValidator; use LibreCode\ReleaseTool\Application\Console\Command\AppStorePublicationWaitCommand; @@ -14,6 +15,7 @@ use LibreCode\ReleaseTool\Application\Console\Command\ArtifactValidateCommand; use LibreCode\ReleaseTool\Application\Console\Command\ArtifactValidatePackageCommand; use LibreCode\ReleaseTool\Application\Console\Command\AuthorizationCheckCommand; +use LibreCode\ReleaseTool\Application\Console\Command\BuildInspectCommand; use LibreCode\ReleaseTool\Application\Console\Command\ConfigValidateCommand; use LibreCode\ReleaseTool\Application\Console\Command\MetadataInspectCommand; use LibreCode\ReleaseTool\Application\Console\Command\MilestoneTransitionCommand; @@ -74,6 +76,7 @@ public static function create( ?ReleasePreflight $releasePreflight = null, ?ReleaseIdentityValidator $releaseIdentityValidator = null, ?ReleaseResumeContextResolver $releaseResumeContextResolver = null, + ?BuildMetadataInspector $buildMetadataInspector = null, ): Application { $application = new Application('release-tool', self::version()); @@ -82,6 +85,10 @@ public static function create( contextValidator: $configValidator ?? new NoopConsumerConfigContextValidator(), )); + if ($buildMetadataInspector !== null) { + $application->add(new BuildInspectCommand($buildMetadataInspector)); + } + if ($authorizationChecker !== null) { $application->add(new AuthorizationCheckCommand($authorizationChecker)); } diff --git a/src/Application/Console/Command/BuildInspectCommand.php b/src/Application/Console/Command/BuildInspectCommand.php new file mode 100644 index 0000000..f63b65e --- /dev/null +++ b/src/Application/Console/Command/BuildInspectCommand.php @@ -0,0 +1,68 @@ +addOption('root', null, InputOption::VALUE_REQUIRED, 'Application checkout path.', '.') + ->addOption('github-output', null, InputOption::VALUE_REQUIRED, 'Append validated values to a GitHub Actions output file.') + ->addOption('json', null, InputOption::VALUE_NONE, 'Print JSON metadata.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $metadata = $this->inspector->inspect((string) $input->getOption('root')); + $destination = $input->getOption('github-output'); + if (is_string($destination) && $destination !== '') { + $values = [ + 'version' => $metadata->version, + 'nextcloud-min-version' => $metadata->nextcloudMinVersion, + 'node-version' => $metadata->nodeVersion, + 'npm-version' => $metadata->npmVersion, + 'has-package-json' => $metadata->hasPackageJson ? 'true' : 'false', + ]; + $lines = ''; + foreach ($values as $name => $value) { + $lines .= $name.'='.$value."\n"; + } + if (@file_put_contents($destination, $lines, FILE_APPEND | LOCK_EX) === false) { + throw new \DomainException('Unable to write GitHub Actions output file.'); + } + } + } catch (\Throwable $exception) { + $output->writeln(''.$exception->getMessage().''); + return Command::FAILURE; + } + + if ((bool) $input->getOption('json')) { + $output->writeln((string) json_encode($metadata->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)); + } else { + $output->writeln(sprintf( + 'Build metadata: %s (Nextcloud >= %s)', + $metadata->version, + $metadata->nextcloudMinVersion, + )); + } + + return Command::SUCCESS; + } +} diff --git a/tests/Acceptance/ReleaseActionsTest.php b/tests/Acceptance/ReleaseActionsTest.php index d1be8aa..10b2edb 100644 --- a/tests/Acceptance/ReleaseActionsTest.php +++ b/tests/Acceptance/ReleaseActionsTest.php @@ -10,7 +10,7 @@ final class ReleaseActionsTest extends TestCase { - private const array PUBLIC_ACTIONS = ['appstore-publication-wait', 'artifact-validate', 'metadata-inspect', 'post-merge', 'prepare', 'publication', 'release-identity', 'release-notes', 'release-preflight', 'resume-context', 'stable-select']; + private const array PUBLIC_ACTIONS = ['appstore-publication-wait', 'artifact-validate', 'authorization-check', 'build-inspect', 'metadata-inspect', 'post-merge', 'prepare', 'publication', 'release-identity', 'release-notes', 'release-preflight', 'resume-context', 'stable-select']; public function testPublicActionSurfaceIsExplicit(): void { @@ -164,6 +164,16 @@ public static function publicActionContracts(): iterable ['artifact', 'app-name', 'version'], [], ]; + yield 'authorization-check' => [ + 'authorization-check', + ['repository', 'actor', 'minimum', 'github-token'], + [], + ]; + yield 'build-inspect' => [ + 'build-inspect', + ['root'], + ['version', 'nextcloud-min-version', 'node-version', 'npm-version', 'has-package-json'], + ]; yield 'metadata-inspect' => [ 'metadata-inspect', ['config-path', 'root', 'ref'], diff --git a/tests/Unit/Application/Build/BuildInspectCommandTest.php b/tests/Unit/Application/Build/BuildInspectCommandTest.php new file mode 100644 index 0000000..724493c --- /dev/null +++ b/tests/Unit/Application/Build/BuildInspectCommandTest.php @@ -0,0 +1,67 @@ +15.0.8'); + file_put_contents($root.'/package.json', '{}'); + $outputPath = $root.'/github-output'; + + try { + $application = ApplicationFactory::create( + buildMetadataInspector: new BuildMetadataInspector(new LocalBuildFileReader()), + ); + $command = $application->find('build:inspect'); + $tester = new CommandTester($command); + $exit = $tester->execute([ + '--root' => $root, + '--github-output' => $outputPath, + '--json' => true, + ]); + + self::assertSame(0, $exit, $tester->getDisplay()); + $data = json_decode(trim($tester->getDisplay()), true, 512, JSON_THROW_ON_ERROR); + self::assertSame('15.0.8', $data['version']); + self::assertSame('^24', $data['node_version']); + self::assertSame('35', $data['nextcloud_min_version']); + self::assertSame( + "version=15.0.8\nnextcloud-min-version=35\nnode-version=^24\nnpm-version=^11.3\nhas-package-json=true\n", + file_get_contents($outputPath), + ); + } finally { + @unlink($outputPath); + @unlink($root.'/package.json'); + @unlink($root.'/appinfo/info.xml'); + @rmdir($root.'/appinfo'); + @rmdir($root); + } + } + + public function testRejectsMissingCheckoutWithoutWritingOutputs(): void + { + $path = sys_get_temp_dir().'/release-missing-'.bin2hex(random_bytes(8)); + $application = ApplicationFactory::create( + buildMetadataInspector: new BuildMetadataInspector(new LocalBuildFileReader()), + ); + $tester = new CommandTester($application->find('build:inspect')); + + self::assertNotSame(0, $tester->execute([ + '--root' => $path, + '--github-output' => $path.'.output', + ])); + self::assertFileDoesNotExist($path.'.output'); + } +} From 195f7dd48affe3c9721a3f520ef3230bbfe2d7f5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:51:27 +0000 Subject: [PATCH 3/3] chore(release): prepare release-tool 0.11.21 Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 7809b80..a9546b6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.11.20 +0.11.21