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
118 changes: 76 additions & 42 deletions docs/nextcloud-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<full-release-sha> # 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.
15 changes: 6 additions & 9 deletions patches/nextcloud/sync-workflow-templates.yml.patch
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 }}
Expand All @@ -33,7 +30,7 @@

name: Update workflows in ${{ matrix.branches }}

@@ -42,12 +45,111 @@
@@ -42,12 +42,111 @@
with:
require: admin

Expand Down Expand Up @@ -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 }}

Expand Down
6 changes: 2 additions & 4 deletions tests/test_portable_workflow_sync_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
69 changes: 69 additions & 0 deletions tests/test_prepare_release_template.py
Original file line number Diff line number Diff line change
@@ -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("<!-- release-tool:preparation ", content)
self.assertIn("github.event.pull_request.merged_by.login", content)


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion workflow-catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"node-test",
"npm-build",
"openapi",
"prepare-release",
"psalm",
"release-nextcloud-app",
"reuse",
"sync-workflow-templates"
]
Expand Down
5 changes: 5 additions & 0 deletions workflow-templates/prepare-release.properties.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Prepare release",
"description": "Plan, prepare, finalize and verify a Nextcloud app release with explicit maintainer review and publish gates.",
"iconName": "octicon tag"
}
2 changes: 2 additions & 0 deletions workflow-templates/prepare-release.properties.json.license
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
SPDX-License-Identifier: AGPL-3.0-or-later
Loading
Loading