From f0121d3e0b2f2912c428621298dbf4b150b4eafd Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 20 Aug 2026 11:52:06 -0400 Subject: [PATCH 1/2] feat: add core GitHub protection policy --- ops/github/README.md | 144 ++++ ops/github/core-protection-policy.json | 512 ++++++++++++ scripts/manage_github_protection.py | 1058 ++++++++++++++++++++++++ tests/test_manage_github_protection.py | 286 +++++++ 4 files changed, 2000 insertions(+) create mode 100644 ops/github/README.md create mode 100644 ops/github/core-protection-policy.json create mode 100755 scripts/manage_github_protection.py create mode 100644 tests/test_manage_github_protection.py diff --git a/ops/github/README.md b/ops/github/README.md new file mode 100644 index 0000000..6aea1d5 --- /dev/null +++ b/ops/github/README.md @@ -0,0 +1,144 @@ +# Core GitHub protection policy + +This directory contains the reviewed target policy for eight public core +repositories. The policy does not manage `openadapt-cloud`. It does not manage +a foreign repository. + +The policy has these results: + +- All changes to `main` use a pull request. +- No person, administrator, role, team, deploy key, or app can bypass the + `main` rule. +- One review is necessary. A new push makes an old review invalid. A different + person must approve the last push. All review threads must be complete. +- Each check in the policy comes from the GitHub Actions integration. +- The branch must be current with `main` before GitHub admits it. +- Only the `openadapt-release` GitHub App can create a release tag. +- A second ruleset prevents all identities, including the release app, from + changing or deleting that tag. +- A protected environment admits only the exact branch or tag pattern in the + policy. +- A required reviewer must approve each release environment use. + +GitHub documents the applicable [repository ruleset API](https://docs.github.com/en/rest/repos/rules), +[environment API](https://docs.github.com/en/rest/deployments/environments), and +[deployment policy API](https://docs.github.com/en/rest/deployments/branch-policies). + +## Files + +`core-protection-policy.json` is the source of truth. It records the exact +repository names, audited `main` commits, check names, tag patterns, +environments, and release workflow contracts. + +`scripts/manage_github_protection.py` validates, plans, applies, and verifies +the policy. A plan and a verify operation use only GitHub `GET` requests. + +## Read-only audit on 2026-08-20 + +The public GitHub API reported zero repository rulesets in all eight +repositories. GitHub marked these `main` branches as protected: `OpenAdapt`, +`openadapt-flow`, `openadapt-capture`, `openadapt-evals`, and `openadapt-web`. +It marked `openadapt-desktop`, `openadapt-ops`, and `.github` as not protected. + +The public API did not expose the classic branch protection detail. The local +GitHub CLI tokens were invalid. Therefore, this audit does not claim the exact +classic protection settings for the five protected branches. + +Flow and Evals had an unprotected `pypi` environment. Capture and OpenAdapt had +no release environment. Desktop had a protected `native-release` environment. +It admitted `desktop-v*` and `ffmpeg-runtime-v8.1.2-r1`. The target policy uses +`desktop-v*` and `ffmpeg-runtime-v*`. It also adds the release identity +environment and the PyPI environment. The tool does not change the Ops backup +environments. + +## Required check selection + +The policy requires only a check that starts on every pull request. GitHub can +leave a path-filtered required workflow in a pending state when its paths do +not match. Such a workflow can then stop an unrelated pull request. + +The policy records a path-scoped check in `path_scoped_checks`. It does not make +that check a global requirement. The target policy does require +`build-and-e2e` in `openadapt-web` and `validate-profile` in `.github`. The tool +refuses an apply while either workflow has pull request path filters. Keep each +exact check name. Use a cheap internal path classifier when the expensive work +does not apply. + +## Release sequence + +Use this sequence for each package repository: + +1. A workflow on exact `main` enters the `release-identity` environment. +2. The workflow gets a short-lived `openadapt-release` App token. +3. The app opens a version pull request. It does not push to `main`. +4. The normal `main` rules admit the version pull request. +5. An approved workflow uses the app to create the exact release tag. +6. The tag starts the publication workflow. +7. The publication job enters `pypi` or `native-release`. +8. The job uses OIDC to publish the exact tag bytes. + +An event from `GITHUB_TOKEN` does not normally start another workflow. GitHub +documents this behavior in the [GITHUB_TOKEN reference](https://docs.github.com/en/actions/concepts/security/github_token). +Use the release App token for the release pull request and tag events. + +The current package release workflows still refer to `ADMIN_TOKEN`, or they do +not use both protected environments. The plan reports this state as a refusal. +Migrate these workflows before an apply operation. + +## Commands + +Validate only the local policy: + +```bash +uv run python scripts/manage_github_protection.py validate-config +``` + +Create a live read-only plan: + +```bash +export OPENADAPT_RELEASE_APP_ID=123456 +uv run python scripts/manage_github_protection.py plan \ + --output /tmp/openadapt-github-protection-plan.json +``` + +The GitHub CLI token needs repository read access and organization installation +read access for the plan. It needs repository administration write access for +an apply operation. The tool checks the app ID, installation scope, and the +environment reviewer ID against GitHub. + +Inspect the plan. Resolve every refusal. Wait until all pull request checks are +complete. Then create a new plan. A plan expires after 15 minutes. + +Apply that exact plan: + +```bash +uv run python scripts/manage_github_protection.py apply \ + --plan /tmp/openadapt-github-protection-plan.json \ + --confirm "APPLY OpenAdaptAI CORE PROTECTION" +``` + +The apply operation checks every `main` commit again. It refuses a changed +commit, a changed action list, an active pull request check, a missing release +identity, or an invalid workflow contract. + +The tool does not remove an extra environment deployment policy by default. +Inspect the planned deletion. Then add `--prune-environment-policies` if the +extra policy is not valid. + +Verify the live result: + +```bash +uv run python scripts/manage_github_protection.py verify \ + --output /tmp/openadapt-github-protection-verify.json +``` + +## Private repository plan limit + +`openadapt-cloud` stays audit-only. The present organization plan cannot use +GitHub artifact attestations for a private repository. GitHub requires +Enterprise Cloud for that feature in a private repository. See the +[artifact attestation plan requirements](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations). + +Keep the existing signed Ed25519 evidence envelope and the public verifier. +Do not claim GitHub private-repository attestation. Reassess this limit after a +move to GitHub Enterprise Cloud. diff --git a/ops/github/core-protection-policy.json b/ops/github/core-protection-policy.json new file mode 100644 index 0000000..b42adf0 --- /dev/null +++ b/ops/github/core-protection-policy.json @@ -0,0 +1,512 @@ +{ + "schema_version": 1, + "organization": "OpenAdaptAI", + "reviewed_at": "2026-08-20T00:00:00Z", + "review_source": "Exact origin/main refs plus GitHub pull-request workflow jobs", + "live_audit": { + "observed_at": "2026-08-20", + "repository_ruleset_counts": { + ".github": 0, + "OpenAdapt": 0, + "openadapt-capture": 0, + "openadapt-desktop": 0, + "openadapt-evals": 0, + "openadapt-flow": 0, + "openadapt-ops": 0, + "openadapt-web": 0 + }, + "main_protected": { + ".github": false, + "OpenAdapt": true, + "openadapt-capture": true, + "openadapt-desktop": false, + "openadapt-evals": true, + "openadapt-flow": true, + "openadapt-ops": false, + "openadapt-web": true + }, + "classic_branch_protection_detail": "Not available without valid authenticated administration read access", + "release_environment_state": { + "OpenAdapt": "No release environment", + "openadapt-flow": "pypi exists with no protection rule and no deployment policy", + "openadapt-capture": "No release environment", + "openadapt-desktop": "native-release has reviewer 774615 and exact desktop-v* plus ffmpeg-runtime-v8.1.2-r1 tag policies", + "openadapt-evals": "pypi exists with no protection rule and no deployment policy", + "openadapt-ops": "No release environment; operational environments are outside this policy", + "openadapt-web": "No release environment", + ".github": "No release environment" + } + }, + "github_actions_integration_id": 15368, + "main_rule_defaults": { + "required_approvals": 1, + "dismiss_stale_reviews": true, + "require_last_push_approval": true, + "require_review_thread_resolution": true, + "strict_status_checks": true, + "allowed_merge_methods": [ + "squash", + "rebase", + "merge" + ] + }, + "release_identity": { + "actor_type": "Integration", + "app_slug": "openadapt-release", + "actor_id": null, + "actor_id_environment": "OPENADAPT_RELEASE_APP_ID", + "bypass_mode": "always", + "required_repository_permissions": [ + "Contents: write", + "Pull requests: write", + "Metadata: read" + ], + "purpose": "Create release tags only. This identity has no main-branch bypass." + }, + "environment_reviewer": { + "type": "User", + "login": "abrichr", + "id": 774615 + }, + "environment_defaults": { + "wait_timer": 0, + "prevent_self_review": false + }, + "repositories": [ + { + "name": "OpenAdapt", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "42970fbb1246f77f8350254569e5ce55f9bb60ed", + "require_code_owner_review": true, + "required_checks": [ + "Analyze (python)", + "check-source-boundary", + "dependency-review", + "gitleaks", + "run-ci (macos-latest, 3.12)", + "run-ci (ubuntu-latest, 3.10)", + "run-ci (ubuntu-latest, 3.11)", + "run-ci (ubuntu-latest, 3.12)", + "validate-platform-manifest" + ], + "path_scoped_checks": [ + "guard", + "Prove the detectors fire and stay quiet" + ], + "release_tag_patterns": [ + "refs/tags/v*" + ], + "release_environments": [ + { + "name": "release-identity", + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ] + }, + { + "name": "pypi", + "deployment_policies": [ + { + "type": "tag", + "name": "v*" + } + ] + } + ], + "release_workflows": [ + { + "path": ".github/workflows/release-and-publish.yml", + "required_patterns": [ + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?release-identity\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?pypi\\s*$", + "(?m)^\\s*id-token:\\s*write\\s*$" + ], + "forbidden_patterns": [ + "ADMIN_TOKEN" + ] + } + ] + }, + { + "name": "openadapt-flow", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "7da6571bdc38faca5b6b944ae8dbac6ec1d771cb", + "require_code_owner_review": true, + "required_checks": [ + "docs-consistency", + "e2e-browser", + "effectbench-standalone", + "gate", + "interop-types", + "lint", + "linux-atspi-x11", + "mypy-strict-safety", + "phi-guard", + "python-compatibility", + "test", + "wheel", + "windows-mock" + ], + "path_scoped_checks": [ + "citrix-workspace-standin", + "docker-rdp-vision-ladder" + ], + "release_tag_patterns": [ + "refs/tags/v*" + ], + "release_environments": [ + { + "name": "release-identity", + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ] + }, + { + "name": "pypi", + "deployment_policies": [ + { + "type": "tag", + "name": "v*" + } + ] + } + ], + "release_workflows": [ + { + "path": ".github/workflows/release.yml", + "required_patterns": [ + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?release-identity\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?pypi\\s*$", + "(?m)^\\s*id-token:\\s*write\\s*$" + ], + "forbidden_patterns": [ + "ADMIN_TOKEN" + ] + } + ] + }, + { + "name": "openadapt-capture", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "61096788a623a4a302a69a363215dad81fb9008b", + "require_code_owner_review": true, + "required_checks": [ + "Analyze (javascript-typescript)", + "Analyze (python)", + "dependency-review", + "gitleaks", + "lint", + "package-contract", + "test (3.10)", + "test (3.11)", + "test (3.12)" + ], + "path_scoped_checks": [ + "control-contract (macos-latest)", + "control-contract (windows-latest)" + ], + "release_tag_patterns": [ + "refs/tags/v*" + ], + "release_environments": [ + { + "name": "release-identity", + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ] + }, + { + "name": "pypi", + "deployment_policies": [ + { + "type": "tag", + "name": "v*" + } + ] + } + ], + "release_workflows": [ + { + "path": ".github/workflows/release.yml", + "required_patterns": [ + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?release-identity\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?pypi\\s*$", + "(?m)^\\s*id-token:\\s*write\\s*$" + ], + "forbidden_patterns": [ + "ADMIN_TOKEN" + ] + } + ] + }, + { + "name": "openadapt-desktop", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "f0f5d140698ff91ccabd391a835463afe9370e06", + "require_code_owner_review": false, + "required_checks": [ + "Analyze (javascript-typescript)", + "Analyze (python)", + "Analyze (rust)", + "Frontend behavior and build", + "Python Engine Tests (ubuntu-latest, 3.11)", + "Python Engine Tests (ubuntu-latest, 3.12)", + "Python distribution", + "Python locked dependencies", + "Python locked dependencies (macOS Intel)", + "Python sidecar (ubuntu-22.04)", + "Qualification contract (bundled Flow)", + "Rust locked dependencies", + "Select artifact scope", + "gitleaks", + "npm locked dependencies" + ], + "path_scoped_checks": [ + "Reject an unreserved or stale native version pull request" + ], + "release_tag_patterns": [ + "refs/tags/v*", + "refs/tags/desktop-v*", + "refs/tags/ffmpeg-runtime-v*" + ], + "release_environments": [ + { + "name": "release-identity", + "deployment_policies": [ + { + "type": "branch", + "name": "main" + }, + { + "type": "tag", + "name": "v*" + } + ] + }, + { + "name": "pypi", + "deployment_policies": [ + { + "type": "tag", + "name": "v*" + } + ] + }, + { + "name": "native-release", + "deployment_policies": [ + { + "type": "tag", + "name": "desktop-v*" + }, + { + "type": "tag", + "name": "ffmpeg-runtime-v*" + } + ] + } + ], + "release_workflows": [ + { + "path": ".github/workflows/release.yml", + "required_patterns": [ + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?release-identity\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?pypi\\s*$", + "(?m)^\\s*id-token:\\s*write\\s*$" + ], + "forbidden_patterns": [ + "ADMIN_TOKEN" + ] + }, + { + "path": ".github/workflows/native-freshness.yml", + "required_patterns": [ + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?release-identity\\s*$" + ], + "forbidden_patterns": [ + "ADMIN_TOKEN" + ] + }, + { + "path": ".github/workflows/native-release.yml", + "required_patterns": [ + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?release-identity\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?native-release\\s*$", + "(?m)^\\s*id-token:\\s*write\\s*$" + ], + "forbidden_patterns": [ + "ADMIN_TOKEN" + ] + }, + { + "path": ".github/workflows/ffmpeg-runtime.yml", + "required_patterns": [ + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?release-identity\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?native-release\\s*$", + "(?m)^\\s*id-token:\\s*write\\s*$" + ], + "forbidden_patterns": [ + "ADMIN_TOKEN" + ] + } + ] + }, + { + "name": "openadapt-evals", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "b7d4fe842f5a9ae9e4188df6087911b24a1706f2", + "require_code_owner_review": false, + "required_checks": [ + "test" + ], + "path_scoped_checks": [ + "freshness", + "headed-pixel-campaign" + ], + "release_tag_patterns": [ + "refs/tags/v*" + ], + "release_environments": [ + { + "name": "release-identity", + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ] + }, + { + "name": "pypi", + "deployment_policies": [ + { + "type": "tag", + "name": "v*" + } + ] + } + ], + "release_workflows": [ + { + "path": ".github/workflows/release.yml", + "required_patterns": [ + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?release-identity\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?pypi\\s*$", + "(?m)^\\s*id-token:\\s*write\\s*$" + ], + "forbidden_patterns": [ + "ADMIN_TOKEN" + ] + } + ] + }, + { + "name": "openadapt-ops", + "local_repository_name": "openadapt-maintenance", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "dbc110cc5ccbeca900499ac52e298deb9c7b49de", + "require_code_owner_review": true, + "required_checks": [ + "Analyze (python)", + "Validate canonical docs", + "dependency-review", + "gitleaks" + ], + "path_scoped_checks": [ + "Compare documented versions to PyPI", + "Prove the classifier fires and stays quiet" + ], + "release_tag_patterns": [ + "refs/tags/v*" + ], + "release_environments": [], + "release_workflows": [] + }, + { + "name": "openadapt-web", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "ba35b60125160b4b40a0871644709d8c7e29259f", + "require_code_owner_review": true, + "required_checks": [ + "Analyze (javascript-typescript)", + "build-and-e2e", + "dependency-review", + "gitleaks" + ], + "path_scoped_checks": [], + "admission_gaps": [ + "Make build-and-e2e report on every pull request. It can use a cheap internal path classifier for documentation-only changes." + ], + "admission_workflows": [ + { + "path": ".github/workflows/ci.yml", + "required_patterns": [], + "forbidden_patterns": [ + "(?m)^[ ]{4}paths(?:-ignore)?:\\s*$" + ] + } + ], + "release_tag_patterns": [ + "refs/tags/v*" + ], + "release_environments": [], + "release_workflows": [] + }, + { + "name": ".github", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "d60445bc8617adc4a27c20e6ce05681a95d83a2c", + "require_code_owner_review": false, + "required_checks": [ + "validate-profile" + ], + "path_scoped_checks": [], + "admission_gaps": [ + "Make validate-profile report on every pull request. The check is small and needs no new matrix." + ], + "admission_workflows": [ + { + "path": ".github/workflows/profile-consistency.yml", + "required_patterns": [], + "forbidden_patterns": [ + "(?m)^[ ]{4}paths(?:-ignore)?:\\s*$" + ] + } + ], + "release_tag_patterns": [ + "refs/tags/v*" + ], + "release_environments": [], + "release_workflows": [] + } + ], + "plan_constraints": [ + { + "repository": "openadapt-cloud", + "visibility": "private", + "mode": "audit-only", + "managed": false, + "current_plan": "GitHub Free organization", + "constraint": "GitHub artifact attestations for private repositories require GitHub Enterprise Cloud.", + "required_fallback": "Keep the existing signed Ed25519 evidence envelope and public verifier until the organization has GitHub Enterprise Cloud.", + "apply_rule": "This tool must never mutate openadapt-cloud." + } + ] +} diff --git a/scripts/manage_github_protection.py b/scripts/manage_github_protection.py new file mode 100755 index 0000000..280d088 --- /dev/null +++ b/scripts/manage_github_protection.py @@ -0,0 +1,1058 @@ +#!/usr/bin/env python3 +"""Plan, apply, and verify the OpenAdapt core GitHub protection policy. + +The plan and verify commands only issue GET requests. The apply command needs a +fresh plan, an exact confirmation value, and an unchanged main commit for every +managed repository. The tool does not delete an environment deployment policy +unless the operator adds the explicit prune flag. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +import re +import subprocess +import sys +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Protocol +from urllib.parse import quote + +API_VERSION = "2026-03-10" +PLAN_MAX_AGE_SECONDS = 900 +EXPECTED_REPOSITORIES = { + ".github", + "OpenAdapt", + "openadapt-capture", + "openadapt-desktop", + "openadapt-evals", + "openadapt-flow", + "openadapt-ops", + "openadapt-web", +} +ACTIVE_CHECK_STATES = {"queued", "in_progress", "pending", "requested", "waiting"} +MANAGED_RULESET_NAMES = ( + "OpenAdapt policy: protected main", + "OpenAdapt policy: release tag creation", + "OpenAdapt policy: immutable release tags", +) + + +class PolicyError(RuntimeError): + """The policy or live state is unsafe or invalid.""" + + +class GitHubError(RuntimeError): + """A GitHub CLI request failed.""" + + +class GitHubClient(Protocol): + def get(self, path: str, *, optional: bool = False) -> Any: + """Return one GitHub REST response.""" + + def write( + self, method: str, path: str, payload: Mapping[str, Any] | None = None + ) -> Any: + """Issue one GitHub REST mutation.""" + + +class GhApiClient: + """Small fail-closed wrapper around ``gh api``.""" + + def __init__(self, *, allow_writes: bool = False) -> None: + self.allow_writes = allow_writes + + @staticmethod + def require_auth() -> None: + result = subprocess.run( + ["gh", "auth", "status", "--hostname", "github.com"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + raise GitHubError(f"GitHub authentication is not valid: {detail}") + + def _request( + self, + method: str, + path: str, + payload: Mapping[str, Any] | None = None, + *, + optional: bool = False, + ) -> Any: + if method != "GET" and not self.allow_writes: + raise GitHubError(f"dry-run client refused {method} {path}") + command = [ + "gh", + "api", + "--method", + method, + "-H", + "Accept: application/vnd.github+json", + "-H", + f"X-GitHub-Api-Version: {API_VERSION}", + path, + ] + stdin = None + if payload is not None: + command.extend(["--input", "-"]) + stdin = json.dumps(payload, sort_keys=True) + result = subprocess.run( + command, + input=stdin, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + if optional and ("HTTP 404" in detail or "Not Found" in detail): + return None + raise GitHubError(f"{method} {path} failed: {detail}") + if not result.stdout.strip(): + return None + try: + return json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise GitHubError(f"{method} {path} returned invalid JSON") from exc + + def get(self, path: str, *, optional: bool = False) -> Any: + return self._request("GET", path, optional=optional) + + def write( + self, method: str, path: str, payload: Mapping[str, Any] | None = None + ) -> Any: + if method not in {"POST", "PUT", "PATCH", "DELETE"}: + raise GitHubError(f"unsupported write method: {method}") + return self._request(method, path, payload) + + +@dataclass(frozen=True) +class ReleaseActor: + actor_id: int + app_slug: str + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def _json_digest(value: Any) -> str: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def load_config(path: Path) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise PolicyError(f"cannot read policy config {path}: {exc}") from exc + validate_config(data) + return data + + +def _require_list(value: Any, field: str) -> list[Any]: + if not isinstance(value, list): + raise PolicyError(f"{field} must be a list") + return value + + +def validate_config(config: Mapping[str, Any]) -> None: + if config.get("schema_version") != 1: + raise PolicyError("schema_version must be 1") + if config.get("organization") != "OpenAdaptAI": + raise PolicyError("organization must be OpenAdaptAI") + live_audit = config.get("live_audit") + if not isinstance(live_audit, Mapping): + raise PolicyError("live_audit must be an object") + for field in ( + "repository_ruleset_counts", + "main_protected", + "release_environment_state", + ): + values = live_audit.get(field) + if not isinstance(values, Mapping) or set(values) != EXPECTED_REPOSITORIES: + raise PolicyError(f"live_audit.{field} must cover the eight core repositories") + actions_id = config.get("github_actions_integration_id") + if not isinstance(actions_id, int) or actions_id <= 0: + raise PolicyError("github_actions_integration_id must be a positive integer") + environment_defaults = config.get("environment_defaults") + if environment_defaults != {"wait_timer": 0, "prevent_self_review": False}: + raise PolicyError("environment_defaults must define the reviewed release gate") + + repositories = _require_list(config.get("repositories"), "repositories") + names = [repo.get("name") for repo in repositories if isinstance(repo, Mapping)] + if set(names) != EXPECTED_REPOSITORIES or len(names) != len(EXPECTED_REPOSITORIES): + raise PolicyError( + "repositories must contain exactly the eight reviewed OpenAdapt core repositories" + ) + + for repo in repositories: + if not isinstance(repo, Mapping): + raise PolicyError("each repository policy must be an object") + name = repo.get("name") + if repo.get("visibility") != "public": + raise PolicyError(f"{name}: managed repository must be public") + if repo.get("default_branch") != "main": + raise PolicyError(f"{name}: default_branch must be main") + sha = repo.get("audited_main_sha") + if not isinstance(sha, str) or re.fullmatch(r"[0-9a-f]{40}", sha) is None: + raise PolicyError(f"{name}: audited_main_sha must be a full commit SHA") + required = _require_list(repo.get("required_checks"), f"{name}.required_checks") + scoped = _require_list(repo.get("path_scoped_checks"), f"{name}.path_scoped_checks") + if any(not isinstance(item, str) or not item for item in required + scoped): + raise PolicyError(f"{name}: check names must be non-empty strings") + if len(required) != len(set(required)): + raise PolicyError(f"{name}: required_checks contains duplicates") + overlap = set(required).intersection(scoped) + if overlap: + raise PolicyError(f"{name}: path-scoped checks cannot be required: {sorted(overlap)}") + tags = _require_list(repo.get("release_tag_patterns"), f"{name}.release_tag_patterns") + if not tags or any( + not isinstance(pattern, str) or not pattern.startswith("refs/tags/") + for pattern in tags + ): + raise PolicyError(f"{name}: release tag patterns must use refs/tags/") + environments = _require_list( + repo.get("release_environments"), f"{name}.release_environments" + ) + environment_names = [item.get("name") for item in environments] + if len(environment_names) != len(set(environment_names)): + raise PolicyError(f"{name}: duplicate release environment") + for environment in environments: + policies = _require_list( + environment.get("deployment_policies"), + f"{name}.{environment.get('name')}.deployment_policies", + ) + if not policies: + raise PolicyError(f"{name}: release environment needs a deployment policy") + for policy in policies: + if policy.get("type") not in {"branch", "tag"} or not policy.get("name"): + raise PolicyError(f"{name}: invalid environment deployment policy") + workflows = _require_list( + repo.get("release_workflows"), f"{name}.release_workflows" + ) + if workflows and "release-identity" not in environment_names: + raise PolicyError(f"{name}: publishing repository needs release-identity") + admission_workflows = _require_list( + repo.get("admission_workflows", []), f"{name}.admission_workflows" + ) + for workflow in workflows + admission_workflows: + path = workflow.get("path") + if not isinstance(path, str) or not path.startswith(".github/workflows/"): + raise PolicyError(f"{name}: invalid release workflow path") + for field in ("required_patterns", "forbidden_patterns"): + patterns = _require_list(workflow.get(field), f"{name}.{path}.{field}") + for pattern in patterns: + try: + re.compile(pattern) + except (TypeError, re.error) as exc: + raise PolicyError(f"{name}: invalid workflow pattern {pattern!r}") from exc + + constraints = _require_list(config.get("plan_constraints"), "plan_constraints") + cloud = [item for item in constraints if item.get("repository") == "openadapt-cloud"] + if len(cloud) != 1 or cloud[0].get("managed") is not False: + raise PolicyError("openadapt-cloud must exist once as an unmanaged constraint") + if cloud[0].get("mode") != "audit-only": + raise PolicyError("openadapt-cloud must remain audit-only") + + +def _resolve_release_actor( + client: GitHubClient, config: Mapping[str, Any], blockers: list[dict[str, str]] +) -> ReleaseActor | None: + identity = config["release_identity"] + actor_id = identity.get("actor_id") + source = "config" + if actor_id is None: + source = identity["actor_id_environment"] + raw = os.environ.get(source) + if raw: + try: + actor_id = int(raw) + except ValueError: + actor_id = None + if not isinstance(actor_id, int) or actor_id <= 0: + blockers.append( + { + "code": "release_identity_unresolved", + "message": ( + "Set OPENADAPT_RELEASE_APP_ID to the reviewed openadapt-release " + "GitHub App ID before apply." + ), + } + ) + return None + + slug = identity["app_slug"] + app = client.get(f"/apps/{quote(slug, safe='')}", optional=True) + if not isinstance(app, Mapping): + blockers.append( + { + "code": "release_identity_not_found", + "message": f"GitHub App {slug!r} from {source} was not found.", + } + ) + return None + if app.get("id") != actor_id or app.get("slug") != slug: + blockers.append( + { + "code": "release_identity_mismatch", + "message": f"GitHub App {slug!r} does not have actor ID {actor_id}.", + } + ) + return None + owner = config["organization"] + response = client.get(f"/orgs/{owner}/installations?per_page=100") + installations = ( + response.get("installations", []) if isinstance(response, Mapping) else response + ) + installation = next( + ( + item + for item in installations or [] + if item.get("app_id") == actor_id and item.get("app_slug") == slug + ), + None, + ) + if installation is None: + blockers.append( + { + "code": "release_identity_not_installed", + "message": f"GitHub App {slug!r} is not installed for {owner}.", + } + ) + return None + if installation.get("repository_selection") != "all": + repository_response = client.get( + f"/user/installations/{installation['id']}/repositories?per_page=100" + ) + installed_names = { + item.get("name") for item in repository_response.get("repositories", []) + } + missing = EXPECTED_REPOSITORIES.difference(installed_names) + if missing: + blockers.append( + { + "code": "release_identity_repository_scope", + "message": ( + f"GitHub App {slug!r} is not installed on: " + f"{', '.join(sorted(missing))}." + ), + } + ) + return None + return ReleaseActor(actor_id=actor_id, app_slug=slug) + + +def _verify_reviewer( + client: GitHubClient, config: Mapping[str, Any], blockers: list[dict[str, str]] +) -> None: + reviewer = config["environment_reviewer"] + user = client.get(f"/users/{quote(reviewer['login'], safe='')}", optional=True) + if not isinstance(user, Mapping) or user.get("id") != reviewer.get("id"): + blockers.append( + { + "code": "environment_reviewer_mismatch", + "message": ( + f"Environment reviewer {reviewer['login']!r} does not have " + f"reviewed ID {reviewer['id']}." + ), + } + ) + + +def _pull_request_rule(config: Mapping[str, Any], repo: Mapping[str, Any]) -> dict[str, Any]: + defaults = config["main_rule_defaults"] + return { + "type": "pull_request", + "parameters": { + "allowed_merge_methods": defaults["allowed_merge_methods"], + "dismiss_stale_reviews_on_push": defaults["dismiss_stale_reviews"], + "require_code_owner_review": repo["require_code_owner_review"], + "require_last_push_approval": defaults["require_last_push_approval"], + "required_approving_review_count": defaults["required_approvals"], + "required_review_thread_resolution": defaults[ + "require_review_thread_resolution" + ], + }, + } + + +def desired_rulesets( + config: Mapping[str, Any], repo: Mapping[str, Any], actor: ReleaseActor | None +) -> list[dict[str, Any]]: + rules: list[dict[str, Any]] = [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + _pull_request_rule(config, repo), + ] + checks = repo["required_checks"] + if checks: + rules.append( + { + "type": "required_status_checks", + "parameters": { + "do_not_enforce_on_create": False, + "strict_required_status_checks_policy": config["main_rule_defaults"][ + "strict_status_checks" + ], + "required_status_checks": [ + { + "context": context, + "integration_id": config["github_actions_integration_id"], + } + for context in checks + ], + }, + } + ) + + main = { + "name": MANAGED_RULESET_NAMES[0], + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": {"include": ["refs/heads/main"], "exclude": []} + }, + "rules": rules, + } + immutable = { + "name": MANAGED_RULESET_NAMES[2], + "target": "tag", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": {"include": repo["release_tag_patterns"], "exclude": []} + }, + "rules": [ + {"type": "update", "parameters": {"update_allows_fetch_and_merge": False}}, + {"type": "deletion"}, + {"type": "non_fast_forward"}, + ], + } + result = [main, immutable] + if actor is not None: + result.append( + { + "name": MANAGED_RULESET_NAMES[1], + "target": "tag", + "enforcement": "active", + "bypass_actors": [ + { + "actor_id": actor.actor_id, + "actor_type": "Integration", + "bypass_mode": config["release_identity"]["bypass_mode"], + } + ], + "conditions": { + "ref_name": { + "include": repo["release_tag_patterns"], + "exclude": [], + } + }, + "rules": [{"type": "creation"}], + } + ) + return result + + +def desired_environment( + config: Mapping[str, Any], environment: Mapping[str, Any] +) -> dict[str, Any]: + reviewer = config["environment_reviewer"] + defaults = config["environment_defaults"] + return { + "wait_timer": defaults["wait_timer"], + "prevent_self_review": defaults["prevent_self_review"], + "reviewers": [{"type": reviewer["type"], "id": reviewer["id"]}], + "deployment_branch_policy": { + "protected_branches": False, + "custom_branch_policies": True, + }, + } + + +def _normalize_ruleset(value: Mapping[str, Any]) -> dict[str, Any]: + rules: list[dict[str, Any]] = [] + for rule in value.get("rules", []): + normalized: dict[str, Any] = {"type": rule.get("type")} + parameters = rule.get("parameters") + if rule.get("type") == "pull_request" and isinstance(parameters, Mapping): + normalized["parameters"] = { + key: parameters.get(key) + for key in ( + "allowed_merge_methods", + "dismiss_stale_reviews_on_push", + "require_code_owner_review", + "require_last_push_approval", + "required_approving_review_count", + "required_review_thread_resolution", + ) + } + elif rule.get("type") == "required_status_checks" and isinstance( + parameters, Mapping + ): + checks = [ + { + "context": check.get("context"), + "integration_id": check.get("integration_id"), + } + for check in parameters.get("required_status_checks", []) + ] + normalized["parameters"] = { + "do_not_enforce_on_create": parameters.get("do_not_enforce_on_create", False), + "strict_required_status_checks_policy": parameters.get( + "strict_required_status_checks_policy" + ), + "required_status_checks": sorted(checks, key=lambda item: item["context"]), + } + elif rule.get("type") == "update" and isinstance(parameters, Mapping): + normalized["parameters"] = { + "update_allows_fetch_and_merge": parameters.get( + "update_allows_fetch_and_merge" + ) + } + rules.append(normalized) + rules.sort(key=lambda item: (item["type"], json.dumps(item, sort_keys=True))) + bypass = [ + { + "actor_id": item.get("actor_id"), + "actor_type": item.get("actor_type"), + "bypass_mode": item.get("bypass_mode"), + } + for item in value.get("bypass_actors", []) + ] + bypass.sort(key=lambda item: json.dumps(item, sort_keys=True)) + ref_name = value.get("conditions", {}).get("ref_name", {}) + return { + "name": value.get("name"), + "target": value.get("target"), + "enforcement": value.get("enforcement"), + "bypass_actors": bypass, + "conditions": { + "ref_name": { + "include": sorted(ref_name.get("include", [])), + "exclude": sorted(ref_name.get("exclude", [])), + } + }, + "rules": rules, + } + + +def _normalize_environment(value: Mapping[str, Any]) -> dict[str, Any]: + reviewer_rule = next( + ( + rule + for rule in value.get("protection_rules", []) + if rule.get("type") == "required_reviewers" + ), + {}, + ) + reviewers = [] + for item in reviewer_rule.get("reviewers", []): + identity = item.get("reviewer", {}) + reviewers.append({"type": item.get("type"), "id": identity.get("id")}) + reviewers.sort(key=lambda item: (item["type"], item["id"])) + wait_rule = next( + (rule for rule in value.get("protection_rules", []) if rule.get("type") == "wait_timer"), + {}, + ) + deployment = value.get("deployment_branch_policy") or {} + return { + "wait_timer": wait_rule.get("wait_timer", 0), + "prevent_self_review": reviewer_rule.get("prevent_self_review", False), + "reviewers": reviewers, + "deployment_branch_policy": { + "protected_branches": deployment.get("protected_branches"), + "custom_branch_policies": deployment.get("custom_branch_policies"), + }, + } + + +def _workflow_text(client: GitHubClient, owner: str, repo: str, path: str) -> str | None: + encoded_path = quote(path, safe="/") + response = client.get( + f"/repos/{owner}/{repo}/contents/{encoded_path}?ref=main", optional=True + ) + if not isinstance(response, Mapping) or response.get("type") != "file": + return None + try: + return base64.b64decode(response["content"]).decode("utf-8") + except (KeyError, ValueError, UnicodeDecodeError) as exc: + raise GitHubError(f"cannot decode {owner}/{repo}/{path}") from exc + + +def _workflow_contract_blockers( + client: GitHubClient, + owner: str, + repo: Mapping[str, Any], + contract_field: str, + code_prefix: str, +) -> list[dict[str, str]]: + blockers: list[dict[str, str]] = [] + for workflow in repo.get(contract_field, []): + path = workflow["path"] + content = _workflow_text(client, owner, repo["name"], path) + if content is None: + blockers.append( + { + "code": f"{code_prefix}_workflow_missing", + "message": f"{repo['name']}: {path} does not exist on main.", + } + ) + continue + for pattern in workflow["required_patterns"]: + if re.search(pattern, content) is None: + blockers.append( + { + "code": f"{code_prefix}_workflow_contract_missing", + "message": f"{repo['name']}: {path} does not match {pattern!r}.", + } + ) + for pattern in workflow["forbidden_patterns"]: + if re.search(pattern, content) is not None: + blockers.append( + { + "code": f"{code_prefix}_workflow_forbidden_pattern", + "message": f"{repo['name']}: {path} still matches {pattern!r}.", + } + ) + return blockers + + +def _list_rulesets(client: GitHubClient, owner: str, repo: str) -> dict[str, Mapping[str, Any]]: + response = client.get(f"/repos/{owner}/{repo}/rulesets?includes_parents=false&per_page=100") + if not isinstance(response, list): + raise GitHubError(f"{owner}/{repo}: ruleset list is not an array") + result: dict[str, Mapping[str, Any]] = {} + for summary in response: + if summary.get("name") not in MANAGED_RULESET_NAMES: + continue + detail = client.get(f"/repos/{owner}/{repo}/rulesets/{summary['id']}") + result[summary["name"]] = detail + return result + + +def _open_pull_requests( + client: GitHubClient, owner: str, repo: str, branch: str +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + response = client.get( + f"/repos/{owner}/{repo}/pulls?state=open&base={quote(branch, safe='')}&per_page=100" + ) + if not isinstance(response, list): + raise GitHubError(f"{owner}/{repo}: pull request list is not an array") + pulls: list[dict[str, Any]] = [] + active: list[dict[str, Any]] = [] + for pull in response: + head = pull.get("head", {}).get("sha") + pulls.append( + { + "number": pull.get("number"), + "draft": pull.get("draft", False), + "head_sha": head, + } + ) + if not head: + continue + checks = client.get( + f"/repos/{owner}/{repo}/commits/{head}/check-runs?per_page=100" + ) + for check in checks.get("check_runs", []): + if check.get("status") in ACTIVE_CHECK_STATES: + active.append( + { + "pull_request": pull.get("number"), + "name": check.get("name"), + "status": check.get("status"), + } + ) + return pulls, active + + +def _environment_actions( + client: GitHubClient, + config: Mapping[str, Any], + owner: str, + repo: Mapping[str, Any], +) -> tuple[list[dict[str, Any]], bool]: + actions: list[dict[str, Any]] = [] + prune_needed = False + for environment in repo["release_environments"]: + name = environment["name"] + encoded = quote(name, safe="") + current = client.get( + f"/repos/{owner}/{repo['name']}/environments/{encoded}", optional=True + ) + desired = desired_environment(config, environment) + if not isinstance(current, Mapping) or _normalize_environment(current) != desired: + actions.append( + { + "kind": "put_environment", + "environment": name, + "payload": desired, + } + ) + current_policies: list[Mapping[str, Any]] = [] + if isinstance(current, Mapping) and current.get("deployment_branch_policy", {}).get( + "custom_branch_policies" + ): + response = client.get( + f"/repos/{owner}/{repo['name']}/environments/{encoded}/deployment-branch-policies?per_page=100" + ) + current_policies = response.get("branch_policies", []) + if any(item.get("type") not in {"branch", "tag"} for item in current_policies): + raise GitHubError( + f"{owner}/{repo['name']}:{name}: GitHub omitted a deployment policy type" + ) + current_by_key = { + (item["type"], item.get("name")): item for item in current_policies + } + desired_keys = { + (item["type"], item["name"]) for item in environment["deployment_policies"] + } + for policy in environment["deployment_policies"]: + if (policy["type"], policy["name"]) not in current_by_key: + actions.append( + { + "kind": "create_environment_policy", + "environment": name, + "payload": policy, + } + ) + for key, policy in current_by_key.items(): + if key not in desired_keys: + prune_needed = True + actions.append( + { + "kind": "delete_environment_policy", + "environment": name, + "policy_id": policy.get("id"), + "current": {"type": key[0], "name": key[1]}, + } + ) + return actions, prune_needed + + +def build_plan(client: GitHubClient, config: Mapping[str, Any]) -> dict[str, Any]: + owner = config["organization"] + global_blockers: list[dict[str, str]] = [] + actor = _resolve_release_actor(client, config, global_blockers) + _verify_reviewer(client, config, global_blockers) + repositories: list[dict[str, Any]] = [] + + for repo in config["repositories"]: + name = repo["name"] + blockers: list[dict[str, str]] = [] + warnings = [ + {"code": "admission_gap", "message": message} + for message in repo.get("admission_gaps", []) + ] + metadata = client.get(f"/repos/{owner}/{name}") + expected_full_name = f"{owner}/{name}" + if metadata.get("full_name") != expected_full_name: + blockers.append( + { + "code": "repository_identity_mismatch", + "message": f"Expected {expected_full_name}, got {metadata.get('full_name')!r}.", + } + ) + actual_visibility = "private" if metadata.get("private") else "public" + if actual_visibility != repo["visibility"]: + blockers.append( + { + "code": "repository_visibility_mismatch", + "message": f"{name}: expected {repo['visibility']}, got {actual_visibility}.", + } + ) + if metadata.get("default_branch") != repo["default_branch"]: + blockers.append( + { + "code": "default_branch_mismatch", + "message": f"{name}: default branch is not {repo['default_branch']}.", + } + ) + commit = client.get(f"/repos/{owner}/{name}/commits/{repo['default_branch']}") + main_sha = commit.get("sha") + if main_sha != repo["audited_main_sha"]: + warnings.append( + { + "code": "audit_snapshot_advanced", + "message": ( + f"{name}: main advanced from {repo['audited_main_sha']} to {main_sha}. " + "The apply plan will bind the new SHA." + ), + } + ) + + pulls, active_checks = _open_pull_requests( + client, owner, name, repo["default_branch"] + ) + if pulls: + warnings.append( + { + "code": "open_pull_requests", + "message": f"{name}: {len(pulls)} open pull request(s) target main.", + } + ) + if active_checks: + blockers.append( + { + "code": "active_pull_request_checks", + "message": f"{name}: pull-request checks are still active.", + } + ) + + blockers.extend( + _workflow_contract_blockers( + client, owner, repo, "release_workflows", "release" + ) + ) + blockers.extend( + _workflow_contract_blockers( + client, owner, repo, "admission_workflows", "admission" + ) + ) + current_rulesets = _list_rulesets(client, owner, name) + actions: list[dict[str, Any]] = [] + for desired in desired_rulesets(config, repo, actor): + current = current_rulesets.get(desired["name"]) + if current is None: + actions.append( + {"kind": "create_ruleset", "name": desired["name"], "payload": desired} + ) + elif _normalize_ruleset(current) != _normalize_ruleset(desired): + actions.append( + { + "kind": "update_ruleset", + "name": desired["name"], + "ruleset_id": current.get("id"), + "payload": desired, + } + ) + + environment_actions, prune_needed = _environment_actions( + client, config, owner, repo + ) + actions.extend(environment_actions) + repositories.append( + { + "name": name, + "main_sha": main_sha, + "audited_main_sha": repo["audited_main_sha"], + "open_pull_requests": pulls, + "active_checks": active_checks, + "path_scoped_checks": repo["path_scoped_checks"], + "warnings": warnings, + "blockers": blockers, + "requires_environment_policy_prune": prune_needed, + "actions": actions, + } + ) + + blocker_count = len(global_blockers) + sum( + len(repo["blockers"]) for repo in repositories + ) + return { + "schema_version": 1, + "generated_at": _utc_now().isoformat(), + "max_age_seconds": PLAN_MAX_AGE_SECONDS, + "organization": owner, + "config_sha256": _json_digest(config), + "release_actor_id": actor.actor_id if actor else None, + "global_blockers": global_blockers, + "repositories": repositories, + "plan_constraints": config["plan_constraints"], + "blocker_count": blocker_count, + "safe_to_apply": blocker_count == 0, + } + + +def _plan_snapshot(plan: Mapping[str, Any]) -> dict[str, Any]: + return { + "config_sha256": plan.get("config_sha256"), + "release_actor_id": plan.get("release_actor_id"), + "repositories": [ + { + "name": repo.get("name"), + "main_sha": repo.get("main_sha"), + "actions": repo.get("actions"), + } + for repo in plan.get("repositories", []) + ], + } + + +def _parse_plan_time(value: Any) -> datetime: + if not isinstance(value, str): + raise PolicyError("plan has no generated_at time") + try: + result = datetime.fromisoformat(value) + except ValueError as exc: + raise PolicyError("plan generated_at time is invalid") from exc + if result.tzinfo is None: + raise PolicyError("plan generated_at time has no timezone") + return result.astimezone(timezone.utc) + + +def validate_plan_for_apply( + saved: Mapping[str, Any], current: Mapping[str, Any], config: Mapping[str, Any] +) -> None: + if saved.get("organization") != config["organization"]: + raise PolicyError("plan organization does not match the config") + age = (_utc_now() - _parse_plan_time(saved.get("generated_at"))).total_seconds() + if age < 0 or age > PLAN_MAX_AGE_SECONDS: + raise PolicyError("plan is stale; create a new plan") + if saved.get("blocker_count") != 0 or not saved.get("safe_to_apply"): + raise PolicyError("saved plan has blockers") + if current.get("blocker_count") != 0 or not current.get("safe_to_apply"): + raise PolicyError("live preflight has blockers") + if _plan_snapshot(saved) != _plan_snapshot(current): + raise PolicyError("live state changed after the saved plan") + + +def _apply_actions( + client: GitHubClient, + plan: Mapping[str, Any], + *, + prune_environment_policies: bool, +) -> None: + owner = plan["organization"] + if not prune_environment_policies and any( + repo.get("requires_environment_policy_prune") for repo in plan["repositories"] + ): + raise PolicyError( + "the plan removes environment policies; inspect it and add " + "--prune-environment-policies" + ) + for repo in plan["repositories"]: + name = repo["name"] + for action in repo["actions"]: + kind = action["kind"] + if kind == "create_ruleset": + client.write("POST", f"/repos/{owner}/{name}/rulesets", action["payload"]) + elif kind == "update_ruleset": + client.write( + "PUT", + f"/repos/{owner}/{name}/rulesets/{action['ruleset_id']}", + action["payload"], + ) + elif kind == "put_environment": + environment = quote(action["environment"], safe="") + client.write( + "PUT", + f"/repos/{owner}/{name}/environments/{environment}", + action["payload"], + ) + elif kind == "create_environment_policy": + environment = quote(action["environment"], safe="") + client.write( + "POST", + f"/repos/{owner}/{name}/environments/{environment}/deployment-branch-policies", + action["payload"], + ) + elif kind == "delete_environment_policy": + if not prune_environment_policies: + raise PolicyError("environment policy prune was not confirmed") + environment = quote(action["environment"], safe="") + client.write( + "DELETE", + ( + f"/repos/{owner}/{name}/environments/{environment}/" + f"deployment-branch-policies/{action['policy_id']}" + ), + ) + else: + raise PolicyError(f"unknown plan action: {kind}") + + +def _write_json(value: Any, output: Path | None) -> None: + text = json.dumps(value, indent=2, sort_keys=True) + "\n" + if output is None: + sys.stdout.write(text) + else: + output.write_text(text, encoding="utf-8") + print(f"Wrote {output}") + + +def _default_config() -> Path: + return Path(__file__).resolve().parents[1] / "ops/github/core-protection-policy.json" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, default=_default_config()) + commands = parser.add_subparsers(dest="command", required=True) + + commands.add_parser("validate-config", help="Validate the local JSON policy only") + + plan = commands.add_parser("plan", help="Read GitHub and write a non-mutating plan") + plan.add_argument("--output", type=Path) + + verify = commands.add_parser("verify", help="Verify live GitHub state against the policy") + verify.add_argument("--output", type=Path) + + apply = commands.add_parser("apply", help="Apply one fresh, reviewed plan") + apply.add_argument("--plan", type=Path, required=True) + apply.add_argument("--confirm", required=True) + apply.add_argument("--prune-environment-policies", action="store_true") + return parser + + +def main(argv: Iterable[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + config = load_config(args.config) + if args.command == "validate-config": + print( + f"Valid policy for {len(config['repositories'])} managed repositories; " + "openadapt-cloud is audit-only." + ) + return 0 + + GhApiClient.require_auth() + read_client = GhApiClient(allow_writes=False) + plan = build_plan(read_client, config) + if args.command == "plan": + _write_json(plan, args.output) + return 0 if plan["safe_to_apply"] else 2 + if args.command == "verify": + _write_json(plan, args.output) + has_actions = any(repo["actions"] for repo in plan["repositories"]) + return 0 if plan["safe_to_apply"] and not has_actions else 2 + + if args.confirm != "APPLY OpenAdaptAI CORE PROTECTION": + raise PolicyError("apply confirmation value is invalid") + try: + saved = json.loads(args.plan.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise PolicyError(f"cannot read apply plan: {exc}") from exc + validate_plan_for_apply(saved, plan, config) + write_client = GhApiClient(allow_writes=True) + _apply_actions( + write_client, + plan, + prune_environment_policies=args.prune_environment_policies, + ) + verified = build_plan(read_client, config) + if verified["blocker_count"] or any( + repo["actions"] for repo in verified["repositories"] + ): + raise PolicyError("post-apply verification did not converge") + print("Applied and verified the OpenAdapt core GitHub protection policy.") + return 0 + except (GitHubError, PolicyError) as exc: + print(f"REFUSED: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_manage_github_protection.py b/tests/test_manage_github_protection.py new file mode 100644 index 0000000..6de1680 --- /dev/null +++ b/tests/test_manage_github_protection.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import base64 +import json +import pathlib +import sys +from collections.abc import Mapping +from datetime import datetime, timedelta, timezone +from typing import Any + +import pytest + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from manage_github_protection import ( + GhApiClient, + GitHubError, + PolicyError, + ReleaseActor, + _apply_actions, + build_plan, + desired_rulesets, + load_config, + validate_config, + validate_plan_for_apply, +) + +CONFIG_PATH = REPO_ROOT / "ops/github/core-protection-policy.json" + + +class ReadOnlyFixtureGitHub: + def __init__( + self, + config: Mapping[str, Any], + *, + active_repo: str | None = None, + path_filtered_repo: str | None = None, + ) -> None: + self.config = config + self.active_repo = active_repo + self.path_filtered_repo = path_filtered_repo + self.writes: list[tuple[str, str, Mapping[str, Any]]] = [] + self.by_name = {repo["name"]: repo for repo in config["repositories"]} + + def get(self, path: str, *, optional: bool = False) -> Any: + if path == "/apps/openadapt-release": + return {"id": 991122, "slug": "openadapt-release"} + if path == "/users/abrichr": + return {"id": 774615, "login": "abrichr"} + if path == "/orgs/OpenAdaptAI/installations?per_page=100": + return { + "installations": [ + { + "id": 551100, + "app_id": 991122, + "app_slug": "openadapt-release", + "repository_selection": "all", + } + ] + } + parts = path.split("?")[0].split("/") + if len(parts) >= 4 and parts[1] == "repos": + name = parts[3] + repo = self.by_name[name] + if len(parts) == 4: + return { + "full_name": f"OpenAdaptAI/{name}", + "private": False, + "default_branch": "main", + } + if parts[4] == "commits" and parts[5] == "main": + return {"sha": repo["audited_main_sha"]} + if parts[4] == "commits" and parts[-1] == "check-runs": + if name == self.active_repo: + return { + "check_runs": [ + {"name": "test", "status": "in_progress", "conclusion": None} + ] + } + return {"check_runs": []} + if parts[4] == "pulls": + if name == self.active_repo: + return [ + { + "number": 12, + "draft": False, + "head": {"sha": "f" * 40}, + } + ] + return [] + if parts[4] == "rulesets": + return [] + if parts[4] == "environments": + return None + if parts[4] == "contents": + workflow = ( + "permissions:\n" + " id-token: write\n" + "jobs:\n" + " prepare:\n" + " environment: release-identity\n" + " pypi:\n" + " environment: pypi\n" + " native:\n" + " environment: native-release\n" + ) + if name == self.path_filtered_repo: + workflow += "pull_request:\n paths-ignore:\n - docs/**\n" + return { + "type": "file", + "content": base64.b64encode(workflow.encode()).decode(), + } + raise AssertionError(f"unexpected GET {path}") + + def write( + self, method: str, path: str, payload: Mapping[str, Any] | None = None + ) -> Any: + self.writes.append((method, path, payload or {})) + return {} + + +def config() -> dict[str, Any]: + value = load_config(CONFIG_PATH) + value["release_identity"]["actor_id"] = 991122 + return value + + +def test_policy_has_only_the_reviewed_owned_repositories() -> None: + value = load_config(CONFIG_PATH) + assert {repo["name"] for repo in value["repositories"]} == { + ".github", + "OpenAdapt", + "openadapt-capture", + "openadapt-desktop", + "openadapt-evals", + "openadapt-flow", + "openadapt-ops", + "openadapt-web", + } + assert value["plan_constraints"] == [ + { + "repository": "openadapt-cloud", + "visibility": "private", + "mode": "audit-only", + "managed": False, + "current_plan": "GitHub Free organization", + "constraint": ( + "GitHub artifact attestations for private repositories require " + "GitHub Enterprise Cloud." + ), + "required_fallback": ( + "Keep the existing signed Ed25519 evidence envelope and public verifier " + "until the organization has GitHub Enterprise Cloud." + ), + "apply_rule": "This tool must never mutate openadapt-cloud.", + } + ] + + +def test_path_scoped_check_cannot_also_be_required() -> None: + value = config() + value["repositories"][0]["path_scoped_checks"].append( + value["repositories"][0]["required_checks"][0] + ) + with pytest.raises(PolicyError, match="path-scoped checks cannot be required"): + validate_config(value) + + +def test_main_has_no_bypass_and_tag_immutability_has_no_bypass() -> None: + value = config() + repo = value["repositories"][0] + actor = ReleaseActor(actor_id=991122, app_slug="openadapt-release") + by_name = {item["name"]: item for item in desired_rulesets(value, repo, actor)} + + main = by_name["OpenAdapt policy: protected main"] + creation = by_name["OpenAdapt policy: release tag creation"] + immutable = by_name["OpenAdapt policy: immutable release tags"] + assert main["bypass_actors"] == [] + assert creation["bypass_actors"] == [ + { + "actor_id": 991122, + "actor_type": "Integration", + "bypass_mode": "always", + } + ] + assert creation["rules"] == [{"type": "creation"}] + assert immutable["bypass_actors"] == [] + assert {rule["type"] for rule in immutable["rules"]} == { + "update", + "deletion", + "non_fast_forward", + } + + +def test_plan_is_read_only_and_never_manages_private_cloud(monkeypatch: pytest.MonkeyPatch) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + github = ReadOnlyFixtureGitHub(value) + plan = build_plan(github, value) + + assert plan["safe_to_apply"] is True + assert plan["blocker_count"] == 0 + assert github.writes == [] + assert {repo["name"] for repo in plan["repositories"]} == { + repo["name"] for repo in value["repositories"] + } + assert "openadapt-cloud" not in {repo["name"] for repo in plan["repositories"]} + assert all(repo["actions"] for repo in plan["repositories"]) + + +def test_active_pull_request_check_blocks_apply(monkeypatch: pytest.MonkeyPatch) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + plan = build_plan(ReadOnlyFixtureGitHub(value, active_repo="openadapt-flow"), value) + flow = next(repo for repo in plan["repositories"] if repo["name"] == "openadapt-flow") + assert plan["safe_to_apply"] is False + assert flow["active_checks"] == [ + {"pull_request": 12, "name": "test", "status": "in_progress"} + ] + assert {item["code"] for item in flow["blockers"]} == { + "active_pull_request_checks" + } + + +def test_path_filtered_target_check_blocks_apply(monkeypatch: pytest.MonkeyPatch) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + plan = build_plan( + ReadOnlyFixtureGitHub(value, path_filtered_repo="openadapt-web"), value + ) + web = next(repo for repo in plan["repositories"] if repo["name"] == "openadapt-web") + assert plan["safe_to_apply"] is False + assert {item["code"] for item in web["blockers"]} == { + "admission_workflow_forbidden_pattern" + } + + +def test_dry_run_client_refuses_a_mutation_before_starting_gh() -> None: + with pytest.raises(GitHubError, match="dry-run client refused"): + GhApiClient(allow_writes=False).write("PUT", "/repos/example/example", {}) + + +def test_apply_refuses_unconfirmed_environment_policy_deletion() -> None: + plan = { + "organization": "OpenAdaptAI", + "repositories": [ + { + "name": "OpenAdapt", + "requires_environment_policy_prune": True, + "actions": [ + { + "kind": "delete_environment_policy", + "environment": "pypi", + "policy_id": 3, + } + ], + } + ], + } + github = ReadOnlyFixtureGitHub(config()) + with pytest.raises(PolicyError, match="--prune-environment-policies"): + _apply_actions(github, plan, prune_environment_policies=False) + assert github.writes == [] + + +def test_apply_plan_must_be_fresh_and_unchanged() -> None: + value = config() + base = { + "organization": "OpenAdaptAI", + "generated_at": (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat(), + "blocker_count": 0, + "safe_to_apply": True, + "config_sha256": "a", + "release_actor_id": 1, + "repositories": [], + } + with pytest.raises(PolicyError, match="stale"): + validate_plan_for_apply(base, base, value) + + fresh = json.loads(json.dumps(base)) + fresh["generated_at"] = datetime.now(timezone.utc).isoformat() + changed = json.loads(json.dumps(fresh)) + changed["release_actor_id"] = 2 + with pytest.raises(PolicyError, match="live state changed"): + validate_plan_for_apply(fresh, changed, value) From 60a04b4812758b740f1c196d0f1b15544edaad39 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 20 Aug 2026 12:56:18 -0400 Subject: [PATCH 2/2] feat: guard lifecycle and docs governance --- ops/github/README.md | 118 +++- ops/github/core-protection-policy.json | 497 ++++++++++++++- scripts/manage_github_protection.py | 828 +++++++++++++++++++++++-- tests/test_manage_github_protection.py | 674 +++++++++++++++++++- 4 files changed, 2044 insertions(+), 73 deletions(-) diff --git a/ops/github/README.md b/ops/github/README.md index 6aea1d5..6f669be 100644 --- a/ops/github/README.md +++ b/ops/github/README.md @@ -19,6 +19,9 @@ The policy has these results: - A protected environment admits only the exact branch or tag pattern in the policy. - A required reviewer must approve each release environment use. +- The lifecycle environments prevent self-review. The founder reviews a run + that the separate lifecycle App starts. +- The lifecycle App has no `main` bypass and no Contents permission. GitHub documents the applicable [repository ruleset API](https://docs.github.com/en/rest/repos/rules), [environment API](https://docs.github.com/en/rest/deployments/environments), and @@ -51,6 +54,16 @@ It admitted `desktop-v*` and `ffmpeg-runtime-v8.1.2-r1`. The target policy uses environment and the PyPI environment. The tool does not change the Ops backup environments. +The organization did not have an `openadapt-lifecycle` App installation. The +target policy keeps the App ID, bot actor ID, and installation ID unresolved. +The plan and apply operations refuse this state. Do not create a lifecycle +environment until the exact App installation exists. + +Ops `main` had no protection. The existing `production-backup` and +`production-backup-monitor` environments had no protection rule, deployment +branch policy, or reviewer. This policy records that finding. It does not +change the two operational backup environments. + ## Required check selection The policy requires only a check that starts on every pull request. GitHub can @@ -59,10 +72,93 @@ not match. Such a workflow can then stop an unrelated pull request. The policy records a path-scoped check in `path_scoped_checks`. It does not make that check a global requirement. The target policy does require -`build-and-e2e` in `openadapt-web` and `validate-profile` in `.github`. The tool -refuses an apply while either workflow has pull request path filters. Keep each -exact check name. Use a cheap internal path classifier when the expensive work -does not apply. +`build-and-e2e` in `openadapt-web`, `validate-profile` in `.github`, and +`Validate Production lifecycle` in Ops. The tool refuses an apply while one of +these workflows has pull request path filters. Keep each exact check name. Use +a cheap internal path classifier when the expensive work does not apply. + +## Documentation and lifecycle environments + +Ops uses `github-pages` for the documentation deployment. It admits only +`main`. The registered `.github/workflows/sync.yml` contract requires the +`github-pages` environment, `pages: write`, and `id-token: write`. + +Documentation synchronization uses a separate `openadapt-docs` App. The App is +not present. The policy keeps its App ID, bot actor ID, and installation ID +unresolved. It has an exact Ops-only scope. It has Actions write, Metadata read, +and Pull requests write. It has no Contents write and no ruleset bypass. + +The dispatch job enters `production-docs-deploy`. This environment admits only +`main`, requires `abrichr`, and prevents self-review. `sync.yml` accepts only +`workflow_dispatch` when both the actor and triggering actor are +`openadapt-docs[bot]`. It binds the source repository, +source `main` ref, source commit, `push` event, and idempotency value. It checks +the source repository against the reviewed `repos.yml` allowlist. It verifies +that the source commit is the current default-branch commit before an effect. +The idempotency value is `docs-sync:` plus 64 lowercase hexadecimal characters. +It uses the `OpenAdapt docs sync dispatch v1` domain and binds the closed +repository, ref, commit, and event tuple. It does not accept +`repository_dispatch` or the old `repo-updated` event. After approval, the +workflow token can push only an automation branch. The docs App token creates +the pull request. A later approved `main` push enters `github-pages` and deploys +the site. The workflow must not push to `main` directly. + +The global environment default stays at `prevent_self_review: false`. The five +lifecycle environments set an explicit override to `true`: + +- `.github` uses `production-lifecycle-activation` only from + `.github/workflows/production-lifecycle-activation.yml`. +- `.github` uses `qualification-authority-state` only from + `.github/workflows/qualification-authority-state.yml`. +- `.github` uses `qualification-revocation-state` only from + `.github/workflows/qualification-revocation-state.yml`. +- Evals uses `production-lifecycle-evidence` only from + `.github/workflows/production-lifecycle-evidence.yml`. +- Ops uses `production-lifecycle-projection` only from + `.github/workflows/production-lifecycle-projection.yml`. + +Each environment admits only `main`. The required reviewer is `abrichr`. The +workflow actor and triggering actor must be `openadapt-lifecycle[bot]`. The +policy verifies the exact App ID, bot actor ID, installation ID, and repository +variables. The installation scope must contain only `.github`, +`openadapt-evals`, and `openadapt-ops`. + +The two qualification workflows attest their exact candidate state and open a +reviewable pull request. They cannot push to `main`. The Ops projection accepts +only `production_lifecycle_ledger_changed` from exact `OpenAdaptAI/.github` +`main`. It binds the current 40-character source commit, the exact admissions +digest, the ledger-head digest, and the projection idempotency digest. Each +digest uses `sha256:` plus 64 lowercase hexadecimal characters. The ledger head +uses the `OpenAdapt production lifecycle ledger head v1\0` domain. Projection +idempotency uses the `OpenAdapt production lifecycle projection idempotency +v1\0` domain. + +The lifecycle App has only these repository permissions: + +- Actions: write +- Metadata: read +- Pull requests: write + +It has no Contents write permission. It has no ruleset bypass. After the +founder approves the environment, the workflow `GITHUB_TOKEN` pushes the +automation branch. The lifecycle App token creates the pull request. The +normal pull request checks then run. A lifecycle workflow must not push to +`main` directly. + +Actions write also permits the App to cancel or rerun workflow runs and delete +workflow artifacts. The exact repository scope limits this authority. The +policy inventories every workflow that accepts `workflow_dispatch` or +`repository_dispatch` in the three repositories. Only the five lifecycle +workflows can accept the lifecycle App actor. Each other manual path needs a +`reject-lifecycle-app` predecessor with no permission. It checks both +`github.actor` and `github.triggering_actor`. Each later job depends on that +predecessor and repeats both identity refusals before GitHub allocates a job. +A new or unguarded path blocks apply. + +Each dispatch path uses a workflow-and-event-specific concurrency group. It +sets `cancel-in-progress` to `false`. A manual run cannot cancel a real run. +Production evidence remains content-addressed outside mutable Actions +artifacts. ## Release sequence @@ -97,14 +193,21 @@ Create a live read-only plan: ```bash export OPENADAPT_RELEASE_APP_ID=123456 +export OPENADAPT_LIFECYCLE_APP_ID=234567 +export OPENADAPT_LIFECYCLE_ACTOR_ID=345678 +export OPENADAPT_LIFECYCLE_INSTALLATION_ID=456789 +export OPENADAPT_DOCS_APP_ID=567890 +export OPENADAPT_DOCS_ACTOR_ID=678901 +export OPENADAPT_DOCS_INSTALLATION_ID=789012 uv run python scripts/manage_github_protection.py plan \ --output /tmp/openadapt-github-protection-plan.json ``` The GitHub CLI token needs repository read access and organization installation read access for the plan. It needs repository administration write access for -an apply operation. The tool checks the app ID, installation scope, and the -environment reviewer ID against GitHub. +an apply operation. The tool checks all App identities, exact installation +permissions and scopes, repository identity variables, and the environment +reviewer ID against GitHub. Inspect the plan. Resolve every refusal. Wait until all pull request checks are complete. Then create a new plan. A plan expires after 15 minutes. @@ -119,7 +222,8 @@ uv run python scripts/manage_github_protection.py apply \ The apply operation checks every `main` commit again. It refuses a changed commit, a changed action list, an active pull request check, a missing release -identity, or an invalid workflow contract. +identity, a missing lifecycle identity, an unguarded dispatch workflow, or an +invalid workflow contract. It also refuses a missing docs identity. The tool does not remove an extra environment deployment policy by default. Inspect the planned deletion. Then add `--prune-environment-policies` if the diff --git a/ops/github/core-protection-policy.json b/ops/github/core-protection-policy.json index b42adf0..83a6fc8 100644 --- a/ops/github/core-protection-policy.json +++ b/ops/github/core-protection-policy.json @@ -32,11 +32,36 @@ "openadapt-capture": "No release environment", "openadapt-desktop": "native-release has reviewer 774615 and exact desktop-v* plus ffmpeg-runtime-v8.1.2-r1 tag policies", "openadapt-evals": "pypi exists with no protection rule and no deployment policy", - "openadapt-ops": "No release environment; operational environments are outside this policy", + "openadapt-ops": "production-backup and production-backup-monitor exist without a protection rule, deployment branch policy, or reviewer; operational backup environments remain outside this policy", "openadapt-web": "No release environment", ".github": "No release environment" } }, + "dispatch_privilege_audit": { + "observed_at": "2026-08-20", + "openadapt_ops_main_protected": false, + "unprotected_operational_environments": { + "production-backup": { + "protection_rules": 0, + "deployment_branch_policy": null, + "required_reviewers": [] + }, + "production-backup-monitor": { + "protection_rules": 0, + "deployment_branch_policy": null, + "required_reviewers": [] + } + }, + "lifecycle_app_installation": "absent", + "docs_app_installation": "absent", + "required_non_lifecycle_dispatch_guard": "A no-permission reject-lifecycle-app predecessor plus actor and triggering_actor rejection on every effect job", + "required_dispatch_concurrency": "A workflow-and-event-specific concurrency group with cancel-in-progress false", + "mutable_actions_capabilities": [ + "Cancel workflow runs", + "Rerun workflow runs", + "Delete workflow artifacts" + ] + }, "github_actions_integration_id": 15368, "main_rule_defaults": { "required_approvals": 1, @@ -63,6 +88,95 @@ ], "purpose": "Create release tags only. This identity has no main-branch bypass." }, + "lifecycle_identity": { + "app_slug": "openadapt-lifecycle", + "app_id": null, + "app_id_environment": "OPENADAPT_LIFECYCLE_APP_ID", + "actor_login": "openadapt-lifecycle[bot]", + "actor_id": null, + "actor_id_environment": "OPENADAPT_LIFECYCLE_ACTOR_ID", + "installation_id": null, + "installation_id_environment": "OPENADAPT_LIFECYCLE_INSTALLATION_ID", + "repository_scope": [ + ".github", + "openadapt-evals", + "openadapt-ops" + ], + "required_repository_permissions": [ + "Actions: write", + "Metadata: read", + "Pull requests: write" + ], + "forbidden_repository_permissions": [ + "Contents: write" + ], + "ruleset_bypass": false, + "workflow_paths": { + ".github": [ + ".github/workflows/production-lifecycle-activation.yml", + ".github/workflows/qualification-authority-state.yml", + ".github/workflows/qualification-revocation-state.yml" + ], + "openadapt-evals": [ + ".github/workflows/production-lifecycle-evidence.yml" + ], + "openadapt-ops": [ + ".github/workflows/production-lifecycle-projection.yml" + ] + }, + "repository_variables": { + "app_id": "OPENADAPT_LIFECYCLE_APP_ID", + "actor_id": "OPENADAPT_LIFECYCLE_ACTOR_ID", + "installation_id": "OPENADAPT_LIFECYCLE_INSTALLATION_ID" + }, + "actions_write_risk": { + "capabilities": [ + "Dispatch repository workflows", + "Cancel or rerun workflow runs", + "Delete workflow artifacts" + ], + "mitigations": [ + "Exact three-repository installation scope", + "No Contents write", + "No ruleset bypass", + "Founder-reviewed lifecycle environments", + "Every other dispatchable workflow rejects the App actor", + "Production evidence remains content-addressed outside mutable Actions artifacts" + ] + }, + "purpose": "Dispatch only the five founder-reviewed lifecycle workflows and create their reviewable pull requests. The workflow GITHUB_TOKEN pushes the automation branch." + }, + "docs_identity": { + "app_slug": "openadapt-docs", + "app_id": null, + "app_id_environment": "OPENADAPT_DOCS_APP_ID", + "actor_login": "openadapt-docs[bot]", + "actor_id": null, + "actor_id_environment": "OPENADAPT_DOCS_ACTOR_ID", + "installation_id": null, + "installation_id_environment": "OPENADAPT_DOCS_INSTALLATION_ID", + "repository_scope": [ + "openadapt-ops" + ], + "required_repository_permissions": [ + "Actions: write", + "Metadata: read", + "Pull requests: write" + ], + "forbidden_repository_permissions": [ + "Contents: write" + ], + "ruleset_bypass": false, + "workflow_paths": { + "openadapt-ops": ".github/workflows/sync.yml" + }, + "repository_variables": { + "app_id": "OPENADAPT_DOCS_APP_ID", + "actor_id": "OPENADAPT_DOCS_ACTOR_ID", + "installation_id": "OPENADAPT_DOCS_INSTALLATION_ID" + }, + "purpose": "Dispatch only the founder-reviewed documentation synchronization workflow and create its reviewable pull request. The workflow GITHUB_TOKEN pushes the automation branch." + }, "environment_reviewer": { "type": "User", "login": "abrichr", @@ -400,6 +514,20 @@ ] } ], + "lifecycle_environments": [ + { + "name": "production-lifecycle-evidence", + "wait_timer": 0, + "prevent_self_review": true, + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ], + "exclusive_workflow": ".github/workflows/production-lifecycle-evidence.yml" + } + ], "release_workflows": [ { "path": ".github/workflows/release.yml", @@ -412,6 +540,46 @@ "ADMIN_TOKEN" ] } + ], + "lifecycle_workflows": [ + { + "path": ".github/workflows/production-lifecycle-evidence.yml", + "required_patterns": [ + "(?m)^[ ]{2}workflow_dispatch:\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?production-lifecycle-evidence\\s*$", + "github\\.repository\\s*==\\s*'OpenAdaptAI/openadapt-evals'", + "github\\.ref\\s*==\\s*'refs/heads/main'", + "github\\.event_name\\s*==\\s*'workflow_dispatch'", + "github\\.actor\\s*==\\s*'openadapt-lifecycle\\[bot\\]'", + "github\\.triggering_actor\\s*==\\s*'openadapt-lifecycle\\[bot\\]'", + "github\\.actor_id\\s*==\\s*vars\\.OPENADAPT_LIFECYCLE_ACTOR_ID", + "vars\\.OPENADAPT_LIFECYCLE_APP_ID", + "vars\\.OPENADAPT_LIFECYCLE_INSTALLATION_ID", + "secrets\\.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY", + "(?m)^\\s*contents:\\s*write\\s*$", + "github\\.token", + "gh\\s+pr\\s+create" + ], + "forbidden_patterns": [ + "(?m)^[ ]{2}(?:pull_request|pull_request_target|push|release|schedule|repository_dispatch|workflow_call):\\s*$", + "git\\s+push[^\\n]*(?:refs/heads/)?main", + "permission-contents:\\s*write" + ] + } + ], + "dispatch_workflow_inventory": [ + { + "path": ".github/workflows/production-lifecycle-evidence.yml", + "mode": "lifecycle-only" + }, + { + "path": ".github/workflows/complex-visual.yml", + "mode": "reject-lifecycle-app" + }, + { + "path": ".github/workflows/evidence-freshness.yml", + "mode": "reject-lifecycle-app" + } ] }, { @@ -419,10 +587,11 @@ "local_repository_name": "openadapt-maintenance", "visibility": "public", "default_branch": "main", - "audited_main_sha": "dbc110cc5ccbeca900499ac52e298deb9c7b49de", + "audited_main_sha": "2b3fef3a6672fe7dd31312e2cba56608342bb4dd", "require_code_owner_review": true, "required_checks": [ "Analyze (python)", + "Validate Production lifecycle", "Validate canonical docs", "dependency-review", "gitleaks" @@ -431,11 +600,185 @@ "Compare documented versions to PyPI", "Prove the classifier fires and stays quiet" ], + "admission_gaps": [ + "production-backup and production-backup-monitor have no protection rule, deployment branch policy, or reviewer.", + "Every non-lifecycle manual workflow needs the no-permission lifecycle-App rejection predecessor and non-cancelling dispatch concurrency.", + "sync.yml must remove repository_dispatch and direct main push before the protected documentation workflow can apply." + ], "release_tag_patterns": [ "refs/tags/v*" ], - "release_environments": [], - "release_workflows": [] + "release_environments": [ + { + "name": "github-pages", + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ], + "exclusive_workflow": ".github/workflows/sync.yml" + }, + { + "name": "production-docs-deploy", + "wait_timer": 0, + "prevent_self_review": true, + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ], + "exclusive_workflow": ".github/workflows/sync.yml" + } + ], + "lifecycle_environments": [ + { + "name": "production-lifecycle-projection", + "wait_timer": 0, + "prevent_self_review": true, + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ], + "exclusive_workflow": ".github/workflows/production-lifecycle-projection.yml" + } + ], + "release_workflows": [ + { + "path": ".github/workflows/sync.yml", + "required_patterns": [ + "(?m)^[ ]{2}workflow_dispatch:\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?github-pages\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?production-docs-deploy\\s*$", + "(?m)^\\s*pages:\\s*write\\s*$", + "(?m)^\\s*id-token:\\s*write\\s*$", + "github\\.repository\\s*==\\s*'OpenAdaptAI/openadapt-ops'", + "github\\.ref\\s*==\\s*'refs/heads/main'", + "github\\.event_name\\s*==\\s*'workflow_dispatch'", + "github\\.actor\\s*==\\s*'openadapt-docs\\[bot\\]'", + "github\\.triggering_actor\\s*==\\s*'openadapt-docs\\[bot\\]'", + "github\\.actor_id\\s*==\\s*vars\\.OPENADAPT_DOCS_ACTOR_ID", + "vars\\.OPENADAPT_DOCS_APP_ID", + "vars\\.OPENADAPT_DOCS_INSTALLATION_ID", + "secrets\\.OPENADAPT_DOCS_APP_PRIVATE_KEY", + "inputs\\.source_repository", + "OpenAdaptAI/openadapt-evals", + "repos\\.yml", + "inputs\\.source_ref", + "refs/heads/main", + "inputs\\.source_commit", + "gh\\s+api[^\\n]*commits/main", + "\\[0-9a-f\\]\\{40\\}", + "inputs\\.source_event\\s*==\\s*['\"]push['\"]", + "inputs\\.idempotency_key", + "OpenAdapt docs sync dispatch v1", + "docs-sync:", + "\\[0-9a-f\\]\\{64\\}", + "sha256", + "(?m)^\\s*contents:\\s*write\\s*$", + "github\\.token", + "gh\\s+pr\\s+create" + ], + "forbidden_patterns": [ + "(?m)^[ ]{2}repository_dispatch:\\s*$", + "repo-updated", + "git\\s+push[^\\n]*(?:refs/heads/)?main", + "permission-contents:\\s*write" + ] + } + ], + "admission_workflows": [ + { + "path": ".github/workflows/production-lifecycle-policy.yml", + "required_patterns": [ + "(?m)^[ ]{2}pull_request:\\s*$", + "(?m)^[ ]{4}name:\\s*Validate Production lifecycle\\s*$" + ], + "forbidden_patterns": [ + "(?m)^[ ]{4}paths(?:-ignore)?:\\s*$" + ] + } + ], + "lifecycle_workflows": [ + { + "path": ".github/workflows/production-lifecycle-projection.yml", + "required_patterns": [ + "(?m)^[ ]{2}workflow_dispatch:\\s*$", + "(?m)^[ ]{4}name:\\s*Project canonical Production lifecycle\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?production-lifecycle-projection\\s*$", + "inputs\\.source_event\\s*==\\s*['\"]production_lifecycle_ledger_changed['\"]", + "inputs\\.source_repository\\s*==\\s*['\"]OpenAdaptAI/\\.github['\"]", + "inputs\\.source_ref\\s*==\\s*['\"]refs/heads/main['\"]", + "inputs\\.source_commit", + "gh\\s+api[^\\n]*commits/main", + "\\[0-9a-f\\]\\{40\\}", + "inputs\\.candidate_admissions_sha256", + "inputs\\.candidate_ledger_head_sha256", + "inputs\\.idempotency_key", + "sha256:\\[0-9a-f\\]\\{64\\}", + "OpenAdapt production lifecycle ledger head v1\\\\0", + "OpenAdapt production lifecycle projection idempotency v1\\\\0", + "github\\.repository\\s*==\\s*'OpenAdaptAI/openadapt-ops'", + "github\\.ref\\s*==\\s*'refs/heads/main'", + "github\\.event_name\\s*==\\s*'workflow_dispatch'", + "github\\.actor\\s*==\\s*'openadapt-lifecycle\\[bot\\]'", + "github\\.triggering_actor\\s*==\\s*'openadapt-lifecycle\\[bot\\]'", + "github\\.actor_id\\s*==\\s*vars\\.OPENADAPT_LIFECYCLE_ACTOR_ID", + "vars\\.OPENADAPT_LIFECYCLE_APP_ID", + "vars\\.OPENADAPT_LIFECYCLE_INSTALLATION_ID", + "secrets\\.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY", + "(?m)^\\s*contents:\\s*write\\s*$", + "github\\.token", + "gh\\s+pr\\s+create" + ], + "forbidden_patterns": [ + "(?m)^[ ]{2}(?:pull_request|pull_request_target|push|release|schedule|repository_dispatch|workflow_call):\\s*$", + "git\\s+push[^\\n]*(?:refs/heads/)?main", + "permission-contents:\\s*write" + ] + } + ], + "dispatch_workflow_inventory": [ + { + "path": ".github/workflows/production-lifecycle-projection.yml", + "mode": "lifecycle-only" + }, + { + "path": ".github/workflows/azure-cost-guard.yml", + "mode": "reject-lifecycle-app" + }, + { + "path": ".github/workflows/db-backup-freshness.yml", + "mode": "reject-lifecycle-app" + }, + { + "path": ".github/workflows/db-backup.yml", + "mode": "reject-lifecycle-app" + }, + { + "path": ".github/workflows/default-branch-sweep.yml", + "mode": "reject-lifecycle-app" + }, + { + "path": ".github/workflows/prod-health-alert.yml", + "mode": "reject-lifecycle-app" + }, + { + "path": ".github/workflows/production-lifecycle-policy.yml", + "mode": "reject-lifecycle-app" + }, + { + "path": ".github/workflows/published-version-claims.yml", + "mode": "reject-lifecycle-app" + }, + { + "path": ".github/workflows/sync.yml", + "mode": "docs-only" + } + ] }, { "name": "openadapt-web", @@ -479,12 +822,15 @@ ], "path_scoped_checks": [], "admission_gaps": [ - "Make validate-profile report on every pull request. The check is small and needs no new matrix." + "Make validate-profile report on every pull request and add the three App-only Profile governance workflows before apply." ], "admission_workflows": [ { "path": ".github/workflows/profile-consistency.yml", - "required_patterns": [], + "required_patterns": [ + "(?m)^[ ]{2}pull_request:\\s*$", + "(?m)^[ ]{2}validate-profile:\\s*$" + ], "forbidden_patterns": [ "(?m)^[ ]{4}paths(?:-ignore)?:\\s*$" ] @@ -494,7 +840,144 @@ "refs/tags/v*" ], "release_environments": [], - "release_workflows": [] + "lifecycle_environments": [ + { + "name": "production-lifecycle-activation", + "wait_timer": 0, + "prevent_self_review": true, + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ], + "exclusive_workflow": ".github/workflows/production-lifecycle-activation.yml" + }, + { + "name": "qualification-authority-state", + "wait_timer": 0, + "prevent_self_review": true, + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ], + "exclusive_workflow": ".github/workflows/qualification-authority-state.yml" + }, + { + "name": "qualification-revocation-state", + "wait_timer": 0, + "prevent_self_review": true, + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ], + "exclusive_workflow": ".github/workflows/qualification-revocation-state.yml" + } + ], + "release_workflows": [], + "lifecycle_workflows": [ + { + "path": ".github/workflows/production-lifecycle-activation.yml", + "required_patterns": [ + "(?m)^[ ]{2}workflow_dispatch:\\s*$", + "(?m)^[ ]{4}name:\\s*Create Production lifecycle activation PR\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?production-lifecycle-activation\\s*$", + "github\\.repository\\s*==\\s*'OpenAdaptAI/\\.github'", + "github\\.ref\\s*==\\s*'refs/heads/main'", + "github\\.event_name\\s*==\\s*'workflow_dispatch'", + "github\\.actor\\s*==\\s*'openadapt-lifecycle\\[bot\\]'", + "github\\.triggering_actor\\s*==\\s*'openadapt-lifecycle\\[bot\\]'", + "github\\.actor_id\\s*==\\s*vars\\.OPENADAPT_LIFECYCLE_ACTOR_ID", + "vars\\.OPENADAPT_LIFECYCLE_APP_ID", + "vars\\.OPENADAPT_LIFECYCLE_INSTALLATION_ID", + "secrets\\.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY", + "(?m)^\\s*contents:\\s*write\\s*$", + "github\\.token", + "gh\\s+pr\\s+create" + ], + "forbidden_patterns": [ + "(?m)^[ ]{2}(?:pull_request|pull_request_target|push|release|schedule|repository_dispatch|workflow_call):\\s*$", + "git\\s+push[^\\n]*(?:refs/heads/)?main", + "permission-contents:\\s*write" + ] + }, + { + "path": ".github/workflows/qualification-authority-state.yml", + "required_patterns": [ + "(?m)^[ ]{2}workflow_dispatch:\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?qualification-authority-state\\s*$", + "github\\.repository\\s*==\\s*'OpenAdaptAI/\\.github'", + "github\\.ref\\s*==\\s*'refs/heads/main'", + "github\\.event_name\\s*==\\s*'workflow_dispatch'", + "github\\.actor\\s*==\\s*'openadapt-lifecycle\\[bot\\]'", + "github\\.triggering_actor\\s*==\\s*'openadapt-lifecycle\\[bot\\]'", + "github\\.actor_id\\s*==\\s*vars\\.OPENADAPT_LIFECYCLE_ACTOR_ID", + "vars\\.OPENADAPT_LIFECYCLE_APP_ID", + "vars\\.OPENADAPT_LIFECYCLE_INSTALLATION_ID", + "secrets\\.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY", + "(?m)^\\s*attestations:\\s*write\\s*$", + "(?m)^\\s*contents:\\s*write\\s*$", + "(?m)^\\s*id-token:\\s*write\\s*$", + "actions/attest", + "github\\.token", + "gh\\s+pr\\s+create" + ], + "forbidden_patterns": [ + "(?m)^[ ]{2}(?:pull_request|pull_request_target|push|release|schedule|repository_dispatch|workflow_call):\\s*$", + "git\\s+push[^\\n]*(?:refs/heads/)?main", + "permission-contents:\\s*write" + ] + }, + { + "path": ".github/workflows/qualification-revocation-state.yml", + "required_patterns": [ + "(?m)^[ ]{2}workflow_dispatch:\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?qualification-revocation-state\\s*$", + "github\\.repository\\s*==\\s*'OpenAdaptAI/\\.github'", + "github\\.ref\\s*==\\s*'refs/heads/main'", + "github\\.event_name\\s*==\\s*'workflow_dispatch'", + "github\\.actor\\s*==\\s*'openadapt-lifecycle\\[bot\\]'", + "github\\.triggering_actor\\s*==\\s*'openadapt-lifecycle\\[bot\\]'", + "github\\.actor_id\\s*==\\s*vars\\.OPENADAPT_LIFECYCLE_ACTOR_ID", + "vars\\.OPENADAPT_LIFECYCLE_APP_ID", + "vars\\.OPENADAPT_LIFECYCLE_INSTALLATION_ID", + "secrets\\.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY", + "(?m)^\\s*attestations:\\s*write\\s*$", + "(?m)^\\s*contents:\\s*write\\s*$", + "(?m)^\\s*id-token:\\s*write\\s*$", + "actions/attest", + "github\\.token", + "gh\\s+pr\\s+create" + ], + "forbidden_patterns": [ + "(?m)^[ ]{2}(?:pull_request|pull_request_target|push|release|schedule|repository_dispatch|workflow_call):\\s*$", + "git\\s+push[^\\n]*(?:refs/heads/)?main", + "permission-contents:\\s*write" + ] + } + ], + "dispatch_workflow_inventory": [ + { + "path": ".github/workflows/production-lifecycle-activation.yml", + "mode": "lifecycle-only" + }, + { + "path": ".github/workflows/qualification-authority-state.yml", + "mode": "lifecycle-only" + }, + { + "path": ".github/workflows/qualification-revocation-state.yml", + "mode": "lifecycle-only" + }, + { + "path": ".github/workflows/profile-consistency.yml", + "mode": "reject-lifecycle-app" + } + ] } ], "plan_constraints": [ diff --git a/scripts/manage_github_protection.py b/scripts/manage_github_protection.py index 280d088..53ff3ed 100755 --- a/scripts/manage_github_protection.py +++ b/scripts/manage_github_protection.py @@ -141,6 +141,14 @@ class ReleaseActor: app_slug: str +@dataclass(frozen=True) +class LifecycleActor: + app_id: int + actor_id: int + actor_login: str + installation_id: int + + def _utc_now() -> datetime: return datetime.now(timezone.utc) @@ -180,7 +188,23 @@ def validate_config(config: Mapping[str, Any]) -> None: ): values = live_audit.get(field) if not isinstance(values, Mapping) or set(values) != EXPECTED_REPOSITORIES: - raise PolicyError(f"live_audit.{field} must cover the eight core repositories") + raise PolicyError( + f"live_audit.{field} must cover the eight core repositories" + ) + dispatch_audit = config.get("dispatch_privilege_audit") + if not isinstance(dispatch_audit, Mapping): + raise PolicyError("dispatch_privilege_audit must be an object") + if dispatch_audit.get("openadapt_ops_main_protected") is not False: + raise PolicyError("dispatch audit must record unprotected Ops main") + if set(dispatch_audit.get("unprotected_operational_environments", {})) != { + "production-backup", + "production-backup-monitor", + }: + raise PolicyError("dispatch audit must record both backup environments") + if dispatch_audit.get("lifecycle_app_installation") != "absent": + raise PolicyError("dispatch audit must record the missing lifecycle App") + if dispatch_audit.get("docs_app_installation") != "absent": + raise PolicyError("dispatch audit must record the missing docs App") actions_id = config.get("github_actions_integration_id") if not isinstance(actions_id, int) or actions_id <= 0: raise PolicyError("github_actions_integration_id must be a positive integer") @@ -188,6 +212,128 @@ def validate_config(config: Mapping[str, Any]) -> None: if environment_defaults != {"wait_timer": 0, "prevent_self_review": False}: raise PolicyError("environment_defaults must define the reviewed release gate") + lifecycle_identity = config.get("lifecycle_identity") + if not isinstance(lifecycle_identity, Mapping): + raise PolicyError("lifecycle_identity must be an object") + if lifecycle_identity.get("app_slug") != "openadapt-lifecycle": + raise PolicyError("lifecycle_identity must use the openadapt-lifecycle App") + if lifecycle_identity.get("actor_login") != "openadapt-lifecycle[bot]": + raise PolicyError("lifecycle_identity actor login is not exact") + if lifecycle_identity.get("ruleset_bypass") is not False: + raise PolicyError("lifecycle_identity must not have a ruleset bypass") + expected_lifecycle_scope = {".github", "openadapt-evals", "openadapt-ops"} + lifecycle_scope = _require_list( + lifecycle_identity.get("repository_scope"), + "lifecycle_identity.repository_scope", + ) + if set(lifecycle_scope) != expected_lifecycle_scope or len(lifecycle_scope) != 3: + raise PolicyError("lifecycle_identity repository scope is not exact") + if lifecycle_identity.get("required_repository_permissions") != [ + "Actions: write", + "Metadata: read", + "Pull requests: write", + ]: + raise PolicyError("lifecycle_identity repository permissions are not exact") + if lifecycle_identity.get("forbidden_repository_permissions") != [ + "Contents: write" + ]: + raise PolicyError("lifecycle_identity must forbid Contents write") + expected_lifecycle_environments = { + ".github": [ + ( + "production-lifecycle-activation", + ".github/workflows/production-lifecycle-activation.yml", + ), + ( + "qualification-authority-state", + ".github/workflows/qualification-authority-state.yml", + ), + ( + "qualification-revocation-state", + ".github/workflows/qualification-revocation-state.yml", + ), + ], + "openadapt-evals": [ + ( + "production-lifecycle-evidence", + ".github/workflows/production-lifecycle-evidence.yml", + ) + ], + "openadapt-ops": [ + ( + "production-lifecycle-projection", + ".github/workflows/production-lifecycle-projection.yml", + ) + ], + } + expected_lifecycle_workflows = { + repo: [path for _, path in environments] + for repo, environments in expected_lifecycle_environments.items() + } + if lifecycle_identity.get("workflow_paths") != expected_lifecycle_workflows: + raise PolicyError("lifecycle_identity workflow paths are not exact") + if lifecycle_identity.get("repository_variables") != { + "app_id": "OPENADAPT_LIFECYCLE_APP_ID", + "actor_id": "OPENADAPT_LIFECYCLE_ACTOR_ID", + "installation_id": "OPENADAPT_LIFECYCLE_INSTALLATION_ID", + }: + raise PolicyError("lifecycle_identity repository variables are not exact") + actions_write_risk = lifecycle_identity.get("actions_write_risk") + if not isinstance(actions_write_risk, Mapping): + raise PolicyError("lifecycle_identity must record the Actions write risk") + if set(actions_write_risk.get("capabilities", [])) != { + "Dispatch repository workflows", + "Cancel or rerun workflow runs", + "Delete workflow artifacts", + }: + raise PolicyError( + "lifecycle_identity Actions write capabilities are incomplete" + ) + for field in ("app_id", "actor_id", "installation_id"): + value = lifecycle_identity.get(field) + if value is not None and (not isinstance(value, int) or value <= 0): + raise PolicyError(f"lifecycle_identity.{field} must be null or positive") + environment_field = f"{field}_environment" + if not isinstance(lifecycle_identity.get(environment_field), str): + raise PolicyError(f"lifecycle_identity.{environment_field} is required") + + docs_identity = config.get("docs_identity") + if not isinstance(docs_identity, Mapping): + raise PolicyError("docs_identity must be an object") + if docs_identity.get("app_slug") != "openadapt-docs": + raise PolicyError("docs_identity must use the openadapt-docs App") + if docs_identity.get("actor_login") != "openadapt-docs[bot]": + raise PolicyError("docs_identity actor login is not exact") + if docs_identity.get("repository_scope") != ["openadapt-ops"]: + raise PolicyError("docs_identity repository scope is not exact") + if docs_identity.get("required_repository_permissions") != [ + "Actions: write", + "Metadata: read", + "Pull requests: write", + ]: + raise PolicyError("docs_identity repository permissions are not exact") + if docs_identity.get("forbidden_repository_permissions") != ["Contents: write"]: + raise PolicyError("docs_identity must forbid Contents write") + if docs_identity.get("ruleset_bypass") is not False: + raise PolicyError("docs_identity must not have a ruleset bypass") + if docs_identity.get("workflow_paths") != { + "openadapt-ops": ".github/workflows/sync.yml" + }: + raise PolicyError("docs_identity workflow path is not exact") + if docs_identity.get("repository_variables") != { + "app_id": "OPENADAPT_DOCS_APP_ID", + "actor_id": "OPENADAPT_DOCS_ACTOR_ID", + "installation_id": "OPENADAPT_DOCS_INSTALLATION_ID", + }: + raise PolicyError("docs_identity repository variables are not exact") + for field in ("app_id", "actor_id", "installation_id"): + value = docs_identity.get(field) + if value is not None and (not isinstance(value, int) or value <= 0): + raise PolicyError(f"docs_identity.{field} must be null or positive") + environment_field = f"{field}_environment" + if not isinstance(docs_identity.get(environment_field), str): + raise PolicyError(f"docs_identity.{environment_field} is required") + repositories = _require_list(config.get("repositories"), "repositories") names = [repo.get("name") for repo in repositories if isinstance(repo, Mapping)] if set(names) != EXPECTED_REPOSITORIES or len(names) != len(EXPECTED_REPOSITORIES): @@ -207,58 +353,166 @@ def validate_config(config: Mapping[str, Any]) -> None: if not isinstance(sha, str) or re.fullmatch(r"[0-9a-f]{40}", sha) is None: raise PolicyError(f"{name}: audited_main_sha must be a full commit SHA") required = _require_list(repo.get("required_checks"), f"{name}.required_checks") - scoped = _require_list(repo.get("path_scoped_checks"), f"{name}.path_scoped_checks") + scoped = _require_list( + repo.get("path_scoped_checks"), f"{name}.path_scoped_checks" + ) if any(not isinstance(item, str) or not item for item in required + scoped): raise PolicyError(f"{name}: check names must be non-empty strings") if len(required) != len(set(required)): raise PolicyError(f"{name}: required_checks contains duplicates") overlap = set(required).intersection(scoped) if overlap: - raise PolicyError(f"{name}: path-scoped checks cannot be required: {sorted(overlap)}") - tags = _require_list(repo.get("release_tag_patterns"), f"{name}.release_tag_patterns") + raise PolicyError( + f"{name}: path-scoped checks cannot be required: {sorted(overlap)}" + ) + tags = _require_list( + repo.get("release_tag_patterns"), f"{name}.release_tag_patterns" + ) if not tags or any( not isinstance(pattern, str) or not pattern.startswith("refs/tags/") for pattern in tags ): raise PolicyError(f"{name}: release tag patterns must use refs/tags/") - environments = _require_list( + release_environments = _require_list( repo.get("release_environments"), f"{name}.release_environments" ) + lifecycle_environments = _require_list( + repo.get("lifecycle_environments", []), + f"{name}.lifecycle_environments", + ) + environments = release_environments + lifecycle_environments environment_names = [item.get("name") for item in environments] if len(environment_names) != len(set(environment_names)): - raise PolicyError(f"{name}: duplicate release environment") + raise PolicyError(f"{name}: duplicate protected environment") for environment in environments: + if not isinstance(environment.get("name"), str) or not environment["name"]: + raise PolicyError(f"{name}: protected environment needs a name") policies = _require_list( environment.get("deployment_policies"), f"{name}.{environment.get('name')}.deployment_policies", ) if not policies: - raise PolicyError(f"{name}: release environment needs a deployment policy") + raise PolicyError( + f"{name}: protected environment needs a deployment policy" + ) for policy in policies: - if policy.get("type") not in {"branch", "tag"} or not policy.get("name"): + if policy.get("type") not in {"branch", "tag"} or not policy.get( + "name" + ): raise PolicyError(f"{name}: invalid environment deployment policy") + for environment in lifecycle_environments: + if environment.get("wait_timer") != 0: + raise PolicyError( + f"{name}: lifecycle environment wait_timer must be zero" + ) + if environment.get("prevent_self_review") is not True: + raise PolicyError( + f"{name}: lifecycle environment must prevent self-review" + ) + if environment.get("deployment_policies") != [ + {"type": "branch", "name": "main"} + ]: + raise PolicyError( + f"{name}: lifecycle environment must admit exact main" + ) + expected_workflows = expected_lifecycle_workflows.get(name, []) + if environment.get("exclusive_workflow") not in expected_workflows: + raise PolicyError( + f"{name}: lifecycle environment workflow is not exact" + ) + actual_lifecycle_environments = [ + (item.get("name"), item.get("exclusive_workflow")) + for item in lifecycle_environments + ] + if actual_lifecycle_environments != expected_lifecycle_environments.get( + name, [] + ): + raise PolicyError(f"{name}: lifecycle environments are not exact") workflows = _require_list( repo.get("release_workflows"), f"{name}.release_workflows" ) - if workflows and "release-identity" not in environment_names: + requires_release_identity = any( + "release-identity" in pattern + for workflow in workflows + for pattern in workflow.get("required_patterns", []) + ) + if requires_release_identity and "release-identity" not in environment_names: raise PolicyError(f"{name}: publishing repository needs release-identity") admission_workflows = _require_list( repo.get("admission_workflows", []), f"{name}.admission_workflows" ) - for workflow in workflows + admission_workflows: + lifecycle_workflows = _require_list( + repo.get("lifecycle_workflows", []), f"{name}.lifecycle_workflows" + ) + expected_lifecycle_paths = expected_lifecycle_workflows.get(name, []) + actual_lifecycle_paths = [item.get("path") for item in lifecycle_workflows] + if not expected_lifecycle_paths and actual_lifecycle_paths: + raise PolicyError(f"{name}: lifecycle workflow is outside the App scope") + if actual_lifecycle_paths != expected_lifecycle_paths: + raise PolicyError(f"{name}: lifecycle workflow path is not exact") + all_workflows = workflows + admission_workflows + lifecycle_workflows + configured_workflow_paths = {item.get("path") for item in all_workflows} + for environment in environments: + exclusive_workflow = environment.get("exclusive_workflow") + if ( + exclusive_workflow + and exclusive_workflow not in configured_workflow_paths + ): + raise PolicyError( + f"{name}: protected environment workflow is not registered" + ) + dispatch_inventory = _require_list( + repo.get("dispatch_workflow_inventory", []), + f"{name}.dispatch_workflow_inventory", + ) + dispatch_paths = [item.get("path") for item in dispatch_inventory] + if len(dispatch_paths) != len(set(dispatch_paths)): + raise PolicyError(f"{name}: dispatch workflow inventory has duplicates") + if any( + not isinstance(item.get("path"), str) + or not item["path"].startswith(".github/workflows/") + or item.get("mode") + not in {"docs-only", "lifecycle-only", "reject-lifecycle-app"} + for item in dispatch_inventory + ): + raise PolicyError(f"{name}: dispatch workflow inventory is invalid") + lifecycle_dispatch_paths = [ + item["path"] + for item in dispatch_inventory + if item["mode"] == "lifecycle-only" + ] + if not expected_lifecycle_paths and lifecycle_dispatch_paths: + raise PolicyError(f"{name}: lifecycle dispatch is outside the App scope") + if lifecycle_dispatch_paths != expected_lifecycle_paths: + raise PolicyError(f"{name}: lifecycle dispatch path is not exact") + docs_dispatch_paths = [ + item["path"] for item in dispatch_inventory if item["mode"] == "docs-only" + ] + expected_docs_path = docs_identity["workflow_paths"].get(name) + if expected_docs_path is None and docs_dispatch_paths: + raise PolicyError(f"{name}: docs dispatch is outside the App scope") + if expected_docs_path is not None and docs_dispatch_paths != [ + expected_docs_path + ]: + raise PolicyError(f"{name}: docs dispatch path is not exact") + for workflow in all_workflows: path = workflow.get("path") if not isinstance(path, str) or not path.startswith(".github/workflows/"): - raise PolicyError(f"{name}: invalid release workflow path") + raise PolicyError(f"{name}: invalid workflow path") for field in ("required_patterns", "forbidden_patterns"): patterns = _require_list(workflow.get(field), f"{name}.{path}.{field}") for pattern in patterns: try: re.compile(pattern) except (TypeError, re.error) as exc: - raise PolicyError(f"{name}: invalid workflow pattern {pattern!r}") from exc + raise PolicyError( + f"{name}: invalid workflow pattern {pattern!r}" + ) from exc constraints = _require_list(config.get("plan_constraints"), "plan_constraints") - cloud = [item for item in constraints if item.get("repository") == "openadapt-cloud"] + cloud = [ + item for item in constraints if item.get("repository") == "openadapt-cloud" + ] if len(cloud) != 1 or cloud[0].get("managed") is not False: raise PolicyError("openadapt-cloud must exist once as an unmanaged constraint") if cloud[0].get("mode") != "audit-only": @@ -352,6 +606,186 @@ def _resolve_release_actor( return ReleaseActor(actor_id=actor_id, app_slug=slug) +def _identity_number( + identity: Mapping[str, Any], + field: str, + blockers: list[dict[str, str]], + identity_key: str, +) -> int | None: + value = identity.get(field) + source = "config" + if value is None: + source = identity[f"{field}_environment"] + raw = os.environ.get(source) + if raw: + try: + value = int(raw) + except ValueError: + value = None + if not isinstance(value, int) or value <= 0: + blockers.append( + { + "code": f"{identity_key}_{field}_unresolved", + "message": ( + f"Set {identity[f'{field}_environment']} to the reviewed " + f"{identity['app_slug']} {field.replace('_', ' ')}." + ), + } + ) + return None + return value + + +def _resolve_scoped_dispatch_actor( + client: GitHubClient, + config: Mapping[str, Any], + blockers: list[dict[str, str]], + identity_key: str, +) -> LifecycleActor | None: + identity = config[identity_key] + app_id = _identity_number(identity, "app_id", blockers, identity_key) + actor_id = _identity_number(identity, "actor_id", blockers, identity_key) + installation_id = _identity_number( + identity, "installation_id", blockers, identity_key + ) + if app_id is None or actor_id is None or installation_id is None: + return None + + slug = identity["app_slug"] + app = client.get(f"/apps/{quote(slug, safe='')}", optional=True) + if not isinstance(app, Mapping): + blockers.append( + { + "code": f"{identity_key}_app_not_found", + "message": f"GitHub App {slug!r} was not found.", + } + ) + return None + if app.get("id") != app_id or app.get("slug") != slug: + blockers.append( + { + "code": f"{identity_key}_app_mismatch", + "message": f"GitHub App {slug!r} does not have App ID {app_id}.", + } + ) + return None + + actor_login = identity["actor_login"] + actor = client.get(f"/users/{quote(actor_login, safe='')}", optional=True) + if not isinstance(actor, Mapping): + blockers.append( + { + "code": f"{identity_key}_actor_not_found", + "message": f"GitHub App actor {actor_login!r} was not found.", + } + ) + return None + if actor.get("id") != actor_id or actor.get("login") != actor_login: + blockers.append( + { + "code": f"{identity_key}_actor_mismatch", + "message": ( + f"GitHub App actor {actor_login!r} does not have actor ID {actor_id}." + ), + } + ) + return None + + owner = config["organization"] + response = client.get(f"/orgs/{owner}/installations?per_page=100") + installations = ( + response.get("installations", []) if isinstance(response, Mapping) else response + ) + installation = next( + ( + item + for item in installations or [] + if item.get("id") == installation_id + and item.get("app_id") == app_id + and item.get("app_slug") == slug + ), + None, + ) + if installation is None: + blockers.append( + { + "code": f"{identity_key}_installation_not_found", + "message": ( + f"GitHub App {slug!r} installation {installation_id} was not found " + f"for {owner}." + ), + } + ) + return None + + expected_permissions = { + item.split(":", 1)[0].strip().lower().replace(" ", "_"): item.split(":", 1)[1] + .strip() + .lower() + for item in identity["required_repository_permissions"] + } + if installation.get("permissions") != expected_permissions: + blockers.append( + { + "code": f"{identity_key}_permissions_mismatch", + "message": ( + f"GitHub App {slug!r} does not have the exact reviewed permissions." + ), + } + ) + return None + if installation.get("repository_selection") != "selected": + blockers.append( + { + "code": f"{identity_key}_repository_scope_mismatch", + "message": f"GitHub App {slug!r} must use an exact selected-repository scope.", + } + ) + return None + repository_response = client.get( + f"/user/installations/{installation_id}/repositories?per_page=100" + ) + installed_names = { + item.get("name") for item in repository_response.get("repositories", []) + } + expected_names = set(identity["repository_scope"]) + if installed_names != expected_names: + blockers.append( + { + "code": f"{identity_key}_repository_scope_mismatch", + "message": ( + f"GitHub App {slug!r} repository scope must be exactly: " + f"{', '.join(sorted(expected_names))}." + ), + } + ) + return None + return LifecycleActor( + app_id=app_id, + actor_id=actor_id, + actor_login=actor_login, + installation_id=installation_id, + ) + + +def _resolve_lifecycle_actor( + client: GitHubClient, + config: Mapping[str, Any], + blockers: list[dict[str, str]], +) -> LifecycleActor | None: + return _resolve_scoped_dispatch_actor( + client, config, blockers, "lifecycle_identity" + ) + + +def _resolve_docs_actor( + client: GitHubClient, + config: Mapping[str, Any], + blockers: list[dict[str, str]], +) -> LifecycleActor | None: + return _resolve_scoped_dispatch_actor(client, config, blockers, "docs_identity") + + def _verify_reviewer( client: GitHubClient, config: Mapping[str, Any], blockers: list[dict[str, str]] ) -> None: @@ -369,7 +803,9 @@ def _verify_reviewer( ) -def _pull_request_rule(config: Mapping[str, Any], repo: Mapping[str, Any]) -> dict[str, Any]: +def _pull_request_rule( + config: Mapping[str, Any], repo: Mapping[str, Any] +) -> dict[str, Any]: defaults = config["main_rule_defaults"] return { "type": "pull_request", @@ -401,9 +837,9 @@ def desired_rulesets( "type": "required_status_checks", "parameters": { "do_not_enforce_on_create": False, - "strict_required_status_checks_policy": config["main_rule_defaults"][ - "strict_status_checks" - ], + "strict_required_status_checks_policy": config[ + "main_rule_defaults" + ]["strict_status_checks"], "required_status_checks": [ { "context": context, @@ -420,9 +856,7 @@ def desired_rulesets( "target": "branch", "enforcement": "active", "bypass_actors": [], - "conditions": { - "ref_name": {"include": ["refs/heads/main"], "exclude": []} - }, + "conditions": {"ref_name": {"include": ["refs/heads/main"], "exclude": []}}, "rules": rules, } immutable = { @@ -471,8 +905,10 @@ def desired_environment( reviewer = config["environment_reviewer"] defaults = config["environment_defaults"] return { - "wait_timer": defaults["wait_timer"], - "prevent_self_review": defaults["prevent_self_review"], + "wait_timer": environment.get("wait_timer", defaults["wait_timer"]), + "prevent_self_review": environment.get( + "prevent_self_review", defaults["prevent_self_review"] + ), "reviewers": [{"type": reviewer["type"], "id": reviewer["id"]}], "deployment_branch_policy": { "protected_branches": False, @@ -509,11 +945,15 @@ def _normalize_ruleset(value: Mapping[str, Any]) -> dict[str, Any]: for check in parameters.get("required_status_checks", []) ] normalized["parameters"] = { - "do_not_enforce_on_create": parameters.get("do_not_enforce_on_create", False), + "do_not_enforce_on_create": parameters.get( + "do_not_enforce_on_create", False + ), "strict_required_status_checks_policy": parameters.get( "strict_required_status_checks_policy" ), - "required_status_checks": sorted(checks, key=lambda item: item["context"]), + "required_status_checks": sorted( + checks, key=lambda item: item["context"] + ), } elif rule.get("type") == "update" and isinstance(parameters, Mapping): normalized["parameters"] = { @@ -563,7 +1003,11 @@ def _normalize_environment(value: Mapping[str, Any]) -> dict[str, Any]: reviewers.append({"type": item.get("type"), "id": identity.get("id")}) reviewers.sort(key=lambda item: (item["type"], item["id"])) wait_rule = next( - (rule for rule in value.get("protection_rules", []) if rule.get("type") == "wait_timer"), + ( + rule + for rule in value.get("protection_rules", []) + if rule.get("type") == "wait_timer" + ), {}, ) deployment = value.get("deployment_branch_policy") or {} @@ -578,7 +1022,9 @@ def _normalize_environment(value: Mapping[str, Any]) -> dict[str, Any]: } -def _workflow_text(client: GitHubClient, owner: str, repo: str, path: str) -> str | None: +def _workflow_text( + client: GitHubClient, owner: str, repo: str, path: str +) -> str | None: encoded_path = quote(path, safe="/") response = client.get( f"/repos/{owner}/{repo}/contents/{encoded_path}?ref=main", optional=True @@ -629,8 +1075,261 @@ def _workflow_contract_blockers( return blockers -def _list_rulesets(client: GitHubClient, owner: str, repo: str) -> dict[str, Mapping[str, Any]]: - response = client.get(f"/repos/{owner}/{repo}/rulesets?includes_parents=false&per_page=100") +def _dispatch_identity_variable_blockers( + client: GitHubClient, + owner: str, + repo: Mapping[str, Any], + identity: Mapping[str, Any], + actor: LifecycleActor | None, +) -> list[dict[str, str]]: + if repo["name"] not in identity["workflow_paths"] or actor is None: + return [] + blockers: list[dict[str, str]] = [] + expected_values = { + identity["repository_variables"]["app_id"]: actor.app_id, + identity["repository_variables"]["actor_id"]: actor.actor_id, + identity["repository_variables"]["installation_id"]: actor.installation_id, + } + for variable_name, expected in expected_values.items(): + variable = client.get( + ( + f"/repos/{owner}/{repo['name']}/actions/variables/" + f"{quote(variable_name, safe='')}" + ), + optional=True, + ) + if not isinstance(variable, Mapping): + blockers.append( + { + "code": f"{identity['app_slug']}_variable_missing", + "message": f"{repo['name']}: Actions variable {variable_name} is missing.", + } + ) + elif variable.get("name") != variable_name or variable.get("value") != str( + expected + ): + blockers.append( + { + "code": f"{identity['app_slug']}_variable_mismatch", + "message": ( + f"{repo['name']}: Actions variable {variable_name} does not " + "match the reviewed lifecycle App identity." + ), + } + ) + return blockers + + +def _exclusive_environment_blockers( + client: GitHubClient, + owner: str, + repo: Mapping[str, Any], +) -> list[dict[str, str]]: + environments = repo.get("lifecycle_environments", []) + [ + item + for item in repo.get("release_environments", []) + if item.get("exclusive_workflow") + ] + if not environments: + return [] + tree = client.get(f"/repos/{owner}/{repo['name']}/git/trees/main?recursive=1") + if not isinstance(tree, Mapping) or tree.get("truncated"): + raise GitHubError( + f"{owner}/{repo['name']}: complete workflow tree is not available" + ) + workflow_paths = sorted( + item.get("path") + for item in tree.get("tree", []) + if item.get("type") == "blob" + and isinstance(item.get("path"), str) + and item["path"].startswith(".github/workflows/") + and item["path"].endswith((".yml", ".yaml")) + ) + content_by_path = { + path: _workflow_text(client, owner, repo["name"], path) + for path in workflow_paths + } + blockers: list[dict[str, str]] = [] + for environment in environments: + environment_name = environment["name"] + allowed = environment["exclusive_workflow"] + unexpected = [ + path + for path, content in content_by_path.items() + if path != allowed and content is not None and environment_name in content + ] + if unexpected: + blockers.append( + { + "code": "lifecycle_environment_workflow_scope", + "message": ( + f"{repo['name']}:{environment_name} is referenced outside " + f"{allowed}: {', '.join(unexpected)}." + ), + } + ) + return blockers + + +def _workflow_job_blocks(content: str) -> dict[str, list[str]]: + lines = content.splitlines() + try: + jobs_index = next(index for index, line in enumerate(lines) if line == "jobs:") + except StopIteration: + return {} + starts: list[tuple[int, str]] = [] + for index in range(jobs_index + 1, len(lines)): + match = re.fullmatch(r" ([A-Za-z0-9_-]+):\s*(?:#.*)?", lines[index]) + if match: + starts.append((index, match.group(1))) + elif lines[index] and not lines[index].startswith(" "): + break + result: dict[str, list[str]] = {} + for position, (start, job_name) in enumerate(starts): + end = starts[position + 1][0] if position + 1 < len(starts) else len(lines) + result[job_name] = lines[start + 1 : end] + return result + + +def _job_if_expression(block: list[str]) -> str: + expression_lines: list[str] = [] + for index, line in enumerate(block): + if not line.startswith(" if:"): + continue + expression_lines.append(line.split(":", 1)[1]) + for continuation in block[index + 1 :]: + if continuation.startswith(" "): + expression_lines.append(continuation.strip()) + else: + break + break + return " ".join(expression_lines) + + +def _job_actor_rejection_failures(content: str) -> list[str]: + jobs = _workflow_job_blocks(content) + guard_name = "reject-lifecycle-app" + guard = jobs.get(guard_name) + if guard is None: + return [""] + guard_text = "\n".join(guard) + failures: list[str] = [] + if " permissions: {}" not in guard_text: + failures.append(f"{guard_name}:permissions") + if not ( + "github.actor" in guard_text + and "github.triggering_actor" in guard_text + and "openadapt-lifecycle[bot]" in guard_text + and guard_text.count("!=") >= 2 + ): + failures.append(f"{guard_name}:identity") + actor_pattern = re.compile( + r"github\.actor\s*!=\s*['\"]openadapt-lifecycle\[bot\]['\"]" + ) + triggering_pattern = re.compile( + r"github\.triggering_actor\s*!=\s*['\"]openadapt-lifecycle\[bot\]['\"]" + ) + for job_name, block in jobs.items(): + if job_name == guard_name: + continue + block_text = "\n".join(block) + expression = _job_if_expression(block) + if guard_name not in block_text or not re.search( + r"(?m)^ needs:[^\n]*reject-lifecycle-app", block_text + ): + failures.append(f"{job_name}:needs") + if ( + actor_pattern.search(expression) is None + or triggering_pattern.search(expression) is None + ): + failures.append(f"{job_name}:identity") + return failures + + +def _dispatch_workflow_blockers( + client: GitHubClient, + owner: str, + repo: Mapping[str, Any], +) -> list[dict[str, str]]: + inventory = { + item["path"]: item["mode"] + for item in repo.get("dispatch_workflow_inventory", []) + } + if not inventory: + return [] + tree = client.get(f"/repos/{owner}/{repo['name']}/git/trees/main?recursive=1") + if not isinstance(tree, Mapping) or tree.get("truncated"): + raise GitHubError( + f"{owner}/{repo['name']}: complete dispatch workflow tree is not available" + ) + workflow_paths = sorted( + item.get("path") + for item in tree.get("tree", []) + if item.get("type") == "blob" + and isinstance(item.get("path"), str) + and item["path"].startswith(".github/workflows/") + and item["path"].endswith((".yml", ".yaml")) + ) + blockers: list[dict[str, str]] = [] + for path in workflow_paths: + content = _workflow_text(client, owner, repo["name"], path) + if ( + content is None + or re.search( + r"(?m)^[ ]{2}(?:workflow_dispatch|repository_dispatch):\s*$", content + ) + is None + ): + continue + group = re.search(r"(?m)^[ ]{2}group:\s*([^\n]+)$", content) + non_cancelling = re.search( + r"(?m)^[ ]{2}cancel-in-progress:\s*false\s*$", content + ) + if ( + group is None + or "github.workflow" not in group.group(1) + or "github.event_name" not in group.group(1) + or non_cancelling is None + ): + blockers.append( + { + "code": "dispatch_workflow_concurrency_not_isolated", + "message": ( + f"{repo['name']}:{path} needs a workflow-and-event-specific " + "non-cancelling concurrency group." + ), + } + ) + mode = inventory.get(path) + if mode is None: + blockers.append( + { + "code": "dispatch_workflow_not_inventoried", + "message": f"{repo['name']}: dispatchable workflow {path} is not inventoried.", + } + ) + continue + if mode == "reject-lifecycle-app": + failures = _job_actor_rejection_failures(content) + if failures: + blockers.append( + { + "code": "dispatch_workflow_accepts_lifecycle_app", + "message": ( + f"{repo['name']}:{path} does not reject openadapt-lifecycle[bot] " + f"in every job: {', '.join(failures)}." + ), + } + ) + return blockers + + +def _list_rulesets( + client: GitHubClient, owner: str, repo: str +) -> dict[str, Mapping[str, Any]]: + response = client.get( + f"/repos/{owner}/{repo}/rulesets?includes_parents=false&per_page=100" + ) if not isinstance(response, list): raise GitHubError(f"{owner}/{repo}: ruleset list is not an array") result: dict[str, Mapping[str, Any]] = {} @@ -686,14 +1385,18 @@ def _environment_actions( ) -> tuple[list[dict[str, Any]], bool]: actions: list[dict[str, Any]] = [] prune_needed = False - for environment in repo["release_environments"]: + environments = repo["release_environments"] + repo.get("lifecycle_environments", []) + for environment in environments: name = environment["name"] encoded = quote(name, safe="") current = client.get( f"/repos/{owner}/{repo['name']}/environments/{encoded}", optional=True ) desired = desired_environment(config, environment) - if not isinstance(current, Mapping) or _normalize_environment(current) != desired: + if ( + not isinstance(current, Mapping) + or _normalize_environment(current) != desired + ): actions.append( { "kind": "put_environment", @@ -702,9 +1405,9 @@ def _environment_actions( } ) current_policies: list[Mapping[str, Any]] = [] - if isinstance(current, Mapping) and current.get("deployment_branch_policy", {}).get( - "custom_branch_policies" - ): + if isinstance(current, Mapping) and current.get( + "deployment_branch_policy", {} + ).get("custom_branch_policies"): response = client.get( f"/repos/{owner}/{repo['name']}/environments/{encoded}/deployment-branch-policies?per_page=100" ) @@ -746,6 +1449,8 @@ def build_plan(client: GitHubClient, config: Mapping[str, Any]) -> dict[str, Any owner = config["organization"] global_blockers: list[dict[str, str]] = [] actor = _resolve_release_actor(client, config, global_blockers) + lifecycle_actor = _resolve_lifecycle_actor(client, config, global_blockers) + docs_actor = _resolve_docs_actor(client, config, global_blockers) _verify_reviewer(client, config, global_blockers) repositories: list[dict[str, Any]] = [] @@ -821,13 +1526,42 @@ def build_plan(client: GitHubClient, config: Mapping[str, Any]) -> dict[str, Any client, owner, repo, "admission_workflows", "admission" ) ) + blockers.extend( + _workflow_contract_blockers( + client, owner, repo, "lifecycle_workflows", "lifecycle" + ) + ) + blockers.extend( + _dispatch_identity_variable_blockers( + client, + owner, + repo, + config["lifecycle_identity"], + lifecycle_actor, + ) + ) + blockers.extend( + _dispatch_identity_variable_blockers( + client, + owner, + repo, + config["docs_identity"], + docs_actor, + ) + ) + blockers.extend(_exclusive_environment_blockers(client, owner, repo)) + blockers.extend(_dispatch_workflow_blockers(client, owner, repo)) current_rulesets = _list_rulesets(client, owner, name) actions: list[dict[str, Any]] = [] for desired in desired_rulesets(config, repo, actor): current = current_rulesets.get(desired["name"]) if current is None: actions.append( - {"kind": "create_ruleset", "name": desired["name"], "payload": desired} + { + "kind": "create_ruleset", + "name": desired["name"], + "payload": desired, + } ) elif _normalize_ruleset(current) != _normalize_ruleset(desired): actions.append( @@ -868,6 +1602,14 @@ def build_plan(client: GitHubClient, config: Mapping[str, Any]) -> dict[str, Any "organization": owner, "config_sha256": _json_digest(config), "release_actor_id": actor.actor_id if actor else None, + "lifecycle_app_id": lifecycle_actor.app_id if lifecycle_actor else None, + "lifecycle_actor_id": lifecycle_actor.actor_id if lifecycle_actor else None, + "lifecycle_installation_id": ( + lifecycle_actor.installation_id if lifecycle_actor else None + ), + "docs_app_id": docs_actor.app_id if docs_actor else None, + "docs_actor_id": docs_actor.actor_id if docs_actor else None, + "docs_installation_id": docs_actor.installation_id if docs_actor else None, "global_blockers": global_blockers, "repositories": repositories, "plan_constraints": config["plan_constraints"], @@ -880,6 +1622,12 @@ def _plan_snapshot(plan: Mapping[str, Any]) -> dict[str, Any]: return { "config_sha256": plan.get("config_sha256"), "release_actor_id": plan.get("release_actor_id"), + "lifecycle_app_id": plan.get("lifecycle_app_id"), + "lifecycle_actor_id": plan.get("lifecycle_actor_id"), + "lifecycle_installation_id": plan.get("lifecycle_installation_id"), + "docs_app_id": plan.get("docs_app_id"), + "docs_actor_id": plan.get("docs_actor_id"), + "docs_installation_id": plan.get("docs_installation_id"), "repositories": [ { "name": repo.get("name"), @@ -938,7 +1686,9 @@ def _apply_actions( for action in repo["actions"]: kind = action["kind"] if kind == "create_ruleset": - client.write("POST", f"/repos/{owner}/{name}/rulesets", action["payload"]) + client.write( + "POST", f"/repos/{owner}/{name}/rulesets", action["payload"] + ) elif kind == "update_ruleset": client.write( "PUT", @@ -984,7 +1734,9 @@ def _write_json(value: Any, output: Path | None) -> None: def _default_config() -> Path: - return Path(__file__).resolve().parents[1] / "ops/github/core-protection-policy.json" + return ( + Path(__file__).resolve().parents[1] / "ops/github/core-protection-policy.json" + ) def build_parser() -> argparse.ArgumentParser: @@ -997,7 +1749,9 @@ def build_parser() -> argparse.ArgumentParser: plan = commands.add_parser("plan", help="Read GitHub and write a non-mutating plan") plan.add_argument("--output", type=Path) - verify = commands.add_parser("verify", help="Verify live GitHub state against the policy") + verify = commands.add_parser( + "verify", help="Verify live GitHub state against the policy" + ) verify.add_argument("--output", type=Path) apply = commands.add_parser("apply", help="Apply one fresh, reviewed plan") diff --git a/tests/test_manage_github_protection.py b/tests/test_manage_github_protection.py index 6de1680..333a91c 100644 --- a/tests/test_manage_github_protection.py +++ b/tests/test_manage_github_protection.py @@ -20,6 +20,7 @@ ReleaseActor, _apply_actions, build_plan, + desired_environment, desired_rulesets, load_config, validate_config, @@ -36,16 +37,40 @@ def __init__( *, active_repo: str | None = None, path_filtered_repo: str | None = None, + missing_lifecycle_app: bool = False, + missing_docs_app: bool = False, + extra_dispatch_repo: str | None = None, + unguarded_dispatch_repo: str | None = None, + unauthorized_environment_repo: str | None = None, + cancelling_dispatch_repo: str | None = None, ) -> None: self.config = config self.active_repo = active_repo self.path_filtered_repo = path_filtered_repo + self.missing_lifecycle_app = missing_lifecycle_app + self.missing_docs_app = missing_docs_app + self.extra_dispatch_repo = extra_dispatch_repo + self.unguarded_dispatch_repo = unguarded_dispatch_repo + self.unauthorized_environment_repo = unauthorized_environment_repo + self.cancelling_dispatch_repo = cancelling_dispatch_repo self.writes: list[tuple[str, str, Mapping[str, Any]]] = [] self.by_name = {repo["name"]: repo for repo in config["repositories"]} def get(self, path: str, *, optional: bool = False) -> Any: if path == "/apps/openadapt-release": return {"id": 991122, "slug": "openadapt-release"} + if path == "/apps/openadapt-lifecycle": + if self.missing_lifecycle_app: + return None + return {"id": 771100, "slug": "openadapt-lifecycle"} + if path == "/apps/openadapt-docs": + if self.missing_docs_app: + return None + return {"id": 772200, "slug": "openadapt-docs"} + if path == "/users/openadapt-lifecycle%5Bbot%5D": + return {"id": 881100, "login": "openadapt-lifecycle[bot]"} + if path == "/users/openadapt-docs%5Bbot%5D": + return {"id": 882200, "login": "openadapt-docs[bot]"} if path == "/users/abrichr": return {"id": 774615, "login": "abrichr"} if path == "/orgs/OpenAdaptAI/installations?per_page=100": @@ -56,9 +81,41 @@ def get(self, path: str, *, optional: bool = False) -> Any: "app_id": 991122, "app_slug": "openadapt-release", "repository_selection": "all", - } + }, + { + "id": 661100, + "app_id": 771100, + "app_slug": "openadapt-lifecycle", + "repository_selection": "selected", + "permissions": { + "actions": "write", + "metadata": "read", + "pull_requests": "write", + }, + }, + { + "id": 761100, + "app_id": 772200, + "app_slug": "openadapt-docs", + "repository_selection": "selected", + "permissions": { + "actions": "write", + "metadata": "read", + "pull_requests": "write", + }, + }, + ] + } + if path == "/user/installations/661100/repositories?per_page=100": + return { + "repositories": [ + {"name": ".github"}, + {"name": "openadapt-evals"}, + {"name": "openadapt-ops"}, ] } + if path == "/user/installations/761100/repositories?per_page=100": + return {"repositories": [{"name": "openadapt-ops"}]} parts = path.split("?")[0].split("/") if len(parts) >= 4 and parts[1] == "repos": name = parts[3] @@ -75,7 +132,11 @@ def get(self, path: str, *, optional: bool = False) -> Any: if name == self.active_repo: return { "check_runs": [ - {"name": "test", "status": "in_progress", "conclusion": None} + { + "name": "test", + "status": "in_progress", + "conclusion": None, + } ] } return {"check_runs": []} @@ -93,26 +154,313 @@ def get(self, path: str, *, optional: bool = False) -> Any: return [] if parts[4] == "environments": return None + if parts[4:6] == ["actions", "variables"]: + variable = parts[6] + values = { + "OPENADAPT_LIFECYCLE_APP_ID": "771100", + "OPENADAPT_LIFECYCLE_ACTOR_ID": "881100", + "OPENADAPT_LIFECYCLE_INSTALLATION_ID": "661100", + "OPENADAPT_DOCS_APP_ID": "772200", + "OPENADAPT_DOCS_ACTOR_ID": "882200", + "OPENADAPT_DOCS_INSTALLATION_ID": "761100", + } + return {"name": variable, "value": values[variable]} + if parts[4:6] == ["git", "trees"]: + configured = set() + for field in ( + "release_workflows", + "admission_workflows", + "lifecycle_workflows", + "dispatch_workflow_inventory", + ): + configured.update(item["path"] for item in repo.get(field, [])) + if name == self.extra_dispatch_repo: + configured.add(".github/workflows/uninventoried.yml") + if name == self.unauthorized_environment_repo: + configured.add(".github/workflows/unauthorized.yml") + return { + "truncated": False, + "tree": [ + {"path": item, "type": "blob"} for item in sorted(configured) + ], + } if parts[4] == "contents": - workflow = ( - "permissions:\n" - " id-token: write\n" - "jobs:\n" - " prepare:\n" - " environment: release-identity\n" - " pypi:\n" - " environment: pypi\n" - " native:\n" - " environment: native-release\n" - ) - if name == self.path_filtered_repo: - workflow += "pull_request:\n paths-ignore:\n - docs/**\n" + workflow_path = "/".join(parts[5:]) + workflow = self._workflow_content(name, workflow_path) return { "type": "file", "content": base64.b64encode(workflow.encode()).decode(), } raise AssertionError(f"unexpected GET {path}") + def _workflow_content(self, repo_name: str, path: str) -> str: + repo = self.by_name[repo_name] + lifecycle_path = next( + ( + item["exclusive_workflow"] + for item in repo.get("lifecycle_environments", []) + if item["exclusive_workflow"] == path + ), + None, + ) + if lifecycle_path is not None: + environment = next( + item["name"] + for item in repo["lifecycle_environments"] + if item["exclusive_workflow"] == path + ) + job_names = { + ".github/workflows/production-lifecycle-activation.yml": ( + "activate", + "Create Production lifecycle activation PR", + ), + ".github/workflows/qualification-authority-state.yml": ( + "update", + "Create qualification authority state PR", + ), + ".github/workflows/qualification-revocation-state.yml": ( + "update", + "Create qualification revocation state PR", + ), + ".github/workflows/production-lifecycle-evidence.yml": ( + "produce", + "Produce Production lifecycle evidence", + ), + ".github/workflows/production-lifecycle-projection.yml": ( + "project", + "Project canonical Production lifecycle", + ), + } + job_id, job_name = job_names[path] + projection_inputs = "" + projection_conditions = "" + projection_steps = "" + if path == ".github/workflows/production-lifecycle-projection.yml": + projection_inputs = ( + " inputs:\n" + " source_event:\n" + " source_repository:\n" + " source_ref:\n" + " source_commit:\n" + " candidate_admissions_sha256:\n" + " candidate_ledger_head_sha256:\n" + " idempotency_key:\n" + ) + projection_conditions = ( + " &&\n" + " inputs.source_event == 'production_lifecycle_ledger_changed' &&\n" + " inputs.source_repository == 'OpenAdaptAI/.github' &&\n" + " inputs.source_ref == 'refs/heads/main'\n" + ) + projection_steps = ( + " - run: gh api repos/OpenAdaptAI/.github/commits/main && " + "test sha =~ '[0-9a-f]{40}'\n" + " - run: test digests =~ 'sha256:[0-9a-f]{64}'\n" + " - run: echo 'OpenAdapt production lifecycle ledger head v1\\0'\n" + " - run: echo 'OpenAdapt production lifecycle projection idempotency v1\\0'\n" + " - run: echo '${{ inputs.source_commit }} " + "${{ inputs.candidate_admissions_sha256 }} " + "${{ inputs.candidate_ledger_head_sha256 }} " + "${{ inputs.idempotency_key }}'\n" + ) + return ( + "name: Lifecycle fixture\n" + "on:\n" + " workflow_dispatch:\n" + f"{projection_inputs}" + "permissions:\n" + " attestations: write\n" + " contents: write\n" + " id-token: write\n" + "concurrency:\n" + " group: ${{ github.workflow }}-${{ github.event_name }}\n" + " cancel-in-progress: false\n" + "jobs:\n" + f" {job_id}:\n" + f" name: {job_name}\n" + " if: >-\n" + f" github.repository == 'OpenAdaptAI/{repo_name}' &&\n" + " github.ref == 'refs/heads/main' &&\n" + " github.event_name == 'workflow_dispatch' &&\n" + " github.actor == 'openadapt-lifecycle[bot]' &&\n" + " github.triggering_actor == 'openadapt-lifecycle[bot]' &&\n" + " github.actor_id == vars.OPENADAPT_LIFECYCLE_ACTOR_ID" + f"{projection_conditions}" + "\n" + " environment:\n" + f" name: {environment}\n" + " steps:\n" + " - uses: actions/attest@deadbeef\n" + f"{projection_steps}" + " - run: echo '${{ vars.OPENADAPT_LIFECYCLE_APP_ID }} " + "${{ vars.OPENADAPT_LIFECYCLE_INSTALLATION_ID }} " + "${{ secrets.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY }}'\n" + " - env:\n" + " GH_TOKEN: ${{ github.token }}\n" + " run: git push origin HEAD && gh pr create\n" + ) + + inventory_mode = next( + ( + item["mode"] + for item in repo.get("dispatch_workflow_inventory", []) + if item["path"] == path + ), + None, + ) + if path == ".github/workflows/uninventoried.yml": + inventory_mode = "reject-lifecycle-app" + if inventory_mode == "docs-only": + return ( + "name: Documentation sync fixture\n" + "on:\n" + " push:\n" + " branches: [main]\n" + " workflow_dispatch:\n" + " inputs:\n" + " source_repository:\n" + " source_ref:\n" + " source_commit:\n" + " source_event:\n" + " idempotency_key:\n" + "permissions:\n" + " contents: write\n" + " pages: write\n" + " id-token: write\n" + "concurrency:\n" + " group: ${{ github.workflow }}-${{ github.event_name }}\n" + " cancel-in-progress: false\n" + "jobs:\n" + " sync-docs:\n" + " if: >-\n" + " github.repository == 'OpenAdaptAI/openadapt-ops' &&\n" + " github.ref == 'refs/heads/main' &&\n" + " github.event_name == 'workflow_dispatch' &&\n" + " github.actor == 'openadapt-docs[bot]' &&\n" + " github.triggering_actor == 'openadapt-docs[bot]' &&\n" + " github.actor_id == vars.OPENADAPT_DOCS_ACTOR_ID &&\n" + " inputs.source_repository == 'OpenAdaptAI/openadapt-evals' &&\n" + " inputs.source_ref == 'refs/heads/main' &&\n" + " inputs.source_event == 'push' &&\n" + " inputs.source_commit != '' && inputs.idempotency_key != ''\n" + " environment: production-docs-deploy\n" + " steps:\n" + " - run: gh api repos/source/commits/main && test sha =~ '[0-9a-f]{40}'\n" + " - run: python scripts/validate_docs_sync.py repos.yml 'OpenAdapt docs sync dispatch v1' sha256\n" + " - run: test '${{ inputs.idempotency_key }}' =~ '^docs-sync:[0-9a-f]{64}$'\n" + " - run: echo '${{ vars.OPENADAPT_DOCS_APP_ID }} " + "${{ vars.OPENADAPT_DOCS_INSTALLATION_ID }} " + "${{ secrets.OPENADAPT_DOCS_APP_PRIVATE_KEY }}'\n" + " - env:\n" + " GH_TOKEN: ${{ github.token }}\n" + " run: git push origin HEAD:automation-docs && gh pr create\n" + " deploy-pages:\n" + " if: github.event_name == 'push'\n" + " environment:\n" + " name: github-pages\n" + " steps:\n" + " - run: true\n" + ) + if inventory_mode == "reject-lifecycle-app": + guarded = repo_name != self.unguarded_dispatch_repo + cancel_value = ( + "true" if repo_name == self.cancelling_dispatch_repo else "false" + ) + extra = "" + if path == ".github/workflows/profile-consistency.yml": + extra = " pull_request:\n" + if repo_name == self.path_filtered_repo: + extra += " paths-ignore:\n - docs/**\n" + job_id = "validate-profile" + job_name = "Validate profile" + elif path == ".github/workflows/production-lifecycle-policy.yml": + extra = " pull_request:\n" + if repo_name == self.path_filtered_repo: + extra += " paths-ignore:\n - docs/**\n" + job_id = "validate" + job_name = "Validate Production lifecycle" + else: + job_id = "run" + job_name = "Run" + pages = "" + if path == ".github/workflows/sync.yml": + pages = ( + "permissions:\n" + " contents: write\n" + " pages: write\n" + " id-token: write\n" + ) + environment = " environment:\n name: github-pages\n" + else: + pages = "permissions:\n contents: read\n" + environment = "" + return ( + "name: Dispatch fixture\n" + "on:\n" + " workflow_dispatch:\n" + f"{extra}" + f"{pages}" + "concurrency:\n" + " group: ${{ github.workflow }}-${{ github.event_name }}\n" + f" cancel-in-progress: {cancel_value}\n" + "jobs:\n" + + ( + " reject-lifecycle-app:\n" + " permissions: {}\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - env:\n" + " ACTOR: ${{ github.actor }}\n" + " TRIGGERING_ACTOR: ${{ github.triggering_actor }}\n" + " run: test \"$ACTOR\" != 'openadapt-lifecycle[bot]' " + "-a \"$TRIGGERING_ACTOR\" != 'openadapt-lifecycle[bot]'\n" + if guarded + else "" + ) + + ( + f" {job_id}:\n" + f" name: {job_name}\n" + + ( + " needs: reject-lifecycle-app\n" + " if: >-\n" + " github.actor != 'openadapt-lifecycle[bot]' &&\n" + " github.triggering_actor != 'openadapt-lifecycle[bot]'\n" + if guarded + else "" + ) + + ( + f"{environment}" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - run: true\n" + ) + ) + ) + + if path == ".github/workflows/unauthorized.yml": + environment = repo["lifecycle_environments"][0]["name"] + return f"on:\n push:\njobs:\n run:\n environment: {environment}\n" + + workflow = ( + "on:\n" + " pull_request:\n" + "permissions:\n" + " id-token: write\n" + "jobs:\n" + " prepare:\n" + " environment: release-identity\n" + " pypi:\n" + " environment: pypi\n" + " native:\n" + " environment: native-release\n" + ) + if repo_name == self.path_filtered_repo: + workflow = workflow.replace( + " pull_request:\n", + " pull_request:\n paths-ignore:\n - docs/**\n", + ) + return workflow + def write( self, method: str, path: str, payload: Mapping[str, Any] | None = None ) -> Any: @@ -123,6 +471,12 @@ def write( def config() -> dict[str, Any]: value = load_config(CONFIG_PATH) value["release_identity"]["actor_id"] = 991122 + value["lifecycle_identity"]["app_id"] = 771100 + value["lifecycle_identity"]["actor_id"] = 881100 + value["lifecycle_identity"]["installation_id"] = 661100 + value["docs_identity"]["app_id"] = 772200 + value["docs_identity"]["actor_id"] = 882200 + value["docs_identity"]["installation_id"] = 761100 return value @@ -158,6 +512,173 @@ def test_policy_has_only_the_reviewed_owned_repositories() -> None: ] +def test_lifecycle_identity_is_separate_and_least_privilege() -> None: + value = load_config(CONFIG_PATH) + release = value["release_identity"] + lifecycle = value["lifecycle_identity"] + assert release["app_slug"] == "openadapt-release" + assert release["required_repository_permissions"] == [ + "Contents: write", + "Pull requests: write", + "Metadata: read", + ] + assert lifecycle["app_slug"] == "openadapt-lifecycle" + assert lifecycle["actor_login"] == "openadapt-lifecycle[bot]" + assert lifecycle["repository_scope"] == [ + ".github", + "openadapt-evals", + "openadapt-ops", + ] + assert lifecycle["required_repository_permissions"] == [ + "Actions: write", + "Metadata: read", + "Pull requests: write", + ] + assert lifecycle["forbidden_repository_permissions"] == ["Contents: write"] + assert lifecycle["ruleset_bypass"] is False + assert lifecycle["workflow_paths"][".github"] == [ + ".github/workflows/production-lifecycle-activation.yml", + ".github/workflows/qualification-authority-state.yml", + ".github/workflows/qualification-revocation-state.yml", + ] + assert set(lifecycle["actions_write_risk"]["capabilities"]) == { + "Dispatch repository workflows", + "Cancel or rerun workflow runs", + "Delete workflow artifacts", + } + docs = value["docs_identity"] + assert docs["app_slug"] == "openadapt-docs" + assert docs["actor_login"] == "openadapt-docs[bot]" + assert docs["repository_scope"] == ["openadapt-ops"] + assert docs["required_repository_permissions"] == [ + "Actions: write", + "Metadata: read", + "Pull requests: write", + ] + assert docs["forbidden_repository_permissions"] == ["Contents: write"] + assert docs["ruleset_bypass"] is False + audit = value["dispatch_privilege_audit"] + assert audit["openadapt_ops_main_protected"] is False + assert set(audit["unprotected_operational_environments"]) == { + "production-backup", + "production-backup-monitor", + } + assert audit["lifecycle_app_installation"] == "absent" + assert audit["docs_app_installation"] == "absent" + + +def test_lifecycle_environments_override_the_unchanged_default() -> None: + value = load_config(CONFIG_PATH) + assert value["environment_defaults"] == { + "wait_timer": 0, + "prevent_self_review": False, + } + expected = { + ".github": [ + ( + "production-lifecycle-activation", + ".github/workflows/production-lifecycle-activation.yml", + ), + ( + "qualification-authority-state", + ".github/workflows/qualification-authority-state.yml", + ), + ( + "qualification-revocation-state", + ".github/workflows/qualification-revocation-state.yml", + ), + ], + "openadapt-evals": [ + ( + "production-lifecycle-evidence", + ".github/workflows/production-lifecycle-evidence.yml", + ) + ], + "openadapt-ops": [ + ( + "production-lifecycle-projection", + ".github/workflows/production-lifecycle-projection.yml", + ) + ], + } + by_name = {repo["name"]: repo for repo in value["repositories"]} + for repo_name, expected_environments in expected.items(): + environments = by_name[repo_name]["lifecycle_environments"] + assert environments == [ + { + "name": environment_name, + "wait_timer": 0, + "prevent_self_review": True, + "deployment_policies": [{"type": "branch", "name": "main"}], + "exclusive_workflow": workflow_path, + } + for environment_name, workflow_path in expected_environments + ] + assert all( + desired_environment(value, environment)["prevent_self_review"] is True + for environment in environments + ) + + +def test_pages_and_lifecycle_required_checks_are_exact() -> None: + value = load_config(CONFIG_PATH) + by_name = {repo["name"]: repo for repo in value["repositories"]} + ops = by_name["openadapt-ops"] + profile = by_name[".github"] + assert "Validate Production lifecycle" in ops["required_checks"] + assert profile["required_checks"] == ["validate-profile"] + assert ops["release_environments"] == [ + { + "name": "github-pages", + "deployment_policies": [{"type": "branch", "name": "main"}], + "exclusive_workflow": ".github/workflows/sync.yml", + }, + { + "name": "production-docs-deploy", + "wait_timer": 0, + "prevent_self_review": True, + "deployment_policies": [{"type": "branch", "name": "main"}], + "exclusive_workflow": ".github/workflows/sync.yml", + }, + ] + sync = ops["release_workflows"][0] + assert sync["path"] == ".github/workflows/sync.yml" + assert any( + "pages" in pattern and "write" in pattern + for pattern in sync["required_patterns"] + ) + assert any( + "id-token" in pattern and "write" in pattern + for pattern in sync["required_patterns"] + ) + assert "inputs\\.source_event\\s*==\\s*['\"]push['\"]" in sync["required_patterns"] + assert "repo-updated" in sync["forbidden_patterns"] + assert "docs-sync:" in sync["required_patterns"] + + projection = ops["lifecycle_workflows"][0] + assert projection["path"] == ".github/workflows/production-lifecycle-projection.yml" + for exact_pattern in ( + "inputs\\.candidate_admissions_sha256", + "inputs\\.candidate_ledger_head_sha256", + "inputs\\.idempotency_key", + "OpenAdapt production lifecycle ledger head v1\\\\0", + "OpenAdapt production lifecycle projection idempotency v1\\\\0", + ): + assert exact_pattern in projection["required_patterns"] + + qualification = [ + item + for item in profile["lifecycle_workflows"] + if "qualification-" in item["path"] + ] + assert len(qualification) == 2 + assert all("actions/attest" in item["required_patterns"] for item in qualification) + assert all( + any("git\\s+push" in pattern for pattern in item["forbidden_patterns"]) + for item in profile["lifecycle_workflows"] + ) + + def test_path_scoped_check_cannot_also_be_required() -> None: value = config() value["repositories"][0]["path_scoped_checks"].append( @@ -193,7 +714,9 @@ def test_main_has_no_bypass_and_tag_immutability_has_no_bypass() -> None: } -def test_plan_is_read_only_and_never_manages_private_cloud(monkeypatch: pytest.MonkeyPatch) -> None: +def test_plan_is_read_only_and_never_manages_private_cloud( + monkeypatch: pytest.MonkeyPatch, +) -> None: value = config() monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") github = ReadOnlyFixtureGitHub(value) @@ -209,21 +732,119 @@ def test_plan_is_read_only_and_never_manages_private_cloud(monkeypatch: pytest.M assert all(repo["actions"] for repo in plan["repositories"]) -def test_active_pull_request_check_blocks_apply(monkeypatch: pytest.MonkeyPatch) -> None: +def test_missing_lifecycle_app_keeps_plan_fail_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + github = ReadOnlyFixtureGitHub(value, missing_lifecycle_app=True) + plan = build_plan(github, value) + assert plan["safe_to_apply"] is False + assert plan["lifecycle_app_id"] is None + assert {item["code"] for item in plan["global_blockers"]} >= { + "lifecycle_identity_app_not_found" + } + assert github.writes == [] + + +def test_missing_docs_app_keeps_plan_fail_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + github = ReadOnlyFixtureGitHub(value, missing_docs_app=True) + plan = build_plan(github, value) + assert plan["safe_to_apply"] is False + assert plan["docs_app_id"] is None + assert {item["code"] for item in plan["global_blockers"]} >= { + "docs_identity_app_not_found" + } + assert github.writes == [] + + +def test_uninventoried_dispatch_workflow_blocks_apply( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + plan = build_plan( + ReadOnlyFixtureGitHub(value, extra_dispatch_repo="openadapt-evals"), value + ) + evals = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-evals" + ) + assert {item["code"] for item in evals["blockers"]} >= { + "dispatch_workflow_not_inventoried" + } + + +def test_dispatch_workflow_without_app_rejection_blocks_apply( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + plan = build_plan( + ReadOnlyFixtureGitHub(value, unguarded_dispatch_repo="openadapt-ops"), value + ) + ops = next(repo for repo in plan["repositories"] if repo["name"] == "openadapt-ops") + assert {item["code"] for item in ops["blockers"]} >= { + "dispatch_workflow_accepts_lifecycle_app" + } + + +def test_dispatch_workflow_cannot_cancel_an_active_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + plan = build_plan( + ReadOnlyFixtureGitHub(value, cancelling_dispatch_repo="openadapt-evals"), + value, + ) + evals = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-evals" + ) + assert {item["code"] for item in evals["blockers"]} >= { + "dispatch_workflow_concurrency_not_isolated" + } + + +def test_lifecycle_environment_reference_outside_exact_workflow_blocks_apply( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + plan = build_plan( + ReadOnlyFixtureGitHub(value, unauthorized_environment_repo="openadapt-evals"), + value, + ) + evals = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-evals" + ) + assert {item["code"] for item in evals["blockers"]} >= { + "lifecycle_environment_workflow_scope" + } + + +def test_active_pull_request_check_blocks_apply( + monkeypatch: pytest.MonkeyPatch, +) -> None: value = config() monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") plan = build_plan(ReadOnlyFixtureGitHub(value, active_repo="openadapt-flow"), value) - flow = next(repo for repo in plan["repositories"] if repo["name"] == "openadapt-flow") + flow = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-flow" + ) assert plan["safe_to_apply"] is False assert flow["active_checks"] == [ {"pull_request": 12, "name": "test", "status": "in_progress"} ] - assert {item["code"] for item in flow["blockers"]} == { - "active_pull_request_checks" - } + assert {item["code"] for item in flow["blockers"]} == {"active_pull_request_checks"} -def test_path_filtered_target_check_blocks_apply(monkeypatch: pytest.MonkeyPatch) -> None: +def test_path_filtered_target_check_blocks_apply( + monkeypatch: pytest.MonkeyPatch, +) -> None: value = config() monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") plan = build_plan( @@ -284,3 +905,12 @@ def test_apply_plan_must_be_fresh_and_unchanged() -> None: changed["release_actor_id"] = 2 with pytest.raises(PolicyError, match="live state changed"): validate_plan_for_apply(fresh, changed, value) + + changed_lifecycle = json.loads(json.dumps(fresh)) + fresh["lifecycle_app_id"] = 771100 + fresh["lifecycle_actor_id"] = 881100 + fresh["lifecycle_installation_id"] = 661100 + changed_lifecycle.update(fresh) + changed_lifecycle["lifecycle_actor_id"] = 881101 + with pytest.raises(PolicyError, match="live state changed"): + validate_plan_for_apply(fresh, changed_lifecycle, value)