From 2304819cc021aca7d581f65b3a043b4e23fdb869 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 20 Aug 2026 11:52:06 -0400 Subject: [PATCH 1/6] 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 cb3cdbc25707923a4ed9b24bc793302f39e2b173 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 20 Aug 2026 12:56:18 -0400 Subject: [PATCH 2/6] 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) From 48bb90ffbab6357313cfbd8e1e12e2e7223ac2e5 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 26 Aug 2026 13:26:41 -0400 Subject: [PATCH 3/6] fix: align core protection with product admissions --- ops/github/README.md | 40 ++++---- ops/github/core-protection-policy.json | 122 ++++++++++++++++++++++--- scripts/manage_github_protection.py | 95 ++++++++++++++----- tests/test_manage_github_protection.py | 120 +++++++++++++++++++++++- 4 files changed, 322 insertions(+), 55 deletions(-) diff --git a/ops/github/README.md b/ops/github/README.md index 6f669be..5e750d8 100644 --- a/ops/github/README.md +++ b/ops/github/README.md @@ -1,8 +1,7 @@ # 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. +This directory contains the reviewed target policy for nine public core +repositories. It doesn't manage `openadapt-cloud` or a foreign repository. The policy has these results: @@ -14,6 +13,8 @@ The policy has these results: - 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. +- The release App selects only the nine public core repositories. It has no + access to private Cloud. - 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 @@ -36,23 +37,26 @@ 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 +## Read-only audit on 2026-08-26 -The public GitHub API reported zero repository rulesets in all eight +The public GitHub API reported zero repository rulesets in all nine 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. +`openadapt-agent`, `openadapt-flow`, `openadapt-capture`, `openadapt-evals`, +and `openadapt-web`. It marked `openadapt-desktop`, `openadapt-ops`, and +`.github` as not protected. + +An authenticated read returned the exact classic protection settings. Flow has +thirteen strict required checks but no review rule. Capture has four strict +checks. Evals has one non-strict check and permits force pushes. OpenAdapt, +Agent, Capture, and Web require zero approvals and don't enforce administrators. + +Agent and Flow had an unprotected `pypi` environment. Evals had unprotected +`pypi` and `testpypi` environments. Capture and OpenAdapt had no package 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. +environment and the PyPI environment. Agent also gets a separate +`mcp-registry` environment. The tool doesn't 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. @@ -173,6 +177,10 @@ Use this sequence for each package repository: 7. The publication job enters `pypi` or `native-release`. 8. The job uses OIDC to publish the exact tag bytes. +Agent uses `mcp-registry` after its PyPI publication. Both publication jobs use +OIDC. The Agent release workflow can't accept an API-token fallback or +download an unpinned registry publisher. + 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. diff --git a/ops/github/core-protection-policy.json b/ops/github/core-protection-policy.json index 83a6fc8..efce97e 100644 --- a/ops/github/core-protection-policy.json +++ b/ops/github/core-protection-policy.json @@ -1,13 +1,14 @@ { "schema_version": 1, "organization": "OpenAdaptAI", - "reviewed_at": "2026-08-20T00:00:00Z", + "reviewed_at": "2026-08-26T00:00:00Z", "review_source": "Exact origin/main refs plus GitHub pull-request workflow jobs", "live_audit": { - "observed_at": "2026-08-20", + "observed_at": "2026-08-26", "repository_ruleset_counts": { ".github": 0, "OpenAdapt": 0, + "openadapt-agent": 0, "openadapt-capture": 0, "openadapt-desktop": 0, "openadapt-evals": 0, @@ -18,6 +19,7 @@ "main_protected": { ".github": false, "OpenAdapt": true, + "openadapt-agent": true, "openadapt-capture": true, "openadapt-desktop": false, "openadapt-evals": true, @@ -25,20 +27,21 @@ "openadapt-ops": false, "openadapt-web": true }, - "classic_branch_protection_detail": "Not available without valid authenticated administration read access", + "classic_branch_protection_detail": "Authenticated read: OpenAdapt, Agent, Capture, and Web require zero approvals and do not enforce administrators; Flow has thirteen strict checks and no review rule; Capture has four strict checks; Evals has one non-strict check and permits force pushes; Desktop, Ops, and Profile are unprotected.", "release_environment_state": { - "OpenAdapt": "No release environment", + "OpenAdapt": "No package release environment; github-pages admits an exact branch policy", + "openadapt-agent": "pypi exists with no protection rule and no deployment policy; release-identity and mcp-registry do not exist", "openadapt-flow": "pypi exists with no protection rule and no deployment policy", - "openadapt-capture": "No release environment", + "openadapt-capture": "No package release environment; production-qualification uses protected branches and one required reviewer", "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": "production-backup and production-backup-monitor exist without a protection rule, deployment branch policy, or reviewer; operational backup environments remain outside this policy", + "openadapt-evals": "pypi and testpypi exist with no protection rule and no deployment policy", + "openadapt-ops": "github-pages admits an exact branch policy; production-backup and production-backup-monitor have no 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", + "observed_at": "2026-08-26", "openadapt_ops_main_protected": false, "unprotected_operational_environments": { "production-backup": { @@ -191,7 +194,7 @@ "name": "OpenAdapt", "visibility": "public", "default_branch": "main", - "audited_main_sha": "42970fbb1246f77f8350254569e5ce55f9bb60ed", + "audited_main_sha": "e68fb332c0da9da0125e45e0aa9babfdf942595b", "require_code_owner_review": true, "required_checks": [ "Analyze (python)", @@ -245,11 +248,100 @@ } ] }, + { + "name": "openadapt-agent", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "be253dd558a95bfb457c18715d258854aba79f69", + "require_code_owner_review": true, + "required_checks": [ + "Analyze (python)", + "dependency-review", + "gitleaks", + "test (3.10, floor)", + "test (3.11, current)", + "test (3.12, current)" + ], + "path_scoped_checks": [ + "Build + validate (dry run, no publish)" + ], + "admission_gaps": [ + "Move tag creation to a founder-reviewed main-branch job that uses the release App. Publish to PyPI and the MCP registry from separate protected tag environments. Remove the token fallbacks, the release-event trigger, and the unpinned registry publisher download." + ], + "release_tag_patterns": [ + "refs/tags/v*" + ], + "release_environments": [ + { + "name": "release-identity", + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ], + "exclusive_workflow": ".github/workflows/release.yml" + }, + { + "name": "pypi", + "deployment_policies": [ + { + "type": "tag", + "name": "v*" + } + ], + "exclusive_workflow": ".github/workflows/release.yml" + }, + { + "name": "mcp-registry", + "deployment_policies": [ + { + "type": "tag", + "name": "v*" + } + ], + "exclusive_workflow": ".github/workflows/release.yml" + } + ], + "release_workflows": [ + { + "path": ".github/workflows/release.yml", + "required_patterns": [ + "(?m)^[ ]{2}workflow_dispatch:\\s*$", + "(?m)^[ ]{2}push:\\s*$", + "tags:\\s*\\[?[\"']?v\\*[\"']?\\]?", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?release-identity\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?pypi\\s*$", + "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?mcp-registry\\s*$", + "actions/create-github-app-token", + "vars\\.OPENADAPT_RELEASE_APP_ID", + "secrets\\.OPENADAPT_RELEASE_APP_PRIVATE_KEY", + "permission-contents:\\s*write", + "git\\s+tag", + "git\\s+push", + "(?m)^\\s*id-token:\\s*write\\s*$", + "pypa/gh-action-pypi-publish", + "mcp-publisher\\s+login\\s+github-oidc", + "scripts/check_release_artifacts\\.py", + "scripts/check_source_boundary\\.py\\s+--require-dist", + "scripts/verify_release_registries\\.py", + "production-admission-candidate\\.json" + ], + "forbidden_patterns": [ + "ADMIN_TOKEN", + "(?m)^[ ]{2}release:\\s*$", + "PYPI_API_TOKEN", + "MCP_GITHUB_TOKEN", + "registry/releases/latest/download" + ] + } + ] + }, { "name": "openadapt-flow", "visibility": "public", "default_branch": "main", - "audited_main_sha": "7da6571bdc38faca5b6b944ae8dbac6ec1d771cb", + "audited_main_sha": "e17e2aa9ab367c11b2a64b2022f1cf45e62a729d", "require_code_owner_review": true, "required_checks": [ "docs-consistency", @@ -311,7 +403,7 @@ "name": "openadapt-capture", "visibility": "public", "default_branch": "main", - "audited_main_sha": "61096788a623a4a302a69a363215dad81fb9008b", + "audited_main_sha": "d1f66151b72fe95967b6f8d2696ab93cf5b49721", "require_code_owner_review": true, "required_checks": [ "Analyze (javascript-typescript)", @@ -482,7 +574,7 @@ "name": "openadapt-evals", "visibility": "public", "default_branch": "main", - "audited_main_sha": "b7d4fe842f5a9ae9e4188df6087911b24a1706f2", + "audited_main_sha": "aca85d431158ac517c969c5a146b38f1895c9ada", "require_code_owner_review": false, "required_checks": [ "test" @@ -587,7 +679,7 @@ "local_repository_name": "openadapt-maintenance", "visibility": "public", "default_branch": "main", - "audited_main_sha": "2b3fef3a6672fe7dd31312e2cba56608342bb4dd", + "audited_main_sha": "91edc06ae38e879755d7faac786e60a0e2aacd4e", "require_code_owner_review": true, "required_checks": [ "Analyze (python)", @@ -784,7 +876,7 @@ "name": "openadapt-web", "visibility": "public", "default_branch": "main", - "audited_main_sha": "ba35b60125160b4b40a0871644709d8c7e29259f", + "audited_main_sha": "0a23bd9af5ade15928e79cc4d8e0278e983d375c", "require_code_owner_review": true, "required_checks": [ "Analyze (javascript-typescript)", @@ -815,7 +907,7 @@ "name": ".github", "visibility": "public", "default_branch": "main", - "audited_main_sha": "d60445bc8617adc4a27c20e6ce05681a95d83a2c", + "audited_main_sha": "efa5c2299273d94f56b428e7c60a3d96fe382c69", "require_code_owner_review": false, "required_checks": [ "validate-profile" diff --git a/scripts/manage_github_protection.py b/scripts/manage_github_protection.py index 53ff3ed..2730575 100755 --- a/scripts/manage_github_protection.py +++ b/scripts/manage_github_protection.py @@ -29,6 +29,7 @@ EXPECTED_REPOSITORIES = { ".github", "OpenAdapt", + "openadapt-agent", "openadapt-capture", "openadapt-desktop", "openadapt-evals", @@ -189,7 +190,7 @@ 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" + f"live_audit.{field} must cover the nine core repositories" ) dispatch_audit = config.get("dispatch_privilege_audit") if not isinstance(dispatch_audit, Mapping): @@ -212,6 +213,22 @@ 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") + release_identity = config.get("release_identity") + if not isinstance(release_identity, Mapping): + raise PolicyError("release_identity must be an object") + if ( + release_identity.get("actor_type") != "Integration" + or release_identity.get("app_slug") != "openadapt-release" + or release_identity.get("bypass_mode") != "always" + ): + raise PolicyError("release_identity is not exact") + if release_identity.get("required_repository_permissions") != [ + "Contents: write", + "Pull requests: write", + "Metadata: read", + ]: + raise PolicyError("release_identity repository permissions are not exact") + lifecycle_identity = config.get("lifecycle_identity") if not isinstance(lifecycle_identity, Mapping): raise PolicyError("lifecycle_identity must be an object") @@ -338,7 +355,7 @@ def validate_config(config: Mapping[str, Any]) -> None: 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" + "repositories must contain exactly the nine reviewed OpenAdapt core repositories" ) for repo in repositories: @@ -584,25 +601,52 @@ def _resolve_release_actor( } ) return None - if installation.get("repository_selection") != "all": - repository_response = client.get( - f"/user/installations/{installation['id']}/repositories?per_page=100" + 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": "release_identity_permissions_mismatch", + "message": ( + f"GitHub App {slug!r} does not have the exact reviewed permissions." + ), + } ) - 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 None + if installation.get("repository_selection") != "selected": + blockers.append( + { + "code": "release_identity_repository_scope", + "message": ( + f"GitHub App {slug!r} must select only the nine public core " + "repositories." + ), + } + ) + 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", []) + } + if installed_names != EXPECTED_REPOSITORIES: + blockers.append( + { + "code": "release_identity_repository_scope", + "message": ( + f"GitHub App {slug!r} repository scope must be exactly: " + f"{', '.join(sorted(EXPECTED_REPOSITORIES))}." + ), + } + ) + return None return ReleaseActor(actor_id=actor_id, app_slug=slug) @@ -1405,9 +1449,14 @@ def _environment_actions( } ) current_policies: list[Mapping[str, Any]] = [] - if isinstance(current, Mapping) and current.get( - "deployment_branch_policy", {} - ).get("custom_branch_policies"): + current_deployment_policy = ( + current.get("deployment_branch_policy") + if isinstance(current, Mapping) + else None + ) + if isinstance( + current_deployment_policy, Mapping + ) and current_deployment_policy.get("custom_branch_policies"): response = client.get( f"/repos/{owner}/{repo['name']}/environments/{encoded}/deployment-branch-policies?per_page=100" ) diff --git a/tests/test_manage_github_protection.py b/tests/test_manage_github_protection.py index 333a91c..be03811 100644 --- a/tests/test_manage_github_protection.py +++ b/tests/test_manage_github_protection.py @@ -43,6 +43,10 @@ def __init__( unguarded_dispatch_repo: str | None = None, unauthorized_environment_repo: str | None = None, cancelling_dispatch_repo: str | None = None, + release_app_all_repositories: bool = False, + release_app_permissions_mismatch: bool = False, + release_app_extra_repository: bool = False, + null_deployment_policy_repo: str | None = None, ) -> None: self.config = config self.active_repo = active_repo @@ -53,6 +57,10 @@ def __init__( self.unguarded_dispatch_repo = unguarded_dispatch_repo self.unauthorized_environment_repo = unauthorized_environment_repo self.cancelling_dispatch_repo = cancelling_dispatch_repo + self.release_app_all_repositories = release_app_all_repositories + self.release_app_permissions_mismatch = release_app_permissions_mismatch + self.release_app_extra_repository = release_app_extra_repository + self.null_deployment_policy_repo = null_deployment_policy_repo self.writes: list[tuple[str, str, Mapping[str, Any]]] = [] self.by_name = {repo["name"]: repo for repo in config["repositories"]} @@ -80,7 +88,18 @@ def get(self, path: str, *, optional: bool = False) -> Any: "id": 551100, "app_id": 991122, "app_slug": "openadapt-release", - "repository_selection": "all", + "repository_selection": ( + "all" if self.release_app_all_repositories else "selected" + ), + "permissions": ( + {"contents": "write", "metadata": "read"} + if self.release_app_permissions_mismatch + else { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + } + ), }, { "id": 661100, @@ -106,6 +125,16 @@ def get(self, path: str, *, optional: bool = False) -> Any: }, ] } + if path == "/user/installations/551100/repositories?per_page=100": + return { + "repositories": [ + {"name": name} + for name in sorted( + set(self.by_name) + | ({"unexpected-public-repo"} if self.release_app_extra_repository else set()) + ) + ] + } if path == "/user/installations/661100/repositories?per_page=100": return { "repositories": [ @@ -153,6 +182,11 @@ def get(self, path: str, *, optional: bool = False) -> Any: if parts[4] == "rulesets": return [] if parts[4] == "environments": + if name == self.null_deployment_policy_repo: + return { + "protection_rules": [], + "deployment_branch_policy": None, + } return None if parts[4:6] == ["actions", "variables"]: variable = parts[6] @@ -441,6 +475,42 @@ def _workflow_content(self, repo_name: str, path: str) -> str: environment = repo["lifecycle_environments"][0]["name"] return f"on:\n push:\njobs:\n run:\n environment: {environment}\n" + if repo_name == "openadapt-agent": + return ( + "on:\n" + " workflow_dispatch:\n" + " push:\n" + " tags: [\"v*\"]\n" + "permissions:\n" + " contents: read\n" + "jobs:\n" + " tag:\n" + " environment: release-identity\n" + " steps:\n" + " - uses: actions/create-github-app-token@deadbeef\n" + " with:\n" + " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" + " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " permission-contents: write\n" + " - run: git tag v1.2.3 && git push origin v1.2.3\n" + " pypi:\n" + " environment: pypi\n" + " permissions:\n" + " id-token: write\n" + " steps:\n" + " - run: python scripts/check_release_artifacts.py dist\n" + " - run: python scripts/check_source_boundary.py --require-dist\n" + " - uses: pypa/gh-action-pypi-publish@deadbeef\n" + " mcp:\n" + " environment: mcp-registry\n" + " permissions:\n" + " id-token: write\n" + " steps:\n" + " - run: ./mcp-publisher login github-oidc\n" + " - run: python scripts/verify_release_registries.py\n" + " - run: test -f production-admission-candidate.json\n" + ) + workflow = ( "on:\n" " pull_request:\n" @@ -485,6 +555,7 @@ def test_policy_has_only_the_reviewed_owned_repositories() -> None: assert {repo["name"] for repo in value["repositories"]} == { ".github", "OpenAdapt", + "openadapt-agent", "openadapt-capture", "openadapt-desktop", "openadapt-evals", @@ -732,6 +803,53 @@ def test_plan_is_read_only_and_never_manages_private_cloud( assert all(repo["actions"] for repo in plan["repositories"]) +@pytest.mark.parametrize( + ("fixture_kwargs", "expected_code"), + [ + ({"release_app_all_repositories": True}, "release_identity_repository_scope"), + ({"release_app_extra_repository": True}, "release_identity_repository_scope"), + ( + {"release_app_permissions_mismatch": True}, + "release_identity_permissions_mismatch", + ), + ], +) +def test_release_identity_requires_exact_public_scope_and_permissions( + monkeypatch: pytest.MonkeyPatch, + fixture_kwargs: dict[str, bool], + expected_code: str, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + plan = build_plan(ReadOnlyFixtureGitHub(value, **fixture_kwargs), value) + + assert plan["safe_to_apply"] is False + assert expected_code in {item["code"] for item in plan["global_blockers"]} + + +def test_unprotected_existing_environment_produces_a_plan_instead_of_crashing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + plan = build_plan( + ReadOnlyFixtureGitHub( + value, + null_deployment_policy_repo="openadapt-agent", + ), + value, + ) + + agent = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-agent" + ) + assert plan["safe_to_apply"] is True + assert any( + action["kind"] == "put_environment" and action["environment"] == "pypi" + for action in agent["actions"] + ) + + def test_missing_lifecycle_app_keeps_plan_fail_closed( monkeypatch: pytest.MonkeyPatch, ) -> None: From 003ebfe0c088c87ef7fcf8d503b2a26ab1fc0b81 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 26 Aug 2026 13:30:50 -0400 Subject: [PATCH 4/6] docs: refresh protection audit snapshot --- ops/github/core-protection-policy.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ops/github/core-protection-policy.json b/ops/github/core-protection-policy.json index efce97e..329f81b 100644 --- a/ops/github/core-protection-policy.json +++ b/ops/github/core-protection-policy.json @@ -194,7 +194,7 @@ "name": "OpenAdapt", "visibility": "public", "default_branch": "main", - "audited_main_sha": "e68fb332c0da9da0125e45e0aa9babfdf942595b", + "audited_main_sha": "2aa387f005022e6e8ba451bed98c4d06de5d5114", "require_code_owner_review": true, "required_checks": [ "Analyze (python)", @@ -341,7 +341,7 @@ "name": "openadapt-flow", "visibility": "public", "default_branch": "main", - "audited_main_sha": "e17e2aa9ab367c11b2a64b2022f1cf45e62a729d", + "audited_main_sha": "defb0e19167ffc8331c20035cafe71291691f83a", "require_code_owner_review": true, "required_checks": [ "docs-consistency", @@ -679,7 +679,7 @@ "local_repository_name": "openadapt-maintenance", "visibility": "public", "default_branch": "main", - "audited_main_sha": "91edc06ae38e879755d7faac786e60a0e2aacd4e", + "audited_main_sha": "0fb2db9ea46ce69fa3dae415dc1c3f7b8f9f117a", "require_code_owner_review": true, "required_checks": [ "Analyze (python)", @@ -907,7 +907,7 @@ "name": ".github", "visibility": "public", "default_branch": "main", - "audited_main_sha": "efa5c2299273d94f56b428e7c60a3d96fe382c69", + "audited_main_sha": "4d2a82657d837a3ab87f08e4702456233636e5ff", "require_code_owner_review": false, "required_checks": [ "validate-profile" From cdf21c56f91d71562de7a7ce9b9ea383eb28e6aa Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 26 Aug 2026 15:49:18 -0400 Subject: [PATCH 5/6] fix: fail closed on admission prerequisites --- ops/github/README.md | 100 ++- ops/github/core-protection-policy.json | 345 +++++++- scripts/manage_github_protection.py | 852 ++++++++++++++++--- tests/test_manage_github_protection.py | 1046 +++++++++++++++++++++++- 4 files changed, 2148 insertions(+), 195 deletions(-) diff --git a/ops/github/README.md b/ops/github/README.md index 5e750d8..09211f1 100644 --- a/ops/github/README.md +++ b/ops/github/README.md @@ -12,14 +12,18 @@ The policy has these results: 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. -- The release App selects only the nine public core repositories. It has no +- Only the `openadapt-release` GitHub App can create a release tag and its + matching GitHub Release. +- The release App selects only the six public package repositories. It has + Contents write and Metadata read. It can't open pull requests and has no access to private Cloud. - 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. +- Administrators can't bypass any managed environment. GitHub exposes this + control in the environment settings page, but not in the REST update API. - 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. @@ -153,11 +157,20 @@ 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. +workflows can accept the lifecycle App actor. Only the Docs sync job can accept +the Docs App actor. Each other manual path needs a `reject-lifecycle-app` +predecessor with no permission. Despite its historical name, that predecessor +rejects both `openadapt-lifecycle[bot]` and `openadapt-docs[bot]`. It checks +`github.actor` and `github.triggering_actor` for each App. Each later job +depends on that predecessor and repeats all four identity refusals before +GitHub allocates a job. A new or unguarded path blocks apply. + +The workflow audit parses YAML. It recognizes mapping, scalar, and flow-list +trigger forms, including `workflow_dispatch: {}` and `repository_dispatch`. +Malformed YAML blocks the plan. For an authorized lifecycle or Docs path, the +audit checks the actor, protected environment, App inputs, token output, and +sensitive effect in the same job. A matching string elsewhere in the file +isn't enough. 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. @@ -166,28 +179,68 @@ artifacts. ## Release sequence +The plan also checks the runtime configuration for each App. Every package +repository needs an `OPENADAPT_RELEASE_APP_ID` variable with the reviewed App +ID. A private key must not exist as a repository secret or a repository +variable. It can exist only as an environment secret in an exact binding. For +the Release App, Launcher uses `release-identity` and `pypi`; Agent uses +`release-identity`; Capture, Evals, and Flow use `release-identity` and `pypi`; +Desktop uses `release-identity`, `pypi`, and `native-release`. For the +Lifecycle App, Profile uses its three lifecycle environments; Evals uses +`production-lifecycle-evidence`; Ops uses `production-lifecycle-projection`. +For the Docs App, Ops uses `production-docs-deploy`. + +Evals does not hold the Docs App key. The target Evals dispatcher does not use +that identity. Ops owns the protected Docs sync job. + +The plan reads secret and variable metadata from every repository environment. +It never requests a secret value. A missing key, an extra environment copy, or +a variable that shadows a private-key name blocks apply. + +Every managed environment sets `can_admins_bypass` to `false`. If an +environment is absent, the field is missing, or GitHub reports `true`, the plan +does not offer a REST repair. Open the repository's **Settings > Environments** +page, create the named environment if needed, and clear **Allow administrators +to bypass configured protection rules**. Run a new plan after that one-time UI +change. + 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. +1. A maintainer merges the reviewed version, changelog, lock, and candidate + files through the normal `main` rules. +2. A manual run from that exact current `main` commit enters + `release-identity`. +3. The workflow gets a short-lived `openadapt-release` App token. +4. The App creates one annotated release tag. It can't push a branch or open a + pull request. +5. The tag run checks the original App actor and the exact protected-main + commit before publication. +6. The publication job enters `pypi`, `mcp-registry`, or `native-release` and + uses OIDC to publish the checked bytes. + +Any job that creates, edits, or uploads a GitHub Release must create the +Release App token in that job. Its `GH_TOKEN` must reference that exact step's +output. The same job rule prevents a workflow from passing because an unrelated +job contains the right App strings. Launcher remains a plan refusal until its +release workflow follows this contract on `main`. Agent uses `mcp-registry` after its PyPI publication. Both publication jobs use OIDC. The Agent release workflow can't accept an API-token fallback or download an unpinned registry publisher. +A person with repository write access can rerun the exact tag workflow for +recovery. [GitHub keeps the original actor, ref, and commit on a +rerun](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs). +The workflow refuses a different tag or artifact identity. + 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. +Use the release App token only for the annotated tag and matching GitHub +Release. Never use it to push a branch or open a pull request. -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. +A package release workflow that still refers to `ADMIN_TOKEN`, or that skips a +required protected environment, remains a plan refusal. Migrate every such +workflow before an apply operation. ## Commands @@ -214,11 +267,14 @@ uv run python scripts/manage_github_protection.py plan \ 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 all App identities, exact installation -permissions and scopes, repository identity variables, and the environment -reviewer ID against GitHub. +permissions and scopes, repository identity variables, environment secret and +variable names, administrator-bypass state, 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. +complete. If an audited `main` SHA changed, review the exact current workflows +and update the policy SHA before you create another plan. Drift is a refusal, +not a warning. A plan expires after 15 minutes. Apply that exact plan: diff --git a/ops/github/core-protection-policy.json b/ops/github/core-protection-policy.json index 329f81b..ff0c728 100644 --- a/ops/github/core-protection-policy.json +++ b/ops/github/core-protection-policy.json @@ -86,10 +86,47 @@ "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." + "repository_scope": [ + "OpenAdapt", + "openadapt-agent", + "openadapt-capture", + "openadapt-desktop", + "openadapt-evals", + "openadapt-flow" + ], + "repository_variables": { + "app_id": "OPENADAPT_RELEASE_APP_ID" + }, + "private_key_secret": "OPENADAPT_RELEASE_APP_PRIVATE_KEY", + "private_key_environment_bindings": { + "OpenAdapt": [ + "release-identity", + "pypi" + ], + "openadapt-agent": [ + "release-identity" + ], + "openadapt-capture": [ + "release-identity", + "pypi" + ], + "openadapt-desktop": [ + "release-identity", + "pypi", + "native-release" + ], + "openadapt-evals": [ + "release-identity", + "pypi" + ], + "openadapt-flow": [ + "release-identity", + "pypi" + ] + }, + "purpose": "Create package release tags and matching GitHub Releases. Registered workflows must not use it to push a branch, and it has no main-branch bypass." }, "lifecycle_identity": { "app_slug": "openadapt-lifecycle", @@ -132,6 +169,20 @@ "actor_id": "OPENADAPT_LIFECYCLE_ACTOR_ID", "installation_id": "OPENADAPT_LIFECYCLE_INSTALLATION_ID" }, + "private_key_secret": "OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY", + "private_key_environment_bindings": { + ".github": [ + "production-lifecycle-activation", + "qualification-authority-state", + "qualification-revocation-state" + ], + "openadapt-evals": [ + "production-lifecycle-evidence" + ], + "openadapt-ops": [ + "production-lifecycle-projection" + ] + }, "actions_write_risk": { "capabilities": [ "Dispatch repository workflows", @@ -178,6 +229,12 @@ "actor_id": "OPENADAPT_DOCS_ACTOR_ID", "installation_id": "OPENADAPT_DOCS_INSTALLATION_ID" }, + "private_key_secret": "OPENADAPT_DOCS_APP_PRIVATE_KEY", + "private_key_environment_bindings": { + "openadapt-ops": [ + "production-docs-deploy" + ] + }, "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": { @@ -187,14 +244,15 @@ }, "environment_defaults": { "wait_timer": 0, - "prevent_self_review": false + "prevent_self_review": false, + "can_admins_bypass": false }, "repositories": [ { "name": "OpenAdapt", "visibility": "public", "default_branch": "main", - "audited_main_sha": "2aa387f005022e6e8ba451bed98c4d06de5d5114", + "audited_main_sha": "3098d46064c8d3e3fa1a515588487cdb724500e8", "require_code_owner_review": true, "required_checks": [ "Analyze (python)", @@ -217,6 +275,7 @@ "release_environments": [ { "name": "release-identity", + "can_admins_bypass": false, "deployment_policies": [ { "type": "branch", @@ -226,6 +285,7 @@ }, { "name": "pypi", + "can_admins_bypass": false, "deployment_policies": [ { "type": "tag", @@ -238,12 +298,35 @@ { "path": ".github/workflows/release-and-publish.yml", "required_patterns": [ + "(?m)^[ ]{2}workflow_dispatch:\\s*$", + "(?m)^[ ]{2}push:\\s*$", + "(?m)^[ ]{4}tags:\\s*$", + "[\"']v\\*[\"']", "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?release-identity\\s*$", + "actions/create-github-app-token", + "vars\\.OPENADAPT_RELEASE_APP_ID", + "secrets\\.OPENADAPT_RELEASE_APP_PRIVATE_KEY", + "permission-contents:\\s*write", + "refs/heads/main", + "OpenAdaptAI/OpenAdapt", + "git\\s+tag\\s+-a", + "git\\s+push\\s+origin\\s+[\"']refs/tags/", + "GITHUB_ACTOR", + "openadapt-release\\[bot\\]", "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?pypi\\s*$", - "(?m)^\\s*id-token:\\s*write\\s*$" + "(?m)^\\s*id-token:\\s*write\\s*$", + "pypa/gh-action-pypi-publish", + "scripts/check_source_boundary\\.py", + "scripts/verify_release_artifacts\\.py", + "scripts/validate_platform_manifest\\.py", + "GH_TOKEN:\\s*\\$\\{\\{\\s*steps\\.release_app\\.outputs\\.token\\s*\\}\\}", + "gh\\s+release\\s+(?:create|edit|upload)" ], "forbidden_patterns": [ - "ADMIN_TOKEN" + "ADMIN_TOKEN", + "PYPI_TOKEN", + "permission-pull-requests:\\s*write", + "git\\s+push[^\\n]*refs/heads/" ] } ] @@ -252,7 +335,7 @@ "name": "openadapt-agent", "visibility": "public", "default_branch": "main", - "audited_main_sha": "be253dd558a95bfb457c18715d258854aba79f69", + "audited_main_sha": "c27b29a5f3c3fe3a14a91566405d01648ef1eb36", "require_code_owner_review": true, "required_checks": [ "Analyze (python)", @@ -274,6 +357,7 @@ "release_environments": [ { "name": "release-identity", + "can_admins_bypass": false, "deployment_policies": [ { "type": "branch", @@ -284,6 +368,7 @@ }, { "name": "pypi", + "can_admins_bypass": false, "deployment_policies": [ { "type": "tag", @@ -294,6 +379,7 @@ }, { "name": "mcp-registry", + "can_admins_bypass": false, "deployment_policies": [ { "type": "tag", @@ -325,14 +411,18 @@ "scripts/check_release_artifacts\\.py", "scripts/check_source_boundary\\.py\\s+--require-dist", "scripts/verify_release_registries\\.py", - "production-admission-candidate\\.json" + "production-admission-candidate\\.json", + "GITHUB_ACTOR", + "openadapt-release\\[bot\\]" ], "forbidden_patterns": [ "ADMIN_TOKEN", "(?m)^[ ]{2}release:\\s*$", "PYPI_API_TOKEN", "MCP_GITHUB_TOKEN", - "registry/releases/latest/download" + "registry/releases/latest/download", + "permission-pull-requests:\\s*write", + "git\\s+push[^\\n]*refs/heads/" ] } ] @@ -341,7 +431,7 @@ "name": "openadapt-flow", "visibility": "public", "default_branch": "main", - "audited_main_sha": "defb0e19167ffc8331c20035cafe71291691f83a", + "audited_main_sha": "fa49a5de54a749ebf00621b0e4d38a2b439bb058", "require_code_owner_review": true, "required_checks": [ "docs-consistency", @@ -368,6 +458,7 @@ "release_environments": [ { "name": "release-identity", + "can_admins_bypass": false, "deployment_policies": [ { "type": "branch", @@ -377,6 +468,7 @@ }, { "name": "pypi", + "can_admins_bypass": false, "deployment_policies": [ { "type": "tag", @@ -389,12 +481,34 @@ { "path": ".github/workflows/release.yml", "required_patterns": [ + "(?m)^[ ]{2}workflow_dispatch:\\s*$", + "(?m)^[ ]{2}push:\\s*$", + "tags:\\s*\\[?[\"']?v\\*[\"']?\\]?", "(?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*$" + "group:\\s*engine-release-\\$\\{\\{\\s*github\\.ref\\s*\\}\\}", + "cancel-in-progress:\\s*false", + "Refuse an invalid engine release dispatch", + "(?m)^\\s*permissions:\\s*\\{\\}\\s*$", + "actions/create-github-app-token", + "vars\\.OPENADAPT_RELEASE_APP_ID", + "secrets\\.OPENADAPT_RELEASE_APP_PRIVATE_KEY", + "permission-contents:\\s*write", + "refs/heads/main", + "git\\s+tag\\s+--annotate", + "git\\s+push\\s+origin\\s+[\"']refs/tags/", + "GITHUB_ACTOR", + "openadapt-release\\[bot\\]", + "(?m)^\\s*id-token:\\s*write\\s*$", + "pypa/gh-action-pypi-publish", + "scripts/check_release_consistency\\.py\\s+--require-dist", + "scripts/verify_release_publication\\.py" ], "forbidden_patterns": [ - "ADMIN_TOKEN" + "ADMIN_TOKEN", + "PYPI_TOKEN", + "permission-pull-requests:\\s*write", + "git\\s+push[^\\n]*refs/heads/" ] } ] @@ -403,7 +517,7 @@ "name": "openadapt-capture", "visibility": "public", "default_branch": "main", - "audited_main_sha": "d1f66151b72fe95967b6f8d2696ab93cf5b49721", + "audited_main_sha": "681683e73be13ce75ee3e8c720cd8ce7fbd7c76a", "require_code_owner_review": true, "required_checks": [ "Analyze (javascript-typescript)", @@ -426,6 +540,7 @@ "release_environments": [ { "name": "release-identity", + "can_admins_bypass": false, "deployment_policies": [ { "type": "branch", @@ -435,6 +550,7 @@ }, { "name": "pypi", + "can_admins_bypass": false, "deployment_policies": [ { "type": "tag", @@ -447,12 +563,31 @@ { "path": ".github/workflows/release.yml", "required_patterns": [ + "(?m)^[ ]{2}workflow_dispatch:\\s*$", + "(?m)^[ ]{2}push:\\s*$", + "tags:\\s*\\[?[\"']?v\\*[\"']?\\]?", "(?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*$" + "actions/create-github-app-token", + "vars\\.OPENADAPT_RELEASE_APP_ID", + "secrets\\.OPENADAPT_RELEASE_APP_PRIVATE_KEY", + "permission-contents:\\s*write", + "refs/heads/main", + "git\\s+tag\\s+--annotate", + "git\\s+push\\s+origin\\s+[\"']refs/tags/", + "GITHUB_ACTOR", + "openadapt-release\\[bot\\]", + "(?m)^\\s*id-token:\\s*write\\s*$", + "pypa/gh-action-pypi-publish", + "scripts/verify_distribution\\.py", + "scripts/check_source_boundary\\.py\\s+--require-dist", + "scripts/verify_release_publication\\.py" ], "forbidden_patterns": [ - "ADMIN_TOKEN" + "ADMIN_TOKEN", + "PYPI_TOKEN", + "permission-pull-requests:\\s*write", + "git\\s+push[^\\n]*refs/heads/" ] } ] @@ -461,7 +596,7 @@ "name": "openadapt-desktop", "visibility": "public", "default_branch": "main", - "audited_main_sha": "f0f5d140698ff91ccabd391a835463afe9370e06", + "audited_main_sha": "a73c3faf90f5d0b766ea7156a057bd6240d1c2bb", "require_code_owner_review": false, "required_checks": [ "Analyze (javascript-typescript)", @@ -491,19 +626,17 @@ "release_environments": [ { "name": "release-identity", + "can_admins_bypass": false, "deployment_policies": [ { "type": "branch", "name": "main" - }, - { - "type": "tag", - "name": "v*" } ] }, { "name": "pypi", + "can_admins_bypass": false, "deployment_policies": [ { "type": "tag", @@ -513,6 +646,7 @@ }, { "name": "native-release", + "can_admins_bypass": false, "deployment_policies": [ { "type": "tag", @@ -529,21 +663,54 @@ { "path": ".github/workflows/release.yml", "required_patterns": [ + "(?m)^[ ]{2}workflow_dispatch:\\s*$", + "(?m)^[ ]{2}push:\\s*$", + "(?m)^[ ]{4}tags:\\s*$", + "[\"']v\\*[\"']", "(?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*$" + "actions/create-github-app-token", + "vars\\.OPENADAPT_RELEASE_APP_ID", + "secrets\\.OPENADAPT_RELEASE_APP_PRIVATE_KEY", + "permission-contents:\\s*write", + "OpenAdaptAI/openadapt-desktop", + "refs/heads/main", + "git\\s+tag\\s+-a", + "git\\s+push\\s+origin\\s+[\"']refs/tags/", + "github\\.actor\\s*==\\s*['\"]openadapt-release\\[bot\\]['\"]", + "GITHUB_ACTOR", + "(?m)^\\s*id-token:\\s*write\\s*$", + "pypa/gh-action-pypi-publish", + "skip-existing:\\s*true", + "scripts/verify_release_lock\\.py", + "scripts/check_source_boundary\\.py\\s+--require-dist", + "gh\\s+release\\s+create" ], "forbidden_patterns": [ - "ADMIN_TOKEN" + "ADMIN_TOKEN", + "PYPI_TOKEN", + "permission-pull-requests:\\s*write", + "python-semantic-release", + "git\\s+push[^\\n]*refs/heads/" ] }, { "path": ".github/workflows/native-freshness.yml", "required_patterns": [ - "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?release-identity\\s*$" + "(?m)^[ ]{2}workflow_dispatch:\\s*$", + "(?m)^[ ]{2}release:\\s*$", + "group:\\s*native-freshness-check", + "cancel-in-progress:\\s*false", + "(?m)^permissions:\\s*\\n[ ]{2}contents:\\s*read\\s*$", + "refs/heads/main" ], "forbidden_patterns": [ - "ADMIN_TOKEN" + "ADMIN_TOKEN", + "OPENADAPT_RELEASE_APP_PRIVATE_KEY", + "(?m)^\\s*contents:\\s*write\\s*$", + "git\\s+push", + "gh\\s+pr\\s+create", + "environment:\\s*release-identity" ] }, { @@ -551,10 +718,34 @@ "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*$" + "(?m)^\\s*id-token:\\s*write\\s*$", + "(?m)^[ ]{2}workflow_dispatch:\\s*$", + "desktop-v\\*", + "group:\\s*native-release", + "cancel-in-progress:\\s*false", + "Refuse an invalid native release dispatch", + "(?m)^\\s*permissions:\\s*\\{\\}\\s*$", + "actions/create-github-app-token", + "vars\\.OPENADAPT_RELEASE_APP_ID", + "secrets\\.OPENADAPT_RELEASE_APP_PRIVATE_KEY", + "permission-contents:\\s*write", + "git\\s+tag\\s+--annotate", + "git\\s+push\\s+origin\\s+[\"']refs/tags/", + "GITHUB_ACTOR", + "openadapt-release\\[bot\\]", + "recover-published-native", + "state=absent", + "state=partial", + "state=complete", + "GH_TOKEN:\\s*\\$\\{\\{\\s*steps\\.release_app\\.outputs\\.token\\s*\\}\\}", + "gh\\s+release\\s+create", + "actions/attest" ], "forbidden_patterns": [ - "ADMIN_TOKEN" + "ADMIN_TOKEN", + "PYPI_TOKEN", + "permission-pull-requests:\\s*write", + "git\\s+push[^\\n]*refs/heads/" ] }, { @@ -562,10 +753,31 @@ "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*$" + "(?m)^\\s*id-token:\\s*write\\s*$", + "(?m)^[ ]{2}workflow_dispatch:\\s*$", + "ffmpeg-runtime-v\\*", + "group:\\s*ffmpeg-runtime-\\$\\{\\{\\s*github\\.ref\\s*\\}\\}", + "cancel-in-progress:\\s*false", + "Refuse an invalid managed-runtime dispatch", + "(?m)^\\s*permissions:\\s*\\{\\}\\s*$", + "actions/create-github-app-token", + "vars\\.OPENADAPT_RELEASE_APP_ID", + "secrets\\.OPENADAPT_RELEASE_APP_PRIVATE_KEY", + "permission-contents:\\s*write", + "git\\s+tag\\s+--annotate", + "git\\s+push\\s+origin\\s+[\"']refs/tags/", + "GITHUB_ACTOR", + "openadapt-release\\[bot\\]", + "SOURCE_SIGNATURE_SHA256", + "SIGNING_KEY_SHA256", + "GH_TOKEN:\\s*\\$\\{\\{\\s*steps\\.release_app\\.outputs\\.token\\s*\\}\\}", + "gh\\s+release\\s+create" ], "forbidden_patterns": [ - "ADMIN_TOKEN" + "ADMIN_TOKEN", + "PYPI_TOKEN", + "permission-pull-requests:\\s*write", + "git\\s+push[^\\n]*refs/heads/" ] } ] @@ -574,7 +786,7 @@ "name": "openadapt-evals", "visibility": "public", "default_branch": "main", - "audited_main_sha": "aca85d431158ac517c969c5a146b38f1895c9ada", + "audited_main_sha": "55d16ed04561b0b78c63176cac90b73dbc0cdfad", "require_code_owner_review": false, "required_checks": [ "test" @@ -589,6 +801,7 @@ "release_environments": [ { "name": "release-identity", + "can_admins_bypass": false, "deployment_policies": [ { "type": "branch", @@ -598,6 +811,7 @@ }, { "name": "pypi", + "can_admins_bypass": false, "deployment_policies": [ { "type": "tag", @@ -609,6 +823,7 @@ "lifecycle_environments": [ { "name": "production-lifecycle-evidence", + "can_admins_bypass": false, "wait_timer": 0, "prevent_self_review": true, "deployment_policies": [ @@ -624,12 +839,33 @@ { "path": ".github/workflows/release.yml", "required_patterns": [ + "(?m)^[ ]{2}workflow_dispatch:\\s*$", + "(?m)^[ ]{2}push:\\s*$", + "(?m)^[ ]{4}tags:\\s*$", + "[\"']v\\*[\"']", "(?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*$" + "actions/create-github-app-token", + "vars\\.OPENADAPT_RELEASE_APP_ID", + "secrets\\.OPENADAPT_RELEASE_APP_PRIVATE_KEY", + "permission-contents:\\s*write", + "OpenAdaptAI/openadapt-evals", + "refs/heads/main", + "git\\s+tag\\s+-a", + "git\\s+push\\s+origin\\s+[\"']refs/tags/", + "github\\.actor\\s*==\\s*['\"]openadapt-release\\[bot\\]['\"]", + "(?m)^\\s*id-token:\\s*write\\s*$", + "pypa/gh-action-pypi-publish", + "skip-existing:\\s*true", + "scripts/verify_release_lock\\.py", + "scripts/check_source_boundary\\.py\\s+--require-dist" ], "forbidden_patterns": [ - "ADMIN_TOKEN" + "ADMIN_TOKEN", + "PYPI_TOKEN", + "permission-pull-requests:\\s*write", + "python-semantic-release", + "git\\s+push[^\\n]*refs/heads/" ] } ], @@ -660,6 +896,10 @@ } ], "dispatch_workflow_inventory": [ + { + "path": ".github/workflows/release.yml", + "mode": "reject-lifecycle-app" + }, { "path": ".github/workflows/production-lifecycle-evidence.yml", "mode": "lifecycle-only" @@ -679,7 +919,7 @@ "local_repository_name": "openadapt-maintenance", "visibility": "public", "default_branch": "main", - "audited_main_sha": "0fb2db9ea46ce69fa3dae415dc1c3f7b8f9f117a", + "audited_main_sha": "789345539c5a76a9fc1de21b79bb7189c266f870", "require_code_owner_review": true, "required_checks": [ "Analyze (python)", @@ -703,6 +943,7 @@ "release_environments": [ { "name": "github-pages", + "can_admins_bypass": false, "deployment_policies": [ { "type": "branch", @@ -713,6 +954,7 @@ }, { "name": "production-docs-deploy", + "can_admins_bypass": false, "wait_timer": 0, "prevent_self_review": true, "deployment_policies": [ @@ -727,6 +969,7 @@ "lifecycle_environments": [ { "name": "production-lifecycle-projection", + "can_admins_bypass": false, "wait_timer": 0, "prevent_self_review": true, "deployment_policies": [ @@ -756,27 +999,29 @@ "vars\\.OPENADAPT_DOCS_APP_ID", "vars\\.OPENADAPT_DOCS_INSTALLATION_ID", "secrets\\.OPENADAPT_DOCS_APP_PRIVATE_KEY", + "actions/create-github-app-token", + "permission-actions:\\s*write", + "permission-metadata:\\s*read", + "permission-pull-requests:\\s*write", + "steps\\.docs-app\\.outputs\\.token", "inputs\\.source_repository", - "OpenAdaptAI/openadapt-evals", + "scripts/validate_docs_sync\\.py", "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\\.source_event", "inputs\\.idempotency_key", - "OpenAdapt docs sync dispatch v1", - "docs-sync:", - "\\[0-9a-f\\]\\{64\\}", - "sha256", "(?m)^\\s*contents:\\s*write\\s*$", "github\\.token", + "git\\s+push\\s+origin\\s+[\"]HEAD:refs/heads/", "gh\\s+pr\\s+create" ], "forbidden_patterns": [ "(?m)^[ ]{2}repository_dispatch:\\s*$", "repo-updated", + "DOCS_(?:PAT|DISPATCH_TOKEN)", "git\\s+push[^\\n]*(?:refs/heads/)?main", "permission-contents:\\s*write" ] @@ -806,13 +1051,10 @@ "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", + "scripts/prepare_production_lifecycle_projection\\.py", "github\\.repository\\s*==\\s*'OpenAdaptAI/openadapt-ops'", "github\\.ref\\s*==\\s*'refs/heads/main'", "github\\.event_name\\s*==\\s*'workflow_dispatch'", @@ -822,8 +1064,14 @@ "vars\\.OPENADAPT_LIFECYCLE_APP_ID", "vars\\.OPENADAPT_LIFECYCLE_INSTALLATION_ID", "secrets\\.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY", + "actions/create-github-app-token", + "permission-actions:\\s*write", + "permission-metadata:\\s*read", + "permission-pull-requests:\\s*write", + "steps\\.lifecycle-app\\.outputs\\.token", "(?m)^\\s*contents:\\s*write\\s*$", "github\\.token", + "git\\s+push\\s+origin\\s+[\"]HEAD:refs/heads/", "gh\\s+pr\\s+create" ], "forbidden_patterns": [ @@ -869,6 +1117,10 @@ { "path": ".github/workflows/sync.yml", "mode": "docs-only" + }, + { + "path": ".github/workflows/workspace-staleness-sweep.yml", + "mode": "reject-lifecycle-app" } ] }, @@ -876,7 +1128,7 @@ "name": "openadapt-web", "visibility": "public", "default_branch": "main", - "audited_main_sha": "0a23bd9af5ade15928e79cc4d8e0278e983d375c", + "audited_main_sha": "0f2c1dff2441d4bac5eb12a9e48b5d05d651f64b", "require_code_owner_review": true, "required_checks": [ "Analyze (javascript-typescript)", @@ -907,7 +1159,7 @@ "name": ".github", "visibility": "public", "default_branch": "main", - "audited_main_sha": "4d2a82657d837a3ab87f08e4702456233636e5ff", + "audited_main_sha": "7e1a3c742455598c236318ce5a70ed27a36f1816", "require_code_owner_review": false, "required_checks": [ "validate-profile" @@ -935,6 +1187,7 @@ "lifecycle_environments": [ { "name": "production-lifecycle-activation", + "can_admins_bypass": false, "wait_timer": 0, "prevent_self_review": true, "deployment_policies": [ @@ -947,6 +1200,7 @@ }, { "name": "qualification-authority-state", + "can_admins_bypass": false, "wait_timer": 0, "prevent_self_review": true, "deployment_policies": [ @@ -959,6 +1213,7 @@ }, { "name": "qualification-revocation-state", + "can_admins_bypass": false, "wait_timer": 0, "prevent_self_review": true, "deployment_policies": [ diff --git a/scripts/manage_github_protection.py b/scripts/manage_github_protection.py index 2730575..3e3c5df 100755 --- a/scripts/manage_github_protection.py +++ b/scripts/manage_github_protection.py @@ -24,6 +24,8 @@ from typing import Any, Protocol from urllib.parse import quote +import yaml + API_VERSION = "2026-03-10" PLAN_MAX_AGE_SECONDS = 900 EXPECTED_REPOSITORIES = { @@ -43,6 +45,26 @@ "OpenAdapt policy: release tag creation", "OpenAdapt policy: immutable release tags", ) +EXPECTED_PRIVATE_KEY_BINDINGS = { + "openadapt-release": { + "OpenAdapt": ["release-identity", "pypi"], + "openadapt-agent": ["release-identity"], + "openadapt-capture": ["release-identity", "pypi"], + "openadapt-desktop": ["release-identity", "pypi", "native-release"], + "openadapt-evals": ["release-identity", "pypi"], + "openadapt-flow": ["release-identity", "pypi"], + }, + "openadapt-lifecycle": { + ".github": [ + "production-lifecycle-activation", + "qualification-authority-state", + "qualification-revocation-state", + ], + "openadapt-evals": ["production-lifecycle-evidence"], + "openadapt-ops": ["production-lifecycle-projection"], + }, + "openadapt-docs": {"openadapt-ops": ["production-docs-deploy"]}, +} class PolicyError(RuntimeError): @@ -210,7 +232,11 @@ def validate_config(config: Mapping[str, Any]) -> None: 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}: + if environment_defaults != { + "wait_timer": 0, + "prevent_self_review": False, + "can_admins_bypass": False, + }: raise PolicyError("environment_defaults must define the reviewed release gate") release_identity = config.get("release_identity") @@ -224,10 +250,41 @@ def validate_config(config: Mapping[str, Any]) -> None: raise PolicyError("release_identity is not exact") if release_identity.get("required_repository_permissions") != [ "Contents: write", - "Pull requests: write", "Metadata: read", ]: raise PolicyError("release_identity repository permissions are not exact") + release_repository_scope = _require_list( + release_identity.get("repository_scope"), + "release_identity.repository_scope", + ) + if ( + not all(isinstance(item, str) for item in release_repository_scope) + or set(release_repository_scope) + != { + "OpenAdapt", + "openadapt-agent", + "openadapt-capture", + "openadapt-desktop", + "openadapt-evals", + "openadapt-flow", + } + or len(release_repository_scope) != 6 + ): + raise PolicyError("release_identity repository scope is not exact") + if release_identity.get("repository_variables") != { + "app_id": "OPENADAPT_RELEASE_APP_ID" + }: + raise PolicyError("release_identity repository variables are not exact") + if ( + release_identity.get("private_key_secret") + != "OPENADAPT_RELEASE_APP_PRIVATE_KEY" + ): + raise PolicyError("release_identity private-key secret is not exact") + if ( + release_identity.get("private_key_environment_bindings") + != (EXPECTED_PRIVATE_KEY_BINDINGS["openadapt-release"]) + ): + raise PolicyError("release_identity private-key bindings are not exact") lifecycle_identity = config.get("lifecycle_identity") if not isinstance(lifecycle_identity, Mapping): @@ -295,6 +352,16 @@ def validate_config(config: Mapping[str, Any]) -> None: "installation_id": "OPENADAPT_LIFECYCLE_INSTALLATION_ID", }: raise PolicyError("lifecycle_identity repository variables are not exact") + if ( + lifecycle_identity.get("private_key_secret") + != "OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY" + ): + raise PolicyError("lifecycle_identity private-key secret is not exact") + if ( + lifecycle_identity.get("private_key_environment_bindings") + != (EXPECTED_PRIVATE_KEY_BINDINGS["openadapt-lifecycle"]) + ): + raise PolicyError("lifecycle_identity private-key bindings 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") @@ -343,6 +410,13 @@ def validate_config(config: Mapping[str, Any]) -> None: "installation_id": "OPENADAPT_DOCS_INSTALLATION_ID", }: raise PolicyError("docs_identity repository variables are not exact") + if docs_identity.get("private_key_secret") != "OPENADAPT_DOCS_APP_PRIVATE_KEY": + raise PolicyError("docs_identity private-key secret is not exact") + if ( + docs_identity.get("private_key_environment_bindings") + != (EXPECTED_PRIVATE_KEY_BINDINGS["openadapt-docs"]) + ): + raise PolicyError("docs_identity private-key bindings 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): @@ -404,6 +478,10 @@ def validate_config(config: Mapping[str, Any]) -> None: for environment in environments: if not isinstance(environment.get("name"), str) or not environment["name"]: raise PolicyError(f"{name}: protected environment needs a name") + if environment.get("can_admins_bypass") is not False: + raise PolicyError( + f"{name}: protected environment must set can_admins_bypass false" + ) policies = _require_list( environment.get("deployment_policies"), f"{name}.{environment.get('name')}.deployment_policies", @@ -602,9 +680,7 @@ def _resolve_release_actor( ) return None expected_permissions = { - item.split(":", 1)[0].strip().lower().replace(" ", "_"): item.split( - ":", 1 - )[1] + item.split(":", 1)[0].strip().lower().replace(" ", "_"): item.split(":", 1)[1] .strip() .lower() for item in identity["required_repository_permissions"] @@ -624,7 +700,7 @@ def _resolve_release_actor( { "code": "release_identity_repository_scope", "message": ( - f"GitHub App {slug!r} must select only the nine public core " + f"GitHub App {slug!r} must select only the six public package " "repositories." ), } @@ -636,13 +712,14 @@ def _resolve_release_actor( installed_names = { item.get("name") for item in repository_response.get("repositories", []) } - if installed_names != EXPECTED_REPOSITORIES: + expected_names = set(identity["repository_scope"]) + if installed_names != expected_names: blockers.append( { "code": "release_identity_repository_scope", "message": ( f"GitHub App {slug!r} repository scope must be exactly: " - f"{', '.join(sorted(EXPECTED_REPOSITORIES))}." + f"{', '.join(sorted(expected_names))}." ), } ) @@ -919,18 +996,21 @@ def desired_rulesets( } result = [main, immutable] if actor is not None: + bypass_actors = [] + if repo["name"] in config["release_identity"]["repository_scope"]: + bypass_actors = [ + { + "actor_id": actor.actor_id, + "actor_type": "Integration", + "bypass_mode": config["release_identity"]["bypass_mode"], + } + ] 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"], - } - ], + "bypass_actors": bypass_actors, "conditions": { "ref_name": { "include": repo["release_tag_patterns"], @@ -1081,6 +1161,241 @@ def _workflow_text( raise GitHubError(f"cannot decode {owner}/{repo}/{path}") from exc +def _parse_workflow_document( + content: str, owner: str, repo: str, path: str +) -> Mapping[str, Any]: + try: + document = yaml.safe_load(content) + except yaml.YAMLError as exc: + raise PolicyError( + f"{owner}/{repo}:{path}: malformed workflow YAML: {exc}" + ) from exc + if not isinstance(document, Mapping): + raise PolicyError(f"{owner}/{repo}:{path}: workflow YAML must be an object") + jobs = document.get("jobs") + if jobs is not None and not isinstance(jobs, Mapping): + raise PolicyError(f"{owner}/{repo}:{path}: workflow jobs must be an object") + return document + + +def _workflow_triggers(document: Mapping[str, Any]) -> set[str]: + trigger = document.get("on", document.get(True)) + if isinstance(trigger, str): + return {trigger} + if isinstance(trigger, list): + if not all(isinstance(item, str) for item in trigger): + raise PolicyError("workflow trigger list must contain only names") + return set(trigger) + if isinstance(trigger, Mapping): + if not all(isinstance(item, str) for item in trigger): + raise PolicyError("workflow trigger map must contain only names") + return set(trigger) + raise PolicyError("workflow must declare a valid trigger") + + +def _all_strings(value: Any) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, Mapping): + result: list[str] = [] + for key, item in value.items(): + if isinstance(key, str): + result.append(key) + result.extend(_all_strings(item)) + return result + if isinstance(value, list): + result = [] + for item in value: + result.extend(_all_strings(item)) + return result + return [] + + +def _job_environment(job: Mapping[str, Any]) -> str | None: + environment = job.get("environment") + if isinstance(environment, str): + return environment + if isinstance(environment, Mapping) and isinstance(environment.get("name"), str): + return environment["name"] + return None + + +def _job_needs(job: Mapping[str, Any]) -> set[str]: + needs = job.get("needs", []) + if isinstance(needs, str): + return {needs} + if isinstance(needs, list) and all(isinstance(item, str) for item in needs): + return set(needs) + return set() + + +def _job_steps(job: Mapping[str, Any]) -> list[Mapping[str, Any]]: + steps = job.get("steps", []) + if not isinstance(steps, list): + return [] + return [step for step in steps if isinstance(step, Mapping)] + + +def _references_expression(value: Any, reference: str) -> bool: + return isinstance(value, str) and reference in re.sub(r"\s+", "", value) + + +def _matching_app_token_step_ids( + job: Mapping[str, Any], identity: Mapping[str, Any] +) -> set[str]: + required_permissions = { + permission.split(":", 1)[0].strip().lower().replace(" ", "-"): permission.split( + ":", 1 + )[1] + .strip() + .lower() + for permission in identity["required_repository_permissions"] + if permission.split(":", 1)[0].strip().lower() != "metadata" + } + app_id_name = identity["repository_variables"]["app_id"] + private_key_name = identity["private_key_secret"] + matching: set[str] = set() + for step in _job_steps(job): + uses = step.get("uses") + inputs = step.get("with") + step_id = step.get("id") + if ( + not isinstance(uses, str) + or not uses.startswith("actions/create-github-app-token@") + or not isinstance(inputs, Mapping) + or not isinstance(step_id, str) + or not step_id + ): + continue + if not _references_expression(inputs.get("app-id"), f"vars.{app_id_name}"): + continue + if not _references_expression( + inputs.get("private-key"), f"secrets.{private_key_name}" + ): + continue + if any( + inputs.get(f"permission-{name}") != level + for name, level in required_permissions.items() + ): + continue + matching.add(step_id) + return matching + + +def _token_references(step_ids: Iterable[str]) -> set[str]: + return {f"steps.{step_id}.outputs.token" for step_id in step_ids} + + +def _step_uses_token(step: Mapping[str, Any], references: set[str]) -> bool: + return any( + _references_expression(value, reference) + for value in _all_strings(step) + for reference in references + ) + + +def _release_workflow_semantic_blockers( + client: GitHubClient, + owner: str, + repo: Mapping[str, Any], + identity: Mapping[str, Any], +) -> list[dict[str, str]]: + if repo["name"] not in identity["repository_scope"]: + return [] + allowed_environments = set( + identity["private_key_environment_bindings"][repo["name"]] + ) + blockers: list[dict[str, str]] = [] + for workflow in repo.get("release_workflows", []): + path = workflow["path"] + content = _workflow_text(client, owner, repo["name"], path) + if content is None: + continue + try: + document = _parse_workflow_document(content, owner, repo["name"], path) + except PolicyError: + continue + jobs = document.get("jobs", {}) + for job_name, job in jobs.items(): + if not isinstance(job_name, str) or not isinstance(job, Mapping): + continue + job_text = "\n".join(_all_strings(job)) + has_tag_effect = bool( + re.search(r"\bgit\s+tag\b", job_text) + and re.search(r"\bgit\s+push\b", job_text) + ) + release_steps = [ + step + for step in _job_steps(job) + if re.search( + r"\bgh\s+release\s+(?:create|edit|upload)\b", + "\n".join(_all_strings(step)), + ) + ] + if not has_tag_effect and not release_steps: + continue + environment = _job_environment(job) + if environment not in allowed_environments: + blockers.append( + { + "code": "release_effect_environment_mismatch", + "message": ( + f"{repo['name']}:{path}:{job_name}: release effect must run " + "in an exact release private-key environment." + ), + } + ) + token_step_ids = _matching_app_token_step_ids(job, identity) + token_references = _token_references(token_step_ids) + if not token_step_ids: + blockers.append( + { + "code": "release_effect_app_token_missing", + "message": ( + f"{repo['name']}:{path}:{job_name}: release effect needs a " + "same-job token from the exact Release App inputs." + ), + } + ) + continue + if has_tag_effect and not any( + _references_expression(value, reference) + for value in _all_strings(job) + for reference in token_references + ): + blockers.append( + { + "code": "release_tag_app_token_not_bound", + "message": ( + f"{repo['name']}:{path}:{job_name}: tag creation does not " + "bind its push to the same-job Release App token." + ), + } + ) + for step in release_steps: + environment_values = step.get("env", {}) + gh_token = ( + environment_values.get("GH_TOKEN") + if isinstance(environment_values, Mapping) + else None + ) + if not any( + _references_expression(gh_token, reference) + for reference in token_references + ): + blockers.append( + { + "code": "github_release_app_token_not_bound", + "message": ( + f"{repo['name']}:{path}:{job_name}: each GitHub Release " + "create, edit, or upload step must bind GH_TOKEN to the " + "same-job Release App output." + ), + } + ) + return blockers + + def _workflow_contract_blockers( client: GitHubClient, owner: str, @@ -1100,6 +1415,16 @@ def _workflow_contract_blockers( } ) continue + try: + _parse_workflow_document(content, owner, repo["name"], path) + except PolicyError as exc: + blockers.append( + { + "code": f"{code_prefix}_workflow_yaml_invalid", + "message": str(exc), + } + ) + continue for pattern in workflow["required_patterns"]: if re.search(pattern, content) is None: blockers.append( @@ -1164,6 +1489,175 @@ def _dispatch_identity_variable_blockers( return blockers +def _release_identity_variable_blockers( + client: GitHubClient, + owner: str, + repo: Mapping[str, Any], + identity: Mapping[str, Any], + actor: ReleaseActor | None, +) -> list[dict[str, str]]: + if repo["name"] not in identity["repository_scope"] or actor is None: + return [] + variable_name = identity["repository_variables"]["app_id"] + variable = client.get( + ( + f"/repos/{owner}/{repo['name']}/actions/variables/" + f"{quote(variable_name, safe='')}" + ), + optional=True, + ) + if not isinstance(variable, Mapping): + return [ + { + "code": "openadapt-release_variable_missing", + "message": ( + f"{repo['name']}: Actions variable {variable_name} is missing." + ), + } + ] + if variable.get("name") != variable_name or variable.get("value") != str( + actor.actor_id + ): + return [ + { + "code": "openadapt-release_variable_mismatch", + "message": ( + f"{repo['name']}: Actions variable {variable_name} does not " + "match the reviewed release App identity." + ), + } + ] + return [] + + +def _inventory_names(response: Any, field: str, description: str) -> set[str]: + if not isinstance(response, Mapping): + raise GitHubError(f"{description} inventory is unavailable") + values = response.get(field) + if not isinstance(values, list): + raise GitHubError(f"{description} inventory is malformed") + names = { + item.get("name") + for item in values + if isinstance(item, Mapping) and isinstance(item.get("name"), str) + } + total_count = response.get("total_count", len(values)) + if not isinstance(total_count, int) or total_count != len(values): + raise GitHubError(f"{description} inventory is incomplete") + if len(names) != len(values): + raise GitHubError(f"{description} inventory contains an invalid name") + return names + + +def _identity_private_key_secret_blockers( + client: GitHubClient, + owner: str, + repo: Mapping[str, Any], + identity: Mapping[str, Any], + repository_id: int | None, +) -> list[dict[str, str]]: + repo_name = repo["name"] + if repo_name not in identity["repository_scope"]: + return [] + secret_name = identity["private_key_secret"] + expected_environments = set(identity["private_key_environment_bindings"][repo_name]) + blockers: list[dict[str, str]] = [] + + response = client.get(f"/repos/{owner}/{repo_name}/actions/secrets?per_page=100") + repository_secret_names = _inventory_names( + response, "secrets", f"{repo_name}: repository Actions secret" + ) + if secret_name in repository_secret_names: + blockers.append( + { + "code": f"{identity['app_slug']}_repository_private_key_present", + "message": ( + f"{repo_name}: remove repository Actions secret {secret_name}; " + "the private key must exist only in its exact protected environments." + ), + } + ) + + repository_variable = client.get( + f"/repos/{owner}/{repo_name}/actions/variables/{quote(secret_name, safe='')}", + optional=True, + ) + if isinstance(repository_variable, Mapping): + blockers.append( + { + "code": f"{identity['app_slug']}_repository_private_key_variable_present", + "message": ( + f"{repo_name}: remove repository Actions variable {secret_name}; " + "a private-key name must not be shadowed by a variable." + ), + } + ) + + environment_response = client.get( + f"/repos/{owner}/{repo_name}/environments?per_page=100" + ) + environment_names = _inventory_names( + environment_response, "environments", f"{repo_name}: environment" + ) + if repository_id is None or repository_id <= 0: + raise GitHubError(f"{repo_name}: numeric repository id is unavailable") + environments_with_key: set[str] = set() + for environment_name in sorted(environment_names): + encoded_environment = quote(environment_name, safe="") + environment_secrets = client.get( + f"/repos/{owner}/{repo_name}/environments/{encoded_environment}/secrets?per_page=100" + ) + secret_names = _inventory_names( + environment_secrets, + "secrets", + f"{repo_name}:{environment_name}: environment Actions secret", + ) + if secret_name in secret_names: + environments_with_key.add(environment_name) + environment_variables = client.get( + f"/repositories/{repository_id}/environments/{encoded_environment}/variables?per_page=100" + ) + variable_names = _inventory_names( + environment_variables, + "variables", + f"{repo_name}:{environment_name}: environment Actions variable", + ) + if secret_name in variable_names: + blockers.append( + { + "code": f"{identity['app_slug']}_environment_private_key_variable_present", + "message": ( + f"{repo_name}:{environment_name}: remove environment variable " + f"{secret_name}; it shadows the private-key secret name." + ), + } + ) + + missing = sorted(expected_environments - environments_with_key) + unexpected = sorted(environments_with_key - expected_environments) + if missing: + blockers.append( + { + "code": f"{identity['app_slug']}_environment_private_key_missing", + "message": ( + f"{repo_name}: environment secret {secret_name} is missing from " + f"the exact binding(s): {', '.join(missing)}." + ), + } + ) + if unexpected: + blockers.append( + { + "code": f"{identity['app_slug']}_environment_private_key_out_of_scope", + "message": ( + f"{repo_name}: environment secret {secret_name} exists outside " + f"its exact binding(s): {', '.join(unexpected)}." + ), + } + ) + return blockers + + def _exclusive_environment_blockers( client: GitHubClient, owner: str, @@ -1215,78 +1709,169 @@ def _exclusive_environment_blockers( 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 _actor_rejection_failures(expression: str) -> list[str]: + failures: list[str] = [] + for actor_login in ("openadapt-lifecycle[bot]", "openadapt-docs[bot]"): + escaped = re.escape(actor_login) + for field in ("actor", "triggering_actor"): + if ( + re.search(rf"github\.{field}\s*!=\s*['\"]{escaped}['\"]", expression) + is None + ): + failures.append(f"{field}:{actor_login}") + return failures -def _job_if_expression(block: list[str]) -> str: - expression_lines: list[str] = [] - for index, line in enumerate(block): - if not line.startswith(" if:"): +def _guard_actor_rejection_failures(guard: Mapping[str, Any]) -> list[str]: + direct = _actor_rejection_failures("\n".join(_all_strings(guard))) + unresolved = set(direct) + for step in _job_steps(guard): + environment = step.get("env", {}) + run = step.get("run") + if not isinstance(environment, Mapping) or not isinstance(run, str): 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) + for variable_name, value in environment.items(): + if not isinstance(variable_name, str) or not isinstance(value, str): + continue + field = next( + ( + candidate + for candidate in ("actor", "triggering_actor") + if f"github.{candidate}" in value + ), + None, + ) + if field is None: + continue + for actor_login in ("openadapt-lifecycle[bot]", "openadapt-docs[bot]"): + if re.search( + rf"\$\{{?{re.escape(variable_name)}\}}?\"?\s*!=\s*['\"]{re.escape(actor_login)}['\"]", + run, + ): + unresolved.discard(f"{field}:{actor_login}") + return sorted(unresolved) -def _job_actor_rejection_failures(content: str) -> list[str]: - jobs = _workflow_job_blocks(content) +def _job_actor_rejection_failures(document: Mapping[str, Any]) -> list[str]: + jobs = document.get("jobs", {}) + if not isinstance(jobs, Mapping): + return [""] guard_name = "reject-lifecycle-app" guard = jobs.get(guard_name) - if guard is None: + if not isinstance(guard, Mapping): return [""] - guard_text = "\n".join(guard) failures: list[str] = [] - if " permissions: {}" not in guard_text: + if guard.get("permissions") != {}: 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(): + guard_failures = _guard_actor_rejection_failures(guard) + failures.extend(f"{guard_name}:{item}" for item in guard_failures) + for job_name, job 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 - ): + if not isinstance(job_name, str) or not isinstance(job, Mapping): + failures.append("") + continue + expression = str(job.get("if", "")) + tag_only = ( + re.search(r"github\.event_name\s*==\s*['\"]push['\"]", expression) + is not None + and "refs/tags/" in expression + and "workflow_dispatch" not in expression + and "repository_dispatch" not in expression + and "||" not in expression + ) + if tag_only: + continue + if guard_name not in _job_needs(job): failures.append(f"{job_name}:needs") + failures.extend( + f"{job_name}:{item}" for item in _actor_rejection_failures(expression) + ) + return failures + + +def _authorized_dispatch_semantic_failures( + document: Mapping[str, Any], + owner: str, + repo: Mapping[str, Any], + path: str, + identity: Mapping[str, Any], +) -> list[str]: + jobs = document.get("jobs", {}) + if not isinstance(jobs, Mapping): + return [""] + expected_environments = [ + environment["name"] + for environment in ( + repo.get("release_environments", []) + + repo.get("lifecycle_environments", []) + ) + if environment.get("exclusive_workflow") == path + and environment["name"] + in identity["private_key_environment_bindings"][repo["name"]] + ] + if len(expected_environments) != 1: + return [""] + expected_environment = expected_environments[0] + actor_login = identity["actor_login"] + actor_id_variable = identity["repository_variables"]["actor_id"] + installation_variable = identity["repository_variables"]["installation_id"] + candidates: list[tuple[str, Mapping[str, Any]]] = [] + for job_name, job in jobs.items(): + if not isinstance(job_name, str) or not isinstance(job, Mapping): + continue + text = "\n".join(_all_strings(job)) if ( - actor_pattern.search(expression) is None - or triggering_pattern.search(expression) is None + actor_login in text + or identity["private_key_secret"] in text + or re.search(r"\bgh\s+pr\s+create\b", text) + or re.search(r"/dispatches\b", text) ): - failures.append(f"{job_name}:identity") + candidates.append((job_name, job)) + if not candidates: + return [""] + + failures: list[str] = [] + for job_name, job in candidates: + expression = str(job.get("if", "")) + required_conditions = { + "repository": rf"github\.repository\s*==\s*['\"]{re.escape(owner)}/{re.escape(repo['name'])}['\"]", + "ref": r"github\.ref\s*==\s*['\"]refs/heads/main['\"]", + "event": r"github\.event_name\s*==\s*['\"](?:workflow_dispatch|repository_dispatch)['\"]", + "actor": rf"github\.actor\s*==\s*['\"]{re.escape(actor_login)}['\"]", + "triggering_actor": rf"github\.triggering_actor\s*==\s*['\"]{re.escape(actor_login)}['\"]", + "actor_id": rf"github\.actor_id\s*==\s*vars\.{re.escape(actor_id_variable)}", + } + for label, pattern in required_conditions.items(): + if re.search(pattern, expression) is None: + failures.append(f"{job_name}:{label}") + if _job_environment(job) != expected_environment: + failures.append(f"{job_name}:environment") + token_step_ids = _matching_app_token_step_ids(job, identity) + if not token_step_ids: + failures.append(f"{job_name}:app-token") + continue + if installation_variable not in "\n".join(_all_strings(job)): + failures.append(f"{job_name}:installation-id") + token_references = _token_references(token_step_ids) + for step in _job_steps(job): + step_text = "\n".join(_all_strings(step)) + if re.search(r"\bgh\s+pr\s+create\b|/dispatches\b", step_text): + step_environment = step.get("env", {}) + gh_token = ( + step_environment.get("GH_TOKEN") + if isinstance(step_environment, Mapping) + else None + ) + if not any( + _references_expression(gh_token, reference) + for reference in token_references + ): + failures.append(f"{job_name}:effect-token") + if re.search(r"\bgit\s+push\b", step_text) and _step_uses_token( + step, token_references + ): + failures.append(f"{job_name}:branch-push-app-token") return failures @@ -1294,6 +1879,8 @@ def _dispatch_workflow_blockers( client: GitHubClient, owner: str, repo: Mapping[str, Any], + lifecycle_identity: Mapping[str, Any], + docs_identity: Mapping[str, Any], ) -> list[dict[str, str]]: inventory = { item["path"]: item["mode"] @@ -1317,23 +1904,30 @@ def _dispatch_workflow_blockers( 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 + if content is None: + continue + try: + document = _parse_workflow_document(content, owner, repo["name"], path) + triggers = _workflow_triggers(document) + except PolicyError as exc: + blockers.append( + {"code": "dispatch_workflow_yaml_invalid", "message": str(exc)} ) - 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 not triggers.intersection({"workflow_dispatch", "repository_dispatch"}): + continue + concurrency = document.get("concurrency") + group = concurrency.get("group") if isinstance(concurrency, Mapping) else None + non_cancelling = ( + concurrency.get("cancel-in-progress") + if isinstance(concurrency, Mapping) + else None ) 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 + not isinstance(group, str) + or "github.workflow" not in group + or "github.event_name" not in group + or non_cancelling is not False ): blockers.append( { @@ -1354,17 +1948,32 @@ def _dispatch_workflow_blockers( ) continue if mode == "reject-lifecycle-app": - failures = _job_actor_rejection_failures(content) + failures = _job_actor_rejection_failures(document) if failures: blockers.append( { - "code": "dispatch_workflow_accepts_lifecycle_app", + "code": "dispatch_workflow_accepts_privileged_app", "message": ( - f"{repo['name']}:{path} does not reject openadapt-lifecycle[bot] " + f"{repo['name']}:{path} does not reject both privileged App actors " f"in every job: {', '.join(failures)}." ), } ) + elif mode in {"lifecycle-only", "docs-only"}: + identity = lifecycle_identity if mode == "lifecycle-only" else docs_identity + failures = _authorized_dispatch_semantic_failures( + document, owner, repo, path, identity + ) + if failures: + blockers.append( + { + "code": f"{mode}_workflow_semantic_contract", + "message": ( + f"{repo['name']}:{path} does not bind its actor, environment, " + f"App token, and effects in the authorized job: {', '.join(failures)}." + ), + } + ) return blockers @@ -1426,8 +2035,9 @@ def _environment_actions( config: Mapping[str, Any], owner: str, repo: Mapping[str, Any], -) -> tuple[list[dict[str, Any]], bool]: +) -> tuple[list[dict[str, Any]], list[dict[str, str]], bool]: actions: list[dict[str, Any]] = [] + blockers: list[dict[str, str]] = [] prune_needed = False environments = repo["release_environments"] + repo.get("lifecycle_environments", []) for environment in environments: @@ -1439,8 +2049,21 @@ def _environment_actions( desired = desired_environment(config, environment) if ( not isinstance(current, Mapping) - or _normalize_environment(current) != desired + or current.get("can_admins_bypass") is not False ): + blockers.append( + { + "code": "environment_admin_bypass_not_disabled", + "message": ( + f"{repo['name']}:{name}: open Settings > Environments > {name} " + "in GitHub, create the environment if needed, and clear " + "'Allow administrators to bypass configured protection rules'. " + "The REST API cannot set this control." + ), + } + ) + continue + if _normalize_environment(current) != desired: actions.append( { "kind": "put_environment", @@ -1491,7 +2114,7 @@ def _environment_actions( "current": {"type": key[0], "name": key[1]}, } ) - return actions, prune_needed + return actions, blockers, prune_needed def build_plan(client: GitHubClient, config: Mapping[str, Any]) -> dict[str, Any]: @@ -1537,12 +2160,12 @@ def build_plan(client: GitHubClient, config: Mapping[str, Any]) -> dict[str, Any 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( + blockers.append( { - "code": "audit_snapshot_advanced", + "code": "audited_main_drift", "message": ( f"{name}: main advanced from {repo['audited_main_sha']} to {main_sha}. " - "The apply plan will bind the new SHA." + "Re-audit the exact current main workflow state and update the policy SHA." ), } ) @@ -1580,6 +2203,20 @@ def build_plan(client: GitHubClient, config: Mapping[str, Any]) -> dict[str, Any client, owner, repo, "lifecycle_workflows", "lifecycle" ) ) + blockers.extend( + _release_workflow_semantic_blockers( + client, owner, repo, config["release_identity"] + ) + ) + blockers.extend( + _release_identity_variable_blockers( + client, + owner, + repo, + config["release_identity"], + actor, + ) + ) blockers.extend( _dispatch_identity_variable_blockers( client, @@ -1598,8 +2235,30 @@ def build_plan(client: GitHubClient, config: Mapping[str, Any]) -> dict[str, Any docs_actor, ) ) + for identity in ( + config["release_identity"], + config["lifecycle_identity"], + config["docs_identity"], + ): + blockers.extend( + _identity_private_key_secret_blockers( + client, + owner, + repo, + identity, + metadata.get("id"), + ) + ) blockers.extend(_exclusive_environment_blockers(client, owner, repo)) - blockers.extend(_dispatch_workflow_blockers(client, owner, repo)) + blockers.extend( + _dispatch_workflow_blockers( + client, + owner, + repo, + config["lifecycle_identity"], + config["docs_identity"], + ) + ) current_rulesets = _list_rulesets(client, owner, name) actions: list[dict[str, Any]] = [] for desired in desired_rulesets(config, repo, actor): @@ -1622,9 +2281,10 @@ def build_plan(client: GitHubClient, config: Mapping[str, Any]) -> dict[str, Any } ) - environment_actions, prune_needed = _environment_actions( + environment_actions, environment_blockers, prune_needed = _environment_actions( client, config, owner, repo ) + blockers.extend(environment_blockers) actions.extend(environment_actions) repositories.append( { diff --git a/tests/test_manage_github_protection.py b/tests/test_manage_github_protection.py index be03811..23b0740 100644 --- a/tests/test_manage_github_protection.py +++ b/tests/test_manage_github_protection.py @@ -19,6 +19,8 @@ PolicyError, ReleaseActor, _apply_actions, + _parse_workflow_document, + _workflow_triggers, build_plan, desired_environment, desired_rulesets, @@ -47,6 +49,17 @@ def __init__( release_app_permissions_mismatch: bool = False, release_app_extra_repository: bool = False, null_deployment_policy_repo: str | None = None, + missing_variable: tuple[str, str] | None = None, + repository_secret: tuple[str, str] | None = None, + missing_environment_secret: tuple[str, str, str] | None = None, + extra_environment_secret: tuple[str, str, str] | None = None, + repository_variable_shadow: tuple[str, str] | None = None, + environment_variable_shadow: tuple[str, str, str] | None = None, + missing_environment: tuple[str, str] | None = None, + admin_bypass_environment: tuple[str, str] | None = None, + omitted_admin_bypass_environment: tuple[str, str] | None = None, + main_drift_repo: str | None = None, + workflow_overrides: Mapping[tuple[str, str], str] | None = None, ) -> None: self.config = config self.active_repo = active_repo @@ -61,8 +74,23 @@ def __init__( self.release_app_permissions_mismatch = release_app_permissions_mismatch self.release_app_extra_repository = release_app_extra_repository self.null_deployment_policy_repo = null_deployment_policy_repo + self.missing_variable = missing_variable + self.repository_secret = repository_secret + self.missing_environment_secret = missing_environment_secret + self.extra_environment_secret = extra_environment_secret + self.repository_variable_shadow = repository_variable_shadow + self.environment_variable_shadow = environment_variable_shadow + self.missing_environment = missing_environment + self.admin_bypass_environment = admin_bypass_environment + self.omitted_admin_bypass_environment = omitted_admin_bypass_environment + self.main_drift_repo = main_drift_repo + self.workflow_overrides = dict(workflow_overrides or {}) self.writes: list[tuple[str, str, Mapping[str, Any]]] = [] self.by_name = {repo["name"]: repo for repo in config["repositories"]} + self.repository_ids = { + repo["name"]: 1000 + index + for index, repo in enumerate(config["repositories"]) + } def get(self, path: str, *, optional: bool = False) -> Any: if path == "/apps/openadapt-release": @@ -92,13 +120,13 @@ def get(self, path: str, *, optional: bool = False) -> Any: "all" if self.release_app_all_repositories else "selected" ), "permissions": ( - {"contents": "write", "metadata": "read"} - if self.release_app_permissions_mismatch - else { + { "contents": "write", "metadata": "read", "pull_requests": "write", } + if self.release_app_permissions_mismatch + else {"contents": "write", "metadata": "read"} ), }, { @@ -130,8 +158,19 @@ def get(self, path: str, *, optional: bool = False) -> Any: "repositories": [ {"name": name} for name in sorted( - set(self.by_name) - | ({"unexpected-public-repo"} if self.release_app_extra_repository else set()) + { + "OpenAdapt", + "openadapt-agent", + "openadapt-capture", + "openadapt-desktop", + "openadapt-evals", + "openadapt-flow", + } + | ( + {"unexpected-public-repo"} + if self.release_app_extra_repository + else set() + ) ) ] } @@ -145,18 +184,42 @@ def get(self, path: str, *, optional: bool = False) -> Any: } if path == "/user/installations/761100/repositories?per_page=100": return {"repositories": [{"name": "openadapt-ops"}]} + if path.startswith("/repositories/"): + parts = path.split("?")[0].split("/") + repository_id = int(parts[2]) + name = next( + repo_name + for repo_name, candidate_id in self.repository_ids.items() + if candidate_id == repository_id + ) + environment_name = parts[4] + secret_name = ( + self.environment_variable_shadow[2] + if self.environment_variable_shadow is not None + and self.environment_variable_shadow[:2] == (name, environment_name) + else None + ) + variables = [] if secret_name is None else [{"name": secret_name}] + return {"total_count": len(variables), "variables": variables} 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 { + "id": self.repository_ids[name], "full_name": f"OpenAdaptAI/{name}", "private": False, "default_branch": "main", } if parts[4] == "commits" and parts[5] == "main": - return {"sha": repo["audited_main_sha"]} + return { + "sha": ( + "f" * 40 + if name == self.main_drift_repo + else repo["audited_main_sha"] + ) + } if parts[4] == "commits" and parts[-1] == "check-runs": if name == self.active_repo: return { @@ -182,15 +245,115 @@ def get(self, path: str, *, optional: bool = False) -> Any: if parts[4] == "rulesets": return [] if parts[4] == "environments": + environments = repo["release_environments"] + repo.get( + "lifecycle_environments", [] + ) + environment_by_name = {item["name"]: item for item in environments} + if self.extra_environment_secret is not None: + extra_repo, extra_environment, _ = self.extra_environment_secret + if name == extra_repo: + environment_by_name[extra_environment] = { + "name": extra_environment + } + if len(parts) == 5: + values = [ + {"name": environment_name} + for environment_name in sorted(environment_by_name) + if self.missing_environment != (name, environment_name) + ] + return {"total_count": len(values), "environments": values} + environment_name = parts[5] + if self.missing_environment == (name, environment_name): + return None + if len(parts) >= 7 and parts[6] == "secrets": + secret_names = { + identity["private_key_secret"] + for identity in ( + self.config["release_identity"], + self.config["lifecycle_identity"], + self.config["docs_identity"], + ) + if environment_name + in identity.get("private_key_environment_bindings", {}).get( + name, [] + ) + } + if self.missing_environment_secret is not None: + missing_repo, missing_environment, missing_name = ( + self.missing_environment_secret + ) + if (name, environment_name) == ( + missing_repo, + missing_environment, + ): + secret_names.discard(missing_name) + if self.extra_environment_secret is not None: + extra_repo, extra_environment, extra_name = ( + self.extra_environment_secret + ) + if (name, environment_name) == (extra_repo, extra_environment): + secret_names.add(extra_name) + return { + "total_count": len(secret_names), + "secrets": [ + {"name": secret_name} + for secret_name in sorted(secret_names) + ], + } + if len(parts) >= 7 and parts[6] == "deployment-branch-policies": + policies = [ + {"id": index + 1, **policy} + for index, policy in enumerate( + environment_by_name[environment_name].get( + "deployment_policies", [] + ) + ) + ] + return {"branch_policies": policies} + environment = environment_by_name[environment_name] if name == self.null_deployment_policy_repo: return { + "can_admins_bypass": False, "protection_rules": [], "deployment_branch_policy": None, } - return None + response = { + "protection_rules": [ + { + "type": "required_reviewers", + "prevent_self_review": environment.get( + "prevent_self_review", + self.config["environment_defaults"][ + "prevent_self_review" + ], + ), + "reviewers": [ + { + "type": "User", + "reviewer": {"id": 774615}, + } + ], + } + ], + "deployment_branch_policy": { + "protected_branches": False, + "custom_branch_policies": True, + }, + } + if self.omitted_admin_bypass_environment != (name, environment_name): + response["can_admins_bypass"] = self.admin_bypass_environment == ( + name, + environment_name, + ) + return response if parts[4:6] == ["actions", "variables"]: variable = parts[6] + if self.missing_variable == (name, variable): + return None + if self.repository_variable_shadow == (name, variable): + return {"name": variable, "value": "shadow"} values = { + "OPENADAPT_RELEASE_APP_ID": "991122", "OPENADAPT_LIFECYCLE_APP_ID": "771100", "OPENADAPT_LIFECYCLE_ACTOR_ID": "881100", "OPENADAPT_LIFECYCLE_INSTALLATION_ID": "661100", @@ -198,7 +361,21 @@ def get(self, path: str, *, optional: bool = False) -> Any: "OPENADAPT_DOCS_ACTOR_ID": "882200", "OPENADAPT_DOCS_INSTALLATION_ID": "761100", } + if variable not in values: + return None return {"name": variable, "value": values[variable]} + if parts[4:6] == ["actions", "secrets"]: + secret_names: set[str] = set() + if self.repository_secret is not None: + secret_repo, secret_name = self.repository_secret + if name == secret_repo: + secret_names.add(secret_name) + return { + "total_count": len(secret_names), + "secrets": [ + {"name": secret_name} for secret_name in sorted(secret_names) + ], + } if parts[4:6] == ["git", "trees"]: configured = set() for field in ( @@ -228,6 +405,9 @@ def get(self, path: str, *, optional: bool = False) -> Any: raise AssertionError(f"unexpected GET {path}") def _workflow_content(self, repo_name: str, path: str) -> str: + override = self.workflow_overrides.get((repo_name, path)) + if override is not None: + return override repo = self.by_name[repo_name] lifecycle_path = next( ( @@ -268,7 +448,16 @@ def _workflow_content(self, repo_name: str, path: str) -> str: job_id, job_name = job_names[path] projection_inputs = "" projection_conditions = "" - projection_steps = "" + projection_steps = ( + " - uses: actions/create-github-app-token@deadbeef\n" + " id: lifecycle-app\n" + " with:\n" + " app-id: ${{ vars.OPENADAPT_LIFECYCLE_APP_ID }}\n" + " private-key: ${{ secrets.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY }}\n" + " permission-actions: write\n" + " permission-metadata: read\n" + " permission-pull-requests: write\n" + ) if path == ".github/workflows/production-lifecycle-projection.yml": projection_inputs = ( " inputs:\n" @@ -286,12 +475,9 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " 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" + projection_steps += ( + " - run: gh api repos/OpenAdaptAI/.github/commits/main\n" + " - run: python scripts/prepare_production_lifecycle_projection.py\n" " - run: echo '${{ inputs.source_commit }} " "${{ inputs.candidate_admissions_sha256 }} " "${{ inputs.candidate_ledger_head_sha256 }} " @@ -331,7 +517,10 @@ def _workflow_content(self, repo_name: str, path: str) -> str: "${{ secrets.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY }}'\n" " - env:\n" " GH_TOKEN: ${{ github.token }}\n" - " run: git push origin HEAD && gh pr create\n" + ' run: git push origin "HEAD:refs/heads/automation-lifecycle"\n' + " - env:\n" + " GH_TOKEN: ${{ steps.lifecycle-app.outputs.token }}\n" + " run: gh pr create\n" ) inventory_mode = next( @@ -379,15 +568,25 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " 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" + " - uses: actions/create-github-app-token@deadbeef\n" + " id: docs-app\n" + " with:\n" + " app-id: ${{ vars.OPENADAPT_DOCS_APP_ID }}\n" + " private-key: ${{ secrets.OPENADAPT_DOCS_APP_PRIVATE_KEY }}\n" + " permission-actions: write\n" + " permission-metadata: read\n" + " permission-pull-requests: write\n" + " - run: gh api repos/source/commits/main\n" + " - run: python scripts/validate_docs_sync.py --repositories repos.yml\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" + ' run: git push origin "HEAD:refs/heads/automation-docs"\n' + " - env:\n" + " GH_TOKEN: ${{ steps.docs-app.outputs.token }}\n" + " run: gh pr create\n" " deploy-pages:\n" " if: github.event_name == 'push'\n" " environment:\n" @@ -400,6 +599,81 @@ def _workflow_content(self, repo_name: str, path: str) -> str: cancel_value = ( "true" if repo_name == self.cancelling_dispatch_repo else "false" ) + if ( + repo_name == "openadapt-evals" + and path == ".github/workflows/release.yml" + ): + return ( + "name: Release fixture\n" + "on:\n" + " workflow_dispatch:\n" + " push:\n" + " tags:\n" + " - 'v*'\n" + "permissions:\n" + " contents: read\n" + "concurrency:\n" + " group: ${{ github.workflow }}-${{ github.event_name }}\n" + f" cancel-in-progress: {cancel_value}\n" + "jobs:\n" + + ( + " reject-lifecycle-app:\n" + " if: github.event_name == 'workflow_dispatch'\n" + " permissions: {}\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - env:\n" + " ACTOR: ${{ github.actor }}\n" + " TRIGGERING_ACTOR: ${{ github.triggering_actor }}\n" + " run: >-\n" + " test \"$ACTOR\" != 'openadapt-lifecycle[bot]' -a\n" + " \"$TRIGGERING_ACTOR\" != 'openadapt-lifecycle[bot]' -a\n" + " \"$ACTOR\" != 'openadapt-docs[bot]' -a\n" + " \"$TRIGGERING_ACTOR\" != 'openadapt-docs[bot]'\n" + if guarded + else "" + ) + + " authorize-release-tag:\n" + " if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')\n" + " permissions: {}\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - run: test \"$GITHUB_ACTOR\" = 'openadapt-release[bot]'\n" + " create-release-tag:\n" + " needs: reject-lifecycle-app\n" + " if: >-\n" + " github.event_name == 'workflow_dispatch' &&\n" + " github.actor != 'openadapt-lifecycle[bot]' &&\n" + " github.triggering_actor != 'openadapt-lifecycle[bot]' &&\n" + " github.actor != 'openadapt-docs[bot]' &&\n" + " github.triggering_actor != 'openadapt-docs[bot]'\n" + " environment: release-identity\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/create-github-app-token@deadbeef\n" + " id: release_app\n" + " with:\n" + " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" + " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " permission-contents: write\n" + " - run: test OpenAdaptAI/openadapt-evals = OpenAdaptAI/openadapt-evals && test refs/heads/main = refs/heads/main\n" + " - run: python scripts/verify_release_lock.py\n" + " - env:\n" + " APP_TOKEN: ${{ steps.release_app.outputs.token }}\n" + ' run: git tag -a v1.2.3 -m release && git push origin "refs/tags/v1.2.3"\n' + " publish-pypi:\n" + " needs: authorize-release-tag\n" + " if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && github.actor == 'openadapt-release[bot]'\n" + " environment: pypi\n" + " permissions:\n" + " id-token: write\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - run: python scripts/check_source_boundary.py --require-dist\n" + " - uses: pypa/gh-action-pypi-publish@deadbeef\n" + " with:\n" + " skip-existing: true\n" + ) extra = "" if path == ".github/workflows/profile-consistency.yml": extra = " pull_request:\n" @@ -446,8 +720,11 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " - 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" + " run: >-\n" + " test \"$ACTOR\" != 'openadapt-lifecycle[bot]' -a\n" + " \"$TRIGGERING_ACTOR\" != 'openadapt-lifecycle[bot]' -a\n" + " \"$ACTOR\" != 'openadapt-docs[bot]' -a\n" + " \"$TRIGGERING_ACTOR\" != 'openadapt-docs[bot]'\n" if guarded else "" ) @@ -458,7 +735,9 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " needs: reject-lifecycle-app\n" " if: >-\n" " github.actor != 'openadapt-lifecycle[bot]' &&\n" - " github.triggering_actor != 'openadapt-lifecycle[bot]'\n" + " github.triggering_actor != 'openadapt-lifecycle[bot]' &&\n" + " github.actor != 'openadapt-docs[bot]' &&\n" + " github.triggering_actor != 'openadapt-docs[bot]'\n" if guarded else "" ) @@ -475,12 +754,60 @@ def _workflow_content(self, repo_name: str, path: str) -> str: environment = repo["lifecycle_environments"][0]["name"] return f"on:\n push:\njobs:\n run:\n environment: {environment}\n" + if repo_name == "OpenAdapt": + return ( + "on:\n" + " workflow_dispatch:\n" + " push:\n" + " tags:\n" + ' - "v*"\n' + "jobs:\n" + " create-release-tag:\n" + " environment: release-identity\n" + " steps:\n" + " - uses: actions/create-github-app-token@deadbeef\n" + " id: release_app\n" + " with:\n" + " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" + " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " permission-contents: write\n" + " - run: test refs/heads/main = refs/heads/main && test OpenAdaptAI/OpenAdapt = OpenAdaptAI/OpenAdapt\n" + " - run: test \"$GITHUB_ACTOR\" = 'openadapt-release[bot]'\n" + " - env:\n" + " APP_TOKEN: ${{ steps.release_app.outputs.token }}\n" + ' run: git tag -a v1.2.3 -m release && git push origin "refs/tags/v1.2.3"\n' + " build-and-attest:\n" + " permissions:\n" + " id-token: write\n" + " steps:\n" + " - run: python scripts/check_source_boundary.py\n" + " - run: python scripts/verify_release_artifacts.py\n" + " publish-pypi:\n" + " environment: pypi\n" + " permissions:\n" + " id-token: write\n" + " steps:\n" + " - uses: pypa/gh-action-pypi-publish@deadbeef\n" + " - uses: actions/create-github-app-token@deadbeef\n" + " id: release_app\n" + " with:\n" + " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" + " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " permission-contents: write\n" + " - env:\n" + " GH_TOKEN: ${{ steps.release_app.outputs.token }}\n" + " run: gh release create v1.2.3\n" + " verify-publication:\n" + " steps:\n" + " - run: python scripts/validate_platform_manifest.py\n" + ) + if repo_name == "openadapt-agent": return ( "on:\n" " workflow_dispatch:\n" " push:\n" - " tags: [\"v*\"]\n" + ' tags: ["v*"]\n' "permissions:\n" " contents: read\n" "jobs:\n" @@ -488,11 +815,15 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " environment: release-identity\n" " steps:\n" " - uses: actions/create-github-app-token@deadbeef\n" + " id: release_app\n" " with:\n" " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" " permission-contents: write\n" - " - run: git tag v1.2.3 && git push origin v1.2.3\n" + " - run: test \"$GITHUB_ACTOR\" = 'openadapt-release[bot]'\n" + " - env:\n" + " APP_TOKEN: ${{ steps.release_app.outputs.token }}\n" + " run: git tag v1.2.3 && git push origin v1.2.3\n" " pypi:\n" " environment: pypi\n" " permissions:\n" @@ -511,6 +842,229 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " - run: test -f production-admission-candidate.json\n" ) + if repo_name in {"openadapt-flow", "openadapt-capture"}: + archive_checks = ( + " - run: python scripts/check_release_consistency.py --require-dist\n" + if repo_name == "openadapt-flow" + else ( + " - run: python scripts/verify_distribution.py dist/*\n" + " - run: python scripts/check_source_boundary.py --require-dist\n" + ) + ) + flow_dispatch_contract = ( + "concurrency:\n" + " group: engine-release-${{ github.ref }}\n" + " cancel-in-progress: false\n" + if repo_name == "openadapt-flow" + else "" + ) + flow_refusal_job = ( + " authorize-release-dispatch:\n" + " name: Refuse an invalid engine release dispatch\n" + " permissions: {}\n" + " steps:\n" + " - run: true\n" + if repo_name == "openadapt-flow" + else "" + ) + return ( + "on:\n" + " workflow_dispatch:\n" + " push:\n" + ' tags: ["v*"]\n' + f"{flow_dispatch_contract}" + "jobs:\n" + f"{flow_refusal_job}" + " create-release-tag:\n" + " environment: release-identity\n" + " steps:\n" + " - uses: actions/create-github-app-token@deadbeef\n" + " id: release_app\n" + " with:\n" + " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" + " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " permission-contents: write\n" + " - run: test refs/heads/main = refs/heads/main\n" + " - env:\n" + " APP_TOKEN: ${{ steps.release_app.outputs.token }}\n" + ' run: git tag --annotate v1.2.3 -m release && git push origin "refs/tags/v1.2.3"\n' + " publish:\n" + " environment: pypi\n" + " permissions:\n" + " id-token: write\n" + " steps:\n" + " - run: test \"$GITHUB_ACTOR\" = 'openadapt-release[bot]'\n" + + archive_checks + + " - run: python scripts/verify_release_publication.py\n" + " - uses: pypa/gh-action-pypi-publish@deadbeef\n" + ) + + if repo_name == "openadapt-desktop" and path == ".github/workflows/release.yml": + return ( + "on:\n" + " workflow_dispatch:\n" + " push:\n" + " tags:\n" + " - 'v*'\n" + "concurrency:\n" + " group: engine-release-${{ github.ref }}\n" + " cancel-in-progress: false\n" + "jobs:\n" + " authorize-release-dispatch:\n" + " name: Refuse an invalid engine release dispatch\n" + " permissions: {}\n" + " create-release-tag:\n" + " environment: release-identity\n" + " steps:\n" + " - uses: actions/create-github-app-token@deadbeef\n" + " id: release_app\n" + " with:\n" + " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" + " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " permission-contents: write\n" + " - run: test OpenAdaptAI/openadapt-desktop = OpenAdaptAI/openadapt-desktop && test refs/heads/main = refs/heads/main\n" + " - run: python scripts/verify_release_lock.py\n" + " - env:\n" + " APP_TOKEN: ${{ steps.release_app.outputs.token }}\n" + ' run: git tag -a v1.2.3 -m release && git push origin "refs/tags/v1.2.3"\n' + " publish:\n" + " if: github.actor == 'openadapt-release[bot]'\n" + " environment: pypi\n" + " permissions:\n" + " id-token: write\n" + " steps:\n" + " - run: test \"$GITHUB_ACTOR\" = 'openadapt-release[bot]'\n" + " - run: python scripts/check_source_boundary.py --require-dist\n" + " - uses: pypa/gh-action-pypi-publish@deadbeef\n" + " with:\n" + " skip-existing: true\n" + " - uses: actions/create-github-app-token@deadbeef\n" + " id: release_app\n" + " with:\n" + " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" + " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " permission-contents: write\n" + " - env:\n" + " GH_TOKEN: ${{ steps.release_app.outputs.token }}\n" + " run: gh release create v1.2.3\n" + ) + + if ( + repo_name == "openadapt-desktop" + and path == ".github/workflows/native-freshness.yml" + ): + return ( + "on:\n" + " release:\n" + " workflow_dispatch:\n" + "permissions:\n" + " contents: read\n" + "concurrency:\n" + " group: native-freshness-check\n" + " cancel-in-progress: false\n" + "jobs:\n" + " validate:\n" + " steps:\n" + " - run: test refs/heads/main = refs/heads/main\n" + ) + + if ( + repo_name == "openadapt-desktop" + and path == ".github/workflows/native-release.yml" + ): + return ( + "on:\n" + " workflow_dispatch:\n" + " push:\n" + " tags: ['desktop-v*']\n" + "concurrency:\n" + " group: native-release\n" + " cancel-in-progress: false\n" + "jobs:\n" + " authorize-native-dispatch:\n" + " name: Refuse an invalid native release dispatch\n" + " permissions: {}\n" + " create-native-tag:\n" + " environment: release-identity\n" + " steps:\n" + " - uses: actions/create-github-app-token@deadbeef\n" + " id: release_app\n" + " with:\n" + " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" + " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " permission-contents: write\n" + " - env:\n" + " APP_TOKEN: ${{ steps.release_app.outputs.token }}\n" + ' run: git tag --annotate desktop-v1.2.3 -m release && git push origin "refs/tags/desktop-v1.2.3"\n' + " recover-published-native:\n" + " steps:\n" + " - run: echo state=absent state=partial state=complete\n" + " publish-native:\n" + " environment: native-release\n" + " permissions:\n" + " id-token: write\n" + " steps:\n" + " - run: test \"$GITHUB_ACTOR\" = 'openadapt-release[bot]'\n" + " - uses: actions/attest@deadbeef\n" + " - uses: actions/create-github-app-token@deadbeef\n" + " id: release_app\n" + " with:\n" + " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" + " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " permission-contents: write\n" + " - env:\n" + " GH_TOKEN: ${{ steps.release_app.outputs.token }}\n" + " run: gh release create desktop-v1.2.3\n" + ) + + if ( + repo_name == "openadapt-desktop" + and path == ".github/workflows/ffmpeg-runtime.yml" + ): + return ( + "on:\n" + " workflow_dispatch:\n" + " push:\n" + " tags: ['ffmpeg-runtime-v*']\n" + "concurrency:\n" + " group: ffmpeg-runtime-${{ github.ref }}\n" + " cancel-in-progress: false\n" + "env:\n" + " SOURCE_SIGNATURE_SHA256: deadbeef\n" + " SIGNING_KEY_SHA256: deadbeef\n" + "jobs:\n" + " authorize-runtime-dispatch:\n" + " name: Refuse an invalid managed-runtime dispatch\n" + " permissions: {}\n" + " create-runtime-tag:\n" + " environment: release-identity\n" + " steps:\n" + " - uses: actions/create-github-app-token@deadbeef\n" + " id: release_app\n" + " with:\n" + " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" + " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " permission-contents: write\n" + " - env:\n" + " APP_TOKEN: ${{ steps.release_app.outputs.token }}\n" + ' run: git tag --annotate ffmpeg-runtime-v1.2.3-r1 -m release && git push origin "refs/tags/ffmpeg-runtime-v1.2.3-r1"\n' + " publish:\n" + " environment: native-release\n" + " permissions:\n" + " id-token: write\n" + " steps:\n" + " - run: test \"$GITHUB_ACTOR\" = 'openadapt-release[bot]'\n" + " - uses: actions/create-github-app-token@deadbeef\n" + " id: release_app\n" + " with:\n" + " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" + " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " permission-contents: write\n" + " - env:\n" + " GH_TOKEN: ${{ steps.release_app.outputs.token }}\n" + " run: gh release create ffmpeg-runtime-v1.2.3-r1\n" + ) + workflow = ( "on:\n" " pull_request:\n" @@ -590,9 +1144,26 @@ def test_lifecycle_identity_is_separate_and_least_privilege() -> None: assert release["app_slug"] == "openadapt-release" assert release["required_repository_permissions"] == [ "Contents: write", - "Pull requests: write", "Metadata: read", ] + assert release["repository_scope"] == [ + "OpenAdapt", + "openadapt-agent", + "openadapt-capture", + "openadapt-desktop", + "openadapt-evals", + "openadapt-flow", + ] + assert release["repository_variables"] == {"app_id": "OPENADAPT_RELEASE_APP_ID"} + assert release["private_key_secret"] == "OPENADAPT_RELEASE_APP_PRIVATE_KEY" + assert release["private_key_environment_bindings"] == { + "OpenAdapt": ["release-identity", "pypi"], + "openadapt-agent": ["release-identity"], + "openadapt-capture": ["release-identity", "pypi"], + "openadapt-desktop": ["release-identity", "pypi", "native-release"], + "openadapt-evals": ["release-identity", "pypi"], + "openadapt-flow": ["release-identity", "pypi"], + } assert lifecycle["app_slug"] == "openadapt-lifecycle" assert lifecycle["actor_login"] == "openadapt-lifecycle[bot]" assert lifecycle["repository_scope"] == [ @@ -607,6 +1178,16 @@ def test_lifecycle_identity_is_separate_and_least_privilege() -> None: ] assert lifecycle["forbidden_repository_permissions"] == ["Contents: write"] assert lifecycle["ruleset_bypass"] is False + assert lifecycle["private_key_secret"] == ("OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY") + assert lifecycle["private_key_environment_bindings"] == { + ".github": [ + "production-lifecycle-activation", + "qualification-authority-state", + "qualification-revocation-state", + ], + "openadapt-evals": ["production-lifecycle-evidence"], + "openadapt-ops": ["production-lifecycle-projection"], + } assert lifecycle["workflow_paths"][".github"] == [ ".github/workflows/production-lifecycle-activation.yml", ".github/workflows/qualification-authority-state.yml", @@ -628,6 +1209,10 @@ def test_lifecycle_identity_is_separate_and_least_privilege() -> None: ] assert docs["forbidden_repository_permissions"] == ["Contents: write"] assert docs["ruleset_bypass"] is False + assert docs["private_key_secret"] == "OPENADAPT_DOCS_APP_PRIVATE_KEY" + assert docs["private_key_environment_bindings"] == { + "openadapt-ops": ["production-docs-deploy"] + } audit = value["dispatch_privilege_audit"] assert audit["openadapt_ops_main_protected"] is False assert set(audit["unprotected_operational_environments"]) == { @@ -643,6 +1228,7 @@ def test_lifecycle_environments_override_the_unchanged_default() -> None: assert value["environment_defaults"] == { "wait_timer": 0, "prevent_self_review": False, + "can_admins_bypass": False, } expected = { ".github": [ @@ -678,6 +1264,7 @@ def test_lifecycle_environments_override_the_unchanged_default() -> None: assert environments == [ { "name": environment_name, + "can_admins_bypass": False, "wait_timer": 0, "prevent_self_review": True, "deployment_policies": [{"type": "branch", "name": "main"}], @@ -690,6 +1277,14 @@ def test_lifecycle_environments_override_the_unchanged_default() -> None: for environment in environments ) + assert all( + environment["can_admins_bypass"] is False + for repo in value["repositories"] + for environment in ( + repo["release_environments"] + repo.get("lifecycle_environments", []) + ) + ) + def test_pages_and_lifecycle_required_checks_are_exact() -> None: value = load_config(CONFIG_PATH) @@ -701,11 +1296,13 @@ def test_pages_and_lifecycle_required_checks_are_exact() -> None: assert ops["release_environments"] == [ { "name": "github-pages", + "can_admins_bypass": False, "deployment_policies": [{"type": "branch", "name": "main"}], "exclusive_workflow": ".github/workflows/sync.yml", }, { "name": "production-docs-deploy", + "can_admins_bypass": False, "wait_timer": 0, "prevent_self_review": True, "deployment_policies": [{"type": "branch", "name": "main"}], @@ -722,9 +1319,9 @@ def test_pages_and_lifecycle_required_checks_are_exact() -> None: "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 "inputs\\.source_event" in sync["required_patterns"] assert "repo-updated" in sync["forbidden_patterns"] - assert "docs-sync:" in sync["required_patterns"] + assert "scripts/validate_docs_sync\\.py" in sync["required_patterns"] projection = ops["lifecycle_workflows"][0] assert projection["path"] == ".github/workflows/production-lifecycle-projection.yml" @@ -732,8 +1329,7 @@ def test_pages_and_lifecycle_required_checks_are_exact() -> None: "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", + "scripts/prepare_production_lifecycle_projection\\.py", ): assert exact_pattern in projection["required_patterns"] @@ -759,6 +1355,20 @@ def test_path_scoped_check_cannot_also_be_required() -> None: validate_config(value) +def test_config_rejects_private_key_binding_or_admin_bypass_drift() -> None: + value = config() + value["release_identity"]["private_key_environment_bindings"]["OpenAdapt"].append( + "unreviewed-release" + ) + with pytest.raises(PolicyError, match="private-key bindings are not exact"): + validate_config(value) + + value = config() + value["repositories"][0]["release_environments"][0]["can_admins_bypass"] = True + with pytest.raises(PolicyError, match="can_admins_bypass false"): + validate_config(value) + + def test_main_has_no_bypass_and_tag_immutability_has_no_bypass() -> None: value = config() repo = value["repositories"][0] @@ -784,6 +1394,12 @@ def test_main_has_no_bypass_and_tag_immutability_has_no_bypass() -> None: "non_fast_forward", } + web = next( + item for item in value["repositories"] if item["name"] == "openadapt-web" + ) + web_by_name = {item["name"]: item for item in desired_rulesets(value, web, actor)} + assert web_by_name["OpenAdapt policy: release tag creation"]["bypass_actors"] == [] + def test_plan_is_read_only_and_never_manages_private_cloud( monkeypatch: pytest.MonkeyPatch, @@ -827,7 +1443,176 @@ def test_release_identity_requires_exact_public_scope_and_permissions( assert expected_code in {item["code"] for item in plan["global_blockers"]} -def test_unprotected_existing_environment_produces_a_plan_instead_of_crashing( +def test_missing_release_app_variable_keeps_plan_fail_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + plan = build_plan( + ReadOnlyFixtureGitHub( + value, + missing_variable=("openadapt-desktop", "OPENADAPT_RELEASE_APP_ID"), + ), + value, + ) + + desktop = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-desktop" + ) + assert plan["safe_to_apply"] is False + assert {item["code"] for item in desktop["blockers"]} >= { + "openadapt-release_variable_missing" + } + + +@pytest.mark.parametrize( + ("repository", "environment", "secret_name", "expected_code"), + [ + ( + "openadapt-desktop", + "native-release", + "OPENADAPT_RELEASE_APP_PRIVATE_KEY", + "openadapt-release_environment_private_key_missing", + ), + ( + "openadapt-evals", + "production-lifecycle-evidence", + "OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY", + "openadapt-lifecycle_environment_private_key_missing", + ), + ( + "openadapt-ops", + "production-docs-deploy", + "OPENADAPT_DOCS_APP_PRIVATE_KEY", + "openadapt-docs_environment_private_key_missing", + ), + ], +) +def test_missing_app_private_key_secret_keeps_plan_fail_closed( + monkeypatch: pytest.MonkeyPatch, + repository: str, + environment: str, + secret_name: str, + expected_code: str, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + plan = build_plan( + ReadOnlyFixtureGitHub( + value, + missing_environment_secret=(repository, environment, secret_name), + ), + value, + ) + + target = next(repo for repo in plan["repositories"] if repo["name"] == repository) + assert plan["safe_to_apply"] is False + assert expected_code in {item["code"] for item in target["blockers"]} + + +@pytest.mark.parametrize( + ("fixture_kwargs", "expected_code"), + [ + ( + { + "repository_secret": ( + "OpenAdapt", + "OPENADAPT_RELEASE_APP_PRIVATE_KEY", + ) + }, + "openadapt-release_repository_private_key_present", + ), + ( + { + "repository_variable_shadow": ( + "OpenAdapt", + "OPENADAPT_RELEASE_APP_PRIVATE_KEY", + ) + }, + "openadapt-release_repository_private_key_variable_present", + ), + ( + { + "environment_variable_shadow": ( + "OpenAdapt", + "pypi", + "OPENADAPT_RELEASE_APP_PRIVATE_KEY", + ) + }, + "openadapt-release_environment_private_key_variable_present", + ), + ( + { + "extra_environment_secret": ( + "OpenAdapt", + "unreviewed-release", + "OPENADAPT_RELEASE_APP_PRIVATE_KEY", + ) + }, + "openadapt-release_environment_private_key_out_of_scope", + ), + ], +) +def test_private_key_metadata_must_match_exact_environment_bindings( + monkeypatch: pytest.MonkeyPatch, + fixture_kwargs: dict[str, Any], + expected_code: str, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + plan = build_plan(ReadOnlyFixtureGitHub(value, **fixture_kwargs), value) + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert plan["safe_to_apply"] is False + assert expected_code in {item["code"] for item in launcher["blockers"]} + + +@pytest.mark.parametrize( + "fixture_kwargs", + [ + {"admin_bypass_environment": ("openadapt-agent", "pypi")}, + {"omitted_admin_bypass_environment": ("openadapt-agent", "pypi")}, + {"missing_environment": ("openadapt-agent", "pypi")}, + ], +) +def test_admin_bypass_requires_one_time_ui_setup_and_emits_no_false_repair( + monkeypatch: pytest.MonkeyPatch, + fixture_kwargs: dict[str, Any], +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + plan = build_plan(ReadOnlyFixtureGitHub(value, **fixture_kwargs), value) + agent = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-agent" + ) + + assert "environment_admin_bypass_not_disabled" in { + item["code"] for item in agent["blockers"] + } + assert not any( + action["kind"] == "put_environment" and action["environment"] == "pypi" + for action in agent["actions"] + ) + + +def test_audited_main_drift_is_a_blocker(monkeypatch: pytest.MonkeyPatch) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + plan = build_plan( + ReadOnlyFixtureGitHub(value, main_drift_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 "audited_main_drift" in {item["code"] for item in flow["blockers"]} + assert "audit_snapshot_advanced" not in {item["code"] for item in flow["warnings"]} + + +def test_unprotected_existing_environment_produces_a_safe_repair_action( monkeypatch: pytest.MonkeyPatch, ) -> None: value = config() @@ -843,7 +1628,9 @@ def test_unprotected_existing_environment_produces_a_plan_instead_of_crashing( agent = next( repo for repo in plan["repositories"] if repo["name"] == "openadapt-agent" ) - assert plan["safe_to_apply"] is True + assert "environment_admin_bypass_not_disabled" not in { + item["code"] for item in agent["blockers"] + } assert any( action["kind"] == "put_environment" and action["environment"] == "pypi" for action in agent["actions"] @@ -906,7 +1693,7 @@ def test_dispatch_workflow_without_app_rejection_blocks_apply( ) 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" + "dispatch_workflow_accepts_privileged_app" } @@ -927,6 +1714,201 @@ def test_dispatch_workflow_cannot_cancel_an_active_run( } +@pytest.mark.parametrize( + ("source", "expected"), + [ + ("on:\n workflow_dispatch: {}\njobs: {}\n", {"workflow_dispatch"}), + ("on: repository_dispatch\njobs: {}\n", {"repository_dispatch"}), + ( + "on: [workflow_dispatch, repository_dispatch]\njobs: {}\n", + {"workflow_dispatch", "repository_dispatch"}, + ), + ], +) +def test_dispatch_trigger_parser_covers_yaml_forms( + source: str, expected: set[str] +) -> None: + document = _parse_workflow_document( + source, "OpenAdaptAI", "openadapt-evals", ".github/workflows/test.yml" + ) + assert _workflow_triggers(document) == expected + + +def test_malformed_workflow_yaml_blocks_apply(monkeypatch: pytest.MonkeyPatch) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/complex-visual.yml" + plan = build_plan( + ReadOnlyFixtureGitHub( + value, + workflow_overrides={ + ("openadapt-evals", path): "on: [workflow_dispatch\njobs: {}\n" + }, + ), + value, + ) + evals = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-evals" + ) + + assert plan["safe_to_apply"] is False + assert "dispatch_workflow_yaml_invalid" in { + item["code"] for item in evals["blockers"] + } + + +@pytest.mark.parametrize( + "actor_login", ["openadapt-lifecycle[bot]", "openadapt-docs[bot]"] +) +def test_non_authorized_dispatch_rejects_each_privileged_app_actor( + monkeypatch: pytest.MonkeyPatch, actor_login: str +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/complex-visual.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("openadapt-evals", path).replace( + actor_login, "ordinary-user" + ) + plan = build_plan( + ReadOnlyFixtureGitHub( + value, workflow_overrides={("openadapt-evals", path): source} + ), + value, + ) + evals = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-evals" + ) + + assert "dispatch_workflow_accepts_privileged_app" in { + item["code"] for item in evals["blockers"] + } + + +@pytest.mark.parametrize( + ("repository", "path", "token_reference", "expected_code"), + [ + ( + "openadapt-evals", + ".github/workflows/production-lifecycle-evidence.yml", + "steps.lifecycle-app.outputs.token", + "lifecycle-only_workflow_semantic_contract", + ), + ( + "openadapt-ops", + ".github/workflows/sync.yml", + "steps.docs-app.outputs.token", + "docs-only_workflow_semantic_contract", + ), + ], +) +def test_authorized_dispatch_effect_must_use_its_same_job_app_token( + monkeypatch: pytest.MonkeyPatch, + repository: str, + path: str, + token_reference: str, + expected_code: str, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content(repository, path).replace( + token_reference, "github.token" + ) + plan = build_plan( + ReadOnlyFixtureGitHub(value, workflow_overrides={(repository, path): source}), + value, + ) + target = next(repo for repo in plan["repositories"] if repo["name"] == repository) + + assert expected_code in {item["code"] for item in target["blockers"]} + + +@pytest.mark.parametrize( + ("old", "new"), + [ + ("environment: production-docs-deploy", "environment: github-pages"), + ("permission-actions: write", "permission-actions: read"), + ( + "github.actor == 'openadapt-docs[bot]'", + "github.actor == 'ordinary-user'", + ), + ], +) +def test_docs_dispatch_job_binds_actor_environment_and_app_permissions( + monkeypatch: pytest.MonkeyPatch, old: str, new: str +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/sync.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("openadapt-ops", path).replace(old, new, 1) + plan = build_plan( + ReadOnlyFixtureGitHub( + value, workflow_overrides={("openadapt-ops", path): source} + ), + value, + ) + ops = next(repo for repo in plan["repositories"] if repo["name"] == "openadapt-ops") + + assert "docs-only_workflow_semantic_contract" in { + item["code"] for item in ops["blockers"] + } + + +def test_launcher_github_release_requires_same_job_release_app_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("OpenAdapt", path) + prefix, publish = source.split(" publish-pypi:\n", 1) + publish = publish.replace("id: release_app", "id: unrelated_app", 1) + plan = build_plan( + ReadOnlyFixtureGitHub( + value, + workflow_overrides={ + ("OpenAdapt", path): prefix + " publish-pypi:\n" + publish + }, + ), + value, + ) + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert "github_release_app_token_not_bound" in { + item["code"] for item in launcher["blockers"] + } + + +def test_release_tag_push_requires_same_job_release_app_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("OpenAdapt", path).replace( + "APP_TOKEN: ${{ steps.release_app.outputs.token }}", + "APP_TOKEN: ${{ github.token }}", + 1, + ) + plan = build_plan( + ReadOnlyFixtureGitHub(value, workflow_overrides={("OpenAdapt", path): source}), + value, + ) + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert "release_tag_app_token_not_bound" in { + item["code"] for item in launcher["blockers"] + } + + def test_lifecycle_environment_reference_outside_exact_workflow_blocks_apply( monkeypatch: pytest.MonkeyPatch, ) -> None: From 02ca8cbffee77388ea5957e535c9f3d01b0059f2 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 26 Aug 2026 16:50:40 -0400 Subject: [PATCH 6/6] Close release policy fail-open paths --- ops/github/README.md | 26 +- ops/github/core-protection-policy.json | 89 ++- scripts/manage_github_protection.py | 404 +++++++++-- tests/test_manage_github_protection.py | 893 +++++++++++++++++++++++-- 4 files changed, 1294 insertions(+), 118 deletions(-) diff --git a/ops/github/README.md b/ops/github/README.md index 09211f1..c1f7348 100644 --- a/ops/github/README.md +++ b/ops/github/README.md @@ -172,6 +172,11 @@ audit checks the actor, protected environment, App inputs, token output, and sensitive effect in the same job. A matching string elsewhere in the file isn't enough. +Each App-token step names `OpenAdaptAI` and the exact repositories that its job +needs. A Release token names only its current package repository. A lifecycle +or Docs token names only its reviewed workflow scope. An omitted repository +list would grant the token its full installation scope, so the plan refuses it. + 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 @@ -221,8 +226,12 @@ Use this sequence for each package repository: Any job that creates, edits, or uploads a GitHub Release must create the Release App token in that job. Its `GH_TOKEN` must reference that exact step's output. The same job rule prevents a workflow from passing because an unrelated -job contains the right App strings. Launcher remains a plan refusal until its -release workflow follows this contract on `main`. +job contains the right App strings. Evals and every other package follow the +same matching GitHub Release rule. + +A tag push must use the App token through an authenticated checkout credential +or an explicit authenticated Git binding in the push step. An unused token +variable doesn't authorize a push. Agent uses `mcp-registry` after its PyPI publication. Both publication jobs use OIDC. The Agent release workflow can't accept an API-token fallback or @@ -276,6 +285,10 @@ complete. If an audited `main` SHA changed, review the exact current workflows and update the policy SHA before you create another plan. Drift is a refusal, not a warning. A plan expires after 15 minutes. +The audit reads each workflow and complete workflow tree by that recorded +commit SHA. It reads the branch again after those checks. A branch change +during the audit refuses the plan. + Apply that exact plan: ```bash @@ -284,10 +297,11 @@ uv run python scripts/manage_github_protection.py apply \ --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, a missing lifecycle identity, an unguarded dispatch workflow, or an -invalid workflow contract. It also refuses a missing docs identity. +The apply operation checks every `main` commit again immediately before the +first mutation. It refuses a changed commit, a changed action list, an active +pull request check, a missing release 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 ff0c728..fd65855 100644 --- a/ops/github/core-protection-policy.json +++ b/ops/github/core-protection-policy.json @@ -96,6 +96,26 @@ "openadapt-evals", "openadapt-flow" ], + "token_repository_bindings": { + "OpenAdapt": [ + "OpenAdapt" + ], + "openadapt-agent": [ + "openadapt-agent" + ], + "openadapt-capture": [ + "openadapt-capture" + ], + "openadapt-desktop": [ + "openadapt-desktop" + ], + "openadapt-evals": [ + "openadapt-evals" + ], + "openadapt-flow": [ + "openadapt-flow" + ] + }, "repository_variables": { "app_id": "OPENADAPT_RELEASE_APP_ID" }, @@ -142,6 +162,19 @@ "openadapt-evals", "openadapt-ops" ], + "token_repository_bindings": { + ".github": [ + ".github" + ], + "openadapt-evals": [ + "openadapt-evals" + ], + "openadapt-ops": [ + ".github", + "openadapt-evals", + "openadapt-ops" + ] + }, "required_repository_permissions": [ "Actions: write", "Metadata: read", @@ -212,6 +245,11 @@ "repository_scope": [ "openadapt-ops" ], + "token_repository_bindings": { + "openadapt-ops": [ + "openadapt-ops" + ] + }, "required_repository_permissions": [ "Actions: write", "Metadata: read", @@ -252,7 +290,7 @@ "name": "OpenAdapt", "visibility": "public", "default_branch": "main", - "audited_main_sha": "3098d46064c8d3e3fa1a515588487cdb724500e8", + "audited_main_sha": "f7f10f3c88e892ad880372c617c787b7bed01d9c", "require_code_owner_review": true, "required_checks": [ "Analyze (python)", @@ -297,6 +335,10 @@ "release_workflows": [ { "path": ".github/workflows/release-and-publish.yml", + "required_effects": [ + "tag-push", + "github-release" + ], "required_patterns": [ "(?m)^[ ]{2}workflow_dispatch:\\s*$", "(?m)^[ ]{2}push:\\s*$", @@ -319,7 +361,7 @@ "scripts/check_source_boundary\\.py", "scripts/verify_release_artifacts\\.py", "scripts/validate_platform_manifest\\.py", - "GH_TOKEN:\\s*\\$\\{\\{\\s*steps\\.release_app\\.outputs\\.token\\s*\\}\\}", + "GH_TOKEN:\\s*\\$\\{\\{\\s*steps\\.release-app\\.outputs\\.token\\s*\\}\\}", "gh\\s+release\\s+(?:create|edit|upload)" ], "forbidden_patterns": [ @@ -392,6 +434,9 @@ "release_workflows": [ { "path": ".github/workflows/release.yml", + "required_effects": [ + "tag-push" + ], "required_patterns": [ "(?m)^[ ]{2}workflow_dispatch:\\s*$", "(?m)^[ ]{2}push:\\s*$", @@ -431,7 +476,7 @@ "name": "openadapt-flow", "visibility": "public", "default_branch": "main", - "audited_main_sha": "fa49a5de54a749ebf00621b0e4d38a2b439bb058", + "audited_main_sha": "ba127f3a3637a794355a2fef2b381383e5d0be40", "require_code_owner_review": true, "required_checks": [ "docs-consistency", @@ -480,6 +525,9 @@ "release_workflows": [ { "path": ".github/workflows/release.yml", + "required_effects": [ + "tag-push" + ], "required_patterns": [ "(?m)^[ ]{2}workflow_dispatch:\\s*$", "(?m)^[ ]{2}push:\\s*$", @@ -517,7 +565,7 @@ "name": "openadapt-capture", "visibility": "public", "default_branch": "main", - "audited_main_sha": "681683e73be13ce75ee3e8c720cd8ce7fbd7c76a", + "audited_main_sha": "5ed28e6c63e70cdedf32c760f0272841adb1063b", "require_code_owner_review": true, "required_checks": [ "Analyze (javascript-typescript)", @@ -562,6 +610,9 @@ "release_workflows": [ { "path": ".github/workflows/release.yml", + "required_effects": [ + "tag-push" + ], "required_patterns": [ "(?m)^[ ]{2}workflow_dispatch:\\s*$", "(?m)^[ ]{2}push:\\s*$", @@ -596,7 +647,7 @@ "name": "openadapt-desktop", "visibility": "public", "default_branch": "main", - "audited_main_sha": "a73c3faf90f5d0b766ea7156a057bd6240d1c2bb", + "audited_main_sha": "a363865c4d375786628063a04f83d74f2b413f2c", "require_code_owner_review": false, "required_checks": [ "Analyze (javascript-typescript)", @@ -662,6 +713,10 @@ "release_workflows": [ { "path": ".github/workflows/release.yml", + "required_effects": [ + "tag-push", + "github-release" + ], "required_patterns": [ "(?m)^[ ]{2}workflow_dispatch:\\s*$", "(?m)^[ ]{2}push:\\s*$", @@ -696,6 +751,7 @@ }, { "path": ".github/workflows/native-freshness.yml", + "required_effects": [], "required_patterns": [ "(?m)^[ ]{2}workflow_dispatch:\\s*$", "(?m)^[ ]{2}release:\\s*$", @@ -715,6 +771,10 @@ }, { "path": ".github/workflows/native-release.yml", + "required_effects": [ + "tag-push", + "github-release" + ], "required_patterns": [ "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?release-identity\\s*$", "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?native-release\\s*$", @@ -750,6 +810,10 @@ }, { "path": ".github/workflows/ffmpeg-runtime.yml", + "required_effects": [ + "tag-push", + "github-release" + ], "required_patterns": [ "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?release-identity\\s*$", "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?native-release\\s*$", @@ -786,7 +850,7 @@ "name": "openadapt-evals", "visibility": "public", "default_branch": "main", - "audited_main_sha": "55d16ed04561b0b78c63176cac90b73dbc0cdfad", + "audited_main_sha": "ef55e01ebe08382c9a05675d491dfbc297ed6d5e", "require_code_owner_review": false, "required_checks": [ "test" @@ -838,6 +902,10 @@ "release_workflows": [ { "path": ".github/workflows/release.yml", + "required_effects": [ + "tag-push", + "github-release" + ], "required_patterns": [ "(?m)^[ ]{2}workflow_dispatch:\\s*$", "(?m)^[ ]{2}push:\\s*$", @@ -858,7 +926,9 @@ "pypa/gh-action-pypi-publish", "skip-existing:\\s*true", "scripts/verify_release_lock\\.py", - "scripts/check_source_boundary\\.py\\s+--require-dist" + "scripts/check_source_boundary\\.py\\s+--require-dist", + "GH_TOKEN:\\s*\\$\\{\\{\\s*steps\\.release-app\\.outputs\\.token\\s*\\}\\}", + "gh\\s+release\\s+(?:create|edit|upload)" ], "forbidden_patterns": [ "ADMIN_TOKEN", @@ -919,7 +989,7 @@ "local_repository_name": "openadapt-maintenance", "visibility": "public", "default_branch": "main", - "audited_main_sha": "789345539c5a76a9fc1de21b79bb7189c266f870", + "audited_main_sha": "b0327de8019db6a925104148a094f6f0756b90d2", "require_code_owner_review": true, "required_checks": [ "Analyze (python)", @@ -984,6 +1054,7 @@ "release_workflows": [ { "path": ".github/workflows/sync.yml", + "required_effects": [], "required_patterns": [ "(?m)^[ ]{2}workflow_dispatch:\\s*$", "(?m)^\\s*environment:\\s*(?:\\n\\s*name:\\s*)?github-pages\\s*$", @@ -1128,7 +1199,7 @@ "name": "openadapt-web", "visibility": "public", "default_branch": "main", - "audited_main_sha": "0f2c1dff2441d4bac5eb12a9e48b5d05d651f64b", + "audited_main_sha": "ab823011683b8aff035ce5edb62e50f6ef990e1b", "require_code_owner_review": true, "required_checks": [ "Analyze (javascript-typescript)", diff --git a/scripts/manage_github_protection.py b/scripts/manage_github_protection.py index 3e3c5df..6c62f29 100755 --- a/scripts/manage_github_protection.py +++ b/scripts/manage_github_protection.py @@ -65,6 +65,22 @@ }, "openadapt-docs": {"openadapt-ops": ["production-docs-deploy"]}, } +EXPECTED_TOKEN_REPOSITORY_BINDINGS = { + "openadapt-release": { + "OpenAdapt": ["OpenAdapt"], + "openadapt-agent": ["openadapt-agent"], + "openadapt-capture": ["openadapt-capture"], + "openadapt-desktop": ["openadapt-desktop"], + "openadapt-evals": ["openadapt-evals"], + "openadapt-flow": ["openadapt-flow"], + }, + "openadapt-lifecycle": { + ".github": [".github"], + "openadapt-evals": ["openadapt-evals"], + "openadapt-ops": [".github", "openadapt-evals", "openadapt-ops"], + }, + "openadapt-docs": {"openadapt-ops": ["openadapt-ops"]}, +} class PolicyError(RuntimeError): @@ -285,6 +301,11 @@ def validate_config(config: Mapping[str, Any]) -> None: != (EXPECTED_PRIVATE_KEY_BINDINGS["openadapt-release"]) ): raise PolicyError("release_identity private-key bindings are not exact") + if ( + release_identity.get("token_repository_bindings") + != EXPECTED_TOKEN_REPOSITORY_BINDINGS["openadapt-release"] + ): + raise PolicyError("release_identity token repository bindings are not exact") lifecycle_identity = config.get("lifecycle_identity") if not isinstance(lifecycle_identity, Mapping): @@ -362,6 +383,11 @@ def validate_config(config: Mapping[str, Any]) -> None: != (EXPECTED_PRIVATE_KEY_BINDINGS["openadapt-lifecycle"]) ): raise PolicyError("lifecycle_identity private-key bindings are not exact") + if ( + lifecycle_identity.get("token_repository_bindings") + != EXPECTED_TOKEN_REPOSITORY_BINDINGS["openadapt-lifecycle"] + ): + raise PolicyError("lifecycle_identity token repository bindings 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") @@ -417,6 +443,11 @@ def validate_config(config: Mapping[str, Any]) -> None: != (EXPECTED_PRIVATE_KEY_BINDINGS["openadapt-docs"]) ): raise PolicyError("docs_identity private-key bindings are not exact") + if ( + docs_identity.get("token_repository_bindings") + != EXPECTED_TOKEN_REPOSITORY_BINDINGS["openadapt-docs"] + ): + raise PolicyError("docs_identity token repository bindings 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): @@ -526,6 +557,15 @@ def validate_config(config: Mapping[str, Any]) -> None: workflows = _require_list( repo.get("release_workflows"), f"{name}.release_workflows" ) + for workflow in workflows: + effects = _require_list( + workflow.get("required_effects"), + f"{name}.{workflow.get('path')}.required_effects", + ) + if any( + effect not in {"tag-push", "github-release"} for effect in effects + ) or len(effects) != len(set(effects)): + raise PolicyError(f"{name}: release workflow effects are invalid") requires_release_identity = any( "release-identity" in pattern for workflow in workflows @@ -1147,11 +1187,12 @@ def _normalize_environment(value: Mapping[str, Any]) -> dict[str, Any]: def _workflow_text( - client: GitHubClient, owner: str, repo: str, path: str + client: GitHubClient, owner: str, repo: str, path: str, ref: str ) -> str | None: encoded_path = quote(path, safe="/") response = client.get( - f"/repos/{owner}/{repo}/contents/{encoded_path}?ref=main", optional=True + f"/repos/{owner}/{repo}/contents/{encoded_path}?ref={quote(ref, safe='')}", + optional=True, ) if not isinstance(response, Mapping) or response.get("type") != "file": return None @@ -1240,8 +1281,17 @@ def _references_expression(value: Any, reference: str) -> bool: return isinstance(value, str) and reference in re.sub(r"\s+", "", value) +def _is_exact_expression(value: Any, reference: str) -> bool: + return ( + isinstance(value, str) and re.sub(r"\s+", "", value) == f"${{{{{reference}}}}}" + ) + + def _matching_app_token_step_ids( - job: Mapping[str, Any], identity: Mapping[str, Any] + job: Mapping[str, Any], + identity: Mapping[str, Any], + owner: str, + repository_name: str, ) -> set[str]: required_permissions = { permission.split(":", 1)[0].strip().lower().replace(" ", "-"): permission.split( @@ -1254,6 +1304,7 @@ def _matching_app_token_step_ids( } app_id_name = identity["repository_variables"]["app_id"] private_key_name = identity["private_key_secret"] + expected_repositories = set(identity["token_repository_bindings"][repository_name]) matching: set[str] = set() for step in _job_steps(job): uses = step.get("uses") @@ -1267,12 +1318,30 @@ def _matching_app_token_step_ids( or not step_id ): continue - if not _references_expression(inputs.get("app-id"), f"vars.{app_id_name}"): + if not _is_exact_expression(inputs.get("app-id"), f"vars.{app_id_name}"): continue - if not _references_expression( + if not _is_exact_expression( inputs.get("private-key"), f"secrets.{private_key_name}" ): continue + token_owner = inputs.get("owner") + if token_owner != owner and not _is_exact_expression( + token_owner, "github.repository_owner" + ): + continue + repositories_input = inputs.get("repositories") + if expected_repositories == {repository_name} and _is_exact_expression( + repositories_input, "github.event.repository.name" + ): + token_repositories = {repository_name} + elif isinstance(repositories_input, str) and "${{" not in repositories_input: + token_repositories = { + item for item in re.split(r"[\s,]+", repositories_input.strip()) if item + } + else: + continue + if token_repositories != expected_repositories: + continue if any( inputs.get(f"permission-{name}") != level for name, level in required_permissions.items() @@ -1294,11 +1363,152 @@ def _step_uses_token(step: Mapping[str, Any], references: set[str]) -> bool: ) +def _shell_command_match(run: str, command_pattern: str) -> re.Match[str] | None: + match = re.search(rf"(?m)^[ \t]*{command_pattern}", run) + if match is None: + return None + line_end = run.find("\n", match.start()) + line = run[match.start() :] if line_end == -1 else run[match.start() : line_end] + # A line continuation can move a fail-open operator to a later physical line. + # Required release effects stay on one physical line so the complete command + # is available to this validator. + if re.search(r"\\\s*(?:#.*)?$", line): + return None + if re.search(r"\|\||;|(? bool: + uses = step.get("uses") + inputs = step.get("with") + if ( + not isinstance(uses, str) + or not uses.startswith("actions/checkout@") + or not isinstance(inputs, Mapping) + ): + return False + if not any( + _is_exact_expression(inputs.get("token"), reference) for reference in references + ): + return False + persist_credentials = inputs.get("persist-credentials", True) + return ( + persist_credentials is not False and str(persist_credentials).lower() != "false" + ) + + +def _push_step_binds_app_token(step: Mapping[str, Any], references: set[str]) -> bool: + run = step.get("run") + environment = step.get("env") + if not isinstance(run, str) or not isinstance(environment, Mapping): + return False + token_names = { + name + for name, value in environment.items() + if isinstance(name, str) + and any(_is_exact_expression(value, reference) for reference in references) + } + if not token_names: + return False + push = _shell_command_match(run, r"git\s+push\b") + if push is None: + return False + binding = run[: push.start()] + if re.search(r"(?m)^\s*set\s+-euo\s+pipefail\s*$", binding) is None: + return False + key_indexes = set( + re.findall( + r"GIT_CONFIG_KEY_([0-9]+)=['\"]?http\.https://github\.com/\.extraheader", + binding, + ) + ) + if not key_indexes: + return False + for token_name in token_names: + if re.search( + rf"(?m)^\s*(?:(?:export|readonly)\s+)?{re.escape(token_name)}\s*=", + binding, + ): + continue + token_reference = rf'["\']?\$\{{?{re.escape(token_name)}\}}?["\']?' + assignment = re.search( + rf"(?m)^\s*([A-Za-z_][A-Za-z0-9_]*)=\$\(\s*printf\s+" + rf"['\"]x-access-token:%s['\"]\s+{token_reference}\s*\|" + rf"[^;\n]*\bbase64\b[^;\n]*\)\s*$", + binding, + ) + if assignment is None: + continue + auth_variable = assignment.group(1) + for index in key_indexes: + if re.search( + rf"GIT_CONFIG_VALUE_{index}=['\"]?AUTHORIZATION:\s*basic\s+" + rf"\$\{{?{re.escape(auth_variable)}\}}?", + binding, + flags=re.IGNORECASE, + ): + return True + return False + + +def _step_mutates_git_auth(step: Mapping[str, Any]) -> bool: + run = step.get("run") + return ( + isinstance(run, str) + and re.search( + r"\bgit\s+remote\s+(?:add|remove|rename|set-url)\b|" + r"\bgit\s+config\b[^\n]*(?:extraheader|credential\.helper)|" + r"\bGIT_CONFIG_(?:COUNT|KEY_[0-9]+|VALUE_[0-9]+)\b|" + r"\bunset\b[^\n]*(?:TOKEN|AUTH|GIT_CONFIG)", + run, + flags=re.IGNORECASE, + ) + is not None + ) + + +def _tag_pushes_use_app_token(job: Mapping[str, Any], token_step_ids: set[str]) -> bool: + available_references: set[str] = set() + persisted_app_credential = False + saw_push = False + for step in _job_steps(job): + step_id = step.get("id") + if isinstance(step_id, str) and step_id in token_step_ids: + available_references.add(f"steps.{step_id}.outputs.token") + + uses = step.get("uses") + if isinstance(uses, str) and uses.startswith("actions/checkout@"): + persisted_app_credential = _checkout_persists_app_token( + step, available_references + ) + elif persisted_app_credential and isinstance(uses, str): + persisted_app_credential = False + + run = step.get("run") + mutates_persisted_auth = _step_mutates_git_auth(step) + if ( + not isinstance(run, str) + or _shell_command_match(run, r"git\s+push\b") is None + ): + if persisted_app_credential and mutates_persisted_auth: + persisted_app_credential = False + continue + saw_push = True + direct_binding = _push_step_binds_app_token(step, available_references) + if ( + not persisted_app_credential or mutates_persisted_auth + ) and not direct_binding: + return False + return saw_push + + def _release_workflow_semantic_blockers( client: GitHubClient, owner: str, repo: Mapping[str, Any], identity: Mapping[str, Any], + ref: str, ) -> list[dict[str, str]]: if repo["name"] not in identity["repository_scope"]: return [] @@ -1308,7 +1518,10 @@ def _release_workflow_semantic_blockers( blockers: list[dict[str, str]] = [] for workflow in repo.get("release_workflows", []): path = workflow["path"] - content = _workflow_text(client, owner, repo["name"], path) + required_effects = set(workflow["required_effects"]) + requires_tag_effect = "tag-push" in required_effects + requires_release_effect = "github-release" in required_effects + content = _workflow_text(client, owner, repo["name"], path, ref) if content is None: continue try: @@ -1316,22 +1529,30 @@ def _release_workflow_semantic_blockers( except PolicyError: continue jobs = document.get("jobs", {}) + saw_tag_effect = False + saw_release_effect = False for job_name, job in jobs.items(): if not isinstance(job_name, str) or not isinstance(job, Mapping): continue - job_text = "\n".join(_all_strings(job)) + run_steps = [ + step["run"] + for step in _job_steps(job) + if isinstance(step.get("run"), str) + ] has_tag_effect = bool( - re.search(r"\bgit\s+tag\b", job_text) - and re.search(r"\bgit\s+push\b", job_text) + any(_shell_command_match(run, r"git\s+tag\b") for run in run_steps) + and any(_shell_command_match(run, r"git\s+push\b") for run in run_steps) ) release_steps = [ step for step in _job_steps(job) - if re.search( - r"\bgh\s+release\s+(?:create|edit|upload)\b", - "\n".join(_all_strings(step)), + if isinstance(step.get("run"), str) + if _shell_command_match( + step["run"], r"gh\s+release\s+(?:create|edit|upload)\b" ) ] + saw_tag_effect = saw_tag_effect or has_tag_effect + saw_release_effect = saw_release_effect or bool(release_steps) if not has_tag_effect and not release_steps: continue environment = _job_environment(job) @@ -1345,7 +1566,9 @@ def _release_workflow_semantic_blockers( ), } ) - token_step_ids = _matching_app_token_step_ids(job, identity) + token_step_ids = _matching_app_token_step_ids( + job, identity, owner, repo["name"] + ) token_references = _token_references(token_step_ids) if not token_step_ids: blockers.append( @@ -1358,11 +1581,7 @@ def _release_workflow_semantic_blockers( } ) continue - if has_tag_effect and not any( - _references_expression(value, reference) - for value in _all_strings(job) - for reference in token_references - ): + if has_tag_effect and not _tag_pushes_use_app_token(job, token_step_ids): blockers.append( { "code": "release_tag_app_token_not_bound", @@ -1380,7 +1599,7 @@ def _release_workflow_semantic_blockers( else None ) if not any( - _references_expression(gh_token, reference) + _is_exact_expression(gh_token, reference) for reference in token_references ): blockers.append( @@ -1393,6 +1612,26 @@ def _release_workflow_semantic_blockers( ), } ) + if requires_tag_effect and not saw_tag_effect: + blockers.append( + { + "code": "release_tag_effect_missing", + "message": ( + f"{repo['name']}:{path}: no run step creates and pushes the " + "release tag." + ), + } + ) + if requires_release_effect and not saw_release_effect: + blockers.append( + { + "code": "github_release_effect_missing", + "message": ( + f"{repo['name']}:{path}: no run step creates, edits, or uploads " + "the GitHub Release." + ), + } + ) return blockers @@ -1402,11 +1641,12 @@ def _workflow_contract_blockers( repo: Mapping[str, Any], contract_field: str, code_prefix: str, + ref: 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) + content = _workflow_text(client, owner, repo["name"], path, ref) if content is None: blockers.append( { @@ -1662,6 +1902,7 @@ def _exclusive_environment_blockers( client: GitHubClient, owner: str, repo: Mapping[str, Any], + ref: str, ) -> list[dict[str, str]]: environments = repo.get("lifecycle_environments", []) + [ item @@ -1670,7 +1911,9 @@ def _exclusive_environment_blockers( ] if not environments: return [] - tree = client.get(f"/repos/{owner}/{repo['name']}/git/trees/main?recursive=1") + tree = client.get( + f"/repos/{owner}/{repo['name']}/git/trees/{quote(ref, safe='')}?recursive=1" + ) if not isinstance(tree, Mapping) or tree.get("truncated"): raise GitHubError( f"{owner}/{repo['name']}: complete workflow tree is not available" @@ -1684,7 +1927,7 @@ def _exclusive_environment_blockers( and item["path"].endswith((".yml", ".yaml")) ) content_by_path = { - path: _workflow_text(client, owner, repo["name"], path) + path: _workflow_text(client, owner, repo["name"], path, ref) for path in workflow_paths } blockers: list[dict[str, str]] = [] @@ -1709,27 +1952,61 @@ def _exclusive_environment_blockers( return blockers +def _conjunctive_clauses(expression: str) -> list[str]: + normalized = expression.strip() + if normalized.startswith("${{") and normalized.endswith("}}"): + normalized = normalized[3:-2].strip() + if re.search(r"\|\||\bor\b", normalized, flags=re.IGNORECASE): + return [] + return [part.strip() for part in normalized.split("&&")] + + +def _has_exact_clause(clauses: Iterable[str], pattern: str) -> bool: + return any(re.fullmatch(pattern, clause) is not None for clause in clauses) + + def _actor_rejection_failures(expression: str) -> list[str]: failures: list[str] = [] + clauses = _conjunctive_clauses(expression) + if not clauses: + failures.append("non-conjunctive-condition") for actor_login in ("openadapt-lifecycle[bot]", "openadapt-docs[bot]"): escaped = re.escape(actor_login) for field in ("actor", "triggering_actor"): - if ( - re.search(rf"github\.{field}\s*!=\s*['\"]{escaped}['\"]", expression) - is None + if not _has_exact_clause( + clauses, rf"github\.{field}\s*!=\s*['\"]{escaped}['\"]" ): failures.append(f"{field}:{actor_login}") return failures def _guard_actor_rejection_failures(guard: Mapping[str, Any]) -> list[str]: - direct = _actor_rejection_failures("\n".join(_all_strings(guard))) + direct = _actor_rejection_failures(str(guard.get("if", ""))) unresolved = set(direct) for step in _job_steps(guard): environment = step.get("env", {}) run = step.get("run") if not isinstance(environment, Mapping) or not isinstance(run, str): continue + if re.search(r"\|\||\bor\b|;\s*true\b", run, flags=re.IGNORECASE): + unresolved.add("non-conjunctive-condition") + run_lines = [line.strip() for line in run.splitlines() if line.strip()] + test_commands: list[tuple[str, str]] = [] + invalid_command = False + for line in run_lines: + if line == "set -euo pipefail": + continue + match = re.fullmatch( + r"test\s+['\"]?\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?['\"]?\s*" + r"!=\s*['\"]([^'\"]+)['\"]", + line, + ) + if match is None: + invalid_command = True + break + test_commands.append((match.group(1), match.group(2))) + if invalid_command or not test_commands: + continue for variable_name, value in environment.items(): if not isinstance(variable_name, str) or not isinstance(value, str): continue @@ -1744,10 +2021,7 @@ def _guard_actor_rejection_failures(guard: Mapping[str, Any]) -> list[str]: if field is None: continue for actor_login in ("openadapt-lifecycle[bot]", "openadapt-docs[bot]"): - if re.search( - rf"\$\{{?{re.escape(variable_name)}\}}?\"?\s*!=\s*['\"]{re.escape(actor_login)}['\"]", - run, - ): + if (variable_name, actor_login) in test_commands: unresolved.discard(f"{field}:{actor_login}") return sorted(unresolved) @@ -1772,13 +2046,12 @@ def _job_actor_rejection_failures(document: Mapping[str, Any]) -> list[str]: failures.append("") continue expression = str(job.get("if", "")) - tag_only = ( - re.search(r"github\.event_name\s*==\s*['\"]push['\"]", expression) - is not None - and "refs/tags/" in expression - and "workflow_dispatch" not in expression - and "repository_dispatch" not in expression - and "||" not in expression + clauses = _conjunctive_clauses(expression) + tag_only = _has_exact_clause( + clauses, r"github\.event_name\s*==\s*['\"]push['\"]" + ) and _has_exact_clause( + clauses, + r"startsWith\(\s*github\.ref\s*,\s*['\"]refs/tags/[^'\"]*['\"]\s*\)", ) if tag_only: continue @@ -1834,6 +2107,9 @@ def _authorized_dispatch_semantic_failures( failures: list[str] = [] for job_name, job in candidates: expression = str(job.get("if", "")) + clauses = _conjunctive_clauses(expression) + if not clauses: + failures.append(f"{job_name}:non-conjunctive-condition") required_conditions = { "repository": rf"github\.repository\s*==\s*['\"]{re.escape(owner)}/{re.escape(repo['name'])}['\"]", "ref": r"github\.ref\s*==\s*['\"]refs/heads/main['\"]", @@ -1843,11 +2119,13 @@ def _authorized_dispatch_semantic_failures( "actor_id": rf"github\.actor_id\s*==\s*vars\.{re.escape(actor_id_variable)}", } for label, pattern in required_conditions.items(): - if re.search(pattern, expression) is None: + if not _has_exact_clause(clauses, pattern): failures.append(f"{job_name}:{label}") if _job_environment(job) != expected_environment: failures.append(f"{job_name}:environment") - token_step_ids = _matching_app_token_step_ids(job, identity) + token_step_ids = _matching_app_token_step_ids( + job, identity, owner, repo["name"] + ) if not token_step_ids: failures.append(f"{job_name}:app-token") continue @@ -1864,7 +2142,7 @@ def _authorized_dispatch_semantic_failures( else None ) if not any( - _references_expression(gh_token, reference) + _is_exact_expression(gh_token, reference) for reference in token_references ): failures.append(f"{job_name}:effect-token") @@ -1881,6 +2159,7 @@ def _dispatch_workflow_blockers( repo: Mapping[str, Any], lifecycle_identity: Mapping[str, Any], docs_identity: Mapping[str, Any], + ref: str, ) -> list[dict[str, str]]: inventory = { item["path"]: item["mode"] @@ -1888,7 +2167,9 @@ def _dispatch_workflow_blockers( } if not inventory: return [] - tree = client.get(f"/repos/{owner}/{repo['name']}/git/trees/main?recursive=1") + tree = client.get( + f"/repos/{owner}/{repo['name']}/git/trees/{quote(ref, safe='')}?recursive=1" + ) if not isinstance(tree, Mapping) or tree.get("truncated"): raise GitHubError( f"{owner}/{repo['name']}: complete dispatch workflow tree is not available" @@ -1903,7 +2184,7 @@ def _dispatch_workflow_blockers( ) blockers: list[dict[str, str]] = [] for path in workflow_paths: - content = _workflow_text(client, owner, repo["name"], path) + content = _workflow_text(client, owner, repo["name"], path, ref) if content is None: continue try: @@ -2159,6 +2440,11 @@ def build_plan(client: GitHubClient, config: Mapping[str, Any]) -> dict[str, Any ) commit = client.get(f"/repos/{owner}/{name}/commits/{repo['default_branch']}") main_sha = commit.get("sha") + if ( + not isinstance(main_sha, str) + or re.fullmatch(r"[0-9a-f]{40}", main_sha) is None + ): + raise GitHubError(f"{name}: default branch did not resolve to a commit SHA") if main_sha != repo["audited_main_sha"]: blockers.append( { @@ -2190,22 +2476,22 @@ def build_plan(client: GitHubClient, config: Mapping[str, Any]) -> dict[str, Any blockers.extend( _workflow_contract_blockers( - client, owner, repo, "release_workflows", "release" + client, owner, repo, "release_workflows", "release", main_sha ) ) blockers.extend( _workflow_contract_blockers( - client, owner, repo, "admission_workflows", "admission" + client, owner, repo, "admission_workflows", "admission", main_sha ) ) blockers.extend( _workflow_contract_blockers( - client, owner, repo, "lifecycle_workflows", "lifecycle" + client, owner, repo, "lifecycle_workflows", "lifecycle", main_sha ) ) blockers.extend( _release_workflow_semantic_blockers( - client, owner, repo, config["release_identity"] + client, owner, repo, config["release_identity"], main_sha ) ) blockers.extend( @@ -2249,7 +2535,7 @@ def build_plan(client: GitHubClient, config: Mapping[str, Any]) -> dict[str, Any metadata.get("id"), ) ) - blockers.extend(_exclusive_environment_blockers(client, owner, repo)) + blockers.extend(_exclusive_environment_blockers(client, owner, repo, main_sha)) blockers.extend( _dispatch_workflow_blockers( client, @@ -2257,6 +2543,7 @@ def build_plan(client: GitHubClient, config: Mapping[str, Any]) -> dict[str, Any repo, config["lifecycle_identity"], config["docs_identity"], + main_sha, ) ) current_rulesets = _list_rulesets(client, owner, name) @@ -2286,9 +2573,24 @@ def build_plan(client: GitHubClient, config: Mapping[str, Any]) -> dict[str, Any ) blockers.extend(environment_blockers) actions.extend(environment_actions) + final_commit = client.get( + f"/repos/{owner}/{name}/commits/{repo['default_branch']}" + ) + final_main_sha = final_commit.get("sha") + if final_main_sha != main_sha: + blockers.append( + { + "code": "audited_main_changed_during_plan", + "message": ( + f"{name}: {repo['default_branch']} changed from {main_sha} " + f"to {final_main_sha} during the audit. Create a new plan." + ), + } + ) repositories.append( { "name": name, + "default_branch": repo["default_branch"], "main_sha": main_sha, "audited_main_sha": repo["audited_main_sha"], "open_pull_requests": pulls, @@ -2390,6 +2692,14 @@ def _apply_actions( "the plan removes environment policies; inspect it and add " "--prune-environment-policies" ) + for repo in plan["repositories"]: + name = repo["name"] + branch = repo.get("default_branch", "main") + current = client.get(f"/repos/{owner}/{name}/commits/{branch}") + if current.get("sha") != repo.get("main_sha"): + raise PolicyError( + f"{name}: {branch} changed after preflight; no protection mutation was sent" + ) for repo in plan["repositories"]: name = repo["name"] for action in repo["actions"]: diff --git a/tests/test_manage_github_protection.py b/tests/test_manage_github_protection.py index 23b0740..dec8394 100644 --- a/tests/test_manage_github_protection.py +++ b/tests/test_manage_github_protection.py @@ -59,6 +59,7 @@ def __init__( admin_bypass_environment: tuple[str, str] | None = None, omitted_admin_bypass_environment: tuple[str, str] | None = None, main_drift_repo: str | None = None, + advance_main_during_plan_repo: str | None = None, workflow_overrides: Mapping[tuple[str, str], str] | None = None, ) -> None: self.config = config @@ -84,8 +85,11 @@ def __init__( self.admin_bypass_environment = admin_bypass_environment self.omitted_admin_bypass_environment = omitted_admin_bypass_environment self.main_drift_repo = main_drift_repo + self.advance_main_during_plan_repo = advance_main_during_plan_repo self.workflow_overrides = dict(workflow_overrides or {}) self.writes: list[tuple[str, str, Mapping[str, Any]]] = [] + self.gets: list[str] = [] + self.main_reads: dict[str, int] = {} self.by_name = {repo["name"]: repo for repo in config["repositories"]} self.repository_ids = { repo["name"]: 1000 + index @@ -93,6 +97,7 @@ def __init__( } def get(self, path: str, *, optional: bool = False) -> Any: + self.gets.append(path) if path == "/apps/openadapt-release": return {"id": 991122, "slug": "openadapt-release"} if path == "/apps/openadapt-lifecycle": @@ -213,10 +218,15 @@ def get(self, path: str, *, optional: bool = False) -> Any: "default_branch": "main", } if parts[4] == "commits" and parts[5] == "main": + self.main_reads[name] = self.main_reads.get(name, 0) + 1 return { "sha": ( "f" * 40 if name == self.main_drift_repo + or ( + name == self.advance_main_during_plan_repo + and self.main_reads[name] > 1 + ) else repo["audited_main_sha"] ) } @@ -448,12 +458,22 @@ def _workflow_content(self, repo_name: str, path: str) -> str: job_id, job_name = job_names[path] projection_inputs = "" projection_conditions = "" + lifecycle_token_repositories = ( + " repositories: |\n" + " .github\n" + " openadapt-evals\n" + " openadapt-ops\n" + if repo_name == "openadapt-ops" + else f" repositories: {repo_name}\n" + ) projection_steps = ( " - uses: actions/create-github-app-token@deadbeef\n" " id: lifecycle-app\n" " with:\n" " app-id: ${{ vars.OPENADAPT_LIFECYCLE_APP_ID }}\n" " private-key: ${{ secrets.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY }}\n" + " owner: OpenAdaptAI\n" + f"{lifecycle_token_repositories}" " permission-actions: write\n" " permission-metadata: read\n" " permission-pull-requests: write\n" @@ -573,6 +593,8 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " with:\n" " app-id: ${{ vars.OPENADAPT_DOCS_APP_ID }}\n" " private-key: ${{ secrets.OPENADAPT_DOCS_APP_PRIVATE_KEY }}\n" + " owner: OpenAdaptAI\n" + " repositories: openadapt-ops\n" " permission-actions: write\n" " permission-metadata: read\n" " permission-pull-requests: write\n" @@ -625,11 +647,11 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " - env:\n" " ACTOR: ${{ github.actor }}\n" " TRIGGERING_ACTOR: ${{ github.triggering_actor }}\n" - " run: >-\n" - " test \"$ACTOR\" != 'openadapt-lifecycle[bot]' -a\n" - " \"$TRIGGERING_ACTOR\" != 'openadapt-lifecycle[bot]' -a\n" - " \"$ACTOR\" != 'openadapt-docs[bot]' -a\n" - " \"$TRIGGERING_ACTOR\" != 'openadapt-docs[bot]'\n" + " run: |\n" + " test \"$ACTOR\" != 'openadapt-lifecycle[bot]'\n" + " test \"$TRIGGERING_ACTOR\" != 'openadapt-lifecycle[bot]'\n" + " test \"$ACTOR\" != 'openadapt-docs[bot]'\n" + " test \"$TRIGGERING_ACTOR\" != 'openadapt-docs[bot]'\n" if guarded else "" ) @@ -655,12 +677,17 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " with:\n" " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " owner: OpenAdaptAI\n" + " repositories: openadapt-evals\n" " permission-contents: write\n" " - run: test OpenAdaptAI/openadapt-evals = OpenAdaptAI/openadapt-evals && test refs/heads/main = refs/heads/main\n" " - run: python scripts/verify_release_lock.py\n" - " - env:\n" - " APP_TOKEN: ${{ steps.release_app.outputs.token }}\n" - ' run: git tag -a v1.2.3 -m release && git push origin "refs/tags/v1.2.3"\n' + " - uses: actions/checkout@deadbeef\n" + " with:\n" + " token: ${{ steps.release_app.outputs.token }}\n" + " - run: |\n" + " git tag -a v1.2.3 -m release\n" + ' git push origin "refs/tags/v1.2.3"\n' " publish-pypi:\n" " needs: authorize-release-tag\n" " if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && github.actor == 'openadapt-release[bot]'\n" @@ -673,6 +700,17 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " - uses: pypa/gh-action-pypi-publish@deadbeef\n" " with:\n" " skip-existing: true\n" + " - uses: actions/create-github-app-token@deadbeef\n" + " id: release-app\n" + " with:\n" + " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" + " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " owner: OpenAdaptAI\n" + " repositories: openadapt-evals\n" + " permission-contents: write\n" + " - env:\n" + " GH_TOKEN: ${{ steps.release-app.outputs.token }}\n" + " run: gh release create v1.2.3\n" ) extra = "" if path == ".github/workflows/profile-consistency.yml": @@ -720,11 +758,11 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " - env:\n" " ACTOR: ${{ github.actor }}\n" " TRIGGERING_ACTOR: ${{ github.triggering_actor }}\n" - " run: >-\n" - " test \"$ACTOR\" != 'openadapt-lifecycle[bot]' -a\n" - " \"$TRIGGERING_ACTOR\" != 'openadapt-lifecycle[bot]' -a\n" - " \"$ACTOR\" != 'openadapt-docs[bot]' -a\n" - " \"$TRIGGERING_ACTOR\" != 'openadapt-docs[bot]'\n" + " run: |\n" + " test \"$ACTOR\" != 'openadapt-lifecycle[bot]'\n" + " test \"$TRIGGERING_ACTOR\" != 'openadapt-lifecycle[bot]'\n" + " test \"$ACTOR\" != 'openadapt-docs[bot]'\n" + " test \"$TRIGGERING_ACTOR\" != 'openadapt-docs[bot]'\n" if guarded else "" ) @@ -766,16 +804,21 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " environment: release-identity\n" " steps:\n" " - uses: actions/create-github-app-token@deadbeef\n" - " id: release_app\n" + " id: release-app\n" " with:\n" " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " owner: OpenAdaptAI\n" + " repositories: OpenAdapt\n" " permission-contents: write\n" " - run: test refs/heads/main = refs/heads/main && test OpenAdaptAI/OpenAdapt = OpenAdaptAI/OpenAdapt\n" " - run: test \"$GITHUB_ACTOR\" = 'openadapt-release[bot]'\n" - " - env:\n" - " APP_TOKEN: ${{ steps.release_app.outputs.token }}\n" - ' run: git tag -a v1.2.3 -m release && git push origin "refs/tags/v1.2.3"\n' + " - uses: actions/checkout@deadbeef\n" + " with:\n" + " token: ${{ steps.release-app.outputs.token }}\n" + " - run: |\n" + " git tag -a v1.2.3 -m release\n" + ' git push origin "refs/tags/v1.2.3"\n' " build-and-attest:\n" " permissions:\n" " id-token: write\n" @@ -789,13 +832,15 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " steps:\n" " - uses: pypa/gh-action-pypi-publish@deadbeef\n" " - uses: actions/create-github-app-token@deadbeef\n" - " id: release_app\n" + " id: release-app\n" " with:\n" " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " owner: OpenAdaptAI\n" + " repositories: OpenAdapt\n" " permission-contents: write\n" " - env:\n" - " GH_TOKEN: ${{ steps.release_app.outputs.token }}\n" + " GH_TOKEN: ${{ steps.release-app.outputs.token }}\n" " run: gh release create v1.2.3\n" " verify-publication:\n" " steps:\n" @@ -819,11 +864,16 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " with:\n" " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " owner: OpenAdaptAI\n" + " repositories: openadapt-agent\n" " permission-contents: write\n" " - run: test \"$GITHUB_ACTOR\" = 'openadapt-release[bot]'\n" - " - env:\n" - " APP_TOKEN: ${{ steps.release_app.outputs.token }}\n" - " run: git tag v1.2.3 && git push origin v1.2.3\n" + " - uses: actions/checkout@deadbeef\n" + " with:\n" + " token: ${{ steps.release_app.outputs.token }}\n" + " - run: |\n" + " git tag v1.2.3\n" + " git push origin v1.2.3\n" " pypi:\n" " environment: pypi\n" " permissions:\n" @@ -883,11 +933,16 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " with:\n" " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " owner: OpenAdaptAI\n" + f" repositories: {repo_name}\n" " permission-contents: write\n" " - run: test refs/heads/main = refs/heads/main\n" - " - env:\n" - " APP_TOKEN: ${{ steps.release_app.outputs.token }}\n" - ' run: git tag --annotate v1.2.3 -m release && git push origin "refs/tags/v1.2.3"\n' + " - uses: actions/checkout@deadbeef\n" + " with:\n" + " token: ${{ steps.release_app.outputs.token }}\n" + " - run: |\n" + " git tag --annotate v1.2.3 -m release\n" + ' git push origin "refs/tags/v1.2.3"\n' " publish:\n" " environment: pypi\n" " permissions:\n" @@ -921,12 +976,17 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " with:\n" " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " owner: OpenAdaptAI\n" + " repositories: openadapt-desktop\n" " permission-contents: write\n" " - run: test OpenAdaptAI/openadapt-desktop = OpenAdaptAI/openadapt-desktop && test refs/heads/main = refs/heads/main\n" " - run: python scripts/verify_release_lock.py\n" - " - env:\n" - " APP_TOKEN: ${{ steps.release_app.outputs.token }}\n" - ' run: git tag -a v1.2.3 -m release && git push origin "refs/tags/v1.2.3"\n' + " - uses: actions/checkout@deadbeef\n" + " with:\n" + " token: ${{ steps.release_app.outputs.token }}\n" + " - run: |\n" + " git tag -a v1.2.3 -m release\n" + ' git push origin "refs/tags/v1.2.3"\n' " publish:\n" " if: github.actor == 'openadapt-release[bot]'\n" " environment: pypi\n" @@ -943,6 +1003,8 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " with:\n" " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " owner: OpenAdaptAI\n" + " repositories: openadapt-desktop\n" " permission-contents: write\n" " - env:\n" " GH_TOKEN: ${{ steps.release_app.outputs.token }}\n" @@ -992,10 +1054,15 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " with:\n" " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " owner: OpenAdaptAI\n" + " repositories: openadapt-desktop\n" " permission-contents: write\n" - " - env:\n" - " APP_TOKEN: ${{ steps.release_app.outputs.token }}\n" - ' run: git tag --annotate desktop-v1.2.3 -m release && git push origin "refs/tags/desktop-v1.2.3"\n' + " - uses: actions/checkout@deadbeef\n" + " with:\n" + " token: ${{ steps.release_app.outputs.token }}\n" + " - run: |\n" + " git tag --annotate desktop-v1.2.3 -m release\n" + ' git push origin "refs/tags/desktop-v1.2.3"\n' " recover-published-native:\n" " steps:\n" " - run: echo state=absent state=partial state=complete\n" @@ -1011,6 +1078,8 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " with:\n" " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " owner: OpenAdaptAI\n" + " repositories: openadapt-desktop\n" " permission-contents: write\n" " - env:\n" " GH_TOKEN: ${{ steps.release_app.outputs.token }}\n" @@ -1044,10 +1113,15 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " with:\n" " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " owner: OpenAdaptAI\n" + " repositories: openadapt-desktop\n" " permission-contents: write\n" - " - env:\n" - " APP_TOKEN: ${{ steps.release_app.outputs.token }}\n" - ' run: git tag --annotate ffmpeg-runtime-v1.2.3-r1 -m release && git push origin "refs/tags/ffmpeg-runtime-v1.2.3-r1"\n' + " - uses: actions/checkout@deadbeef\n" + " with:\n" + " token: ${{ steps.release_app.outputs.token }}\n" + " - run: |\n" + " git tag --annotate ffmpeg-runtime-v1.2.3-r1 -m release\n" + ' git push origin "refs/tags/ffmpeg-runtime-v1.2.3-r1"\n' " publish:\n" " environment: native-release\n" " permissions:\n" @@ -1059,6 +1133,8 @@ def _workflow_content(self, repo_name: str, path: str) -> str: " with:\n" " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n" " private-key: ${{ secrets.OPENADAPT_RELEASE_APP_PRIVATE_KEY }}\n" + " owner: OpenAdaptAI\n" + " repositories: openadapt-desktop\n" " permission-contents: write\n" " - env:\n" " GH_TOKEN: ${{ steps.release_app.outputs.token }}\n" @@ -1612,6 +1688,37 @@ def test_audited_main_drift_is_a_blocker(monkeypatch: pytest.MonkeyPatch) -> Non assert "audit_snapshot_advanced" not in {item["code"] for item in flow["warnings"]} +def test_plan_reads_workflows_by_audited_sha_and_detects_mid_audit_advance( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + github = ReadOnlyFixtureGitHub( + value, advance_main_during_plan_repo="openadapt-flow" + ) + plan = build_plan(github, value) + flow_policy = next( + repo for repo in value["repositories"] if repo["name"] == "openadapt-flow" + ) + flow = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-flow" + ) + + assert "audited_main_changed_during_plan" in { + item["code"] for item in flow["blockers"] + } + assert any( + path.endswith(f"?ref={flow_policy['audited_main_sha']}") + and "/openadapt-flow/contents/.github/workflows/" in path + for path in github.gets + ) + assert not any( + "/openadapt-flow/contents/.github/workflows/" in path + and path.endswith("?ref=main") + for path in github.gets + ) + + def test_unprotected_existing_environment_produces_a_safe_repair_action( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1785,6 +1892,172 @@ def test_non_authorized_dispatch_rejects_each_privileged_app_actor( } +def test_non_authorized_dispatch_rejects_bypassed_job_condition( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/complex-visual.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("openadapt-evals", path).replace( + " github.triggering_actor != 'openadapt-docs[bot]'\n", + " github.triggering_actor != 'openadapt-docs[bot]' || true\n", + 1, + ) + plan = build_plan( + ReadOnlyFixtureGitHub( + value, workflow_overrides={("openadapt-evals", path): source} + ), + value, + ) + evals = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-evals" + ) + + assert "dispatch_workflow_accepts_privileged_app" in { + item["code"] for item in evals["blockers"] + } + + +def test_non_authorized_dispatch_rejects_bypassed_shell_guard( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/complex-visual.yml" + fixture = ReadOnlyFixtureGitHub(value) + original = fixture._workflow_content("openadapt-evals", path) + source = original.replace( + " test \"$TRIGGERING_ACTOR\" != 'openadapt-docs[bot]'\n", + " test \"$TRIGGERING_ACTOR\" != 'openadapt-docs[bot]' || true\n", + 1, + ) + assert source != original + plan = build_plan( + ReadOnlyFixtureGitHub( + value, workflow_overrides={("openadapt-evals", path): source} + ), + value, + ) + evals = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-evals" + ) + + assert "dispatch_workflow_accepts_privileged_app" in { + item["code"] for item in evals["blockers"] + } + + +def test_non_authorized_dispatch_rejects_echo_only_shell_guard( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/complex-visual.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("openadapt-evals", path).replace( + ' run: |\n test "$ACTOR"', + ' run: |\n echo test "$ACTOR"', + 1, + ) + plan = build_plan( + ReadOnlyFixtureGitHub( + value, workflow_overrides={("openadapt-evals", path): source} + ), + value, + ) + evals = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-evals" + ) + + assert "dispatch_workflow_accepts_privileged_app" in { + item["code"] for item in evals["blockers"] + } + + +def test_non_authorized_dispatch_rejects_test_with_true_alternative( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/complex-visual.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("openadapt-evals", path).replace( + " test \"$ACTOR\" != 'openadapt-lifecycle[bot]'\n", + " test \"$ACTOR\" != 'openadapt-lifecycle[bot]' -o 1 = 1\n", + 1, + ) + plan = build_plan( + ReadOnlyFixtureGitHub( + value, workflow_overrides={("openadapt-evals", path): source} + ), + value, + ) + evals = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-evals" + ) + + assert "dispatch_workflow_accepts_privileged_app" in { + item["code"] for item in evals["blockers"] + } + + +def test_non_authorized_dispatch_rejects_quoted_condition_spoof( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/complex-visual.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("openadapt-evals", path).replace( + " github.actor != 'openadapt-lifecycle[bot]' &&\n", + " contains(\"github.actor != 'openadapt-lifecycle[bot]'\", 'github') &&\n", + 1, + ) + plan = build_plan( + ReadOnlyFixtureGitHub( + value, workflow_overrides={("openadapt-evals", path): source} + ), + value, + ) + evals = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-evals" + ) + + assert "dispatch_workflow_accepts_privileged_app" in { + item["code"] for item in evals["blockers"] + } + + +def test_tag_only_exemption_rejects_quoted_condition_spoof( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release.yml" + fixture = ReadOnlyFixtureGitHub(value) + original = fixture._workflow_content("openadapt-evals", path) + source = original.replace( + " if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')\n", + " if: contains(\"github.event_name == 'push' refs/tags/\", 'github')\n", + 1, + ) + assert source != original + plan = build_plan( + ReadOnlyFixtureGitHub( + value, workflow_overrides={("openadapt-evals", path): source} + ), + value, + ) + evals = next( + repo for repo in plan["repositories"] if repo["name"] == "openadapt-evals" + ) + + assert "dispatch_workflow_accepts_privileged_app" in { + item["code"] for item in evals["blockers"] + } + + @pytest.mark.parametrize( ("repository", "path", "token_reference", "expected_code"), [ @@ -1824,6 +2097,31 @@ def test_authorized_dispatch_effect_must_use_its_same_job_app_token( assert expected_code in {item["code"] for item in target["blockers"]} +def test_authorized_dispatch_rejects_quoted_condition_spoof( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/sync.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("openadapt-ops", path).replace( + " github.repository == 'OpenAdaptAI/openadapt-ops' &&\n", + " contains(\"github.repository == 'OpenAdaptAI/openadapt-ops'\", 'github') &&\n", + 1, + ) + plan = build_plan( + ReadOnlyFixtureGitHub( + value, workflow_overrides={("openadapt-ops", path): source} + ), + value, + ) + ops = next(repo for repo in plan["repositories"] if repo["name"] == "openadapt-ops") + + assert "docs-only_workflow_semantic_contract" in { + item["code"] for item in ops["blockers"] + } + + @pytest.mark.parametrize( ("old", "new"), [ @@ -1865,7 +2163,7 @@ def test_launcher_github_release_requires_same_job_release_app_output( fixture = ReadOnlyFixtureGitHub(value) source = fixture._workflow_content("OpenAdapt", path) prefix, publish = source.split(" publish-pypi:\n", 1) - publish = publish.replace("id: release_app", "id: unrelated_app", 1) + publish = publish.replace("id: release-app", "id: unrelated-app", 1) plan = build_plan( ReadOnlyFixtureGitHub( value, @@ -1892,8 +2190,15 @@ def test_release_tag_push_requires_same_job_release_app_output( path = ".github/workflows/release-and-publish.yml" fixture = ReadOnlyFixtureGitHub(value) source = fixture._workflow_content("OpenAdapt", path).replace( - "APP_TOKEN: ${{ steps.release_app.outputs.token }}", - "APP_TOKEN: ${{ github.token }}", + " - uses: actions/checkout@deadbeef\n" + " with:\n" + " token: ${{ steps.release-app.outputs.token }}\n", + " - env:\n" + " APP_TOKEN: ${{ steps.release-app.outputs.token }}\n" + " run: echo App token is present\n" + " - uses: actions/checkout@deadbeef\n" + " with:\n" + " token: ${{ github.token }}\n", 1, ) plan = build_plan( @@ -1909,49 +2214,493 @@ def test_release_tag_push_requires_same_job_release_app_output( } -def test_lifecycle_environment_reference_outside_exact_workflow_blocks_apply( +def test_release_tag_push_accepts_explicit_app_authenticated_git_binding( monkeypatch: pytest.MonkeyPatch, ) -> None: value = config() monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("OpenAdapt", path).replace( + " - uses: actions/checkout@deadbeef\n" + " with:\n" + " token: ${{ steps.release-app.outputs.token }}\n" + " - run: |\n" + " git tag -a v1.2.3 -m release\n" + ' git push origin "refs/tags/v1.2.3"\n', + " - uses: actions/checkout@deadbeef\n" + " with:\n" + " persist-credentials: false\n" + " token: ${{ steps.release-app.outputs.token }}\n" + " - env:\n" + " APP_TOKEN: ${{ steps.release-app.outputs.token }}\n" + " run: |\n" + " set -euo pipefail\n" + " git tag -a v1.2.3 -m release\n" + " auth=$(printf 'x-access-token:%s' \"${APP_TOKEN}\" | base64 | tr -d '\\n')\n" + " export GIT_CONFIG_COUNT=1\n" + " export GIT_CONFIG_KEY_0=http.https://github.com/.extraheader\n" + ' export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}"\n' + " git push origin refs/tags/v1.2.3\n", + 1, + ) plan = build_plan( - ReadOnlyFixtureGitHub(value, unauthorized_environment_repo="openadapt-evals"), + ReadOnlyFixtureGitHub(value, workflow_overrides={("OpenAdapt", path): source}), value, ) - evals = next( - repo for repo in plan["repositories"] if repo["name"] == "openadapt-evals" + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" ) - assert {item["code"] for item in evals["blockers"]} >= { - "lifecycle_environment_workflow_scope" + + assert "release_tag_app_token_not_bound" not in { + item["code"] for item in launcher["blockers"] } -def test_active_pull_request_check_blocks_apply( +def test_release_tag_push_rejects_decorative_auth_text( 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" + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("OpenAdapt", path).replace( + " - uses: actions/checkout@deadbeef\n" + " with:\n" + " token: ${{ steps.release-app.outputs.token }}\n" + " - run: |\n" + " git tag -a v1.2.3 -m release\n" + ' git push origin "refs/tags/v1.2.3"\n', + " - uses: actions/checkout@deadbeef\n" + " with:\n" + " persist-credentials: false\n" + " - env:\n" + " APP_TOKEN: ${{ steps.release-app.outputs.token }}\n" + " run: |\n" + " set -euo pipefail\n" + " echo 'x-access-token AUTHORIZATION:' \"${APP_TOKEN}\"\n" + " git tag -a v1.2.3 -m release\n" + " git push origin refs/tags/v1.2.3\n", + 1, ) - 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"} + plan = build_plan( + ReadOnlyFixtureGitHub(value, workflow_overrides={("OpenAdapt", path): source}), + value, + ) + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert "release_tag_app_token_not_bound" in { + item["code"] for item in launcher["blockers"] + } -def test_path_filtered_target_check_blocks_apply( +def test_release_tag_push_rejects_reassigned_app_token( monkeypatch: pytest.MonkeyPatch, ) -> None: value = config() monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("OpenAdapt", path).replace( + " - uses: actions/checkout@deadbeef\n" + " with:\n" + " token: ${{ steps.release-app.outputs.token }}\n" + " - run: |\n" + " git tag -a v1.2.3 -m release\n" + ' git push origin "refs/tags/v1.2.3"\n', + " - uses: actions/checkout@deadbeef\n" + " with:\n" + " persist-credentials: false\n" + " - env:\n" + " APP_TOKEN: ${{ steps.release-app.outputs.token }}\n" + " DEFAULT_TOKEN: ${{ github.token }}\n" + " run: |\n" + " set -euo pipefail\n" + ' APP_TOKEN="$DEFAULT_TOKEN"\n' + " auth=$(printf 'x-access-token:%s' \"$APP_TOKEN\" | base64)\n" + " export GIT_CONFIG_COUNT=1\n" + " export GIT_CONFIG_KEY_0=http.https://github.com/.extraheader\n" + ' export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}"\n' + " git tag -a v1.2.3 -m release\n" + " git push origin refs/tags/v1.2.3\n", + 1, + ) plan = build_plan( - ReadOnlyFixtureGitHub(value, path_filtered_repo="openadapt-web"), value + ReadOnlyFixtureGitHub(value, workflow_overrides={("OpenAdapt", path): source}), + value, ) - web = next(repo for repo in plan["repositories"] if repo["name"] == "openadapt-web") - assert plan["safe_to_apply"] is False + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert "release_tag_app_token_not_bound" in { + item["code"] for item in launcher["blockers"] + } + + +def test_release_tag_push_rejects_checkout_credential_replacement( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("OpenAdapt", path).replace( + " - run: |\n" + " git tag -a v1.2.3 -m release\n" + ' git push origin "refs/tags/v1.2.3"\n', + " - env:\n" + " DEFAULT_TOKEN: ${{ github.token }}\n" + " run: |\n" + " set -euo pipefail\n" + ' git remote set-url origin "https://x-access-token:${DEFAULT_TOKEN}@github.com/OpenAdaptAI/OpenAdapt.git"\n' + " git tag -a v1.2.3 -m release\n" + " git push origin refs/tags/v1.2.3\n", + 1, + ) + plan = build_plan( + ReadOnlyFixtureGitHub(value, workflow_overrides={("OpenAdapt", path): source}), + value, + ) + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert "release_tag_app_token_not_bound" in { + item["code"] for item in launcher["blockers"] + } + + +def test_release_tag_push_rejects_intervening_credential_replacement( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("OpenAdapt", path).replace( + " - run: |\n" + " git tag -a v1.2.3 -m release\n" + ' git push origin "refs/tags/v1.2.3"\n', + " - env:\n" + " DEFAULT_TOKEN: ${{ github.token }}\n" + ' run: git remote set-url origin "https://x-access-token:${DEFAULT_TOKEN}@github.com/OpenAdaptAI/OpenAdapt.git"\n' + " - run: |\n" + " git tag -a v1.2.3 -m release\n" + ' git push origin "refs/tags/v1.2.3"\n', + 1, + ) + plan = build_plan( + ReadOnlyFixtureGitHub(value, workflow_overrides={("OpenAdapt", path): source}), + value, + ) + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert "release_tag_app_token_not_bound" in { + item["code"] for item in launcher["blockers"] + } + + +def test_release_workflow_rejects_effect_named_but_not_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("OpenAdapt", path).replace( + " run: gh release create v1.2.3\n", + " name: gh release create v1.2.3\n" + " run: echo gh release create v1.2.3\n", + 1, + ) + plan = build_plan( + ReadOnlyFixtureGitHub(value, workflow_overrides={("OpenAdapt", path): source}), + value, + ) + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert "github_release_effect_missing" in { + item["code"] for item in launcher["blockers"] + } + + +def test_release_workflow_rejects_echoed_tag_commands( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + original = fixture._workflow_content("OpenAdapt", path) + source = original.replace( + " git tag -a v1.2.3 -m release\n" + ' git push origin "refs/tags/v1.2.3"\n', + " echo git tag -a v1.2.3 -m release\n" + ' echo git push origin "refs/tags/v1.2.3"\n', + 1, + ) + assert source != original + plan = build_plan( + ReadOnlyFixtureGitHub(value, workflow_overrides={("OpenAdapt", path): source}), + value, + ) + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert "release_tag_effect_missing" in { + item["code"] for item in launcher["blockers"] + } + + +def test_release_workflow_rejects_ignored_tag_push_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + original = fixture._workflow_content("OpenAdapt", path) + source = original.replace( + ' git push origin "refs/tags/v1.2.3"\n', + ' git push origin "refs/tags/v1.2.3" || true\n', + 1, + ) + assert source != original + plan = build_plan( + ReadOnlyFixtureGitHub(value, workflow_overrides={("OpenAdapt", path): source}), + value, + ) + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert "release_tag_effect_missing" in { + item["code"] for item in launcher["blockers"] + } + + +def test_release_workflow_rejects_continued_ignored_tag_push_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + original = fixture._workflow_content("OpenAdapt", path) + source = original.replace( + ' git push origin "refs/tags/v1.2.3"\n', + (' git push origin "refs/tags/v1.2.3" \\\\\n || true\n'), + 1, + ) + assert source != original + plan = build_plan( + ReadOnlyFixtureGitHub(value, workflow_overrides={("OpenAdapt", path): source}), + value, + ) + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert "release_tag_effect_missing" in { + item["code"] for item in launcher["blockers"] + } + + +def test_release_workflow_rejects_ignored_github_release_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + original = fixture._workflow_content("OpenAdapt", path) + source = original.replace( + " run: gh release create v1.2.3\n", + " run: gh release create v1.2.3 || true\n", + 1, + ) + assert source != original + plan = build_plan( + ReadOnlyFixtureGitHub(value, workflow_overrides={("OpenAdapt", path): source}), + value, + ) + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert "github_release_effect_missing" in { + item["code"] for item in launcher["blockers"] + } + + +def test_release_workflow_rejects_continued_ignored_github_release_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + original = fixture._workflow_content("OpenAdapt", path) + source = original.replace( + " run: gh release create v1.2.3\n", + ( + " run: |\n" + " gh release create v1.2.3 \\\\\n" + " || true\n" + ), + 1, + ) + assert source != original + plan = build_plan( + ReadOnlyFixtureGitHub(value, workflow_overrides={("OpenAdapt", path): source}), + value, + ) + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert "github_release_effect_missing" in { + item["code"] for item in launcher["blockers"] + } + + +def test_github_release_rejects_compound_token_expression( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("OpenAdapt", path).replace( + " GH_TOKEN: ${{ steps.release-app.outputs.token }}\n", + " GH_TOKEN: ${{ steps.release-app.outputs.token || github.token }}\n", + 1, + ) + plan = build_plan( + ReadOnlyFixtureGitHub(value, workflow_overrides={("OpenAdapt", path): source}), + value, + ) + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert "github_release_app_token_not_bound" in { + item["code"] for item in launcher["blockers"] + } + + +def test_release_app_token_requires_exact_repository_scope( + monkeypatch: pytest.MonkeyPatch, +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("OpenAdapt", path).replace( + " repositories: OpenAdapt\n", "", 1 + ) + plan = build_plan( + ReadOnlyFixtureGitHub(value, workflow_overrides={("OpenAdapt", path): source}), + value, + ) + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert "release_effect_app_token_missing" in { + item["code"] for item in launcher["blockers"] + } + + +@pytest.mark.parametrize( + ("old", "new"), + [ + ( + " owner: OpenAdaptAI\n", + " owner: ${{ github.repository_owner && 'OtherOwner' }}\n", + ), + ( + " repositories: OpenAdapt\n", + " repositories: ${{ github.event.repository.name && 'openadapt-ops' }}\n", + ), + ( + " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID }}\n", + " app-id: ${{ vars.OPENADAPT_RELEASE_APP_ID || vars.OTHER_APP_ID }}\n", + ), + ], +) +def test_release_app_token_rejects_compound_scope_expressions( + monkeypatch: pytest.MonkeyPatch, old: str, new: str +) -> None: + value = config() + monkeypatch.setenv("OPENADAPT_RELEASE_APP_ID", "991122") + path = ".github/workflows/release-and-publish.yml" + fixture = ReadOnlyFixtureGitHub(value) + source = fixture._workflow_content("OpenAdapt", path).replace(old, new, 1) + plan = build_plan( + ReadOnlyFixtureGitHub(value, workflow_overrides={("OpenAdapt", path): source}), + value, + ) + launcher = next( + repo for repo in plan["repositories"] if repo["name"] == "OpenAdapt" + ) + + assert "release_effect_app_token_missing" in { + item["code"] for item in launcher["blockers"] + } + + +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" + ) + 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" } @@ -1985,6 +2734,38 @@ def test_apply_refuses_unconfirmed_environment_policy_deletion() -> None: assert github.writes == [] +def test_apply_rechecks_every_main_before_first_mutation() -> None: + value = config() + audited = next( + repo["audited_main_sha"] + for repo in value["repositories"] + if repo["name"] == "OpenAdapt" + ) + plan = { + "organization": "OpenAdaptAI", + "repositories": [ + { + "name": "OpenAdapt", + "default_branch": "main", + "main_sha": audited, + "requires_environment_policy_prune": False, + "actions": [ + { + "kind": "create_ruleset", + "name": "test", + "payload": {"name": "test"}, + } + ], + } + ], + } + github = ReadOnlyFixtureGitHub(value, main_drift_repo="OpenAdapt") + + with pytest.raises(PolicyError, match="changed after preflight"): + _apply_actions(github, plan, prune_environment_policies=False) + assert github.writes == [] + + def test_apply_plan_must_be_fresh_and_unchanged() -> None: value = config() base = {