diff --git a/.github/rulesets/README.md b/.github/rulesets/README.md new file mode 100644 index 00000000..dd7753ce --- /dev/null +++ b/.github/rulesets/README.md @@ -0,0 +1,87 @@ +# Repository rulesets + +JSON files in this directory are the desired configuration for existing GitHub +repository rulesets. Each file contains the writable REST API fields plus an +`id` identifying the existing ruleset. The synchronizer updates that ID in place, +so renaming a ruleset does not create a duplicate. It never creates or deletes +rulesets, and deleting a file only stops managing that ruleset. + +`default.json` was imported from `foyer-rs/foyer` ruleset `2665193`, including +the nine Rust 1.95.0 required checks that replaced Rust 1.91.0 during the MSRV +migration. The initial configuration preserves the live ruleset, including check +integration IDs and bypass actors. The first synchronization should be a no-op. +Future changes to required checks can be reviewed and applied through this pipeline. + +## Authentication + +Create a fine-grained personal access token with resource owner `foyer-rs`, +repository access limited to `foyer`, and repository **Administration: Read and +write**. Create an environment named `rulesets` in repository Settings, select +**Selected branches and tags**, and add a **branch** rule for `main` only (no tag +rule). Store the token as the environment secret `RULESETS_TOKEN`, not a repository +secret. If a repository secret with that name already exists, remove it after +moving the credential to the environment. An +organization may require approval before the token can access the repository. +Choose an expiration date and replace the secret before it expires. + +The normal `GITHUB_TOKEN` cannot write rulesets. Pull requests use it only for +read-only previews. The `rulesets` environment branch policy prevents pull +request and non-main jobs from receiving `RULESETS_TOKEN`. GitHub may hide bypass +actors from read-only responses; the preview reports that limitation. Apply +requires the complete response, including bypass actors. + +## Workflow + +- Pull requests changing these files, the synchronizer, its tests, or the workflow + validate the configuration, run tests, and show the live diff in the job log. +- Pushes to `main` affecting those paths automatically apply changes. +- The **Rulesets** workflow also supports manual dispatch on `main`. Leave + `apply` false for a full preview, or set it true to reconcile drift. +- Sync jobs are serialized and always check out the latest `main`, including + on re-runs. Writes are restricted to `foyer-rs/foyer` on `main`. +- Unchanged rulesets are skipped. Updates are followed by a read-back check. + A detected edit between planning and writing aborts the update. GitHub does + not provide a transaction across rulesets; earlier updates can remain applied + if a later update fails. Fix the failure and rerun to converge. + +Do not make this path-filtered workflow a required status check: it does not run +on unrelated pull requests. Review changes to the ruleset and privileged sync +code before merging. The environment restriction is configured in GitHub, outside +this workflow, so a branch cannot remove it by editing YAML. Anyone who can merge +code into `main` remains trusted to use the credential. For a separate trust +boundary, run reconciliation from an independently administered repository. + +## Local use + +Python 3 and an authenticated `gh` CLI are sufficient; no Python dependencies +are needed. Run from the repository root: + +```sh +python3 .github/scripts/sync_rulesets.py --validate +python3 -m unittest discover -s .github/scripts -p 'test_rulesets.py' +python3 .github/scripts/sync_rulesets.py +python3 .github/scripts/sync_rulesets.py --apply +``` + +The default is a read-only preview. `--apply` uses the local `gh` authentication +(or `GH_TOKEN`) and requires repository administration permission. This can +bootstrap a required-check migration if stale checks block the initial merge. +Inspect the preview first. Once the workflow is merged, prefer it for updates. + +To adopt another existing repository ruleset, export its ID and writable fields +into a new JSON file: + +```sh +gh api repos/foyer-rs/foyer/rulesets/RULESET_ID \ + --jq '{id, name, target, enforcement, conditions, bypass_actors, rules}' \ + > .github/rulesets/another.json +``` + +Use administrative credentials for export so bypass actors are included. Local +validation checks the document shape; GitHub validates individual rule parameters +when applying. Unknown top-level fields are rejected rather than silently dropped. +To roll back a configuration change, revert its commit and merge the revert; +manual changes in GitHub are overwritten by the next reconciliation. + +See [GitHub's ruleset API](https://docs.github.com/en/rest/repos/rules) and +[fine-grained token setup](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). diff --git a/.github/rulesets/default.json b/.github/rulesets/default.json new file mode 100644 index 00000000..f64eec63 --- /dev/null +++ b/.github/rulesets/default.json @@ -0,0 +1,219 @@ +{ + "id": 2665193, + "name": "default", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "~DEFAULT_BRANCH" + ] + } + }, + "bypass_actors": [ + { + "actor_id": null, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always" + } + ], + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_linear_history" + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { + "context": "GitGuardian Security Checks", + "integration_id": 46505 + }, + { + "context": "check build with madsim", + "integration_id": 15368 + }, + { + "context": "run with single worker thread and deadlock detection", + "integration_id": 15368 + }, + { + "context": "run with address saniziter", + "integration_id": 15368 + }, + { + "context": "rust ffmt check (macos-latest)", + "integration_id": 15368 + }, + { + "context": "rust ffmt check (ubuntu-latest)", + "integration_id": 15368 + }, + { + "context": "rust ffmt check (windows-latest)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (macos-latest, stable)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (ubuntu-latest, stable)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (windows-latest, stable)", + "integration_id": 15368 + }, + { + "context": "rust udeps test (macos-latest)", + "integration_id": 15368 + }, + { + "context": "rust udeps test (ubuntu-latest)", + "integration_id": 15368 + }, + { + "context": "rust udeps test (windows-latest)", + "integration_id": 15368 + }, + { + "context": "license-header-check", + "integration_id": 15368 + }, + { + "context": "misc check", + "integration_id": 15368 + }, + { + "context": "DCO", + "integration_id": 1861 + }, + { + "context": "rust doc test (macos-latest)", + "integration_id": 15368 + }, + { + "context": "rust doc test (ubuntu-latest)", + "integration_id": 15368 + }, + { + "context": "rust doc test (windows-latest)", + "integration_id": 15368 + }, + { + "context": "rust machete check", + "integration_id": 15368 + }, + { + "context": "cargo manifest check", + "integration_id": 15368 + }, + { + "context": "rust doc test (ubuntu-24.04-arm)", + "integration_id": 15368 + }, + { + "context": "rust ffmt check (ubuntu-24.04-arm)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (ubuntu-24.04-arm, stable)", + "integration_id": 15368 + }, + { + "context": "rust udeps test (ubuntu-24.04-arm)", + "integration_id": 15368 + }, + { + "context": "foyer bench fuzzy test with codecov (ubuntu-latest, stable)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (macos-latest, stable, --features serde)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (ubuntu-24.04-arm, stable, --features serde)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (ubuntu-latest, stable, --features serde)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (windows-latest, stable, --features serde)", + "integration_id": 15368 + }, + { + "context": "foyer bench fuzzy test with codecov (ubuntu-latest, 1.95.0)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (macos-latest, 1.95.0)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (macos-latest, 1.95.0, --features serde)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (ubuntu-24.04-arm, 1.95.0)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (ubuntu-24.04-arm, 1.95.0, --features serde)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (ubuntu-latest, 1.95.0)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (ubuntu-latest, 1.95.0, --features serde)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (windows-latest, 1.95.0)", + "integration_id": 15368 + }, + { + "context": "rust test with codecov (windows-latest, 1.95.0, --features serde)", + "integration_id": 15368 + } + ] + } + }, + { + "type": "creation" + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": false, + "required_reviewers": [], + "require_code_owner_review": false, + "dismissal_restriction": { + "enabled": false, + "allowed_actors": [] + }, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": [ + "squash" + ] + } + } + ] +} diff --git a/.github/scripts/sync_rulesets.py b/.github/scripts/sync_rulesets.py new file mode 100644 index 00000000..da31dec5 --- /dev/null +++ b/.github/scripts/sync_rulesets.py @@ -0,0 +1,136 @@ +"""Preview or reconcile explicitly managed repository rulesets using gh.""" + +import argparse +import difflib +import json +from pathlib import Path +import subprocess +import sys + +FIELDS = {"name", "target", "enforcement", "conditions", "bypass_actors", "rules"} +DEFAULT_DIRECTORY = Path(__file__).resolve().parents[1] / "rulesets" + + +def canonical(value): + # Rules, conditions, actors, and check lists are unordered in the API. + if isinstance(value, dict): + return {key: canonical(item) for key, item in sorted(value.items())} + if isinstance(value, list): + return sorted((canonical(item) for item in value), key=json.dumps) + return value + + +def render(value): + return json.dumps(canonical(value), indent=2, sort_keys=True).splitlines(True) + + +def load_configs(directory): + configs = [] + ids = set() + paths = sorted(directory.glob("*.json")) + if not paths: + raise ValueError(f"No ruleset JSON files found in {directory}") + for path in paths: + config = json.loads(path.read_text()) + if not isinstance(config, dict) or set(config) != FIELDS | {"id"}: + raise ValueError(f"{path}: expected exactly id and {sorted(FIELDS)}") + ruleset_id = config.pop("id") + if type(ruleset_id) is not int or ruleset_id <= 0 or ruleset_id in ids: + raise ValueError(f"{path}: id must be a unique positive integer") + ids.add(ruleset_id) + if not isinstance(config["name"], str) or not config["name"].strip(): + raise ValueError(f"{path}: name must be a nonempty string") + if config["target"] not in ("branch", "tag", "push"): + raise ValueError(f"{path}: unsupported target") + if config["enforcement"] not in ("active", "disabled", "evaluate"): + raise ValueError(f"{path}: invalid enforcement") + if not isinstance(config["conditions"], dict): + raise ValueError(f"{path}: conditions must be an object") + if not isinstance(config["bypass_actors"], list): + raise ValueError(f"{path}: bypass_actors must be an array") + if not isinstance(config["rules"], list) or not all( + isinstance(rule, dict) and isinstance(rule.get("type"), str) + for rule in config["rules"] + ): + raise ValueError(f"{path}: rules must be an array of typed objects") + configs.append((ruleset_id, config)) + return configs + + +def api(endpoint, payload=None): + command = ["gh", "api", "--hostname", "github.com", endpoint] + if payload is not None: + command += ["--method", "PUT", "--input", "-"] + result = subprocess.run( + command, + input=json.dumps(payload) if payload is not None else None, + text=True, + stdout=subprocess.PIPE, + check=True, + ) + return json.loads(result.stdout) + + +def remote_config(remote, repository, apply): + if remote.get("source_type") != "Repository" or remote.get("source") != repository: + raise ValueError("Refusing to manage a ruleset from a different source") + missing = FIELDS - remote.keys() + if missing == {"bypass_actors"} and not apply: + print("Bypass actors are hidden by GitHub for this token; preview excludes them.") + elif missing: + raise ValueError(f"Incomplete ruleset response (permissions?): {sorted(missing)}") + return {key: remote[key] for key in FIELDS if key in remote} + + +def sync(configs, repository, apply=False): + changes = [] + # Read and plan every managed ruleset before issuing any writes. + for ruleset_id, desired in configs: + endpoint = f"repos/{repository}/rulesets/{ruleset_id}" + current = remote_config(api(endpoint), repository, apply) + visible_desired = {key: desired[key] for key in current} + print(f"Ruleset {ruleset_id}: {desired['name']}") + if canonical(current) == canonical(visible_desired): + print("No visible changes." if "bypass_actors" not in current else "No changes.") + continue + print("".join(difflib.unified_diff( + render(current), render(visible_desired), fromfile="GitHub", tofile="repository" + )), end="") + changes.append((endpoint, current, desired)) + if not apply: + print(f"Dry run: {len(changes)} ruleset(s) would be updated. No writes performed.") + return + for endpoint, expected, desired in changes: + # Avoid overwriting a concurrent manual edit between planning and writing. + current = remote_config(api(endpoint), repository, True) + if canonical(current) != canonical(expected): + raise ValueError(f"{endpoint}: changed during planning; rerun to review the new diff") + api(endpoint, desired) + actual = remote_config(api(endpoint), repository, True) + if canonical(actual) != canonical(desired): + raise ValueError(f"{endpoint}: verification failed after update") + print(f"Updated and verified {endpoint}.") + print(f"Applied {len(changes)} ruleset update(s).") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default="foyer-rs/foyer") + parser.add_argument("--directory", type=Path, default=DEFAULT_DIRECTORY) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--apply", action="store_true", help="Write and verify changes") + mode.add_argument("--validate", action="store_true", help="Validate local files only") + args = parser.parse_args() + configs = load_configs(args.directory) + if args.validate: + print(f"Validated {len(configs)} ruleset configuration(s).") + else: + sync(configs, args.repo, args.apply) + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, subprocess.CalledProcessError) as error: + print(f"Error: {error}", file=sys.stderr) + sys.exit(1) diff --git a/.github/scripts/test_rulesets.py b/.github/scripts/test_rulesets.py new file mode 100644 index 00000000..eccda086 --- /dev/null +++ b/.github/scripts/test_rulesets.py @@ -0,0 +1,97 @@ +"""Exercise reconciliation without writing to GitHub.""" + +import copy +import contextlib +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import sync_rulesets as rulesets + +REPO = "foyer-rs/foyer" + + +class SyncTests(unittest.TestCase): + def setUp(self): + self.configs = rulesets.load_configs(rulesets.DEFAULT_DIRECTORY) + self.ruleset_id, self.desired = self.configs[0] + self.remote = copy.deepcopy(self.desired) + self.remote.update(source_type="Repository", source=REPO) + self.output = contextlib.redirect_stdout(io.StringIO()) + self.output.__enter__() + self.addCleanup(self.output.__exit__, None, None, None) + + def changed_remote(self): + remote = copy.deepcopy(self.remote) + remote["name"] = "previous name" + return remote + + @patch.object(rulesets, "api") + def test_dry_run_never_writes(self, api): + api.return_value = self.changed_remote() + rulesets.sync(self.configs, REPO) + self.assertTrue(all(len(call.args) == 1 for call in api.call_args_list)) + + @patch.object(rulesets, "api") + def test_unchanged_is_noop_even_with_reordered_rules(self, api): + self.remote["rules"].reverse() + api.return_value = self.remote + rulesets.sync(self.configs, REPO, apply=True) + self.assertEqual(api.call_count, 1) + + @patch.object(rulesets, "api") + def test_update_preserves_complete_payload_and_verifies(self, api): + previous = self.changed_remote() + api.side_effect = [previous, previous, self.remote, self.remote] + rulesets.sync(self.configs, REPO, apply=True) + self.assertEqual(api.call_count, 4) + self.assertEqual(api.call_args_list[2].args[1], self.desired) + self.assertEqual(len(api.call_args_list[3].args), 1) + + @patch.object(rulesets, "api") + def test_hidden_bypass_allowed_only_for_preview(self, api): + del self.remote["bypass_actors"] + api.return_value = self.remote + rulesets.sync(self.configs, REPO) + with self.assertRaisesRegex(ValueError, "Incomplete"): + rulesets.sync(self.configs, REPO, apply=True) + self.assertTrue(all(len(call.args) == 1 for call in api.call_args_list)) + + @patch.object(rulesets, "api") + def test_foreign_source_rejected(self, api): + self.remote["source_type"] = "Organization" + api.return_value = self.remote + with self.assertRaisesRegex(ValueError, "different source"): + rulesets.sync(self.configs, REPO, apply=True) + self.assertEqual(api.call_count, 1) + + @patch.object(rulesets, "api") + def test_concurrent_edit_aborts_before_write(self, api): + api.side_effect = [self.changed_remote(), self.remote] + with self.assertRaisesRegex(ValueError, "changed during planning"): + rulesets.sync(self.configs, REPO, apply=True) + self.assertTrue(all(len(call.args) == 1 for call in api.call_args_list)) + + @patch.object(rulesets, "api") + def test_verification_failure_is_not_success(self, api): + api.return_value = self.changed_remote() + with self.assertRaisesRegex(ValueError, "verification failed"): + rulesets.sync(self.configs, REPO, apply=True) + + def test_empty_directory_and_duplicate_ids_rejected(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + with self.assertRaisesRegex(ValueError, "No ruleset"): + rulesets.load_configs(directory) + payload = json.dumps(dict(id=self.ruleset_id, **self.desired)) + (directory / "a.json").write_text(payload) + (directory / "b.json").write_text(payload) + with self.assertRaisesRegex(ValueError, "unique positive"): + rulesets.load_configs(directory) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/rulesets.yml b/.github/workflows/rulesets.yml new file mode 100644 index 00000000..5978754e --- /dev/null +++ b/.github/workflows/rulesets.yml @@ -0,0 +1,79 @@ +name: Rulesets + +on: + pull_request: + branches: [main] + paths: + - '.github/rulesets/**' + - '.github/scripts/*rulesets*.py' + - '.github/workflows/rulesets.yml' + push: + branches: [main] + paths: + - '.github/rulesets/**' + - '.github/scripts/*rulesets*.py' + - '.github/workflows/rulesets.yml' + workflow_dispatch: + inputs: + apply: + description: Apply the rulesets from the latest main commit + type: boolean + default: false + +permissions: + contents: read + +jobs: + validate: + name: validate rulesets + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Validate configuration and test synchronizer + run: | + python3 .github/scripts/sync_rulesets.py --validate + python3 -m unittest discover -s .github/scripts -p 'test_rulesets.py' + - name: Preview changes without administrative credentials + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: python3 .github/scripts/sync_rulesets.py + + sync: + name: sync rulesets + needs: validate + environment: rulesets + if: >- + github.repository == 'foyer-rs/foyer' && + github.event_name != 'pull_request' && + github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + concurrency: + group: rulesets-sync + cancel-in-progress: false + steps: + # Re-runs must reconcile the latest configuration, not restore an old commit. + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: main + persist-credentials: false + - name: Validate the latest configuration + run: | + python3 .github/scripts/sync_rulesets.py --validate + python3 -m unittest discover -s .github/scripts -p 'test_rulesets.py' + - name: Preview or apply rulesets + env: + GH_TOKEN: ${{ secrets.RULESETS_TOKEN }} + APPLY: ${{ github.event_name == 'push' || inputs.apply }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo "::error::Configure RULESETS_TOKEN with repository Administration: write permission." + exit 1 + fi + if [ "$APPLY" = "true" ]; then + python3 .github/scripts/sync_rulesets.py --apply + else + python3 .github/scripts/sync_rulesets.py + fi