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
80 changes: 80 additions & 0 deletions .github/workflows/github-governance-auto-onboard.yml
Original file line number Diff line number Diff line change
@@ -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'
55 changes: 55 additions & 0 deletions docs/repository-governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
39 changes: 39 additions & 0 deletions governance.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -198,6 +232,11 @@
}
],
"repositories": {
"LibreCodeCoop/agent-skills": {
"policies": [
"required-ci-gate-active"
]
},
"LibreCodeCoop/github-governance": {
"policies": [
"governance-ci"
Expand Down
160 changes: 160 additions & 0 deletions scripts/discover_unprotected_repositories.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading