diff --git a/AGENTS.md b/AGENTS.md index b585669..48016a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,11 @@ never deletes a branch, tag, release, or asset. - 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. - 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/CONTRIBUTING.md b/CONTRIBUTING.md index 7a7100a..b203d15 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,15 @@ repository set in GitHub's installation UI. Keep the App out of bypass lists, preserve read-only Actions defaults, and prove the consumer's disposable pull request receives ordinary checks without self-approval or self-merge. +External provider GitHub Apps use a separate value-free inventory entry with +stable App, installation, and selected repository IDs; exact permissions and +events; provider purpose and status producer; accountable review ownership; +and a revocation runbook. Preserve every existing selected repository unless a +separate reviewed retirement authorizes its removal, never select all +repositories, and require organization-owner UI proof of the exact set when +GitHub's administrative API cannot enumerate it. Provider tokens, account +coordinates, and credential values do not belong in this repository. + Organization identity changes must preserve the exact public description and canonical website in `config/organization.json`, remain delta-aware in plan and apply modes, and include unchanged-state coverage. Keep the public profile in diff --git a/README.md b/README.md index 5b7a49d..cea119b 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,11 @@ 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. - GitHub Actions defaults to read-only, cannot approve pull requests, and may use only Atrinik, GitHub, Codecov coverage, and explicitly allowed Docker actions. @@ -338,6 +343,77 @@ If any ID, permission, event, suspension state, selected repository, or credential name differs, stop. Disable the consumer and reconcile reviewed desired state before minting another installation token. +## Cloudflare GitHub App + +The existing organization installation for the **Cloudflare Workers and +Pages** GitHub App is App ID `85455`, installation ID `152311798`, and selected- +repository mode. Its exact effective GitHub permissions are administration, +checks, contents, deployments, and pull requests write plus metadata read; its +event subscriptions are exactly `pull_request` and `push`. These broad App +permissions are provider-managed GitHub capabilities, not the Cloudflare build +token or runtime authority. Issue +[`atrinik/metaserver-worker#56`](https://github.com/atrinik/metaserver-worker/issues/56) +separately owns the least-privilege Cloudflare identities and provider +connection. + +The selected set must be exactly these two stable repositories: + +- `atrinik/website`, ID `1327107093`, preserving its existing Pages + integration; and +- `atrinik/metaserver-worker`, ID `1324297032`, for the serialized Workers + Builds topology reviewed in metaserver-worker issues 53-56. + +No other repository and never **All repositories** is authorized. GitHub's +organization-installations API verifies the public installation identity, +mode, effective permissions, events, organization owner, and suspension state. +It does not give this administrative token an authoritative enumeration of the +selected set, so an organization owner must verify that exact two-repository +set in +. +`bin/verify-manual-settings` deliberately reports this remaining manual proof; +it also verifies both repository IDs read-only and never reads a credential +value. + +After the metaserver-worker production and review contracts are merged and +validated, a separately authorized organization owner may edit only this +installation: preserve `website`, add only `metaserver-worker`, save selected- +repository mode, and immediately re-open the selection to confirm the exact +two entries. Do not change App permissions, events, the website connection, +repository rulesets, or any unrelated installation. Run +`bin/verify-manual-settings` before and after the UI step and retain the owner +confirmation in the owner-controlled +`atrinik/metaserver-worker#56-private-provider-evidence` record. That stable +coordinate is public governance metadata; the evidence contents remain private +and contain no credential values. `bin/publish` remains plan-only for this +record and never changes installation access. + +The App may publish its native Cloudflare check/deployment result. For +`metaserver-worker`, `main` remains the sole automatic production branch and a +normal protected-branch pull-request merge is the routine authorization; do +not add another production branch, an Actions environment approval, a release +or tag gate, Deploy Hook, privileged dispatch, bypass actor, or GitHub Actions +deployment credential. The existing PR-only `main` rule remains unchanged. +The provider's post-merge production result is evidence, not a pre-merge merge +gate. Any review-branch result described by issue #55 must be observed as +stable and unambiguous before a later reviewed governance change can make it a +required check. + +Migration or separately authorized control-plane recovery may retry only the +provider build for the exact SHA that is still current `main`. It is not a +GitHub branch-policy bypass: never push a recovery ref, create a second +long-lived production branch, invoke a privileged GitHub dispatch, or weaken +the pull-request gate. If `main` advances, abandon that retry and re-evaluate +the new current revision through the provider-owned recovery contract. + +To revoke metaserver access, first disconnect the repository in Cloudflare and +stop its Builds triggers under the recovery procedure owned by #56. Then merge +a reviewed desired-state removal and, with separate organization-owner +authorization, remove only `atrinik/metaserver-worker` from installation +`152311798`. Preserve `atrinik/website` and selected-repository mode. Re-run the +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. + ## Cross-repository planning Review and apply the planning layers in their dependency order: diff --git a/bin/publish b/bin/publish index 169193f..e8856c0 100755 --- a/bin/publish +++ b/bin/publish @@ -1359,4 +1359,10 @@ done < <(jq -r '.archive[]' "${repositories_config}") if [[ $(jq -r '.members_can_create_teams' <<<"${organization_metadata}") != false ]]; then echo "MANUAL members can still create teams; disable this in Member privileges." fi +while IFS= read -r external_app; do + app_slug=$(jq -r '.app_slug' <<<"${external_app}") + installation_id=$(jq -r '.installation_id' <<<"${external_app}") + repositories=$(jq -r '[.repositories[].repository] | join(", ")' <<<"${external_app}") + echo "MANUAL ${app_slug} installation ${installation_id} selected repositories: ${repositories}; publisher does not apply" +done < <(jq -c '.external_provider_apps[]' "${root}/config/manual-settings.json") echo "Manual confirmation required for config/manual-settings.json." diff --git a/bin/validate b/bin/validate index 86ee72e..8eb707e 100755 --- a/bin/validate +++ b/bin/validate @@ -286,6 +286,7 @@ jq -e '. == {state: "not-configured"}' \ jq -e ' (keys == [ "codecov_github_app_repositories", + "external_provider_apps", "github_actions_apps", "github_actions_credentials", "github_actions_environments", @@ -311,7 +312,86 @@ jq -e \ def valid_day($value): ($value | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) and (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[0] | keys == [ + "app_id", + "app_slug", + "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", + "status_producer" + ]) 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 + ($external_apps[0].repository_selection == "selected") and + ($external_apps[0].repository_scope_verification == "organization_owner_ui") and + ($external_apps[0].events == ["pull_request", "push"]) and + ($external_apps[0].evidence_location == "atrinik/metaserver-worker#56-private-provider-evidence") and + ($external_apps[0].exceptional_retry == { + authorization: "provider-retry-only", + long_lived_production_branches: ["main"], + source: "exact-sha-still-current-main", + stale_policy: "abandon-and-re-evaluate-without-github-bypass" + }) and + ($external_apps[0].permissions == { + administration: "write", + checks: "write", + contents: "write", + deployments: "write", + metadata: "read", + pull_requests: "write" + }) and + ($external_apps[0].provider == "Cloudflare Workers Builds and Pages") and + ($external_apps[0].status_producer == "Cloudflare Workers and Pages GitHub App") and + ($external_apps[0].repositories == [ + { + purpose: "Preserve the existing Cloudflare Pages website connection.", + repository: "atrinik/website", + repository_id: 1327107093 + }, + { + purpose: "Authorize the serialized Workers Builds topology governed by atrinik/metaserver-worker issues 53-56.", + repository: "atrinik/metaserver-worker", + repository_id: 1324297032 + } + ]) and + all( + $external_apps[0].repositories[]; + (.repository | split("/")[1]) as $repository | + (.repository | test("^atrinik/[A-Za-z0-9_.-]+$")) and + (.repository_id | type == "number" and floor == . and . > 0) and + (.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[0].owner | type == "string" and length > 0) and + ($external_apps[0].purpose | type == "string" and length > 0) and + ($external_apps[0].review_owner | type == "string" and length > 0) and + ($external_apps[0].revocation | type == "string" and length > 0) and + ($external_apps[0].runbook == "README.md#cloudflare-github-app") and + ($external_apps[0].review_cadence_days == 90) and + valid_day($external_apps[0].last_verified_on) and + valid_day($external_apps[0].review_by) and + (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 ($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 b831425..8600a96 100755 --- a/bin/verify-manual-settings +++ b/bin/verify-manual-settings @@ -553,6 +553,60 @@ else exit "${status}" fi +while IFS= read -r app_record; do + 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}") + installation=$(jq -sc \ + --argjson app_id "${app_id}" \ + --argjson installation_id "${installation_id}" ' + [.[] | select(.app_id == $app_id and .id == $installation_id)] | + if length == 1 then .[0] else empty end + ' "${installations_file}") + if [[ -z ${installation} ]]; then + echo "error: external provider App installation is missing or ambiguous for ${app_slug}" >&2 + exit 1 + fi + if ! jq -e \ + --arg app_slug "${app_slug}" \ + --arg organization "${organization}" \ + --argjson expected_permissions "${expected_permissions}" \ + --argjson expected_events "${expected_events}" ' + .app_slug == $app_slug and + .target_type == "Organization" and + .account.login == $organization and + .account.type == "Organization" and + .repository_selection == "selected" and + .permissions == $expected_permissions and + .events == $expected_events and + .suspended_at == null + ' <<<"${installation}" >/dev/null; then + echo "error: external provider App installation metadata or permission drift for ${app_slug}" >&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: 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 "KEEP ${app_slug} installation metadata and exact permissions/events" + echo "MANUAL ${app_slug} selected repositories require owner UI proof: atrinik/website and atrinik/metaserver-worker only" +done < <(jq -c '.external_provider_apps[]' "${manual_settings}") + while IFS= read -r app_record; do app_id=$(jq -r '.app_id' <<<"${app_record}") app_slug=$(jq -r '.app_slug' <<<"${app_record}") @@ -679,4 +733,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, 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." diff --git a/config/manual-settings.json b/config/manual-settings.json index 0c34022..5eed83a 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -44,6 +44,58 @@ "renderer", "server" ], + "external_provider_apps": [ + { + "app_id": 85455, + "app_slug": "cloudflare-workers-and-pages", + "events": [ + "pull_request", + "push" + ], + "evidence_location": "atrinik/metaserver-worker#56-private-provider-evidence", + "exceptional_retry": { + "authorization": "provider-retry-only", + "long_lived_production_branches": [ + "main" + ], + "source": "exact-sha-still-current-main", + "stale_policy": "abandon-and-re-evaluate-without-github-bypass" + }, + "installation_id": 152311798, + "last_verified_on": "2026-08-15", + "owner": "Atrinik organization owners", + "permissions": { + "administration": "write", + "checks": "write", + "contents": "write", + "deployments": "write", + "metadata": "read", + "pull_requests": "write" + }, + "provider": "Cloudflare Workers Builds and Pages", + "purpose": "Allow the existing website Pages connection and the reviewed metaserver-worker Workers Builds connection to receive GitHub events, read source, and publish provider-owned checks and deployments without access to any other Atrinik repository.", + "repositories": [ + { + "purpose": "Preserve the existing Cloudflare Pages website connection.", + "repository": "atrinik/website", + "repository_id": 1327107093 + }, + { + "purpose": "Authorize the serialized Workers Builds topology governed by atrinik/metaserver-worker issues 53-56.", + "repository": "atrinik/metaserver-worker", + "repository_id": 1324297032 + } + ], + "repository_scope_verification": "organization_owner_ui", + "repository_selection": "selected", + "review_by": "2026-11-13", + "review_cadence_days": 90, + "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_producer": "Cloudflare Workers and Pages GitHub App" + } + ], "github_actions_environments": [ { "deployment_branch_policy": { diff --git a/tests/check-project-health.sh b/tests/check-project-health.sh index d436983..02682d6 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-11 \ + ATRINIK_VALIDATION_TODAY=2026-08-15 \ 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-11 \ + ATRINIK_VALIDATION_TODAY=2026-08-15 \ "${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-11 \ + ATRINIK_VALIDATION_TODAY=2026-08-15 \ "${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-maintenance-branch.sh b/tests/publish-maintenance-branch.sh index ebaa0bc..846ab0f 100755 --- a/tests/publish-maintenance-branch.sh +++ b/tests/publish-maintenance-branch.sh @@ -1429,6 +1429,9 @@ grep -F \ grep -F \ 'PLAN VERIFY /repos/atrinik/classic/immutable-releases => enabled=true,enforced_by_owner=true' \ "${plan_output}" >/dev/null +grep -F \ + 'MANUAL cloudflare-workers-and-pages installation 152311798 selected repositories: atrinik/website, atrinik/metaserver-worker; publisher does not apply' \ + "${plan_output}" >/dev/null jq -e '. == {enforced_repositories: "none"}' \ "${plan_immutable_state}" >/dev/null jq -s -e ' diff --git a/tests/validate-manual-credentials.sh b/tests/validate-manual-credentials.sh index 88732d1..3e56b7e 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-11 \ + if ATRINIK_VALIDATION_TODAY=2026-08-15 \ "${temporary}/bin/validate" >/dev/null 2>&1; then echo "error: validator accepted ${description}" >&2 exit 1 @@ -36,9 +36,54 @@ rewrite_manual_settings() { mv "${output}" "${temporary}/config/manual-settings.json" } -ATRINIK_VALIDATION_TODAY=2026-08-11 \ +ATRINIK_VALIDATION_TODAY=2026-08-15 \ "${temporary}/bin/validate" >/dev/null jq -e ' + .external_provider_apps == [{ + app_id: 85455, + app_slug: "cloudflare-workers-and-pages", + events: ["pull_request", "push"], + evidence_location: "atrinik/metaserver-worker#56-private-provider-evidence", + exceptional_retry: { + authorization: "provider-retry-only", + long_lived_production_branches: ["main"], + source: "exact-sha-still-current-main", + stale_policy: "abandon-and-re-evaluate-without-github-bypass" + }, + installation_id: 152311798, + last_verified_on: "2026-08-15", + owner: "Atrinik organization owners", + permissions: { + administration: "write", + checks: "write", + contents: "write", + deployments: "write", + metadata: "read", + pull_requests: "write" + }, + provider: "Cloudflare Workers Builds and Pages", + purpose: "Allow the existing website Pages connection and the reviewed metaserver-worker Workers Builds connection to receive GitHub events, read source, and publish provider-owned checks and deployments without access to any other Atrinik repository.", + repositories: [ + { + purpose: "Preserve the existing Cloudflare Pages website connection.", + repository: "atrinik/website", + repository_id: 1327107093 + }, + { + purpose: "Authorize the serialized Workers Builds topology governed by atrinik/metaserver-worker issues 53-56.", + repository: "atrinik/metaserver-worker", + repository_id: 1324297032 + } + ], + repository_scope_verification: "organization_owner_ui", + repository_selection: "selected", + review_by: "2026-11-13", + review_cadence_days: 90, + 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_producer: "Cloudflare Workers and Pages GitHub App" + }] and .github_actions_apps == [{ app_id: 4564008, app_slug: "atrinik-classic-dependency-updater", @@ -91,6 +136,42 @@ jq -e ' }] ' "${root}/config/manual-settings.json" >/dev/null +rewrite_manual_settings '.external_provider_apps[0].repository_selection = "all"' +assert_invalid 'an external provider App installed on all repositories' +reset_manual_settings + +rewrite_manual_settings 'del(.external_provider_apps[0].repositories[0])' +assert_invalid 'an external provider App that drops website access' +reset_manual_settings + +rewrite_manual_settings '.external_provider_apps[0].repositories += [{purpose: "unrelated", repository: "atrinik/classic", repository_id: 1327289971}]' +assert_invalid 'an external provider App with unrelated repository access' +reset_manual_settings + +rewrite_manual_settings 'del(.external_provider_apps[0].repositories[1])' +assert_invalid 'an external provider App without metaserver-worker access' +reset_manual_settings + +rewrite_manual_settings '.external_provider_apps[0].permissions.contents = "read"' +assert_invalid 'external provider App permission drift' +reset_manual_settings + +rewrite_manual_settings '.external_provider_apps[0].events = ["push"]' +assert_invalid 'external provider App event drift' +reset_manual_settings + +rewrite_manual_settings '.external_provider_apps[0].evidence_location = ""' +assert_invalid 'an external provider App without its owner evidence location' +reset_manual_settings + +rewrite_manual_settings '.external_provider_apps[0].exceptional_retry.source = "branch"' +assert_invalid 'an external provider App retry not pinned to exact current main' +reset_manual_settings + +rewrite_manual_settings '.external_provider_apps[0].installation_token = "secret"' +assert_invalid 'external provider App credential material' +reset_manual_settings + rewrite_manual_settings \ '.github_actions_apps += [.github_actions_apps[0]]' assert_invalid 'a duplicate GitHub App record' @@ -214,7 +295,7 @@ assert_invalid 'a non-leap-year February 29' reset_manual_settings rewrite_manual_settings \ - '.github_actions_credentials[0].last_verified_on = "2026-08-12"' + '.github_actions_credentials[0].last_verified_on = "2026-08-16"' assert_invalid 'a future verification date' reset_manual_settings @@ -239,6 +320,8 @@ assert_invalid 'a top-level credential-value field' 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" | .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 9303d4d..975cf28 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-11 \ + if ATRINIK_VALIDATION_TODAY=2026-08-15 \ "${temporary}/repository/bin/validate" >/dev/null 2>&1; then echo "error: validator accepted ${description}" >&2 exit 1 fi } -ATRINIK_VALIDATION_TODAY=2026-08-11 \ +ATRINIK_VALIDATION_TODAY=2026-08-15 \ "${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 ed3c426..2c3282d 100755 --- a/tests/verify-manual-settings.sh +++ b/tests/verify-manual-settings.sh @@ -88,6 +88,30 @@ repos/atrinik/github-settings) }' fi ;; +repos/atrinik/website) + repository_id=1327107093 + if [[ ${FAKE_GH_SCENARIO} == external-website-identity-drift ]]; then + repository_id=1 + fi + jq -n --argjson repository_id "${repository_id}" '{ + id: $repository_id, + full_name: "atrinik/website", + archived: false, + default_branch: "main" + }' + ;; +repos/atrinik/metaserver-worker) + repository_id=1324297032 + if [[ ${FAKE_GH_SCENARIO} == external-metaserver-identity-drift ]]; then + repository_id=1 + fi + jq -n --argjson repository_id "${repository_id}" '{ + id: $repository_id, + full_name: "atrinik/metaserver-worker", + archived: false, + default_branch: "main" + }' + ;; repos/atrinik/classic) if [[ ${FAKE_GH_SCENARIO} == environment-identity-drift ]]; then jq -n '{ @@ -168,7 +192,7 @@ repos/atrinik/classic/pages) elif [[ ${FAKE_GH_SCENARIO} == app-installations-page2 || \ ${FAKE_GH_SCENARIO} == app-installations-page2-failure ]]; then jq -n '{ - total_count: 101, + total_count: 102, installations: [range(0; 100) | { id: (1000 + .), app_id: (2000 + .), @@ -182,6 +206,28 @@ repos/atrinik/classic/pages) }] }' elif [[ ${FAKE_GH_SCENARIO} == missing-app ]]; then + jq -n '{ + total_count: 1, + installations: [{ + id: 152311798, + app_id: 85455, + app_slug: "cloudflare-workers-and-pages", + target_type: "Organization", + account: {login: "atrinik", type: "Organization"}, + repository_selection: "selected", + permissions: { + administration: "write", + checks: "write", + contents: "write", + deployments: "write", + metadata: "read", + pull_requests: "write" + }, + events: ["pull_request", "push"], + suspended_at: null + }] + }' + elif [[ ${FAKE_GH_SCENARIO} == missing-external-app ]]; then jq -n '{total_count: 0, installations: []}' else app_id=4564008 @@ -202,6 +248,22 @@ repos/atrinik/classic/pages) app-permission-drift) permissions='{"actions":"write","contents":"write","metadata":"read","pull_requests":"write"}' ;; app-suspended) suspended_at='"2026-08-11T00:00:00Z"' ;; esac + external_app_id=85455 + external_installation_id=152311798 + external_app_slug=cloudflare-workers-and-pages + external_repository_selection=selected + external_events='["pull_request","push"]' + external_permissions='{"administration":"write","checks":"write","contents":"write","deployments":"write","metadata":"read","pull_requests":"write"}' + external_suspended_at=null + case ${FAKE_GH_SCENARIO} in + external-app-id-drift) external_app_id=1 ;; + external-app-installation-id-drift) external_installation_id=1 ;; + external-app-slug-drift) external_app_slug=other-app ;; + external-app-selection-drift) external_repository_selection=all ;; + external-app-events-drift) external_events='["push"]' ;; + 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 jq -n \ --argjson app_id "${app_id}" \ --argjson installation_id "${installation_id}" \ @@ -210,8 +272,15 @@ repos/atrinik/classic/pages) --arg account_login "${account_login}" \ --argjson events "${events}" \ --argjson permissions "${permissions}" \ - --argjson suspended_at "${suspended_at}" '{ - total_count: 1, + --argjson suspended_at "${suspended_at}" \ + --argjson external_app_id "${external_app_id}" \ + --argjson external_installation_id "${external_installation_id}" \ + --arg external_app_slug "${external_app_slug}" \ + --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: [{ id: $installation_id, app_id: $app_id, @@ -222,6 +291,16 @@ repos/atrinik/classic/pages) permissions: $permissions, events: $events, suspended_at: $suspended_at + }, { + id: $external_installation_id, + app_id: $external_app_id, + app_slug: $external_app_slug, + target_type: "Organization", + account: {login: "atrinik", type: "Organization"}, + repository_selection: $external_repository_selection, + permissions: $external_permissions, + events: $external_events, + suspended_at: $external_suspended_at }] }' fi @@ -233,7 +312,7 @@ repos/atrinik/classic/pages) fi [[ ${FAKE_GH_SCENARIO} == app-installations-page2 ]] jq -n '{ - total_count: 101, + total_count: 102, installations: [{ id: 153045686, app_id: 4564008, @@ -248,6 +327,23 @@ repos/atrinik/classic/pages) }, events: [], suspended_at: null + }, { + id: 152311798, + app_id: 85455, + app_slug: "cloudflare-workers-and-pages", + target_type: "Organization", + account: {login: "atrinik", type: "Organization"}, + repository_selection: "selected", + permissions: { + administration: "write", + checks: "write", + contents: "write", + deployments: "write", + metadata: "read", + pull_requests: "write" + }, + events: ["pull_request", "push"], + suspended_at: null }] }' ;; @@ -494,7 +590,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-11 \ + ATRINIK_VALIDATION_TODAY=2026-08-15 \ "${root}/bin/verify-manual-settings" } @@ -506,6 +602,10 @@ grep -Fq 'KEEP atrinik/classic environment discord-release metadata' <<<"${outpu grep -Fq 'PENDING atrinik/classic Pages remains on the exact legacy main-root source' \ <<<"${output}" grep -Fq 'KEEP atrinik/classic environment github-pages metadata' <<<"${output}" +grep -Fq 'KEEP cloudflare-workers-and-pages installation metadata and exact permissions/events' \ + <<<"${output}" +grep -Fq 'MANUAL cloudflare-workers-and-pages selected repositories require owner UI proof: atrinik/website and atrinik/metaserver-worker 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' \ @@ -513,7 +613,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, 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.' \ <<<"${output}" : >"${temporary}/gh.log" @@ -565,7 +665,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-11 \ + ATRINIK_VALIDATION_TODAY=2026-08-15 \ "${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" @@ -589,7 +689,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-11 \ + ATRINIK_VALIDATION_TODAY=2026-08-15 \ "${shared_root}/bin/verify-manual-settings") grep -Fq 'SECOND_SETTINGS_TOKEN' <<<"${output}" [[ $(grep -Fc 'repos/atrinik/github-settings' "${temporary}/gh.log") == 2 ]] @@ -613,6 +713,7 @@ for scenario in pin-drift malformed-pins; do done app_failures=( + missing-external-app missing-app app-id-drift app-installation-id-drift @@ -629,6 +730,15 @@ app_failures=( app-api-failure app-variable-api-failure app-installations-page2-failure + external-app-id-drift + external-app-installation-id-drift + external-app-slug-drift + external-app-selection-drift + external-app-events-drift + external-app-permission-drift + external-app-suspended + external-website-identity-drift + external-metaserver-identity-drift ) for scenario in "${app_failures[@]}"; do : >"${temporary}/gh.log" @@ -640,8 +750,14 @@ for scenario in "${app_failures[@]}"; do done grep -Fq 'GitHub App installation is missing or ambiguous' \ "${temporary}/missing-app.err" +grep -Fq 'external provider App installation is missing or ambiguous' \ + "${temporary}/missing-external-app.err" grep -Fq 'GitHub App installation metadata or permission drift' \ "${temporary}/app-permission-drift.err" +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 'GitHub App Actions secret name is missing' \ "${temporary}/missing-app-secret.err" grep -Fq 'GitHub App Actions variable name is missing' \ @@ -780,7 +896,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-11 \ + GITHUB_ACTIONS=true GH_TOKEN='' ATRINIK_VALIDATION_TODAY=2026-08-15 \ "${root}/bin/verify-manual-settings" \ >"${temporary}/empty.out" 2>"${temporary}/empty.err"; then echo "error: manual-settings verifier accepted an empty workflow credential" >&2 @@ -789,4 +905,4 @@ fi grep -Fq 'ATRINIK_SETTINGS_TOKEN is unavailable' "${temporary}/empty.err" [[ ! -s ${temporary}/gh.log ]] -echo "Manual settings live credential, GitHub App, Pages, and environment verification tests passed." +echo "Manual settings live credential, GitHub App, external provider App, Pages, and environment verification tests passed."