diff --git a/ops/github/README.md b/ops/github/README.md new file mode 100644 index 0000000..c1f7348 --- /dev/null +++ b/ops/github/README.md @@ -0,0 +1,326 @@ +# Core GitHub protection policy + +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: + +- 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 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. + +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-26 + +The public GitHub API reported zero repository rulesets in all nine +repositories. GitHub marked these `main` branches as protected: `OpenAdapt`, +`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. 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. +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 +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`, `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. 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 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 +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 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. 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 +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 only for the annotated tag and matching GitHub +Release. Never use it to push a branch or open a pull request. + +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 + +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 +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 all App identities, exact installation +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. 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 +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 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 +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..fd65855 --- /dev/null +++ b/ops/github/core-protection-policy.json @@ -0,0 +1,1413 @@ +{ + "schema_version": 1, + "organization": "OpenAdaptAI", + "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-26", + "repository_ruleset_counts": { + ".github": 0, + "OpenAdapt": 0, + "openadapt-agent": 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-agent": true, + "openadapt-capture": true, + "openadapt-desktop": false, + "openadapt-evals": true, + "openadapt-flow": true, + "openadapt-ops": false, + "openadapt-web": true + }, + "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 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 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 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-26", + "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, + "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", + "Metadata: read" + ], + "repository_scope": [ + "OpenAdapt", + "openadapt-agent", + "openadapt-capture", + "openadapt-desktop", + "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" + }, + "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", + "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" + ], + "token_repository_bindings": { + ".github": [ + ".github" + ], + "openadapt-evals": [ + "openadapt-evals" + ], + "openadapt-ops": [ + ".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" + }, + "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", + "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" + ], + "token_repository_bindings": { + "openadapt-ops": [ + "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" + }, + "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": { + "type": "User", + "login": "abrichr", + "id": 774615 + }, + "environment_defaults": { + "wait_timer": 0, + "prevent_self_review": false, + "can_admins_bypass": false + }, + "repositories": [ + { + "name": "OpenAdapt", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "f7f10f3c88e892ad880372c617c787b7bed01d9c", + "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", + "can_admins_bypass": false, + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ] + }, + { + "name": "pypi", + "can_admins_bypass": false, + "deployment_policies": [ + { + "type": "tag", + "name": "v*" + } + ] + } + ], + "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*$", + "(?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*$", + "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", + "PYPI_TOKEN", + "permission-pull-requests:\\s*write", + "git\\s+push[^\\n]*refs/heads/" + ] + } + ] + }, + { + "name": "openadapt-agent", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "c27b29a5f3c3fe3a14a91566405d01648ef1eb36", + "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", + "can_admins_bypass": false, + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ], + "exclusive_workflow": ".github/workflows/release.yml" + }, + { + "name": "pypi", + "can_admins_bypass": false, + "deployment_policies": [ + { + "type": "tag", + "name": "v*" + } + ], + "exclusive_workflow": ".github/workflows/release.yml" + }, + { + "name": "mcp-registry", + "can_admins_bypass": false, + "deployment_policies": [ + { + "type": "tag", + "name": "v*" + } + ], + "exclusive_workflow": ".github/workflows/release.yml" + } + ], + "release_workflows": [ + { + "path": ".github/workflows/release.yml", + "required_effects": [ + "tag-push" + ], + "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", + "GITHUB_ACTOR", + "openadapt-release\\[bot\\]" + ], + "forbidden_patterns": [ + "ADMIN_TOKEN", + "(?m)^[ ]{2}release:\\s*$", + "PYPI_API_TOKEN", + "MCP_GITHUB_TOKEN", + "registry/releases/latest/download", + "permission-pull-requests:\\s*write", + "git\\s+push[^\\n]*refs/heads/" + ] + } + ] + }, + { + "name": "openadapt-flow", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "ba127f3a3637a794355a2fef2b381383e5d0be40", + "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", + "can_admins_bypass": false, + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ] + }, + { + "name": "pypi", + "can_admins_bypass": false, + "deployment_policies": [ + { + "type": "tag", + "name": "v*" + } + ] + } + ], + "release_workflows": [ + { + "path": ".github/workflows/release.yml", + "required_effects": [ + "tag-push" + ], + "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*$", + "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", + "PYPI_TOKEN", + "permission-pull-requests:\\s*write", + "git\\s+push[^\\n]*refs/heads/" + ] + } + ] + }, + { + "name": "openadapt-capture", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "5ed28e6c63e70cdedf32c760f0272841adb1063b", + "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", + "can_admins_bypass": false, + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ] + }, + { + "name": "pypi", + "can_admins_bypass": false, + "deployment_policies": [ + { + "type": "tag", + "name": "v*" + } + ] + } + ], + "release_workflows": [ + { + "path": ".github/workflows/release.yml", + "required_effects": [ + "tag-push" + ], + "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*$", + "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", + "PYPI_TOKEN", + "permission-pull-requests:\\s*write", + "git\\s+push[^\\n]*refs/heads/" + ] + } + ] + }, + { + "name": "openadapt-desktop", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "a363865c4d375786628063a04f83d74f2b413f2c", + "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", + "can_admins_bypass": false, + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ] + }, + { + "name": "pypi", + "can_admins_bypass": false, + "deployment_policies": [ + { + "type": "tag", + "name": "v*" + } + ] + }, + { + "name": "native-release", + "can_admins_bypass": false, + "deployment_policies": [ + { + "type": "tag", + "name": "desktop-v*" + }, + { + "type": "tag", + "name": "ffmpeg-runtime-v*" + } + ] + } + ], + "release_workflows": [ + { + "path": ".github/workflows/release.yml", + "required_effects": [ + "tag-push", + "github-release" + ], + "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*$", + "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", + "PYPI_TOKEN", + "permission-pull-requests:\\s*write", + "python-semantic-release", + "git\\s+push[^\\n]*refs/heads/" + ] + }, + { + "path": ".github/workflows/native-freshness.yml", + "required_effects": [], + "required_patterns": [ + "(?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", + "OPENADAPT_RELEASE_APP_PRIVATE_KEY", + "(?m)^\\s*contents:\\s*write\\s*$", + "git\\s+push", + "gh\\s+pr\\s+create", + "environment:\\s*release-identity" + ] + }, + { + "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*$", + "(?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", + "PYPI_TOKEN", + "permission-pull-requests:\\s*write", + "git\\s+push[^\\n]*refs/heads/" + ] + }, + { + "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*$", + "(?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", + "PYPI_TOKEN", + "permission-pull-requests:\\s*write", + "git\\s+push[^\\n]*refs/heads/" + ] + } + ] + }, + { + "name": "openadapt-evals", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "ef55e01ebe08382c9a05675d491dfbc297ed6d5e", + "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", + "can_admins_bypass": false, + "deployment_policies": [ + { + "type": "branch", + "name": "main" + } + ] + }, + { + "name": "pypi", + "can_admins_bypass": false, + "deployment_policies": [ + { + "type": "tag", + "name": "v*" + } + ] + } + ], + "lifecycle_environments": [ + { + "name": "production-lifecycle-evidence", + "can_admins_bypass": false, + "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", + "required_effects": [ + "tag-push", + "github-release" + ], + "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*$", + "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", + "GH_TOKEN:\\s*\\$\\{\\{\\s*steps\\.release-app\\.outputs\\.token\\s*\\}\\}", + "gh\\s+release\\s+(?:create|edit|upload)" + ], + "forbidden_patterns": [ + "ADMIN_TOKEN", + "PYPI_TOKEN", + "permission-pull-requests:\\s*write", + "python-semantic-release", + "git\\s+push[^\\n]*refs/heads/" + ] + } + ], + "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/release.yml", + "mode": "reject-lifecycle-app" + }, + { + "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" + } + ] + }, + { + "name": "openadapt-ops", + "local_repository_name": "openadapt-maintenance", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "b0327de8019db6a925104148a094f6f0756b90d2", + "require_code_owner_review": true, + "required_checks": [ + "Analyze (python)", + "Validate Production lifecycle", + "Validate canonical docs", + "dependency-review", + "gitleaks" + ], + "path_scoped_checks": [ + "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": [ + { + "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" + } + ], + "exclusive_workflow": ".github/workflows/sync.yml" + } + ], + "lifecycle_environments": [ + { + "name": "production-lifecycle-projection", + "can_admins_bypass": false, + "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_effects": [], + "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", + "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", + "scripts/validate_docs_sync\\.py", + "repos\\.yml", + "inputs\\.source_ref", + "refs/heads/main", + "inputs\\.source_commit", + "gh\\s+api[^\\n]*commits/main", + "inputs\\.source_event", + "inputs\\.idempotency_key", + "(?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" + ] + } + ], + "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", + "inputs\\.candidate_admissions_sha256", + "inputs\\.candidate_ledger_head_sha256", + "inputs\\.idempotency_key", + "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'", + "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", + "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": [ + "(?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" + }, + { + "path": ".github/workflows/workspace-staleness-sweep.yml", + "mode": "reject-lifecycle-app" + } + ] + }, + { + "name": "openadapt-web", + "visibility": "public", + "default_branch": "main", + "audited_main_sha": "ab823011683b8aff035ce5edb62e50f6ef990e1b", + "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": "7e1a3c742455598c236318ce5a70ed27a36f1816", + "require_code_owner_review": false, + "required_checks": [ + "validate-profile" + ], + "path_scoped_checks": [], + "admission_gaps": [ + "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": [ + "(?m)^[ ]{2}pull_request:\\s*$", + "(?m)^[ ]{2}validate-profile:\\s*$" + ], + "forbidden_patterns": [ + "(?m)^[ ]{4}paths(?:-ignore)?:\\s*$" + ] + } + ], + "release_tag_patterns": [ + "refs/tags/v*" + ], + "release_environments": [], + "lifecycle_environments": [ + { + "name": "production-lifecycle-activation", + "can_admins_bypass": false, + "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", + "can_admins_bypass": false, + "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", + "can_admins_bypass": false, + "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": [ + { + "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..6c62f29 --- /dev/null +++ b/scripts/manage_github_protection.py @@ -0,0 +1,2831 @@ +#!/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 + +import yaml + +API_VERSION = "2026-03-10" +PLAN_MAX_AGE_SECONDS = 900 +EXPECTED_REPOSITORIES = { + ".github", + "OpenAdapt", + "openadapt-agent", + "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", +) +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"]}, +} +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): + """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 + + +@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) + + +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 nine 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") + environment_defaults = config.get("environment_defaults") + 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") + 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", + "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") + 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): + 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") + 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") + 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") + 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") + 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") + 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): + 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): + raise PolicyError( + "repositories must contain exactly the nine 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/") + 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 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") + 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", + ) + if not policies: + 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" + ): + 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" + ) + 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 + 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" + ) + 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 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 + 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." + ), + } + ) + 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 six public package " + "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", []) + } + 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_names))}." + ), + } + ) + return None + 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: + 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: + 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": bypass_actors, + "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": 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, + "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, ref: str +) -> str | None: + encoded_path = quote(path, safe="/") + response = client.get( + 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 + 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 _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 _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], + owner: str, + repository_name: str, +) -> 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"] + expected_repositories = set(identity["token_repository_bindings"][repository_name]) + 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 _is_exact_expression(inputs.get("app-id"), f"vars.{app_id_name}"): + continue + 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() + ): + 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 _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 [] + 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"] + 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: + document = _parse_workflow_document(content, owner, repo["name"], path) + 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 + run_steps = [ + step["run"] + for step in _job_steps(job) + if isinstance(step.get("run"), str) + ] + has_tag_effect = bool( + 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 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) + 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, owner, repo["name"] + ) + 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 _tag_pushes_use_app_token(job, token_step_ids): + 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( + _is_exact_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." + ), + } + ) + 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 + + +def _workflow_contract_blockers( + client: GitHubClient, + owner: str, + 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, ref) + if content is None: + blockers.append( + { + "code": f"{code_prefix}_workflow_missing", + "message": f"{repo['name']}: {path} does not exist on main.", + } + ) + 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( + { + "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 _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 _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, + repo: Mapping[str, Any], + ref: str, +) -> 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/{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" + ) + 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, ref) + 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 _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 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(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 + 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 (variable_name, actor_login) in test_commands: + unresolved.discard(f"{field}:{actor_login}") + return sorted(unresolved) + + +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 not isinstance(guard, Mapping): + return [""] + failures: list[str] = [] + if guard.get("permissions") != {}: + failures.append(f"{guard_name}:permissions") + 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 + if not isinstance(job_name, str) or not isinstance(job, Mapping): + failures.append("") + continue + expression = str(job.get("if", "")) + 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 + 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_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) + ): + candidates.append((job_name, job)) + if not candidates: + return [""] + + 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['\"]", + "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 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, owner, repo["name"] + ) + 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( + _is_exact_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 + + +def _dispatch_workflow_blockers( + client: GitHubClient, + owner: str, + 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"] + for item in repo.get("dispatch_workflow_inventory", []) + } + if not inventory: + return [] + 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" + ) + 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, ref) + 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)} + ) + continue + 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 ( + 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( + { + "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(document) + if failures: + blockers.append( + { + "code": "dispatch_workflow_accepts_privileged_app", + "message": ( + 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 + + +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]], 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: + 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 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", + "environment": name, + "payload": desired, + } + ) + current_policies: list[Mapping[str, Any]] = [] + 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" + ) + 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, blockers, 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) + 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]] = [] + + 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 ( + 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( + { + "code": "audited_main_drift", + "message": ( + f"{name}: main advanced from {repo['audited_main_sha']} to {main_sha}. " + "Re-audit the exact current main workflow state and update the policy 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", main_sha + ) + ) + blockers.extend( + _workflow_contract_blockers( + client, owner, repo, "admission_workflows", "admission", main_sha + ) + ) + blockers.extend( + _workflow_contract_blockers( + client, owner, repo, "lifecycle_workflows", "lifecycle", main_sha + ) + ) + blockers.extend( + _release_workflow_semantic_blockers( + client, owner, repo, config["release_identity"], main_sha + ) + ) + blockers.extend( + _release_identity_variable_blockers( + client, + owner, + repo, + config["release_identity"], + actor, + ) + ) + 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, + ) + ) + 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, main_sha)) + blockers.extend( + _dispatch_workflow_blockers( + client, + owner, + repo, + config["lifecycle_identity"], + config["docs_identity"], + main_sha, + ) + ) + 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, environment_blockers, prune_needed = _environment_actions( + client, config, owner, repo + ) + 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, + "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, + "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"], + "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"), + "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"), + "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"] + 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"]: + 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..dec8394 --- /dev/null +++ b/tests/test_manage_github_protection.py @@ -0,0 +1,2797 @@ +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, + _parse_workflow_document, + _workflow_triggers, + build_plan, + desired_environment, + 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, + 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, + 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, + 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, + advance_main_during_plan_repo: str | None = None, + workflow_overrides: Mapping[tuple[str, str], 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.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.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.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 + for index, repo in enumerate(config["repositories"]) + } + + 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": + 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": + return { + "installations": [ + { + "id": 551100, + "app_id": 991122, + "app_slug": "openadapt-release", + "repository_selection": ( + "all" if self.release_app_all_repositories else "selected" + ), + "permissions": ( + { + "contents": "write", + "metadata": "read", + "pull_requests": "write", + } + if self.release_app_permissions_mismatch + else {"contents": "write", "metadata": "read"} + ), + }, + { + "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/551100/repositories?per_page=100": + return { + "repositories": [ + {"name": name} + for name in sorted( + { + "OpenAdapt", + "openadapt-agent", + "openadapt-capture", + "openadapt-desktop", + "openadapt-evals", + "openadapt-flow", + } + | ( + {"unexpected-public-repo"} + if self.release_app_extra_repository + else set() + ) + ) + ] + } + 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"}]} + 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": + 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"] + ) + } + 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": + 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, + } + 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", + "OPENADAPT_DOCS_APP_ID": "772200", + "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 ( + "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_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: + override = self.workflow_overrides.get((repo_name, path)) + if override is not None: + return override + 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 = "" + 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" + ) + 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\n" + " - run: python scripts/prepare_production_lifecycle_projection.py\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:refs/heads/automation-lifecycle"\n' + " - env:\n" + " GH_TOKEN: ${{ steps.lifecycle-app.outputs.token }}\n" + " run: 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" + " - 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" + " owner: OpenAdaptAI\n" + " repositories: openadapt-ops\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: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" + " 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" + ) + 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]'\n" + " test \"$TRIGGERING_ACTOR\" != 'openadapt-lifecycle[bot]'\n" + " test \"$ACTOR\" != 'openadapt-docs[bot]'\n" + " test \"$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" + " 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" + " - 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" + " 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" + " - 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": + 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: |\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 "" + ) + + ( + 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" + " github.actor != 'openadapt-docs[bot]' &&\n" + " github.triggering_actor != 'openadapt-docs[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" + + 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" + " 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" + " - 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" + " 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" + " owner: OpenAdaptAI\n" + " repositories: OpenAdapt\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' + "permissions:\n" + " contents: read\n" + "jobs:\n" + " 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" + " owner: OpenAdaptAI\n" + " repositories: openadapt-agent\n" + " permission-contents: write\n" + " - run: test \"$GITHUB_ACTOR\" = 'openadapt-release[bot]'\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" + " 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" + ) + + 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" + " owner: OpenAdaptAI\n" + f" repositories: {repo_name}\n" + " permission-contents: write\n" + " - run: test refs/heads/main = refs/heads/main\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" + " 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" + " 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" + " - 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" + " 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" + " owner: OpenAdaptAI\n" + " repositories: openadapt-desktop\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" + " owner: OpenAdaptAI\n" + " repositories: openadapt-desktop\n" + " permission-contents: write\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" + " 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" + " owner: OpenAdaptAI\n" + " repositories: openadapt-desktop\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" + " owner: OpenAdaptAI\n" + " repositories: openadapt-desktop\n" + " permission-contents: write\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" + " 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" + " owner: OpenAdaptAI\n" + " repositories: openadapt-desktop\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" + "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: + self.writes.append((method, path, payload or {})) + return {} + + +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 + + +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-agent", + "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_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", + "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"] == [ + ".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["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", + ".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 + 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"]) == { + "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, + "can_admins_bypass": 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, + "can_admins_bypass": False, + "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 + ) + + 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) + 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", + "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"}], + "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" in sync["required_patterns"] + assert "repo-updated" in sync["forbidden_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" + for exact_pattern in ( + "inputs\\.candidate_admissions_sha256", + "inputs\\.candidate_ledger_head_sha256", + "inputs\\.idempotency_key", + "scripts/prepare_production_lifecycle_projection\\.py", + ): + 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( + value["repositories"][0]["required_checks"][0] + ) + with pytest.raises(PolicyError, match="path-scoped checks cannot be required"): + 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] + 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", + } + + 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, +) -> 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"]) + + +@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_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_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: + 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 "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"] + ) + + +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_privileged_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" + } + + +@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"] + } + + +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"), + [ + ( + "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"]} + + +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"), + [ + ("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( + " - 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( + 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_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, 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" not in { + item["code"] for item in launcher["blockers"] + } + + +def test_release_tag_push_rejects_decorative_auth_text( + 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" + " 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, + ) + 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_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, 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_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" + } + + +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_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 = { + "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) + + 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)