diff --git a/AGENTS.md b/AGENTS.md index 5150f6b..351c686 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,7 +173,11 @@ consumer paths, verified key creation date, verification date, rotation owner, and a rotate-by deadline within the existing 90-day cadence. The deadline is internal policy, not upstream expiry; verify replacement workflow use before - revoking the old key. + revoking the old key. Keep a selected-set change after the recorded readback + as pending `repository_scope_reverification` until a later readback replaces + it. An owner-accepted key exposure stays until rotation, never extends + rotate-by, and is removed on rotation. Both records pin the exact editor + transfer. - Record GitHub Pages sites with stable repository identity, exact provider URL, HTTPS enforcement, owning deployment environment, desired Actions source, workflow path, and immutable deployment-action marker. Before that marker is @@ -231,7 +235,8 @@ - Remove a repository transferred out of the organization from every desired-state inventory, validator identity map and test fixture in one change, and record its transfer in `README.md#transferred-repositories`. - Historical evidence sections stay as written. + Historical evidence sections stay as written. Validated evidence records of + the transfer itself may pin the former identity. - Update this `AGENTS.md` in the same change when major rework alters governance ownership, policy scope, publisher behavior, required checks, or validation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1776b5a..45e045a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -251,7 +251,11 @@ creation/verification dates, rotation owner, and a deadline within 90 days of ke creation; that internal deadline does not assert upstream expiry. Verify a replacement key in the owning writing workflow before revoking the old key. Keep App-authenticated selected-set readback or owner -UI evidence, and do not infer the webhook switch from an empty event set. +UI evidence, and do not infer the webhook switch from an empty event set. A +selected-set change after that readback stays pending re-verification until a +later readback is recorded. An owner-accepted key exposure stays until the key +rotates and never extends the rotate-by deadline; both records pin the exact +editor transfer. Private linux-build consumption needs separate client-only read grants through owner UI. Keep exact private package/source identities and pending grant state until observed; metadata and owner pulls do not prove an effective owning diff --git a/README.md b/README.md index 74a20ec..e00fa6c 100644 --- a/README.md +++ b/README.md @@ -1589,9 +1589,13 @@ reviewed source workflows produced the signed immutable releases used for the initial library publications. Protocol registration was owner-observed on 2026-10-11 and its manual 0.2 upload succeeded in [run 38156755961](https://github.com/atrinik/protocol/actions/runs/38156755961). -The fourteen renderer/toolkit names have now been bootstrapped and their exact -Trusted Publisher registrations are owner-observed. Their automatic jobs passed -OIDC authentication and reconciliation of the existing published coordinates; +The fourteen initial renderer/toolkit libraries have been bootstrapped and their +exact Trusted Publisher registrations are owner-observed: the seven +content-toolkit libraries in the table below, and the seven renderer libraries +that were registered for `atrinik/renderer` before it left the organization +(see [Renderer Trusted Publisher revocation](#renderer-trusted-publisher-revocation)). +Their automatic jobs passed OIDC authentication and reconciliation of the +existing published coordinates; [the onboarding evidence below](#initial-library-onboarding-evidence) records that scope. A source inventory update records these observations; it does not perform external registration or publication. @@ -1651,12 +1655,15 @@ are the supported provisioning references. Activation proceeds in this order: 2. Verify live repository ID/name, active status, default branch, exact main ref, protected main, and both merged publication/source-signing workflows. Record `source_state: observed-reviewed-workflow` only after that observation. -3. The fourteen renderer and toolkit names above require a first owner-local - publication because Trusted Publishing cannot create their initial crate - records. Prepare, review, and verify the exact release archives, package - dependency order, versions, digests, native prerequisites, and registry - readiness first. The owner uses a narrowly scoped local API token only for - that initial bootstrap, then revokes it. Exclude the renderer CLI +3. The fourteen initial renderer and toolkit names (the toolkit libraries in + the table above and the seven renderer libraries listed under + [Renderer Trusted Publisher revocation](#renderer-trusted-publisher-revocation)) + require a first owner-local publication because Trusted Publishing cannot + create their initial crate records. Prepare, review, and verify the exact + release archives, package dependency order, versions, digests, native + prerequisites, and registry readiness first. The owner uses a narrowly + scoped local API token only for that initial bootstrap, then revokes it. + Exclude the renderer CLI `atrinik-render` and toolkit applications `atrinik-content` and `atrinik-content-mcp`. Protocol already exists and needs no bootstrap. No bootstrap token is sent to GitHub or stored in this inventory. @@ -1676,9 +1683,15 @@ are the supported provisioning references. Activation proceeds in this order: ### Initial library onboarding evidence On 2026-10-12 (UTC), the owner confirmed Trusted Publisher registration for all -fourteen renderer/toolkit names in the table above, with their respective exact -repository, `publish-crate.yml` and `crates-io-release` environment. Registration -is external owner evidence; the GitHub metadata API does not enumerate it. +fourteen renderer/toolkit names, with their respective exact repository, +`publish-crate.yml` and `crates-io-release` environment: the seven +`atrinik/content-toolkit` libraries in the table above, and the seven +`atrinik/renderer` (`1327106998`) libraries `atrinik-render-api`, +`atrinik-render-resources`, `atrinik-render-sdl3`, `atrinik-render-testkit`, +`atrinik-render-ui`, `atrinik-render-wgpu` and `atrinik-scene`. The table no +longer lists the renderer because it left the organization on 2026-10-17. +Registration is external owner evidence; the GitHub metadata API does not +enumerate it. The initial renderer libraries are bound to the signed immutable [v1.8.0 source release](https://github.com/atrinik/renderer/releases/tag/v1.8.0) @@ -1762,6 +1775,29 @@ remaining registered contract needs them. Preserve published crate versions, release assets, tags, unrelated environments, and unrelated Actions access. Removing unactivated inventory intent is also a valid reviewed rollback. +### Renderer Trusted Publisher revocation + +The seven renderer libraries `atrinik-render-api`, `atrinik-render-resources`, +`atrinik-render-sdl3`, `atrinik-render-testkit`, `atrinik-render-ui`, +`atrinik-render-wgpu` and `atrinik-scene` are still registered on crates.io with +a Trusted Publisher for `atrinik/renderer`, `publish-crate.yml` and +`crates-io-release`, owner-observed on 2026-10-12. `atrinik/renderer` +(`1327106998`) became `seraphinastudios/tilewright-renderer` on 2026-10-17 (see +[Transferred repositories](#transferred-repositories)), so no contract in this +repository covers those registrations any more. The former name can be reused: +creating a new `atrinik/renderer` in the organization ends GitHub's redirect to +the transferred repository. A new repository with that name, a matching +`publish-crate.yml` workflow and a `crates-io-release` environment would then +match the registrations and could publish new versions of those crates. + +On 2026-10-18 the owner decided to revoke the `atrinik/renderer` Trusted +Publisher from all seven crates through the crates.io owner UI, the first step +of the rollback above. **The revocation is an owner action pending +confirmation.** It has not been observed yet, so treat all seven registrations +as live until it is. After the owner confirms it, record the dated, sanitized +owner observation for each crate here through reviewed source. Revoking a +Trusted Publisher does not yank or delete any published version. + ## Protocol crate Trusted Publishing The automatic contract above replaces the former planned per-release @@ -1770,7 +1806,10 @@ future reviewed source/version merges use the automatic path after observed onboarding. Protocol registration is owner-observed with the successful manual 0.2 run; its automatic source/environment setup is observed. Renderer/toolkit initial publication and owner registration are complete, with the known-coordinate OIDC -proofs above. The dependency App's workflow activation is pending: its only +proofs above. The renderer's seven registrations now await the owner's +revocation (see +[Renderer Trusted Publisher revocation](#renderer-trusted-publisher-revocation)). +The dependency App's workflow activation is pending: its only remaining consumer, the client, has not yet run its PR-writing job. ## Crate dependency update App @@ -1789,13 +1828,29 @@ signed commit, all ordinary checks passed, independent review accepted it, and the maintainer merged it through the ordinary process. That observation belongs to the transferred repository and does not count as client activation. The live App is `atrinik-crate-dependency-update`, App ID `5276152`, installation -ID `170332148`, with **selected repositories** exactly `atrinik/client` (ID -`1327106844`). The editor repository left the organization on 2026-10-17 (see -[Transferred repositories](#transferred-repositories)); the editor's copy of the -App credential and variable were deleted from the transferred repository. App-authenticated supported -API readback confirmed that complete selected set, exact contents write/pull -requests write/metadata read permissions, no subscribed events, and the external -homepage `https://www.atrinik.org`. Preserve every other App and installation, +ID `170332148`. Its expected **selected repositories** set is exactly +`atrinik/client` (ID `1327106844`). App-authenticated supported API readback on +2026-10-11 confirmed the selected set at that time, `atrinik/client` and +`atrinik/editor` (ID `1327106902`), exact contents write/pull requests +write/metadata read permissions, no subscribed events, and the external homepage +`https://www.atrinik.org`. The editor left the organization on 2026-10-17 (see +[Transferred repositories](#transferred-repositories)), which changed the +installation's repository set after that readback. The client-only set is +therefore recorded as pending re-verification: `repository_scope_reverification` +has state `pending-reverification`, reason `repository-transfer-after-readback`, +the 2026-10-11 readback date, the 2026-10-17 change date and the removed editor +identity. No readback of the client-only set has been recorded yet. + +To re-verify, the owner confirms in the installation's organization-owner UI, +or through supported App-authenticated readback, that the selected set is +exactly `atrinik/client`. Reviewed source then records that method in +`repository_scope_verification`, the readback date in `observed_on`, and sets +`repository_scope_reverification` to `null`. Validation pins the record to the +editor transfer. While `observed_on` is on or before 2026-10-17, the pending +record must name exactly that readback, the 2026-10-17 change and +`atrinik/editor` (`1327106902`). It can be cleared only together with a +readback dated after the transfer, and must be cleared once one is recorded. +Preserve every other App and installation, including the existing Classic updater and provider Apps. The desired disabled webhook is a separate owner UI setting; `events: []` proves no subscriptions and does not independently prove the webhook switch is disabled. @@ -1823,11 +1878,33 @@ through reviewed source. If replacement proof fails, stop the client's writing workflow and preserve the old working key until the owner restores service or explicitly revokes it. +The editor's transfer also carried a copy of the current key. On 2026-10-17 +`atrinik/editor` became `seraphinastudios/tilewright-editor` (same ID +`1327106902`). GitHub keeps repository secrets and variables with a transferred +repository, so the editor's `CRATE_DEPENDENCY_UPDATE_APP_PRIVATE_KEY` secret and +`CRATE_DEPENDENCY_UPDATE_APP_ID` variable moved with it. Only the Atrinik owner +controls that repository and its organization, and the copy was deleted within +hours: a names-only readback on 2026-10-18 listed no Actions secrets or +variables in the transferred repository. **On 2026-10-18 the owner explicitly +accepted that exposure window instead of rotating the key early.** +`accepted_key_exposures` records the acceptance: the exposure type, the exposed +names, the former and current repository identity, the exposure start, the +removal readback and the acceptance date. The key keeps its ordinary 2027-01-09 +rotate-by deadline; the acceptance does not extend it. Validation pins the +record to the editor transfer (former and current name, ID, the 2026-10-17 +start and both exposed names), and requires an observed removal with the +acceptance on or after it. The record must stay while the current key was +created on or before the transfer. Once a rotation records a newer +`key_created_on`, the list must be empty, so remove the record in that same +reviewed change. + The read-only verifier checks live installation identity, exact permissions, empty events, active state, selected repository identities, and credential **names**. An administrative-token API cannot enumerate the complete selected repository set; retain current supported App-authenticated readback or owner UI -proof. It reports `credential_state: observed-name-metadata` separately from +proof. While `repository_scope_reverification` is pending, the verifier reports +`PENDING` with the recorded change instead of the ordinary `MANUAL` reminder. +It reports `credential_state: observed-name-metadata` separately from workflow activation. The client's reviewed `released-dependencies.yml` workflow has merged. The dated editor run/PR above is historical evidence of effective App use by the transferred repository's writer and does not prove the client's @@ -1865,4 +1942,17 @@ Trusted Publishing, package-access and crate dependency App inventories. The organization rulesets that named them stop matching anything; an authorized `bin/publish --apply` removes the obsolete `03 - Required CI` rulesets. Atrinik consumes both projects as external MIT dependencies. Historical evidence in the -sections above that names them is preserved unchanged. +sections above that names them is preserved. Where that evidence pointed at a +table row that left with the renderer, it now names the renderer crates +directly. + +Three owner decisions from 2026-10-18 follow up on the transfer: + +- The editor's retained copy of the crate dependency App key is an accepted + exposure, and the key keeps its 2027-01-09 rotate-by deadline (see + [Crate dependency update App](#crate-dependency-update-app)). +- The App's client-only selected set is pending re-verification, because the + transfer changed it after the 2026-10-11 readback (same section). +- The owner is revoking the renderer's crates.io Trusted Publishers; that + revocation is pending confirmation (see + [Renderer Trusted Publisher revocation](#renderer-trusted-publisher-revocation)). diff --git a/bin/validate b/bin/validate index 64be716..243140e 100755 --- a/bin/validate +++ b/bin/validate @@ -981,13 +981,25 @@ jq -e --arg today "${validation_today}" ' def observed_day: (type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) and (try ((. + "T00:00:00Z" | fromdateiso8601 | strftime("%Y-%m-%d")) == . and . <= $today) catch false); + def evidence_reference: type == "string" and test("^[A-Za-z0-9_./#-]+$"); + # The 2026-10-17 editor transfer (README.md#transferred-repositories) removed + # a selected repository after the 2026-10-11 readback and carried a copy of + # the then-current key. Its evidence records pin this exact identity. + def editor_transfer: { + former_repository: "atrinik/editor", repository: "seraphinastudios/tilewright-editor", + repository_id: 1327106902, transferred_on: "2026-10-17" + }; .github_actions_apps as $apps | .external_provider_apps as $external_apps | .crate_dependency_update_app as $app | - ($app | keys == ["app_id", "app_slug", "consumers", "credential_state", "events", "evidence_location", "homepage_url", "installation_id", - "key_created_on", "last_verified_on", "observed_on", "owner", "permissions", "pull_request_policy", "readiness", "repositories", - "repository_scope_verification", "repository_selection", "rotate_by", "rotation_cadence_days", "rotation_owner", "runbook", "secret_name", - "secret_scope", "status", "variable_name", "variable_scope", "webhook_active", "workflow_activation", "workflow_filename", "workflow_triggers"]) and + editor_transfer as $transfer | + ($app | keys == ["accepted_key_exposures", "app_id", "app_slug", "consumers", "credential_state", "events", "evidence_location", + "homepage_url", "installation_id", "key_created_on", "last_verified_on", "observed_on", "owner", "permissions", "pull_request_policy", + "readiness", "repositories", "repository_scope_reverification", "repository_scope_verification", "repository_selection", "rotate_by", + "rotation_cadence_days", "rotation_owner", "runbook", "secret_name", "secret_scope", "status", "variable_name", "variable_scope", + "webhook_active", "workflow_activation", "workflow_filename", "workflow_triggers"]) and + ($app.accepted_key_exposures | type == "array") and + ($app.repository_scope_reverification == null or ($app.repository_scope_reverification | type == "object")) and $app.app_slug == "atrinik-crate-dependency-update" and $app.homepage_url == "https://www.atrinik.org" and $app.webhook_active == false and $app.repository_selection == "selected" and @@ -1018,11 +1030,52 @@ jq -e --arg today "${validation_today}" ' $app.app_id == null and $app.installation_id == null and $app.repository_scope_verification == "pending-owner-ui" and $app.observed_on == null and $app.evidence_location == null and - $app.key_created_on == null and $app.last_verified_on == null and $app.rotate_by == null + $app.key_created_on == null and $app.last_verified_on == null and $app.rotate_by == null and + $app.accepted_key_exposures == [] and $app.repository_scope_reverification == null elif $app.status == "owner-observed" then ($app.app_id | type == "number" and floor == . and . > 0) and ($app.installation_id | type == "number" and floor == . and . > 0) and ($app.repository_scope_verification | IN("organization_owner_ui", "app_authenticated_repository_api")) and + # A readback on or before the transfer no longer proves the selected set: + # keep it pending until a later readback replaces observed_on. + (if $app.observed_on <= $transfer.transferred_on then + $app.repository_scope_reverification as $pending | + ($pending | type == "object") and + ($pending | keys == ["evidence_location", "last_readback_on", "reason", "removed_repositories", + "selection_changed_on", "state"]) and + $pending.state == "pending-reverification" and + $pending.reason == "repository-transfer-after-readback" and + $pending.last_readback_on == $app.observed_on and + $pending.selection_changed_on == $transfer.transferred_on and + $pending.last_readback_on < $pending.selection_changed_on and + $pending.removed_repositories == + [{repository: $transfer.former_repository, repository_id: $transfer.repository_id}] and + ($pending.evidence_location | evidence_reference) + else + $app.repository_scope_reverification == null + end) and + # A key that was current at the transfer keeps its exposure acceptance + # until rotation; a newer key has none. Acceptance never extends rotate-by. + (if $app.key_created_on <= $transfer.transferred_on then + ($app.accepted_key_exposures | length == 1) and + ($app.accepted_key_exposures[0] as $exposure | + ($exposure | type == "object") and + ($exposure | keys == ["accepted_by", "accepted_on", "evidence_location", "exposed_names", "exposure", + "exposure_started_on", "former_repository", "removal_observed_on", "repository", "repository_id"]) and + $exposure.exposure == "repository-transfer-retained-copy" and + $exposure.exposed_names == ([$app.secret_name, $app.variable_name] | sort) and + $exposure.former_repository == $transfer.former_repository and + $exposure.repository == $transfer.repository and + $exposure.repository_id == $transfer.repository_id and + $exposure.exposure_started_on == $transfer.transferred_on and + ($exposure.removal_observed_on | observed_day) and + $exposure.removal_observed_on >= $exposure.exposure_started_on and + ($exposure.accepted_on | observed_day) and $exposure.accepted_on >= $exposure.removal_observed_on and + $exposure.accepted_by == $app.owner and + ($exposure.evidence_location | evidence_reference)) + else + $app.accepted_key_exposures == [] + end) and ($app.observed_on | observed_day) and ($app.key_created_on | observed_day) and ($app.last_verified_on | observed_day) and $app.last_verified_on >= $app.key_created_on and diff --git a/bin/verify-manual-settings b/bin/verify-manual-settings index 4143129..796988f 100755 --- a/bin/verify-manual-settings +++ b/bin/verify-manual-settings @@ -934,7 +934,16 @@ done < <(jq -c '.repositories[]' <<<"${app_record}") if [[ $(jq -r '.status' <<<"${app_record}") == planned ]]; then echo "PENDING ${app_slug} selected client App installation and repository credential slots; no provisioning applied" else - echo "MANUAL ${app_slug} selected repositories require current owner UI or App-authenticated API proof: atrinik/client only" + # A selected-set change after the recorded readback stays pending until a + # fresh owner UI or App-authenticated readback is recorded. + if [[ $(jq -c '.repository_scope_reverification' <<<"${app_record}") != null ]]; then + echo "PENDING ${app_slug} selected repositories await re-verification: $(jq -r ' + .repository_scope_reverification | + "a \(.selection_changed_on) repository transfer removed \(.removed_repositories | map(.repository) | join(", ")) after the \(.last_readback_on) readback" + ' <<<"${app_record}"); fresh owner UI or App-authenticated API proof is required: atrinik/client only" + else + echo "MANUAL ${app_slug} selected repositories require current owner UI or App-authenticated API proof: atrinik/client only" + fi activation_status=PENDING if [[ $(jq -r '.workflow_activation.state' <<<"${app_record}") == observed-successful-owned-pr ]]; then activation_status=OWNER-OBSERVED diff --git a/config/manual-settings.json b/config/manual-settings.json index cb560d9..e1f02ff 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -578,10 +578,40 @@ "rotate_by": "2027-01-09", "rotation_cadence_days": 90, "rotation_owner": "Atrinik organization owners", + "accepted_key_exposures": [ + { + "exposure": "repository-transfer-retained-copy", + "exposed_names": [ + "CRATE_DEPENDENCY_UPDATE_APP_ID", + "CRATE_DEPENDENCY_UPDATE_APP_PRIVATE_KEY" + ], + "former_repository": "atrinik/editor", + "repository": "seraphinastudios/tilewright-editor", + "repository_id": 1327106902, + "exposure_started_on": "2026-10-17", + "removal_observed_on": "2026-10-18", + "accepted_by": "Atrinik organization owners", + "accepted_on": "2026-10-18", + "evidence_location": "README.md#crate-dependency-update-app" + } + ], "installation_id": 170332148, "status": "owner-observed", "repository_selection": "selected", "repository_scope_verification": "app_authenticated_repository_api", + "repository_scope_reverification": { + "state": "pending-reverification", + "reason": "repository-transfer-after-readback", + "last_readback_on": "2026-10-11", + "selection_changed_on": "2026-10-17", + "removed_repositories": [ + { + "repository": "atrinik/editor", + "repository_id": 1327106902 + } + ], + "evidence_location": "README.md#crate-dependency-update-app" + }, "owner": "Atrinik organization owners", "permissions": { "contents": "write", diff --git a/tests/check-project-health.sh b/tests/check-project-health.sh index c879a23..5e37c06 100755 --- a/tests/check-project-health.sh +++ b/tests/check-project-health.sh @@ -466,7 +466,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-10-12 \ + ATRINIK_VALIDATION_TODAY=2026-10-18 \ GITHUB_STEP_SUMMARY="${temporary}/step-summary" \ "${root}/bin/check-project-health" "$@" } @@ -675,7 +675,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-10-12 \ + ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${root}/bin/check-project-health" --apply \ >"${temporary}/missing.out" 2>"${temporary}/missing.err"; then echo "error: health check accepted a missing settings credential" >&2 @@ -750,7 +750,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-10-12 \ + ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${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/validate-crate-trusted-publishing.sh b/tests/validate-crate-trusted-publishing.sh index ba4ceb4..6108f83 100755 --- a/tests/validate-crate-trusted-publishing.sh +++ b/tests/validate-crate-trusted-publishing.sh @@ -4,7 +4,7 @@ set -euo pipefail root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) temporary=$(mktemp -d) trap 'rm -rf "${temporary}"' EXIT -export ATRINIK_VALIDATION_TODAY=2026-10-12 +export ATRINIK_VALIDATION_TODAY=2026-10-18 mkdir -p "${temporary}/bin" cp "${root}/bin/validate" "${temporary}/bin/validate" cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${temporary}/" @@ -115,6 +115,7 @@ done jq '.crate_dependency_update_app |= (.status="owner-observed" | .app_id=85500002 | .installation_id=154000002 | .repository_scope_verification="organization_owner_ui" | .observed_on="2026-08-31" | .evidence_location="atrinik/github-settings#owner-app" | + .repository_scope_reverification.last_readback_on="2026-08-31" | .workflow_activation={state:"pending-reviewed-workflows", observed_on:null, evidence_location:null})' \ "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" "${temporary}/bin/validate" >/dev/null @@ -146,6 +147,98 @@ for filter in \ '.crate_dependency_update_app.observed_on = null'; do assert_rejected "${filter}" done + +assert_accepted() { + local filter=$1 + jq "${filter}" "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" + if ! "${temporary}/bin/validate" >/dev/null 2>&1; then + echo "error: rejected valid dependency App record: ${filter}" >&2 + exit 1 + fi + reset_policy +} + +# The exposure acceptance pins the editor transfer and stays while the key +# that was current at the transfer is in use. Rotation retires it. +assert_accepted '.crate_dependency_update_app |= (.key_created_on = "2026-10-18" | + .last_verified_on = "2026-10-18" | .rotate_by = "2027-01-16" | .accepted_key_exposures = [])' +exposure='.crate_dependency_update_app.accepted_key_exposures' +for filter in \ + "${exposure} = null" \ + "${exposure} = []" \ + "${exposure}[0] |= (.former_repository = \"atrinik/classic\" | .repository = \"elsewhere/classic\" | .repository_id = 1327289971)" \ + "${exposure}[0].repository = \"seraphinastudios/other-editor\"" \ + "${exposure}[0].exposure_started_on = \"2026-10-18\"" \ + "${exposure} = {}" \ + "${exposure} += [${exposure}[0]]" \ + "${exposure}[0].private_key = \"forbidden\"" \ + "del(${exposure}[0].accepted_on)" \ + "${exposure}[0].exposure = \"accepted\"" \ + "${exposure}[0].exposed_names = [\"CRATE_DEPENDENCY_UPDATE_APP_ID\"]" \ + "${exposure}[0].exposed_names += [\"CARGO_REGISTRY_TOKEN\"]" \ + "${exposure}[0].exposed_names |= reverse" \ + "${exposure}[0].former_repository = \"atrinik/client\"" \ + "${exposure}[0].former_repository = \"seraphinastudios/tilewright-editor\"" \ + "${exposure}[0].repository = \"atrinik/editor\"" \ + "${exposure}[0].repository = \"Atrinik/editor\"" \ + "${exposure}[0].repository_id = 1327106844" \ + "${exposure}[0].repository_id = 0" \ + "${exposure}[0].exposure_started_on = \"2026-10-10\"" \ + "${exposure}[0].exposure_started_on = \"2026-02-30\"" \ + "${exposure}[0].removal_observed_on = null" \ + "${exposure}[0].removal_observed_on = \"2026-10-16\"" \ + "${exposure}[0].accepted_on = null" \ + "${exposure}[0].accepted_on = \"2026-10-17\"" \ + "${exposure}[0].accepted_on = \"2099-01-01\"" \ + "${exposure}[0].accepted_by = \"unknown\"" \ + "${exposure}[0].evidence_location = \"https://example.test/evidence?id=1\"" \ + '.crate_dependency_update_app |= (.key_created_on = "2026-10-18" | + .last_verified_on = "2026-10-18" | .rotate_by = "2027-01-16")'; do + assert_rejected "${filter}" +done + +# A readback on or before the transfer stays pending; only a later readback +# in observed_on may clear the record, and then it must be cleared. +assert_accepted '.crate_dependency_update_app |= (.repository_scope_verification = "organization_owner_ui" | + .observed_on = "2026-10-18" | .repository_scope_reverification = null)' +reverification='.crate_dependency_update_app.repository_scope_reverification' +for filter in \ + "${reverification} = null" \ + "${reverification} = \"pending\"" \ + '.crate_dependency_update_app |= (.observed_on = "2026-10-17" | .repository_scope_reverification = null)' \ + '.crate_dependency_update_app.observed_on = "2026-10-18"' \ + '.crate_dependency_update_app |= (.observed_on = "2026-10-17" | .repository_scope_reverification.last_readback_on = "2026-10-17")' \ + "${reverification}.removed_repositories = [{repository:\"atrinik/classic\", repository_id:1327289971}]" \ + "${reverification}.selection_changed_on = \"2026-10-16\"" \ + "${reverification} = {}" \ + "${reverification}.observed_on = \"2026-10-18\"" \ + "${reverification}.state = \"reverified\"" \ + "${reverification}.reason = \"unknown\"" \ + "${reverification}.last_readback_on = \"2026-10-10\"" \ + "${reverification}.last_readback_on = null" \ + "${reverification}.selection_changed_on = \"2026-10-11\"" \ + "${reverification}.selection_changed_on = \"2099-01-01\"" \ + "${reverification}.selection_changed_on = null" \ + "${reverification}.removed_repositories = []" \ + "${reverification}.removed_repositories = null" \ + "${reverification}.removed_repositories += [${reverification}.removed_repositories[0]]" \ + "${reverification}.removed_repositories[0] = {repository:\"atrinik/client\", repository_id:1327106844}" \ + "${reverification}.removed_repositories[0].repository_id = 1327106844" \ + "${reverification}.removed_repositories[0].repository = \"seraphinastudios/tilewright-editor\"" \ + "${reverification}.removed_repositories[0].visibility = \"public\"" \ + "${reverification}.evidence_location = null" \ + '.crate_dependency_update_app.observed_on = "2026-10-12"'; do + assert_rejected "${filter}" +done + +# A planned App has no observed readback or key to qualify. +planned='.crate_dependency_update_app |= (.status="planned" | .app_id=null | .installation_id=null | + .key_created_on=null | .last_verified_on=null | .rotate_by=null | + .repository_scope_verification="pending-owner-ui" | .observed_on=null | .evidence_location=null | + .credential_state="pending-name-metadata")' +assert_accepted "${planned} | .crate_dependency_update_app |= (.accepted_key_exposures=[] | .repository_scope_reverification=null)" +assert_rejected "${planned} | .crate_dependency_update_app.accepted_key_exposures=[]" +assert_rejected "${planned} | .crate_dependency_update_app.repository_scope_reverification=null" # Removing unactivated manual intent is a valid reviewed rollback. jq '.crates_io_trusted_publishing = []' "${root}/config/manual-settings.json" \ >"${temporary}/config/manual-settings.json" diff --git a/tests/validate-manual-credentials.sh b/tests/validate-manual-credentials.sh index 9ab3031..8d4d545 100755 --- a/tests/validate-manual-credentials.sh +++ b/tests/validate-manual-credentials.sh @@ -31,6 +31,7 @@ reset_manual_settings() { .crate_dependency_update_app |= (.status="planned" | .app_id=null | .installation_id=null | .key_created_on=null | .last_verified_on=null | .rotate_by=null | .repository_scope_verification="pending-owner-ui" | .observed_on=null | .evidence_location=null | + .accepted_key_exposures=[] | .repository_scope_reverification=null | .credential_state="pending-name-metadata" | .workflow_activation={state:"pending-reviewed-workflows", observed_on:null, evidence_location:null})' \ "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" @@ -235,7 +236,7 @@ jq '.github_actions_credentials |= map(select(.environment != "production")) | .runbook = "README.md#metaserver-production-credentials" ]' "${root}/config/manual-settings.json" >"${temporary}/production-credentials.json" cp "${temporary}/production-credentials.json" "${temporary}/config/manual-settings.json" -ATRINIK_VALIDATION_TODAY=2026-10-12 "${temporary}/bin/validate" >/dev/null +ATRINIK_VALIDATION_TODAY=2026-10-18 "${temporary}/bin/validate" >/dev/null for invalid in \ '.github_actions_credentials += [.github_actions_credentials[-1]]' \ @@ -245,7 +246,7 @@ for invalid in \ '.github_actions_credentials[-1].required_scopes += ["Workers Scripts Edit"]' \ '.github_actions_credentials[-1].credential_type = "cloudflare_account_token"'; do jq "$invalid" "${temporary}/production-credentials.json" >"${temporary}/config/manual-settings.json" - if ATRINIK_VALIDATION_TODAY=2026-10-12 "${temporary}/bin/validate" >/dev/null 2>&1; then + if ATRINIK_VALIDATION_TODAY=2026-10-18 "${temporary}/bin/validate" >/dev/null 2>&1; then echo "error: validator accepted malformed PROD credential lifecycle: ${invalid}" >&2 exit 1 fi @@ -259,7 +260,7 @@ for invalid in \ '.github_actions_credentials[1].consumers = [".github/workflows/publish.yml"]' \ '.github_actions_credentials[1].credential_value = "secret"'; do jq "$invalid" "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" - if ATRINIK_VALIDATION_TODAY=2026-10-12 "${temporary}/bin/validate" >/dev/null 2>&1; then + if ATRINIK_VALIDATION_TODAY=2026-10-18 "${temporary}/bin/validate" >/dev/null 2>&1; then echo "error: validator accepted malformed DEV credential lifecycle: ${invalid}" >&2 exit 1 fi diff --git a/tests/validate-planning-health.sh b/tests/validate-planning-health.sh index 8e18b0d..b0f815b 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-10-12 \ + if ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${temporary}/repository/bin/validate" >/dev/null 2>&1; then echo "error: validator accepted ${description}" >&2 exit 1 fi } -ATRINIK_VALIDATION_TODAY=2026-10-12 \ +ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${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 61000f3..1411aa6 100755 --- a/tests/verify-manual-settings.sh +++ b/tests/verify-manual-settings.sh @@ -822,7 +822,7 @@ run_verify() { FAKE_GH_SCENARIO="${scenario}" \ FAKE_CRATE_FIXTURES="${root}/tests/fixtures" \ GITHUB_ACTIONS=true GH_TOKEN=test-token \ - ATRINIK_VALIDATION_TODAY=2026-10-12 \ + ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${verification_root}/bin/verify-manual-settings" } @@ -882,10 +882,11 @@ cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${planned_u jq '.crate_dependency_update_app |= (.status="planned" | .app_id=null | .installation_id=null | .key_created_on=null | .last_verified_on=null | .rotate_by=null | .repository_scope_verification="pending-owner-ui" | .observed_on=null | .evidence_location=null | + .accepted_key_exposures=[] | .repository_scope_reverification=null | .credential_state="pending-name-metadata" | .workflow_activation={state:"pending-reviewed-workflows", observed_on:null, evidence_location:null})' "${fixture_root}/config/manual-settings.json" \ >"${planned_updater_root}/config/manual-settings.json" -ATRINIK_VALIDATION_TODAY=2026-10-12 "${planned_updater_root}/bin/validate" >/dev/null +ATRINIK_VALIDATION_TODAY=2026-10-18 "${planned_updater_root}/bin/validate" >/dev/null if run_verify updater-present "${planned_updater_root}" >"${temporary}/updater-planned.out" 2>"${temporary}/updater-planned.err"; then echo "error: pending App accepted an unrecorded live installation" >&2 exit 1 @@ -897,12 +898,17 @@ cp "${root}/bin/validate" "${fixture_root}/bin/verify-manual-settings" "${update cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${updater_root}/" jq '.crate_dependency_update_app |= (.status="owner-observed" | .app_id=5276152 | .installation_id=170332148 | .repository_scope_verification="organization_owner_ui" | - .observed_on="2026-08-31" | .evidence_location="atrinik/github-settings#owner-app" | + .observed_on="2026-10-18" | .evidence_location="atrinik/github-settings#owner-app" | + .repository_scope_reverification=null | .workflow_activation={state:"pending-reviewed-workflows", observed_on:null, evidence_location:null})' \ "${fixture_root}/config/manual-settings.json" >"${updater_root}/config/manual-settings.json" updater_output=$(run_verify updater-present "${updater_root}") grep -Fq 'KEEP atrinik/client crate dependency App credential name metadata' <<<"${updater_output}" -grep -Fq 'selected repositories require current owner UI or App-authenticated API proof: atrinik/client only' <<<"${updater_output}" +grep -Fq 'MANUAL atrinik-crate-dependency-update selected repositories require current owner UI or App-authenticated API proof: atrinik/client only' <<<"${updater_output}" +if grep -Fq 'await re-verification' <<<"${updater_output}"; then + echo "error: verifier reported pending re-verification after a fresh selected-set readback" >&2 + exit 1 +fi for scenario in updater-missing updater-permissions updater-selection updater-missing-secret updater-missing-variable; do if run_verify "${scenario}" "${updater_root}" >"${temporary}/updater-${scenario}.out" \ 2>"${temporary}/updater-${scenario}.err"; then @@ -929,6 +935,15 @@ grep -Fq 'KEEP atrinik/classic repository Actions secret DEPENDENCY_UPDATE_APP_P <<<"${output}" grep -Fq 'KEEP atrinik/classic repository Actions variable DEPENDENCY_UPDATE_APP_ID' \ <<<"${output}" +# The recorded transfer leaves the crate App's client-only set pending. +grep -Fq 'PENDING atrinik-crate-dependency-update selected repositories await re-verification: a 2026-10-17 repository transfer removed atrinik/editor after the 2026-10-11 readback; fresh owner UI or App-authenticated API proof is required: atrinik/client only' \ + <<<"${output}" +if grep -Fq 'MANUAL atrinik-crate-dependency-update selected repositories' <<<"${output}"; then + echo "error: verifier treated a stale selected-set readback as current" >&2 + exit 1 +fi +grep -Fq 'PENDING atrinik-crate-dependency-update owning workflow activation evidence: pending-reviewed-workflows' \ + <<<"${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; planned integrations are recorded without live provisioning.' \ <<<"${output}" @@ -992,7 +1007,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-10-12 \ + ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${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" @@ -1016,7 +1031,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-10-12 \ + ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${shared_root}/bin/verify-manual-settings") grep -Fq 'SECOND_SETTINGS_TOKEN' <<<"${output}" [[ $(grep -Fc 'repos/atrinik/github-settings' "${temporary}/gh.log") == 2 ]] @@ -1232,7 +1247,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-10-12 \ + GITHUB_ACTIONS=true GH_TOKEN='' ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${fixture_root}/bin/verify-manual-settings" \ >"${temporary}/empty.out" 2>"${temporary}/empty.err"; then echo "error: manual-settings verifier accepted an empty workflow credential" >&2