From d726a4d4978ba3ae5729eaf3e2c7fcf2ab7f31bd Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 31 Aug 2026 22:15:14 +0000 Subject: [PATCH] feat(governance): register deploy-control policy (#79) --- AGENTS.md | 11 +-- README.md | 79 +++++++++++++++++++-- bin/validate | 96 +++++++++++++++++++++++++- bin/verify-manual-settings | 50 +++++++++++++- config/manual-settings.json | 54 +++++++++++++++ config/repositories.json | 5 ++ config/repository-properties.json | 10 +++ tests/check-project-health.sh | 6 +- tests/publish-repository-properties.sh | 2 +- tests/validate-manual-credentials.sh | 93 ++++++++++++++++++++++++- tests/validate-planning-health.sh | 4 +- tests/verify-manual-settings.sh | 68 +++++++++++++++--- 12 files changed, 447 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0dcb3dc..cc874a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,10 +86,13 @@ - Preserve the selected-actions entry for Codecov and the manual GitHub App repository-access inventory while coverage uploads use OIDC authentication. - Preserve external-provider App records as value-free manual state with exact - stable identities, permissions, events, and selected repository IDs. A - shared installation change must retain every unrelated reviewed repository, - must never widen to all repositories, and requires organization-owner UI - readback when the administrative API cannot enumerate the selected set. + stable identities, permissions, events, and selected repository IDs. Planned + integrations may retain null live App/installation IDs only with an explicit + pending owner-UI state, proposed name-only secret/variable slots, and a + documented follow-up. A shared installation change must retain every + unrelated reviewed repository, must never widen to all repositories, and + requires organization-owner UI readback when the administrative API cannot + enumerate the selected set. - Record cross-repository private-package consumption in `config/manual-settings.json` with stable package and repository IDs. Grant consumer workflows only the `read` role through the package's **Manage diff --git a/README.md b/README.md index 8476183..9c59241 100644 --- a/README.md +++ b/README.md @@ -94,11 +94,13 @@ read-only and are skipped on later runs. intended repository and consumer, credential metadata names, accountable owner, verification date, rotation deadline, and runbook. The App private key and installation tokens never belong in this repository. -- External provider GitHub Apps have a distinct value-free inventory. It binds - the stable App and installation identities, exact permissions and events, - exact selected repository IDs, provider purpose, review owner and deadline, - revocation procedure, and status producer. Provider credentials and account - coordinates remain outside GitHub and this repository. +- External provider GitHub Apps have a distinct value-free inventory. Active + installations bind stable App and installation identities, exact permissions + and events, exact selected repository IDs, provider purpose, review owner and + deadline, revocation procedure, and status producer. Planned integrations + explicitly retain null live IDs, the proposed selected set, and a pending + owner-UI verification state. Provider credentials and account coordinates + remain outside GitHub and this repository. - GitHub Actions defaults to read-only, cannot approve pull requests, and may use only Atrinik, GitHub, Codecov coverage, and explicitly allowed Docker actions. @@ -418,6 +420,73 @@ verifier, confirm the website still builds through Pages, and confirm the metaserver repository no longer appears in the installation UI. Suspending or deleting the shared installation is not an acceptable metaserver rollback. +## Deploy-control repository and GitHub App + +`atrinik/deploy-control` is a public MIT seed repository with stable repository +ID `1352850539`, default branch `main`, and no release-tag or semantic-release +contract. Its desired repository properties are `service`, `replacement`, +`seed`, and `none`. It remains pull-request governed and requires the two +contexts emitted by its current workflows: + +- `Deploy-control validation`, from the Check workflow's `npm run check` job; + and +- `Conventional PR title`, from the Pull Request Policy workflow. + +The repository's Dependabot and CodeQL workflows remain enabled, but they are +not merge gates until a stable aggregate check contract exists. The existing +Actions allowlist, read-only default permissions, merge settings, security +baseline, community-health defaults, and Team-compatible rulesets apply. No +Cloudflare resource, package grant, environment, or deployment is part of this +repository registration. + +The future control-plane integration is recorded in +`config/manual-settings.json` as the planned GitHub App +`atrinik-deploy-control`. The App and installation do not exist yet: both live +numeric IDs are deliberately `null`, while the control-plane repository is +recorded as `atrinik/deploy-control`, ID `1352850539`. After owner provisioning, +the installation must select exactly one repository, `atrinik/classic`, ID +`1327289971`, and subscribe only to `workflow_run` with these exact read-only +permissions: + +- `actions: read`; +- `metadata: read`. + +The App is intended to receive successful Classic workflow-run completion for +the reviewed staging-image handoff. It has no authorization for metaserver, +packages, environments, organization-wide selection, source writes, or any +other repository. Its value-free name-only slots are: + +- secrets: `DEPLOY_CONTROL_AGENT_ENROLLMENT_SECRET`, + `DEPLOY_CONTROL_GITHUB_APP_PRIVATE_KEY`, and + `DEPLOY_CONTROL_GITHUB_WEBHOOK_SECRET`; +- variables: `CLOUDFLARE_ACCOUNT_ID`, + `CLOUDFLARE_DURABLE_OBJECT_BINDING`, `CLOUDFLARE_WORKER_NAME`, + `DEPLOY_CONTROL_GITHUB_APP_ID`, and + `DEPLOY_CONTROL_GITHUB_INSTALLATION_ID`. + +These are future secret and variable names only; no values, private key, +installation token, webhook payload, Cloudflare binding, or account credential +is recorded. Atrinik organization owners own the record and review it every 90 +days. `atrinik/deploy-control#3` owns the control-plane follow-up and the +provisioning runbook. `repository_scope_verification` remains +`pending_provisioning` until an owner creates the App, installs it in selected +mode, and reads back the exact Classic-only selection in the GitHub UI. + +GitHub App creation and installation remain owner-only manual UI operations on +Team. `bin/verify-manual-settings` verifies the stable repository identities and +reports this planned integration as `PENDING`; it does not pretend that an +absent App is live. `bin/publish` remains plan-only for this record and never +creates the App or changes an installation. The existing Cloudflare Workers and +Pages installation and its `atrinik/metaserver-worker` boundary remain +unchanged. + +To revoke the future integration, first stop workflow-run delivery and disable +the control-plane consumer, revoke the App keys and webhook secret, remove only +the named control-plane secret and variable slots, uninstall the App from Classic, +and verify the selected set is empty. Preserve the existing Cloudflare +installation and metaserver access record; do not use this runbook to alter +unrelated Apps or repositories. + ## Cross-repository planning Review and apply the planning layers in their dependency order: diff --git a/bin/validate b/bin/validate index e6230b6..ad0943d 100755 --- a/bin/validate +++ b/bin/validate @@ -314,7 +314,8 @@ jq -e \ (try ((day_epoch($value) | strftime("%Y-%m-%d")) == $value) catch false); .external_provider_apps as $external_apps | .github_actions_apps as $apps | - ($external_apps | type == "array" and length == 1) and + ($external_apps | type == "array" and length == 2) and + ([$external_apps[].status] == ["active", "planned"]) and ($external_apps[0] | keys == [ "app_id", "app_slug", @@ -335,8 +336,10 @@ jq -e \ "review_owner", "revocation", "runbook", + "status", "status_producer" ]) and + ($external_apps[0].status == "active") and ($external_apps[0].app_id == 85455) and ($external_apps[0].app_slug == "cloudflare-workers-and-pages") and ($external_apps[0].installation_id == 152311798) and @@ -392,6 +395,97 @@ jq -e \ (day_epoch($external_apps[0].last_verified_on) <= day_epoch($today)) and (day_epoch($external_apps[0].review_by) > day_epoch($today)) and ((day_epoch($external_apps[0].review_by) - day_epoch($external_apps[0].last_verified_on)) / 86400 == $external_apps[0].review_cadence_days) and + ($external_apps[1] | keys == [ + "app_id", + "app_slug", + "control_plane_repository", + "events", + "evidence_location", + "exceptional_retry", + "installation_id", + "last_verified_on", + "owner", + "permissions", + "provider", + "purpose", + "repositories", + "repository_scope_verification", + "repository_selection", + "review_by", + "review_cadence_days", + "review_owner", + "revocation", + "runbook", + "secret_names", + "status", + "status_producer", + "variable_names" + ]) and + ($external_apps[1].status == "planned") and + ($external_apps[1].app_id == null) and + ($external_apps[1].app_slug == "atrinik-deploy-control") and + ($external_apps[1].control_plane_repository == { + default_branch: "main", + license: "MIT", + repository: "atrinik/deploy-control", + repository_id: 1352850539, + visibility: "public" + }) and + ($external_apps[1].installation_id == null) and + ($external_apps[1].repository_selection == "selected") and + ($external_apps[1].repository_scope_verification == "pending_provisioning") and + ($external_apps[1].events == ["workflow_run"]) and + ($external_apps[1].exceptional_retry == null) and + ($external_apps[1].permissions == { + actions: "read", + metadata: "read" + }) and + ($external_apps[1].provider == "Cloudflare Workers deploy-control control plane") and + ($external_apps[1].status_producer == "Atrinik deploy-control control-plane owner") and + ($external_apps[1].repositories == [{ + purpose: "Authorize only the Classic workflow-run source for the deploy-control control plane.", + repository: "atrinik/classic", + repository_id: 1327289971 + }]) and + ($external_apps[1].control_plane_repository.repository | test("^atrinik/[A-Za-z0-9_.-]+$")) and + ($external_apps[1].control_plane_repository.repository_id | type == "number" and floor == . and . > 0) and + all( + $external_apps[1].repositories[]; + . as $repository_record | + ($repository_record.repository | split("/")[1]) as $repository | + ($repository_record.repository | test("^atrinik/[A-Za-z0-9_.-]+$")) and + ($repository_record.repository_id | type == "number" and floor == . and . > 0) and + ($repository_record.purpose | type == "string" and length > 0) and + ($repositories[0].pull_request_gate | index($repository)) != null and + ($repositories[0].archive | index($repository)) == null + ) and + ($external_apps[1].owner | type == "string" and length > 0) and + ($external_apps[1].purpose | type == "string" and length > 0) and + ($external_apps[1].evidence_location | type == "string" and length > 0) and + ($external_apps[1].review_owner | type == "string" and length > 0) and + ($external_apps[1].revocation | type == "string" and length > 0) and + ($external_apps[1].runbook == "README.md#deploy-control-github-app") and + ($external_apps[1].review_cadence_days == 90) and + valid_day($external_apps[1].last_verified_on) and + valid_day($external_apps[1].review_by) and + (day_epoch($external_apps[1].last_verified_on) <= day_epoch($today)) and + (day_epoch($external_apps[1].review_by) > day_epoch($today)) and + ((day_epoch($external_apps[1].review_by) - day_epoch($external_apps[1].last_verified_on)) / 86400 == $external_apps[1].review_cadence_days) and + ($external_apps[1].secret_names | type == "array" and length > 0 and . == (sort | unique) and all(.[]; type == "string" and test("^[A-Z][A-Z0-9_]*$"))) and + ($external_apps[1].variable_names | type == "array" and length > 0 and . == (sort | unique) and all(.[]; type == "string" and test("^[A-Z][A-Z0-9_]*$"))) and + ($external_apps[1].secret_names == [ + "DEPLOY_CONTROL_AGENT_ENROLLMENT_SECRET", + "DEPLOY_CONTROL_GITHUB_APP_PRIVATE_KEY", + "DEPLOY_CONTROL_GITHUB_WEBHOOK_SECRET" + ]) and + ($external_apps[1].variable_names == [ + "CLOUDFLARE_ACCOUNT_ID", + "CLOUDFLARE_DURABLE_OBJECT_BINDING", + "CLOUDFLARE_WORKER_NAME", + "DEPLOY_CONTROL_GITHUB_APP_ID", + "DEPLOY_CONTROL_GITHUB_INSTALLATION_ID" + ]) and + all($external_apps[1].secret_names[]; . as $secret | ($external_apps[1].variable_names | index($secret)) == null) and ($apps | type == "array" and length > 0) and ([$apps[].app_id] | length == (unique | length)) and ([$apps[].installation_id] | length == (unique | length)) and diff --git a/bin/verify-manual-settings b/bin/verify-manual-settings index 8600a96..c453c28 100755 --- a/bin/verify-manual-settings +++ b/bin/verify-manual-settings @@ -554,11 +554,59 @@ else fi while IFS= read -r app_record; do + app_status=$(jq -r '.status' <<<"${app_record}") app_id=$(jq -r '.app_id' <<<"${app_record}") app_slug=$(jq -r '.app_slug' <<<"${app_record}") installation_id=$(jq -r '.installation_id' <<<"${app_record}") expected_permissions=$(jq -c '.permissions' <<<"${app_record}") expected_events=$(jq -c '.events' <<<"${app_record}") + + if [[ ${app_status} == planned ]]; then + if jq -s -e --arg app_slug "${app_slug}" \ + 'any(.[]; .app_slug == $app_slug)' "${installations_file}" \ + >/dev/null; then + echo "error: planned external provider App has a live installation for ${app_slug}" >&2 + exit 1 + fi + control_plane_repository=$(jq -r '.control_plane_repository.repository' <<<"${app_record}") + control_plane_repository_id=$(jq -r '.control_plane_repository.repository_id' <<<"${app_record}") + if verify_repository_identity "${control_plane_repository}" "${control_plane_repository_id}"; then + : + else + status=$? + exit "${status}" + fi + control_plane_repository_visibility=$(jq -r '.control_plane_repository.visibility' <<<"${app_record}") + control_plane_repository_license=$(jq -r '.control_plane_repository.license' <<<"${app_record}") + if ! jq -e \ + --arg visibility "${control_plane_repository_visibility}" \ + --arg license "${control_plane_repository_license}" \ + '.visibility == $visibility and .license.spdx_id == $license' \ + "${repository_data_files["${control_plane_repository}"]}" >/dev/null; then + echo "error: repository visibility or license drift for ${control_plane_repository}" >&2 + exit 1 + fi + + while IFS= read -r repository_record; do + repository=$(jq -r '.repository' <<<"${repository_record}") + repository_id=$(jq -r '.repository_id' <<<"${repository_record}") + repository_owner=${repository%%/*} + if [[ ${repository_owner} != "${organization}" ]]; then + echo "error: planned external provider App repository owner does not match ${organization}: ${repository}" >&2 + exit 1 + fi + if verify_repository_identity "${repository}" "${repository_id}"; then + : + else + status=$? + exit "${status}" + fi + done < <(jq -c '.repositories[]' <<<"${app_record}") + + echo "PENDING ${app_slug} GitHub App installation and credentials; owner UI proof is required for ${control_plane_repository} and selected repository atrinik/classic only" + continue + fi + installation=$(jq -sc \ --argjson app_id "${app_id}" \ --argjson installation_id "${installation_id}" ' @@ -733,4 +781,4 @@ if [[ ${live_pins} != "${desired_pins}" ]]; then fi echo "KEEP ${organization} organization pins match the exact governed order" -echo "Manual settings live credential, GitHub App, external provider App, Pages, environment, and organization pin metadata is present." +echo "Manual settings live credential, GitHub App, external provider App, Pages, environment, and organization pin metadata is present; planned integrations are recorded without live provisioning." diff --git a/config/manual-settings.json b/config/manual-settings.json index 5eed83a..321892a 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -93,7 +93,61 @@ "review_owner": "Atrinik organization owners", "revocation": "Remove only atrinik/metaserver-worker from installation 152311798 after disconnecting its Cloudflare repository connection; preserve atrinik/website unless its separately governed Pages connection is retired.", "runbook": "README.md#cloudflare-github-app", + "status": "active", "status_producer": "Cloudflare Workers and Pages GitHub App" + }, + { + "app_id": null, + "app_slug": "atrinik-deploy-control", + "control_plane_repository": { + "default_branch": "main", + "license": "MIT", + "repository": "atrinik/deploy-control", + "repository_id": 1352850539, + "visibility": "public" + }, + "events": [ + "workflow_run" + ], + "evidence_location": "atrinik/deploy-control#3", + "exceptional_retry": null, + "installation_id": null, + "last_verified_on": "2026-08-31", + "owner": "Atrinik organization owners", + "permissions": { + "actions": "read", + "metadata": "read" + }, + "provider": "Cloudflare Workers deploy-control control plane", + "purpose": "Receive only successful workflow_run events from atrinik/classic for the reviewed deploy-control staging-image handoff; do not authorize source, package, environment, metaserver, or organization-wide access.", + "repositories": [ + { + "purpose": "Authorize only the Classic workflow-run source for the deploy-control control plane.", + "repository": "atrinik/classic", + "repository_id": 1327289971 + } + ], + "repository_scope_verification": "pending_provisioning", + "repository_selection": "selected", + "review_by": "2026-11-29", + "review_cadence_days": 90, + "review_owner": "Atrinik organization owners", + "revocation": "Before retiring the control plane, disable its workflow-run delivery, revoke the App keys and webhook secret, remove only the named control-plane secret/variable slots, and verify the App installation no longer selects atrinik/classic; leave the Cloudflare Workers and Pages installation and metaserver boundary unchanged.", + "runbook": "README.md#deploy-control-github-app", + "secret_names": [ + "DEPLOY_CONTROL_AGENT_ENROLLMENT_SECRET", + "DEPLOY_CONTROL_GITHUB_APP_PRIVATE_KEY", + "DEPLOY_CONTROL_GITHUB_WEBHOOK_SECRET" + ], + "status": "planned", + "status_producer": "Atrinik deploy-control control-plane owner", + "variable_names": [ + "CLOUDFLARE_ACCOUNT_ID", + "CLOUDFLARE_DURABLE_OBJECT_BINDING", + "CLOUDFLARE_WORKER_NAME", + "DEPLOY_CONTROL_GITHUB_APP_ID", + "DEPLOY_CONTROL_GITHUB_INSTALLATION_ID" + ] } ], "github_actions_environments": [ diff --git a/config/repositories.json b/config/repositories.json index a031f51..a4e1071 100644 --- a/config/repositories.json +++ b/config/repositories.json @@ -5,6 +5,7 @@ "client", "content", "content-toolkit", + "deploy-control", "devcontainer", "editor", "github-settings", @@ -65,6 +66,10 @@ "Content validation", "Conventional PR title" ], + "deploy-control": [ + "Deploy-control validation", + "Conventional PR title" + ], "resources": [ "Asset validation", "Conventional PR title" diff --git a/config/repository-properties.json b/config/repository-properties.json index 9e6a03e..0a8d518 100644 --- a/config/repository-properties.json +++ b/config/repository-properties.json @@ -136,6 +136,16 @@ "lifecycle": "active", "release_policy": "semantic-release" }, + "deploy-control": { + "component_role": [ + "service" + ], + "provider_set": [ + "replacement" + ], + "lifecycle": "seed", + "release_policy": "none" + }, "devcontainer": { "component_role": [ "infrastructure" diff --git a/tests/check-project-health.sh b/tests/check-project-health.sh index 02682d6..05c1b46 100755 --- a/tests/check-project-health.sh +++ b/tests/check-project-health.sh @@ -311,7 +311,7 @@ run_health() { ATRINIK_SETTINGS_TOKEN=settings-token \ ATRINIK_PROJECT_HEALTH_NOW=2026-08-10T05:00:00Z \ ATRINIK_PROJECT_HEALTH_SYNC_COMMAND="${temporary}/bin/sync-plan" \ - ATRINIK_VALIDATION_TODAY=2026-08-15 \ + ATRINIK_VALIDATION_TODAY=2026-08-31 \ GITHUB_STEP_SUMMARY="${temporary}/step-summary" \ "${root}/bin/check-project-health" "$@" } @@ -396,7 +396,7 @@ if PATH="${temporary}/bin:${PATH}" \ GH_TOKEN=health-token ATRINIK_SETTINGS_TOKEN='' \ ATRINIK_PROJECT_HEALTH_NOW=2026-08-10T05:00:00Z \ ATRINIK_PROJECT_HEALTH_SYNC_COMMAND="${temporary}/bin/sync-plan" \ - ATRINIK_VALIDATION_TODAY=2026-08-15 \ + ATRINIK_VALIDATION_TODAY=2026-08-31 \ "${root}/bin/check-project-health" --apply \ >"${temporary}/missing.out" 2>"${temporary}/missing.err"; then echo "error: health check accepted a missing settings credential" >&2 @@ -471,7 +471,7 @@ if PATH="${temporary}/bin:${PATH}" \ GH_TOKEN=health-token ATRINIK_SETTINGS_TOKEN='' \ ATRINIK_PROJECT_HEALTH_NOW=2026-08-10T05:00:00Z \ ATRINIK_PROJECT_HEALTH_SYNC_COMMAND="${temporary}/bin/sync-plan" \ - ATRINIK_VALIDATION_TODAY=2026-08-15 \ + ATRINIK_VALIDATION_TODAY=2026-08-31 \ "${root}/bin/check-project-health" --apply \ >"${temporary}/reopen.out" 2>"${temporary}/reopen.err"; then echo "error: health check accepted a missing credential during recurrence" >&2 diff --git a/tests/publish-repository-properties.sh b/tests/publish-repository-properties.sh index aad4717..a107051 100755 --- a/tests/publish-repository-properties.sh +++ b/tests/publish-repository-properties.sh @@ -61,6 +61,6 @@ output=$( ) [[ $(grep -c '^PLAN PUT /orgs/atrinik/properties/schema/' <<<"${output}") == 4 ]] -[[ $(grep -c '^PLAN PATCH /orgs/atrinik/properties/values ' <<<"${output}") == 25 ]] +[[ $(grep -c '^PLAN PATCH /orgs/atrinik/properties/values ' <<<"${output}") == 26 ]] echo "Repository-property publisher plans every definition and repository value." diff --git a/tests/validate-manual-credentials.sh b/tests/validate-manual-credentials.sh index 3e56b7e..41f9d33 100755 --- a/tests/validate-manual-credentials.sh +++ b/tests/validate-manual-credentials.sh @@ -15,7 +15,7 @@ cp -R "${root}/.github" "${temporary}/.github" assert_invalid() { local description=$1 - if ATRINIK_VALIDATION_TODAY=2026-08-15 \ + if ATRINIK_VALIDATION_TODAY=2026-08-31 \ "${temporary}/bin/validate" >/dev/null 2>&1; then echo "error: validator accepted ${description}" >&2 exit 1 @@ -36,7 +36,7 @@ rewrite_manual_settings() { mv "${output}" "${temporary}/config/manual-settings.json" } -ATRINIK_VALIDATION_TODAY=2026-08-15 \ +ATRINIK_VALIDATION_TODAY=2026-08-31 \ "${temporary}/bin/validate" >/dev/null jq -e ' .external_provider_apps == [{ @@ -82,7 +82,56 @@ jq -e ' review_owner: "Atrinik organization owners", revocation: "Remove only atrinik/metaserver-worker from installation 152311798 after disconnecting its Cloudflare repository connection; preserve atrinik/website unless its separately governed Pages connection is retired.", runbook: "README.md#cloudflare-github-app", + status: "active", status_producer: "Cloudflare Workers and Pages GitHub App" + }, { + app_id: null, + app_slug: "atrinik-deploy-control", + control_plane_repository: { + default_branch: "main", + license: "MIT", + repository: "atrinik/deploy-control", + repository_id: 1352850539, + visibility: "public" + }, + events: ["workflow_run"], + evidence_location: "atrinik/deploy-control#3", + exceptional_retry: null, + installation_id: null, + last_verified_on: "2026-08-31", + owner: "Atrinik organization owners", + permissions: { + actions: "read", + metadata: "read" + }, + provider: "Cloudflare Workers deploy-control control plane", + purpose: "Receive only successful workflow_run events from atrinik/classic for the reviewed deploy-control staging-image handoff; do not authorize source, package, environment, metaserver, or organization-wide access.", + repositories: [{ + purpose: "Authorize only the Classic workflow-run source for the deploy-control control plane.", + repository: "atrinik/classic", + repository_id: 1327289971 + }], + repository_scope_verification: "pending_provisioning", + repository_selection: "selected", + review_by: "2026-11-29", + review_cadence_days: 90, + review_owner: "Atrinik organization owners", + revocation: "Before retiring the control plane, disable its workflow-run delivery, revoke the App keys and webhook secret, remove only the named control-plane secret/variable slots, and verify the App installation no longer selects atrinik/classic; leave the Cloudflare Workers and Pages installation and metaserver boundary unchanged.", + runbook: "README.md#deploy-control-github-app", + secret_names: [ + "DEPLOY_CONTROL_AGENT_ENROLLMENT_SECRET", + "DEPLOY_CONTROL_GITHUB_APP_PRIVATE_KEY", + "DEPLOY_CONTROL_GITHUB_WEBHOOK_SECRET" + ], + status: "planned", + status_producer: "Atrinik deploy-control control-plane owner", + variable_names: [ + "CLOUDFLARE_ACCOUNT_ID", + "CLOUDFLARE_DURABLE_OBJECT_BINDING", + "CLOUDFLARE_WORKER_NAME", + "DEPLOY_CONTROL_GITHUB_APP_ID", + "DEPLOY_CONTROL_GITHUB_INSTALLATION_ID" + ] }] and .github_actions_apps == [{ app_id: 4564008, @@ -172,6 +221,42 @@ rewrite_manual_settings '.external_provider_apps[0].installation_token = "secret assert_invalid 'external provider App credential material' reset_manual_settings +rewrite_manual_settings '.external_provider_apps[1].app_id = 123' +assert_invalid 'a planned external provider App with a fabricated App ID' +reset_manual_settings + +rewrite_manual_settings '.external_provider_apps[1].installation_id = 123' +assert_invalid 'a planned external provider App with a fabricated installation ID' +reset_manual_settings + +rewrite_manual_settings '.external_provider_apps[1].permissions.actions = "write"' +assert_invalid 'a planned external provider App with write Actions permission' +reset_manual_settings + +rewrite_manual_settings '.external_provider_apps[1].events = ["push"]' +assert_invalid 'a planned external provider App with an unexpected event' +reset_manual_settings + +rewrite_manual_settings '.external_provider_apps[1].repositories[0].repository_id = 1' +assert_invalid 'a planned external provider App with a wrong selected repository ID' +reset_manual_settings + +rewrite_manual_settings '.external_provider_apps[1].secret_names[0] = "private-key"' +assert_invalid 'a planned external provider App with a malformed secret name' +reset_manual_settings + +rewrite_manual_settings '.external_provider_apps[1].secret_names[0] = "DEPLOY_CONTROL_AGENT_TOKEN"' +assert_invalid 'a planned external provider App with an unexpected secret name' +reset_manual_settings + +rewrite_manual_settings '.external_provider_apps[1].variable_names[0] = "CLOUDFLARE_ACCOUNT"' +assert_invalid 'a planned external provider App with an unexpected variable name' +reset_manual_settings + +rewrite_manual_settings '.external_provider_apps[1].private_key = "secret"' +assert_invalid 'a planned external provider App with credential material' +reset_manual_settings + rewrite_manual_settings \ '.github_actions_apps += [.github_actions_apps[0]]' assert_invalid 'a duplicate GitHub App record' @@ -295,7 +380,7 @@ assert_invalid 'a non-leap-year February 29' reset_manual_settings rewrite_manual_settings \ - '.github_actions_credentials[0].last_verified_on = "2026-08-16"' + '.github_actions_credentials[0].last_verified_on = "2026-09-01"' assert_invalid 'a future verification date' reset_manual_settings @@ -322,6 +407,8 @@ reset_manual_settings rewrite_manual_settings ' .external_provider_apps[0].last_verified_on = "2028-02-29" | .external_provider_apps[0].review_by = "2028-05-29" | + .external_provider_apps[1].last_verified_on = "2028-02-29" | + .external_provider_apps[1].review_by = "2028-05-29" | .github_actions_apps[0].last_verified_on = "2028-02-29" | .github_actions_apps[0].rotate_by = "2028-05-29" | .github_actions_credentials[0].last_verified_on = "2028-02-29" | diff --git a/tests/validate-planning-health.sh b/tests/validate-planning-health.sh index 975cf28..8217194 100755 --- a/tests/validate-planning-health.sh +++ b/tests/validate-planning-health.sh @@ -13,14 +13,14 @@ assert_invalid() { jq "${filter}" "${root}/config/planning-health.json" \ >"${temporary}/repository/config/planning-health.json" - if ATRINIK_VALIDATION_TODAY=2026-08-15 \ + if ATRINIK_VALIDATION_TODAY=2026-08-31 \ "${temporary}/repository/bin/validate" >/dev/null 2>&1; then echo "error: validator accepted ${description}" >&2 exit 1 fi } -ATRINIK_VALIDATION_TODAY=2026-08-15 \ +ATRINIK_VALIDATION_TODAY=2026-08-31 \ "${temporary}/repository/bin/validate" >/dev/null assert_invalid '.freshness_threshold_minutes = 30' \ diff --git a/tests/verify-manual-settings.sh b/tests/verify-manual-settings.sh index 2c3282d..411a28e 100755 --- a/tests/verify-manual-settings.sh +++ b/tests/verify-manual-settings.sh @@ -88,6 +88,28 @@ repos/atrinik/github-settings) }' fi ;; +repos/atrinik/deploy-control) + repository_id=1352850539 + repository_visibility=public + repository_license=MIT + if [[ ${FAKE_GH_SCENARIO} == planned-control-identity-drift ]]; then + repository_id=1 + fi + if [[ ${FAKE_GH_SCENARIO} == planned-control-license-drift ]]; then + repository_license=GPL-2.0 + fi + jq -n \ + --argjson repository_id "${repository_id}" \ + --arg repository_visibility "${repository_visibility}" \ + --arg repository_license "${repository_license}" '{ + id: $repository_id, + full_name: "atrinik/deploy-control", + archived: false, + default_branch: "main", + visibility: $repository_visibility, + license: {spdx_id: $repository_license} + }' + ;; repos/atrinik/website) repository_id=1327107093 if [[ ${FAKE_GH_SCENARIO} == external-website-identity-drift ]]; then @@ -264,6 +286,8 @@ repos/atrinik/classic/pages) external-app-permission-drift) external_permissions='{"checks":"write","contents":"write","deployments":"write","metadata":"read","pull_requests":"write"}' ;; external-app-suspended) external_suspended_at='"2026-08-15T00:00:00Z"' ;; esac + planned_control=false + [[ ${FAKE_GH_SCENARIO} == planned-control-installed ]] && planned_control=true jq -n \ --argjson app_id "${app_id}" \ --argjson installation_id "${installation_id}" \ @@ -279,9 +303,9 @@ repos/atrinik/classic/pages) --arg external_repository_selection "${external_repository_selection}" \ --argjson external_events "${external_events}" \ --argjson external_permissions "${external_permissions}" \ - --argjson external_suspended_at "${external_suspended_at}" '{ - total_count: 2, - installations: [{ + --argjson external_suspended_at "${external_suspended_at}" \ + --argjson planned_control "${planned_control}" '( + [{ id: $installation_id, app_id: $app_id, app_slug: $app_slug, @@ -301,8 +325,19 @@ repos/atrinik/classic/pages) permissions: $external_permissions, events: $external_events, suspended_at: $external_suspended_at - }] - }' + }] + (if $planned_control then [{ + id: 154000001, + app_id: 85500001, + app_slug: "atrinik-deploy-control", + target_type: "Organization", + account: {login: "atrinik", type: "Organization"}, + repository_selection: "selected", + permissions: {actions: "read", metadata: "read"}, + events: ["workflow_run"], + suspended_at: null + }] else [] end) + ) as $installations | + {total_count: ($installations | length), installations: $installations}' fi ;; "orgs/atrinik/installations?per_page=100&page=2") @@ -590,7 +625,7 @@ run_verify() { FAKE_GH_LOG="${temporary}/gh.log" \ FAKE_GH_SCENARIO="${scenario}" \ GITHUB_ACTIONS=true GH_TOKEN=test-token \ - ATRINIK_VALIDATION_TODAY=2026-08-15 \ + ATRINIK_VALIDATION_TODAY=2026-08-31 \ "${root}/bin/verify-manual-settings" } @@ -606,6 +641,8 @@ grep -Fq 'KEEP cloudflare-workers-and-pages installation metadata and exact perm <<<"${output}" grep -Fq 'MANUAL cloudflare-workers-and-pages selected repositories require owner UI proof: atrinik/website and atrinik/metaserver-worker only' \ <<<"${output}" +grep -Fq 'PENDING atrinik-deploy-control GitHub App installation and credentials; owner UI proof is required for atrinik/deploy-control and selected repository atrinik/classic only' \ + <<<"${output}" grep -Fq 'KEEP atrinik-classic-dependency-updater installation metadata and exact permissions' \ <<<"${output}" grep -Fq 'KEEP atrinik/classic repository Actions secret DEPENDENCY_UPDATE_APP_PRIVATE_KEY' \ @@ -613,7 +650,7 @@ grep -Fq 'KEEP atrinik/classic repository Actions secret DEPENDENCY_UPDATE_APP_P grep -Fq 'KEEP atrinik/classic repository Actions variable DEPENDENCY_UPDATE_APP_ID' \ <<<"${output}" grep -Fq 'KEEP atrinik organization pins match the exact governed order' <<<"${output}" -grep -Fq 'Manual settings live credential, GitHub App, external provider App, Pages, environment, and organization pin metadata is present.' \ +grep -Fq 'Manual settings live credential, GitHub App, external provider App, Pages, environment, and organization pin metadata is present; planned integrations are recorded without live provisioning.' \ <<<"${output}" : >"${temporary}/gh.log" @@ -665,7 +702,7 @@ output=$(PATH="${temporary}/bin:${PATH}" \ FAKE_GH_LOG="${temporary}/gh.log" \ FAKE_GH_SCENARIO=environment-page2 \ GITHUB_ACTIONS=true GH_TOKEN=test-token \ - ATRINIK_VALIDATION_TODAY=2026-08-15 \ + ATRINIK_VALIDATION_TODAY=2026-08-31 \ "${environment_page_root}/bin/verify-manual-settings") grep -Fq 'KEEP atrinik/classic environment discord-release metadata' <<<"${output}" grep -Fq 'deployment-branch-policies?per_page=100&page=2' "${temporary}/gh.log" @@ -689,7 +726,7 @@ output=$(PATH="${temporary}/bin:${PATH}" \ FAKE_GH_LOG="${temporary}/gh.log" \ FAKE_GH_SCENARIO=shared-repository \ GITHUB_ACTIONS=true GH_TOKEN=test-token \ - ATRINIK_VALIDATION_TODAY=2026-08-15 \ + ATRINIK_VALIDATION_TODAY=2026-08-31 \ "${shared_root}/bin/verify-manual-settings") grep -Fq 'SECOND_SETTINGS_TOKEN' <<<"${output}" [[ $(grep -Fc 'repos/atrinik/github-settings' "${temporary}/gh.log") == 2 ]] @@ -737,6 +774,9 @@ app_failures=( external-app-events-drift external-app-permission-drift external-app-suspended + planned-control-installed + planned-control-identity-drift + planned-control-license-drift external-website-identity-drift external-metaserver-identity-drift ) @@ -758,6 +798,12 @@ grep -Fq 'external provider App installation metadata or permission drift' \ "${temporary}/external-app-permission-drift.err" grep -Fq 'repository identity or active-state drift for atrinik/website' \ "${temporary}/external-website-identity-drift.err" +grep -Fq 'repository identity or active-state drift for atrinik/deploy-control' \ + "${temporary}/planned-control-identity-drift.err" +grep -Fq 'planned external provider App has a live installation for atrinik-deploy-control' \ + "${temporary}/planned-control-installed.err" +grep -Fq 'repository visibility or license drift for atrinik/deploy-control' \ + "${temporary}/planned-control-license-drift.err" grep -Fq 'GitHub App Actions secret name is missing' \ "${temporary}/missing-app-secret.err" grep -Fq 'GitHub App Actions variable name is missing' \ @@ -896,7 +942,7 @@ fi : >"${temporary}/gh.log" if PATH="${temporary}/bin:${PATH}" \ FAKE_GH_LOG="${temporary}/gh.log" FAKE_GH_SCENARIO=present \ - GITHUB_ACTIONS=true GH_TOKEN='' ATRINIK_VALIDATION_TODAY=2026-08-15 \ + GITHUB_ACTIONS=true GH_TOKEN='' ATRINIK_VALIDATION_TODAY=2026-08-31 \ "${root}/bin/verify-manual-settings" \ >"${temporary}/empty.out" 2>"${temporary}/empty.err"; then echo "error: manual-settings verifier accepted an empty workflow credential" >&2 @@ -905,4 +951,4 @@ fi grep -Fq 'ATRINIK_SETTINGS_TOKEN is unavailable' "${temporary}/empty.err" [[ ! -s ${temporary}/gh.log ]] -echo "Manual settings live credential, GitHub App, external provider App, Pages, and environment verification tests passed." +echo "Manual settings live credential, GitHub App, external provider App, planned integration, Pages, and environment verification tests passed."