Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.11.20
0.11.21
43 changes: 43 additions & 0 deletions actions/authorization-check/action.yml
Original file line number Diff line number Diff line change
@@ -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}"
49 changes: 49 additions & 0 deletions actions/build-inspect/action.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions bin/release-tool
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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());
7 changes: 7 additions & 0 deletions docs/github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
30 changes: 30 additions & 0 deletions src/Application/Build/BuildMetadata.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace LibreCode\ReleaseTool\Application\Build;

final readonly class BuildMetadata
{
public function __construct(
public string $version,
public string $nextcloudMinVersion,
public string $nodeVersion,
public string $npmVersion,
public bool $hasPackageJson,
) {
}

/** @return array{schema:int,version:string,nextcloud_min_version:string,node_version:string,npm_version:string,has_package_json:bool} */
public function toArray(): array
{
return [
'schema' => 1,
'version' => $this->version,
'nextcloud_min_version' => $this->nextcloudMinVersion,
'node_version' => $this->nodeVersion,
'npm_version' => $this->npmVersion,
'has_package_json' => $this->hasPackageJson,
];
}
}
81 changes: 81 additions & 0 deletions src/Application/Build/BuildMetadataInspector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace LibreCode\ReleaseTool\Application\Build;

use DomainException;
use JsonException;
use LibreCode\ReleaseTool\Application\Build\Port\BuildFileReader;
use SimpleXMLElement;

final readonly class BuildMetadataInspector
{
public function __construct(private BuildFileReader $reader)
{
}

public function inspect(string $root): BuildMetadata
{
$xmlContent = $this->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;
}
}
10 changes: 10 additions & 0 deletions src/Application/Build/Port/BuildFileReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace LibreCode\ReleaseTool\Application\Build\Port;

interface BuildFileReader
{
public function read(string $root, string $path): ?string;
}
7 changes: 7 additions & 0 deletions src/Application/Console/ApplicationFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
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;
use LibreCode\ReleaseTool\Application\Console\Command\ArtifactRestoreCommand;
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;
Expand Down Expand Up @@ -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());
Expand All @@ -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));
}
Expand Down
68 changes: 68 additions & 0 deletions src/Application/Console/Command/BuildInspectCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace LibreCode\ReleaseTool\Application\Console\Command;

use LibreCode\ReleaseTool\Application\Build\BuildMetadataInspector;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'build:inspect', description: 'Read validated build metadata from a Nextcloud app checkout.')]
final class BuildInspectCommand extends Command
{
public function __construct(private readonly BuildMetadataInspector $inspector)
{
parent::__construct();
}

protected function configure(): void
{
$this
->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('<error>'.$exception->getMessage().'</error>');
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;
}
}
26 changes: 26 additions & 0 deletions src/Infrastructure/Build/LocalBuildFileReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace LibreCode\ReleaseTool\Infrastructure\Build;

use DomainException;
use LibreCode\ReleaseTool\Application\Build\Port\BuildFileReader;

final class LocalBuildFileReader implements BuildFileReader
{
public function read(string $root, string $path): ?string
{
$file = rtrim($root, '/').'/'.$path;
if (!is_file($file)) {
return null;
}

$content = @file_get_contents($file);
if ($content === false) {
throw new DomainException(sprintf('Unable to read build metadata: %s', $path));
}

return $content;
}
}
Loading
Loading