diff --git a/docs/nextcloud-release.md b/docs/nextcloud-release.md index b39fd29..194e048 100644 --- a/docs/nextcloud-release.md +++ b/docs/nextcloud-release.md @@ -3,59 +3,93 @@ SPDX-FileCopyrightText: 2026 LibreCode coop and contributors SPDX-License-Identifier: AGPL-3.0-or-later --> -# Nextcloud release planning +# Nextcloud release automation -The reusable release-plan workflow is intentionally non-mutating. It validates -release prerequisites before any tag, GitHub Release, signing or App Store -publication occurs. +The public `Prepare release` workflow is the maintainer entry point for the +reusable release platform. -## Architecture +Normal releases have two human gates: -The reusable workflow owns orchestration concerns: permissions, runner selection -and checking out the caller plus the workflow tooling repository. +1. review and merge the generated release preparation pull request; +2. review and publish the generated GitHub Release draft. -The release-plan operation itself is exposed as the local composite action -`actions/release-plan`. The action maps its declared inputs to a small, -namespaced environment contract and invokes `scripts/release_plan.py`. +Planning, finalization, milestone transition and post-publication verification +are automated around those gates. -Business rules, input parsing, GitHub API checks, exit status and step-summary -rendering live in the Python script and are covered by unit tests. The workflow -does not contain release decision logic. +## Consumer contract -This follows GitHub's distinction between reusable workflows, which reuse whole -workflow/job structures, and composite actions, which encapsulate a reusable -sequence of steps within a job. +A consumer repository provides a versioned `.nextcloud-release.yml` file and +installs `workflow-templates/prepare-release.yml`. -## Checks +The manual entry point requires only the release branch. Optional inputs allow: -The first implementation validates: +- an exact planning ref; +- an explicit version; +- alpha, beta, rc or final channel; +- an explicit open-backport override; +- follow-up milestone creation; +- normal or security mode; +- explicitly public-safe text for security mode. -- semantic release version in `MAJOR.MINOR.PATCH` form; -- execution from the declared stable branch; -- `appinfo/info.xml` version matches the requested release; -- changelog contains a level-2 section for the requested version; -- optional milestone exists, is closed and has zero open issues; -- optional GitHub blocker queries return zero open issues or pull requests. +The workflow delegates release policy to the pinned PHP release tool. GitHub +Actions owns orchestration, authentication, permissions and artifact handoff; +it does not reimplement version, changelog or milestone policy in YAML. -Blocker queries are caller-owned. This keeps project conventions out of the -shared workflow. A caller can model pending backports with a label query without -making that label part of the reusable workflow contract. +## Lifecycle -## Example caller - -```yaml -jobs: - release-plan: - uses: LibreCodeCoop/github-workflows/.github/workflows/release-plan.yml@ # v0.1.0 - with: - version: 16.0.0 - stable_branch: stable36 - milestone: 16.0.0 - blocker_queries: '["label:\"backport pending\""]' +```text +Actions -> Prepare release + -> ReleasePlan v1 + -> generated release PR + -> maintainer review + merge + -> PreparedRelease v1 + -> milestone transition + -> GitHub Release draft + -> maintainer review + Publish + -> existing package/sign/App Store publisher + -> PublicationVerification v1 ``` -The workflow only needs read permissions. Signing keys and App Store tokens are -deliberately not accepted by the planning stage. +The generated release PR is recognized by deterministic release-tool identity +and provenance. Post-merge continuation validates the actual merger permission +before any privileged release mutation. + +## Authentication and permissions + +Read-only planning uses the repository token with read permissions. + +Mutating preparation/finalization stages use short-lived GitHub App installation +tokens scoped to the current repository. Consumers configure: + +- `LIBRECODE_WORKFLOW_APP_ID` as an Actions variable; +- `LIBRECODE_WORKFLOW_APP_PRIVATE_KEY` as an Actions secret. + +The reusable actions request only the permissions needed by each stage. The +whole workflow does not receive broad write permissions. + +## Release tool pinning + +The reusable actions install an exact released `release-tool` PHAR, verify its +published SHA-256 checksum and expose the same CLI used for local diagnostics and +recovery. No production path executes a floating `latest` artifact. + +## Publication + +Publishing the GitHub Release remains an explicit maintainer action. + +The existing consumer-specific publisher remains responsible for packaging, +signing, uploading the release asset and App Store publication. After the +release event, the workflow restores the finalized release contracts and +produces `PublicationVerification v1` only when the published release identity, +asset, publisher handoff and App Store visibility agree. + +## Recovery and local parity + +Every stage contract can be reproduced with the release-tool CLI for dry-run, +diagnostics and manual recovery. The public LibreSign documentation contains the +consumer-facing procedure and recovery guidance; this repository documents the +reusable orchestration contract only. -Publication will be implemented as a separate privileged workflow after the -planning contract is proven with LibreSign and at least one additional app. +The previous `release-nextcloud-app` template is retired from the public +catalog because it created a release directly and bypassed the final staged +contracts. diff --git a/patches/nextcloud/sync-workflow-templates.yml.patch b/patches/nextcloud/sync-workflow-templates.yml.patch index 2714f63..076776a 100644 --- a/patches/nextcloud/sync-workflow-templates.yml.patch +++ b/patches/nextcloud/sync-workflow-templates.yml.patch @@ -1,6 +1,6 @@ --- upstream/vendor/nextcloud/sync-workflow-templates.yml +++ workflow-templates/sync-workflow-templates.yml -@@ -1,13 +1,19 @@ +@@ -1,13 +1,16 @@ # This workflow is provided via the organization template repository # -# https://github.com/nextcloud/.github @@ -15,15 +15,12 @@ +# This workflow will update all workflow templates. +# Additionally it will reapply workflow.yml.patch files after syncing and only then commit the result. +# -+# Authentication is explicit through vars.WORKFLOW_SYNC_AUTH_MODE: -+# - librecode-app (default): LibreCode-managed GitHub App credentials -+# - github-app: consumer-owned GitHub App credentials -+# - token: consumer-owned repository-scoped token -+# - github-token: built-in GITHUB_TOKEN (generated PRs do not trigger normal PR workflows) ++# Authentication is selected explicitly with vars.WORKFLOW_SYNC_AUTH_MODE. ++# See docs/cross-repository-automation.md for modes, credentials and GITHUB_TOKEN limitations. name: Update workflows on: workflow_dispatch: -@@ -26,9 +32,6 @@ +@@ -26,9 +29,6 @@ matrix: branches: - ${{ github.event.repository.default_branch }} @@ -33,7 +30,7 @@ name: Update workflows in ${{ matrix.branches }} -@@ -42,12 +45,111 @@ +@@ -42,12 +42,111 @@ with: require: admin @@ -146,7 +143,7 @@ - name: Checkout app uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 -@@ -56,86 +158,33 @@ +@@ -56,86 +155,33 @@ path: target ref: ${{ matrix.branches }} diff --git a/tests/test_portable_workflow_sync_auth.py b/tests/test_portable_workflow_sync_auth.py index bac0ac7..a22ddfd 100644 --- a/tests/test_portable_workflow_sync_auth.py +++ b/tests/test_portable_workflow_sync_auth.py @@ -37,10 +37,8 @@ def test_generated_pull_request_uses_selected_token(self) -> None: def test_github_token_limitation_is_visible_in_template(self) -> None: content = TEMPLATE.read_text(encoding="utf-8") - self.assertIn( - "generated PRs do not trigger normal PR workflows", - content, - ) + self.assertIn("GITHUB_TOKEN limitations", content) + self.assertIn("docs/cross-repository-automation.md", content) def test_sync_action_is_pinned_with_release_and_catalog_provenance(self) -> None: content = TEMPLATE.read_text(encoding="utf-8") diff --git a/tests/test_prepare_release_template.py b/tests/test_prepare_release_template.py new file mode 100644 index 0000000..bfeefcd --- /dev/null +++ b/tests/test_prepare_release_template.py @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +TEMPLATE = ROOT / "workflow-templates" / "prepare-release.yml" +CATALOG = ROOT / "workflow-catalog.json" + + +class PrepareReleaseTemplateTest(unittest.TestCase): + def test_template_is_published_and_legacy_release_template_is_not(self) -> None: + import json + + catalog = json.loads(CATALOG.read_text(encoding="utf-8")) + self.assertIn("prepare-release", catalog["templates"]) + self.assertNotIn("release-nextcloud-app", catalog["templates"]) + + def test_template_exposes_required_release_entry_points(self) -> None: + content = TEMPLATE.read_text(encoding="utf-8") + + self.assertIn("workflow_dispatch:", content) + self.assertIn("pull_request:", content) + self.assertIn("release:", content) + self.assertIn("branch:", content) + self.assertIn("channel:", content) + self.assertIn("ignore_open_backport:", content) + self.assertIn("create_follow_up_milestone:", content) + self.assertIn("mode:", content) + + def test_template_delegates_all_release_stages_to_versioned_actions(self) -> None: + content = TEMPLATE.read_text(encoding="utf-8") + sha = "002f17274ba1eade3351ba81890bf53674b37c43" + + self.assertIn( + f"actions/release-prepare@{sha} # v0.4.0", + content, + ) + self.assertIn( + f"actions/release-post-merge@{sha} # v0.4.0", + content, + ) + self.assertIn( + f"actions/release-publication@{sha} # v0.4.0", + content, + ) + + def test_template_keeps_permissions_stage_scoped(self) -> None: + content = TEMPLATE.read_text(encoding="utf-8") + + self.assertIn("permissions: {}", content) + self.assertIn("actions: read", content) + self.assertIn("contents: read", content) + self.assertIn("pull-requests: read", content) + self.assertNotIn("permissions: write-all", content) + self.assertNotIn("contents: write", content) + + def test_post_merge_only_accepts_generated_merged_release_prs(self) -> None: + content = TEMPLATE.read_text(encoding="utf-8") + + self.assertIn("github.event.pull_request.merged == true", content) + self.assertIn("startsWith(github.event.pull_request.head.ref, 'release-tool/')", content) + self.assertIn("