diff --git a/.github/scripts/discover.py b/.github/scripts/discover.py new file mode 100755 index 0000000..68d6b5e --- /dev/null +++ b/.github/scripts/discover.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +# /.github/scripts/discover.py + +"""Discover existing GitHub objects; write local OpenTofu and file-sync inputs.""" + +import argparse +import json +import os +from pathlib import Path, PurePosixPath +import re +import subprocess +import sys +import tempfile + + +ORGANIZATION = "pheaz" +SOURCE_REPOSITORY = f"{ORGANIZATION}/.github" +ROOT = Path(__file__).resolve().parents[2] + + +class DiscoveryError(Exception): + """A failed or ambiguous read that must prevent configuration generation.""" + + +class GitHub: + def read(self, route, *, paginate=False, missing_ok=False): + arguments = ["gh", "api", "--method", "GET"] + if paginate: + arguments.extend(["--paginate", "--slurp"]) + result = subprocess.run( + [*arguments, route], capture_output=True, text=True, check=False + ) + if result.returncode != 0: + # Distinguish a missing path from authentication/rate-limit failures. + status = re.search(r"\(HTTP (\d{3})\)", result.stderr) + if missing_ok and status and status.group(1) == "404": + return None + raise DiscoveryError(f"GitHub read failed for {route}: {result.stderr.strip()}") + try: + return json.loads(result.stdout) + except json.JSONDecodeError as error: + raise DiscoveryError(f"Invalid GitHub JSON for {route}") from error + + +def load_files(root): + config = json.loads((root / ".github/sync-files.json").read_text(encoding="utf-8")) + if not isinstance(config, dict) or set(config) != {"agents", "workflows"}: + raise DiscoveryError("File policy must contain exactly agents and workflows groups") + for group, files in config.items(): + if not isinstance(files, list) or not files: + raise DiscoveryError(f"File policy group {group} must be a nonempty list") + for file in files: + if not isinstance(file, dict) or set(file) != {"source", "dest"}: + raise DiscoveryError("File policy entries require only source and dest") + for value in file.values(): + if not isinstance(value, str) or not value: + raise DiscoveryError("File paths must be nonempty strings") + path = PurePosixPath(value) + if path.is_absolute() or ".." in path.parts: + raise DiscoveryError(f"File path must remain inside its repository: {value}") + if not (root / file["source"]).is_file(): + raise DiscoveryError(f"Canonical source does not exist: {file['source']}") + return config + + +def mask_private_repositories(repositories): + if os.environ.get("GITHUB_ACTIONS") != "true": + return + # Public source-repository logs must not disclose private target names. + for repository in repositories: + if repository["visibility"] != "public": + for value in (repository["full_name"], repository["name"]): + print(f"::add-mask::{value}", flush=True) + + +def discover(github, files, sync_repositories=""): + pages = github.read( + f"orgs/{ORGANIZATION}/repos?type=all&per_page=100", paginate=True + ) + if not isinstance(pages, list) or not all(isinstance(page, list) for page in pages): + raise DiscoveryError("Repository discovery did not return paginated lists") + repositories = [repository for page in pages for repository in page] + if not repositories: + raise DiscoveryError("Empty organization inventory; refusing to generate configuration") + seen = set() + for repository in repositories: + if not isinstance(repository, dict): + raise DiscoveryError("Invalid repository inventory entry") + name = repository.get("name") + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,100}", name): + raise DiscoveryError("Invalid repository name in inventory") + if repository.get("full_name") != f"{ORGANIZATION}/{name}" or name in seen: + raise DiscoveryError("Duplicate repository or unexpected organization in inventory") + seen.add(name) + if type(repository.get("id")) is not int or repository["id"] <= 0: + raise DiscoveryError("Repository ID must be a positive integer") + if any(type(repository.get(key)) is not bool for key in ("fork", "archived")): + raise DiscoveryError("Repository fork/archive flags must be booleans") + if repository.get("visibility") not in {"public", "private", "internal"}: + raise DiscoveryError("Repository visibility is missing or invalid") + branch = repository.get("default_branch") + if branch is not None and not isinstance(branch, str): + raise DiscoveryError("Invalid default branch in repository inventory") + if SOURCE_REPOSITORY not in {repository["full_name"] for repository in repositories}: + raise DiscoveryError("Policy repository is missing; verify organization read access") + mask_private_repositories(repositories) + + inventory = {} + sync = {} + requested = {name.strip() for name in sync_repositories.split(",") if name.strip()} + eligible = set() + for repository in sorted(repositories, key=lambda item: item["name"]): + if repository["fork"]: + continue + name = repository["name"] + full_name = repository["full_name"] + inventory[name] = {"id": repository["id"], "archived": repository["archived"]} + if repository["archived"] or not repository.get("default_branch"): + continue + if full_name == SOURCE_REPOSITORY: + continue + eligible.add(full_name) + if requested and full_name not in requested: + continue + # Do not pin a branch name: repo-files-sync resolves the current default. + mappings = list(files["workflows"]) + directory = github.read(f"repos/{full_name}/contents/.github", missing_ok=True) + if isinstance(directory, list): + mappings = [*files["agents"], *mappings] + elif directory is not None and not isinstance(directory, dict): + raise DiscoveryError(f"Unexpected .github response for {full_name}") + sync[full_name] = mappings + if not inventory: + raise DiscoveryError("No non-fork repositories found") + if requested - eligible: + raise DiscoveryError("Requested sync repositories are not eligible organization targets") + return {"repositories": inventory}, sync + + +def write_if_changed(path, content): + path.parent.mkdir(parents=True, exist_ok=True) + if path.is_file() and path.read_text(encoding="utf-8") == content: + path.chmod(0o600) + return + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=path.parent, delete=False + ) as temporary: + temporary.write(content) + temporary_path = Path(temporary.name) + try: + temporary_path.replace(path) + finally: + temporary_path.unlink(missing_ok=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--sync-repositories", default="", help="Comma-separated owner/name pilot targets; empty means all" + ) + parser.add_argument("--github-output", type=Path, help="GitHub Actions step output file") + arguments = parser.parse_args() + try: + inventory, sync = discover(GitHub(), load_files(ROOT), arguments.sync_repositories) + write_if_changed( + ROOT / "tofu/repositories.auto.tfvars.json", json.dumps(inventory, indent=2) + "\n" + ) + # JSON is valid YAML and avoids a runtime YAML package dependency. + write_if_changed( + ROOT / ".github/sync.yml", + "# /.github/sync.yml\n# Generated by discover.py; MUST NOT be committed.\n" + + json.dumps(sync, indent=2) + "\n", + ) + if arguments.github_output: + names = sorted({".github", *(name.split("/", 1)[1] for name in sync)}) + with arguments.github_output.open("a", encoding="utf-8") as output: + output.write(f"has_targets={'true' if sync else 'false'}\n") + output.write(f"token_repositories={','.join(names)}\n") + print( + f"Discovered {len(inventory['repositories'])} non-fork repositories; " + f"generated {len(sync)} file-sync targets." + ) + return 0 + except (DiscoveryError, OSError, ValueError) as error: + print(str(error), file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/sync-files.json b/.github/sync-files.json new file mode 100644 index 0000000..a304cf1 --- /dev/null +++ b/.github/sync-files.json @@ -0,0 +1,22 @@ +{ + "agents": [ + { + "source": ".github/AGENTS.md", + "dest": ".github/AGENTS.md" + }, + { + "source": ".github/actions/AGENTS.md", + "dest": ".github/actions/AGENTS.md" + }, + { + "source": ".github/workflows/AGENTS.md", + "dest": ".github/workflows/AGENTS.md" + } + ], + "workflows": [ + { + "source": ".github/workflows/lint-pr.yml", + "dest": ".github/workflows/lint-pr.yml" + } + ] +} diff --git a/.github/tests/test_discover.py b/.github/tests/test_discover.py new file mode 100644 index 0000000..37e9926 --- /dev/null +++ b/.github/tests/test_discover.py @@ -0,0 +1,148 @@ +# /.github/tests/test_discover.py + +import contextlib +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("discovery", ROOT / ".github/scripts/discover.py") +discovery = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(discovery) + + +def repository(name, *, fork=False, archived=False, branch="main", visibility="public"): + return { + "name": name, + "full_name": f"pheaz/{name}", + "id": 100 + sum(map(ord, name)), + "fork": fork, + "archived": archived, + "default_branch": branch, + "visibility": visibility, + } + + +class FakeGitHub: + def __init__(self, pages, directories=None): + self.pages = pages + self.directories = directories or {} + self.calls = [] + + def read(self, route, *, paginate=False, missing_ok=False): + self.calls.append((route, paginate, missing_ok)) + if route.startswith("orgs/"): + return self.pages + response = self.directories[route.split("/")[2]] + if isinstance(response, Exception): + raise response + return response + + +class DiscoveryTests(unittest.TestCase): + def setUp(self): + self.files = discovery.load_files(ROOT) + self.source = repository(".github") + + def test_preserves_different_sync_scopes_across_pages(self): + github = FakeGitHub( + [ + [self.source, repository("existing"), repository("fork", fork=True)], + [repository("missing"), repository("file"), repository("archived", archived=True), repository("empty", branch=None)], + ], + {"existing": [], "missing": None, "file": {"type": "file"}}, + ) + inventory, sync = discovery.discover(github, self.files) + self.assertEqual(set(inventory["repositories"]), {".github", "existing", "missing", "file", "archived", "empty"}) + self.assertTrue(inventory["repositories"]["archived"]["archived"]) + self.assertEqual(set(sync), {"pheaz/existing", "pheaz/missing", "pheaz/file"}) + self.assertEqual( + [file["dest"] for file in sync["pheaz/existing"]], + [".github/AGENTS.md", ".github/actions/AGENTS.md", ".github/workflows/AGENTS.md", ".github/workflows/lint-pr.yml"], + ) + self.assertEqual(sync["pheaz/missing"], self.files["workflows"]) + self.assertEqual(sync["pheaz/file"], self.files["workflows"]) + self.assertTrue(github.calls[0][1]) + + def test_pilot_filters_only_sync_not_iac_inventory(self): + github = FakeGitHub([[self.source, repository("first"), repository("second")]], {"first": []}) + inventory, sync = discovery.discover(github, self.files, "pheaz/first") + self.assertEqual(set(inventory["repositories"]), {".github", "first", "second"}) + self.assertEqual(set(sync), {"pheaz/first"}) + self.assertEqual(len(github.calls), 2) + + def test_unknown_archived_fork_and_source_pilot_targets_are_rejected(self): + pages = [[self.source, repository("archive", archived=True), repository("fork", fork=True)]] + for target in ["pheaz/typo", "pheaz/archive", "pheaz/fork", "pheaz/.github"]: + with self.subTest(target=target), self.assertRaises(discovery.DiscoveryError): + discovery.discover(FakeGitHub(pages), self.files, target) + + def test_permission_error_is_not_treated_as_a_missing_directory(self): + github = FakeGitHub([[self.source, repository("restricted")]], {"restricted": discovery.DiscoveryError("HTTP 403")}) + with self.assertRaisesRegex(discovery.DiscoveryError, "403"): + discovery.discover(github, self.files) + + def test_empty_missing_source_duplicate_and_malformed_inventories_fail(self): + cases = [[], [[]], [[repository("other")]], [[self.source, self.source]]] + for key, value in [("id", True), ("fork", None), ("visibility", None), ("full_name", "other/.github")]: + cases.append([[{**self.source, key: value}]]) + for pages in cases: + with self.subTest(pages=pages), self.assertRaises(discovery.DiscoveryError): + discovery.discover(FakeGitHub(pages), self.files) + + def test_private_names_are_masked_before_public_action_logs(self): + output = io.StringIO() + with patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}), contextlib.redirect_stdout(output): + discovery.discover( + FakeGitHub([[self.source, repository("private-target", visibility="private")]], {"private-target": []}), + self.files, + ) + self.assertEqual(output.getvalue(), "::add-mask::pheaz/private-target\n::add-mask::private-target\n") + + def test_output_is_idempotent_and_private(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "nested/sync.yml" + discovery.write_if_changed(path, "first\n") + timestamp = path.stat().st_mtime_ns + discovery.write_if_changed(path, "first\n") + self.assertEqual(path.stat().st_mtime_ns, timestamp) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + discovery.write_if_changed(path, "second\n") + self.assertEqual(path.read_text(), "second\n") + + def test_invalid_source_paths_fail_before_discovery(self): + for value in ["../outside", "/outside", "missing"]: + with self.subTest(value=value), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / ".github").mkdir() + config = {"agents": [{"source": value, "dest": "AGENTS.md"}], "workflows": self.files["workflows"]} + (root / ".github/sync-files.json").write_text(json.dumps(config)) + with self.assertRaises(discovery.DiscoveryError): + discovery.load_files(root) + + def test_cli_reads_use_get_and_slurp_all_pages(self): + result = subprocess.CompletedProcess([], 0, stdout="[[]]", stderr="") + with patch.object(discovery.subprocess, "run", return_value=result) as run: + self.assertEqual(discovery.GitHub().read("orgs/pheaz/repos", paginate=True), [[]]) + self.assertEqual(run.call_args.args[0], ["gh", "api", "--method", "GET", "--paginate", "--slurp", "orgs/pheaz/repos"]) + + def test_cli_only_accepts_404_as_a_missing_path(self): + for status in [401, 403, 404, 429, 500]: + result = subprocess.CompletedProcess([], 1, stdout="", stderr=f"gh: failed (HTTP {status})") + with self.subTest(status=status), patch.object(discovery.subprocess, "run", return_value=result): + if status == 404: + self.assertIsNone(discovery.GitHub().read("repos/pheaz/example/contents/.github", missing_ok=True)) + else: + with self.assertRaises(discovery.DiscoveryError): + discovery.GitHub().read("repos/pheaz/example/contents/.github", missing_ok=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1e352d0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +# /.github/workflows/ci.yml + +name: Validate policy automation + +on: + pull_request: + paths: + - .github/scripts/discover.py + - .github/tests/** + - .github/workflows/ci.yml + push: + branches: [main] + paths: + - .github/scripts/discover.py + - .github/tests/** + - .github/workflows/ci.yml + +permissions: + contents: read + +jobs: + test-discovery: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Test read-only discovery + run: python3 -m unittest discover -s .github/tests -v diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml new file mode 100644 index 0000000..5e3bdff --- /dev/null +++ b/.github/workflows/sync.yml @@ -0,0 +1,96 @@ +# /.github/workflows/sync.yml + +name: Sync canonical files + +on: + workflow_dispatch: + inputs: + repositories: + description: Comma-separated pheaz/repo pilot targets; empty selects all eligible repositories + type: string + required: false + default: '' + dry_run: + description: Compare files without pushing branches or opening pull requests + type: boolean + required: true + default: true + push: + branches: [main] + paths: + - .github/AGENTS.md + - .github/actions/AGENTS.md + - .github/workflows/AGENTS.md + - .github/workflows/lint-pr.yml + - .github/sync-files.json + - .github/scripts/discover.py + - .github/workflows/sync.yml + schedule: + - cron: '17 4 * * 1' + +permissions: + contents: read + +concurrency: + group: canonical-file-sync + cancel-in-progress: false + +jobs: + sync: + # Automatic sync remains off until the pilot succeeds and this variable is set. + if: github.ref == 'refs/heads/main' && (github.event_name == 'workflow_dispatch' || vars.FILE_SYNC_ENABLED == 'true') + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Check out the canonical snapshot + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Create a discovery token + id: discovery-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.FILE_SYNC_APP_ID }} + private-key: ${{ secrets.FILE_SYNC_APP_PRIVATE_KEY }} + owner: pheaz + permission-contents: read + + - name: Discover and validate targets + id: targets + env: + GH_TOKEN: ${{ steps.discovery-token.outputs.token }} + SYNC_REPOSITORIES: ${{ inputs.repositories }} + run: python3 .github/scripts/discover.py --sync-repositories "$SYNC_REPOSITORIES" --github-output "$GITHUB_OUTPUT" + + - name: Create a scoped sync token + if: steps.targets.outputs.has_targets == 'true' + id: sync-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.FILE_SYNC_APP_ID }} + private-key: ${{ secrets.FILE_SYNC_APP_PRIVATE_KEY }} + owner: pheaz + repositories: ${{ steps.targets.outputs.token_repositories }} + permission-contents: write + permission-pull-requests: write + permission-workflows: write + + - name: Synchronize through pull requests + if: steps.targets.outputs.has_targets == 'true' + uses: raven-actions/repo-files-sync@bb4d579be65a4086050c7e5441fb6f69a382db07 # v0.1.0-rc.17 + with: + GH_TOKEN: ${{ steps.sync-token.outputs.token }} + CONFIG_PATH: .github/sync.yml + GIT_USERNAME: pheaz-file-sync + GIT_EMAIL: pheaz-file-sync@users.noreply.github.com + PR_TITLE: 'chore: sync canonical policy files' + COMMIT_PREFIX: 'chore:' + COMMIT_EACH_FILE: 'false' + PR_LABELS: 'false' + BRANCH_PREFIX: repo-sync/pheaz-policy + OVERWRITE_EXISTING_PR: 'true' + SKIP_PR: 'false' + DELETE_ORPHANED: 'false' + REBASE: 'false' + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'true' || 'false' }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40ba4a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# /.gitignore + +# Discovery outputs can contain private repository names. +/.github/sync.yml +/tofu/repositories.auto.tfvars.json + +# Credentials, plans, state, and provider caches are local runtime data. +**/.terraform/ +*.tfstate +*.tfstate.* +*.tfplan +crash.log +crash.*.log +*.local.tfvars +*.local.tfvars.json +*.local.hcl +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 0d52dc4..ec3ee9f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,167 @@ -# .github -Organization-wide GitHub configuration + + +# pheaz organization policy + +OpenTofu manages persistent GitHub settings. `raven-actions/repo-files-sync` +propagates canonical contents through pull requests. Custom maintenance remains +responsible for branch garbage collection. + +This is a staged migration. Merging the scaffold does not apply OpenTofu or +enable automatic file synchronization. Legacy scripts remain available until +their replacements pass the acceptance gates below. + +## Ownership + +| Concern | Replacement | Initial behavior | +| --- | --- | --- | +| Squash commit title | `tofu/` | `squash_merge_commit_title = PR_TITLE` on active non-fork repositories | +| Branch deletion after merging | `tofu/` | `delete_branch_on_merge = true` on active non-fork repositories | +| Other repository settings | Future OpenTofu adoption | Preserved through `ignore_changes` | +| Archived repositories | OpenTofu read-only adoption | Imported without setting changes | +| Three nested agent files | File-sync PRs | Active non-fork targets with an existing `.github` directory | +| `lint-pr.yml` | File-sync PRs | Every active non-fork target with a default branch | +| Organization rulesets | Existing configuration until compatible adoption | Enforcement and bypass actors remain unchanged | +| Branch garbage collection | Existing cleanup script | Predicate and SHA rechecks remain unchanged | + +`delete_branch_on_merge` and `allow_auto_merge` are independent settings. +OpenTofu initially owns only the former and the squash title. The baseline +ruleset currently limits PR merging to squash; this migration does not change +repository-level merge-method switches. + +## Canonical contents and discovery + +`.github/sync-files.json` is the authoritative source/destination mapping. +Existing `.github/AGENTS.md`, `.github/actions/AGENTS.md`, +`.github/workflows/AGENTS.md`, and `.github/workflows/lint-pr.yml` remain their +canonical sources. The workflow is copied byte-for-byte and MUST NOT be edited +as part of this migration. Root `AGENTS.md` is deliberately excluded: the existing +agent-sync script does not propagate it. Adding it requires an explicit policy +change. + +The layout deliberately keeps existing sources in place instead of duplicating +them under `canonical/`. Discovery generates `.github/sync.yml` and +`tofu/repositories.auto.tfvars.json`. Both are ignored by Git because the public +policy repository must not publish private target names. The generated sync +document uses JSON syntax, which is valid YAML. + +Run from the repository root with Python 3.11+ and an authenticated GitHub CLI: + +```bash +python3 .github/scripts/discover.py +``` + +Discovery uses only GET requests, paginates repository inventory, excludes forks, +records stable repository IDs, and checks `.github` directories. Permission, +authentication, and rate-limit failures stop generation. The credential MUST see +every organization repository; an installation restricted to selected +repositories MUST NOT be used for complete adoption. The sync app SHOULD be +installed on all repositories, including future repositories. + +Generated inventory MUST be reviewed before each OpenTofu plan. Private target +names are masked before public Actions logs. Generated inventory, sync +configuration, plans, and state MUST NOT be uploaded as public artifacts. +Discovery adopts existing repositories; it does not create them. + +## OpenTofu cutover + +See [tofu/README.md](tofu/README.md) for bootstrap, state, imports, and plan review. + +1. Discover and review the full organization inventory. +2. Generate and commit the provider lockfile using OpenTofu, then validate. +3. Review an adoption plan. It MUST import existing repositories and MUST NOT + create, delete, rename, or change unrelated repository settings. Updates MUST + be limited to the two owned settings. +4. Apply the reviewed saved plan; a fresh plan MUST show no changes. +5. Retire `set-pheaz-squash-commit-title-to-pr-title.sh` and + `set-pheaz-delete-branch-on-merge.sh` in a follow-up PR. Operators MUST NOT run + these legacy writers concurrently with OpenTofu after cutover. + +The old squash-title script attempts archived repositories; delete-on-merge +skips them. Archived repositories do not expose these settings in the inspected +API response and are frozen by this migration. This is an intentional difference +from attempting archived writes. They MUST NOT be unarchived to make adoption pass. + +## File-sync cutover + +The workflow pins published `v0.1.0-rc.17` commit +`bb4d579be65a4086050c7e5441fb6f69a382db07`. As checked on 2026-10-06, stable +`v0.1.0` does not exist. The release candidate includes `dist/index.mjs`; `main` +does not. A release candidate is a deliberate pilot dependency; a stable release +SHOULD replace it after validation. + +Configure a dedicated GitHub App installed on the policy and target repositories: + +| Configuration | Value | +| --- | --- | +| Repository permissions | Metadata read; Contents write; Pull requests write; Workflows write | +| Variable `FILE_SYNC_APP_ID` | App ID | +| Secret `FILE_SYNC_APP_PRIVATE_KEY` | App private key | +| Variable `FILE_SYNC_ENABLED` | Unset during bootstrap; `true` after acceptance | + +The app does not need ruleset administration or bypass permissions. The workflow +uses a read token for discovery, then a write token scoped to selected targets. +`GITHUB_TOKEN` is used for source checkout; cross-repository synchronization uses +the installation token so required target PR checks can run. + +1. Merge the scaffold through normal review. +2. Dispatch **Sync canonical files** on `main`, select one `pheaz/repo`, and keep + `dry_run = true`. Review differences. +3. Dispatch that pilot with `dry_run = false`. Review and merge its sync PR + through the existing baseline ruleset. Validate its Conventional Commits title + and required `validate-pr-title` check. +4. Repeat the pilot. It MUST produce no content change after merge; subsequent + changes while a PR is open MUST reuse the existing sync PR. +5. Dispatch across all targets and review their PRs. After acceptance, set + `FILE_SYNC_ENABLED = true`. Canonical changes then sync on push; weekly runs + discover newly eligible repositories. +6. Retire `sync-pheaz-agents-by-sha.sh` and `sync.sh` in a follow-up PR. + Operators MUST NOT use their ruleset-disabling path after cutover. + +Sync preserves target-specific files, disables orphan deletion, and uses the +stable `repo-sync/pheaz-policy/main` branch. This policy repository is excluded +from targets because its source files are already canonical. The app MUST NOT +disable `ruleset-baseline`. Operators SHOULD treat sync branches as bot-owned +and MUST inspect any pre-existing branch with the same name before the pilot. +Archived repositories are skipped instead of reported as agent-write failures. + +## Ruleset adoption gate + +| Inspected organization ruleset | ID | Constraint in provider 6.13.0 | +| --- | --- | --- | +| `ruleset-baseline` | `24536027` | Does not represent `require_extra_approval_for_unattributed_changes = true` | +| Repository lifecycle guardrails | `24525606` | Does not support target `repository` | + +Inherited repository responses do not expose complete organization repository +selectors. Before adoption, operators MUST export the full organization ruleset +with an organization-admin credential, including selectors, bypass actors, +enforcement, and every rule parameter. The provider MUST round-trip the complete +definition. Importing a partial approximation MUST NOT be used as a workaround. + +There are no speculative `rulesets.tf` or `teams.tf` placeholders. Add those +resources when complete definitions and ownership are known. Secrets SHOULD be +adopted only after accounting for sensitive values in state; the sync app private +key remains outside initial OpenTofu ownership. + +## Procedural maintenance + +`scripts/delete-pheaz-behind-branches.sh` remains unchanged. It excludes forks, +archived repositories, and default branches; deletes only when +`ahead_by == 0 && behind_by > 0`; and rechecks the default branch name and both +SHAs before deletion. Identical, ahead, diverged, and changed-during-scan branches +are preserved. The rechecks reduce races; REST ref deletion is not an atomic +compare-and-delete operation. This migration does not schedule or execute it. + +## Verification + +```bash +python3 -m unittest discover -s .github/tests -v +python3 .github/scripts/discover.py --help +tofu -chdir=tofu fmt -check -recursive +tofu -chdir=tofu init -lockfile=readonly +tofu -chdir=tofu validate +``` + +Tests cover pagination, different target scopes, exclusions, pilot selection, +read failures, private-name masking, malformed inventory, and idempotent output. +Live OpenTofu plans and file-sync pilots require organization credentials and +the selected state backend. diff --git a/tofu/README.md b/tofu/README.md new file mode 100644 index 0000000..f9ba10f --- /dev/null +++ b/tofu/README.md @@ -0,0 +1,94 @@ + + +# OpenTofu adoption + +This root imports existing non-fork repositories and initially enforces only +`squash_merge_commit_title = PR_TITLE` and `delete_branch_on_merge = true`. +Unrelated settings are preserved. Archived repositories are imported read-only. +Provider 6.13.0 is pinned; OpenTofu 1.8+ is required. + +## Bootstrap + +Use a credential with organization-wide metadata access and repository +Administration write permission. Authentication comes from `GITHUB_TOKEN`. +Credentials MUST NOT be committed or written into configuration. File sync +SHOULD use a separate app credential with narrower contents/PR/workflow access. + +From the repository root: + +```bash +gh auth status +python3 .github/scripts/discover.py +tofu -chdir=tofu init +tofu -chdir=tofu providers lock -platform=linux_amd64 -platform=darwin_arm64 +tofu -chdir=tofu fmt -check -recursive +tofu -chdir=tofu validate +``` + +Review and commit generated `tofu/.terraform.lock.hcl` before first apply. It MUST +be generated by OpenTofu, never hand-edited. Subsequent initialization MUST use +`-lockfile=readonly`. This scaffold deliberately contains no fabricated lockfile: +OpenTofu/provider installation was unavailable in the preparation environment. + +## State and review + +No organization backend has been selected. The default local backend supports +a single-operator pilot. Its `tofu/terraform.tfstate` MUST be retained securely +and backed up; it MUST NOT be discarded after adoption or replaced with unrelated +state. Local runtime data is ignored by Git. + +Before shared or CI-driven apply, operators MUST configure a durable backend with +encryption, locking, restricted access, and backups. Backend credentials MUST +stay outside committed configuration. Migrate pilot state with +`tofu init -migrate-state` when selecting that backend. Actions artifacts and +caches MUST NOT serve as authoritative state. + +Imports are configuration-driven and idempotent. An imported address makes its +import block inert. Discovery is idempotent and does not mutate GitHub. Its +generated inventory contains private repository names and MUST remain local. +Review it against the complete organization inventory before planning. + +```bash +tofu -chdir=tofu init -lockfile=readonly +tofu -chdir=tofu plan -input=false -out=adoption.tfplan +tofu -chdir=tofu show adoption.tfplan +``` + +The plan MUST import existing objects without creating or deleting repositories. +Only the two owned settings MAY change. Visibility, default branches, merge +methods, `allow_auto_merge`, features, rulesets, and permissions MUST remain +unchanged. A stable repository-ID mismatch MUST stop adoption. Investigate any +other proposed change before apply. + +After reviewing that exact saved plan: + +```bash +tofu -chdir=tofu apply adoption.tfplan +tofu -chdir=tofu plan -input=false -detailed-exitcode +``` + +The fresh plan MUST exit 0. Status 2 means changes remain; status 1 means failure. +Serialize all writers. Adopting additional fields requires replacing their +`ignore_changes` entries with explicit desired values in a reviewed change. + +## Repository lifecycle changes + +`prevent_destroy` protects resources while their lifecycle configuration remains +present. Removing a resource block also removes that protection. It does not +replace plan review or the organization lifecycle guardrails. + +Active and archived repositories use distinct addresses. If a repository is +archived, move its state address before planning with refreshed inventory: + +```bash +tofu -chdir=tofu state mv 'github_repository.active["example"]' 'github_repository.archived["example"]' +``` + +The reverse move handles unarchiving. For a rename, move the old indexed address +to the new name and refresh inventory. Review the plan; the stable ID MUST match. +Removed repositories and intentional ownership removal require explicit state +review. Operators MUST NOT automate `state rm` to make a destructive plan pass. + +Repository creation, teams, permissions, and fully representable rulesets belong +here when explicitly adopted. The initial discovery/import path never creates +replacement repositories after a failed import. diff --git a/tofu/imports.tf b/tofu/imports.tf new file mode 100644 index 0000000..c005852 --- /dev/null +++ b/tofu/imports.tf @@ -0,0 +1,15 @@ +# /tofu/imports.tf + +# Discovery enumerates existing objects. Missing repositories fail import; +# adoption never falls back to creating replacement repositories. +import { + for_each = local.active_repositories + to = github_repository.active[each.key] + id = each.key +} + +import { + for_each = local.archived_repositories + to = github_repository.archived[each.key] + id = each.key +} diff --git a/tofu/providers.tf b/tofu/providers.tf new file mode 100644 index 0000000..a7c2ae4 --- /dev/null +++ b/tofu/providers.tf @@ -0,0 +1,6 @@ +# /tofu/providers.tf + +# Authentication comes from GITHUB_TOKEN, outside configuration and state. +provider "github" { + owner = var.organization +} diff --git a/tofu/repositories.tf b/tofu/repositories.tf new file mode 100644 index 0000000..791a202 --- /dev/null +++ b/tofu/repositories.tf @@ -0,0 +1,75 @@ +# /tofu/repositories.tf + +resource "github_repository" "active" { + for_each = local.active_repositories + + name = each.key + squash_merge_commit_title = "PR_TITLE" + delete_branch_on_merge = true + + lifecycle { + prevent_destroy = true + + # Initial adoption owns only the two settings above. Optional provider + # defaults must not change unrelated features or merge behavior on import. + ignore_changes = [ + description, + homepage_url, + private, + visibility, + fork, + source_owner, + source_repo, + security_and_analysis, + has_issues, + has_discussions, + has_projects, + has_downloads, + has_wiki, + is_template, + allow_merge_commit, + allow_squash_merge, + allow_rebase_merge, + allow_auto_merge, + allow_forking, + allow_update_branch, + squash_merge_commit_message, + merge_commit_title, + merge_commit_message, + web_commit_signoff_required, + auto_init, + default_branch, + license_template, + gitignore_template, + archived, + archive_on_destroy, + pages, + topics, + vulnerability_alerts, + ignore_vulnerability_alerts_during_read, + template, + ] + + postcondition { + condition = self.repo_id == each.value.id + error_message = "Repository identity changed since discovery. Refresh and review the inventory." + } + } +} + +# Archived repositories remain tracked without attempting unsupported writes. +resource "github_repository" "archived" { + for_each = local.archived_repositories + + name = each.key + + lifecycle { + prevent_destroy = true + ignore_changes = all + + postcondition { + condition = self.repo_id == each.value.id + error_message = "Archived repository identity changed since discovery. Refresh the inventory." + } + } +} diff --git a/tofu/variables.tf b/tofu/variables.tf new file mode 100644 index 0000000..2d331f2 --- /dev/null +++ b/tofu/variables.tf @@ -0,0 +1,40 @@ +# /tofu/variables.tf + +variable "organization" { + description = "The GitHub organization whose repositories are adopted." + type = string + default = "pheaz" + + validation { + condition = var.organization == "pheaz" + error_message = "This policy configuration manages only the pheaz organization." + } +} + +variable "repositories" { + description = "Non-fork repositories discovered by .github/scripts/discover.py." + type = map(object({ + id = number + archived = bool + })) + + # No default: missing inventory must fail instead of proposing removals. + validation { + condition = length(var.repositories) > 0 && alltrue([ + for name, repository in var.repositories : + can(regex("^[A-Za-z0-9_.-]{1,100}$", name)) && repository.id > 0 + ]) + error_message = "A nonempty repository inventory with valid names and IDs is required." + } +} + +locals { + active_repositories = { + for name, repository in var.repositories : name => repository + if !repository.archived + } + archived_repositories = { + for name, repository in var.repositories : name => repository + if repository.archived + } +} diff --git a/tofu/versions.tf b/tofu/versions.tf new file mode 100644 index 0000000..2c5a109 --- /dev/null +++ b/tofu/versions.tf @@ -0,0 +1,12 @@ +# /tofu/versions.tf + +terraform { + required_version = ">= 1.8.0, < 2.0.0" + + required_providers { + github = { + source = "integrations/github" + version = "= 6.13.0" + } + } +}