From ca7ae7c99429a0d6efd1fc13d54786640daca4db Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 19:01:21 -0300 Subject: [PATCH 01/10] feat: publish maintainer-facing release template --- workflow-templates/prepare-release.yml | 153 +++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 workflow-templates/prepare-release.yml diff --git a/workflow-templates/prepare-release.yml b/workflow-templates/prepare-release.yml new file mode 100644 index 0000000..46f84d9 --- /dev/null +++ b/workflow-templates/prepare-release.yml @@ -0,0 +1,153 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Prepare release + +on: + workflow_dispatch: + inputs: + branch: + description: Stable branch to release (for example stable35) + required: true + type: string + ref: + description: Optional exact commit/ref override + required: false + type: string + version: + description: Optional explicit version override + required: false + type: string + channel: + description: Release channel + required: true + default: final + type: choice + options: + - alpha + - beta + - rc + - final + ignore_open_backport: + description: Ignore an open backport blocker for this stable + required: true + default: false + type: boolean + create_follow_up_milestone: + description: Create the next configured milestone + required: true + default: true + type: boolean + mode: + description: Release mode + required: true + default: normal + type: choice + options: + - normal + - security + safe_public_text: + description: Public-safe release text for security mode + required: false + type: string + pull_request: + types: [closed] + release: + types: [published] + +permissions: {} + +concurrency: + group: release-automation-${{ github.repository }} + cancel-in-progress: false + +jobs: + prepare: + name: Prepare release PR + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + issues: read + pull-requests: read + steps: + - name: Checkout selected release state + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + ref: ${{ inputs.ref != '' && inputs.ref || inputs.branch }} + + - name: Prepare release + uses: LibreCodeCoop/github-workflows/actions/release-prepare@002f17274ba1eade3351ba81890bf53674b37c43 # v0.4.0 + with: + branch: ${{ inputs.branch }} + ref: ${{ inputs.ref }} + version: ${{ inputs.version }} + channel: ${{ inputs.channel }} + mode: ${{ inputs.mode }} + safe-public-text: ${{ inputs.safe_public_text }} + ignore-open-backport: ${{ inputs.ignore_open_backport }} + create-follow-up-milestone: ${{ inputs.create_follow_up_milestone }} + config-path: .nextcloud-release.yml + actor: ${{ github.actor }} + github-token: ${{ secrets.GITHUB_TOKEN }} + app-id: ${{ vars.LIBRECODE_WORKFLOW_APP_ID }} + app-private-key: ${{ secrets.LIBRECODE_WORKFLOW_APP_PRIVATE_KEY }} + + post_merge: + name: Finalize release and prepare draft + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release-tool/') && + contains(github.event.pull_request.body, ' -# 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. From 48494566400388ceb55ac29c1395dc360a17c11a Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 19:02:11 -0300 Subject: [PATCH 06/10] docs: keep workflow sync template concise --- workflow-templates/sync-workflow-templates.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/workflow-templates/sync-workflow-templates.yml b/workflow-templates/sync-workflow-templates.yml index e41ce4e..a0e59f2 100644 --- a/workflow-templates/sync-workflow-templates.yml +++ b/workflow-templates/sync-workflow-templates.yml @@ -9,11 +9,8 @@ # 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: From 54b62b02359bfca1f7af6e2053cceb757f58e5d5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 19:02:15 -0300 Subject: [PATCH 07/10] docs: keep workflow sync patch concise --- patches/nextcloud/sync-workflow-templates.yml.patch | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/patches/nextcloud/sync-workflow-templates.yml.patch b/patches/nextcloud/sync-workflow-templates.yml.patch index 2714f63..4d13e50 100644 --- a/patches/nextcloud/sync-workflow-templates.yml.patch +++ b/patches/nextcloud/sync-workflow-templates.yml.patch @@ -15,11 +15,8 @@ +# 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: From ca63fefb76dd6302fcdfe47f36397de1d737b870 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Mon, 21 Sep 2026 19:02:36 -0300 Subject: [PATCH 08/10] test: validate prepare release template --- tests/test_prepare_release_template.py | 69 ++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_prepare_release_template.py 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("