diff --git a/AGENTS.md b/AGENTS.md index 9434fc6..8d54f75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ This repository is the LibreCode Coop organization configuration repository. It - `governance.config.json` is the declarative source for LibreCodeCoop repository governance and supported GitHub metadata. - The governance engine itself lives in `LibreCodeCoop/github-governance`. - Organization workflow templates and helper Actions live in this repository. Product-specific release behavior belongs in its owning product repository, such as `LibreCodeCoop/release-tool`. +- Release workflows must consume testable, versioned release-tool Actions for authorization and build metadata; do not introduce duplicate inline Python or shell parsers in workflow YAML. - `workflow-templates/**` is the organization catalog consumed by repositories; do not add product-specific business logic here. - Repository-local licenses, README files and `AGENTS.md` stay in their own repositories. diff --git a/actions/nextcloud-appstore-publish/action.yml b/actions/nextcloud-appstore-publish/action.yml index 70c3add..04dd422 100644 --- a/actions/nextcloud-appstore-publish/action.yml +++ b/actions/nextcloud-appstore-publish/action.yml @@ -41,10 +41,12 @@ runs: using: composite steps: - name: Check actor permission - uses: skjnldsv/check-actor-permission@69e92a3c4711150929bca9fcf34448c5bf5526e7 # v3.0 + uses: LibreCodeCoop/release-tool/actions/authorization-check@30ba5d8a33089331d829f586546d34cd45a529d7 with: - require: write - token: ${{ inputs.github-token }} + github-token: ${{ inputs.github-token }} + repository: ${{ github.repository }} + actor: ${{ github.actor }} + minimum: write - name: Validate boolean inputs shell: bash @@ -77,32 +79,23 @@ runs: - name: Validate release identity id: release_identity - uses: LibreCodeCoop/release-tool/actions/release-identity@9b33a67a7b1e37a45b0a3c252370899effcdfdc2 + uses: LibreCodeCoop/release-tool/actions/release-identity@30ba5d8a33089331d829f586546d34cd45a529d7 with: tag: ${{ inputs.release-tag }} working-directory: ${{ inputs.app-name }} require-tag-exists: 'true' - - name: Get appinfo data - id: appinfo - uses: skjnldsv/xpath-action@f5b036e9d973f42c86324833fd00be90665fbf77 # v1.0.0 + - name: Inspect application build metadata + id: build_metadata + uses: LibreCodeCoop/release-tool/actions/build-inspect@30ba5d8a33089331d829f586546d34cd45a529d7 with: - filename: ${{ inputs.app-name }}/appinfo/info.xml - expression: "//info//dependencies//nextcloud/@min-version" - - - name: Read package engines - id: versions - continue-on-error: true - uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 - with: - path: ${{ inputs.app-name }} - fallbackNode: '^24' + root: ${{ inputs.app-name }} - name: Set up node - if: ${{ steps.versions.outputs.nodeVersion }} + if: steps.build_metadata.outputs.has-package-json == 'true' uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: ${{ steps.versions.outputs.nodeVersion }} + node-version: ${{ steps.build_metadata.outputs.node-version }} package-manager-cache: false - name: Resolve PHP version @@ -134,7 +127,7 @@ runs: ignore-cache: 'yes' - name: Build application - if: ${{ steps.versions.outputs.nodeVersion }} + if: steps.build_metadata.outputs.has-package-json == 'true' shell: bash env: APP_NAME: ${{ inputs.app-name }} @@ -174,7 +167,7 @@ runs: if: steps.krankerl.outputs.files_exists != 'true' shell: bash env: - NC_VERSION: ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }} + NC_VERSION: ${{ steps.build_metadata.outputs.nextcloud-min-version }} run: | set -euo pipefail download_url="$(curl --fail --silent --show-error "https://updates.nextcloud.com/updater_server/latest?channel=beta&version=${NC_VERSION}" | jq -r '.downloads.zip[0]')" @@ -263,7 +256,7 @@ runs: tar -zcvf "${APP_NAME}.tar.gz" "${APP_NAME}" - name: Set up PHP 8.3 for release-tool - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: '8.3' coverage: none @@ -271,7 +264,7 @@ runs: GITHUB_TOKEN: ${{ inputs.github-token }} - name: Validate release artifact - uses: LibreCodeCoop/release-tool/actions/artifact-validate@9b33a67a7b1e37a45b0a3c252370899effcdfdc2 + uses: LibreCodeCoop/release-tool/actions/artifact-validate@30ba5d8a33089331d829f586546d34cd45a529d7 with: artifact: ${{ inputs.app-name }}/build/artifacts/${{ inputs.app-name }}.tar.gz app-name: ${{ inputs.app-name }} @@ -300,8 +293,8 @@ runs: app_private_key: ${{ inputs.app-private-key }} - name: Verify App Store publication - uses: LibreCodeCoop/release-tool/actions/appstore-publication-wait@9b33a67a7b1e37a45b0a3c252370899effcdfdc2 + uses: LibreCodeCoop/release-tool/actions/appstore-publication-wait@30ba5d8a33089331d829f586546d34cd45a529d7 with: app-name: ${{ inputs.app-name }} version: ${{ steps.release_identity.outputs.version }} - platform: ${{ fromJSON(steps.appinfo.outputs.result).nextcloud.min-version }} + platform: ${{ steps.build_metadata.outputs.nextcloud-min-version }} diff --git a/docs/release-workflow-architecture.md b/docs/release-workflow-architecture.md new file mode 100644 index 0000000..9f017be --- /dev/null +++ b/docs/release-workflow-architecture.md @@ -0,0 +1,41 @@ + + +# Release workflow implementation boundaries + +GitHub Actions workflows orchestrate reusable, versioned Release Tool Actions. +They must not independently implement XML/JSON parsing, permission policies, +or stable-branch selection with inline Python or shell programs. + +The organization publication Action delegates permission validation to the +release-tool authorization-check adapter and checkout metadata to build-inspect. +When package.json is missing, build-inspect returns no Node/npm versions and +Node build steps must not run. A present package.json without engines uses +Node ^24 and npm ^11.3. + +The nightly workflow runs daily or on manual dispatch. It selects the highest +stable branch using stable-select and checks out that exact ref. The tag and +release notes always refer to the checked-out commit, never the main branch +commit that triggered the scheduled run. When no stable branch is available, +the publishing job is skipped. + +The release finalization workflow uses pull_request_target only for closed, +merged release preparation PRs with verified branch and marker metadata. +Do not filter these events using changed file paths. + +Validation boundaries: + +1. Release Tool PHPUnit tests validate parsed build metadata, default values, + malformed or missing files, injection attempts and GitHub outputs. +2. Release Tool public Action acceptance tests pin adapter contracts. +3. Organization workflow tests validate orchestration, immutable pins, + branch selection and sensitive event guards; actionlint and zizmor + validate workflow structure and security. +4. Live App Store publishing and nightly tag mutations require a separate + controlled integration run, not an ad hoc CI dry run. + +Both repositories pin shared Actions to a single immutable release-tool commit. +Verify that the corresponding versioned PHAR is published and its checksum +valid before consuming the release. diff --git a/tests/test_release_tool_pins.py b/tests/test_release_tool_pins.py index 2cdd1c7..73f916b 100644 --- a/tests/test_release_tool_pins.py +++ b/tests/test_release_tool_pins.py @@ -7,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1] -RELEASE_TOOL_RELEASE_SHA = "9b33a67a7b1e37a45b0a3c252370899effcdfdc2" +RELEASE_TOOL_RELEASE_SHA = "30ba5d8a33089331d829f586546d34cd45a529d7" SURFACES = ( ROOT / "workflow-templates/prepare-release.yml", ROOT / "workflow-templates/nightly-release.yml", diff --git a/tests/test_release_workflow_architecture.py b/tests/test_release_workflow_architecture.py new file mode 100644 index 0000000..265c999 --- /dev/null +++ b/tests/test_release_workflow_architecture.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +NIGHTLY = ROOT / "workflow-templates/nightly-release.yml" +PUBLISH = ROOT / "actions/nextcloud-appstore-publish/action.yml" +PREPARE = ROOT / "workflow-templates/prepare-release.yml" + + +class ReleaseWorkflowArchitectureTest(unittest.TestCase): + def test_metadata_and_authorization_delegate_to_testable_php_adapters(self) -> None: + for path in (NIGHTLY, PUBLISH): + with self.subTest(path=path.name): + content = path.read_text() + self.assertIn("release-tool/actions/build-inspect@", content) + self.assertIn("outputs.has-package-json == 'true'", content) + self.assertNotIn("python3", content) + self.assertNotIn("skjnldsv/", content) + self.assertNotIn("read-package-engines-version-actions", content) + self.assertNotIn("xpath-action", content) + self.assertIn("release-tool/actions/authorization-check@", PUBLISH.read_text()) + + def test_nightly_only_uses_latest_stable_and_checked_out_sha(self) -> None: + content = NIGHTLY.read_text() + self.assertIn("cron: '17 3 * * *'", content) + self.assertNotIn("\n push:\n", content) + self.assertIn("if: needs.resolve-latest-stable.outputs.latest-branch != ''", content) + self.assertIn("ref: ${{ needs.resolve-latest-stable.outputs.latest-branch }}", content) + self.assertIn('sha=$(git rev-parse HEAD)', content) + self.assertIn('sha="${{ steps.source.outputs.sha }}"', content) + self.assertIn('node-version: ${{ steps.build_metadata.outputs.node-version }}', content) + self.assertIn('NPM_VERSION: ${{ steps.build_metadata.outputs.npm-version }}', content) + self.assertIn('run: npm i -g "npm@${NPM_VERSION}"', content) + + def test_prepare_release_preserves_privileged_event_guards(self) -> None: + content = PREPARE.read_text() + self.assertIn("github.event.pull_request.merged == true", content) + self.assertIn("startsWith(github.event.pull_request.head.ref, 'release-tool/')", content) + self.assertIn("contains(github.event.pull_request.body, '