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
18 changes: 18 additions & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
version = 1

[[annotations]]
path = [
"upstream/*.json",
"workflow-templates/*.properties.json"
]
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 LibreCode coop and contributors"
SPDX-License-Identifier = "AGPL-3.0-or-later"

[[annotations]]
path = "patches/nextcloud/*.yml.patch"
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 LibreCode coop and contributors"
SPDX-License-Identifier = "MIT"
2 changes: 1 addition & 1 deletion actions/sync-workflows/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ outputs:
description: Whether at least one local workflow patch failed to apply.
value: ${{ steps.sync.outputs.patch_failed }}
blocked:
description: Whether synchronization was blocked by unmanaged local divergence.
description: Whether synchronization was blocked by local divergence or a patch failure.
value: ${{ steps.sync.outputs.blocked }}
updated:
description: JSON array of updated workflow names.
Expand Down
23 changes: 14 additions & 9 deletions actions/sync-workflows/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,6 @@ def apply_patch(target_root: Path, target_file: Path) -> tuple[bool, str]:

def render_expected(
source_file: Path,
target_root: Path,
target_file: Path,
) -> tuple[bytes | None, bool, str]:
patch_file = Path(f"{target_file}.patch")
Expand Down Expand Up @@ -287,7 +286,7 @@ def sync(

if locked is None:
expected, patch_ok, patch_message = render_expected(
source_file, target, target_file
source_file, target_file
)
if not patch_ok:
failed.append(name)
Expand All @@ -310,7 +309,7 @@ def sync(

if matches_source(locked, source_file):
expected, patch_ok, patch_message = render_expected(
source_file, target, target_file
source_file, target_file
)
if not patch_ok:
failed.append(name)
Expand All @@ -335,15 +334,21 @@ def sync(
unchanged.append(name)
continue

shutil.copyfile(source_file, target_file)
patch_ok, patch_message = apply_patch(target, target_file)
# Evaluate the upstream file and its local patch in isolation first.
# Never replace the last known-good workflow or advance its lock on failure.
expected, patch_ok, patch_message = render_expected(
source_file, target_file
)
if not patch_ok or expected is None:
failed.append(name)
details.append(f"- {name}: {patch_message}")
continue

target_file.write_bytes(expected)
entries[name] = desired
updated.append(name)

if patch_message:
details.append(f"- {name}: {patch_message}")
if not patch_ok:
failed.append(name)

lock_changed = bool(
updated or adopted or stale_entries or provenance_updated
Expand All @@ -354,7 +359,7 @@ def sync(
return {
"changed": lock_changed,
"patch_failed": bool(set(failed) - set(diverged)),
"blocked": bool(diverged),
"blocked": bool(diverged or failed),
"updated": updated,
"adopted": adopted,
"unchanged": unchanged,
Expand Down
22 changes: 22 additions & 0 deletions docs/upstream-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ Rendered downstream templates are declared separately in
`upstream/templates.json`. Each template points to one vendored source, an
ordered patch list and a generated workflow under GitHub's native `workflow-templates/` directory. Template metadata (`*.properties.json`) is maintained locally so LibreCode can provide its own names, descriptions, categories and icons without inheriting upstream branding.

## Standard PHP and browser-testing workflows

The organization also publishes the official Nextcloud PHPStan, SQLite/MariaDB/PostgreSQL
PHPUnit, and Playwright templates. Their source files are vendored by immutable
Nextcloud commits and checksums. Unlike product-local workflow forks, these
shared templates are updated by the normal upstream refresh procedure.

`upstream/templates.json` declares a single reusable runner mapping:
`ubuntu-latest-low` to `ubuntu-latest`, applied only to YAML `runs-on:` values
**after** upstream patches. It does not edit the vendored Nextcloud files,
change comments, or change any other workflow strings. This avoids repeated
consumer runner patches while preserving provenance and future updates.

Consumer application behavior such as database/fixture provisioning, custom
Playwright assertions, or bespoke PHPStan policy belongs in a small local
`.github/workflows/<name>.yml.patch`. Applications that use the official
Composer `phpstan` and `test:unit` contracts need no local PHP patches.

A failed consumer patch is now a hard synchronization failure: the last known-good
workflow and its lock entry are preserved. The synchronization must not publish
a broken upstream workflow before a maintainer repairs the patch.

## Dependency-audit workflow

The `npm-audit-fix.yml` catalog template is derived from a pinned Nextcloud source
Expand Down
27 changes: 26 additions & 1 deletion scripts/render_upstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
from pathlib import Path


RUNNER_RE = re.compile(r"^(?P<start>[ \t]*runs-on:[ \t]*)(?P<runner>[A-Za-z0-9_-]+)(?P<end>[ \t]*(?:#.*)?)$", re.MULTILINE)
RUNNER_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")

ACTION_USE_RE = re.compile(
r"^(?P<prefix>\s*uses:\s*)(?P<action>[^@\s]+)@(?P<ref>[0-9a-f]{40})(?P<suffix>\s*(?:#.*)?)$",
re.MULTILINE,
Expand All @@ -26,6 +29,7 @@ class Template:
source: Path
patches: tuple[Path, ...]
destination: Path
runner_aliases: tuple[tuple[str, str], ...] = ()


def load_templates(manifest_path: Path) -> list[Template]:
Expand All @@ -37,6 +41,15 @@ def load_templates(manifest_path: Path) -> list[Template]:
if not isinstance(raw_templates, list):
raise ValueError("manifest.templates must be an array")

aliases = payload.get("runner_aliases", {})
if not isinstance(aliases, dict) or any(
not isinstance(old, str) or not isinstance(new, str)
or RUNNER_NAME_RE.fullmatch(old) is None
or RUNNER_NAME_RE.fullmatch(new) is None
for old, new in aliases.items()
):
raise ValueError("manifest.runner_aliases must map safe runner names to safe runner names")

templates: list[Template] = []
for index, raw in enumerate(raw_templates):
if not isinstance(raw, dict):
Expand All @@ -63,6 +76,7 @@ def load_templates(manifest_path: Path) -> list[Template]:
source=source,
patches=tuple(Path(item) for item in raw_patches),
destination=destination,
runner_aliases=tuple(sorted(aliases.items())),
)
)

Expand Down Expand Up @@ -93,7 +107,18 @@ def render(template: Template, root: Path) -> bytes:
details = (result.stderr or result.stdout).strip()
raise ValueError(f"failed to apply {patch_path}: {details}")

return working.read_bytes()
content = working.read_text(encoding="utf-8")
if template.runner_aliases:
replacements = dict(template.runner_aliases)
content = RUNNER_RE.sub(
lambda match: (
match.group("start")
+ replacements.get(match.group("runner"), match.group("runner"))
+ match.group("end")
),
content,
)
return content.encode("utf-8")


def preserve_action_pins(rendered: bytes, current: bytes | None) -> bytes:
Expand Down
32 changes: 32 additions & 0 deletions tests/test_render_upstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,38 @@ def fixture(self, directory: str) -> tuple[Path, Path]:
)
return root, manifest

def test_nextcloud_runner_alias_is_generic_and_does_not_touch_other_text(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root, manifest = self.fixture(directory)
source = root / "upstream/vendor/example.yml"
source.write_text(
"name: Test\n# ubuntu-latest-low in a comment\n"
"jobs:\n test:\n runs-on: ubuntu-latest-low\n"
" fallback:\n runs-on: ubuntu-latest\n", encoding="utf-8",
)
payload = json.loads(manifest.read_text())
payload["templates"][0]["patches"] = []
payload["runner_aliases"] = {"ubuntu-latest-low": "ubuntu-latest"}
manifest.write_text(json.dumps(payload))
templates = load_templates(manifest)
self.assertTrue(sync(templates, root)["ok"])
generated = (root / "workflow-templates/example.yml").read_text()
self.assertEqual(generated.count("runs-on: ubuntu-latest"), 2)
self.assertIn("# ubuntu-latest-low in a comment", generated)
check(templates, root)
self.assertEqual(sync(templates, root)["unchanged"], 1)

def test_runner_alias_rejects_invalid_names(self) -> None:
with tempfile.TemporaryDirectory() as directory:
_, manifest = self.fixture(directory)
payload = json.loads(manifest.read_text())
for aliases in ([], {"bad run": "ubuntu-latest"}, {"ubuntu-latest-low": "x\ny"}):
with self.subTest(aliases=aliases):
payload["runner_aliases"] = aliases
manifest.write_text(json.dumps(payload))
with self.assertRaisesRegex(ValueError, "runner_aliases"):
load_templates(manifest)

def test_sync_applies_patch(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root, manifest = self.fixture(directory)
Expand Down
10 changes: 5 additions & 5 deletions tests/test_sync_workflows_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,13 +268,13 @@ def test_broken_patch_sets_draft_signal_and_keeps_catalog_lock(self) -> None:
source, target, target / ".github/actions-lock.txt"
)

self.assertTrue(report["changed"])
self.assertFalse(report["changed"])
self.assertTrue(report["blocked"])
self.assertTrue(report["patch_failed"])
self.assertEqual(report["failed"], ["sync.yml"])
self.assertEqual(
sync_module.parse_lock(target / ".github/actions-lock.txt")["sync.yml"],
sync_module.sha256(source_file),
)
self.assertEqual(target_file.read_text(encoding="utf-8"), "name: Old\n")
lock = sync_module.parse_lock_records(target / ".github/actions-lock.txt")
self.assertEqual(lock["sync.yml"].digest, old_hash)

def test_removes_lock_entries_missing_from_catalog(self) -> None:
temporary, source, target = self.fixture()
Expand Down
45 changes: 45 additions & 0 deletions upstream/sources.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,51 @@
"url": "https://raw.githubusercontent.com/nextcloud/.github/d17a1728cd176b54fe0be73407ed0062a7f4ec5a/workflow-templates/npm-audit-fix.yml",
"sha256": "ae8743960f808afaf479d7c3aa0f7ee04b4ad13a52c5cafe9f7eb32b19807edc",
"destination": "upstream/vendor/nextcloud/npm-audit-fix.yml"
},
{
"name": "nextcloud-phpstan",
"repository": "nextcloud/.github",
"ref": "master",
"path": "workflow-templates/phpstan.yml",
"url": "https://raw.githubusercontent.com/nextcloud/.github/c2798fc61844a6420779add9f5e1b51f8aabfd87/workflow-templates/phpstan.yml",
"sha256": "3737c57183fb37a41d0fa4cf2efa55245e7fc8348dbad43faf9ee6197440edae",
"destination": "upstream/vendor/nextcloud/phpstan.yml"
},
{
"name": "nextcloud-phpunit-sqlite",
"repository": "nextcloud/.github",
"ref": "master",
"path": "workflow-templates/phpunit-sqlite.yml",
"url": "https://raw.githubusercontent.com/nextcloud/.github/c2798fc61844a6420779add9f5e1b51f8aabfd87/workflow-templates/phpunit-sqlite.yml",
"sha256": "06642c0d4ee358baa079716426a2ae24d387d3cb04b00c88f50ea46f8da1a1e8",
"destination": "upstream/vendor/nextcloud/phpunit-sqlite.yml"
},
{
"name": "nextcloud-phpunit-mariadb",
"repository": "nextcloud/.github",
"ref": "master",
"path": "workflow-templates/phpunit-mariadb.yml",
"url": "https://raw.githubusercontent.com/nextcloud/.github/c2798fc61844a6420779add9f5e1b51f8aabfd87/workflow-templates/phpunit-mariadb.yml",
"sha256": "706800a7d01034c9c4a657585a1b8c955259eb86869dadabafba046081fa1446",
"destination": "upstream/vendor/nextcloud/phpunit-mariadb.yml"
},
{
"name": "nextcloud-phpunit-pgsql",
"repository": "nextcloud/.github",
"ref": "master",
"path": "workflow-templates/phpunit-pgsql.yml",
"url": "https://raw.githubusercontent.com/nextcloud/.github/c2798fc61844a6420779add9f5e1b51f8aabfd87/workflow-templates/phpunit-pgsql.yml",
"sha256": "e0a15c5d99b0ad092a5560660f712712b1cdcf0ede6e22fca15c8b591402eec3",
"destination": "upstream/vendor/nextcloud/phpunit-pgsql.yml"
},
{
"name": "nextcloud-playwright",
"repository": "nextcloud/.github",
"ref": "master",
"path": "workflow-templates/playwright.yml",
"url": "https://raw.githubusercontent.com/nextcloud/.github/c2798fc61844a6420779add9f5e1b51f8aabfd87/workflow-templates/playwright.yml",
"sha256": "26106f8ae4bef2a191c445fb3a246ae6e1ab3f43348b8940feaf356768091a38",
"destination": "upstream/vendor/nextcloud/playwright.yml"
}
]
}
35 changes: 34 additions & 1 deletion upstream/templates.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,39 @@
"patches/nextcloud/npm-audit-fix.yml.patch"
],
"destination": "workflow-templates/npm-audit-fix.yml"
},
{
"name": "nextcloud-phpstan",
"source": "upstream/vendor/nextcloud/phpstan.yml",
"patches": [],
"destination": "workflow-templates/phpstan.yml"
},
{
"name": "nextcloud-phpunit-sqlite",
"source": "upstream/vendor/nextcloud/phpunit-sqlite.yml",
"patches": [],
"destination": "workflow-templates/phpunit-sqlite.yml"
},
{
"name": "nextcloud-phpunit-mariadb",
"source": "upstream/vendor/nextcloud/phpunit-mariadb.yml",
"patches": [],
"destination": "workflow-templates/phpunit-mariadb.yml"
},
{
"name": "nextcloud-phpunit-pgsql",
"source": "upstream/vendor/nextcloud/phpunit-pgsql.yml",
"patches": [],
"destination": "workflow-templates/phpunit-pgsql.yml"
},
{
"name": "nextcloud-playwright",
"source": "upstream/vendor/nextcloud/playwright.yml",
"patches": [],
"destination": "workflow-templates/playwright.yml"
}
]
],
"runner_aliases": {
"ubuntu-latest-low": "ubuntu-latest"
}
}
61 changes: 61 additions & 0 deletions upstream/vendor/nextcloud/phpstan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT

name: Static analysis

on: pull_request

concurrency:
group: phpstan-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

permissions:
contents: read

jobs:
static-analysis:
runs-on: ubuntu-latest

name: static-phpstan-analysis
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Get php version
id: versions
uses: nextcloud-libraries/nextcloud-version-matrix@71959de73c244b11ecbf4503b87c41d65b0f64ae # v1.3.4

- name: Check enforcement of minimum PHP version ${{ steps.versions.outputs.php-min-id }} in phpstan.neon
run: "grep 'min: ${{ steps.versions.outputs.php-min-id }}' phpstan.neon"

- name: Set up php${{ steps.versions.outputs.php-available }}
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0
with:
php-version: ${{ steps.versions.outputs.php-available }}
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
coverage: none
ini-file: development
# Temporary workaround for missing pcntl_* in PHP 8.3
ini-values: disable_functions=
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Remove nextcloud/ocp
run: |
composer remove nextcloud/ocp --dev --no-scripts

- name: Install composer dependencies
uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0

- name: Install nextcloud/ocp:dev-${{ steps.versions.outputs.branches-max }}
run: composer require --dev nextcloud/ocp:dev-${{ steps.versions.outputs.branches-max }} --ignore-platform-reqs --with-dependencies

- name: Run coding standards check
run: composer run phpstan -- --no-progress
Loading
Loading