diff --git a/.github/workflows/workflow-policy.yml b/.github/workflows/workflow-policy.yml new file mode 100644 index 0000000..903dd17 --- /dev/null +++ b/.github/workflows/workflow-policy.yml @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Workflow policy + +on: + pull_request: + paths: + - '.github/workflows/**' + - 'workflow-templates/**' + - 'scripts/check_workflow_policy.py' + - 'tests/test_workflow_policy.py' + push: + branches: + - main + paths: + - '.github/workflows/**' + - 'workflow-templates/**' + - 'scripts/check_workflow_policy.py' + - 'tests/test_workflow_policy.py' + +permissions: + contents: read + +jobs: + policy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check workflow policy + run: python3 scripts/check_workflow_policy.py diff --git a/README.md b/README.md index b6244bd..5cb80c1 100644 --- a/README.md +++ b/README.md @@ -5,40 +5,49 @@ SPDX-License-Identifier: AGPL-3.0-or-later # GitHub Workflows -Reusable, testable GitHub workflows for organizations that want consistent CI -and release automation without copying opaque YAML between repositories. +Managed, testable GitHub workflow templates and supporting Actions for organizations +that want consistent CI without copying opaque YAML between repositories. -This project keeps reusable workflow logic independent from +This project keeps workflow distribution independent from [GitHub Governance](https://github.com/LibreCodeCoop/github-governance): -governance manages repository rulesets, while this repository manages reusable -workflows and reproducible upstream workflow adaptations. +governance manages repository rulesets, while this repository manages workflow +sources, adaptations, tests and publication. ## Why use it -- **Reusable automation:** consume shared workflows instead of maintaining copies. - **Reproducible upstream imports:** source files are tied to immutable upstream commits and SHA-256 hashes. -- **Reviewable downstream changes:** local adaptations are explicit and testable. -- **Security-first defaults:** third-party Actions are pinned to immutable commit SHAs. -- **Versioned consumption:** releases are referenced by immutable SHA with a human-readable version comment. +- **Reviewable downstream changes:** LibreCode adaptations are explicit patches. +- **Materialized consumer workflows:** repositories keep normal local GitHub workflows instead of opaque remote callers. +- **Automated updates:** consumers receive reviewable pull requests from the organization catalog. +- **Local customization:** consumer-specific differences live in `.github/workflows/.patch`. +- **Security-first defaults:** external Actions are pinned and checked by policy CI. -## Current scope +## Distribution model -The first target is reusable automation for Nextcloud applications, with -LibreSign as the first production consumer. +`LibreCodeCoop/github-workflows` is the source of truth. -The repository is intentionally product-agnostic. LibreSign and Nextcloud are -reference consumers and upstream sources, not hard-coded engine concepts. +`LibreCodeCoop/.github` is the organization catalog used by GitHub's +**Actions → New workflow** UI. -## Repository layout +Consumer repositories install full workflow files. Their local +`sync-workflow-templates.yml` periodically invokes +`actions/sync-workflows`, which: + +- updates workflows already installed in the repository; +- applies local workflow patches; +- records catalog versions in `.github/actions-lock.txt`; +- refuses to overwrite unexplained local divergence; +- opens reviewable update pull requests through the caller workflow. -- `workflow-templates/` — generated GitHub-native organization workflow templates ready for catalog publication. -- `upstream/` — immutable source manifests. -- `patches/` — explicit downstream adaptations. -- `scripts/` — deterministic synchronization/check tooling. -- `tests/` — tests for synchronization and template behavior. -- `docs/` — architecture, adoption and security guidance. +## Repository layout -`LibreCodeCoop/.github` is the organization catalog used by GitHub's **Actions → New workflow** UI. This repository remains the source of truth; catalog publication should mirror generated templates rather than make `.github` a second editing source. +- `workflow-templates/` — generated organization workflow templates. +- `actions/` — tested Actions used by the workflow platform. +- `upstream/` — immutable source manifests and vendored upstream files. +- `patches/` — explicit organization-level adaptations. +- `scripts/` — deterministic synchronization and policy tooling. +- `tests/` — tests for synchronization, rendering and policy behavior. +- `docs/` — architecture, security and adoption decisions. ## Development @@ -47,10 +56,14 @@ Run: ```bash python3 -m unittest discover -s tests -p 'test_*.py' python3 scripts/sync_upstream.py check upstream/sources.json +python3 scripts/render_upstream.py check upstream/templates.json +python3 scripts/check_workflow_policy.py ``` -See [Architecture](docs/architecture.md) and -[Upstream workflow model](docs/upstream-workflows.md). +See [Architecture](docs/architecture.md), +[Upstream workflow model](docs/upstream-workflows.md), +[GitHub Actions security policy](docs/security-policy.md) and +[Dependency update policy](docs/dependency-update-policy.md). ## Security diff --git a/docs/architecture.md b/docs/architecture.md index 40cfb21..ba4cf3c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,69 +7,103 @@ SPDX-License-Identifier: AGPL-3.0-or-later ## Responsibility boundary -`github-workflows` is the source of truth for reusable CI, imported workflow adaptations, generated workflow templates and release automation. +`github-workflows` is the source of truth for imported workflow adaptations, +generated organization templates and tested helper Actions. -`LibreCodeCoop/.github` is the organization-facing catalog. Generated workflow templates can be published there so developers can discover them through GitHub's **Actions → New workflow** experience. The catalog is a distribution target, not the editing source. +`LibreCodeCoop/.github` is the organization-facing catalog. It is a distribution +target, not an editing source. -Repository rulesets remain the responsibility of `LibreCodeCoop/github-governance`. +Repository rulesets remain the responsibility of +`LibreCodeCoop/github-governance`. Consumer repositories own: +- which catalog workflows they install; +- consumer-specific workflow patches; - credentials and protected environments; -- product-specific configuration; -- the decision to invoke a mutating workflow; -- immutable pins to released workflow revisions. +- branch policy; +- the final review and merge of workflow-update pull requests. ## Upstream workflow pipeline -An imported workflow follows this pipeline: - ```text immutable upstream commit ↓ -source URL + SHA-256 in manifest - ↓ -deterministic fetch - ↓ -hash verification +source URL + SHA-256 ↓ -explicit downstream patches +vendored upstream file ↓ -generated `workflow-templates/` artifact +explicit LibreCode patch ↓ -tests + actionlint + zizmor +generated workflow template ↓ -publish catalog copy to `LibreCodeCoop/.github` +tests + actionlint + zizmor + workflow policy ↓ -versioned release / consumer update +LibreCodeCoop/.github catalog ``` The source manifest is authoritative. A network response that does not match the recorded SHA-256 fails closed. -## Generated files +When a refresh resolves a newer upstream commit but the file bytes are unchanged, +the existing immutable pin is preserved to avoid meaningless pin-only pull requests. + +## Consumer update pipeline + +```text +LibreCodeCoop/.github catalog + ↓ +consumer sync-workflow-templates.yml + ↓ +LibreCodeCoop/github-workflows/actions/sync-workflows + ↓ +compare .github/actions-lock.txt + ↓ +copy changed catalog workflow + ↓ +apply optional consumer-local .patch + ↓ +reviewable consumer pull request +``` + +Only workflows already installed in the consumer are managed. The sync Action does +not maintain a central consumer registry. -Generated templates must not be edited directly. Changes should come from: +The lock records the catalog version before consumer-local patching. If a local file +cannot be explained by the catalog plus its local patch, synchronization stops rather +than overwriting the divergence. -1. an upstream source revision change; or -2. an explicit downstream patch. +A local patch that no longer applies is surfaced for human intervention. -CI should detect when regenerated output differs from committed output. +## Organization patches vs consumer patches -## Release automation +Organization-level differences from Nextcloud belong in +`patches/nextcloud/*.patch` and should remain minimal. + +Consumer-specific differences belong beside the installed workflow: + +```text +.github/workflows/example.yml +.github/workflows/example.yml.patch +``` -Release automation is split into two stages: +Do not move a consumer-only branch list, product dependency or credential assumption +into the organization template. -- **plan:** non-mutating validation and release proposal; -- **apply:** explicit mutation and publication. +## Distribution decision -Credentials remain in the consumer repository or protected environment. +The default model is a materialized workflow template because it remains visible, +reviewable and native to the consumer repository. -## Developer experience +A custom Action is appropriate when substantial deterministic logic can be extracted +from YAML and tested independently, as with `actions/sync-workflows`. -The distribution model has two complementary entry points: +Reusable workflows are not the default distribution model. Introduce one only when +GitHub Actions semantics clearly benefit from centralized execution and the consumer +still retains an explicit, reviewable interface. -1. **Discovery / first install:** `LibreCodeCoop/.github/workflow-templates/` provides the GitHub-native template cards, metadata and optional icons. -2. **Ongoing updates:** consumer repositories receive reviewable update pull requests generated from the tested templates in this repository. +## Credential-sensitive automation -When a workflow can be expressed as a thin caller of a reusable workflow, prefer that model because fixes remain centralized. When GitHub Actions semantics require a full installed workflow, publish the generated workflow template and keep its downstream differences as explicit patches here. +Catalog publication does not imply that every workflow is safe to install everywhere. +Dependency approval, auto-merge and release workflows follow the documented security +and credential policies and may intentionally remain repository-local. diff --git a/docs/dependency-update-policy.md b/docs/dependency-update-policy.md new file mode 100644 index 0000000..3b54a20 --- /dev/null +++ b/docs/dependency-update-policy.md @@ -0,0 +1,67 @@ + + +# Dependency update and auto-merge policy + +Dependency automation is more privileged than lint or test workflows because it can +create, approve or merge pull requests. It is therefore not automatically copied from +Nextcloud into the LibreCode catalog. + +## Allowed actors + +Automatic approval or merge may only act on pull requests created by explicitly +recognized dependency bots: + +- GitHub Dependabot; +- other bots only after an explicit organization-level decision and equivalent actor + verification. + +Do not auto-approve arbitrary pull requests based only on branch naming or labels. + +## Merge policy + +Default policy: + +- patch and minor dependency updates may be eligible for auto-merge after all required + checks pass; +- major updates require human review unless a repository documents a narrower exception; +- security remediation may create a pull request automatically but must not bypass + required checks; +- approval and merge are separate operations and should remain independently auditable. + +## Credentials + +Prefer the `LibreCode Workflow Automation` GitHub App for cross-repository or +workflow-file mutations. + +Do not require a maintainer's personal access token in a shared template. A PAT-based +workflow stays repository-local until it can be replaced with an organization-owned +credential model. + +## pull_request_target + +A workflow using `pull_request_target` must: + +- verify the pull request actor before any privileged action; +- avoid checking out or executing untrusted pull-request code with write credentials; +- grant only the permissions required for metadata, approval or merge operations; +- pin every external Action to a full commit SHA. + +## Distribution decision + +Dependency/update workflows are cataloged only when their credential and actor model is +generic across LibreCode consumers. + +Repository-specific combinations of Dependabot, Renovate, labels, branch naming or PATs +remain local workflows. The organization catalog should not centralize them merely to +reduce YAML duplication. + +## Current decisions + +- `dependabot-approve-merge.yml`: do not migrate the existing Extract workflow until it + uses the organization policy above and an organization-owned credential model. +- `npm-audit-fix.yml`: keep repository-local; it may create a remediation PR but should + not imply automatic approval/merge. +- obsolete Nextcloud OCP auto-merge workflows are not migrated. diff --git a/docs/security-policy.md b/docs/security-policy.md new file mode 100644 index 0000000..127d4bb --- /dev/null +++ b/docs/security-policy.md @@ -0,0 +1,52 @@ + + +# GitHub Actions security policy + +This repository publishes workflow templates that execute inside consumer repositories. +The baseline policy is intentionally small, objective and testable. + +## Mandatory rules + +Published templates and this repository's own workflows must: + +- pin external GitHub Actions to a full 40-character commit SHA; +- keep a human-readable version comment next to the pin when a stable release is known; +- configure `actions/checkout` with `persist-credentials: false`; +- avoid `permissions: write-all`; +- use explicit least-privilege workflow or job permissions. + +The first three mechanically enforceable rules are checked by +`scripts/check_workflow_policy.py`. Existing actionlint and zizmor checks remain +responsible for syntax, expression and broader workflow security analysis. + +## Exceptions + +An exception must be explicit in the pull request that introduces it and must explain: + +1. why the workflow cannot use the mandatory rule; +2. the smallest additional permission or credential needed; +3. how the risk is constrained; +4. how the exception will be tested. + +Do not encode permanent organization-name bypasses when a generic permission or +capability check can express the same requirement. + +## Credentials + +Consumer repositories own runtime credentials and protected environments. +Organization-level GitHub App credentials may be used for public consumer repositories +when the organization plan allows them. + +Mutating automation must prefer a GitHub App over personal access tokens. Personal or +bot PATs are a last resort and require an explicit documented exception. + +## Governance + +`github-workflows` validates workflow content. Repository rulesets and required-check +enforcement belong in `LibreCodeCoop/github-governance`. + +A workflow policy check becomes a candidate required check only after it is stable on +the default branch and does not produce false positives on the published catalog. diff --git a/docs/workflow-family-decisions.md b/docs/workflow-family-decisions.md new file mode 100644 index 0000000..4a0fe29 --- /dev/null +++ b/docs/workflow-family-decisions.md @@ -0,0 +1,40 @@ + + +# Workflow family decisions + +This document records the migration outcome for the workflow families evaluated in the +shared-workflow roadmap. Completion does not mean every upstream workflow is copied; +an explicit decision to keep a workflow local is a valid outcome when its behavior is +consumer-specific or credential-sensitive. + +| Family | Decision | Status | +| --- | --- | --- | +| REUSE | Organization template | Cataloged and validated in consumers | +| info.xml lint | Organization template | Cataloged and validated | +| PHP lint / coding standards | Organization templates | Cataloged and validated | +| Psalm static analysis | Organization template | Cataloged and validated | +| ESLint / Stylelint / TypeScript | Organization templates | Cataloged; TypeScript validated in LibreSign | +| Node tests | Organization template | Cataloged and validated in LibreSign | +| Frontend build | `npm-build.yml` organization template | Cataloged and validated | +| Conventional Commits | Organization template | Cataloged and validated | +| OpenAPI | Organization template | Cataloged and validated | +| PHPUnit database workflows | Keep consumer-local for now | LibreSign copies materially diverge from current upstream through app-specific system packages, submodules and coverage behavior; centralizing them would create large product-specific patches | +| Dependency approval / auto-merge | Keep local until policy-compatible | Governed by `docs/dependency-update-policy.md` | +| npm audit remediation | Keep local | Credential and merge policy are repository-specific | +| App Store build/publish | Organization template available | Cataloged; installation remains opt-in because credentials and release policy are consumer-owned | +| Other release automation | Keep consumer-local unless generic | Requires an explicit credential and release contract before cataloging | + +## Decision rule + +A workflow is promoted to the organization catalog when its upstream behavior can be +preserved with small organization-level adaptations and at least one consumer can use it +without product-specific logic. + +A workflow stays local when centralization would require substantial patches for one +product, personal/bot PAT assumptions, or product-specific release semantics. + +These decisions should be revisited when upstream or consumer requirements materially +change; they are not an instruction to force all future workflows into either model. diff --git a/scripts/check_workflow_policy.py b/scripts/check_workflow_policy.py new file mode 100644 index 0000000..6f54727 --- /dev/null +++ b/scripts/check_workflow_policy.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*([^@\s]+)@([^\s#]+)") +FULL_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") + + +def workflow_files(root: Path) -> list[Path]: + return sorted( + path + for path in root.rglob("*") + if path.is_file() and path.suffix in {".yml", ".yaml"} + ) + + +def check_file(path: Path) -> list[str]: + lines = path.read_text(encoding="utf-8").splitlines() + findings: list[str] = [] + + for index, line in enumerate(lines): + line_number = index + 1 + + if re.search(r"permissions:\s*write-all\b", line): + findings.append(f"{path}:{line_number}: permissions: write-all is forbidden") + + match = USES_RE.match(line) + if not match: + continue + + action, revision = match.groups() + if action.startswith("./"): + continue + + if not FULL_SHA_RE.fullmatch(revision): + findings.append( + f"{path}:{line_number}: {action} must be pinned to a full 40-character commit SHA" + ) + + if action == "actions/checkout": + block = "\n".join(lines[index + 1 : index + 8]) + if not re.search(r"persist-credentials:\s*false\b", block): + findings.append( + f"{path}:{line_number}: actions/checkout must set persist-credentials: false" + ) + + return findings + + +def check_roots(roots: list[Path]) -> list[str]: + findings: list[str] = [] + for root in roots: + if not root.exists(): + continue + for path in workflow_files(root): + findings.extend(check_file(path)) + return findings + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "roots", + nargs="*", + type=Path, + default=[Path("workflow-templates"), Path(".github/workflows")], + ) + args = parser.parse_args() + + findings = check_roots(args.roots) + if findings: + print("\n".join(findings)) + return 1 + + print("Workflow policy checks passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_workflow_policy.py b/tests/test_workflow_policy.py new file mode 100644 index 0000000..90c8372 --- /dev/null +++ b/tests/test_workflow_policy.py @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import tempfile +import unittest +from pathlib import Path + +from scripts.check_workflow_policy import check_file + + +class WorkflowPolicyTest(unittest.TestCase): + def write(self, content: str) -> Path: + temporary = tempfile.NamedTemporaryFile(suffix=".yml", delete=False) + path = Path(temporary.name) + temporary.close() + path.write_text(content, encoding="utf-8") + self.addCleanup(path.unlink) + return path + + def test_accepts_pinned_action_and_hardened_checkout(self) -> None: + path = self.write( + """ +permissions: + contents: read +steps: + - uses: actions/checkout@0123456789012345678901234567890123456789 + with: + persist-credentials: false + - uses: example/action@abcdefabcdefabcdefabcdefabcdefabcdefabcd +""" + ) + self.assertEqual(check_file(path), []) + + def test_rejects_mutable_action_revision(self) -> None: + path = self.write("steps:\n - uses: example/action@v2\n") + findings = check_file(path) + self.assertEqual(len(findings), 1) + self.assertIn("full 40-character commit SHA", findings[0]) + + def test_rejects_checkout_with_persisted_credentials(self) -> None: + path = self.write( + """ +steps: + - uses: actions/checkout@0123456789012345678901234567890123456789 +""" + ) + findings = check_file(path) + self.assertEqual(len(findings), 1) + self.assertIn("persist-credentials: false", findings[0]) + + def test_rejects_write_all(self) -> None: + path = self.write("permissions: write-all\n") + findings = check_file(path) + self.assertEqual(len(findings), 1) + self.assertIn("write-all", findings[0]) + + def test_allows_local_action(self) -> None: + path = self.write("steps:\n - uses: ./actions/sync-workflows\n") + self.assertEqual(check_file(path), []) + + +if __name__ == "__main__": + unittest.main()