diff --git a/VERSION b/VERSION index bc859cbd..1a96df19 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.11.2 +0.11.3 diff --git a/actions/artifact-validate/action.yml b/actions/artifact-validate/action.yml new file mode 100644 index 00000000..a31b9fea --- /dev/null +++ b/actions/artifact-validate/action.yml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Validate release artifact +description: Validate a packaged Nextcloud app artifact using release-tool. + +inputs: + artifact: + description: Path to the packaged tar.gz artifact. + required: true + app-name: + description: Expected Nextcloud app id. + required: true + version: + description: Expected app version. + required: true + +runs: + using: composite + steps: + - id: setup + name: Setup release-tool + shell: bash + run: bash "${GITHUB_ACTION_PATH}/../_internal/setup.sh" + + - name: Validate packaged app + shell: bash + env: + RELEASE_TOOL_PATH: ${{ steps.setup.outputs.path }} + RELEASE_ARTIFACT: ${{ inputs.artifact }} + RELEASE_APP_NAME: ${{ inputs.app-name }} + RELEASE_VERSION: ${{ inputs.version }} + run: >- + php "${RELEASE_TOOL_PATH}" artifact:validate-package + --artifact "${RELEASE_ARTIFACT}" + --app-name "${RELEASE_APP_NAME}" + --expected-version "${RELEASE_VERSION}" diff --git a/actions/metadata-inspect/action.yml b/actions/metadata-inspect/action.yml new file mode 100644 index 00000000..293c2b29 --- /dev/null +++ b/actions/metadata-inspect/action.yml @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Inspect release metadata +description: Validate and expose release metadata for a consumer repository. + +inputs: + config-path: + description: Consumer release configuration path. + required: false + default: .nextcloud-release.yml + root: + description: Consumer repository root. + required: false + default: . + ref: + description: Git ref to inspect. + required: false + default: HEAD + +outputs: + changelog-path: + description: Resolved changelog path. + value: ${{ steps.inspect.outputs.changelog-path }} + version: + description: Resolved application version. + value: ${{ steps.inspect.outputs.version }} + major: + description: Resolved application major version. + value: ${{ steps.inspect.outputs.major }} + development: + description: Whether the inspected version is a development version. + value: ${{ steps.inspect.outputs.development }} + +runs: + using: composite + steps: + - id: setup + name: Setup release-tool + shell: bash + run: bash "${GITHUB_ACTION_PATH}/../_internal/setup.sh" + + - id: inspect + name: Inspect release metadata + shell: bash + env: + RELEASE_TOOL_PATH: ${{ steps.setup.outputs.path }} + RELEASE_CONFIG_PATH: ${{ inputs.config-path }} + RELEASE_ROOT: ${{ inputs.root }} + RELEASE_REF: ${{ inputs.ref }} + run: | + set -euo pipefail + metadata="${RUNNER_TEMP}/release-metadata.json" + php "${RELEASE_TOOL_PATH}" metadata:inspect --config "${RELEASE_CONFIG_PATH}" --root "${RELEASE_ROOT}" --ref "${RELEASE_REF}" --json > "${metadata}" + php -r ' + $m=json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); + $out=getenv("GITHUB_OUTPUT"); + $values=[ + "changelog-path" => $m["changelog_path"] ?? "", + "version" => $m["version"] ?? "", + "major" => $m["major"] ?? "", + "development" => !empty($m["development"]) ? "true" : "false", + ]; + $fh=fopen($out, "ab"); + foreach ($values as $key => $value) { + fwrite($fh, $key . "=" . $value . PHP_EOL); + } + fclose($fh); + ' "${metadata}" diff --git a/actions/release-notes/action.yml b/actions/release-notes/action.yml new file mode 100644 index 00000000..f6876b86 --- /dev/null +++ b/actions/release-notes/action.yml @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Build release note changes +description: Build release-note entries from commits and associated pull requests. + +inputs: + repository: + description: Repository in owner/name form. + required: true + branch: + description: Release branch. + required: true + working-directory: + description: Git working directory used to resolve commits and tags. + required: false + default: . + from-ref: + description: Optional starting ref. When omitted, the latest non-nightly tag is used. + required: false + default: '' + to-ref: + description: Ending ref. + required: false + default: HEAD + fallback-limit: + description: Number of direct commits to include when no starting tag exists. + required: false + default: '10' + github-token: + description: GitHub token used to resolve pull requests. + required: true + +outputs: + changes-file: + description: Markdown file containing generated change entries. + value: ${{ steps.notes.outputs.changes-file }} + change-count: + description: Total number of change entries. + value: ${{ steps.notes.outputs.change-count }} + pull-request-count: + description: Number of pull-request-backed entries. + value: ${{ steps.notes.outputs.pull-request-count }} + commit-fallback-count: + description: Number of direct-commit fallback entries. + value: ${{ steps.notes.outputs.commit-fallback-count }} + +runs: + using: composite + steps: + - id: setup + name: Setup release-tool + shell: bash + run: bash "${GITHUB_ACTION_PATH}/../_internal/setup.sh" + + - id: notes + name: Build release note changes + shell: bash + env: + RELEASE_TOOL_PATH: ${{ steps.setup.outputs.path }} + RELEASE_NOTES_GITHUB_TOKEN: ${{ inputs.github-token }} + RELEASE_REPOSITORY: ${{ inputs.repository }} + RELEASE_BRANCH: ${{ inputs.branch }} + RELEASE_WORKING_DIRECTORY: ${{ inputs.working-directory }} + RELEASE_FROM_REF: ${{ inputs.from-ref }} + RELEASE_TO_REF: ${{ inputs.to-ref }} + RELEASE_FALLBACK_LIMIT: ${{ inputs.fallback-limit }} + run: | + set -euo pipefail + from_ref="${RELEASE_FROM_REF}" + if [[ -z "${from_ref}" ]]; then + from_ref="$(git -C "${RELEASE_WORKING_DIRECTORY}" tag --list --sort=-version:refname | grep -v '^nightly$' | head -1 || true)" + fi + php "${RELEASE_TOOL_PATH}" release:notes --repository "${RELEASE_REPOSITORY}" --branch "${RELEASE_BRANCH}" --working-directory "${RELEASE_WORKING_DIRECTORY}" --from-ref "${from_ref}" --to-ref "${RELEASE_TO_REF}" --fallback-limit "${RELEASE_FALLBACK_LIMIT}" diff --git a/actions/stable-select/action.yml b/actions/stable-select/action.yml new file mode 100644 index 00000000..f98f80a5 --- /dev/null +++ b/actions/stable-select/action.yml @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Resolve stable release line +description: Resolve whether the current stable branch is the latest supported stable release line. + +inputs: + repository: + description: Repository in owner/name form. Defaults to the current repository. + required: false + default: '' + branch: + description: Stable branch to compare. Defaults to the current ref name. + required: false + default: '' + github-token: + description: GitHub token used to inspect repository branches. + required: true + +outputs: + is-latest: + description: Whether the selected branch is the latest stable release line. + value: ${{ steps.select.outputs.is_latest }} + current-branch: + description: Current branch. + value: ${{ steps.select.outputs.current_branch }} + current-major: + description: Current stable major. + value: ${{ steps.select.outputs.current_major }} + latest-branch: + description: Latest stable branch. + value: ${{ steps.select.outputs.latest_branch }} + latest-major: + description: Latest stable major. + value: ${{ steps.select.outputs.latest_major }} + +runs: + using: composite + steps: + - id: setup + name: Setup release-tool + shell: bash + run: bash "${GITHUB_ACTION_PATH}/../_internal/setup.sh" + + - id: select + name: Resolve stable release line + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + RELEASE_TOOL_PATH: ${{ steps.setup.outputs.path }} + RELEASE_REPOSITORY: ${{ inputs.repository }} + RELEASE_BRANCH: ${{ inputs.branch }} + run: | + set -euo pipefail + repository="${RELEASE_REPOSITORY:-${GITHUB_REPOSITORY}}" + branch="${RELEASE_BRANCH:-${GITHUB_REF_NAME}}" + php "${RELEASE_TOOL_PATH}" release:stable-select --repository "${repository}" --branch "${branch}" diff --git a/docs/github-actions.md b/docs/github-actions.md index 79b37030..32de1e20 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -45,15 +45,14 @@ Do not run mutation logic for arbitrary pull requests. ## Actions -The main building blocks are: +The public building blocks are: -- `actions/prepare`; -- `actions/post-merge`; -- `actions/publication`. +- lifecycle: `actions/prepare`, `actions/post-merge`, `actions/publication`; +- supporting release operations: `actions/stable-select`, `actions/artifact-validate`, `actions/metadata-inspect`, `actions/release-notes`. -Pin them to an immutable commit SHA and keep a comment with the corresponding release version. +Consumers should call these Actions instead of downloading the PHAR or duplicating release-tool command bootstrap in workflow YAML. The Actions resolve the matching product version from this repository's `VERSION` file and verify the published PHAR checksum internally. -Do not reference `main` or a floating tag in production release automation. +Pin Actions to an immutable commit SHA. Do not duplicate the release-tool semantic version in consumer shell, and do not reference `main` or a floating tag in production release automation. ## Consumer responsibilities @@ -93,4 +92,4 @@ 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 the three public lifecycle 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. +`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. diff --git a/tests/Acceptance/ReleaseActionsTest.php b/tests/Acceptance/ReleaseActionsTest.php index 69c90629..110d477e 100644 --- a/tests/Acceptance/ReleaseActionsTest.php +++ b/tests/Acceptance/ReleaseActionsTest.php @@ -10,9 +10,9 @@ final class ReleaseActionsTest extends TestCase { - private const array PUBLIC_ACTIONS = ['post-merge', 'prepare', 'publication']; + private const array PUBLIC_ACTIONS = ['artifact-validate', 'metadata-inspect', 'post-merge', 'prepare', 'publication', 'release-notes', 'stable-select']; - public function testPublicActionSurfaceContainsOnlyLifecycleStages(): void + public function testPublicActionSurfaceIsExplicit(): void { $paths = glob($this->root() . '/actions/*/action.yml'); self::assertIsArray($paths); @@ -27,7 +27,7 @@ public function testPublicActionSurfaceContainsOnlyLifecycleStages(): void } #[DataProvider('publicActions')] - public function testPublicActionsArePhpOrchestrationWithoutLegacyHelpers(string $name): void + public function testPublicActionsAreReleaseToolOrchestrationWithoutLegacyHelpers(string $name): void { $path = $this->root() . '/actions/' . $name . '/action.yml'; $content = (string) file_get_contents($path); @@ -104,6 +104,16 @@ public function testInternalBootstrapUsesExactVersionAndVerifiedChecksum(): void /** @return iterable, list}> */ public static function publicActionContracts(): iterable { + yield 'artifact-validate' => [ + 'artifact-validate', + ['artifact', 'app-name', 'version'], + [], + ]; + yield 'metadata-inspect' => [ + 'metadata-inspect', + ['config-path', 'root', 'ref'], + ['changelog-path', 'version', 'major', 'development'], + ]; yield 'prepare' => [ 'prepare', [ @@ -162,6 +172,16 @@ public static function publicActionContracts(): iterable 'verification-artifact-name', ], ]; + yield 'release-notes' => [ + 'release-notes', + ['repository', 'branch', 'working-directory', 'from-ref', 'to-ref', 'fallback-limit', 'github-token'], + ['changes-file', 'change-count', 'pull-request-count', 'commit-fallback-count'], + ]; + yield 'stable-select' => [ + 'stable-select', + ['repository', 'branch', 'github-token'], + ['is-latest', 'current-branch', 'current-major', 'latest-branch', 'latest-major'], + ]; } /** @return iterable */