diff --git a/.github/workflows/github-governance-auto-onboard.yml b/.github/workflows/github-governance-auto-onboard.yml new file mode 100644 index 0000000..80df3e9 --- /dev/null +++ b/.github/workflows/github-governance-auto-onboard.yml @@ -0,0 +1,80 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Automatic repository baseline protection + +on: + # GitHub Free has no organization-level ruleset for this organization. + # Scan periodically; the delay is not an immediate creation-time guarantee. + schedule: + - cron: '7,37 * * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: librecode-repository-baseline-onboarding + cancel-in-progress: false + +jobs: + discover: + name: Discover public repositories missing a ruleset + if: vars.AUTO_RULESET_ONBOARDING_ENABLED == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + repositories: ${{ steps.discover.outputs.repositories }} + steps: + - name: Checkout trusted policy from main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: main + + - name: Discover candidates (read only) + id: discover + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/discover_unprotected_repositories.py --organization LibreCodeCoop --output "$GITHUB_OUTPUT" + + onboard: + name: Install missing baseline / ${{ matrix.repository }} + needs: discover + if: needs.discover.result == 'success' && needs.discover.outputs.repositories != '[]' + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: ruleset-auto-onboard + strategy: + fail-fast: false + max-parallel: 4 + matrix: + repository: ${{ fromJSON(needs.discover.outputs.repositories) }} + permissions: + contents: read + steps: + - name: Checkout trusted policy from main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: main + + - name: Mint repository-scoped GitHub App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.AUTO_RULESET_APP_CLIENT_ID }} + private-key: ${{ secrets.AUTO_RULESET_APP_PRIVATE_KEY }} + owner: LibreCodeCoop # zizmor: ignore[github-app] tokens must be scoped to the requested repository + repositories: ${{ matrix.repository }} + permission-administration: write + + - name: Create missing default ruleset only + uses: LibreCodeCoop/github-governance@76fcdc8ae250c81aeb055533c2741385ac9f0b7b # pinned reviewed baseline-only engine + with: + token: ${{ steps.app-token.outputs.token }} + contents-token: ${{ github.token }} + repository: LibreCodeCoop/${{ matrix.repository }} + config: ${{ github.workspace }}/governance.config.json + apply: 'true' + onboard-only: 'true' diff --git a/docs/repository-governance.md b/docs/repository-governance.md index 1929828..b5794d0 100644 --- a/docs/repository-governance.md +++ b/docs/repository-governance.md @@ -33,6 +33,15 @@ consumers opt in to shared workflows through reviewable pull requests. enforcement remains **disabled** until each consumer produces and verifies that aggregate check. +- `required-ci-gate-active` shares the **same `Require CI` ruleset contract** + as the staged `required-ci-gate`, with only enforcement enabled. It is + selected for `LibreCodeCoop/agent-skills`, whose `ci-required` producer + passed on its merged PR #1. The default `protected-branches` ruleset is + inherited automatically; no project-specific copy is required. Other staged + repositories keep their disabled CI rule until individually verified. This + JSON change does **not** apply CI rules remotely: a separate reviewed plan + and authorized Apply is still required. + The configuration expresses desired policy, not proof that a ruleset was already applied on GitHub. An organization-wide rollout is not automatic: the consumer must have the matching check before protection is enabled. @@ -123,3 +132,49 @@ do not remove that protection solely to avoid approvals. The [dependency auto-merge policy](dependency-update-policy.md) covers a different workflow and must not be confused with ruleset bypass. + +## Automatic baseline onboarding for new public repositories + +The [automatic onboarding workflow](../.github/workflows/github-governance-auto-onboard.yml) +checks for new eligible **public** repositories twice an hour. Its discovery +uses a read-only token, validates repository names and existence of the default +branch, and selects only repositories that currently have **no rulesets**. +Existing custom rulesets are treated as manual exceptions and are never +rewritten or deleted. Once a ruleset already exists, automatic provisioning +leaves it untouched, even if it differs from the institutional default. + +For each candidate, a short-lived GitHub App token is scoped to **one repo**. +The pinned shared engine runs `onboard-only=true`, checking live rulesets and +classic branch protection again immediately before creating the missing +institutional `protected-branches` rule. Any ambiguity, API error, missing +permission or manual protection fails closed for review. This workflow **does +not** change existing rules, enable CI checks, update metadata, remove bypasses +or perform full reconciliation. + +**One-time configuration before activation:** + +1. Configure a dedicated `ruleset-auto-onboard` Actions environment with a + deployment restriction to protected `main`. It has no human approval + requirement, unlike `ruleset-sync`, because onboarding is scheduled. +2. Set `AUTO_RULESET_APP_CLIENT_ID` as an environment variable and + `AUTO_RULESET_APP_PRIVATE_KEY` as an environment secret. The dedicated + GitHub App installation must have Administration (write) only on the managed public repositories. A + separate short-lived, read-only `github.token` supplies public content + classification, without granting the App Contents (read); each App token + is restricted by the + job to the selected repository. Do not relocate or expose the privileged + `ruleset-sync` credentials. +3. After an independent review of this credential boundary and `main` + protection, set the **repository variable** + `AUTO_RULESET_ONBOARDING_ENABLED=true`. Until then, the new workflow is + intentionally disabled. +4. Run the workflow manually once, inspect the resulting scope and GitHub + rulesets, then let the schedule handle subsequent new public repositories. + +GitHub scheduled workflows are best effort. **This is eventual onboarding, not +an instantaneous guarantee at repository creation time.** Empty repositories +are skipped until their default branch is initialized. Private repositories +remain outside the GitHub Free-supported ruleset scope. A local exception may +add restrictions, but cannot silently weaken an existing institutional +ruleset. Policy changes and exceptional reconciliation of existing rules +continue to require a reviewed `ruleset-sync` Apply. diff --git a/governance.config.json b/governance.config.json index 1f2d12a..bac5787 100644 --- a/governance.config.json +++ b/governance.config.json @@ -135,6 +135,40 @@ } ] }, + "required-ci-gate-active": { + "name": "Require CI", + "target": "branch", + "enforcement": "active", + "bypass_actors": [ + { + "actor_id": 0, + "actor_type": "OrganizationAdmin", + "bypass_mode": "pull_request" + } + ], + "conditions": { + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "ci-required" + } + ], + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false + } + } + ] + }, "organization-config-ci": { "name": "Require organization configuration CI", "target": "branch", @@ -198,6 +232,11 @@ } ], "repositories": { + "LibreCodeCoop/agent-skills": { + "policies": [ + "required-ci-gate-active" + ] + }, "LibreCodeCoop/github-governance": { "policies": [ "governance-ci" diff --git a/scripts/discover_unprotected_repositories.py b/scripts/discover_unprotected_repositories.py new file mode 100644 index 0000000..e5427c8 --- /dev/null +++ b/scripts/discover_unprotected_repositories.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Find safe public-repository candidates for baseline-only governance onboarding. + +Discovery is read-only. The privileged onboarding action must still verify +classic protection and all rulesets immediately before creating any rule. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from urllib.error import HTTPError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen + +REPOSITORY_NAME = re.compile(r"[A-Za-z0-9_.-]+") +MAX_MATRIX_SIZE = 200 + + +class GitHubAPI: + def __init__(self, token: str, base_url: str = "https://api.github.com") -> None: + if not token: + raise ValueError("GitHub read-only token is required") + self._token = token + self._base_url = base_url.rstrip("/") + + def get(self, path: str) -> object: + request = Request( + f"{self._base_url}{path}", + headers={ + "Authorization": f"Bearer {self._token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "librecode-governance-onboarding", + }, + ) + with urlopen(request, timeout=20) as response: + return json.load(response) + + def list_public_repositories(self, organization: str) -> list[dict[str, object]]: + repositories: list[dict[str, object]] = [] + page = 1 + while True: + query = urlencode({"type": "public", "per_page": 100, "page": page}) + result = self.get(f"/orgs/{quote(organization, safe='')}/repos?{query}") + if not isinstance(result, list): + raise ValueError("Unexpected GitHub repository listing") + repositories.extend(result) + if len(result) < 100: + return repositories + page += 1 + + def rulesets(self, organization: str, repository: str) -> list[object]: + result = self.get( + f"/repos/{quote(organization, safe='')}/{quote(repository, safe='')}" + "/rulesets?includes_parents=true" + ) + if not isinstance(result, list): + raise ValueError(f"Unexpected GitHub rulesets response for {repository}") + return result + + def branch_exists(self, organization: str, repository: str, branch: str) -> bool: + path = ( + f"/repos/{quote(organization, safe='')}/{quote(repository, safe='')}" + f"/branches/{quote(branch, safe='')}" + ) + try: + self.get(path) + except HTTPError as error: + if error.code == 404: + return False + raise + return True + + +@dataclass(frozen=True) +class Discovery: + candidates: list[str] + exceptions: list[str] + uninitialized: list[str] + + +def discover(api: GitHubAPI, organization: str) -> Discovery: + candidates: list[str] = [] + exceptions: list[str] = [] + uninitialized: list[str] = [] + for repo in api.list_public_repositories(organization): + if not isinstance(repo, dict): + raise ValueError("Unexpected repository metadata") + if repo.get("archived") or repo.get("private") or repo.get("visibility") != "public": + continue + owner = repo.get("owner") + if not isinstance(owner, dict) or owner.get("login") != organization: + raise ValueError("Unexpected repository owner in GitHub response") + name = repo.get("name") + if not isinstance(name, str) or not REPOSITORY_NAME.fullmatch(name): + raise ValueError(f"Invalid GitHub repository name: {name!r}") + branch = repo.get("default_branch") + if not isinstance(branch, str) or not branch: + uninitialized.append(name) + continue + # Existing rulesets (including custom ones) are explicit exceptions: + # never auto-modify these repositories, even if the common rule differs. + if api.rulesets(organization, name): + exceptions.append(name) + continue + if not api.branch_exists(organization, name, branch): + uninitialized.append(name) + continue + candidates.append(name) + candidates.sort() + if len(candidates) > MAX_MATRIX_SIZE: + raise ValueError("Too many candidates for one GitHub Actions matrix; review required") + return Discovery(candidates, sorted(exceptions), sorted(uninitialized)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--organization", required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.organization != "LibreCodeCoop": + parser.error("This caller may only manage LibreCodeCoop") + try: + result = discover(GitHubAPI(os.environ.get("GH_TOKEN", "")), args.organization) + except (HTTPError, OSError, ValueError) as error: + print(f"::error::Read-only onboarding discovery failed: {error}", file=sys.stderr) + return 1 + + payload = json.dumps(result.candidates, separators=(",", ":")) + with args.output.open("a", encoding="utf-8") as output: + output.write(f"repositories={payload}\n") + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with Path(summary).open("a", encoding="utf-8") as output: + output.write( + "## Automatic baseline protection discovery\n\n" + f"- Missing protection candidates: {len(result.candidates)}\n" + f"- Existing custom/institutional rulesets left untouched: {len(result.exceptions)}\n" + f"- Not initialized: {len(result.uninitialized)}\n" + "- Each candidate is rechecked by the privileged engine before mutation.\n" + ) + print( + f"Candidates: {len(result.candidates)}; " + f"existing rulesets: {len(result.exceptions)}; " + f"uninitialized: {len(result.uninitialized)}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_governance_auto_onboarding.py b/tests/test_governance_auto_onboarding.py new file mode 100644 index 0000000..54b4248 --- /dev/null +++ b/tests/test_governance_auto_onboarding.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +import importlib.util +import tempfile +import unittest +from pathlib import Path +from urllib.error import HTTPError + + +ROOT = Path(__file__).resolve().parents[1] +DISCOVERY = ROOT / "scripts/discover_unprotected_repositories.py" +WORKFLOW = ROOT / ".github/workflows/github-governance-auto-onboard.yml" + +spec = importlib.util.spec_from_file_location("baseline_discovery", DISCOVERY) +module = importlib.util.module_from_spec(spec) +import sys +sys.modules[spec.name] = module +spec.loader.exec_module(module) + + +def repo(name, *, visibility="public", archived=False, branch="main"): + return { + "owner": {"login": "LibreCodeCoop"}, + "name": name, + "visibility": visibility, + "archived": archived, + "private": visibility == "private", + "default_branch": branch, + } + + +class FakeAPI: + def __init__(self, repositories, rulesets=None, missing_branches=None): + self.repositories = repositories + self.existing = rulesets or {} + self.missing = missing_branches or set() + self.examined = [] + + def list_public_repositories(self, organization): + assert organization == "LibreCodeCoop" + return self.repositories + + def rulesets(self, organization, repository): + self.examined.append(repository) + return self.existing.get(repository, []) + + def branch_exists(self, organization, repository, branch): + return repository not in self.missing + + +class BaselineDiscoveryTest(unittest.TestCase): + def test_candidate_is_only_initialized_public_repo_without_any_rulesets(self): + api = FakeAPI( + [ + repo("z-new"), + repo("manual", branch="main"), + repo("inherited"), + repo("private", visibility="private"), + repo("archived", archived=True), + repo("empty", branch=None), + repo("uninitialized"), + repo("a-new"), + ], + rulesets={"manual": [{"name": "Manual exception"}], + "inherited": [{"name": "Protect default and stable branches"}]}, + missing_branches={"uninitialized"}, + ) + result = module.discover(api, "LibreCodeCoop") + self.assertEqual(result.candidates, ["a-new", "z-new"]) + self.assertEqual(result.exceptions, ["inherited", "manual"]) + self.assertEqual(result.uninitialized, ["empty", "uninitialized"]) + self.assertNotIn("private", api.examined) + self.assertNotIn("archived", api.examined) + + def test_invalid_repository_name_fails_closed(self): + api = FakeAPI([repo("evil/path")]) + with self.assertRaisesRegex(ValueError, "Invalid GitHub repository"): + module.discover(api, "LibreCodeCoop") + + def test_mismatched_owner_fails_closed(self): + payload = repo("example") + payload["owner"] = {"login": "attacker"} + with self.assertRaisesRegex(ValueError, "Unexpected repository owner"): + module.discover(FakeAPI([payload]), "LibreCodeCoop") + + def test_ruleset_query_error_must_not_be_treated_as_unprotected(self): + class BrokenAPI(FakeAPI): + def rulesets(self, organization, repository): + raise HTTPError("https://api.github.com", 403, "Forbidden", None, None) + + with self.assertRaises(HTTPError): + module.discover(BrokenAPI([repo("unverified")]), "LibreCodeCoop") + + def test_matrix_size_is_bounded(self): + api = FakeAPI([repo(f"new-{i}") for i in range(201)]) + with self.assertRaisesRegex(ValueError, "Too many candidates"): + module.discover(api, "LibreCodeCoop") + + def test_workflow_requires_opt_in_and_never_performs_bulk_reconciliation(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertIn("AUTO_RULESET_ONBOARDING_ENABLED == 'true'", workflow) + self.assertIn("environment: ruleset-auto-onboard", workflow) + self.assertIn("onboard-only: 'true'", workflow) + self.assertIn("permission-administration: write", workflow) + self.assertNotIn("permission-contents: read", workflow) + self.assertIn("contents-token: ${{ github.token }}", workflow) + self.assertIn("repositories: ${{ matrix.repository }}", workflow) + self.assertIn("max-parallel: 4", workflow) + self.assertNotIn("pull_request_target:", workflow) + self.assertNotIn("organization: LibreCodeCoop", workflow.split("Create missing default ruleset only")[1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_governance_ci_gate_contract.py b/tests/test_governance_ci_gate_contract.py index ab8573c..1a457bc 100644 --- a/tests/test_governance_ci_gate_contract.py +++ b/tests/test_governance_ci_gate_contract.py @@ -42,6 +42,22 @@ def test_selected_repositories_use_same_policy(self) -> None: with self.subTest(repository=name): self.assertEqual(repositories[name]["policies"], ["required-ci-gate"]) + def test_active_ci_gate_is_reusable_and_isolated_from_staged_consumers(self) -> None: + self.assertEqual( + self.config["repositories"]["LibreCodeCoop/agent-skills"]["policies"], + ["required-ci-gate-active"], + ) + policies = self.config["policies"] + active = policies["required-ci-gate-active"] + staged = policies["required-ci-gate"] + self.assertEqual(active, {**staged, "enforcement": "active"}) + self.assertEqual(active["name"], staged["name"]) + self.assertEqual(active["rules"][0]["parameters"]["required_status_checks"], [ + {"context": "ci-required"}, + ]) + self.assertEqual(staged["enforcement"], "disabled") + self.assertEqual(self.config["defaults"]["policies"], ["protected-branches"]) + def test_review_and_existing_governance_checks_stay_separate(self) -> None: self.assertEqual(self.config["defaults"]["policies"], ["protected-branches"]) policies = self.config["policies"] diff --git a/tests/test_nextcloud_translation_bot_contract.py b/tests/test_nextcloud_translation_bot_contract.py new file mode 100644 index 0000000..e89a990 --- /dev/null +++ b/tests/test_nextcloud_translation_bot_contract.py @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Regression contract for Nextcloud translation-bot direct pushes. + +Historic regressions: LibreSign/libresign#8399/#9172 and +LibreCodeCoop/profile_fields#148 / extract#148. +""" + +import json +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +NEXTCLOUD_BOT = { + "actor_id": 20296731, + "actor_type": "User", + "bypass_mode": "always", +} + + +class NextcloudTranslationBotContractTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.config = json.loads((ROOT / "governance.config.json").read_text(encoding="utf-8")) + + def test_all_nextcloud_apps_inherit_default_branch_protection(self): + config = self.config + self.assertIn("protected-branches", config["defaults"]["policies"]) + protected = config["policies"]["protected-branches"] + self.assertEqual(protected["enforcement"], "active") + self.assertEqual( + protected["conditions"]["ref_name"]["include"], + ["~DEFAULT_BRANCH", "refs/heads/stable*"], + ) + self.assertIn("pull_request", [rule["type"] for rule in protected["rules"]]) + self.assertNotIn(NEXTCLOUD_BOT, protected["bypass_actors"]) + + def test_nextcloud_app_marker_adds_direct_push_bypass(self): + conditions = [ + condition for condition in self.config["conditions"] + if condition["when"].get("file_exists") == "appinfo/info.xml" + ] + self.assertEqual(len(conditions), 1, "Nextcloud marker must have exactly one policy condition") + self.assertIn( + NEXTCLOUD_BOT, + conditions[0]["add_bypass_actors"]["protected-branches"], + ) + # A condition must not append a separate required-check ruleset that + # would also block the translation bot, despite the branch bypass. + self.assertFalse(conditions[0].get("policies")) + self.assertFalse(conditions[0].get("rulesets")) + + def test_default_ruleset_does_not_introduce_separate_dco_or_ci_gate(self): + protected = self.config["policies"]["protected-branches"] + self.assertFalse( + any(rule["type"] == "required_status_checks" for rule in protected["rules"]), + "Nextcloud bot must not be blocked by mandatory checks in the common baseline", + ) + self.assertNotIn("required-ci-gate-active", self.config["defaults"]["policies"]) + self.assertNotIn("required-ci-gate", self.config["defaults"]["policies"]) + + def test_new_ci_policy_applies_only_to_homologated_agent_skills(self): + assignments = self.config["repositories"] + selected = [ + repo for repo, config in assignments.items() + if "required-ci-gate-active" in config.get("policies", []) + ] + self.assertIn("LibreCodeCoop/agent-skills", selected) + # Other repositories can adopt the gate only after their own CI is + # verified; never activate it implicitly for historical bot targets. + for repo in ("LibreCodeCoop/profile_fields", "LibreCodeCoop/extract"): + self.assertNotIn("required-ci-gate-active", assignments.get(repo, {}).get("policies", [])) + + def test_provisioner_is_creation_only_and_restricted_to_librecode(self): + workflow = ( + ROOT / ".github/workflows/github-governance-auto-onboard.yml" + ).read_text(encoding="utf-8") + self.assertIn("onboard-only: 'true'", workflow) + self.assertIn("AUTO_RULESET_ONBOARDING_ENABLED == 'true'", workflow) + self.assertIn("repositories: ${{ matrix.repository }}", workflow) + self.assertIn("repository: LibreCodeCoop/${{ matrix.repository }}", workflow) + self.assertNotIn("repository: LibreSign/", workflow) + + +if __name__ == "__main__": + unittest.main()