From 29a1a1b593f64fb59936623ddeb8e4638a9bd170 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 11:46:53 -0500 Subject: [PATCH 01/16] feat: govern automatic library publication and dependency updates --- AGENTS.md | 25 ++- CONTRIBUTING.md | 14 ++ README.md | 195 ++++++++++++++------- bin/publish | 7 +- bin/validate | 109 ++++++++++-- bin/verify-manual-settings | 126 +++++++++++-- config/manual-settings.json | 184 +++++++++++++++++-- tests/validate-crate-trusted-publishing.sh | 55 +++++- tests/verify-manual-settings.sh | 152 +++++++++++++--- 9 files changed, 714 insertions(+), 153 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 10ed425..11c3c5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,13 +116,24 @@ branch policies and reviewer sets, and variable and secret names only. Never record credential values. Provision them only after the owning workflow is merged and reviewed, and do not imply that `bin/publish` applies them. -- Planned crates.io Trusted Publishing belongs in the value-free manual - inventory with exact repository, workflow, environment and immutable action - identities. Keep its desired maintainer review policy explicit; pending source - releases do not invent release pins or imply live environment/registration. - Verify repository/reviewer identities and maintainer permission read-only. - Activation, environment provisioning, external registration and dispatch each - require their owning authorization; publishers never perform these steps. +- Crates.io Trusted Publishing is a value-free desired contract for protocol, + renderer, and content-toolkit. Bind exact stable repository IDs, + `publish-crate.yml`, protected `refs/heads/main`, `crates-io-release`, and the + exact immutable official auth action. Reviewed source/version merge approves + automatic future publication; environments have zero reviewers/wait and no + registry secrets. Keep pending source/environment/registration distinct from + observations; verify live repository/ref/protection and observed environments. + External registration remains sanitized owner evidence, not a GitHub API fact. + First owner-local API-token publication is limited to the fourteen declared + renderer/toolkit libraries; exclude CLIs, revoke bootstrap credentials, and + use OIDC only afterward. Protocol 0.2 retains its approved manual path. +- Keep the planned crate dependency App separate from unrelated Apps. Select + only client/editor, exactly contents/PR write and metadata read, no events, + bypass or auto-merge; inventory names only for repository secret + `CRATE_DEPENDENCY_UPDATE_APP_PRIVATE_KEY` and variable + `CRATE_DEPENDENCY_UPDATE_APP_ID`. Provision after the hourly/manual reviewed + workflows merge and retain exact owner UI selection proof; preparation must + verify registry readiness before the isolated job writes an owned PR. - 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b203d15..1f54237 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,3 +125,17 @@ organization-owner authorization for `--apply`. Re-run the complete preflight after apply and again before separately authorized exact branch deletion; never combine either operation with tag, release, asset, default-branch, or unrelated policy changes. + +Crates.io publication contracts bind exact repository/workflow/environment and +immutable auth-action identities. Automatic publication follows reviewed +source/version merges with zero environment reviewers/wait and no registry +secrets. Keep pending onboarding distinct from observed source/environment and +owner-observed external registration; verify live protected main and exact +refs. First owner-local API-token bootstrap is restricted to the fourteen +listed renderer/toolkit libraries and ends with revocation; subsequent releases +use OIDC only. Protocol 0.2 retains its approved manual path. Dependency-update +App setup is client/editor only, exact contents/PR write and metadata read, +repository credential names only, registry readiness before owned PR creation, +and ordinary review/checks without auto-merge. Changes require negative schema +and live-metadata fixture tests plus a complete publisher plan; publishers do +not provision these integrations. diff --git a/README.md b/README.md index 8402a95..244d1b7 100644 --- a/README.md +++ b/README.md @@ -1076,69 +1076,142 @@ that exact entry if it remains after the visibility transition. Finally, run a real fork pull request and require its digest pulls and complete required checks to succeed without package permissions or registry authentication. -## Protocol crate Trusted Publishing - -`config/manual-settings.json` records a **pending**, value-free crates.io -Trusted Publishing contract for `atrinik-protocol`. It binds -`atrinik/protocol` (repository ID `1327106950`), the workflow filename -`publish-crate.yml`, and the `crates-io-release` environment. The workflow is -manual (`workflow_dispatch`) on `refs/heads/main` only. The desired environment -allows only the selected `main` branch, requires reviewer `zoeyrose` (User ID -`3865595`), permits that maintainer to review their own manually initiated run, -and disallows administrator bypass. It has no secret or variable slots. -The owner identity and current repository admin permission were verified on -2026-10-04; the verifier checks both identity and maintainer permission again. -This record does not claim that the environment or Trusted Publisher exists. -The existing `crates-io-bootstrap` environment and published 0.1 crate are -separate historical state and are not changed by this contract. - -The Actions allowlist adds only +## Automatic crate Trusted Publishing + +`config/manual-settings.json` records the desired, value-free contracts below. +They remain **pending** until the reviewed workflows and owner setup have been +observed. This change does not register a crates.io publisher, provision an +Actions environment, upload a crate, or install a GitHub App. + +| Repository | Stable repository ID | Published libraries | +| --- | --- | --- | +| `atrinik/protocol` | `1327106950` | `atrinik-protocol` | +| `atrinik/renderer` | `1327106998` | `atrinik-render-api`, `atrinik-render-resources`, `atrinik-render-sdl3`, `atrinik-render-testkit`, `atrinik-render-ui`, `atrinik-render-wgpu`, `atrinik-scene` | +| `atrinik/content-toolkit` | `1327107043` | `atrinik-artifact`, `atrinik-catalog`, `atrinik-diagnostics`, `atrinik-schema`, `atrinik-source`, `atrinik-testkit`, `atrinik-transaction` | + +Every Trusted Publisher binds the exact repository, `publish-crate.yml`, and +`crates-io-release` environment. Publication runs on protected `refs/heads/main` +through `workflow_dispatch`; the owning hourly reconciliation workflow +`crate-release-reconcile.yml` dispatches eligible reviewed releases after +credential-free preparation. The release approval is the reviewed source and +version merge. Once onboarding is complete, eligible releases publish without +another release confirmation or environment approval. Hourly schedules are +best-effort; manual reconciliation dispatch remains available. + +The desired environment has exactly one selected branch policy, branch `main`, +no required reviewers, zero wait, administrator bypass disabled, and no secret +or variable slots. The actual `main` branch must be protected: a custom branch +pattern alone does not prove that protection. The existing protocol +`crates-io-bootstrap` environment and published 0.1 crate are historical state; +this contract does not modify them. Protocol 0.2 retains its current approved +manual publication path and exact release proof. The generic automatic path +applies to later eligible reviewed versions. + +The selected-actions allowance is exactly `rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18`, verified against upstream `v1.0.5`. The [pinned official action](https://github.com/rust-lang/crates-io-auth-action/blob/c6f97d42243bad5fab37ca0427f495c86d5b1a18/README.md) exchanges GitHub OIDC for a temporary crates.io token and revokes it in its -post step. Only the owning workflow's upload job may request `id-token: write`, -with `contents: read`; only the Cargo upload step receives the returned token. -Do not store a crates.io token in GitHub secrets or credential files. +post step. Only the guarded upload job requests `id-token: write`; preparation +and reconciliation have no OIDC permission. Only the Cargo upload step receives +the temporary token. Preserve the other action allowances and GitHub-owned +Actions access. Registry credentials never belong in GitHub secrets, inventory, +workflow inputs, logs, or committed credential files. + The [GitHub environment controls](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments) -allow this public repository's manual reviewer, branch restriction, and bypass -policy on GitHub Team. The [crates.io documentation](https://crates.io/docs/trusted-publishing) -owns the external registration procedure. - -Activation is a later, separately authorized operation: - -1. Merge and review the protocol workflow and governance source. Keep the - protocol release policy at `pending-source-release` with null release pins - until the actual source release exists; the upload path must fail before - requesting an OIDC token while those prerequisites are missing. -2. Use credential-free preparation to bind the actual published tag and source - revision to a reproducible crate digest and its release asset/manifest proof. - Review and commit those exact coordinates through the protocol repository. - No future release SHA or crate digest belongs in this planning record. -3. Review `bin/publish` and apply only the merged, authorized desired-state - policy. The organization allowlist applies to all governed repositories, but - the one added authentication-action pattern matches only that exact commit. - Check the complete plan for unrelated drift before requesting apply. -4. With separate owner authorization, provision `crates-io-release` with the - exact desired reviewer/branch/bypass settings and register a crates.io - Trusted Publisher for `atrinik/protocol`, `publish-crate.yml`, and that - environment. Verify live identity and protection settings, then record the - observed activation through a reviewed governance update. This pending-only - schema deliberately cannot represent activation by changing a status string. -5. Obtain separate workflow-dispatch authorization and use the reviewed manual - publication workflow. Environment approval remains the maintainer's action. - -`bin/publish` prints the manual pending requirement; it does not create an -environment or register a publisher. `bin/verify-manual-settings` verifies the -repository and reviewer identities/permission, then reports `PENDING`. It does -not inspect token values or claim the future source release, environment, -external registration, or publication is ready. Missing reviewer access or -identity drift fails closed. Existing environment contracts retain their exact -reviewer restrictions. - -Rollback of this unactivated plan is a reviewed removal of this manual record -and the single action pattern, followed by a plan review. Once activated, stop -new manual publication, revoke the exact crates.io Trusted Publisher through -its supported owner UI, and separately review removal of the matching release -environment/action allowance. Never mutate existing crate versions, release -assets, tags, unrelated environments, or other action allowlist entries. +and [crates.io Trusted Publishing documentation](https://crates.io/docs/trusted-publishing) +are the supported provisioning references. Activation proceeds in this order: + +1. Review and merge governance and owning workflow source. Run the complete + `bin/publish` read-only plan and inspect every planned mutation; apply only + the authorized merged policy. Preserve unrelated live Actions policy. +2. Verify live repository ID/name, active status, default branch, exact main + ref, protected main, and the merged publication workflow. 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 + `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. +4. Provision each `crates-io-release` environment with the exact branch, + reviewer, wait, bypass, and empty-secret/variable contract. Verify the live + settings, then record `environment_state: observed` in reviewed source. +5. Use the supported crates.io owner UI to register each listed crate against + its exact repository, workflow filename, and environment. Capture sanitized + owner evidence, registration date, and the exact selected identities. + Record `publisher_registration.state: owner-observed-registration`, + `observed_on`, and `evidence_location` only after observation. All future + publication uses OIDC; local API-token publication is not a fallback. +6. Run credential-free readiness and the approved publication paths. Verify + registry version/checksum visibility and release evidence before downstream + migration. Later eligible releases reconcile automatically after merge. + +`bin/publish` reports this manual setup but never performs it. +`bin/verify-manual-settings` always checks actual repository identity, exact +main ref, and protected main. Pending source/environment/registration states +remain explicit. Observed source requires the immutable auth action and main, +automatic, and environment markers on that exact main revision. Observed +environments require the exact selected main branch, no reviewer or wait +protection rule, disabled administrator bypass, and empty secret/variable name +metadata. Any identity/protection drift fails closed. The verifier reports +crates.io registration as owner-observed evidence: GitHub metadata cannot prove +external registration, token scope, successful upload, or future readiness. +Change a pending state only through reviewed source with the corresponding +observation; a status edit alone cannot activate a publisher. + +Rollback begins by stopping new reconciliation/publication through reviewed +source and revoking the exact crates.io publisher through its supported owner +UI. Review removal of only matching environments/action allowance when no +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. + +## Protocol crate Trusted Publishing + +The automatic contract above replaces the former planned per-release +maintainer-review policy. Protocol 0.2 keeps its approved manual release path; +future reviewed source/version merges use the automatic path after observed +onboarding. No inventory record claims that its environment or publisher has +already been provisioned. + +## Crate dependency update App + +`crate_dependency_update_app` plans a dedicated +`atrinik-crate-dependency-update` GitHub App. Its IDs and owner evidence remain +null while setup is pending. Install it with **selected repositories** only: +`atrinik/client` (ID `1327106844`) and `atrinik/editor` (ID `1327106902`). Its +permissions are exactly contents write, pull requests write, and metadata read; +it has no subscribed events, administration privilege, bypass entry, or +self-approval/self-merge authority. Preserve every other App and installation, +including the existing Classic updater and provider Apps. + +After both reviewed `released-dependencies.yml` workflows have merged, the +organization owner creates/installs the App and confirms the exact two selected +repositories in GitHub's supported owner UI. Store the private key separately +as repository secret `CRATE_DEPENDENCY_UPDATE_APP_PRIVATE_KEY` in each selected +repository, and its App ID as repository variable +`CRATE_DEPENDENCY_UPDATE_APP_ID`. Never display or copy private-key/token values +into logs or evidence. Update the inventory to `status: owner-observed` only +with stable App/installation IDs, date, and sanitized owner evidence. The +read-only verifier then checks live installation identity, exact permissions, +empty events, active state, repository identities, and credential **names**. +An administrative-token API cannot establish the exact selected repository set +or verify credential values/effective scope; owner UI and owning workflow proof +remain separate evidence. + +The updaters run hourly (`17 * * * *`) and through `workflow_dispatch`. Their +credential-free preparation verifies exact released versions and registry +checksums, resolves dependencies, updates Cargo locks, and runs aggregate +validation before requesting an App token in the isolated PR-writing job. They +maintain only their owned `automation/released-crate-dependencies` branch and +pull request; ordinary required checks and human review still govern merging. +Missing registry readiness produces no migration PR. They never upload crates, +approve a pull request, merge it, or loosen branch protection. Prove initial +App permissions with a disposable/owned PR and current ordinary checks, without +exposing credentials. On retirement, stop these workflows and revoke the +App/key and only the two named repository credential slots; preserve unrelated +installations and secrets. diff --git a/bin/publish b/bin/publish index 4052acf..2f5f792 100755 --- a/bin/publish +++ b/bin/publish @@ -1381,7 +1381,10 @@ while IFS= read -r external_app; do done < <(jq -c '.external_provider_apps[]' "${root}/config/manual-settings.json") while IFS= read -r crate_record; do repository=$(jq -r '.repository' <<<"${crate_record}") - crate=$(jq -r '.crate' <<<"${crate_record}") - echo "MANUAL PENDING ${repository} crates.io ${crate}: reviewed source release, maintainer-reviewed crates-io-release environment and Trusted Publisher owner setup; publisher does not apply" + crate=$(jq -r '.crates | join(",")' <<<"${crate_record}") + state=$(jq -r '"source=\(.source_state), environment=\(.environment_state), registration=\(.publisher_registration.state)"' <<<"${crate_record}") + echo "MANUAL ${repository} crates.io ${crate}: ${state}; reviewed source/version merge, automatic crates-io-release environment (zero reviewers/wait), and Trusted Publisher owner setup; publisher does not apply" done < <(jq -c '.crates_io_trusted_publishing[]' "${root}/config/manual-settings.json") +app_state=$(jq -r '.crate_dependency_update_app.status' "${root}/config/manual-settings.json") +echo "MANUAL atrinik-crate-dependency-update ${app_state} selected client/editor App and credential slots; publisher does not apply" echo "Manual confirmation required for config/manual-settings.json." diff --git a/bin/validate b/bin/validate index db70f03..ae97932 100755 --- a/bin/validate +++ b/bin/validate @@ -286,6 +286,7 @@ jq -e '. == {state: "not-configured"}' \ jq -e ' (keys == [ "codecov_github_app_repositories", + "crate_dependency_update_app", "crates_io_trusted_publishing", "external_provider_apps", "github_actions_apps", @@ -767,47 +768,119 @@ jq -e ' ) ' "${manual_settings_config}" >/dev/null -# Planned crate publishing is a distinct manual contract: its desired review -# policy is explicit, but no live registration or release pin is invented. +# Automatic crate publication follows reviewed source/version merges. Manual +# onboarding observations never claim independent crates.io API verification. jq -e \ --slurpfile repositories "${repositories_config}" \ - --slurpfile actions "${root}/config/actions-selected.json" ' + --slurpfile actions "${root}/config/actions-selected.json" \ + --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 observation: + (keys == ["evidence_location", "observed_on", "state"]) and + (if .state == "pending-owner-registration" then + .observed_on == null and .evidence_location == null + elif .state == "owner-observed-registration" then + (.observed_on | observed_day) and + (.evidence_location | type == "string" and test("^[A-Za-z0-9_./#-]+$")) + else false end); + { + protocol: {id: 1327106950, crates: ["atrinik-protocol"], bootstrap: []}, + renderer: {id: 1327106998, crates: ["atrinik-render-api", "atrinik-render-resources", + "atrinik-render-sdl3", "atrinik-render-testkit", "atrinik-render-ui", + "atrinik-render-wgpu", "atrinik-scene"], bootstrap: true}, + "content-toolkit": {id: 1327107043, crates: ["atrinik-artifact", "atrinik-catalog", + "atrinik-diagnostics", "atrinik-schema", "atrinik-source", "atrinik-testkit", + "atrinik-transaction"], bootstrap: true} + } as $inventory | .github_actions_environments as $environments | .crates_io_trusted_publishing as $publishers | - ($publishers | type == "array" and length <= 1) and + ($publishers | type == "array" and length <= 3) and + ([$publishers[].repository] | length == (unique | length)) and all($publishers[]; - (keys == ["activation_state", "authentication_action", "crate", "environment", - "provider", "repository", "repository_id", "runbook", "workflow_filename", + (.repository | split("/")[1]) as $name | + $inventory[$name] as $expected | + (keys == ["authentication_action", "bootstrap_crates", "bootstrap_policy", "crates", + "environment", "environment_state", "provider", "publisher_registration", "release_approval", + "repository", "repository_id", "runbook", "source_state", "workflow_filename", "workflow_ref", "workflow_trigger"]) and - .activation_state == "pending-source-release" and + $expected != null and .repository == ("atrinik/" + $name) and + .repository_id == $expected.id and .crates == $expected.crates and + .bootstrap_crates == (if $expected.bootstrap == true then $expected.crates else [] end) and + .bootstrap_policy == "owner-local-api-token-once-then-oidc-only" and .authentication_action == "rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18" and (.authentication_action as $action | ($actions[0].patterns_allowed | map(select(startswith("rust-lang/")))) == [$action]) and - .crate == "atrinik-protocol" and .provider == "github" and - .repository == "atrinik/protocol" and .repository_id == 1327106950 and - ($repositories[0].pull_request_gate | index("protocol")) != null and - ($repositories[0].archive | index("protocol")) == null and + .provider == "github" and + ($repositories[0].pull_request_gate | index($name)) != null and + ($repositories[0].archive | index($name)) == null and .workflow_filename == "publish-crate.yml" and .workflow_ref == "refs/heads/main" and .workflow_trigger == "workflow_dispatch" and - .runbook == "README.md#protocol-crate-trusted-publishing" and + .runbook == "README.md#automatic-crate-trusted-publishing" and + .release_approval == "reviewed-source-and-version-merge" and + (.source_state | IN("pending-reviewed-workflow", "observed-reviewed-workflow")) and + (.environment_state | IN("pending", "observed")) and + (.publisher_registration | observation) and + (if .environment_state == "observed" or .publisher_registration.state == "owner-observed-registration" + then .source_state == "observed-reviewed-workflow" else true end) and + (if .publisher_registration.state == "owner-observed-registration" + then .environment_state == "observed" else true end) and .environment == { can_admins_bypass: false, deployment_branch_policy: { custom_branch_policies: true, - patterns: [{name: "main", type: "branch"}], - protected_branches: false + patterns: [{name: "main", type: "branch"}], protected_branches: false }, name: "crates-io-release", prevent_self_review: false, - required_reviewers: [{type: "User", id: 3865595, login: "zoeyrose"}], - reviewer_requirement: "repository-maintainer", - secret_names: [], variable_names: [] + required_reviewers: [], secret_names: [], variable_names: [], wait_timer_minutes: 0 } ) and all($publishers[]; . as $publisher | all($environments[]; - .repository != $publisher.repository or .environment != $publisher.environment.name)) + .repository != $publisher.repository or .environment != $publisher.environment.name)) | + select(.) ' "${manual_settings_config}" >/dev/null +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); + .github_actions_apps as $apps | + .external_provider_apps as $external_apps | + .crate_dependency_update_app as $app | + ($app | keys == ["app_id", "app_slug", "events", "evidence_location", "installation_id", + "observed_on", "owner", "permissions", "pull_request_policy", "readiness", "repositories", + "repository_scope_verification", "repository_selection", "runbook", "secret_name", + "secret_scope", "status", "variable_name", "variable_scope", "workflow_filename", "workflow_triggers"]) and + $app.app_slug == "atrinik-crate-dependency-update" and + $app.repository_selection == "selected" and + $app.permissions == {contents: "write", metadata: "read", pull_requests: "write"} and + $app.events == [] and $app.owner == "Atrinik organization owners" and + $app.repositories == [{repository: "atrinik/client", repository_id: 1327106844}, + {repository: "atrinik/editor", repository_id: 1327106902}] and + $app.secret_name == "CRATE_DEPENDENCY_UPDATE_APP_PRIVATE_KEY" and $app.secret_scope == "repository" and + $app.variable_name == "CRATE_DEPENDENCY_UPDATE_APP_ID" and $app.variable_scope == "repository" and + $app.workflow_filename == "released-dependencies.yml" and + $app.workflow_triggers == ["schedule-hourly", "workflow_dispatch"] and + $app.readiness == "exact-released-crates-and-checksums-before-pr" and + $app.pull_request_policy == "owned-branch-and-pr-only-no-auto-merge" and + $app.runbook == "README.md#crate-dependency-update-app" and + (if $app.status == "planned" then + $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 + 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 == "organization_owner_ui" and + ($app.observed_on | observed_day) and + all(($apps + $external_apps)[]; .app_id != $app.app_id and .installation_id != $app.installation_id) and + ($app.evidence_location | type == "string" and test("^[A-Za-z0-9_./#-]+$")) + else false end) +' "${manual_settings_config}" >/dev/null + + jq -e \ --slurpfile repositories "${repositories_config}" ' .github_actions_environments as $environments | diff --git a/bin/verify-manual-settings b/bin/verify-manual-settings index 6902e3a..196d177 100755 --- a/bin/verify-manual-settings +++ b/bin/verify-manual-settings @@ -406,8 +406,8 @@ while IFS= read -r pages_record; do fi done < <(jq -c '.github_pages_sites[]' "${manual_settings}") -# Planned registration has no live publisher/environment identity yet. Verify -# only its repository identity and report the unresolved activation explicitly. +# Pending setup is not live activation. Every publisher still requires the +# exact live repository, main ref, and protected-main identity. while IFS= read -r crate_record; do repository=$(jq -r '.repository' <<<"${crate_record}") repository_id=$(jq -r '.repository_id' <<<"${crate_record}") @@ -415,25 +415,43 @@ while IFS= read -r crate_record; do echo "error: crate publisher repository owner does not match ${organization}" >&2 exit 1 fi - if verify_repository_identity "${repository}" "${repository_id}"; then - : - else - status=$? - exit "${status}" - fi - reviewer_identity=$(github_api "read crate publishing reviewer identity" "users/zoeyrose") - if ! jq -e '.login == "zoeyrose" and .id == 3865595' <<<"${reviewer_identity}" >/dev/null; then - echo "error: crate publishing reviewer identity drift" >&2 + verify_repository_identity "${repository}" "${repository_id}" + branch=$(github_api "read ${repository} protected main" "repos/${repository}/branches/main") + branch_ref=$(github_api "read ${repository} exact main ref" "repos/${repository}/git/ref/heads/main") + if ! jq -en --argjson branch "${branch}" --argjson ref "${branch_ref}" ' + $branch.name == "main" and $branch.protected == true and + ($branch.commit.sha | type == "string" and test("^[0-9a-f]{40}$")) and + $ref.ref == "refs/heads/main" and $ref.object.type == "commit" and + $ref.object.sha == $branch.commit.sha + ' >/dev/null; then + echo "error: crate publisher protected main or ref drift for ${repository}" >&2 exit 1 fi - reviewer_permission=$(github_api "read crate publishing reviewer permission" \ - "repos/${repository}/collaborators/zoeyrose/permission") - if ! jq -e '.role_name == "admin" or .role_name == "maintain"' \ - <<<"${reviewer_permission}" >/dev/null; then - echo "error: crate publishing reviewer is not a repository maintainer" >&2 - exit 1 + if [[ $(jq -r '.source_state' <<<"${crate_record}") == observed-reviewed-workflow ]]; then + branch_sha=$(jq -r '.object.sha' <<<"${branch_ref}") + workflow=$(github_api "read ${repository} publication workflow at exact main" \ + "repos/${repository}/contents/.github/workflows/publish-crate.yml?ref=${branch_sha}") + workflow_source=$(jq -er 'select(.encoding == "base64" and .type == "file") | .content' \ + <<<"${workflow}" | base64 --decode) + for marker in 'crates-io-release' 'refs/heads/main' 'automatic' \ + "$(jq -r '.authentication_action' <<<"${crate_record}")"; do + if [[ ${workflow_source} != *"${marker}"* ]]; then + echo "error: crate publication workflow marker drift for ${repository}: ${marker}" >&2 + exit 1 + fi + done + echo "KEEP ${repository} reviewed publication workflow at ${branch_sha}" + fi + crates=$(jq -r '.crates | join(",")' <<<"${crate_record}") + echo "KEEP ${repository} exact protected main publication identity" + onboarding_status=PENDING + if [[ $(jq -r '.publisher_registration.state' <<<"${crate_record}") == owner-observed-registration ]]; then + onboarding_status=OWNER-OBSERVED + fi + echo "${onboarding_status} ${repository} crates.io ${crates} via publish-crate.yml / crates-io-release: source=$(jq -r '.source_state' <<<"${crate_record}"), environment=$(jq -r '.environment_state' <<<"${crate_record}"), registration=$(jq -r '.publisher_registration.state' <<<"${crate_record}")" + if [[ $(jq -r '.publisher_registration.state' <<<"${crate_record}") == owner-observed-registration ]]; then + echo "MANUAL ${repository} crates.io registration is owner-observed evidence only; GitHub metadata does not verify external registration" fi - echo "PENDING ${repository} crates.io atrinik-protocol via publish-crate.yml / crates-io-release: source release and owner setup remain unverified; no registration or environment applied" done < <(jq -c '.crates_io_trusted_publishing[]' "${manual_settings}") while IFS= read -r environment_record; do @@ -535,6 +553,15 @@ while IFS= read -r environment_record; do exit 1 fi + if jq -e 'has("wait_timer_minutes")' <<<"${environment_record}" >/dev/null; then + if ! jq -e '.can_admins_bypass == false and + all(.protection_rules[]; .type != "wait_timer" and .type != "required_reviewers")' \ + "${environment_file}" >/dev/null; then + echo "error: automatic crate environment bypass, reviewer, or wait drift for ${repository}" >&2 + exit 1 + fi + fi + secrets_file=${temporary}/${environment_key}-secrets.jsonl if load_paginated_metadata \ "list ${repository} environment ${environment} secrets" \ @@ -570,7 +597,14 @@ while IFS= read -r environment_record; do fi echo "KEEP ${repository} environment ${environment} metadata" -done < <(jq -c '.github_actions_environments[]' "${manual_settings}") +done < <(jq -c ' + .github_actions_environments[], + (.crates_io_trusted_publishing[] | select(.environment_state == "observed") | + . as $publisher | .environment | + {repository: $publisher.repository, repository_id: $publisher.repository_id, + environment: .name, deployment_branch_policy, required_reviewers, + secret_names, variable_names, can_admins_bypass, wait_timer_minutes}) +' "${manual_settings}") installations_file=${temporary}/${organization}-installations.jsonl if load_paginated_metadata \ @@ -769,6 +803,60 @@ while IFS= read -r app_record; do echo "KEEP ${repository} repository Actions variable ${variable_name} (updated ${variable_updated_at})" done < <(jq -c '.github_actions_apps[]' "${manual_settings}") +# This dedicated App preserves every unrelated installation. The selected +# repository set needs owner UI proof; administrative APIs cannot enumerate it. +app_record=$(jq -c '.crate_dependency_update_app' "${manual_settings}") +app_slug=$(jq -r '.app_slug' <<<"${app_record}") +if [[ $(jq -r '.status' <<<"${app_record}") == planned ]]; then + if jq -se --arg slug "${app_slug}" 'any(.[]; .app_slug == $slug)' \ + "${installations_file}" >/dev/null; then + echo "error: planned crate dependency App has a live installation; record owner observations" >&2 + exit 1 + fi +else + installation=$(jq -sc --argjson app "${app_record}" ' + [.[] | select(.app_id == $app.app_id and .id == $app.installation_id)] | + if length == 1 then .[0] else empty end + ' "${installations_file}") + if [[ -z ${installation} ]] || ! jq -e --argjson app "${app_record}" \ + --arg organization "${organization}" ' + .app_slug == $app.app_slug and .target_type == "Organization" and + .account.login == $organization and .account.type == "Organization" and + .repository_selection == "selected" and .permissions == $app.permissions and + .events == [] and .suspended_at == null + ' <<<"${installation}" >/dev/null; then + echo "error: crate dependency App installation identity or permission drift" >&2 + exit 1 + fi +fi +while IFS= read -r selected_repository; do + repository=$(jq -r '.repository' <<<"${selected_repository}") + verify_repository_identity "${repository}" "$(jq -r '.repository_id' <<<"${selected_repository}")" + if [[ $(jq -r '.status' <<<"${app_record}") == owner-observed ]]; then + load_repository_secrets "${repository}" + load_repository_variables "${repository}" + for kind in secret variable; do + name=$(jq -r ".${kind}_name" <<<"${app_record}") + if [[ ${kind} == secret ]]; then + metadata_file=${repository_secret_files["${repository}"]} + else + metadata_file=${repository_variable_files["${repository}"]} + fi + if ! jq -se --arg name "${name}" '[.[] | select(.name == $name)] | length == 1' \ + "${metadata_file}" >/dev/null; then + echo "error: crate dependency App ${kind} name missing for ${repository}" >&2 + exit 1 + fi + done + echo "KEEP ${repository} crate dependency App credential name metadata; values and scope remain unverified" + fi +done < <(jq -c '.repositories[]' <<<"${app_record}") +if [[ $(jq -r '.status' <<<"${app_record}") == planned ]]; then + echo "PENDING ${app_slug} selected client/editor App installation and repository credential slots; no provisioning applied" +else + echo "MANUAL ${app_slug} selected repositories require current owner UI proof: atrinik/client and atrinik/editor only" +fi + # GraphQL variables must remain literal for GitHub to substitute. # shellcheck disable=SC2016 pins_response=$(github_api \ diff --git a/config/manual-settings.json b/config/manual-settings.json index bb0404d..9d01b7f 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -46,9 +46,12 @@ ], "crates_io_trusted_publishing": [ { - "activation_state": "pending-source-release", "authentication_action": "rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18", - "crate": "atrinik-protocol", + "bootstrap_crates": [], + "bootstrap_policy": "owner-local-api-token-once-then-oidc-only", + "crates": [ + "atrinik-protocol" + ], "environment": { "can_admins_bypass": false, "deployment_branch_policy": { @@ -63,24 +66,138 @@ }, "name": "crates-io-release", "prevent_self_review": false, - "required_reviewers": [ - { - "type": "User", - "id": 3865595, - "login": "zoeyrose" - } - ], - "reviewer_requirement": "repository-maintainer", + "required_reviewers": [], "secret_names": [], - "variable_names": [] + "variable_names": [], + "wait_timer_minutes": 0 + }, + "environment_state": "pending", + "publisher_registration": { + "state": "pending-owner-registration", + "observed_on": null, + "evidence_location": null }, "provider": "github", "repository": "atrinik/protocol", "repository_id": 1327106950, - "runbook": "README.md#protocol-crate-trusted-publishing", + "runbook": "README.md#automatic-crate-trusted-publishing", + "source_state": "pending-reviewed-workflow", + "workflow_filename": "publish-crate.yml", + "workflow_ref": "refs/heads/main", + "workflow_trigger": "workflow_dispatch", + "release_approval": "reviewed-source-and-version-merge" + }, + { + "authentication_action": "rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18", + "bootstrap_crates": [ + "atrinik-render-api", + "atrinik-render-resources", + "atrinik-render-sdl3", + "atrinik-render-testkit", + "atrinik-render-ui", + "atrinik-render-wgpu", + "atrinik-scene" + ], + "bootstrap_policy": "owner-local-api-token-once-then-oidc-only", + "crates": [ + "atrinik-render-api", + "atrinik-render-resources", + "atrinik-render-sdl3", + "atrinik-render-testkit", + "atrinik-render-ui", + "atrinik-render-wgpu", + "atrinik-scene" + ], + "environment": { + "can_admins_bypass": false, + "deployment_branch_policy": { + "custom_branch_policies": true, + "patterns": [ + { + "name": "main", + "type": "branch" + } + ], + "protected_branches": false + }, + "name": "crates-io-release", + "prevent_self_review": false, + "required_reviewers": [], + "secret_names": [], + "variable_names": [], + "wait_timer_minutes": 0 + }, + "environment_state": "pending", + "publisher_registration": { + "state": "pending-owner-registration", + "observed_on": null, + "evidence_location": null + }, + "provider": "github", + "repository": "atrinik/renderer", + "repository_id": 1327106998, + "runbook": "README.md#automatic-crate-trusted-publishing", + "source_state": "pending-reviewed-workflow", "workflow_filename": "publish-crate.yml", "workflow_ref": "refs/heads/main", - "workflow_trigger": "workflow_dispatch" + "workflow_trigger": "workflow_dispatch", + "release_approval": "reviewed-source-and-version-merge" + }, + { + "authentication_action": "rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18", + "bootstrap_crates": [ + "atrinik-artifact", + "atrinik-catalog", + "atrinik-diagnostics", + "atrinik-schema", + "atrinik-source", + "atrinik-testkit", + "atrinik-transaction" + ], + "bootstrap_policy": "owner-local-api-token-once-then-oidc-only", + "crates": [ + "atrinik-artifact", + "atrinik-catalog", + "atrinik-diagnostics", + "atrinik-schema", + "atrinik-source", + "atrinik-testkit", + "atrinik-transaction" + ], + "environment": { + "can_admins_bypass": false, + "deployment_branch_policy": { + "custom_branch_policies": true, + "patterns": [ + { + "name": "main", + "type": "branch" + } + ], + "protected_branches": false + }, + "name": "crates-io-release", + "prevent_self_review": false, + "required_reviewers": [], + "secret_names": [], + "variable_names": [], + "wait_timer_minutes": 0 + }, + "environment_state": "pending", + "publisher_registration": { + "state": "pending-owner-registration", + "observed_on": null, + "evidence_location": null + }, + "provider": "github", + "repository": "atrinik/content-toolkit", + "repository_id": 1327107043, + "runbook": "README.md#automatic-crate-trusted-publishing", + "source_state": "pending-reviewed-workflow", + "workflow_filename": "publish-crate.yml", + "workflow_ref": "refs/heads/main", + "workflow_trigger": "workflow_dispatch", + "release_approval": "reviewed-source-and-version-merge" } ], "external_provider_apps": [ @@ -306,5 +423,44 @@ "github_packages_actions_access": [], "require_two_factor_authentication": true, "require_secure_two_factor_methods": true, - "reason": "The public GitHub API does not expose supported update parameters for every setting in this file, including organization pins and the exact selected-repository set for an organization GitHub App installation to an administrative token, and external credential values must never be stored here. Confirm these settings, the exact ordered pin set, credential lifecycle metadata and live secret-name presence, GitHub App repository selection, Codecov GitHub App repository access, any listed GitHub Pages sites and GitHub Actions environments, and GitHub Packages Actions access in the corresponding organization, repository, and package settings UI." + "reason": "The public GitHub API does not expose supported update parameters for every setting in this file, including organization pins and the exact selected-repository set for an organization GitHub App installation to an administrative token, and external credential values must never be stored here. Confirm these settings, the exact ordered pin set, credential lifecycle metadata and live secret-name presence, GitHub App repository selection, Codecov GitHub App repository access, any listed GitHub Pages sites and GitHub Actions environments, and GitHub Packages Actions access in the corresponding organization, repository, and package settings UI.", + "crate_dependency_update_app": { + "app_id": null, + "app_slug": "atrinik-crate-dependency-update", + "installation_id": null, + "status": "planned", + "repository_selection": "selected", + "repository_scope_verification": "pending-owner-ui", + "owner": "Atrinik organization owners", + "permissions": { + "contents": "write", + "metadata": "read", + "pull_requests": "write" + }, + "events": [], + "repositories": [ + { + "repository": "atrinik/client", + "repository_id": 1327106844 + }, + { + "repository": "atrinik/editor", + "repository_id": 1327106902 + } + ], + "secret_name": "CRATE_DEPENDENCY_UPDATE_APP_PRIVATE_KEY", + "secret_scope": "repository", + "variable_name": "CRATE_DEPENDENCY_UPDATE_APP_ID", + "variable_scope": "repository", + "workflow_filename": "released-dependencies.yml", + "workflow_triggers": [ + "schedule-hourly", + "workflow_dispatch" + ], + "readiness": "exact-released-crates-and-checksums-before-pr", + "pull_request_policy": "owned-branch-and-pr-only-no-auto-merge", + "observed_on": null, + "evidence_location": null, + "runbook": "README.md#crate-dependency-update-app" + } } diff --git a/tests/validate-crate-trusted-publishing.sh b/tests/validate-crate-trusted-publishing.sh index c2c8c50..4493bb0 100755 --- a/tests/validate-crate-trusted-publishing.sh +++ b/tests/validate-crate-trusted-publishing.sh @@ -25,21 +25,27 @@ assert_rejected() { "${temporary}/bin/validate" >/dev/null for filter in \ - '.crates_io_trusted_publishing[0].activation_state = "active"' \ - '.crates_io_trusted_publishing[0].crate = "other-crate"' \ + '.crates_io_trusted_publishing[0].source_state = "active"' \ + '.crates_io_trusted_publishing[0].crates = ["other-crate"]' \ '.crates_io_trusted_publishing[0].provider = "other-provider"' \ '.crates_io_trusted_publishing[0].repository = "atrinik/classic"' \ '.crates_io_trusted_publishing[0].repository_id = 1' \ '.crates_io_trusted_publishing[0].workflow_filename = "../publish-crate.yml"' \ '.crates_io_trusted_publishing[0].workflow_ref = "refs/tags/v1.0.0"' \ '.crates_io_trusted_publishing[0].workflow_trigger = "push"' \ + '.crates_io_trusted_publishing[0].environment_state = "active"' \ + '.crates_io_trusted_publishing[0].publisher_registration.state = "active"' \ + '.crates_io_trusted_publishing[0].publisher_registration.evidence_location = "invented"' \ + '.crates_io_trusted_publishing[1].bootstrap_crates += ["atrinik-render"]' \ + '.crates_io_trusted_publishing[2].bootstrap_crates += ["atrinik-content"]' \ + '.crates_io_trusted_publishing[0].bootstrap_crates = ["atrinik-protocol"]' \ + '.crates_io_trusted_publishing[0].bootstrap_policy = "api-token-every-release"' \ + '.crates_io_trusted_publishing[0].release_approval = "per-release-approval"' \ '.crates_io_trusted_publishing[0].authentication_action = "rust-lang/crates-io-auth-action@v1"' \ '.crates_io_trusted_publishing[0].environment.name = "crates-io-bootstrap"' \ - '.crates_io_trusted_publishing[0].environment.required_reviewers = []' \ + '.crates_io_trusted_publishing[0].environment.required_reviewers = [{type: "User", id: 3865595}]' \ + '.crates_io_trusted_publishing[0].environment.wait_timer_minutes = 30' \ '.crates_io_trusted_publishing[0].environment.required_reviewers = null' \ - '.crates_io_trusted_publishing[0].environment.required_reviewers[0].id = 1' \ - '.crates_io_trusted_publishing[0].environment.required_reviewers[0].login = "unknown"' \ - '.crates_io_trusted_publishing[0].environment.required_reviewers[0].type = "Team"' \ '.crates_io_trusted_publishing[0].environment.prevent_self_review = true' \ '.crates_io_trusted_publishing[0].environment.can_admins_bypass = true' \ '.crates_io_trusted_publishing[0].environment.deployment_branch_policy.patterns[0].name = "*"' \ @@ -75,9 +81,44 @@ if "${temporary}/bin/validate" >/dev/null 2>&1; then exit 1 fi reset_policy +# Observed onboarding requires coherent source/environment/external proof. +jq '.crates_io_trusted_publishing[0] |= ( + .source_state = "observed-reviewed-workflow" | .environment_state = "observed" | + .publisher_registration = {state:"owner-observed-registration", observed_on:"2026-10-11", + evidence_location:"atrinik/github-settings#owner-setup"})' "${root}/config/manual-settings.json" \ + >"${temporary}/config/manual-settings.json" +"${temporary}/bin/validate" >/dev/null +reset_policy +for filter in \ + '.crates_io_trusted_publishing[0].environment_state = "observed"' \ + '.crates_io_trusted_publishing[0].publisher_registration = {state:"owner-observed-registration", observed_on:"2026-02-31", evidence_location:"atrinik/github-settings#owner-setup"}' \ + '.crates_io_trusted_publishing[0] |= (.source_state="observed-reviewed-workflow" | .environment_state="observed" | .publisher_registration={state:"owner-observed-registration",observed_on:"2099-01-01",evidence_location:"atrinik/github-settings#owner-setup"})'; do + assert_rejected "${filter}" +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")' \ + "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" +"${temporary}/bin/validate" >/dev/null +reset_policy +for filter in \ + '.crate_dependency_update_app.permissions.administration = "write"' \ + '.crate_dependency_update_app.events = ["push"]' \ + '.crate_dependency_update_app.repository_selection = "all"' \ + '.crate_dependency_update_app.repositories += [{repository:"atrinik/classic", repository_id:1327289971}]' \ + '.crate_dependency_update_app.repositories[0].repository_id = 1' \ + '.crate_dependency_update_app.secret_scope = "organization"' \ + '.crate_dependency_update_app.secret_name = "CARGO_REGISTRY_TOKEN"' \ + '.crate_dependency_update_app.private_key = "forbidden"' \ + '.crate_dependency_update_app.pull_request_policy = "auto-merge"' \ + '.crate_dependency_update_app.status = "active"' \ + '.crate_dependency_update_app.app_id = 1' \ + '.crate_dependency_update_app.observed_on = "2026-10-11"'; do + assert_rejected "${filter}" +done # Removing unactivated manual intent is a valid reviewed rollback. jq '.crates_io_trusted_publishing = []' "${root}/config/manual-settings.json" \ >"${temporary}/config/manual-settings.json" "${temporary}/bin/validate" >/dev/null -echo "Planned crates.io Trusted Publishing validation tests passed." +echo "Automatic crates.io Trusted Publishing and dependency App validation tests passed." diff --git a/tests/verify-manual-settings.sh b/tests/verify-manual-settings.sh index 04514a0..f7a4ae2 100755 --- a/tests/verify-manual-settings.sh +++ b/tests/verify-manual-settings.sh @@ -79,26 +79,72 @@ repos/atrinik/protocol) jq -n --argjson id "${repository_id}" \ '{id: $id, full_name: "atrinik/protocol", archived: false, default_branch: "main"}' ;; -users/zoeyrose) - reviewer_id=3865595 - if [[ ${FAKE_GH_SCENARIO} == crate-reviewer-drift ]]; then - reviewer_id=1 - fi - jq -n --argjson id "${reviewer_id}" '{id: $id, login: "zoeyrose"}' - ;; -repos/atrinik/protocol/collaborators/zoeyrose/permission) - permission=admin - role_name=admin - case ${FAKE_GH_SCENARIO} in - crate-reviewer-maintain) permission=write; role_name=maintain ;; - crate-reviewer-permission) permission=read; role_name=read ;; - crate-reviewer-triage) permission=read; role_name=triage ;; - crate-reviewer-write) permission=write; role_name=write ;; - crate-reviewer-unknown-role) role_name=unknown ;; - crate-reviewer-missing-role) jq -n '{permission: "admin"}'; exit 0 ;; +repos/atrinik/renderer | repos/atrinik/content-toolkit | repos/atrinik/client | repos/atrinik/editor) + name=${endpoint##*/} + case ${name} in + renderer) id=1327106998 ;; + content-toolkit) id=1327107043 ;; + client) id=1327106844 ;; + editor) id=1327106902 ;; esac - jq -n --arg permission "${permission}" --arg role_name "${role_name}" \ - '{permission: $permission, role_name: $role_name}' + jq -n --arg name "${name}" --argjson id "${id}" \ + '{id:$id, full_name:("atrinik/"+$name), archived:false, default_branch:"main"}' + ;; +repos/atrinik/*/branches/main) + protected=true + [[ ${FAKE_GH_SCENARIO} != crate-unprotected ]] || protected=false + jq -n --argjson protected "${protected}" \ + '{name:"main",protected:$protected,commit:{sha:"1111111111111111111111111111111111111111"}}' + ;; +repos/atrinik/*/git/ref/heads/main) + sha=1111111111111111111111111111111111111111 + [[ ${FAKE_GH_SCENARIO} != crate-ref-drift ]] || sha=2222222222222222222222222222222222222222 + jq -n --arg sha "${sha}" '{ref:"refs/heads/main",object:{type:"commit",sha:$sha}}' + ;; +repos/atrinik/protocol/contents/.github/workflows/publish-crate.yml\?ref=*) + source='crates-io-release refs/heads/main automatic rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18' + [[ ${FAKE_GH_SCENARIO} != crate-workflow-drift ]] || source='crates-io-release refs/heads/main automatic rust-lang/crates-io-auth-action@v1' + jq -n --arg content "$(printf '%s' "${source}" | base64)" \ + '{type:"file",encoding:"base64",content:$content}' + ;; +repos/atrinik/protocol/environments/crates-io-release) + rules='[{"type":"branch_policy"}]' + bypass=false + [[ ${FAKE_GH_SCENARIO} != crate-env-wait ]] || rules='[{"type":"branch_policy"},{"type":"wait_timer","wait_timer":1}]' + [[ ${FAKE_GH_SCENARIO} != crate-env-reviewer ]] || rules='[{"type":"branch_policy"},{"type":"required_reviewers","reviewers":[{"type":"User","reviewer":{"id":3865595}}]}]' + [[ ${FAKE_GH_SCENARIO} != crate-env-bypass ]] || bypass=true + jq -n --argjson rules "${rules}" --argjson bypass "${bypass}" \ + '{name:"crates-io-release",can_admins_bypass:$bypass,protection_rules:$rules, + deployment_branch_policy:{protected_branches:false,custom_branch_policies:true}}' + ;; +"repos/atrinik/protocol/environments/crates-io-release/deployment-branch-policies?per_page=100&page=1") + name=main + [[ ${FAKE_GH_SCENARIO} != crate-env-branch ]] || name='*' + jq -n --arg name "${name}" '{total_count:1,branch_policies:[{name:$name,type:"branch"}]}' + ;; +"repos/atrinik/protocol/environments/crates-io-release/secrets?per_page=100&page=1") + if [[ ${FAKE_GH_SCENARIO} == crate-env-secret ]]; then + jq -n '{total_count:1,secrets:[{name:"CARGO_REGISTRY_TOKEN"}]}' + else + jq -n '{total_count:0,secrets:[]}' + fi + ;; +"repos/atrinik/protocol/environments/crates-io-release/variables?per_page=100&page=1") + jq -n '{total_count:0,variables:[]}' + ;; +"repos/atrinik/client/actions/secrets?per_page=100&page=1" | "repos/atrinik/editor/actions/secrets?per_page=100&page=1") + if [[ ${FAKE_GH_SCENARIO} == updater-missing-secret ]]; then + jq -n '{total_count:0,secrets:[]}' + else + jq -n '{total_count:1,secrets:[{name:"CRATE_DEPENDENCY_UPDATE_APP_PRIVATE_KEY",updated_at:"2026-08-31T00:00:00Z"}]}' + fi + ;; +"repos/atrinik/client/actions/variables?per_page=100&page=1" | "repos/atrinik/editor/actions/variables?per_page=100&page=1") + if [[ ${FAKE_GH_SCENARIO} == updater-missing-variable ]]; then + jq -n '{total_count:0,variables:[]}' + else + jq -n '{total_count:1,variables:[{name:"CRATE_DEPENDENCY_UPDATE_APP_ID",created_at:"2026-08-31T00:00:00Z",updated_at:"2026-08-31T00:00:00Z"}]}' + fi ;; repos/atrinik/github-settings) if [[ ${FAKE_GH_SCENARIO} == identity-drift ]]; then @@ -315,6 +361,12 @@ 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 + updater_installed=false + [[ ${FAKE_GH_SCENARIO} != updater-* ]] || updater_installed=true + updater_permissions='{"contents":"write","metadata":"read","pull_requests":"write"}' + [[ ${FAKE_GH_SCENARIO} != updater-permissions ]] || updater_permissions='{"contents":"write","metadata":"read","pull_requests":"write","administration":"write"}' + updater_selection=selected + [[ ${FAKE_GH_SCENARIO} != updater-selection ]] || updater_selection=all planned_control=false [[ ${FAKE_GH_SCENARIO} == planned-control-installed ]] && planned_control=true jq -n \ @@ -333,6 +385,9 @@ repos/atrinik/classic/pages) --argjson external_events "${external_events}" \ --argjson external_permissions "${external_permissions}" \ --argjson external_suspended_at "${external_suspended_at}" \ + --argjson updater_installed "${updater_installed}" \ + --argjson updater_permissions "${updater_permissions}" \ + --arg updater_selection "${updater_selection}" \ --argjson planned_control "${planned_control}" '( [{ id: $installation_id, @@ -365,6 +420,12 @@ repos/atrinik/classic/pages) events: ["workflow_run"], suspended_at: null }] else [] end) + + (if $updater_installed then [{ + id:154000002, app_id:85500002, app_slug:"atrinik-crate-dependency-update", + target_type:"Organization",account:{login:"atrinik",type:"Organization"}, + repository_selection:$updater_selection,permissions:$updater_permissions, + events:[],suspended_at:null + }] else [] end) ) as $installations | {total_count: ($installations | length), installations: $installations}' fi @@ -649,13 +710,14 @@ chmod +x "${temporary}/bin/gh" run_verify() { local scenario=$1 + local verification_root=${2:-${root}} PATH="${temporary}/bin:${PATH}" \ FAKE_GH_LOG="${temporary}/gh.log" \ FAKE_GH_SCENARIO="${scenario}" \ GITHUB_ACTIONS=true GH_TOKEN=test-token \ ATRINIK_VALIDATION_TODAY=2026-08-31 \ - "${root}/bin/verify-manual-settings" + "${verification_root}/bin/verify-manual-settings" } : >"${temporary}/gh.log" @@ -665,16 +727,56 @@ if grep -Eq 'protocol/environments|crates.io|/trusted-publishers' "${temporary}/ echo "error: planned crate contract inspected or mutated unprovisioned state" >&2 exit 1 fi -maintain_output=$(run_verify crate-reviewer-maintain) -grep -Fq 'PENDING atrinik/protocol crates.io atrinik-protocol via publish-crate.yml / crates-io-release' <<<"${maintain_output}" -for scenario in crate-repository-drift crate-reviewer-drift crate-reviewer-permission \ - crate-reviewer-triage crate-reviewer-write crate-reviewer-unknown-role \ - crate-reviewer-missing-role; do +for scenario in crate-repository-drift crate-unprotected crate-ref-drift; do if run_verify "${scenario}" >"${temporary}/${scenario}.out" 2>"${temporary}/${scenario}.err"; then echo "error: verifier accepted ${scenario}" >&2 exit 1 fi done +observed_root=${temporary}/observed-crate +mkdir -p "${observed_root}/bin" +cp "${root}/bin/validate" "${root}/bin/verify-manual-settings" "${observed_root}/bin/" +cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${observed_root}/" +jq '.crates_io_trusted_publishing[0] |= ( + .source_state = "observed-reviewed-workflow" | .environment_state = "observed" | + .publisher_registration = {state:"owner-observed-registration",observed_on:"2026-08-31", + evidence_location:"atrinik/github-settings#owner-setup"})' "${root}/config/manual-settings.json" \ + >"${observed_root}/config/manual-settings.json" +observed_output=$(run_verify present "${observed_root}") +grep -Fq 'KEEP atrinik/protocol environment crates-io-release metadata' <<<"${observed_output}" +grep -Fq 'GitHub metadata does not verify external registration' <<<"${observed_output}" +for scenario in crate-workflow-drift crate-env-wait crate-env-reviewer crate-env-bypass \ + crate-env-branch crate-env-secret; do + if run_verify "${scenario}" "${observed_root}" >"${temporary}/${scenario}.out" \ + 2>"${temporary}/${scenario}.err"; then + echo "error: verifier accepted ${scenario}" >&2 + exit 1 + fi +done + +if run_verify updater-present >"${temporary}/updater-planned.out" 2>"${temporary}/updater-planned.err"; then + echo "error: pending App accepted an unrecorded live installation" >&2 + exit 1 +fi +updater_root=${temporary}/observed-updater +mkdir -p "${updater_root}/bin" +cp "${root}/bin/validate" "${root}/bin/verify-manual-settings" "${updater_root}/bin/" +cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${updater_root}/" +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")' \ + "${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 'KEEP atrinik/editor crate dependency App credential name metadata' <<<"${updater_output}" +grep -Fq 'selected repositories require current owner UI proof: atrinik/client and atrinik/editor only' <<<"${updater_output}" +for scenario in present 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 + echo "error: verifier accepted observed updater ${scenario}" >&2 + exit 1 + fi +done grep -Fq 'KEEP atrinik/github-settings repository Actions secret ATRINIK_SETTINGS_TOKEN' \ <<<"${output}" From dc758962df8d9cf610852ae08a96f70681ad5fb4 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 12:08:06 -0500 Subject: [PATCH 02/16] fix: verify executable publication and stage immutable crate sources --- .github/workflows/check.yml | 3 + .github/workflows/publish.yml | 3 + AGENTS.md | 18 +- CONTRIBUTING.md | 10 +- README.md | 98 ++++++---- bin/validate | 17 +- bin/verify-manual-settings | 11 +- config/actions-selected.json | 2 +- config/immutable-releases.json | 12 ++ config/manual-settings.json | 6 +- lib/verify_crate_workflow.py | 199 +++++++++++++++++++++ requirements-validation.txt | 2 + tests/check-project-health.sh | 6 +- tests/publish-maintenance-branch.sh | 65 +++++++ tests/validate-crate-trusted-publishing.sh | 11 +- tests/validate-immutable-releases.sh | 7 + tests/validate-manual-credentials.sh | 6 +- tests/validate-planning-health.sh | 4 +- tests/verify-manual-settings.sh | 75 +++++++- 19 files changed, 481 insertions(+), 74 deletions(-) create mode 100644 lib/verify_crate_workflow.py create mode 100644 requirements-validation.txt diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9bdf914..c2290aa 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -18,6 +18,9 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install workflow verification dependencies + run: python3 -m pip install --requirement requirements-validation.txt + - name: Validate configuration run: bin/validate diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 237c526..b1a9b88 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,6 +12,9 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install workflow verification dependencies + run: python3 -m pip install --requirement requirements-validation.txt + - name: Verify settings credential inventory env: GH_TOKEN: ${{ secrets.ATRINIK_SETTINGS_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 11c3c5d..0179812 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,11 +119,17 @@ - Crates.io Trusted Publishing is a value-free desired contract for protocol, renderer, and content-toolkit. Bind exact stable repository IDs, `publish-crate.yml`, protected `refs/heads/main`, `crates-io-release`, and the - exact immutable official auth action. Reviewed source/version merge approves + official auth action with a full immutable workflow SHA; allow the reviewed + `rust-lang/crates-io-auth-action@*` pattern for Dependabot updates without a + redundant central action-version lock. Reviewed source/version merge approves automatic future publication; environments have zero reviewers/wait and no registry secrets. Keep pending source/environment/registration distinct from observations; verify live repository/ref/protection and observed environments. - External registration remains sanitized owner evidence, not a GitHub API fact. + Parse observed exact-main workflows structurally with the declared + `requirements-validation.txt` dependency; comments/plain text or disabled + jobs are not executable source proof. External registration remains sanitized + owner evidence, not a GitHub API fact, and can precede a pending later + automatic source/environment transition. First owner-local API-token publication is limited to the fourteen declared renderer/toolkit libraries; exclude CLIs, revoke bootstrap credentials, and use OIDC only afterward. Protocol 0.2 retains its approved manual path. @@ -164,9 +170,11 @@ - Treat `config/immutable-releases.json` as the exhaustive owner-enforced organization inventory. Record stable repository IDs as well as names, fail closed on live identity drift, and preserve the exact previous mode and - selected-ID set for verified automatic rollback. Add a repository only after - its draft-first, complete-before-publication release workflow is merged and - proven. Never apply immutable-release policy from an unmerged branch. + selected-ID set for verified automatic rollback. Stage additions as pending + desired state; apply only after their reviewed draft-first workflows are + merged and qualified. Nonempty new crate finalization fails before tags while + live owner-enforced immutability is disabled, then retries after coordinated + activation. Never apply immutable-release policy from an unmerged branch. - Never print or commit administrative tokens, live secrets, or API responses containing credentials. - Run `bin/validate`, `bash -n`, ShellCheck on governance scripts, the publisher diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f54237..38b7d72 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,8 +97,10 @@ plan attaches the advanced-allowed security configuration before disabling default setup, and include the immediate workflow rerun and rollback owner in the deployment handoff. -An immutable-release inventory change must be coordinated with a merged, -verified draft-first release workflow in every selected repository. Review the +An immutable-release inventory change stages desired state and must be activated +only with a merged, qualified draft-first release workflow in every newly +selected repository. Nonempty new crate manifests must fail before tags while +live owner-enforced immutability is disabled, then retry after activation. Review the repository name and stable numeric ID, the exact organization selected set, owner-enforcement verification, and snapshot rollback tests. Merge the policy before requesting explicit authorization for `bin/publish --apply`; never @@ -127,7 +129,9 @@ never combine either operation with tag, release, asset, default-branch, or unrelated policy changes. Crates.io publication contracts bind exact repository/workflow/environment and -immutable auth-action identities. Automatic publication follows reviewed +official auth-action identities with full immutable workflow SHA references; +preserve the reviewed `rust-lang/crates-io-auth-action@*` allowance for normal +Dependabot updates without a central version lock. Automatic publication follows reviewed source/version merges with zero environment reviewers/wait and no registry secrets. Keep pending onboarding distinct from observed source/environment and owner-observed external registration; verify live protected main and exact diff --git a/README.md b/README.md index 244d1b7..f91d682 100644 --- a/README.md +++ b/README.md @@ -673,9 +673,11 @@ they also need a pull-request gate, required CI, immutable release tags, or archival. Immutable releases require a stricter release contract than an immutable tag -alone. Add a repository to `config/immutable-releases.json` only after its -release workflow creates a draft, uploads and verifies every intended asset, -and publishes the draft as its final step. Record both the repository name and +alone. Stage repository additions in `config/immutable-releases.json` as pending +activation, then apply only after reviewed workflow qualification proves draft +creation/recovery, complete verified assets, and publication as the final step. +New crate source finalization fails before tags while live owner-enforced +immutability is disabled; rerun after coordinated activation. Record both the repository name and the ID reported by GitHub. Validation requires every selected repository to be active, pull-request governed, and protected by the release-tag ruleset; the publisher fails closed if a live repository ID differs from the inventory. @@ -795,26 +797,41 @@ confirm a successful default-setup analysis before rescheduling the migration. Never delete the advanced workflow first and assume a later policy run will repair coverage. -## Enabling immutable releases for Classic - -The initial desired state is the organization-level `selected` mode with only -repository ID `1327289971` (`atrinik/classic`). Do not apply it until the -Classic draft-first release workflow is merged and its build, packaging, -retry/recovery, and publication paths have passed on `main`. A published -release must be complete because its tag and assets can no longer be replaced. +## Enabling immutable source releases + +The desired organization-level `selected` inventory preserves Classic +(`1327289971`) and adds protocol (`1327106950`), renderer (`1327106998`), and +content-toolkit (`1327107043`). These additions are pending activation until +reviewed source workflows are merged and qualified. Their release producers +must assemble and verify the complete crate/archive/manifest payload on a draft +before publishing it. A published immutable release must be complete because +its tag and assets can no longer be replaced. The crate source finalizer checks +live `enabled=true,enforced_by_owner=true` before emitting any tag for a +nonempty new crate manifest. A merge before policy activation therefore fails +closed, preserving a retry after coordinated activation. + +For protocol, separately verify that the actual immutable pinned +semantic-release GitHub plugin supports the configured draft-first publication +and the complete asset payload. A configured flag alone is not release proof. +Qualification includes build, packaging, draft assembly, retry/recovery, +pre-tag disabled-policy failure, and publication paths. Use this rollout order: -1. Merge and verify the Classic release workflow. Confirm it creates or - recovers a draft, attaches the complete release payload, and publishes only - after all validation succeeds. +1. Review, merge, and qualify each owning release workflow before activating + its selection. Confirm draft creation/recovery and complete asset assembly; + preserve Classic. Retain exact source revision and producer validation + evidence, including failure before tags when live immutability is disabled. 2. Merge this governance policy. Run `bin/publish` and inspect the exact `PUT /orgs/atrinik/settings/immutable-releases` payload. It must select only - `[1327289971]`. + `[1327106950,1327106998,1327107043,1327289971]` in this inventory. + Preserve any additional previously accepted selection during future edits. 3. With explicit deployment authorization, run `bin/publish --apply`. The publisher verifies the organization selection and the repository's `enabled=true,enforced_by_owner=true` state before continuing. -4. Confirm the live state independently: +4. Confirm the exact full live selection and each repository independently, + then rerun the failed source workflow and verify its complete immutable + release and registry readiness: ```sh gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ @@ -822,8 +839,10 @@ Use this rollout order: gh api --paginate -H 'X-GitHub-Api-Version: 2026-03-10' \ 'orgs/atrinik/settings/immutable-releases/repositories?per_page=100' \ --jq '.repositories[] | [.name, .id]' - gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ - repos/atrinik/classic/immutable-releases + for repository in classic protocol renderer content-toolkit; do + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/atrinik/${repository}/immutable-releases" + done ``` If the apply or its verification fails, the publisher automatically sends the @@ -1079,8 +1098,12 @@ checks to succeed without package permissions or registry authentication. ## Automatic crate Trusted Publishing `config/manual-settings.json` records the desired, value-free contracts below. -They remain **pending** until the reviewed workflows and owner setup have been -observed. This change does not register a crates.io publisher, provision an +Automatic source/environment setup remains **pending** until observed. +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). +That registration does not imply the future automatic source workflow or +zero-review environment is ready: the current protocol environment still uses +the manually approved reviewer policy until the reviewed transition is applied. This change does not register a crates.io publisher, provision an Actions environment, upload a crate, or install a GitHub App. | Repository | Stable repository ID | Published libraries | @@ -1091,8 +1114,7 @@ Actions environment, upload a crate, or install a GitHub App. Every Trusted Publisher binds the exact repository, `publish-crate.yml`, and `crates-io-release` environment. Publication runs on protected `refs/heads/main` -through `workflow_dispatch`; the owning hourly reconciliation workflow -`crate-release-reconcile.yml` dispatches eligible reviewed releases after +through `workflow_dispatch`; the owning hourly reconciliation path dispatches eligible reviewed releases after credential-free preparation. The release approval is the reviewed source and version merge. Once onboarding is complete, eligible releases publish without another release confirmation or environment approval. Hourly schedules are @@ -1107,9 +1129,13 @@ this contract does not modify them. Protocol 0.2 retains its current approved manual publication path and exact release proof. The generic automatic path applies to later eligible reviewed versions. -The selected-actions allowance is exactly -`rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18`, -verified against upstream `v1.0.5`. The +The reviewed selected-actions allowance is +`rust-lang/crates-io-auth-action@*`, so ordinary Dependabot pin updates do not +require a redundant central version change. Actual executable workflows must +use a full immutable SHA for the official action. The initial observed action +reference is `rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18` +(upstream `v1.0.5`); that inventory observation is not a permanent version lock. +The [pinned official action](https://github.com/rust-lang/crates-io-auth-action/blob/c6f97d42243bad5fab37ca0427f495c86d5b1a18/README.md) exchanges GitHub OIDC for a temporary crates.io token and revokes it in its post step. Only the guarded upload job requests `id-token: write`; preparation @@ -1153,15 +1179,26 @@ are the supported provisioning references. Activation proceeds in this order: `bin/publish` reports this manual setup but never performs it. `bin/verify-manual-settings` always checks actual repository identity, exact main ref, and protected main. Pending source/environment/registration states -remain explicit. Observed source requires the immutable auth action and main, -automatic, and environment markers on that exact main revision. Observed +remain explicit. Observed source is parsed structurally on that exact main revision by +`lib/verify_crate_workflow.py`. Install the declared +`requirements-validation.txt` dependency before local observed-source checks; +the policy and publication workflows install it before verification. The +parser rejects duplicate YAML keys, comment-only/plain text, disabled automatic +jobs, missing OIDC, mutable action refs, extra OIDC jobs, widened guards, +inexact checkout/input bindings, and skipped verification/auth/upload steps. +It verifies executable dispatch, repository/ID/protected-main conditions, +credential-free dependency, environment, scoped permissions, an unconditional +full-SHA official auth action, and only the returned temporary upload token. +Safe schedule reconciliation may coexist; its jobs receive no OIDC. Observed environments require the exact selected main branch, no reviewer or wait protection rule, disabled administrator bypass, and empty secret/variable name metadata. Any identity/protection drift fails closed. The verifier reports crates.io registration as owner-observed evidence: GitHub metadata cannot prove external registration, token scope, successful upload, or future readiness. -Change a pending state only through reviewed source with the corresponding -observation; a status edit alone cannot activate a publisher. +Change each pending state only through reviewed source with its corresponding +observation. External registration can be observed before a later automatic +source/environment transition; it does not activate that transition. A status +edit alone cannot prove publication readiness. Rollback begins by stopping new reconciliation/publication through reviewed source and revoking the exact crates.io publisher through its supported owner @@ -1175,8 +1212,9 @@ Removing unactivated inventory intent is also a valid reviewed rollback. The automatic contract above replaces the former planned per-release maintainer-review policy. Protocol 0.2 keeps its approved manual release path; future reviewed source/version merges use the automatic path after observed -onboarding. No inventory record claims that its environment or publisher has -already been provisioned. +onboarding. Protocol registration is owner-observed with the successful manual 0.2 run; +its automatic source/environment transition remains pending. Renderer/toolkit +registration and the dependency App remain unprovisioned pending owner actions. ## Crate dependency update App diff --git a/bin/validate b/bin/validate index ae97932..a8f4e76 100755 --- a/bin/validate +++ b/bin/validate @@ -244,6 +244,9 @@ jq -e \ (keys == ["id", "name"]) and (.id | type == "number" and floor == . and . > 0) and (.name | type == "string" and length > 0) and + ({classic: 1327289971, protocol: 1327106950, renderer: 1327106998, + "content-toolkit": 1327107043}[.name] as $id | + $id == null or .id == $id) and (.name as $repository | ($repositories[0].pull_request_gate | index($repository)) != null) and (.name as $repository | @@ -809,9 +812,14 @@ jq -e \ .repository_id == $expected.id and .crates == $expected.crates and .bootstrap_crates == (if $expected.bootstrap == true then $expected.crates else [] end) and .bootstrap_policy == "owner-local-api-token-once-then-oidc-only" and - .authentication_action == "rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18" and + (.authentication_action | type == "string" and + test("^rust-lang/crates-io-auth-action@[0-9a-f]{40}$")) and (.authentication_action as $action | - ($actions[0].patterns_allowed | map(select(startswith("rust-lang/")))) == [$action]) and + ($actions[0].patterns_allowed | map(select(startswith("rust-lang/")))) as $patterns | + ($patterns | length) > 0 and + all($patterns[]; test("^rust-lang/crates-io-auth-action@([0-9a-f]{40}|\\*)$")) and + (($patterns | index($action)) != null or + ($patterns | index("rust-lang/crates-io-auth-action@*")) != null)) and .provider == "github" and ($repositories[0].pull_request_gate | index($name)) != null and ($repositories[0].archive | index($name)) == null and @@ -822,10 +830,7 @@ jq -e \ (.source_state | IN("pending-reviewed-workflow", "observed-reviewed-workflow")) and (.environment_state | IN("pending", "observed")) and (.publisher_registration | observation) and - (if .environment_state == "observed" or .publisher_registration.state == "owner-observed-registration" - then .source_state == "observed-reviewed-workflow" else true end) and - (if .publisher_registration.state == "owner-observed-registration" - then .environment_state == "observed" else true end) and + (if .environment_state == "observed" then .source_state == "observed-reviewed-workflow" else true end) and .environment == { can_admins_bypass: false, deployment_branch_policy: { diff --git a/bin/verify-manual-settings b/bin/verify-manual-settings index 196d177..e1782c0 100755 --- a/bin/verify-manual-settings +++ b/bin/verify-manual-settings @@ -433,19 +433,14 @@ while IFS= read -r crate_record; do "repos/${repository}/contents/.github/workflows/publish-crate.yml?ref=${branch_sha}") workflow_source=$(jq -er 'select(.encoding == "base64" and .type == "file") | .content' \ <<<"${workflow}" | base64 --decode) - for marker in 'crates-io-release' 'refs/heads/main' 'automatic' \ - "$(jq -r '.authentication_action' <<<"${crate_record}")"; do - if [[ ${workflow_source} != *"${marker}"* ]]; then - echo "error: crate publication workflow marker drift for ${repository}: ${marker}" >&2 - exit 1 - fi - done + python3 "${root}/lib/verify_crate_workflow.py" --repository "${repository}" \ + --repository-id "${repository_id}" <<<"${workflow_source}" echo "KEEP ${repository} reviewed publication workflow at ${branch_sha}" fi crates=$(jq -r '.crates | join(",")' <<<"${crate_record}") echo "KEEP ${repository} exact protected main publication identity" onboarding_status=PENDING - if [[ $(jq -r '.publisher_registration.state' <<<"${crate_record}") == owner-observed-registration ]]; then + if jq -e '.source_state == "observed-reviewed-workflow" and .environment_state == "observed" and .publisher_registration.state == "owner-observed-registration"' <<<"${crate_record}" >/dev/null; then onboarding_status=OWNER-OBSERVED fi echo "${onboarding_status} ${repository} crates.io ${crates} via publish-crate.yml / crates-io-release: source=$(jq -r '.source_state' <<<"${crate_record}"), environment=$(jq -r '.environment_state' <<<"${crate_record}"), registration=$(jq -r '.publisher_registration.state' <<<"${crate_record}")" diff --git a/config/actions-selected.json b/config/actions-selected.json index 47b5dd9..33aac03 100644 --- a/config/actions-selected.json +++ b/config/actions-selected.json @@ -7,6 +7,6 @@ "docker/login-action@*", "docker/setup-buildx-action@*", "docker/build-push-action@*", - "rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18" + "rust-lang/crates-io-auth-action@*" ] } diff --git a/config/immutable-releases.json b/config/immutable-releases.json index 707167e..7df1c98 100644 --- a/config/immutable-releases.json +++ b/config/immutable-releases.json @@ -4,6 +4,18 @@ { "id": 1327289971, "name": "classic" + }, + { + "id": 1327107043, + "name": "content-toolkit" + }, + { + "id": 1327106950, + "name": "protocol" + }, + { + "id": 1327106998, + "name": "renderer" } ] } diff --git a/config/manual-settings.json b/config/manual-settings.json index 9d01b7f..e068144 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -73,9 +73,9 @@ }, "environment_state": "pending", "publisher_registration": { - "state": "pending-owner-registration", - "observed_on": null, - "evidence_location": null + "state": "owner-observed-registration", + "observed_on": "2026-10-11", + "evidence_location": "atrinik/protocol/actions/runs/38156755961" }, "provider": "github", "repository": "atrinik/protocol", diff --git a/lib/verify_crate_workflow.py b/lib/verify_crate_workflow.py new file mode 100644 index 0000000..1aaf5d2 --- /dev/null +++ b/lib/verify_crate_workflow.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Verify the executable OIDC publication contract on exact-main workflow bytes.""" + +import argparse +import re +import shlex +import sys + +try: + import yaml +except ImportError: + sys.exit("error: install requirements-validation.txt to verify observed crate workflows") + + +class UniqueLoader(yaml.SafeLoader): + """Reject ambiguous duplicate keys and preserve Actions' `on` YAML key.""" + + yaml_implicit_resolvers = { + key: [(tag, regex) for tag, regex in values if tag != "tag:yaml.org,2002:bool"] + for key, values in yaml.SafeLoader.yaml_implicit_resolvers.items() + } + + +def unique_mapping(loader, node, deep=False): + result = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in result: + raise ValueError(f"duplicate YAML key: {key}") + result[key] = loader.construct_object(value_node, deep=deep) + return result + + +UniqueLoader.add_constructor("tag:yaml.org,2002:map", unique_mapping) + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def expression(value): + require(isinstance(value, str), "missing executable job condition") + value = value.strip() + if value.startswith("${{") and value.endswith("}}"): + value = value[3:-2].strip() + return value + + +def helper_run(step, operation): + if "if" in step or not isinstance(step.get("run"), str): + return False + try: + tokens = shlex.split(step["run"], comments=False) + except ValueError: + return False + expected = ["python3", "tools/crate_release.py", operation, "--release-id", + "SOURCE_RELEASE_ID", "--revision", "SOURCE_REVISION", "--manifest-sha256", "MANIFEST_SHA256"] + if len(tokens) != len(expected): + return False + return all(token in ("$" + expected[index], "${" + expected[index] + "}") + if index in (4, 6, 8) else token == expected[index] + for index, token in enumerate(tokens)) + + +def verify(source, repository, repository_id): + workflow = yaml.load(source, Loader=UniqueLoader) + require(isinstance(workflow, dict), "workflow must be a YAML mapping") + triggers = workflow.get("on") + require(isinstance(triggers, dict) and "workflow_dispatch" in triggers + and set(triggers) <= {"workflow_dispatch", "schedule"}, + "publication must offer workflow_dispatch with only optional reconciliation schedule") + operation = triggers["workflow_dispatch"]["inputs"]["operation"] + require(isinstance(operation, dict) and operation.get("type") == "choice" + and isinstance(operation.get("options"), list) and "automatic" in operation["options"], + "dispatch must offer the automatic operation") + require(workflow.get("permissions") == {"contents": "read"}, + "workflow default permission must be contents read only") + require("CARGO_REGISTRY_TOKEN" not in workflow.get("env", {}), + "registry token must not be workflow scoped") + jobs = workflow.get("jobs") + require(isinstance(jobs, dict), "missing executable publication jobs") + guard = { + f"github.repository == '{repository}'", + f"github.repository_id == '{repository_id}'", + "github.ref == 'refs/heads/main'", + "github.event_name == 'workflow_dispatch'", + "inputs.operation == 'automatic'", + } + matching = {name: job for name, job in jobs.items() if isinstance(job, dict) + and isinstance(job.get("if"), str) + and {term.strip() for term in expression(job["if"]).split("&&")} == guard} + publish_names = [name for name, job in matching.items() + if isinstance(job.get("permissions"), dict) + and job["permissions"].get("id-token") == "write"] + require(len(matching) == 2 and len(publish_names) == 1, + "automatic operation needs one credential-free verify job and one OIDC publish job") + publish_name = publish_names[0] + verify_name = next(name for name in matching if name != publish_name) + for name in (verify_name, publish_name): + job = jobs.get(name) + require(isinstance(job, dict), f"missing job {name}") + terms = expression(job.get("if")).split("&&") + require(len(terms) == len(guard) and {term.strip() for term in terms} == guard, + f"{name} must enforce exactly the repository/id/main/dispatch/automatic guard") + require(isinstance(job.get("runs-on"), (str, list)) and bool(job["runs-on"]), + f"{name} has no runner") + require("CARGO_REGISTRY_TOKEN" not in job.get("env", {}), + f"{name} must not scope the registry token to the job") + for key, value in { + "SOURCE_RELEASE_ID": "${{ inputs.source_release_id }}", + "SOURCE_REVISION": "${{ inputs.source_revision }}", + "MANIFEST_SHA256": "${{ inputs.manifest_sha256 }}", + }.items(): + require(job.get("env", {}).get(key) == value, + f"{name} must bind {key} to the reviewed dispatch input") + steps = job.get("steps") + require(isinstance(steps, list) and steps, f"{name} has no executable steps") + checkouts = [step for step in steps if isinstance(step, dict) + and str(step.get("uses", "")).startswith("actions/checkout@")] + require(len(checkouts) == 1, f"{name} must check out the exact main revision") + checkout = checkouts[0] + require(re.fullmatch(r"actions/checkout@[0-9a-f]{40}", checkout["uses"]) is not None + and checkout.get("with", {}).get("ref") == "${{ github.sha }}" + and str(checkout.get("with", {}).get("persist-credentials")).lower() == "false" + and "if" not in checkout, f"{name} checkout identity/credential drift") + for step in steps: + require(isinstance(step, dict), f"invalid {name} step") + if "uses" in step: + require(re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+@[0-9a-f]{40}", + str(step["uses"])) is not None, + f"{name} action reference must be a full immutable SHA") + verify_job = jobs[verify_name] + require(verify_job.get("permissions", workflow["permissions"]) == {"contents": "read"}, + "automatic verification must not request OIDC") + require(any(helper_run(step, "verify") for step in verify_job["steps"]), + "automatic verification helper must run unconditionally") + publish = jobs[publish_name] + require(publish.get("needs") in (verify_name, [verify_name]), + "automatic publication must depend on credential-free verification") + require(publish.get("environment") == "crates-io-release", "wrong publication environment") + require(publish.get("permissions") == {"contents": "read", "id-token": "write"}, + "automatic upload needs exactly contents read and OIDC write") + auth_steps = [(index, step) for index, step in enumerate(publish["steps"]) + if str(step.get("uses", "")).startswith("rust-lang/crates-io-auth-action@")] + require(len(auth_steps) == 1, "automatic upload needs one official authentication action") + auth_index, auth = auth_steps[0] + require(auth.get("id") == "auth" and "if" not in auth, + "official authentication step must be executable and have id auth") + require(any(helper_run(step, "verify") for step in publish["steps"][:auth_index]), + "upload must reverify bytes before requesting OIDC") + uploads = [(index, step) for index, step in enumerate(publish["steps"]) + if "CARGO_REGISTRY_TOKEN" in step.get("env", {})] + require(len(uploads) == 1, "only one upload step may receive the registry token") + upload_index, upload = uploads[0] + require(upload_index > auth_index and upload.get("id") in (None, "upload") and "if" not in upload + and upload["env"]["CARGO_REGISTRY_TOKEN"] == "${{ steps.auth.outputs.token }}" + and helper_run(upload, "upload"), + "upload must execute after auth with only the returned temporary token") + for name, job in jobs.items(): + if name == publish_name: + continue + if repository == "atrinik/protocol" and name == "publish": + # Protocol 0.2 retains its separately reviewed manual upload path. + manual_guard = (guard - {"inputs.operation == 'automatic'"}) | { + "inputs.operation == 'publish'", "needs.verify.outputs.ready == 'true'"} + terms = expression(job.get("if")).split("&&") + require(job.get("environment") == "crates-io-release" + and len(terms) == len(manual_guard) + and {term.strip() for term in terms} == manual_guard + and job.get("needs") in ("verify", ["verify"]) + and job.get("permissions") == {"contents": "read", "id-token": "write"}, + "protocol manual upload path identity/permission drift") + for step in job.get("steps", []): + if "uses" in step: + require(re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+@[0-9a-f]{40}", + str(step["uses"])) is not None, + "protocol manual path action reference must be immutable") + continue + permissions = job.get("permissions", workflow["permissions"]) + require(isinstance(permissions, dict) and permissions.get("id-token", "none") == "none", + f"unexpected OIDC permission on {name}") + return auth["uses"] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", required=True) + parser.add_argument("--repository-id", required=True) + args = parser.parse_args() + try: + action = verify(sys.stdin.read(), args.repository, args.repository_id) + except (ValueError, KeyError, TypeError, yaml.YAMLError) as error: + sys.exit(f"error: crate publication executable workflow drift: {error}") + print(f"Executable automatic publication contract verified; immutable auth action {action}") + + +if __name__ == "__main__": + main() diff --git a/requirements-validation.txt b/requirements-validation.txt new file mode 100644 index 0000000..39990fd --- /dev/null +++ b/requirements-validation.txt @@ -0,0 +1,2 @@ +# YAML parsing for exact-main executable publication workflow verification. +PyYAML==6.0.3 diff --git a/tests/check-project-health.sh b/tests/check-project-health.sh index b9c7709..7d12f4c 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-08-31 \ + ATRINIK_VALIDATION_TODAY=2026-10-11 \ 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-08-31 \ + ATRINIK_VALIDATION_TODAY=2026-10-11 \ "${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-08-31 \ + ATRINIK_VALIDATION_TODAY=2026-10-11 \ "${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 fda3923..f6018a5 100755 --- a/tests/publish-maintenance-branch.sh +++ b/tests/publish-maintenance-branch.sh @@ -5,6 +5,14 @@ set -euo pipefail root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) temporary=$(mktemp -d) trap 'rm -rf "${temporary}"' EXIT +source_root=${root} +# Retain the original Classic rollout/recovery scenarios as a bounded fixture. +root=${temporary}/classic-policy +mkdir -p "${root}/bin" +cp "${source_root}/bin/publish" "${source_root}/bin/validate" "${root}/bin/" +cp -R "${source_root}/config" "${source_root}/community-health" "${source_root}/.github" "${root}/" +jq '.repositories |= map(select(.name == "classic"))' \ + "${source_root}/config/immutable-releases.json" >"${root}/config/immutable-releases.json" mkdir "${temporary}/bin" cat >"${temporary}/bin/gh" <<'EOF' @@ -255,6 +263,11 @@ case "${endpoint}|${jq_filter}" in printf 'classic\t%s\tfalse\tpublic\tfalse\tfalse\ttrue\ttrue\ttrue\n' \ "${GH_CLASSIC_REPOSITORY_ID:-1327289971}" printf 'content\t101\tfalse\tpublic\tfalse\tfalse\ttrue\ttrue\ttrue\n' + if [[ ${GH_CRATE_IMMUTABLE_INVENTORY:-false} == true ]]; then + printf 'protocol\t%s\tfalse\tpublic\tfalse\tfalse\ttrue\ttrue\ttrue\n' "${GH_PROTOCOL_REPOSITORY_ID:-1327106950}" + printf 'renderer\t1327106998\tfalse\tpublic\tfalse\tfalse\ttrue\ttrue\ttrue\n' + printf 'content-toolkit\t1327107043\tfalse\tpublic\tfalse\tfalse\ttrue\ttrue\ttrue\n' + fi printf 'classic-ghsa-8533-3vg8-r287\t999\ttrue\tprivate\tfalse\tfalse\tfalse\tfalse\tfalse\n' ;; "orgs/atrinik/settings/immutable-releases|"*) @@ -268,6 +281,26 @@ case "${endpoint}|${jq_filter}" in } ' "${GH_IMMUTABLE_STATE}" | jq -r "${jq_filter}" ;; +"repos/atrinik/protocol/immutable-releases|"* | "repos/atrinik/renderer/immutable-releases|"* | "repos/atrinik/content-toolkit/immutable-releases|"*) + case ${endpoint} in + */protocol/*) repository_id=1327106950 ;; + */renderer/*) repository_id=1327106998 ;; + */content-toolkit/*) repository_id=1327107043 ;; + esac + if [[ ${GH_CRATE_IMMUTABLE_VERIFY_FAILURE:-false} == true && ${repository_id} == 1327107043 ]]; then + printf '{"enabled":false,"enforced_by_owner":false}\n' + elif jq -e --argjson id "${repository_id}" '.enforced_repositories == "selected" and (.selected_repository_ids | index($id)) != null' "${GH_IMMUTABLE_STATE}" >/dev/null; then + printf '{"enabled":true,"enforced_by_owner":true}\n' + else + printf '{"enabled":false,"enforced_by_owner":false}\n' + fi + ;; +"repos/atrinik/protocol/code-security-configuration|"* | "repos/atrinik/renderer/code-security-configuration|"* | "repos/atrinik/content-toolkit/code-security-configuration|"*) + printf '{"status":"enforced","configuration":{"id":265376}}\n' + ;; +"repos/atrinik/protocol/code-scanning/default-setup|"* | "repos/atrinik/renderer/code-scanning/default-setup|"* | "repos/atrinik/content-toolkit/code-scanning/default-setup|"*) + printf '{"state":"configured","query_suite":"default"}\n' + ;; "repos/atrinik/classic/immutable-releases|"*) if [[ ${GH_IMMUTABLE_VERIFY_FAILURE:-false} == true ]]; then printf '{"enabled":false,"enforced_by_owner":false}\n' @@ -1524,4 +1557,36 @@ grep -F \ "${identity_drift_output}" >/dev/null jq -s -e 'all(.[]; .method == "GET")' "${identity_drift_log}" >/dev/null +# Qualify the complete crate-family owner selection and preserve old members. +crate_state=${temporary}/crate-immutable-state.json +crate_log=${temporary}/crate-immutable-log.jsonl +printf '%s\n' '{"enforced_repositories":"selected","selected_repository_ids":[1327289971]}' >"${crate_state}" +GH_API_LOG=${crate_log} GH_IMMUTABLE_STATE=${crate_state} \ + GH_CRATE_IMMUTABLE_INVENTORY=true GH_SECURITY_SCENARIO=converged \ + PATH="${temporary}/bin:${PATH}" ATRINIK_POLICY_SCOPE=organization \ + "${source_root}/bin/publish" --apply >"${temporary}/crate-immutable-apply.out" +jq -e '. == {enforced_repositories:"selected",selected_repository_ids:[1327106950,1327106998,1327107043,1327289971]}' "${crate_state}" >/dev/null +for repository in classic protocol renderer content-toolkit; do + jq -se --arg endpoint "repos/atrinik/${repository}/immutable-releases" \ + 'any(.[]; .method == "GET" and .endpoint == $endpoint)' "${crate_log}" >/dev/null +done +crate_failure_log=${temporary}/crate-immutable-failure.jsonl +printf '%s\n' '{"enforced_repositories":"selected","selected_repository_ids":[101,1327289971]}' >"${crate_state}" +if GH_API_LOG=${crate_failure_log} GH_IMMUTABLE_STATE=${crate_state} \ + GH_CRATE_IMMUTABLE_INVENTORY=true GH_CRATE_IMMUTABLE_VERIFY_FAILURE=true \ + GH_SECURITY_SCENARIO=converged PATH="${temporary}/bin:${PATH}" ATRINIK_POLICY_SCOPE=organization \ + "${source_root}/bin/publish" --apply >"${temporary}/crate-immutable-failure.out" 2>&1; then + echo "error: crate-family immutable readback drift did not fail" >&2 + exit 1 +fi +jq -e '. == {enforced_repositories:"selected",selected_repository_ids:[101,1327289971]}' "${crate_state}" >/dev/null +if GH_API_LOG=${temporary}/crate-identity-drift.jsonl GH_IMMUTABLE_STATE=${crate_state} \ + GH_CRATE_IMMUTABLE_INVENTORY=true GH_PROTOCOL_REPOSITORY_ID=999 \ + GH_SECURITY_SCENARIO=converged PATH="${temporary}/bin:${PATH}" ATRINIK_POLICY_SCOPE=organization \ + "${source_root}/bin/publish" --apply >"${temporary}/crate-identity-drift.out" 2>&1; then + echo "error: crate-family repository identity drift did not fail" >&2 + exit 1 +fi +jq -se 'all(.[]; .method == "GET")' "${temporary}/crate-identity-drift.jsonl" >/dev/null + echo "Publisher policy-scope tests passed." diff --git a/tests/validate-crate-trusted-publishing.sh b/tests/validate-crate-trusted-publishing.sh index 4493bb0..c4fe2b5 100755 --- a/tests/validate-crate-trusted-publishing.sh +++ b/tests/validate-crate-trusted-publishing.sh @@ -35,7 +35,7 @@ for filter in \ '.crates_io_trusted_publishing[0].workflow_trigger = "push"' \ '.crates_io_trusted_publishing[0].environment_state = "active"' \ '.crates_io_trusted_publishing[0].publisher_registration.state = "active"' \ - '.crates_io_trusted_publishing[0].publisher_registration.evidence_location = "invented"' \ + '.crates_io_trusted_publishing[1].publisher_registration.evidence_location = "invented"' \ '.crates_io_trusted_publishing[1].bootstrap_crates += ["atrinik-render"]' \ '.crates_io_trusted_publishing[2].bootstrap_crates += ["atrinik-content"]' \ '.crates_io_trusted_publishing[0].bootstrap_crates = ["atrinik-protocol"]' \ @@ -63,14 +63,14 @@ done jq '.patterns_allowed |= map(select(startswith("rust-lang/crates-io-auth-action@") | not))' \ "${root}/config/actions-selected.json" >"${temporary}/config/actions-selected.json" if "${temporary}/bin/validate" >/dev/null 2>&1; then - echo "error: accepted planned publisher without exact action allowance" >&2 + echo "error: accepted planned publisher without official action allowance" >&2 exit 1 fi reset_policy jq '.patterns_allowed |= map(if startswith("rust-lang/crates-io-auth-action@") then "rust-lang/*" else . end)' \ "${root}/config/actions-selected.json" >"${temporary}/config/actions-selected.json" if "${temporary}/bin/validate" >/dev/null 2>&1; then - echo "error: accepted wildcard action allowance instead of exact pin" >&2 + echo "error: accepted an unrelated broader Rust action allowance" >&2 exit 1 fi reset_policy @@ -81,6 +81,11 @@ if "${temporary}/bin/validate" >/dev/null 2>&1; then exit 1 fi reset_policy +# Dependabot can change the immutable workflow pin without a central version lock. +jq '.crates_io_trusted_publishing[0].authentication_action = "rust-lang/crates-io-auth-action@2222222222222222222222222222222222222222"' \ + "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" +"${temporary}/bin/validate" >/dev/null +reset_policy # Observed onboarding requires coherent source/environment/external proof. jq '.crates_io_trusted_publishing[0] |= ( .source_state = "observed-reviewed-workflow" | .environment_state = "observed" | diff --git a/tests/validate-immutable-releases.sh b/tests/validate-immutable-releases.sh index 1091e91..27a8685 100755 --- a/tests/validate-immutable-releases.sh +++ b/tests/validate-immutable-releases.sh @@ -34,6 +34,13 @@ expect_invalid() { } "${root}/bin/validate" >/dev/null +jq -e '.enforced_repositories == "selected" and .repositories == [ + {id:1327289971,name:"classic"},{id:1327107043,name:"content-toolkit"}, + {id:1327106950,name:"protocol"},{id:1327106998,name:"renderer"} +]' "${root}/config/immutable-releases.json" >/dev/null +expect_invalid protocol-identity '.repositories |= map(if .name == "protocol" then .id = 1 else . end)' +expect_invalid renderer-identity '.repositories |= map(if .name == "renderer" then .id = 1 else . end)' +expect_invalid toolkit-identity '.repositories |= map(if .name == "content-toolkit" then .id = 1 else . end)' expect_invalid mode-all '.enforced_repositories = "all"' expect_invalid invalid-id '.repositories[0].id = 0' diff --git a/tests/validate-manual-credentials.sh b/tests/validate-manual-credentials.sh index 41f9d33..71a009f 100755 --- a/tests/validate-manual-credentials.sh +++ b/tests/validate-manual-credentials.sh @@ -23,8 +23,9 @@ assert_invalid() { } reset_manual_settings() { - cp "${root}/config/manual-settings.json" \ - "${temporary}/config/manual-settings.json" + jq '.crates_io_trusted_publishing[].publisher_registration = + {state:"pending-owner-registration",observed_on:null,evidence_location:null}' \ + "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" } rewrite_manual_settings() { @@ -36,6 +37,7 @@ rewrite_manual_settings() { mv "${output}" "${temporary}/config/manual-settings.json" } +reset_manual_settings ATRINIK_VALIDATION_TODAY=2026-08-31 \ "${temporary}/bin/validate" >/dev/null jq -e ' diff --git a/tests/validate-planning-health.sh b/tests/validate-planning-health.sh index 8217194..947f65e 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-31 \ + if ATRINIK_VALIDATION_TODAY=2026-10-11 \ "${temporary}/repository/bin/validate" >/dev/null 2>&1; then echo "error: validator accepted ${description}" >&2 exit 1 fi } -ATRINIK_VALIDATION_TODAY=2026-08-31 \ +ATRINIK_VALIDATION_TODAY=2026-10-11 \ "${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 f7a4ae2..261edf1 100755 --- a/tests/verify-manual-settings.sh +++ b/tests/verify-manual-settings.sh @@ -102,8 +102,64 @@ repos/atrinik/*/git/ref/heads/main) jq -n --arg sha "${sha}" '{ref:"refs/heads/main",object:{type:"commit",sha:$sha}}' ;; repos/atrinik/protocol/contents/.github/workflows/publish-crate.yml\?ref=*) - source='crates-io-release refs/heads/main automatic rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18' - [[ ${FAKE_GH_SCENARIO} != crate-workflow-drift ]] || source='crates-io-release refs/heads/main automatic rust-lang/crates-io-auth-action@v1' + source=$(cat <<'WORKFLOW' +on: + workflow_dispatch: + inputs: + operation: + type: choice + options: [automatic] +permissions: + contents: read +jobs: + automatic-verify: + if: github.repository == 'atrinik/protocol' && github.repository_id == '1327106950' && github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' && inputs.operation == 'automatic' + runs-on: ubuntu-24.04 + env: + SOURCE_RELEASE_ID: ${{ inputs.source_release_id }} + SOURCE_REVISION: ${{ inputs.source_revision }} + MANIFEST_SHA256: ${{ inputs.manifest_sha256 }} + steps: + - uses: actions/checkout@1111111111111111111111111111111111111111 + with: + ref: ${{ github.sha }} + persist-credentials: false + - run: python3 tools/crate_release.py verify --release-id "$SOURCE_RELEASE_ID" --revision "$SOURCE_REVISION" --manifest-sha256 "$MANIFEST_SHA256" + automatic-publish: + needs: automatic-verify + if: github.repository == 'atrinik/protocol' && github.repository_id == '1327106950' && github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' && inputs.operation == 'automatic' + environment: crates-io-release + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + env: + SOURCE_RELEASE_ID: ${{ inputs.source_release_id }} + SOURCE_REVISION: ${{ inputs.source_revision }} + MANIFEST_SHA256: ${{ inputs.manifest_sha256 }} + steps: + - uses: actions/checkout@1111111111111111111111111111111111111111 + with: + ref: ${{ github.sha }} + persist-credentials: false + - run: python3 tools/crate_release.py verify --release-id "$SOURCE_RELEASE_ID" --revision "$SOURCE_REVISION" --manifest-sha256 "$MANIFEST_SHA256" + - id: auth + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 + - id: upload + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + run: python3 tools/crate_release.py upload --release-id "$SOURCE_RELEASE_ID" --revision "$SOURCE_REVISION" --manifest-sha256 "$MANIFEST_SHA256" +WORKFLOW +) + case ${FAKE_GH_SCENARIO} in + crate-workflow-drift) source=${source//rust-lang\/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18/rust-lang\/crates-io-auth-action@v1} ;; + crate-workflow-future-pin) source=${source//c6f97d42243bad5fab37ca0427f495c86d5b1a18/2222222222222222222222222222222222222222} ;; + crate-workflow-plaintext) source='crates-io-release refs/heads/main automatic rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18' ;; + crate-workflow-comments) source="# crates-io-release refs/heads/main automatic rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18" ;; + crate-workflow-disabled) source=${source//inputs.operation == \'automatic\'/false} ;; + crate-workflow-no-oidc) source=${source//id-token: write/id-token: none} ;; + crate-workflow-comment-run) source=${source//run: python3/run: \"# python3}; source=${source//\"\$DIGEST\"/\"\$DIGEST\"\"} ;; + esac jq -n --arg content "$(printf '%s' "${source}" | base64)" \ '{type:"file",encoding:"base64",content:$content}' ;; @@ -716,7 +772,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-31 \ + ATRINIK_VALIDATION_TODAY=2026-10-11 \ "${verification_root}/bin/verify-manual-settings" } @@ -736,17 +792,20 @@ done observed_root=${temporary}/observed-crate mkdir -p "${observed_root}/bin" cp "${root}/bin/validate" "${root}/bin/verify-manual-settings" "${observed_root}/bin/" -cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${observed_root}/" +cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${root}/lib" "${observed_root}/" jq '.crates_io_trusted_publishing[0] |= ( .source_state = "observed-reviewed-workflow" | .environment_state = "observed" | .publisher_registration = {state:"owner-observed-registration",observed_on:"2026-08-31", evidence_location:"atrinik/github-settings#owner-setup"})' "${root}/config/manual-settings.json" \ >"${observed_root}/config/manual-settings.json" observed_output=$(run_verify present "${observed_root}") +future_output=$(run_verify crate-workflow-future-pin "${observed_root}") +grep -Fq "immutable auth action rust-lang/crates-io-auth-action@2222222222222222222222222222222222222222" <<<"${future_output}" grep -Fq 'KEEP atrinik/protocol environment crates-io-release metadata' <<<"${observed_output}" grep -Fq 'GitHub metadata does not verify external registration' <<<"${observed_output}" for scenario in crate-workflow-drift crate-env-wait crate-env-reviewer crate-env-bypass \ - crate-env-branch crate-env-secret; do + crate-env-branch crate-env-secret crate-workflow-comments crate-workflow-plaintext \ + crate-workflow-disabled crate-workflow-no-oidc; do if run_verify "${scenario}" "${observed_root}" >"${temporary}/${scenario}.out" \ 2>"${temporary}/${scenario}.err"; then echo "error: verifier accepted ${scenario}" >&2 @@ -849,7 +908,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-31 \ + ATRINIK_VALIDATION_TODAY=2026-10-11 \ "${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" @@ -873,7 +932,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-31 \ + ATRINIK_VALIDATION_TODAY=2026-10-11 \ "${shared_root}/bin/verify-manual-settings") grep -Fq 'SECOND_SETTINGS_TOKEN' <<<"${output}" [[ $(grep -Fc 'repos/atrinik/github-settings' "${temporary}/gh.log") == 2 ]] @@ -1089,7 +1148,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-31 \ + GITHUB_ACTIONS=true GH_TOKEN='' ATRINIK_VALIDATION_TODAY=2026-10-11 \ "${root}/bin/verify-manual-settings" \ >"${temporary}/empty.out" 2>"${temporary}/empty.err"; then echo "error: manual-settings verifier accepted an empty workflow credential" >&2 From 69cc1d8549a59f18d310b37ec2fa9ea63280e69c Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 12:14:13 -0500 Subject: [PATCH 03/16] fix: fence critical workflow execution and supported release proof --- AGENTS.md | 6 +++--- CONTRIBUTING.md | 5 +++-- README.md | 21 ++++++++++++--------- lib/verify_crate_workflow.py | 26 ++++++++++++++++++++++++++ tests/verify-manual-settings.sh | 9 ++++++++- 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0179812..0c07fdd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,9 +172,9 @@ closed on live identity drift, and preserve the exact previous mode and selected-ID set for verified automatic rollback. Stage additions as pending desired state; apply only after their reviewed draft-first workflows are - merged and qualified. Nonempty new crate finalization fails before tags while - live owner-enforced immutability is disabled, then retries after coordinated - activation. Never apply immutable-release policy from an unmerged branch. + merged and qualified. Every registry upload must prove that its actual source + release is immutable; ordinary workflow tokens cannot read administrative + immutability settings, so do not add an unsupported pre-tag settings query. Never apply immutable-release policy from an unmerged branch. - Never print or commit administrative tokens, live secrets, or API responses containing credentials. - Run `bin/validate`, `bash -n`, ShellCheck on governance scripts, the publisher diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38b7d72..8063133 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,8 +99,9 @@ the deployment handoff. An immutable-release inventory change stages desired state and must be activated only with a merged, qualified draft-first release workflow in every newly -selected repository. Nonempty new crate manifests must fail before tags while -live owner-enforced immutability is disabled, then retry after activation. Review the +selected repository. Every registry upload must verify the actual source +release is immutable. Ordinary workflow tokens cannot query administrative +immutability settings; keep the supported source-release evidence boundary. Review the repository name and stable numeric ID, the exact organization selected set, owner-enforcement verification, and snapshot rollback tests. Merge the policy before requesting explicit authorization for `bin/publish --apply`; never diff --git a/README.md b/README.md index f91d682..d6ee39b 100644 --- a/README.md +++ b/README.md @@ -676,8 +676,8 @@ Immutable releases require a stricter release contract than an immutable tag alone. Stage repository additions in `config/immutable-releases.json` as pending activation, then apply only after reviewed workflow qualification proves draft creation/recovery, complete verified assets, and publication as the final step. -New crate source finalization fails before tags while live owner-enforced -immutability is disabled; rerun after coordinated activation. Record both the repository name and +Every registry upload must verify that the actual complete source release is +immutable; a desired inventory entry alone is not publication evidence. Record both the repository name and the ID reported by GitHub. Validation requires every selected repository to be active, pull-request governed, and protected by the release-tag ruleset; the publisher fails closed if a live repository ID differs from the inventory. @@ -805,23 +805,25 @@ content-toolkit (`1327107043`). These additions are pending activation until reviewed source workflows are merged and qualified. Their release producers must assemble and verify the complete crate/archive/manifest payload on a draft before publishing it. A published immutable release must be complete because -its tag and assets can no longer be replaced. The crate source finalizer checks -live `enabled=true,enforced_by_owner=true` before emitting any tag for a -nonempty new crate manifest. A merge before policy activation therefore fails -closed, preserving a retry after coordinated activation. +its tag and assets can no longer be replaced. Before any registry upload, the crate publication helper verifies the actual +source release has `immutable=true`. Ordinary `GITHUB_TOKEN` cannot read the +administrative immutable-settings endpoint, so source workflows do not add an +unsupported pre-tag query or administrative credentials. This inventory is +activated only after reviewed draft-first source qualification. For protocol, separately verify that the actual immutable pinned semantic-release GitHub plugin supports the configured draft-first publication and the complete asset payload. A configured flag alone is not release proof. Qualification includes build, packaging, draft assembly, retry/recovery, -pre-tag disabled-policy failure, and publication paths. +actual immutable-release upload gating, and publication paths. Use this rollout order: 1. Review, merge, and qualify each owning release workflow before activating its selection. Confirm draft creation/recovery and complete asset assembly; preserve Classic. Retain exact source revision and producer validation - evidence, including failure before tags when live immutability is disabled. + evidence, including rejection of mutable source releases before registry + authentication or upload. 2. Merge this governance policy. Run `bin/publish` and inspect the exact `PUT /orgs/atrinik/settings/immutable-releases` payload. It must select only `[1327106950,1327106998,1327107043,1327289971]` in this inventory. @@ -1185,7 +1187,8 @@ remain explicit. Observed source is parsed structurally on that exact main revis the policy and publication workflows install it before verification. The parser rejects duplicate YAML keys, comment-only/plain text, disabled automatic jobs, missing OIDC, mutable action refs, extra OIDC jobs, widened guards, -inexact checkout/input bindings, and skipped verification/auth/upload steps. +inexact checkout/input bindings, shell/workspace overrides, ignored critical +failures, and skipped verification/auth/upload steps. It verifies executable dispatch, repository/ID/protected-main conditions, credential-free dependency, environment, scoped permissions, an unconditional full-SHA official auth action, and only the returned temporary upload token. diff --git a/lib/verify_crate_workflow.py b/lib/verify_crate_workflow.py index 1aaf5d2..5e933b7 100644 --- a/lib/verify_crate_workflow.py +++ b/lib/verify_crate_workflow.py @@ -47,6 +47,21 @@ def expression(value): return value +def run_configuration(value, context): + defaults = value.get("defaults", {}) + require(isinstance(defaults, dict), f"invalid {context} run defaults") + run_defaults = defaults.get("run", {}) + require(isinstance(run_defaults, dict) and not run_defaults, + f"{context} run defaults must not override executable helper semantics") + + +def critical_step(step, context): + require("shell" not in step and "working-directory" not in step, + f"{context} must use the standard runner shell and workspace") + require(step.get("continue-on-error", "false") == "false", + f"{context} must fail on execution errors") + + def helper_run(step, operation): if "if" in step or not isinstance(step.get("run"), str): return False @@ -66,6 +81,7 @@ def helper_run(step, operation): def verify(source, repository, repository_id): workflow = yaml.load(source, Loader=UniqueLoader) require(isinstance(workflow, dict), "workflow must be a YAML mapping") + run_configuration(workflow, "workflow") triggers = workflow.get("on") require(isinstance(triggers, dict) and "workflow_dispatch" in triggers and set(triggers) <= {"workflow_dispatch", "schedule"}, @@ -100,6 +116,9 @@ def verify(source, repository, repository_id): for name in (verify_name, publish_name): job = jobs.get(name) require(isinstance(job, dict), f"missing job {name}") + run_configuration(job, name) + require(job.get("continue-on-error", "false") == "false", + f"{name} must fail on execution errors") terms = expression(job.get("if")).split("&&") require(len(terms) == len(guard) and {term.strip() for term in terms} == guard, f"{name} must enforce exactly the repository/id/main/dispatch/automatic guard") @@ -120,6 +139,7 @@ def verify(source, repository, repository_id): and str(step.get("uses", "")).startswith("actions/checkout@")] require(len(checkouts) == 1, f"{name} must check out the exact main revision") checkout = checkouts[0] + critical_step(checkout, f"{name} checkout") require(re.fullmatch(r"actions/checkout@[0-9a-f]{40}", checkout["uses"]) is not None and checkout.get("with", {}).get("ref") == "${{ github.sha }}" and str(checkout.get("with", {}).get("persist-credentials")).lower() == "false" @@ -130,6 +150,10 @@ def verify(source, repository, repository_id): require(re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+@[0-9a-f]{40}", str(step["uses"])) is not None, f"{name} action reference must be a full immutable SHA") + for name in (verify_name, publish_name): + for step in jobs[name]["steps"]: + if helper_run(step, "verify"): + critical_step(step, f"{name} verification") verify_job = jobs[verify_name] require(verify_job.get("permissions", workflow["permissions"]) == {"contents": "read"}, "automatic verification must not request OIDC") @@ -145,6 +169,7 @@ def verify(source, repository, repository_id): if str(step.get("uses", "")).startswith("rust-lang/crates-io-auth-action@")] require(len(auth_steps) == 1, "automatic upload needs one official authentication action") auth_index, auth = auth_steps[0] + critical_step(auth, "official authentication step") require(auth.get("id") == "auth" and "if" not in auth, "official authentication step must be executable and have id auth") require(any(helper_run(step, "verify") for step in publish["steps"][:auth_index]), @@ -153,6 +178,7 @@ def verify(source, repository, repository_id): if "CARGO_REGISTRY_TOKEN" in step.get("env", {})] require(len(uploads) == 1, "only one upload step may receive the registry token") upload_index, upload = uploads[0] + critical_step(upload, "registry upload step") require(upload_index > auth_index and upload.get("id") in (None, "upload") and "if" not in upload and upload["env"]["CARGO_REGISTRY_TOKEN"] == "${{ steps.auth.outputs.token }}" and helper_run(upload, "upload"), diff --git a/tests/verify-manual-settings.sh b/tests/verify-manual-settings.sh index 261edf1..6fab0b9 100755 --- a/tests/verify-manual-settings.sh +++ b/tests/verify-manual-settings.sh @@ -158,6 +158,11 @@ WORKFLOW crate-workflow-comments) source="# crates-io-release refs/heads/main automatic rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18" ;; crate-workflow-disabled) source=${source//inputs.operation == \'automatic\'/false} ;; crate-workflow-no-oidc) source=${source//id-token: write/id-token: none} ;; + crate-workflow-shell) source=$(sed '/- run: python3 tools\/crate_release.py verify/a\ shell: echo {0}' <<<"${source}") ;; + crate-workflow-ignored-step-failure) source=$(sed '/- run: python3 tools\/crate_release.py verify/a\ continue-on-error: true' <<<"${source}") ;; + crate-workflow-ignored-job-failure) source=$(sed '/ runs-on: ubuntu-24.04/a\ continue-on-error: true' <<<"${source}") ;; + crate-workflow-default-shell) source=$(printf 'defaults:\n run:\n shell: echo {0}\n%s\n' "${source}") ;; + crate-workflow-job-default-shell) source=$(sed '/ runs-on: ubuntu-24.04/a\ defaults:\n run:\n shell: echo {0}' <<<"${source}") ;; crate-workflow-comment-run) source=${source//run: python3/run: \"# python3}; source=${source//\"\$DIGEST\"/\"\$DIGEST\"\"} ;; esac jq -n --arg content "$(printf '%s' "${source}" | base64)" \ @@ -805,7 +810,9 @@ grep -Fq 'KEEP atrinik/protocol environment crates-io-release metadata' <<<"${ob grep -Fq 'GitHub metadata does not verify external registration' <<<"${observed_output}" for scenario in crate-workflow-drift crate-env-wait crate-env-reviewer crate-env-bypass \ crate-env-branch crate-env-secret crate-workflow-comments crate-workflow-plaintext \ - crate-workflow-disabled crate-workflow-no-oidc; do + crate-workflow-disabled crate-workflow-no-oidc crate-workflow-shell \ + crate-workflow-ignored-step-failure crate-workflow-ignored-job-failure \ + crate-workflow-default-shell crate-workflow-job-default-shell; do if run_verify "${scenario}" "${observed_root}" >"${temporary}/${scenario}.out" \ 2>"${temporary}/${scenario}.err"; then echo "error: verifier accepted ${scenario}" >&2 From 89253c016c3d65f933f3b7c6476cc8d7d68192f7 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 12:16:46 -0500 Subject: [PATCH 04/16] docs: bind dependency App homepage and disabled webhook --- README.md | 5 +++-- bin/validate | 5 +++-- config/manual-settings.json | 4 +++- tests/validate-crate-trusted-publishing.sh | 2 ++ 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d6ee39b..77ef4e2 100644 --- a/README.md +++ b/README.md @@ -1225,8 +1225,9 @@ registration and the dependency App remain unprovisioned pending owner actions. `atrinik-crate-dependency-update` GitHub App. Its IDs and owner evidence remain null while setup is pending. Install it with **selected repositories** only: `atrinik/client` (ID `1327106844`) and `atrinik/editor` (ID `1327106902`). Its -permissions are exactly contents write, pull requests write, and metadata read; -it has no subscribed events, administration privilege, bypass entry, or +permissions are exactly contents write, pull requests write, and metadata read. +Set its homepage to `https://www.atrinik.org` and disable its webhook; it has no +subscribed events, administration privilege, bypass entry, or self-approval/self-merge authority. Preserve every other App and installation, including the existing Classic updater and provider Apps. diff --git a/bin/validate b/bin/validate index a8f4e76..5165d7f 100755 --- a/bin/validate +++ b/bin/validate @@ -854,11 +854,12 @@ jq -e --arg today "${validation_today}" ' .github_actions_apps as $apps | .external_provider_apps as $external_apps | .crate_dependency_update_app as $app | - ($app | keys == ["app_id", "app_slug", "events", "evidence_location", "installation_id", + ($app | keys == ["app_id", "app_slug", "events", "evidence_location", "homepage_url", "installation_id", "observed_on", "owner", "permissions", "pull_request_policy", "readiness", "repositories", "repository_scope_verification", "repository_selection", "runbook", "secret_name", - "secret_scope", "status", "variable_name", "variable_scope", "workflow_filename", "workflow_triggers"]) and + "secret_scope", "status", "variable_name", "variable_scope", "webhook_active", "workflow_filename", "workflow_triggers"]) 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 $app.permissions == {contents: "write", metadata: "read", pull_requests: "write"} and $app.events == [] and $app.owner == "Atrinik organization owners" and diff --git a/config/manual-settings.json b/config/manual-settings.json index e068144..b888b43 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -461,6 +461,8 @@ "pull_request_policy": "owned-branch-and-pr-only-no-auto-merge", "observed_on": null, "evidence_location": null, - "runbook": "README.md#crate-dependency-update-app" + "runbook": "README.md#crate-dependency-update-app", + "homepage_url": "https://www.atrinik.org", + "webhook_active": false } } diff --git a/tests/validate-crate-trusted-publishing.sh b/tests/validate-crate-trusted-publishing.sh index c4fe2b5..2ee56e5 100755 --- a/tests/validate-crate-trusted-publishing.sh +++ b/tests/validate-crate-trusted-publishing.sh @@ -109,6 +109,8 @@ reset_policy for filter in \ '.crate_dependency_update_app.permissions.administration = "write"' \ '.crate_dependency_update_app.events = ["push"]' \ + '.crate_dependency_update_app.webhook_active = true' \ + '.crate_dependency_update_app.homepage_url = "https://example.test"' \ '.crate_dependency_update_app.repository_selection = "all"' \ '.crate_dependency_update_app.repositories += [{repository:"atrinik/classic", repository_id:1327289971}]' \ '.crate_dependency_update_app.repositories[0].repository_id = 1' \ From 4caf061189707e74016200a3b3d8ecb614b894fd Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 12:34:42 -0500 Subject: [PATCH 05/16] feat: record observed updater App setup with pending activation --- AGENTS.md | 14 +++-- CONTRIBUTING.md | 6 ++- README.md | 59 +++++++++++++--------- bin/publish | 2 +- bin/validate | 17 +++++-- bin/verify-manual-settings | 9 +++- config/manual-settings.json | 20 +++++--- tests/validate-crate-trusted-publishing.sh | 7 ++- tests/validate-manual-credentials.sh | 5 +- tests/verify-manual-settings.sh | 34 +++++++++---- 10 files changed, 116 insertions(+), 57 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0c07fdd..4f98470 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,13 +133,17 @@ First owner-local API-token publication is limited to the fourteen declared renderer/toolkit libraries; exclude CLIs, revoke bootstrap credentials, and use OIDC only afterward. Protocol 0.2 retains its approved manual path. -- Keep the planned crate dependency App separate from unrelated Apps. Select - only client/editor, exactly contents/PR write and metadata read, no events, +- Keep the crate dependency App separate from unrelated Apps. Select only + client/editor, exactly contents/PR write and metadata read, no events, bypass or auto-merge; inventory names only for repository secret `CRATE_DEPENDENCY_UPDATE_APP_PRIVATE_KEY` and variable - `CRATE_DEPENDENCY_UPDATE_APP_ID`. Provision after the hourly/manual reviewed - workflows merge and retain exact owner UI selection proof; preparation must - verify registry readiness before the isolated job writes an owned PR. + `CRATE_DEPENDENCY_UPDATE_APP_ID`. Record observed installation and name-only + provisioning separately from pending reviewed workflow activation and dated + successful-owned-PR proof. Current owner-authorized early provisioning does + not imply effective credentials or a deployed workflow. Retain exact owner UI + or supported App-authenticated repository-selection readback; empty events + do not independently prove a disabled webhook. Preparation must verify + registry readiness before the isolated job writes an owned PR. - 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8063133..3a581d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -141,6 +141,10 @@ listed renderer/toolkit libraries and ends with revocation; subsequent releases use OIDC only. Protocol 0.2 retains its approved manual path. Dependency-update App setup is client/editor only, exact contents/PR write and metadata read, repository credential names only, registry readiness before owned PR creation, -and ordinary review/checks without auto-merge. Changes require negative schema +and ordinary review/checks without auto-merge. Distinguish observed installation +and credential-name provisioning from pending reviewed workflow activation and +real successful-owned-PR proof; owner-authorized early setup does not prove +effective secret values. Keep App-authenticated selected-set readback or owner +UI evidence, and do not infer the webhook switch from an empty event set. Changes require negative schema and live-metadata fixture tests plus a complete publisher plan; publishers do not provision these integrations. diff --git a/README.md b/README.md index 77ef4e2..b581e51 100644 --- a/README.md +++ b/README.md @@ -1216,34 +1216,43 @@ The automatic contract above replaces the former planned per-release maintainer-review policy. Protocol 0.2 keeps its approved manual release path; 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 transition remains pending. Renderer/toolkit -registration and the dependency App remain unprovisioned pending owner actions. +its automatic source/environment transition remains pending. Renderer/toolkit registration remains pending owner actions; the dependency +App is provisioned with workflow activation still pending. ## Crate dependency update App -`crate_dependency_update_app` plans a dedicated -`atrinik-crate-dependency-update` GitHub App. Its IDs and owner evidence remain -null while setup is pending. Install it with **selected repositories** only: -`atrinik/client` (ID `1327106844`) and `atrinik/editor` (ID `1327106902`). Its -permissions are exactly contents write, pull requests write, and metadata read. -Set its homepage to `https://www.atrinik.org` and disable its webhook; it has no -subscribed events, administration privilege, bypass entry, or -self-approval/self-merge authority. Preserve every other App and installation, -including the existing Classic updater and provider Apps. - -After both reviewed `released-dependencies.yml` workflows have merged, the -organization owner creates/installs the App and confirms the exact two selected -repositories in GitHub's supported owner UI. Store the private key separately -as repository secret `CRATE_DEPENDENCY_UPDATE_APP_PRIVATE_KEY` in each selected -repository, and its App ID as repository variable -`CRATE_DEPENDENCY_UPDATE_APP_ID`. Never display or copy private-key/token values -into logs or evidence. Update the inventory to `status: owner-observed` only -with stable App/installation IDs, date, and sanitized owner evidence. The -read-only verifier then checks live installation identity, exact permissions, -empty events, active state, repository identities, and credential **names**. -An administrative-token API cannot establish the exact selected repository set -or verify credential values/effective scope; owner UI and owning workflow proof -remain separate evidence. +`crate_dependency_update_app` records owner-observed App setup on 2026-10-11, +while owning workflow activation remains `pending-reviewed-workflows`. +The live App is `atrinik-crate-dependency-update`, App ID `5276152`, installation +ID `170332148`, with **selected repositories** exactly `atrinik/client` (ID +`1327106844`) and `atrinik/editor` (ID `1327106902`). 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, +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. + +The owner expressly authorized provisioning before the consumer workflows +merge. Each selected repository now has secret name +`CRATE_DEPENDENCY_UPDATE_APP_PRIVATE_KEY` and variable name +`CRATE_DEPENDENCY_UPDATE_APP_ID`; the observed App ID is `5276152`. Name metadata +was verified after creation on 2026-10-11. Never display or copy private-key/token +values or local credential paths into inventory, logs, or public evidence. The +secret value is not readable through GitHub's metadata API, and name presence +cannot prove that a key is valid or effective. These observations do not grant +workflow activation, bypass, self-approval, or self-merge authority. + +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 +workflow activation. Merge both reviewed `released-dependencies.yml` workflows, +then prove effective App credentials with an owned PR and its ordinary required +checks before recording `observed-successful-owned-pr` with dated evidence. +Current activation evidence remains null; an observed setup status alone cannot +prove a successful owning workflow. The updaters run hourly (`17 * * * *`) and through `workflow_dispatch`. Their credential-free preparation verifies exact released versions and registry diff --git a/bin/publish b/bin/publish index 2f5f792..c11aa4d 100755 --- a/bin/publish +++ b/bin/publish @@ -1385,6 +1385,6 @@ while IFS= read -r crate_record; do state=$(jq -r '"source=\(.source_state), environment=\(.environment_state), registration=\(.publisher_registration.state)"' <<<"${crate_record}") echo "MANUAL ${repository} crates.io ${crate}: ${state}; reviewed source/version merge, automatic crates-io-release environment (zero reviewers/wait), and Trusted Publisher owner setup; publisher does not apply" done < <(jq -c '.crates_io_trusted_publishing[]' "${root}/config/manual-settings.json") -app_state=$(jq -r '.crate_dependency_update_app.status' "${root}/config/manual-settings.json") +app_state=$(jq -r '.crate_dependency_update_app | "identity=\(.status), credential_names=\(.credential_state), workflow=\(.workflow_activation.state)"' "${root}/config/manual-settings.json") echo "MANUAL atrinik-crate-dependency-update ${app_state} selected client/editor App and credential slots; publisher does not apply" echo "Manual confirmation required for config/manual-settings.json." diff --git a/bin/validate b/bin/validate index 5165d7f..3b7a6c9 100755 --- a/bin/validate +++ b/bin/validate @@ -854,10 +854,10 @@ jq -e --arg today "${validation_today}" ' .github_actions_apps as $apps | .external_provider_apps as $external_apps | .crate_dependency_update_app as $app | - ($app | keys == ["app_id", "app_slug", "events", "evidence_location", "homepage_url", "installation_id", + ($app | keys == ["app_id", "app_slug", "credential_state", "events", "evidence_location", "homepage_url", "installation_id", "observed_on", "owner", "permissions", "pull_request_policy", "readiness", "repositories", "repository_scope_verification", "repository_selection", "runbook", "secret_name", - "secret_scope", "status", "variable_name", "variable_scope", "webhook_active", "workflow_filename", "workflow_triggers"]) and + "secret_scope", "status", "variable_name", "variable_scope", "webhook_active", "workflow_activation", "workflow_filename", "workflow_triggers"]) 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 @@ -872,14 +872,25 @@ jq -e --arg today "${validation_today}" ' $app.readiness == "exact-released-crates-and-checksums-before-pr" and $app.pull_request_policy == "owned-branch-and-pr-only-no-auto-merge" and $app.runbook == "README.md#crate-dependency-update-app" and + ($app.credential_state | IN("pending-name-metadata", "observed-name-metadata")) and + ($app.workflow_activation | keys == ["evidence_location", "observed_on", "state"]) and + (if $app.workflow_activation.state == "pending-reviewed-workflows" then + $app.workflow_activation.observed_on == null and $app.workflow_activation.evidence_location == null + elif $app.workflow_activation.state == "observed-successful-owned-pr" then + $app.status == "owner-observed" and $app.credential_state == "observed-name-metadata" and + ($app.workflow_activation.observed_on | observed_day) and + ($app.workflow_activation.evidence_location | type == "string" and test("^[A-Za-z0-9_./#-]+$")) + else false end) and (if $app.status == "planned" then + $app.credential_state == "pending-name-metadata" and + $app.workflow_activation.state == "pending-reviewed-workflows" and $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 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 == "organization_owner_ui" and + ($app.repository_scope_verification | IN("organization_owner_ui", "app_authenticated_repository_api")) and ($app.observed_on | observed_day) and all(($apps + $external_apps)[]; .app_id != $app.app_id and .installation_id != $app.installation_id) and ($app.evidence_location | type == "string" and test("^[A-Za-z0-9_./#-]+$")) diff --git a/bin/verify-manual-settings b/bin/verify-manual-settings index e1782c0..d46904f 100755 --- a/bin/verify-manual-settings +++ b/bin/verify-manual-settings @@ -827,7 +827,7 @@ fi while IFS= read -r selected_repository; do repository=$(jq -r '.repository' <<<"${selected_repository}") verify_repository_identity "${repository}" "$(jq -r '.repository_id' <<<"${selected_repository}")" - if [[ $(jq -r '.status' <<<"${app_record}") == owner-observed ]]; then + if [[ $(jq -r '.credential_state' <<<"${app_record}") == observed-name-metadata ]]; then load_repository_secrets "${repository}" load_repository_variables "${repository}" for kind in secret variable; do @@ -849,7 +849,12 @@ done < <(jq -c '.repositories[]' <<<"${app_record}") if [[ $(jq -r '.status' <<<"${app_record}") == planned ]]; then echo "PENDING ${app_slug} selected client/editor App installation and repository credential slots; no provisioning applied" else - echo "MANUAL ${app_slug} selected repositories require current owner UI proof: atrinik/client and atrinik/editor only" + echo "MANUAL ${app_slug} selected repositories require current owner UI or App-authenticated API proof: atrinik/client and atrinik/editor only" + activation_status=PENDING + if [[ $(jq -r '.workflow_activation.state' <<<"${app_record}") == observed-successful-owned-pr ]]; then + activation_status=OWNER-OBSERVED + fi + echo "${activation_status} ${app_slug} owning workflow activation evidence: $(jq -r '.workflow_activation.state' <<<"${app_record}"); name metadata alone does not prove effective values or a successful PR" fi # GraphQL variables must remain literal for GitHub to substitute. diff --git a/config/manual-settings.json b/config/manual-settings.json index b888b43..2e5d84b 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -425,12 +425,12 @@ "require_secure_two_factor_methods": true, "reason": "The public GitHub API does not expose supported update parameters for every setting in this file, including organization pins and the exact selected-repository set for an organization GitHub App installation to an administrative token, and external credential values must never be stored here. Confirm these settings, the exact ordered pin set, credential lifecycle metadata and live secret-name presence, GitHub App repository selection, Codecov GitHub App repository access, any listed GitHub Pages sites and GitHub Actions environments, and GitHub Packages Actions access in the corresponding organization, repository, and package settings UI.", "crate_dependency_update_app": { - "app_id": null, + "app_id": 5276152, "app_slug": "atrinik-crate-dependency-update", - "installation_id": null, - "status": "planned", + "installation_id": 170332148, + "status": "owner-observed", "repository_selection": "selected", - "repository_scope_verification": "pending-owner-ui", + "repository_scope_verification": "app_authenticated_repository_api", "owner": "Atrinik organization owners", "permissions": { "contents": "write", @@ -459,10 +459,16 @@ ], "readiness": "exact-released-crates-and-checksums-before-pr", "pull_request_policy": "owned-branch-and-pr-only-no-auto-merge", - "observed_on": null, - "evidence_location": null, + "observed_on": "2026-10-11", + "evidence_location": "README.md#crate-dependency-update-app", "runbook": "README.md#crate-dependency-update-app", "homepage_url": "https://www.atrinik.org", - "webhook_active": false + "webhook_active": false, + "credential_state": "observed-name-metadata", + "workflow_activation": { + "state": "pending-reviewed-workflows", + "observed_on": null, + "evidence_location": null + } } } diff --git a/tests/validate-crate-trusted-publishing.sh b/tests/validate-crate-trusted-publishing.sh index 2ee56e5..c951655 100755 --- a/tests/validate-crate-trusted-publishing.sh +++ b/tests/validate-crate-trusted-publishing.sh @@ -119,8 +119,11 @@ for filter in \ '.crate_dependency_update_app.private_key = "forbidden"' \ '.crate_dependency_update_app.pull_request_policy = "auto-merge"' \ '.crate_dependency_update_app.status = "active"' \ - '.crate_dependency_update_app.app_id = 1' \ - '.crate_dependency_update_app.observed_on = "2026-10-11"'; do + '.crate_dependency_update_app.workflow_activation.state = "active"' \ + '.crate_dependency_update_app.workflow_activation.state = "observed-successful-owned-pr"' \ + '.crate_dependency_update_app.workflow_activation.evidence_location = "invented"' \ + '.crate_dependency_update_app.app_id = null' \ + '.crate_dependency_update_app.observed_on = null'; do assert_rejected "${filter}" done # Removing unactivated manual intent is a valid reviewed rollback. diff --git a/tests/validate-manual-credentials.sh b/tests/validate-manual-credentials.sh index 71a009f..2e67838 100755 --- a/tests/validate-manual-credentials.sh +++ b/tests/validate-manual-credentials.sh @@ -24,7 +24,10 @@ assert_invalid() { reset_manual_settings() { jq '.crates_io_trusted_publishing[].publisher_registration = - {state:"pending-owner-registration",observed_on:null,evidence_location:null}' \ + {state:"pending-owner-registration",observed_on:null,evidence_location:null} | + .crate_dependency_update_app |= (.status="planned" | .app_id=null | .installation_id=null | + .repository_scope_verification="pending-owner-ui" | .observed_on=null | .evidence_location=null | + .credential_state="pending-name-metadata")' \ "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" } diff --git a/tests/verify-manual-settings.sh b/tests/verify-manual-settings.sh index 6fab0b9..6f76acf 100755 --- a/tests/verify-manual-settings.sh +++ b/tests/verify-manual-settings.sh @@ -350,7 +350,7 @@ repos/atrinik/classic/pages) elif [[ ${FAKE_GH_SCENARIO} == app-installations-page2 || \ ${FAKE_GH_SCENARIO} == app-installations-page2-failure ]]; then jq -n '{ - total_count: 102, + total_count: 103, installations: [range(0; 100) | { id: (1000 + .), app_id: (2000 + .), @@ -422,8 +422,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 - updater_installed=false - [[ ${FAKE_GH_SCENARIO} != updater-* ]] || updater_installed=true + updater_installed=true + [[ ${FAKE_GH_SCENARIO} != updater-missing ]] || updater_installed=false updater_permissions='{"contents":"write","metadata":"read","pull_requests":"write"}' [[ ${FAKE_GH_SCENARIO} != updater-permissions ]] || updater_permissions='{"contents":"write","metadata":"read","pull_requests":"write","administration":"write"}' updater_selection=selected @@ -482,7 +482,7 @@ repos/atrinik/classic/pages) suspended_at: null }] else [] end) + (if $updater_installed then [{ - id:154000002, app_id:85500002, app_slug:"atrinik-crate-dependency-update", + id:170332148, app_id:5276152, app_slug:"atrinik-crate-dependency-update", target_type:"Organization",account:{login:"atrinik",type:"Organization"}, repository_selection:$updater_selection,permissions:$updater_permissions, events:[],suspended_at:null @@ -498,7 +498,7 @@ repos/atrinik/classic/pages) fi [[ ${FAKE_GH_SCENARIO} == app-installations-page2 ]] jq -n '{ - total_count: 102, + total_count: 103, installations: [{ id: 153045686, app_id: 4564008, @@ -530,6 +530,12 @@ repos/atrinik/classic/pages) }, events: ["pull_request", "push"], suspended_at: null + }, { + id:170332148, app_id:5276152, app_slug:"atrinik-crate-dependency-update", + target_type:"Organization",account:{login:"atrinik",type:"Organization"}, + repository_selection:"selected", + permissions:{contents:"write",metadata:"read",pull_requests:"write"}, + events:[],suspended_at:null }] }' ;; @@ -820,7 +826,15 @@ for scenario in crate-workflow-drift crate-env-wait crate-env-reviewer crate-env fi done -if run_verify updater-present >"${temporary}/updater-planned.out" 2>"${temporary}/updater-planned.err"; then +planned_updater_root=${temporary}/planned-updater +mkdir -p "${planned_updater_root}/bin" +cp "${root}/bin/validate" "${root}/bin/verify-manual-settings" "${planned_updater_root}/bin/" +cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${planned_updater_root}/" +jq '.crate_dependency_update_app |= (.status="planned" | .app_id=null | .installation_id=null | + .repository_scope_verification="pending-owner-ui" | .observed_on=null | .evidence_location=null | + .credential_state="pending-name-metadata")' "${root}/config/manual-settings.json" \ + >"${planned_updater_root}/config/manual-settings.json" +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 fi @@ -828,15 +842,15 @@ updater_root=${temporary}/observed-updater mkdir -p "${updater_root}/bin" cp "${root}/bin/validate" "${root}/bin/verify-manual-settings" "${updater_root}/bin/" cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${updater_root}/" -jq '.crate_dependency_update_app |= (.status="owner-observed" | .app_id=85500002 | - .installation_id=154000002 | .repository_scope_verification="organization_owner_ui" | +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")' \ "${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 'KEEP atrinik/editor crate dependency App credential name metadata' <<<"${updater_output}" -grep -Fq 'selected repositories require current owner UI proof: atrinik/client and atrinik/editor only' <<<"${updater_output}" -for scenario in present updater-permissions updater-selection updater-missing-secret updater-missing-variable; do +grep -Fq 'selected repositories require current owner UI or App-authenticated API proof: atrinik/client and atrinik/editor only' <<<"${updater_output}" +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 echo "error: verifier accepted observed updater ${scenario}" >&2 From 857a1436f047edfc85eaa2bbc056bb2008211ead Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 13:32:24 -0500 Subject: [PATCH 06/16] fix: qualify inert crate provenance and durable upload intent --- AGENTS.md | 15 ++- CONTRIBUTING.md | 11 +- README.md | 141 +++++++++++++++------ bin/validate | 8 +- bin/verify-manual-settings | 9 +- config/manual-settings.json | 9 ++ lib/verify_crate_workflow.py | 139 ++++++++++++++++++-- tests/fixtures/crate-publication.yml | 57 +++++++++ tests/fixtures/crate-source-release.yml | 66 ++++++++++ tests/validate-crate-trusted-publishing.sh | 11 ++ tests/verify-crate-workflow.sh | 5 + tests/verify-manual-settings.sh | 68 +++------- tests/verify_crate_workflow.py | 96 ++++++++++++++ 13 files changed, 526 insertions(+), 109 deletions(-) create mode 100644 tests/fixtures/crate-publication.yml create mode 100644 tests/fixtures/crate-source-release.yml create mode 100755 tests/verify-crate-workflow.sh create mode 100644 tests/verify_crate_workflow.py diff --git a/AGENTS.md b/AGENTS.md index 4f98470..c5df8ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,11 +125,19 @@ automatic future publication; environments have zero reviewers/wait and no registry secrets. Keep pending source/environment/registration distinct from observations; verify live repository/ref/protection and observed environments. - Parse observed exact-main workflows structurally with the declared + Parse both observed exact-main `publish-crate.yml` and `release.yml` structurally with the declared `requirements-validation.txt` dependency; comments/plain text or disabled jobs are not executable source proof. External registration remains sanitized owner evidence, not a GitHub API fact, and can precede a pending later automatic source/environment transition. + Source signing alone may request attestation writes/OIDC in a separate inert + job; build and semantic-release jobs have no OIDC. Automatic verification + uses only contents/actions/checks/attestations read, while the isolated inert + publisher adds contents write for atomic upload intent and OIDC. Require + actual protected-main manifest provenance and immutable source-release proof + in the producer helper. Before registry PUT, atomically reserve the global + crate/version Git-reference coordinate; existing/ambiguous reservations block + absent-version retries and must never be automatically deleted or reset. First owner-local API-token publication is limited to the fourteen declared renderer/toolkit libraries; exclude CLIs, revoke bootstrap credentials, and use OIDC only afterward. Protocol 0.2 retains its approved manual path. @@ -179,6 +187,11 @@ merged and qualified. Every registry upload must prove that its actual source release is immutable; ordinary workflow tokens cannot read administrative immutability settings, so do not add an unsupported pre-tag settings query. Never apply immutable-release policy from an unmerged branch. + This release delivery activates only the exact immutable-release API policy + after a complete snapshot, identity and freshness check, exact readback and + verified rollback. Preserve foreign live state; do not broad-apply the + publisher plan. Suspend only the three producer source-release workflows in + the reviewed reversible window, retaining and restoring their original states. - Never print or commit administrative tokens, live secrets, or API responses containing credentials. - Run `bin/validate`, `bash -n`, ShellCheck on governance scripts, the publisher diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a581d1..0990a0a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -138,7 +138,16 @@ secrets. Keep pending onboarding distinct from observed source/environment and owner-observed external registration; verify live protected main and exact refs. First owner-local API-token bootstrap is restricted to the fourteen listed renderer/toolkit libraries and ends with revocation; subsequent releases -use OIDC only. Protocol 0.2 retains its approved manual path. Dependency-update +use OIDC only. Protocol 0.2 retains its approved manual path. Crate +publication must qualify both exact-main workflows, isolate inert source +signing from build/semantic-release OIDC, and keep automatic verify permissions +read-only. The inert publisher alone adds contents write for the global +crate/version upload intent and receives its intent token only in the upload +step. Runtime certificate/source-run verification and create-only reservation +readback precede upload; an existing/ambiguous intent blocks absent-version +retries and is never automatically reset. Activate immutable-release policy +only through the documented narrow snapshot/readback/verified-rollback window; +do not broad-apply unrelated publisher-plan mutations. Dependency-update App setup is client/editor only, exact contents/PR write and metadata read, repository credential names only, registry readiness before owned PR creation, and ordinary review/checks without auto-merge. Distinguish observed installation diff --git a/README.md b/README.md index b581e51..09ea129 100644 --- a/README.md +++ b/README.md @@ -817,42 +817,68 @@ and the complete asset payload. A configured flag alone is not release proof. Qualification includes build, packaging, draft assembly, retry/recovery, actual immutable-release upload gating, and publication paths. -Use this rollout order: - -1. Review, merge, and qualify each owning release workflow before activating - its selection. Confirm draft creation/recovery and complete asset assembly; - preserve Classic. Retain exact source revision and producer validation - evidence, including rejection of mutable source releases before registry - authentication or upload. -2. Merge this governance policy. Run `bin/publish` and inspect the exact - `PUT /orgs/atrinik/settings/immutable-releases` payload. It must select only - `[1327106950,1327106998,1327107043,1327289971]` in this inventory. - Preserve any additional previously accepted selection during future edits. -3. With explicit deployment authorization, run `bin/publish --apply`. The - publisher verifies the organization selection and the repository's - `enabled=true,enforced_by_owner=true` state before continuing. -4. Confirm the exact full live selection and each repository independently, - then rerun the failed source workflow and verify its complete immutable - release and registry readiness: +Use this narrow rollout order for this release activation: + +1. Qualify each reviewed draft-first producer candidate and retain its exact + revision and validation evidence. Before merging, inspect the three + Semantic Release workflows, verify no in-flight run, record their original + enabled states, and temporarily disable only those three workflows. This + reversible window prevents a first mutable automatic source release while + immutable policy awaits activation; preserve Classic and all other workflows. +2. Merge the accepted producer and governance source. Run `bin/publish` in + read-only plan mode and inspect every mutation. The broad plan can include + unrelated drift, so **do not run `bin/publish --apply` for this activation**. + Provision crate environments only after their owning workflows are merged. +3. The authorized coordinator verifies the live organization and repository + identities and snapshots `GET /orgs/atrinik/settings/immutable-releases` + plus the complete paginated + `GET /orgs/atrinik/settings/immutable-releases/repositories` selection. + Normalize the snapshot exactly as `current_immutable_release_payload` in + `bin/publish`: mode `all`/`none`, or mode `selected` with the complete sorted + unique selected IDs. Retain the snapshot privately for verified rollback. +4. Derive the targeted payload from the merged desired inventory, adding only + the three producer IDs and retaining the complete accepted prior selection. + For this inventory it is `enforced_repositories: selected` and + `selected_repository_ids: [1327106950,1327106998,1327107043,1327289971]`. + Require all four current names/IDs to match and preserve Classic. Re-read the + complete live policy immediately before writing; it must still equal the + snapshot. Unexpected mode, unknown selection, identity or concurrent drift + stops activation until the accepted desired state is reconciled. +5. Issue only `PUT /orgs/atrinik/settings/immutable-releases` with that reviewed + payload through the supported API and the coordinator's existing authority. + Do not apply repository settings, rulesets, other Actions policy or other + organization changes from the broad plan. Re-read the organization mode and + complete selection, require exact equality with the attempted payload, then + require every selected repository's immutable-release readback to report + `enabled=true,enforced_by_owner=true`. +6. Restore only the three workflows to their recorded enabled states. Dispatch + exact merged main as authorized and verify a complete immutable source + release, its protected-main provenance, and registry readiness before any + upload or downstream migration. + +Readback uses the same supported endpoints as the publisher: - ```sh - gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ - orgs/atrinik/settings/immutable-releases - gh api --paginate -H 'X-GitHub-Api-Version: 2026-03-10' \ - 'orgs/atrinik/settings/immutable-releases/repositories?per_page=100' \ - --jq '.repositories[] | [.name, .id]' - for repository in classic protocol renderer content-toolkit; do - gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/atrinik/${repository}/immutable-releases" - done - ``` +```sh +gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + orgs/atrinik/settings/immutable-releases +gh api --paginate -H 'X-GitHub-Api-Version: 2026-03-10' \ + 'orgs/atrinik/settings/immutable-releases/repositories?per_page=100' \ + --jq '.repositories[] | [.name, .id]' +for repository in classic protocol renderer content-toolkit; do + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/atrinik/${repository}/immutable-releases" +done +``` -If the apply or its verification fails, the publisher automatically sends the -pre-change mode and complete selected-ID set back to GitHub and verifies the -rollback. Preserve its full error output and stop; do not retry with an ad hoc -payload. For a later intentional rollback, review and merge a desired-state -change first, then run the normal plan and explicitly authorized apply flow so -the previous selection is not guessed or partially overwritten. +If the targeted write or verification fails, keep the producer workflows +suspended and read the policy again. Restore the exact pre-change snapshot only +when the live state still matches the attempted payload in the coordinator's +owned window; verify the complete restored mode and selection before restoring +workflow states. Ambiguous readback or concurrent foreign drift is a stop +condition, not permission to overwrite it. Retain bounded sanitized evidence +and resolve the conflict before retrying. A later intentional policy removal +requires reviewed desired source and the same narrow snapshot/readback/rollback +procedure. Preserve published releases, tags, assets and durable upload intents. ## Provisioning the Classic Discord release environment @@ -1140,9 +1166,15 @@ reference is `rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d The [pinned official action](https://github.com/rust-lang/crates-io-auth-action/blob/c6f97d42243bad5fab37ca0427f495c86d5b1a18/README.md) exchanges GitHub OIDC for a temporary crates.io token and revokes it in its -post step. Only the guarded upload job requests `id-token: write`; preparation -and reconciliation have no OIDC permission. Only the Cargo upload step receives -the temporary token. Preserve the other action allowances and GitHub-owned +post step. Within `publish-crate.yml`, only the guarded automatic and retained historical +protocol 0.2 upload jobs request +`id-token: write`; crate preparation +and reconciliation have no OIDC permission. The source workflow has a separate +inert manifest-signing job with only contents read, OIDC write and attestation +write; its build and semantic-release jobs have no OIDC. The automatic publisher +requests only contents write for durable upload intent, actions/checks/attestations +read and OIDC write. Only the inert upload helper receives the temporary registry +token and the step-scoped Git-reference intent token; it runs no Cargo, npm or builds. Preserve the other action allowances and GitHub-owned Actions access. Registry credentials never belong in GitHub secrets, inventory, workflow inputs, logs, or committed credential files. @@ -1150,11 +1182,13 @@ The [GitHub environment controls](https://docs.github.com/en/actions/reference/w and [crates.io Trusted Publishing documentation](https://crates.io/docs/trusted-publishing) are the supported provisioning references. Activation proceeds in this order: -1. Review and merge governance and owning workflow source. Run the complete - `bin/publish` read-only plan and inspect every planned mutation; apply only - the authorized merged policy. Preserve unrelated live Actions policy. +1. Qualify and merge governance and owning workflow source using the narrow + reversible immutable-release activation window above. Run the complete + `bin/publish` read-only plan and inspect every planned mutation; activate only + the authorized exact policy through scoped API/UI operations. Preserve + unrelated live policy and never broad-apply this release delivery. 2. Verify live repository ID/name, active status, default branch, exact main - ref, protected main, and the merged publication workflow. Record + 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 @@ -1181,7 +1215,8 @@ are the supported provisioning references. Activation proceeds in this order: `bin/publish` reports this manual setup but never performs it. `bin/verify-manual-settings` always checks actual repository identity, exact main ref, and protected main. Pending source/environment/registration states -remain explicit. Observed source is parsed structurally on that exact main revision by +remain explicit. Observed source parses both `publish-crate.yml` and `release.yml` on the same +exact main revision through `lib/verify_crate_workflow.py`. Install the declared `requirements-validation.txt` dependency before local observed-source checks; the policy and publication workflows install it before verification. The @@ -1192,12 +1227,34 @@ failures, and skipped verification/auth/upload steps. It verifies executable dispatch, repository/ID/protected-main conditions, credential-free dependency, environment, scoped permissions, an unconditional full-SHA official auth action, and only the returned temporary upload token. +Source qualification also verifies a separate inert signer, successful package +and signer dependencies, exact prepared revision/same-run payload, and the +full-SHA official `actions/attest-build-provenance` action over +`build/release/crate-release.json`. Existing `github_owned_allowed: true` covers +that official action and its internal `actions/attest` dependency; actual refs +stay immutable and ordinary Dependabot updates need no central SHA lock. Safe schedule reconciliation may coexist; its jobs receive no OIDC. Observed environments require the exact selected main branch, no reviewer or wait protection rule, disabled administrator bypass, and empty secret/variable name metadata. Any identity/protection drift fails closed. The verifier reports crates.io registration as owner-observed evidence: GitHub metadata cannot prove external registration, token scope, successful upload, or future readiness. +Runtime qualification belongs to the producer helper. It cryptographically +verifies the actual manifest attestation against the exact repository ID, +protected main source/signer revision, source workflow and run attempt; matching +asset digests alone cannot authenticate uploaded release metadata. It also +requires the actual source release to be immutable before registry upload. +Before each new crate PUT, the inert upload helper atomically creates only the +global `refs/tags/crate-upload-intent//` coordinate through the +Git-reference API. Only a successful create response plus exact readback permits +that upload. An existing or ambiguous reservation with an absent registry version +blocks retries, including a retry from another source release; registry absence +alone cannot authorize another PUT. Do not automatically delete/reset these +intents. Reconcile an interrupted upload with owner-observed registry evidence +and the documented producer recovery procedure before any separately reviewed +recovery action. Structural workflow verification does not duplicate runtime +certificate or reservation logic or prove that those live operations succeeded. + Change each pending state only through reviewed source with its corresponding observation. External registration can be observed before a later automatic source/environment transition; it does not activate that transition. A status diff --git a/bin/validate b/bin/validate index 3b7a6c9..8ebbca0 100755 --- a/bin/validate +++ b/bin/validate @@ -806,8 +806,8 @@ jq -e \ $inventory[$name] as $expected | (keys == ["authentication_action", "bootstrap_crates", "bootstrap_policy", "crates", "environment", "environment_state", "provider", "publisher_registration", "release_approval", - "repository", "repository_id", "runbook", "source_state", "workflow_filename", - "workflow_ref", "workflow_trigger"]) and + "repository", "repository_id", "runbook", "source_evidence_policy", "source_state", + "source_workflow_filename", "upload_intent_policy", "workflow_filename", "workflow_ref", "workflow_trigger"]) and $expected != null and .repository == ("atrinik/" + $name) and .repository_id == $expected.id and .crates == $expected.crates and .bootstrap_crates == (if $expected.bootstrap == true then $expected.crates else [] end) and @@ -821,9 +821,13 @@ jq -e \ (($patterns | index($action)) != null or ($patterns | index("rust-lang/crates-io-auth-action@*")) != null)) and .provider == "github" and + $actions[0].github_owned_allowed == true and ($repositories[0].pull_request_gate | index($name)) != null and ($repositories[0].archive | index($name)) == null and .workflow_filename == "publish-crate.yml" and + .source_workflow_filename == "release.yml" and + .source_evidence_policy == "github-attested-protected-main-manifest" and + .upload_intent_policy == "create-only-global-git-ref-before-upload" and .workflow_ref == "refs/heads/main" and .workflow_trigger == "workflow_dispatch" and .runbook == "README.md#automatic-crate-trusted-publishing" and .release_approval == "reviewed-source-and-version-merge" and diff --git a/bin/verify-manual-settings b/bin/verify-manual-settings index d46904f..d1db78b 100755 --- a/bin/verify-manual-settings +++ b/bin/verify-manual-settings @@ -433,9 +433,14 @@ while IFS= read -r crate_record; do "repos/${repository}/contents/.github/workflows/publish-crate.yml?ref=${branch_sha}") workflow_source=$(jq -er 'select(.encoding == "base64" and .type == "file") | .content' \ <<<"${workflow}" | base64 --decode) + source_workflow=$(github_api "read ${repository} source signing workflow at exact main" \ + "repos/${repository}/contents/.github/workflows/release.yml?ref=${branch_sha}") + source_workflow_file=${temporary}/${repository##*/}-source-release.yml + jq -er 'select(.encoding == "base64" and .type == "file") | .content' \ + <<<"${source_workflow}" | base64 --decode >"${source_workflow_file}" python3 "${root}/lib/verify_crate_workflow.py" --repository "${repository}" \ - --repository-id "${repository_id}" <<<"${workflow_source}" - echo "KEEP ${repository} reviewed publication workflow at ${branch_sha}" + --repository-id "${repository_id}" --source-workflow-file "${source_workflow_file}" <<<"${workflow_source}" + echo "KEEP ${repository} reviewed publication and source signing workflows at ${branch_sha}" fi crates=$(jq -r '.crates | join(",")' <<<"${crate_record}") echo "KEEP ${repository} exact protected main publication identity" diff --git a/config/manual-settings.json b/config/manual-settings.json index 2e5d84b..ecdaa17 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -81,7 +81,10 @@ "repository": "atrinik/protocol", "repository_id": 1327106950, "runbook": "README.md#automatic-crate-trusted-publishing", + "source_evidence_policy": "github-attested-protected-main-manifest", "source_state": "pending-reviewed-workflow", + "source_workflow_filename": "release.yml", + "upload_intent_policy": "create-only-global-git-ref-before-upload", "workflow_filename": "publish-crate.yml", "workflow_ref": "refs/heads/main", "workflow_trigger": "workflow_dispatch", @@ -137,7 +140,10 @@ "repository": "atrinik/renderer", "repository_id": 1327106998, "runbook": "README.md#automatic-crate-trusted-publishing", + "source_evidence_policy": "github-attested-protected-main-manifest", "source_state": "pending-reviewed-workflow", + "source_workflow_filename": "release.yml", + "upload_intent_policy": "create-only-global-git-ref-before-upload", "workflow_filename": "publish-crate.yml", "workflow_ref": "refs/heads/main", "workflow_trigger": "workflow_dispatch", @@ -193,7 +199,10 @@ "repository": "atrinik/content-toolkit", "repository_id": 1327107043, "runbook": "README.md#automatic-crate-trusted-publishing", + "source_evidence_policy": "github-attested-protected-main-manifest", "source_state": "pending-reviewed-workflow", + "source_workflow_filename": "release.yml", + "upload_intent_policy": "create-only-global-git-ref-before-upload", "workflow_filename": "publish-crate.yml", "workflow_ref": "refs/heads/main", "workflow_trigger": "workflow_dispatch", diff --git a/lib/verify_crate_workflow.py b/lib/verify_crate_workflow.py index 5e933b7..ddd3dd2 100644 --- a/lib/verify_crate_workflow.py +++ b/lib/verify_crate_workflow.py @@ -5,6 +5,7 @@ import re import shlex import sys +from pathlib import Path try: import yaml @@ -92,6 +93,8 @@ def verify(source, repository, repository_id): "dispatch must offer the automatic operation") require(workflow.get("permissions") == {"contents": "read"}, "workflow default permission must be contents read only") + require(workflow.get("env", {}) in ({}, {"PYTHONDONTWRITEBYTECODE": "1"}), + "publication workflow must not override helper execution through global environment") require("CARGO_REGISTRY_TOKEN" not in workflow.get("env", {}), "registry token must not be workflow scoped") jobs = workflow.get("jobs") @@ -122,17 +125,20 @@ def verify(source, repository, repository_id): terms = expression(job.get("if")).split("&&") require(len(terms) == len(guard) and {term.strip() for term in terms} == guard, f"{name} must enforce exactly the repository/id/main/dispatch/automatic guard") - require(isinstance(job.get("runs-on"), (str, list)) and bool(job["runs-on"]), - f"{name} has no runner") + require(job.get("runs-on") == "ubuntu-24.04" and "container" not in job and "services" not in job, + f"{name} must use the reviewed hosted runner without execution overrides") require("CARGO_REGISTRY_TOKEN" not in job.get("env", {}), f"{name} must not scope the registry token to the job") for key, value in { + "GH_READ_TOKEN": "${{ github.token }}", "SOURCE_RELEASE_ID": "${{ inputs.source_release_id }}", "SOURCE_REVISION": "${{ inputs.source_revision }}", "MANIFEST_SHA256": "${{ inputs.manifest_sha256 }}", }.items(): require(job.get("env", {}).get(key) == value, f"{name} must bind {key} to the reviewed dispatch input") + require(set(job.get("env", {})) == {"GH_READ_TOKEN", "SOURCE_RELEASE_ID", "SOURCE_REVISION", "MANIFEST_SHA256"}, + f"{name} must not override helper execution through extra environment") steps = job.get("steps") require(isinstance(steps, list) and steps, f"{name} has no executable steps") checkouts = [step for step in steps if isinstance(step, dict) @@ -143,6 +149,9 @@ def verify(source, repository, repository_id): require(re.fullmatch(r"actions/checkout@[0-9a-f]{40}", checkout["uses"]) is not None and checkout.get("with", {}).get("ref") == "${{ github.sha }}" and str(checkout.get("with", {}).get("persist-credentials")).lower() == "false" + and set(checkout.get("with", {})) <= {"ref", "persist-credentials", "fetch-depth"} + and str(checkout.get("with", {}).get("fetch-depth", "0")) == "0" + and "env" not in checkout and "run" not in checkout and "if" not in checkout, f"{name} checkout identity/credential drift") for step in steps: require(isinstance(step, dict), f"invalid {name} step") @@ -155,22 +164,24 @@ def verify(source, repository, repository_id): if helper_run(step, "verify"): critical_step(step, f"{name} verification") verify_job = jobs[verify_name] - require(verify_job.get("permissions", workflow["permissions"]) == {"contents": "read"}, - "automatic verification must not request OIDC") + read_permissions = {"contents": "read", "actions": "read", "checks": "read", "attestations": "read"} + require(verify_job.get("permissions") == read_permissions, + "automatic verification needs only source/provenance/run/check read permissions") require(any(helper_run(step, "verify") for step in verify_job["steps"]), "automatic verification helper must run unconditionally") publish = jobs[publish_name] require(publish.get("needs") in (verify_name, [verify_name]), "automatic publication must depend on credential-free verification") require(publish.get("environment") == "crates-io-release", "wrong publication environment") - require(publish.get("permissions") == {"contents": "read", "id-token": "write"}, - "automatic upload needs exactly contents read and OIDC write") + require(publish.get("permissions") == dict(read_permissions, contents="write", **{"id-token": "write"}), + "automatic upload needs only provenance reads, upload-intent contents write and OIDC write") auth_steps = [(index, step) for index, step in enumerate(publish["steps"]) if str(step.get("uses", "")).startswith("rust-lang/crates-io-auth-action@")] require(len(auth_steps) == 1, "automatic upload needs one official authentication action") auth_index, auth = auth_steps[0] critical_step(auth, "official authentication step") - require(auth.get("id") == "auth" and "if" not in auth, + require(auth.get("id") == "auth" and "if" not in auth + and "with" not in auth and "env" not in auth and "run" not in auth, "official authentication step must be executable and have id auth") require(any(helper_run(step, "verify") for step in publish["steps"][:auth_index]), "upload must reverify bytes before requesting OIDC") @@ -181,10 +192,36 @@ def verify(source, repository, repository_id): critical_step(upload, "registry upload step") require(upload_index > auth_index and upload.get("id") in (None, "upload") and "if" not in upload and upload["env"]["CARGO_REGISTRY_TOKEN"] == "${{ steps.auth.outputs.token }}" + and upload["env"].get("GH_INTENT_TOKEN") == "${{ github.token }}" and helper_run(upload, "upload"), "upload must execute after auth with only the returned temporary token") + for name in (verify_name, publish_name): + require(jobs[name].get("env", {}).get("GH_READ_TOKEN") == "${{ github.token }}" + and "GH_INTENT_TOKEN" not in jobs[name].get("env", {}) + and "GH_INTENT_TOKEN" not in workflow.get("env", {}), + "read authority must be explicit; upload-intent token must be step scoped") + for step in jobs[name]["steps"]: + if step is upload: + require(set(step.get("env", {})) == {"CARGO_REGISTRY_TOKEN", "GH_INTENT_TOKEN"}, + "upload step must receive only the temporary registry and intent tokens") + continue + require("GH_INTENT_TOKEN" not in step.get("env", {}), + "only upload may receive the intent token") + if "uses" in step: + require(str(step["uses"]).startswith("actions/checkout@") or step is auth, + "automatic jobs may run only immutable checkout and official authentication actions") + else: + critical_step(step, f"{name} inert helper") + require("env" not in step, "verification/confirmation must not override helper environment") + confirm = dict(step) + confirm.pop("if", None) + require(helper_run(step, "verify") or (name == publish_name + and helper_run(confirm, "confirm") and step.get("if") in ( + "${{ !cancelled() }}", + "${{ !cancelled() && (steps.upload.outcome == 'success' || steps.upload.outcome == 'failure') }}")), + "automatic jobs may execute only the inert verify/upload/confirm helpers") for name, job in jobs.items(): - if name == publish_name: + if name in (verify_name, publish_name): continue if repository == "atrinik/protocol" and name == "publish": # Protocol 0.2 retains its separately reviewed manual upload path. @@ -206,19 +243,101 @@ def verify(source, repository, repository_id): permissions = job.get("permissions", workflow["permissions"]) require(isinstance(permissions, dict) and permissions.get("id-token", "none") == "none", f"unexpected OIDC permission on {name}") + require(permissions.get("attestations", "none") == "none", + f"unexpected attestation permission on {name}") return auth["uses"] +def verify_source_release(source, repository): + """Qualify the signing boundary; runtime certificate/claim checks belong to the producer.""" + workflow = yaml.load(source, Loader=UniqueLoader) + require(isinstance(workflow, dict), "source release must be a YAML mapping") + run_configuration(workflow, "source workflow") + require(workflow.get("permissions") == {"contents": "read"}, + "source workflow default must be contents read only") + require(not workflow.get("env", {}), "source workflow must not supply signing environment overrides") + triggers = workflow.get("on") + require(isinstance(triggers, dict) and set(triggers) == {"push", "workflow_dispatch"} + and triggers["push"].get("branches") == ["main", "[0-9]+.[0-9]+.x"], + "source release must use the reviewed main/maintenance push and dispatch triggers") + jobs = workflow.get("jobs") + require(isinstance(jobs, dict) and jobs, "missing source release jobs") + signers = [] + for name, job in jobs.items(): + require(isinstance(job, dict), "invalid source release job") + permissions = job.get("permissions", workflow["permissions"]) + require(isinstance(permissions, dict), "invalid source permissions") + if permissions.get("id-token", "none") != "none" or permissions.get("attestations", "none") != "none": + require(permissions == {"contents": "read", "id-token": "write", "attestations": "write"}, + "only the isolated signer may request OIDC and attestation writes") + signers.append(name) + require(len(signers) == 1, "source release needs exactly one isolated signer") + signer_name = signers[0] + signer = jobs[signer_name] + run_configuration(signer, "source signer") + require(signer.get("continue-on-error", "false") == "false", "signer must fail on errors") + require(not signer.get("env", {}), "signer must not override inert execution environment") + require(signer.get("runs-on") == "ubuntu-24.04" and "container" not in signer and "services" not in signer, + "signer must use the reviewed hosted runner without execution overrides") + package_name = signer.get("needs") + expected_package = "source-package" if repository == "atrinik/renderer" else "package" + require(package_name == expected_package and package_name in jobs, + "signer must depend on one credential-free package job") + package = jobs[package_name] + require(package.get("permissions", workflow["permissions"]) == {"contents": "read"}, + "source package preparation must be credential-free") + require(package.get("continue-on-error", "false") == "false", "package preparation must fail on errors") + guard = (f"needs.{package_name}.outputs.release == 'true'" if repository == "atrinik/renderer" + else f"needs.{package_name}.outputs.version != ''") + require(expression(signer.get("if")) == guard, "signer must use the package version readiness guard") + release = jobs.get("release", {}) + require(set(release.get("needs", [])) == {package_name, signer_name} + and expression(release.get("if")) == guard + and release.get("continue-on-error", "false") == "false", + "source publication must depend on successful package preparation and signing") + steps = signer.get("steps") + require(isinstance(steps, list) and len(steps) == 4, + "signer must contain only checkout, payload download, inert finalization and official attestation") + checkout, download, finalize, attest = steps + for step in steps: + require(isinstance(step, dict) and "if" not in step, "signing steps must be unconditional") + critical_step(step, "source signing step") + revision = "${{ needs." + package_name + ".outputs.revision }}" + version = "${{ needs." + package_name + ".outputs.version }}" + artifact = ("prepared-renderer-release-${{ github.sha }}" if repository == "atrinik/renderer" + else "source-release-${{ github.sha }}") + require(re.fullmatch(r"actions/checkout@[0-9a-f]{40}", str(checkout.get("uses"))) is not None + and checkout.get("with") == {"ref": revision, "persist-credentials": "false"}, + "signer checkout must use the exact prepared revision without persisted credentials") + require(re.fullmatch(r"actions/download-artifact@[0-9a-f]{40}", str(download.get("uses"))) is not None + and download.get("with") == {"name": artifact, "path": "build/release/"}, + "signer must download only its same-run prepared payload") + require(finalize.get("env") == {"ATRINIK_RELEASE_BRANCH": "${{ github.ref_name }}", + "EXPECTED_SOURCE_REVISION": revision, "EXPECTED_SOURCE_VERSION": version} + and shlex.split(finalize.get("run", "")) == ["python3", "tools/crate_release.py", "finalize", + "--source-tag", "v${EXPECTED_SOURCE_VERSION}", "--archives", "build/release"], + "signer may run only the inert payload finalizer with exact prepared inputs") + require(re.fullmatch(r"actions/attest-build-provenance@[0-9a-f]{40}", str(attest.get("uses"))) is not None + and attest.get("with") == {"subject-path": "build/release/crate-release.json", "push-to-registry": "false"}, + "signer must attest the exact manifest through the immutable official action") + for step in (checkout, download, attest): + require("run" not in step and "env" not in step, + "source signing action must not execute extra commands or receive credential overrides") + return attest["uses"] + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repository", required=True) parser.add_argument("--repository-id", required=True) + parser.add_argument("--source-workflow-file", required=True) args = parser.parse_args() try: action = verify(sys.stdin.read(), args.repository, args.repository_id) - except (ValueError, KeyError, TypeError, yaml.YAMLError) as error: + signer_action = verify_source_release(Path(args.source_workflow_file).read_text(), args.repository) + except (ValueError, KeyError, TypeError, OSError, yaml.YAMLError) as error: sys.exit(f"error: crate publication executable workflow drift: {error}") - print(f"Executable automatic publication contract verified; immutable auth action {action}") + print(f"Executable automatic publication and inert source signing contracts verified; immutable actions {action}, {signer_action}") if __name__ == "__main__": diff --git a/tests/fixtures/crate-publication.yml b/tests/fixtures/crate-publication.yml new file mode 100644 index 0000000..ef3352d --- /dev/null +++ b/tests/fixtures/crate-publication.yml @@ -0,0 +1,57 @@ +on: + workflow_dispatch: + inputs: + operation: + type: choice + options: [automatic] +permissions: + contents: read +jobs: + automatic-verify: + if: github.repository == 'atrinik/protocol' && github.repository_id == '1327106950' && github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' && inputs.operation == 'automatic' + runs-on: ubuntu-24.04 + permissions: + contents: read + actions: read + checks: read + attestations: read + env: + GH_READ_TOKEN: ${{ github.token }} + SOURCE_RELEASE_ID: ${{ inputs.source_release_id }} + SOURCE_REVISION: ${{ inputs.source_revision }} + MANIFEST_SHA256: ${{ inputs.manifest_sha256 }} + steps: + - uses: actions/checkout@1111111111111111111111111111111111111111 + with: + ref: ${{ github.sha }} + persist-credentials: false + - run: python3 tools/crate_release.py verify --release-id "$SOURCE_RELEASE_ID" --revision "$SOURCE_REVISION" --manifest-sha256 "$MANIFEST_SHA256" + automatic-publish: + needs: automatic-verify + if: github.repository == 'atrinik/protocol' && github.repository_id == '1327106950' && github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' && inputs.operation == 'automatic' + environment: crates-io-release + runs-on: ubuntu-24.04 + permissions: + contents: write + actions: read + checks: read + attestations: read + id-token: write + env: + GH_READ_TOKEN: ${{ github.token }} + SOURCE_RELEASE_ID: ${{ inputs.source_release_id }} + SOURCE_REVISION: ${{ inputs.source_revision }} + MANIFEST_SHA256: ${{ inputs.manifest_sha256 }} + steps: + - uses: actions/checkout@1111111111111111111111111111111111111111 + with: + ref: ${{ github.sha }} + persist-credentials: false + - run: python3 tools/crate_release.py verify --release-id "$SOURCE_RELEASE_ID" --revision "$SOURCE_REVISION" --manifest-sha256 "$MANIFEST_SHA256" + - id: auth + uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 + - id: upload + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + GH_INTENT_TOKEN: ${{ github.token }} + run: python3 tools/crate_release.py upload --release-id "$SOURCE_RELEASE_ID" --revision "$SOURCE_REVISION" --manifest-sha256 "$MANIFEST_SHA256" diff --git a/tests/fixtures/crate-source-release.yml b/tests/fixtures/crate-source-release.yml new file mode 100644 index 0000000..36e6553 --- /dev/null +++ b/tests/fixtures/crate-source-release.yml @@ -0,0 +1,66 @@ +name: Semantic Release +'on': + workflow_dispatch: null + push: + branches: + - main + - '[0-9]+.[0-9]+.x' +permissions: + contents: read +concurrency: + group: protocol-semantic-release + cancel-in-progress: 'false' +jobs: + package: + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.plan.outputs.version }} + revision: ${{ steps.plan.outputs.revision }} + steps: + - run: cargo build --locked + attest: + name: Attest verified source release manifest + needs: package + if: needs.package.outputs.version != '' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ needs.package.outputs.revision }} + persist-credentials: 'false' + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: source-release-${{ github.sha }} + path: build/release/ + - name: Verify complete inert payload before attestation + env: + ATRINIK_RELEASE_BRANCH: ${{ github.ref_name }} + EXPECTED_SOURCE_REVISION: ${{ needs.package.outputs.revision }} + EXPECTED_SOURCE_VERSION: ${{ needs.package.outputs.version }} + run: python3 tools/crate_release.py finalize --source-tag "v${EXPECTED_SOURCE_VERSION}" + --archives build/release + - name: Bind manifest bytes to this exact source workflow invocation + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 + with: + subject-path: build/release/crate-release.json + push-to-registry: 'false' + release: + name: Release protocol contracts + needs: + - package + - attest + if: needs.package.outputs.version != '' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + actions: write + contents: write + issues: write + pull-requests: write + steps: + - run: npm run semantic-release diff --git a/tests/validate-crate-trusted-publishing.sh b/tests/validate-crate-trusted-publishing.sh index c951655..f74e901 100755 --- a/tests/validate-crate-trusted-publishing.sh +++ b/tests/validate-crate-trusted-publishing.sh @@ -31,6 +31,9 @@ for filter in \ '.crates_io_trusted_publishing[0].repository = "atrinik/classic"' \ '.crates_io_trusted_publishing[0].repository_id = 1' \ '.crates_io_trusted_publishing[0].workflow_filename = "../publish-crate.yml"' \ + '.crates_io_trusted_publishing[0].source_workflow_filename = "../release.yml"' \ + '.crates_io_trusted_publishing[0].source_evidence_policy = "release-asset-digest-only"' \ + '.crates_io_trusted_publishing[0].upload_intent_policy = "retry-on-registry-404"' \ '.crates_io_trusted_publishing[0].workflow_ref = "refs/tags/v1.0.0"' \ '.crates_io_trusted_publishing[0].workflow_trigger = "push"' \ '.crates_io_trusted_publishing[0].environment_state = "active"' \ @@ -81,6 +84,14 @@ if "${temporary}/bin/validate" >/dev/null 2>&1; then exit 1 fi reset_policy +# GitHub-owned official signing actions remain permitted without central SHA locks. +jq '.github_owned_allowed = false' "${root}/config/actions-selected.json" \ + >"${temporary}/config/actions-selected.json" +if "${temporary}/bin/validate" >/dev/null 2>&1; then + echo "error: accepted source provenance signing without GitHub-owned Actions access" >&2 + exit 1 +fi +reset_policy # Dependabot can change the immutable workflow pin without a central version lock. jq '.crates_io_trusted_publishing[0].authentication_action = "rust-lang/crates-io-auth-action@2222222222222222222222222222222222222222"' \ "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" diff --git a/tests/verify-crate-workflow.sh b/tests/verify-crate-workflow.sh new file mode 100755 index 0000000..f185dfa --- /dev/null +++ b/tests/verify-crate-workflow.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -euo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +python3 "${root}/tests/verify_crate_workflow.py" diff --git a/tests/verify-manual-settings.sh b/tests/verify-manual-settings.sh index 6f76acf..5ccd400 100755 --- a/tests/verify-manual-settings.sh +++ b/tests/verify-manual-settings.sh @@ -102,55 +102,7 @@ repos/atrinik/*/git/ref/heads/main) jq -n --arg sha "${sha}" '{ref:"refs/heads/main",object:{type:"commit",sha:$sha}}' ;; repos/atrinik/protocol/contents/.github/workflows/publish-crate.yml\?ref=*) - source=$(cat <<'WORKFLOW' -on: - workflow_dispatch: - inputs: - operation: - type: choice - options: [automatic] -permissions: - contents: read -jobs: - automatic-verify: - if: github.repository == 'atrinik/protocol' && github.repository_id == '1327106950' && github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' && inputs.operation == 'automatic' - runs-on: ubuntu-24.04 - env: - SOURCE_RELEASE_ID: ${{ inputs.source_release_id }} - SOURCE_REVISION: ${{ inputs.source_revision }} - MANIFEST_SHA256: ${{ inputs.manifest_sha256 }} - steps: - - uses: actions/checkout@1111111111111111111111111111111111111111 - with: - ref: ${{ github.sha }} - persist-credentials: false - - run: python3 tools/crate_release.py verify --release-id "$SOURCE_RELEASE_ID" --revision "$SOURCE_REVISION" --manifest-sha256 "$MANIFEST_SHA256" - automatic-publish: - needs: automatic-verify - if: github.repository == 'atrinik/protocol' && github.repository_id == '1327106950' && github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' && inputs.operation == 'automatic' - environment: crates-io-release - runs-on: ubuntu-24.04 - permissions: - contents: read - id-token: write - env: - SOURCE_RELEASE_ID: ${{ inputs.source_release_id }} - SOURCE_REVISION: ${{ inputs.source_revision }} - MANIFEST_SHA256: ${{ inputs.manifest_sha256 }} - steps: - - uses: actions/checkout@1111111111111111111111111111111111111111 - with: - ref: ${{ github.sha }} - persist-credentials: false - - run: python3 tools/crate_release.py verify --release-id "$SOURCE_RELEASE_ID" --revision "$SOURCE_REVISION" --manifest-sha256 "$MANIFEST_SHA256" - - id: auth - uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 - - id: upload - env: - CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} - run: python3 tools/crate_release.py upload --release-id "$SOURCE_RELEASE_ID" --revision "$SOURCE_REVISION" --manifest-sha256 "$MANIFEST_SHA256" -WORKFLOW -) + source=$(cat "${FAKE_CRATE_FIXTURES}/crate-publication.yml") case ${FAKE_GH_SCENARIO} in crate-workflow-drift) source=${source//rust-lang\/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18/rust-lang\/crates-io-auth-action@v1} ;; crate-workflow-future-pin) source=${source//c6f97d42243bad5fab37ca0427f495c86d5b1a18/2222222222222222222222222222222222222222} ;; @@ -168,6 +120,17 @@ WORKFLOW jq -n --arg content "$(printf '%s' "${source}" | base64)" \ '{type:"file",encoding:"base64",content:$content}' ;; +repos/atrinik/protocol/contents/.github/workflows/release.yml\?ref=*) + source=$(cat "${FAKE_CRATE_FIXTURES}/crate-source-release.yml") + case ${FAKE_GH_SCENARIO} in + crate-source-workflow-comments) source="# id-token: write actions/attest-build-provenance@1111111111111111111111111111111111111111" ;; + crate-source-workflow-oidc-build) source=$(sed '/ package:/a\ permissions: {contents: read, id-token: write}' <<<"${source}") ;; + crate-source-workflow-build-signer) source=${source//python3 tools\/crate_release.py finalize/npm install} ;; + crate-source-workflow-bypass-signer) source=$(sed '/ - attest/d' <<<"${source}") ;; + esac + jq -n --arg content "$(printf '%s' "${source}" | base64)" \ + '{type:"file",encoding:"base64",content:$content}' + ;; repos/atrinik/protocol/environments/crates-io-release) rules='[{"type":"branch_policy"}]' bypass=false @@ -782,6 +745,7 @@ run_verify() { PATH="${temporary}/bin:${PATH}" \ FAKE_GH_LOG="${temporary}/gh.log" \ FAKE_GH_SCENARIO="${scenario}" \ + FAKE_CRATE_FIXTURES="${root}/tests/fixtures" \ GITHUB_ACTIONS=true GH_TOKEN=test-token \ ATRINIK_VALIDATION_TODAY=2026-10-11 \ "${verification_root}/bin/verify-manual-settings" @@ -811,14 +775,16 @@ jq '.crates_io_trusted_publishing[0] |= ( >"${observed_root}/config/manual-settings.json" observed_output=$(run_verify present "${observed_root}") future_output=$(run_verify crate-workflow-future-pin "${observed_root}") -grep -Fq "immutable auth action rust-lang/crates-io-auth-action@2222222222222222222222222222222222222222" <<<"${future_output}" +grep -Fq "immutable actions rust-lang/crates-io-auth-action@2222222222222222222222222222222222222222" <<<"${future_output}" grep -Fq 'KEEP atrinik/protocol environment crates-io-release metadata' <<<"${observed_output}" grep -Fq 'GitHub metadata does not verify external registration' <<<"${observed_output}" for scenario in crate-workflow-drift crate-env-wait crate-env-reviewer crate-env-bypass \ crate-env-branch crate-env-secret crate-workflow-comments crate-workflow-plaintext \ crate-workflow-disabled crate-workflow-no-oidc crate-workflow-shell \ crate-workflow-ignored-step-failure crate-workflow-ignored-job-failure \ - crate-workflow-default-shell crate-workflow-job-default-shell; do + crate-workflow-default-shell crate-workflow-job-default-shell \ + crate-source-workflow-comments crate-source-workflow-oidc-build \ + crate-source-workflow-build-signer crate-source-workflow-bypass-signer; do if run_verify "${scenario}" "${observed_root}" >"${temporary}/${scenario}.out" \ 2>"${temporary}/${scenario}.err"; then echo "error: verifier accepted ${scenario}" >&2 diff --git a/tests/verify_crate_workflow.py b/tests/verify_crate_workflow.py new file mode 100644 index 0000000..25336ed --- /dev/null +++ b/tests/verify_crate_workflow.py @@ -0,0 +1,96 @@ +"""Exercise the credential and executable provenance qualification boundaries.""" + +import copy +import importlib.util +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.dont_write_bytecode = True +spec = importlib.util.spec_from_file_location("crate_workflow", ROOT / "lib/verify_crate_workflow.py") +workflow = importlib.util.module_from_spec(spec) +spec.loader.exec_module(workflow) + + +class WorkflowBoundaryTests(unittest.TestCase): + def setUp(self): + self.publish = workflow.yaml.load((ROOT / "tests/fixtures/crate-publication.yml").read_text(), + Loader=workflow.UniqueLoader) + self.source = workflow.yaml.load((ROOT / "tests/fixtures/crate-source-release.yml").read_text(), + Loader=workflow.UniqueLoader) + + def publication(self, candidate): + return workflow.verify(workflow.yaml.dump(candidate), "atrinik/protocol", "1327106950") + + def signing(self, candidate): + return workflow.verify_source_release(workflow.yaml.dump(candidate), "atrinik/protocol") + + def reject(self, original, checker, mutate): + candidate = copy.deepcopy(original) + mutate(candidate) + with self.assertRaises((ValueError, KeyError, TypeError)): + checker(candidate) + + def test_reviewed_boundary(self): + self.publication(self.publish) + self.signing(self.source) + + def test_safe_full_checkout_and_confirmation_variants(self): + job = self.publish["jobs"]["automatic-publish"] + job["steps"][0]["with"]["fetch-depth"] = 0 + confirm = {"if": "${{ !cancelled() }}", "run": job["steps"][-1]["run"].replace(" upload ", " confirm ")} + job["steps"].append(confirm) + self.publication(self.publish) + + def test_publisher_least_privilege_and_inert_execution(self): + mutations = [ + lambda w: w["jobs"]["automatic-verify"]["permissions"].update({"contents": "write"}), + lambda w: w["jobs"]["automatic-verify"]["permissions"].pop("attestations"), + lambda w: w["jobs"]["automatic-publish"]["permissions"].update({"checks": "write"}), + lambda w: w["jobs"]["automatic-publish"]["permissions"].update({"contents": "read"}), + lambda w: w["jobs"]["automatic-publish"]["env"].update({"GH_INTENT_TOKEN": "${{ github.token }}"}), + lambda w: w["jobs"]["automatic-publish"]["env"].update({"BASH_ENV": "untrusted.sh"}), + lambda w: w["jobs"]["automatic-publish"].update({"container": "untrusted"}), + lambda w: w["jobs"]["automatic-publish"]["steps"][-1]["env"].pop("GH_INTENT_TOKEN"), + lambda w: w["jobs"]["automatic-publish"]["steps"].append({"run": "cargo build"}), + lambda w: w["jobs"]["automatic-publish"]["steps"].append({"uses": "owner/build@" + "1" * 40}), + lambda w: w["jobs"]["automatic-publish"]["steps"][1].update({"env": {"PYTHONPATH": "untrusted"}}), + lambda w: w["jobs"]["automatic-publish"]["steps"][0]["with"].update({"path": "alternate"}), + ] + for index, mutation in enumerate(mutations): + with self.subTest(index=index): + self.reject(self.publish, self.publication, mutation) + + def test_signer_isolation_and_dependency(self): + mutations = [ + lambda w: w["jobs"]["package"].update({"permissions": {"contents": "read", "id-token": "write"}}), + lambda w: w["jobs"]["release"]["permissions"].update({"id-token": "write"}), + lambda w: w["jobs"]["release"].update({"needs": ["package"]}), + lambda w: w["jobs"].pop("attest"), + lambda w: w["jobs"]["attest"].update({"if": "false"}), + lambda w: w["jobs"]["attest"].update({"continue-on-error": "true"}), + lambda w: w["jobs"]["attest"].update({"env": {"PYTHONPATH": "untrusted"}}), + lambda w: w["jobs"]["attest"].update({"container": "untrusted"}), + lambda w: w["jobs"]["attest"]["steps"].append({"run": "npm install"}), + lambda w: w["jobs"]["attest"]["steps"][2].update({"run": "cargo build"}), + lambda w: w["jobs"]["attest"]["steps"][2].update({"shell": "echo {0}"}), + lambda w: w["jobs"]["attest"]["steps"][2].update({"continue-on-error": "true"}), + lambda w: w["jobs"]["attest"]["steps"][2].update({"if": "false"}), + lambda w: w["jobs"]["attest"]["steps"][0]["with"].update({"ref": "main"}), + lambda w: w["jobs"]["attest"]["steps"][1]["with"].update({"run-id": "123"}), + lambda w: w["jobs"]["attest"]["steps"][3].update({"uses": "actions/attest-build-provenance@v4"}), + lambda w: w["jobs"]["attest"]["steps"][3]["with"].update({"subject-path": "build/release/*.crate"}), + lambda w: w["jobs"]["attest"]["steps"][3]["with"].update({"push-to-registry": "true"}), + ] + for index, mutation in enumerate(mutations): + with self.subTest(index=index): + self.reject(self.source, self.signing, mutation) + + def test_immutable_official_signer_can_receive_dependabot_updates(self): + self.source["jobs"]["attest"]["steps"][-1]["uses"] = "actions/attest-build-provenance@" + "2" * 40 + self.signing(self.source) + + +if __name__ == "__main__": + unittest.main() From ef102b7a9428ab066d8a169e6503e505f3ebb947 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 13:40:42 -0500 Subject: [PATCH 07/16] feat: record observed private updater image read grants --- AGENTS.md | 5 ++++ CONTRIBUTING.md | 6 ++++- README.md | 36 +++++++++++++++++++++++++ bin/publish | 6 +++++ bin/validate | 24 ++++++++++++++--- bin/verify-manual-settings | 35 ++++++++++++++++++++++++ config/manual-settings.json | 33 ++++++++++++++++++++++- tests/validate-manual-credentials.sh | 2 ++ tests/validate-manual-package-access.sh | 26 +++++++++++++++++- tests/verify-manual-settings.sh | 19 ++++++++++++- 10 files changed, 185 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c5df8ab..1883131 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,11 @@ merge reviewed desired-state and consumer-permission changes first, preserve the source association, verify anonymous digest access, and remove obsolete repository grants without altering foreign entries. + The private linux-build package keeps its exact package/source identities; + record client/editor-only read grants separately from effective workflow pulls. + Metadata verifies identity/source/visibility, not grants or effective pulls. + Preserve all foreign grants and prove each owning authenticated workflow pulls + its pinned manifest before claiming effective access; never widen visibility. - Record manually provisioned GitHub Actions environments in `config/manual-settings.json` with stable repository IDs, exact deployment branch policies and reviewer sets, and variable and secret names only. Never diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0990a0a..ebb08ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -154,6 +154,10 @@ and ordinary review/checks without auto-merge. Distinguish observed installation and credential-name provisioning from pending reviewed workflow activation and real successful-owned-PR proof; owner-authorized early setup does not prove effective secret values. Keep App-authenticated selected-set readback or owner -UI evidence, and do not infer the webhook switch from an empty event set. Changes require negative schema +UI evidence, and do not infer the webhook switch from an empty event set. +Private linux-build consumption needs separate client/editor-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 +workflow pull. Preserve foreign grants and visibility. Changes require negative schema and live-metadata fixture tests plus a complete publisher plan; publishers do not provision these integrations. diff --git a/README.md b/README.md index 09ea129..c785117 100644 --- a/README.md +++ b/README.md @@ -1123,6 +1123,42 @@ that exact entry if it remains after the visibility transition. Finally, run a real fork pull request and require its digest pulls and complete required checks to succeed without package permissions or registry authentication. +## Private Linux updater build image + +`ghcr.io/atrinik/linux-build` is a private container package, ID `14205615`, +linked to `atrinik/devcontainer` (repository ID `1322185908`). Preserve that +visibility and source association. The owner verified the pinned manifest with +authenticated registry access; the client owning workflow authenticated but +could not resolve that manifest. An owner's successful pull cannot prove a +repository workflow's package access. + +`github_packages_actions_access` records read-only Actions-access setup for +`atrinik/client` (`1327106844`) and `atrinik/editor` (`1327106902`). The owner +confirmed those two **Manage Actions access** read grants on 2026-10-11; their +`grant_state` is `owner-observed-read-grant`, with dated sanitized owner evidence. +Effective owning workflow pulls remain pending. This is owner UI observation, +not API enumeration of the grant list. Retain permission inheritance, private +visibility, linked source and every foreign entry. Do not broaden to +organization/all repositories or change a foreign grant. The publisher never +writes these manual grants. + +The read-only verifier checks consumer/source identities, exact package ID, +private visibility and linked source. GitHub package metadata does not expose +proof of the manual grant or effective registry access. Future unprovisioned +intent uses `pending-owner-ui-read-grant` with null `observed_on` and +`evidence_location`; observation requires sanitized owner UI readback and dated +evidence. Separately prove each exact merged owning workflow pulls the pinned +manifest and completes its ordinary required validation. Registry login success +and secret-name presence alone are insufficient. Host registry credentials must +be removed before credential-free Cargo/build preparation; the updater App +remains isolated to its PR-writing job. No package grant supplies a +registry-publication token or proves the dependency App key effective. + +Rollback stops the owning updater workflows first and removes only these two +read grants through supported owner UI after reviewed inventory removal. +Preserve the package, published versions, source, visibility, foreign grants, +and unrelated App credentials. + ## Automatic crate Trusted Publishing `config/manual-settings.json` records the desired, value-free contracts below. diff --git a/bin/publish b/bin/publish index c11aa4d..9a41b3c 100755 --- a/bin/publish +++ b/bin/publish @@ -1385,6 +1385,12 @@ while IFS= read -r crate_record; do state=$(jq -r '"source=\(.source_state), environment=\(.environment_state), registration=\(.publisher_registration.state)"' <<<"${crate_record}") echo "MANUAL ${repository} crates.io ${crate}: ${state}; reviewed source/version merge, automatic crates-io-release environment (zero reviewers/wait), and Trusted Publisher owner setup; publisher does not apply" done < <(jq -c '.crates_io_trusted_publishing[]' "${root}/config/manual-settings.json") +while IFS= read -r package_record; do + repository=$(jq -r '.actions_repository' <<<"${package_record}") + package_name=$(jq -r '.package_name' <<<"${package_record}") + grant_state=$(jq -r '.grant_state // "manual-owner-ui"' <<<"${package_record}") + echo "MANUAL ${repository} read-only package Actions access to ${package_name}: ${grant_state}; publisher never provisions grants" +done < <(jq -c '.github_packages_actions_access[]' "${root}/config/manual-settings.json") app_state=$(jq -r '.crate_dependency_update_app | "identity=\(.status), credential_names=\(.credential_state), workflow=\(.workflow_activation.state)"' "${root}/config/manual-settings.json") echo "MANUAL atrinik-crate-dependency-update ${app_state} selected client/editor App and credential slots; publisher does not apply" echo "Manual confirmation required for config/manual-settings.json." diff --git a/bin/validate b/bin/validate index 8ebbca0..f95bc0f 100755 --- a/bin/validate +++ b/bin/validate @@ -729,7 +729,7 @@ while IFS= read -r consumer; do done < <(jq -r '.github_actions_credentials[].consumers[]' \ "${manual_settings_config}") -jq -e ' +jq -e --arg today "${validation_today}" ' .github_packages_actions_access as $access | ($access | type == "array") and ( @@ -752,7 +752,9 @@ jq -e ' "package_type", "preserve_visibility", "role" - ]) and + ] or keys == ["actions_repository", "actions_repository_id", "evidence_location", + "grant_state", "observed_on", "package_id", "package_name", "package_owner", "package_type", + "preserve_visibility", "role", "source_repository", "source_repository_id"]) and (.package_owner | type == "string" and length > 0) and (.package_type | type == "string" and length > 0) and (.package_name | type == "string" and length > 0) and @@ -767,7 +769,23 @@ jq -e ' .actions_repository_id | type == "number" and . > 0 and floor == . ) and - (.role == "read") + (.role == "read") and + (if has("grant_state") then + .package_owner == "atrinik" and .package_type == "container" and + .package_name == "linux-build" and .package_id == 14205615 and + .preserve_visibility == "private" and + .source_repository == "atrinik/devcontainer" and .source_repository_id == 1322185908 and + ((.actions_repository == "atrinik/client" and .actions_repository_id == 1327106844) or + (.actions_repository == "atrinik/editor" and .actions_repository_id == 1327106902)) and + (if .grant_state == "pending-owner-ui-read-grant" then + .observed_on == null and .evidence_location == null + elif .grant_state == "owner-observed-read-grant" then + (.observed_on | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) and + (try ((.observed_on + "T00:00:00Z" | fromdateiso8601 | strftime("%Y-%m-%d")) == .observed_on + and .observed_on <= $today) catch false) and + (.evidence_location | type == "string" and test("^[A-Za-z0-9_./#-]+$")) + else false end) + else .package_name != "linux-build" end) ) ' "${manual_settings_config}" >/dev/null diff --git a/bin/verify-manual-settings b/bin/verify-manual-settings index d1db78b..1b66bf8 100755 --- a/bin/verify-manual-settings +++ b/bin/verify-manual-settings @@ -406,6 +406,41 @@ while IFS= read -r pages_record; do fi done < <(jq -c '.github_pages_sites[]' "${manual_settings}") +# Package metadata proves identity/source/visibility, not a manual Actions-access grant. +while IFS= read -r package_record; do + repository=$(jq -r '.actions_repository' <<<"${package_record}") + repository_id=$(jq -r '.actions_repository_id' <<<"${package_record}") + verify_repository_identity "${repository}" "${repository_id}" + package_owner=$(jq -r '.package_owner' <<<"${package_record}") + package_type=$(jq -r '.package_type' <<<"${package_record}") + package_name=$(jq -r '.package_name' <<<"${package_record}") + package=$(github_api "read ${package_owner}/${package_name} package identity" \ + "orgs/${package_owner}/packages/${package_type}/${package_name}") + if ! jq -en --argjson actual "${package}" --argjson expected "${package_record}" ' + $actual.id == $expected.package_id and $actual.name == $expected.package_name and + $actual.package_type == $expected.package_type and $actual.visibility == $expected.preserve_visibility and + (if $expected.source_repository != null then + $actual.repository.full_name == $expected.source_repository and + $actual.repository.id == $expected.source_repository_id + else true end) + ' >/dev/null; then + echo "error: package Actions-access identity/source/visibility drift for ${package_owner}/${package_name}" >&2 + exit 1 + fi + if jq -e 'has("grant_state")' <<<"${package_record}" >/dev/null; then + source_repository=$(jq -r '.source_repository' <<<"${package_record}") + source_repository_id=$(jq -r '.source_repository_id' <<<"${package_record}") + verify_repository_identity "${source_repository}" "${source_repository_id}" + if [[ $(jq -r '.grant_state' <<<"${package_record}") == pending-owner-ui-read-grant ]]; then + echo "PENDING ${repository} read-only Actions access to private ${package_owner}/${package_name}; exact package/source verified, owner UI grant and effective workflow pull remain unobserved" + else + echo "OWNER-OBSERVED ${repository} read-only Actions access to private ${package_owner}/${package_name}; GitHub metadata does not prove the UI grant or an effective workflow pull" + fi + else + echo "MANUAL ${repository} ${package_owner}/${package_name} read-only Actions access requires owner UI and owning workflow proof" + fi +done < <(jq -c '.github_packages_actions_access[]' "${manual_settings}") + # Pending setup is not live activation. Every publisher still requires the # exact live repository, main ref, and protected-main identity. while IFS= read -r crate_record; do diff --git a/config/manual-settings.json b/config/manual-settings.json index ecdaa17..f6f158c 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -429,7 +429,38 @@ "secret_scope": "repository" } ], - "github_packages_actions_access": [], + "github_packages_actions_access": [ + { + "actions_repository": "atrinik/client", + "actions_repository_id": 1327106844, + "evidence_location": "README.md#private-linux-updater-build-image", + "grant_state": "owner-observed-read-grant", + "observed_on": "2026-10-11", + "package_id": 14205615, + "package_name": "linux-build", + "package_owner": "atrinik", + "package_type": "container", + "preserve_visibility": "private", + "role": "read", + "source_repository": "atrinik/devcontainer", + "source_repository_id": 1322185908 + }, + { + "actions_repository": "atrinik/editor", + "actions_repository_id": 1327106902, + "evidence_location": "README.md#private-linux-updater-build-image", + "grant_state": "owner-observed-read-grant", + "observed_on": "2026-10-11", + "package_id": 14205615, + "package_name": "linux-build", + "package_owner": "atrinik", + "package_type": "container", + "preserve_visibility": "private", + "role": "read", + "source_repository": "atrinik/devcontainer", + "source_repository_id": 1322185908 + } + ], "require_two_factor_authentication": true, "require_secure_two_factor_methods": true, "reason": "The public GitHub API does not expose supported update parameters for every setting in this file, including organization pins and the exact selected-repository set for an organization GitHub App installation to an administrative token, and external credential values must never be stored here. Confirm these settings, the exact ordered pin set, credential lifecycle metadata and live secret-name presence, GitHub App repository selection, Codecov GitHub App repository access, any listed GitHub Pages sites and GitHub Actions environments, and GitHub Packages Actions access in the corresponding organization, repository, and package settings UI.", diff --git a/tests/validate-manual-credentials.sh b/tests/validate-manual-credentials.sh index 2e67838..feffe2c 100755 --- a/tests/validate-manual-credentials.sh +++ b/tests/validate-manual-credentials.sh @@ -25,6 +25,8 @@ assert_invalid() { reset_manual_settings() { jq '.crates_io_trusted_publishing[].publisher_registration = {state:"pending-owner-registration",observed_on:null,evidence_location:null} | + .github_packages_actions_access[] |= (.grant_state="pending-owner-ui-read-grant" | + .observed_on=null | .evidence_location=null) | .crate_dependency_update_app |= (.status="planned" | .app_id=null | .installation_id=null | .repository_scope_verification="pending-owner-ui" | .observed_on=null | .evidence_location=null | .credential_state="pending-name-metadata")' \ diff --git a/tests/validate-manual-package-access.sh b/tests/validate-manual-package-access.sh index 11549e4..d17b203 100755 --- a/tests/validate-manual-package-access.sh +++ b/tests/validate-manual-package-access.sh @@ -32,7 +32,12 @@ rewrite_manual_settings() { } jq -e ' - .github_packages_actions_access == [] + [.github_packages_actions_access[] | select(.package_name != "linux-build")] == [] and + [.github_packages_actions_access[].actions_repository] == ["atrinik/client", "atrinik/editor"] and + all(.github_packages_actions_access[]; .package_id == 14205615 and .preserve_visibility == "private" and + .source_repository == "atrinik/devcontainer" and .source_repository_id == 1322185908 and + .role == "read" and .grant_state == "owner-observed-read-grant" and + .observed_on == "2026-10-11" and .evidence_location == "README.md#private-linux-updater-build-image") ' "${root}/config/manual-settings.json" >/dev/null reset_valid_access() { @@ -79,4 +84,23 @@ rewrite_manual_settings \ '.github_packages_actions_access[0].actions_repository = "classic"' assert_invalid 'a package grant without a full repository name' +for filter in \ + '.github_packages_actions_access[0].source_repository_id = 1' \ + '.github_packages_actions_access[0].preserve_visibility = "public"' \ + '.github_packages_actions_access[0].actions_repository_id = 1' \ + '.github_packages_actions_access[0].grant_state = "active"' \ + '.github_packages_actions_access[0].grant_state = "pending-owner-ui-read-grant"' \ + '.github_packages_actions_access[0].evidence_location = null'; do + cp "${root}/config/manual-settings.json" "${temporary}/config/manual-settings.json" + rewrite_manual_settings "${filter}" + assert_invalid "${filter}" +done +cp "${root}/config/manual-settings.json" "${temporary}/config/manual-settings.json" +rewrite_manual_settings '.github_packages_actions_access[] |= (.grant_state="owner-observed-read-grant" | + .observed_on="2026-10-11" | .evidence_location="README.md#private-linux-updater-build-image")' +"${temporary}/bin/validate" >/dev/null +rewrite_manual_settings '.github_packages_actions_access[] |= (.grant_state="pending-owner-ui-read-grant" | + .observed_on=null | .evidence_location=null)' +"${temporary}/bin/validate" >/dev/null + echo "Manual package Actions-access validation tests passed." diff --git a/tests/verify-manual-settings.sh b/tests/verify-manual-settings.sh index 5ccd400..845125e 100755 --- a/tests/verify-manual-settings.sh +++ b/tests/verify-manual-settings.sh @@ -90,6 +90,20 @@ repos/atrinik/renderer | repos/atrinik/content-toolkit | repos/atrinik/client | jq -n --arg name "${name}" --argjson id "${id}" \ '{id:$id, full_name:("atrinik/"+$name), archived:false, default_branch:"main"}' ;; +repos/atrinik/devcontainer) + jq -n '{id:1322185908,full_name:"atrinik/devcontainer",archived:false,default_branch:"main"}' + ;; +orgs/atrinik/packages/container/linux-build) + id=14205615 + visibility=private + source_id=1322185908 + [[ ${FAKE_GH_SCENARIO} != crate-image-identity-drift ]] || id=1 + [[ ${FAKE_GH_SCENARIO} != crate-image-visibility-drift ]] || visibility=public + [[ ${FAKE_GH_SCENARIO} != crate-image-source-drift ]] || source_id=1 + jq -n --argjson id "${id}" --arg visibility "${visibility}" --argjson source_id "${source_id}" \ + '{id:$id,name:"linux-build",package_type:"container",visibility:$visibility, + repository:{id:$source_id,full_name:"atrinik/devcontainer"}}' + ;; repos/atrinik/*/branches/main) protected=true [[ ${FAKE_GH_SCENARIO} != crate-unprotected ]] || protected=false @@ -753,12 +767,15 @@ run_verify() { : >"${temporary}/gh.log" output=$(run_verify present) +grep -Fq 'OWNER-OBSERVED atrinik/client read-only Actions access to private atrinik/linux-build' <<<"${output}" +grep -Fq 'OWNER-OBSERVED atrinik/editor read-only Actions access to private atrinik/linux-build' <<<"${output}" grep -Fq 'PENDING atrinik/protocol crates.io atrinik-protocol via publish-crate.yml / crates-io-release' <<<"${output}" if grep -Eq 'protocol/environments|crates.io|/trusted-publishers' "${temporary}/gh.log"; then echo "error: planned crate contract inspected or mutated unprovisioned state" >&2 exit 1 fi -for scenario in crate-repository-drift crate-unprotected crate-ref-drift; do +for scenario in crate-repository-drift crate-unprotected crate-ref-drift \ + crate-image-identity-drift crate-image-visibility-drift crate-image-source-drift; do if run_verify "${scenario}" >"${temporary}/${scenario}.out" 2>"${temporary}/${scenario}.err"; then echo "error: verifier accepted ${scenario}" >&2 exit 1 From 9e80b98cdaf258828268ac1ab5f6066afb1751a5 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 13:46:53 -0500 Subject: [PATCH 08/16] fix: preserve unrelated package access schemas --- README.md | 21 ++++++++++++++++----- bin/validate | 2 +- tests/validate-manual-package-access.sh | 9 +++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c785117..ec1c9ac 100644 --- a/README.md +++ b/README.md @@ -1136,8 +1136,14 @@ repository workflow's package access. `atrinik/client` (`1327106844`) and `atrinik/editor` (`1327106902`). The owner confirmed those two **Manage Actions access** read grants on 2026-10-11; their `grant_state` is `owner-observed-read-grant`, with dated sanitized owner evidence. -Effective owning workflow pulls remain pending. This is owner UI observation, -not API enumeration of the grant list. Retain permission inheritance, private +Client's exact-main post-grant run +[38164640476](https://github.com/atrinik/client/actions/runs/38164640476) +subsequently proved a repository-token pull of the pinned image and complete +read-only preparation/application validation. It correctly produced no migration +PR before registry readiness; App-writing jobs were skipped, so no effective +App writer was proved. Editor owning workflow pull/activation proof remains +pending. The grant observation comes from owner UI, not API enumeration of the +grant list. Retain permission inheritance, private visibility, linked source and every foreign entry. Do not broaden to organization/all repositories or change a foreign grant. The publisher never writes these manual grants. @@ -1162,12 +1168,17 @@ and unrelated App credentials. ## Automatic crate Trusted Publishing `config/manual-settings.json` records the desired, value-free contracts below. -Automatic source/environment setup remains **pending** until observed. +Formal source/environment onboarding states remain **pending** in this inventory. +The three reviewed owning workflows have merged and their source-release workflows +are temporarily paused for the narrow immutable-policy activation window. The +owner configured all three main-only, zero-review environments; final observed +inventory transitions remain coordinator-owned acceptance after live qualification. 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). That registration does not imply the future automatic source workflow or -zero-review environment is ready: the current protocol environment still uses -the manually approved reviewer policy until the reviewed transition is applied. This change does not register a crates.io publisher, provision an +zero-review environment or future release is ready on its own. The owner has +now applied the reviewed zero-review environment configuration, while source +release restoration and immutable-policy activation remain pending. This change does not register a crates.io publisher, provision an Actions environment, upload a crate, or install a GitHub App. | Repository | Stable repository ID | Published libraries | diff --git a/bin/validate b/bin/validate index f95bc0f..31d6470 100755 --- a/bin/validate +++ b/bin/validate @@ -785,7 +785,7 @@ jq -e --arg today "${validation_today}" ' and .observed_on <= $today) catch false) and (.evidence_location | type == "string" and test("^[A-Za-z0-9_./#-]+$")) else false end) - else .package_name != "linux-build" end) + else (.package_owner != "atrinik" or .package_type != "container" or .package_name != "linux-build") end) ) ' "${manual_settings_config}" >/dev/null diff --git a/tests/validate-manual-package-access.sh b/tests/validate-manual-package-access.sh index d17b203..56c7940 100755 --- a/tests/validate-manual-package-access.sh +++ b/tests/validate-manual-package-access.sh @@ -84,6 +84,15 @@ rewrite_manual_settings \ '.github_packages_actions_access[0].actions_repository = "classic"' assert_invalid 'a package grant without a full repository name' +# Preserve the legacy schema for unrelated owners/types sharing this package name. +for filter in \ + '.github_packages_actions_access[0] |= (.package_owner="another-owner" | .package_name="linux-build")' \ + '.github_packages_actions_access[0] |= (.package_type="npm" | .package_name="linux-build")'; do + reset_valid_access + rewrite_manual_settings "${filter}" + "${temporary}/bin/validate" >/dev/null +done + for filter in \ '.github_packages_actions_access[0].source_repository_id = 1' \ '.github_packages_actions_access[0].preserve_visibility = "public"' \ From 547629cc78f3ae9b7259788b3e8b3ec664136f55 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 13:55:01 -0500 Subject: [PATCH 09/16] test(governance): cover crate workflow token confinement --- tests/verify_crate_workflow.py | 92 ++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/verify_crate_workflow.py b/tests/verify_crate_workflow.py index 25336ed..27632d4 100644 --- a/tests/verify_crate_workflow.py +++ b/tests/verify_crate_workflow.py @@ -62,6 +62,98 @@ def test_publisher_least_privilege_and_inert_execution(self): with self.subTest(index=index): self.reject(self.publish, self.publication, mutation) + def test_automatic_jobs_reject_matrix_and_reusable_job_fields(self): + for job_name in ("automatic-verify", "automatic-publish"): + for field, value in ( + ("strategy", {"matrix": {"target": ["unreviewed"]}}), + ("uses", "./.github/workflows/unreviewed.yml"), + ("with", {"target": "unreviewed"}), + ("secrets", "inherit"), + ): + with self.subTest(job=job_name, field=field): + self.reject( + self.publish, + self.publication, + lambda w, job_name=job_name, field=field, value=value: + w["jobs"][job_name].update({field: value}), + ) + + def test_source_jobs_reject_matrix_and_reusable_job_fields(self): + for job_name in ("package", "attest", "release"): + for field, value in ( + ("strategy", {"matrix": {"target": ["unreviewed"]}}), + ("uses", "./.github/workflows/unreviewed.yml"), + ("with", {"target": "unreviewed"}), + ("secrets", "inherit"), + ): + with self.subTest(job=job_name, field=field): + self.reject( + self.source, + self.signing, + lambda w, job_name=job_name, field=field, value=value: + w["jobs"][job_name].update({field: value}), + ) + + def test_registry_token_expressions_are_confined_to_the_upload_binding(self): + token_references = ( + "${{ steps.auth.outputs.token }}", + "${{ Steps.AUTH.outputs.token }}", + "${{ toJSON(steps) }}", + "${{ steps[inputs.x].outputs.token }}", + "${{ steps.auth.outputs.token }} ", + ) + + def inject(job, location, token): + if location == "extra-env": + job["env"]["UNREVIEWED_TOKEN"] = token + elif location == "outputs": + job["outputs"] = {"token": token} + elif location == "concurrency": + job["concurrency"] = {"group": token, "cancel-in-progress": False} + elif location == "checkout-with": + job["steps"][0]["with"]["fetch-depth"] = token + elif location == "step-name": + job["steps"][1]["name"] = token + elif location == "step-run": + job["steps"][1]["run"] += " # " + token + elif location == "step-if": + job["steps"][1]["if"] = token + elif location == "step-env": + job["steps"][1]["env"] = {"UNREVIEWED_TOKEN": token} + else: + self.fail(f"unknown token injection location: {location}") + + locations = ( + "extra-env", "outputs", "concurrency", "checkout-with", + "step-name", "step-run", "step-if", "step-env", + ) + for job_name in ("automatic-verify", "automatic-publish"): + for location in locations: + for token in token_references: + with self.subTest(job=job_name, location=location, token=token): + self.reject( + self.publish, + self.publication, + lambda w, job_name=job_name, location=location, token=token: + inject(w["jobs"][job_name], location, token), + ) + + def test_only_the_exact_upload_token_expression_is_allowed(self): + variants = ( + "${{ Steps.AUTH.outputs.token }}", + "${{ toJSON(steps) }}", + "${{ steps[inputs.x].outputs.token }}", + "${{ steps.auth.outputs.token }} ", + ) + for token in variants: + with self.subTest(token=token): + self.reject( + self.publish, + self.publication, + lambda w, token=token: w["jobs"]["automatic-publish"]["steps"][3]["env"].update( + {"CARGO_REGISTRY_TOKEN": token}), + ) + def test_signer_isolation_and_dependency(self): mutations = [ lambda w: w["jobs"]["package"].update({"permissions": {"contents": "read", "id-token": "write"}}), From 779c27455d608d82df1f284c2ec51925fa50bd3e Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:11:37 -0500 Subject: [PATCH 10/16] test(governance): cover crate immutable preflight --- tests/publish-maintenance-branch.sh | 216 +++++++++++++++++++++++++++- 1 file changed, 213 insertions(+), 3 deletions(-) diff --git a/tests/publish-maintenance-branch.sh b/tests/publish-maintenance-branch.sh index f6018a5..c2ccada 100755 --- a/tests/publish-maintenance-branch.sh +++ b/tests/publish-maintenance-branch.sh @@ -74,6 +74,62 @@ jq -cn \ security_scenario=${GH_SECURITY_SCENARIO:-converged} +crate_repository_id() { + case $1 in + protocol) printf '%s\n' 1327106950 ;; + renderer) printf '%s\n' 1327106998 ;; + content-toolkit) printf '%s\n' 1327107043 ;; + *) return 1 ;; + esac +} + +crate_workflow_content() { + local repository=$1 + local workflow=$2 + local fixture=${GH_CRATE_FIXTURE_ROOT:?}/tests/fixtures + local identifier + + identifier=$(crate_repository_id "${repository}") + if [[ ${GH_CRATE_SETUP_SCENARIO:-qualified} == malformed-workflow ]] && + [[ ${workflow} == publish-crate.yml ]]; then + printf 'not: [a reviewed workflow\n' + return + fi + if [[ ${workflow} == publish-crate.yml ]]; then + sed -e "s|atrinik/protocol|atrinik/${repository}|g" \ + -e "s/1327106950/${identifier}/g" "${fixture}/crate-publication.yml" + elif [[ ${repository} == renderer ]]; then + sed -e 's/package/source-package/g' \ + -e "s/needs.source-package.outputs.version != ''/needs.source-package.outputs.release == 'true'/g" \ + -e 's/source-release-${{ github.sha }}/prepared-renderer-release-${{ github.sha }}/g' \ + "${fixture}/crate-source-release.yml" + else + cat "${fixture}/crate-source-release.yml" + fi +} + +crate_environment() { + local bypass=false + if [[ ${GH_CRATE_SETUP_SCENARIO:-qualified} == environment-drift ]]; then + bypass=true + fi + jq -cn --argjson bypass "${bypass}" '{ + name: "crates-io-release", + deployment_branch_policy: {protected_branches: false, custom_branch_policies: true}, + protection_rules: [{type: "branch_policy"}], + can_admins_bypass: $bypass + }' +} + +immutable_release_snapshot() { + if [[ ${GH_CRATE_SNAPSHOT_DRIFT:-false} == true ]] && + (( $(jq -s '[.[] | select(.method == "GET" and .endpoint == "orgs/atrinik/settings/immutable-releases")] | length' "${GH_API_LOG}") >= 2 )); then + printf '{"enforced_repositories":"none"}\n' + else + jq '{enforced_repositories}' "${GH_IMMUTABLE_STATE}" + fi +} + if [[ ${method} == PUT ]] && [[ ${endpoint} == orgs/atrinik/settings/immutable-releases ]]; then jq -c . "${input}" >"${GH_IMMUTABLE_STATE}" @@ -271,7 +327,7 @@ case "${endpoint}|${jq_filter}" in printf 'classic-ghsa-8533-3vg8-r287\t999\ttrue\tprivate\tfalse\tfalse\tfalse\tfalse\tfalse\n' ;; "orgs/atrinik/settings/immutable-releases|"*) - jq '{enforced_repositories}' "${GH_IMMUTABLE_STATE}" + immutable_release_snapshot ;; "orgs/atrinik/settings/immutable-releases/repositories?per_page=100|"*) jq ' @@ -316,6 +372,41 @@ case "${endpoint}|${jq_filter}" in printf '{"enabled":false,"enforced_by_owner":false}\n' fi ;; +repos/atrinik/protocol\| | repos/atrinik/renderer\| | repos/atrinik/content-toolkit\|) + repository=${endpoint#repos/atrinik/} + jq -cn --arg repository "${repository}" --argjson id "$(crate_repository_id "${repository}")" \ + '{id: $id, full_name: "atrinik/" + $repository, archived: false, default_branch: "main"}' + ;; +repos/atrinik/protocol/branches/main\| | repos/atrinik/renderer/branches/main\| | repos/atrinik/content-toolkit/branches/main\|) + printf '{"name":"main","protected":true,"commit":{"sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}\n' + ;; +repos/atrinik/protocol/git/ref/heads/main\| | repos/atrinik/renderer/git/ref/heads/main\| | repos/atrinik/content-toolkit/git/ref/heads/main\|) + printf '{"ref":"refs/heads/main","object":{"type":"commit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}\n' + ;; +repos/atrinik/*/contents/.github/workflows/publish-crate.yml*\|) + repository=${endpoint#repos/atrinik/} + repository=${repository%%/*} + crate_workflow_content "${repository}" publish-crate.yml | base64 | tr -d '\n' | \ + jq -R '{type: "file", encoding: "base64", content: .}' + ;; +repos/atrinik/*/contents/.github/workflows/release.yml*\|) + repository=${endpoint#repos/atrinik/} + repository=${repository%%/*} + crate_workflow_content "${repository}" release.yml | base64 | tr -d '\n' | \ + jq -R '{type: "file", encoding: "base64", content: .}' + ;; +repos/atrinik/*/environments/crates-io-release\|) + crate_environment + ;; +repos/atrinik/*/environments/crates-io-release/deployment-branch-policies\?per_page=100\&page=1\|) + printf '{"total_count":1,"branch_policies":[{"name":"main","type":"branch"}]}\n' + ;; +repos/atrinik/*/environments/crates-io-release/secrets\?per_page=100\&page=1\|) + printf '{"total_count":0,"secrets":[]}\n' + ;; +repos/atrinik/*/environments/crates-io-release/variables\?per_page=100\&page=1\|) + printf '{"total_count":0,"variables":[]}\n' + ;; "orgs/atrinik/rulesets|"*) if [[ ${GH_ADVISORY_RULESETS_PRESENT:-false} == true ]]; then printf '%s\n' '[{"id":900,"name":"05 - Maintenance branch - content - retired"},{"id":901,"name":"01 - Default branch linear history - classic"},{"id":902,"name":"02 - Changes through pull requests - classic"},{"id":903,"name":"01 - Default branch integrity - classic"}]' @@ -1557,19 +1648,138 @@ grep -F \ "${identity_drift_output}" >/dev/null jq -s -e 'all(.[]; .method == "GET")' "${identity_drift_log}" >/dev/null -# Qualify the complete crate-family owner selection and preserve old members. +# A newly selected producer must qualify its exact workflow and environment +# before the publisher's first mutation. This fixture deliberately provides no +# source release, registry, or attestation endpoint: setup qualification is +# about reviewed protected-main configuration only. crate_state=${temporary}/crate-immutable-state.json crate_log=${temporary}/crate-immutable-log.jsonl printf '%s\n' '{"enforced_repositories":"selected","selected_repository_ids":[1327289971]}' >"${crate_state}" GH_API_LOG=${crate_log} GH_IMMUTABLE_STATE=${crate_state} \ GH_CRATE_IMMUTABLE_INVENTORY=true GH_SECURITY_SCENARIO=converged \ - PATH="${temporary}/bin:${PATH}" ATRINIK_POLICY_SCOPE=organization \ + GH_CRATE_FIXTURE_ROOT=${source_root} PATH="${temporary}/bin:${PATH}" ATRINIK_POLICY_SCOPE=organization \ "${source_root}/bin/publish" --apply >"${temporary}/crate-immutable-apply.out" jq -e '. == {enforced_repositories:"selected",selected_repository_ids:[1327106950,1327106998,1327107043,1327289971]}' "${crate_state}" >/dev/null +jq -s -e ' + . as $calls | + ([ $calls | to_entries[] | + select(.value.method != "GET") | .key ] | first) as $first_mutation | + all( + ["protocol", "renderer", "content-toolkit"][] as $repository | + ([ $calls | to_entries[] | + select(.value.endpoint == ("repos/atrinik/" + $repository + "/git/ref/heads/main")) | + .key ] | last) < $first_mutation + ) and + all(.[]; (.endpoint | test("/(releases|attestations|actions/runs)(/|$)") | not)) +' "${crate_log}" >/dev/null for repository in classic protocol renderer content-toolkit; do jq -se --arg endpoint "repos/atrinik/${repository}/immutable-releases" \ 'any(.[]; .method == "GET" and .endpoint == $endpoint)' "${crate_log}" >/dev/null done + +crate_setup_root() { + local destination=$1 + + mkdir -p "${destination}/bin" "${destination}/lib" "${destination}/tests" + cp "${source_root}/bin/publish" "${source_root}/bin/validate" \ + "${source_root}/bin/verify-manual-settings" "${destination}/bin/" + cp "${source_root}/lib/verify_crate_workflow.py" "${destination}/lib/" + cp -R "${source_root}/config" "${destination}/" + cp -R "${source_root}/tests/fixtures" "${destination}/tests/" +} + +assert_setup_failed_before_mutation() { + local log=$1 + + jq -s -e 'all(.[]; .method == "GET")' "${log}" >/dev/null +} + +# A pending or absent manual record blocks a newly selected producer before +# immutable policy or any unrelated governance setting can mutate. +pending_setup_root=${temporary}/crate-pending-setup +crate_setup_root "${pending_setup_root}" +jq '(.crates_io_trusted_publishing[] | select(.repository == "atrinik/protocol") | + .source_state) = "pending-reviewed-workflow"' \ + "${pending_setup_root}/config/manual-settings.json" >"${temporary}/pending-manual.json" +mv "${temporary}/pending-manual.json" "${pending_setup_root}/config/manual-settings.json" +pending_setup_log=${temporary}/crate-pending-setup.jsonl +printf '%s\n' '{"enforced_repositories":"selected","selected_repository_ids":[1327289971]}' >"${crate_state}" +if GH_API_LOG=${pending_setup_log} GH_IMMUTABLE_STATE=${crate_state} \ + GH_CRATE_IMMUTABLE_INVENTORY=true GH_CRATE_FIXTURE_ROOT=${source_root} \ + PATH="${temporary}/bin:${PATH}" ATRINIK_POLICY_SCOPE=organization \ + "${pending_setup_root}/bin/publish" --apply >"${temporary}/crate-pending-setup.out" 2>&1; then + echo "error: pending crate setup did not block immutable selection" >&2 + exit 1 +fi +grep -F 'initial immutable selection requires observed qualified source/environment for atrinik/protocol' \ + "${temporary}/crate-pending-setup.out" >/dev/null +assert_setup_failed_before_mutation "${pending_setup_log}" + +missing_setup_root=${temporary}/crate-missing-setup +crate_setup_root "${missing_setup_root}" +jq 'del(.crates_io_trusted_publishing[] | select(.repository == "atrinik/protocol"))' \ + "${missing_setup_root}/config/manual-settings.json" >"${temporary}/missing-manual.json" +mv "${temporary}/missing-manual.json" "${missing_setup_root}/config/manual-settings.json" +missing_setup_log=${temporary}/crate-missing-setup.jsonl +printf '%s\n' '{"enforced_repositories":"selected","selected_repository_ids":[1327289971]}' >"${crate_state}" +if GH_API_LOG=${missing_setup_log} GH_IMMUTABLE_STATE=${crate_state} \ + GH_CRATE_IMMUTABLE_INVENTORY=true GH_CRATE_FIXTURE_ROOT=${source_root} \ + PATH="${temporary}/bin:${PATH}" ATRINIK_POLICY_SCOPE=organization \ + "${missing_setup_root}/bin/publish" --apply >"${temporary}/crate-missing-setup.out" 2>&1; then + echo "error: missing crate setup record did not block immutable selection" >&2 + exit 1 +fi +assert_setup_failed_before_mutation "${missing_setup_log}" + +# The scoped verifier must reject malformed protected-main workflow bytes and +# environment drift before any mutation. +for scenario in malformed-workflow environment-drift; do + scenario_log=${temporary}/crate-${scenario}.jsonl + printf '%s\n' '{"enforced_repositories":"selected","selected_repository_ids":[1327289971]}' >"${crate_state}" + if GH_API_LOG=${scenario_log} GH_IMMUTABLE_STATE=${crate_state} \ + GH_CRATE_IMMUTABLE_INVENTORY=true GH_CRATE_SETUP_SCENARIO=${scenario} \ + GH_CRATE_FIXTURE_ROOT=${source_root} PATH="${temporary}/bin:${PATH}" \ + ATRINIK_POLICY_SCOPE=organization "${source_root}/bin/publish" --apply \ + >"${temporary}/crate-${scenario}.out" 2>&1; then + echo "error: ${scenario} did not block immutable selection" >&2 + exit 1 + fi + assert_setup_failed_before_mutation "${scenario_log}" +done + +# The immutable snapshot is a fence: a changed live policy after successful +# qualification prevents the first convergence mutation. +snapshot_drift_log=${temporary}/crate-snapshot-drift.jsonl +printf '%s\n' '{"enforced_repositories":"selected","selected_repository_ids":[1327289971]}' >"${crate_state}" +if GH_API_LOG=${snapshot_drift_log} GH_IMMUTABLE_STATE=${crate_state} \ + GH_CRATE_IMMUTABLE_INVENTORY=true GH_CRATE_SNAPSHOT_DRIFT=true \ + GH_CRATE_FIXTURE_ROOT=${source_root} PATH="${temporary}/bin:${PATH}" \ + ATRINIK_POLICY_SCOPE=organization "${source_root}/bin/publish" --apply \ + >"${temporary}/crate-snapshot-drift.out" 2>&1; then + echo "error: immutable snapshot drift did not block convergence" >&2 + exit 1 +fi +grep -F 'immutable-release policy changed during initial qualification' \ + "${temporary}/crate-snapshot-drift.out" >/dev/null +assert_setup_failed_before_mutation "${snapshot_drift_log}" + +# Existing selections do not requalify their pending records, and an all-mode +# snapshot does not cause avoidable source/environment verification. +for mode in selected all; do + mode_log=${temporary}/crate-${mode}-already-selected.jsonl + if [[ ${mode} == selected ]]; then + printf '%s\n' '{"enforced_repositories":"selected","selected_repository_ids":[1327106950,1327106998,1327107043,1327289971]}' >"${crate_state}" + else + printf '%s\n' '{"enforced_repositories":"all"}' >"${crate_state}" + fi + GH_API_LOG=${mode_log} GH_IMMUTABLE_STATE=${crate_state} \ + GH_CRATE_IMMUTABLE_INVENTORY=true GH_CRATE_FIXTURE_ROOT=${source_root} \ + PATH="${temporary}/bin:${PATH}" ATRINIK_POLICY_SCOPE=organization \ + "${pending_setup_root}/bin/publish" --apply >"${temporary}/crate-${mode}-already-selected.out" + jq -s -e 'all(.[]; (.endpoint | contains("/contents/.github/workflows/") | not) and + (.endpoint | contains("/environments/crates-io-release") | not))' "${mode_log}" >/dev/null +done + crate_failure_log=${temporary}/crate-immutable-failure.jsonl printf '%s\n' '{"enforced_repositories":"selected","selected_repository_ids":[101,1327289971]}' >"${crate_state}" if GH_API_LOG=${crate_failure_log} GH_IMMUTABLE_STATE=${crate_state} \ From 310608917226f989d728d308e7135d79b308ba3d Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:12:46 -0500 Subject: [PATCH 11/16] fix(governance): fence automatic crate setup and credential execution --- AGENTS.md | 18 +++++-- CONTRIBUTING.md | 17 +++++-- README.md | 57 +++++++++++++++++++--- bin/publish | 26 ++++++++++ bin/validate | 19 ++++++-- bin/verify-manual-settings | 41 ++++++++++++++-- config/manual-settings.json | 21 +++++--- lib/verify_crate_workflow.py | 31 ++++++++++++ tests/validate-crate-trusted-publishing.sh | 9 +++- tests/validate-manual-credentials.sh | 1 + tests/verify-manual-settings.sh | 31 +++++++----- tests/verify_crate_workflow.py | 6 +++ 12 files changed, 236 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1883131..e7feb82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,7 +132,10 @@ observations; verify live repository/ref/protection and observed environments. Parse both observed exact-main `publish-crate.yml` and `release.yml` structurally with the declared `requirements-validation.txt` dependency; comments/plain text or disabled - jobs are not executable source proof. External registration remains sanitized + jobs are not executable source proof. Reject matrix/reusable execution in the + two automatic and source package/sign/release jobs, and recursively confine + all step-context expressions to the exact returned upload-token input and + reviewed confirmation condition. External registration remains sanitized owner evidence, not a GitHub API fact, and can precede a pending later automatic source/environment transition. Source signing alone may request attestation writes/OIDC in a separate inert @@ -145,7 +148,12 @@ absent-version retries and must never be automatically deleted or reset. First owner-local API-token publication is limited to the fourteen declared renderer/toolkit libraries; exclude CLIs, revoke bootstrap credentials, and - use OIDC only afterward. Protocol 0.2 retains its approved manual path. + use OIDC only afterward. Protocol 0.2 retains its approved manual path. Before + any broad publisher apply mutation, qualify newly added live immutable + producer selections through fresh scoped source/environment verification; + pending/missing inventory or drift fails closed. Initial draft-first code + qualification is separate from later immutable-release/runtime proof, and + does not require a pre-existing immutable release. - Keep the crate dependency App separate from unrelated Apps. Select only client/editor, exactly contents/PR write and metadata read, no events, bypass or auto-merge; inventory names only for repository secret @@ -156,7 +164,11 @@ not imply effective credentials or a deployed workflow. Retain exact owner UI or supported App-authenticated repository-selection readback; empty events do not independently prove a disabled webhook. Preparation must verify - registry readiness before the isolated job writes an owned PR. + registry readiness before the isolated job writes an owned PR. Keep exact + 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. - 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ebb08ab..35f85ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -144,16 +144,27 @@ signing from build/semantic-release OIDC, and keep automatic verify permissions read-only. The inert publisher alone adds contents write for the global crate/version upload intent and receives its intent token only in the upload step. Runtime certificate/source-run verification and create-only reservation -readback precede upload; an existing/ambiguous intent blocks absent-version +readback precede upload. Reject matrix/reusable execution in the automatic +verify/publish and source package/sign/release jobs; recursively reject step +references outside the exact upload-token input and confirmation condition. An +existing/ambiguous intent blocks absent-version retries and is never automatically reset. Activate immutable-release policy only through the documented narrow snapshot/readback/verified-rollback window; -do not broad-apply unrelated publisher-plan mutations. Dependency-update +do not broad-apply unrelated publisher-plan mutations. Broad apply must qualify +newly added live producer selections before its first mutation, with observed +inventory and fresh scoped source/environment checks. Initial independently +qualified draft-first code is sufficient for setup; actual immutable public +release and registry proof remain later runtime obligations. Dependency-update App setup is client/editor only, exact contents/PR write and metadata read, repository credential names only, registry readiness before owned PR creation, and ordinary review/checks without auto-merge. Distinguish observed installation and credential-name provisioning from pending reviewed workflow activation and real successful-owned-PR proof; owner-authorized early setup does not prove -effective secret values. Keep App-authenticated selected-set readback or owner +effective secret values. Inventory exact workflow consumer paths, observed key +creation/verification dates, rotation owner, and a deadline within 90 days of key +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. Private linux-build consumption needs separate client/editor-only read grants through owner UI. Keep exact private package/source identities and pending grant diff --git a/README.md b/README.md index ec1c9ac..6d6312c 100644 --- a/README.md +++ b/README.md @@ -811,6 +811,31 @@ administrative immutable-settings endpoint, so source workflows do not add an unsupported pre-tag query or administrative credentials. This inventory is activated only after reviewed draft-first source qualification. +Initial qualification establishes producer code and setup; it does not require +an already published immutable release. The independently accepted producer +candidates and merged main revisions had identical complete Git trees: + +| Repository | Reviewed candidate | Merged main | +| --- | --- | --- | +| protocol | `5f843a69ec52233a5ff66bebe7d04a025ff7295c` | `f9e72b9ebc4f8516f3a1e1f6383ab4484c3c1444` | +| renderer | `9a7e1afcc378cd14d28e56468f6a7cdd16de630c` | `ef70471c87579f3cef0027bf8e126fbaae515f6f` | +| content-toolkit | `c04755ba3936c30171a85ec7ce003413eb75d174` | `0caed7763c1c8d2d83bb3f2636a33d94f3962a71` | + +Local draft-first complete-asset validation, independent source review, and +exact-candidate CI establish this initial code qualification. These historical +revisions do not lock future source or action updates. Workflow parsing alone +does not prove draft assembly; real signing, immutable release assets, and +registry uploads remain runtime proof obligations. + +Before any broad `bin/publish --apply` mutation, its preflight computes producer +IDs newly entering the live immutable selection. Each new producer requires an +observed source/environment inventory record and a fresh scoped +`bin/verify-manual-settings --crate-publication-setup atrinik/REPOSITORY` check. +Pending or missing records, workflow/environment drift, or concurrent immutable +policy changes stop apply before mutation. Existing selected producers and an +already enforced `all` policy need no initial-addition qualification. This guard +does not authorize broad apply for the narrow rollout below. + For protocol, separately verify that the actual immutable pinned semantic-release GitHub plugin supports the configured draft-first publication and the complete asset payload. A configured flag alone is not release proof. @@ -1168,11 +1193,12 @@ and unrelated App credentials. ## Automatic crate Trusted Publishing `config/manual-settings.json` records the desired, value-free contracts below. -Formal source/environment onboarding states remain **pending** in this inventory. -The three reviewed owning workflows have merged and their source-release workflows -are temporarily paused for the narrow immutable-policy activation window. The -owner configured all three main-only, zero-review environments; final observed -inventory transitions remain coordinator-owned acceptance after live qualification. +All three source contracts are `observed-reviewed-workflow`, and their +environments are `observed`: supported live readback qualified both workflow +files at the same protected-main revision, exact repository identities, and +main-only environments with no reviewers, wait, administrator bypass, secrets, +or variables. Their source-release workflows remain temporarily paused for the +narrow immutable-policy activation window. 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). That registration does not imply the future automatic source workflow or @@ -1270,7 +1296,10 @@ the policy and publication workflows install it before verification. The parser rejects duplicate YAML keys, comment-only/plain text, disabled automatic jobs, missing OIDC, mutable action refs, extra OIDC jobs, widened guards, inexact checkout/input bindings, shell/workspace overrides, ignored critical -failures, and skipped verification/auth/upload steps. +failures, matrix/reusable execution, credential references outside the exact +upload input, and skipped verification/auth/upload steps. Recursive expression +checks include job outputs, names, concurrency, checkout inputs, and nested +step fields, including indexed, case-varied, or aggregate step references. It verifies executable dispatch, repository/ID/protected-main conditions, credential-free dependency, environment, scoped permissions, an unconditional full-SHA official auth action, and only the returned temporary upload token. @@ -1320,7 +1349,9 @@ The automatic contract above replaces the former planned per-release maintainer-review policy. Protocol 0.2 keeps its approved manual release path; 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 transition remains pending. Renderer/toolkit registration remains pending owner actions; the dependency +its automatic source/environment setup is observed, while automatic release +activation remains pending. Renderer/toolkit registration remains pending owner +actions; the dependency App is provisioned with workflow activation still pending. ## Crate dependency update App @@ -1347,6 +1378,18 @@ secret value is not readable through GitHub's metadata API, and name presence cannot prove that a key is valid or effective. These observations do not grant workflow activation, bypass, self-approval, or self-merge authority. +The value-free key lifecycle records creation and verification on 2026-10-11, +consumer paths for both `released-dependencies.yml` workflows, and rotation +owner `Atrinik organization owners`. Its 90-day internal rotation deadline is +2027-01-09; this is not an upstream key expiry. Validation rejects missing, +future, overdue, or over-cadence lifecycle dates. Rotate by issuing a replacement +App key, verifying the App identity with it, securely updating only the two +existing repository secret slots, and proving the owning PR-writing workflow. +Revoke the old key after successful replacement proof; retain name-only evidence +and update creation/verification/deadline metadata through reviewed source. If +replacement proof fails, stop the writing workflows and preserve the old working +key until the owner restores service or explicitly revokes it. + 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 diff --git a/bin/publish b/bin/publish index 9a41b3c..597483f 100755 --- a/bin/publish +++ b/bin/publish @@ -1236,6 +1236,31 @@ converge_immutable_release_policy() { return 1 } +preflight_new_crate_immutable_selection() { + local snapshot additions repository + ${apply} || return 0 + snapshot=$(current_immutable_release_payload) + additions=$(jq -cn --argjson current "${snapshot}" \ + --slurpfile desired "${immutable_releases_config}" ' + if $current.enforced_repositories == "all" then [] + elif $current.enforced_repositories == "none" then [$desired[0].repositories[].id] + else [$desired[0].repositories[].id] - $current.selected_repository_ids end + ') + while IFS= read -r repository; do + # These are the newly introduced library producers; Classic retains its + # existing separately qualified release policy. Missing manual records fail + # the scoped verifier rather than silently skipping qualification. + "${root}/bin/verify-manual-settings" --crate-publication-setup "${organization}/${repository}" + done < <(jq -r --argjson additions "${additions}" ' + .repositories[] | select(.name | IN("protocol", "renderer", "content-toolkit")) | + select(.id as $id | $additions | index($id) != null) | .name + ' "${immutable_releases_config}") + if [[ $(current_immutable_release_payload) != "${snapshot}" ]]; then + echo "error: immutable-release policy changed during initial qualification; no broad apply performed" >&2 + exit 1 + fi +} + organization_metadata=$(github_api "orgs/${organization}") if ! jq -e 'type == "object" and (.plan.name | type == "string")' \ <<<"${organization_metadata}" >/dev/null; then @@ -1328,6 +1353,7 @@ done < <( "${immutable_releases_config}" ) +preflight_new_crate_immutable_selection converge_immutable_release_policy converge_organization_settings "${organization_metadata}" diff --git a/bin/validate b/bin/validate index 31d6470..3647f6c 100755 --- a/bin/validate +++ b/bin/validate @@ -876,20 +876,23 @@ jq -e --arg today "${validation_today}" ' .github_actions_apps as $apps | .external_provider_apps as $external_apps | .crate_dependency_update_app as $app | - ($app | keys == ["app_id", "app_slug", "credential_state", "events", "evidence_location", "homepage_url", "installation_id", - "observed_on", "owner", "permissions", "pull_request_policy", "readiness", "repositories", - "repository_scope_verification", "repository_selection", "runbook", "secret_name", + ($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 $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 $app.permissions == {contents: "write", metadata: "read", pull_requests: "write"} and $app.events == [] and $app.owner == "Atrinik organization owners" and + $app.rotation_owner == "Atrinik organization owners" and $app.rotation_cadence_days == 90 and $app.repositories == [{repository: "atrinik/client", repository_id: 1327106844}, {repository: "atrinik/editor", repository_id: 1327106902}] and $app.secret_name == "CRATE_DEPENDENCY_UPDATE_APP_PRIVATE_KEY" and $app.secret_scope == "repository" and $app.variable_name == "CRATE_DEPENDENCY_UPDATE_APP_ID" and $app.variable_scope == "repository" and $app.workflow_filename == "released-dependencies.yml" and + $app.consumers == ["atrinik/client/.github/workflows/released-dependencies.yml", + "atrinik/editor/.github/workflows/released-dependencies.yml"] and $app.workflow_triggers == ["schedule-hourly", "workflow_dispatch"] and $app.readiness == "exact-released-crates-and-checksums-before-pr" and $app.pull_request_policy == "owned-branch-and-pr-only-no-auto-merge" and @@ -908,12 +911,20 @@ jq -e --arg today "${validation_today}" ' $app.workflow_activation.state == "pending-reviewed-workflows" and $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 + $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 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 ($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 + ($app.rotate_by | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) and + (try (($app.rotate_by + "T00:00:00Z" | fromdateiso8601 | strftime("%Y-%m-%d")) == $app.rotate_by) catch false) and + $app.rotate_by >= $today and $app.rotate_by > $app.key_created_on and + ((($app.rotate_by + "T00:00:00Z" | fromdateiso8601) - + ($app.key_created_on + "T00:00:00Z" | fromdateiso8601)) / 86400 <= $app.rotation_cadence_days) and all(($apps + $external_apps)[]; .app_id != $app.app_id and .installation_id != $app.installation_id) and ($app.evidence_location | type == "string" and test("^[A-Za-z0-9_./#-]+$")) else false end) diff --git a/bin/verify-manual-settings b/bin/verify-manual-settings index 1b66bf8..a3af1cf 100755 --- a/bin/verify-manual-settings +++ b/bin/verify-manual-settings @@ -5,8 +5,11 @@ set -euo pipefail organization=${ATRINIK_ORGANIZATION:-atrinik} api_version=2026-03-10 -if (($#)); then - echo "usage: $0" >&2 +crate_setup_repository= +if (($# == 2)) && [[ $1 == --crate-publication-setup ]]; then + crate_setup_repository=$2 +elif (($#)); then + echo "usage: $0 [--crate-publication-setup OWNER/REPOSITORY]" >&2 exit 2 fi @@ -28,6 +31,13 @@ temporary=$(mktemp -d) trap 'rm -rf "${temporary}"' EXIT "${root}/bin/validate" >/dev/null +if [[ -n ${crate_setup_repository} ]] && ! jq -e --arg repository "${crate_setup_repository}" ' + [.crates_io_trusted_publishing[] | select(.repository == $repository and + .source_state == "observed-reviewed-workflow" and .environment_state == "observed")] | length == 1 +' "${manual_settings}" >/dev/null; then + echo "error: initial immutable selection requires observed qualified source/environment for ${crate_setup_repository}" >&2 + exit 1 +fi github_api() { local operation=$1 @@ -281,6 +291,7 @@ verify_repository_identity() { } missing=0 +if [[ -z ${crate_setup_repository} ]]; then while IFS= read -r credential; do repository=$(jq -r '.repository' <<<"${credential}") repository_owner=${repository%%/*} @@ -440,6 +451,7 @@ while IFS= read -r package_record; do echo "MANUAL ${repository} ${package_owner}/${package_name} read-only Actions access requires owner UI and owning workflow proof" fi done < <(jq -c '.github_packages_actions_access[]' "${manual_settings}") +fi # Pending setup is not live activation. Every publisher still requires the # exact live repository, main ref, and protected-main identity. @@ -487,7 +499,9 @@ while IFS= read -r crate_record; do if [[ $(jq -r '.publisher_registration.state' <<<"${crate_record}") == owner-observed-registration ]]; then echo "MANUAL ${repository} crates.io registration is owner-observed evidence only; GitHub metadata does not verify external registration" fi -done < <(jq -c '.crates_io_trusted_publishing[]' "${manual_settings}") +done < <(jq -c --arg repository "${crate_setup_repository}" ' + .crates_io_trusted_publishing[] | select($repository == "" or .repository == $repository) +' "${manual_settings}") while IFS= read -r environment_record; do repository=$(jq -r '.repository' <<<"${environment_record}") @@ -632,15 +646,32 @@ while IFS= read -r environment_record; do fi echo "KEEP ${repository} environment ${environment} metadata" -done < <(jq -c ' - .github_actions_environments[], +done < <(jq -c --arg repository "${crate_setup_repository}" ' + (.github_actions_environments[] | select($repository == "")), (.crates_io_trusted_publishing[] | select(.environment_state == "observed") | + select($repository == "" or .repository == $repository) | . as $publisher | .environment | {repository: $publisher.repository, repository_id: $publisher.repository_id, environment: .name, deployment_branch_policy, required_reviewers, secret_names, variable_names, can_admins_bypass, wait_timer_minutes}) ' "${manual_settings}") +if [[ -n ${crate_setup_repository} ]]; then + final_branch=$(github_api "recheck ${crate_setup_repository} protected main" \ + "repos/${crate_setup_repository}/branches/main") + final_ref=$(github_api "recheck ${crate_setup_repository} main revision" \ + "repos/${crate_setup_repository}/git/ref/heads/main") + if ! jq -en --arg sha "${branch_sha}" --argjson branch "${final_branch}" --argjson ref "${final_ref}" ' + $branch.name == "main" and $branch.protected == true and $branch.commit.sha == $sha and + $ref.ref == "refs/heads/main" and $ref.object.type == "commit" and $ref.object.sha == $sha + ' >/dev/null; then + echo "error: protected-main setup changed during ${crate_setup_repository} qualification" >&2 + exit 1 + fi + echo "KEEP ${crate_setup_repository} initial immutable selection setup qualified at ${branch_sha}; runtime release/registry operations remain separate" + exit 0 +fi + installations_file=${temporary}/${organization}-installations.jsonl if load_paginated_metadata \ "list ${organization} GitHub App installations" \ diff --git a/config/manual-settings.json b/config/manual-settings.json index f6f158c..ec30b1e 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -71,7 +71,7 @@ "variable_names": [], "wait_timer_minutes": 0 }, - "environment_state": "pending", + "environment_state": "observed", "publisher_registration": { "state": "owner-observed-registration", "observed_on": "2026-10-11", @@ -82,7 +82,7 @@ "repository_id": 1327106950, "runbook": "README.md#automatic-crate-trusted-publishing", "source_evidence_policy": "github-attested-protected-main-manifest", - "source_state": "pending-reviewed-workflow", + "source_state": "observed-reviewed-workflow", "source_workflow_filename": "release.yml", "upload_intent_policy": "create-only-global-git-ref-before-upload", "workflow_filename": "publish-crate.yml", @@ -130,7 +130,7 @@ "variable_names": [], "wait_timer_minutes": 0 }, - "environment_state": "pending", + "environment_state": "observed", "publisher_registration": { "state": "pending-owner-registration", "observed_on": null, @@ -141,7 +141,7 @@ "repository_id": 1327106998, "runbook": "README.md#automatic-crate-trusted-publishing", "source_evidence_policy": "github-attested-protected-main-manifest", - "source_state": "pending-reviewed-workflow", + "source_state": "observed-reviewed-workflow", "source_workflow_filename": "release.yml", "upload_intent_policy": "create-only-global-git-ref-before-upload", "workflow_filename": "publish-crate.yml", @@ -189,7 +189,7 @@ "variable_names": [], "wait_timer_minutes": 0 }, - "environment_state": "pending", + "environment_state": "observed", "publisher_registration": { "state": "pending-owner-registration", "observed_on": null, @@ -200,7 +200,7 @@ "repository_id": 1327107043, "runbook": "README.md#automatic-crate-trusted-publishing", "source_evidence_policy": "github-attested-protected-main-manifest", - "source_state": "pending-reviewed-workflow", + "source_state": "observed-reviewed-workflow", "source_workflow_filename": "release.yml", "upload_intent_policy": "create-only-global-git-ref-before-upload", "workflow_filename": "publish-crate.yml", @@ -467,6 +467,15 @@ "crate_dependency_update_app": { "app_id": 5276152, "app_slug": "atrinik-crate-dependency-update", + "consumers": [ + "atrinik/client/.github/workflows/released-dependencies.yml", + "atrinik/editor/.github/workflows/released-dependencies.yml" + ], + "key_created_on": "2026-10-11", + "last_verified_on": "2026-10-11", + "rotate_by": "2027-01-09", + "rotation_cadence_days": 90, + "rotation_owner": "Atrinik organization owners", "installation_id": 170332148, "status": "owner-observed", "repository_selection": "selected", diff --git a/lib/verify_crate_workflow.py b/lib/verify_crate_workflow.py index ddd3dd2..6801b92 100644 --- a/lib/verify_crate_workflow.py +++ b/lib/verify_crate_workflow.py @@ -63,6 +63,33 @@ def critical_step(step, context): f"{context} must fail on execution errors") +def job_execution_shape(job, context): + require(not any(key in job for key in ("strategy", "uses", "with", "secrets")), + f"{context} must be one ordinary job without matrix or reusable execution") + + +def confined_step_references(job, upload_index=None): + """Whitelist the two reviewed step references, including expression casing/index variants.""" + def visit(value, path=()): + if isinstance(value, dict): + for key, item in value.items(): + visit(key, path + ("mapping-key",)) + visit(item, path + (key,)) + elif isinstance(value, list): + for index, item in enumerate(value): + visit(item, path + (index,)) + elif isinstance(value, str): + if "${{" not in value or not re.search(r"\bsteps\b", value, flags=re.IGNORECASE): + return + token = (upload_index is not None and + path == ("steps", upload_index, "env", "CARGO_REGISTRY_TOKEN") and + value == "${{ steps.auth.outputs.token }}") + confirmation = (len(path) == 3 and path[0] == "steps" and path[2] == "if" and + value == "${{ !cancelled() && (steps.upload.outcome == 'success' || steps.upload.outcome == 'failure') }}") + require(token or confirmation, "step references must not expose the authentication token outside validated upload") + visit(job) + + def helper_run(step, operation): if "if" in step or not isinstance(step.get("run"), str): return False @@ -119,6 +146,8 @@ def verify(source, repository, repository_id): for name in (verify_name, publish_name): job = jobs.get(name) require(isinstance(job, dict), f"missing job {name}") + job_execution_shape(job, name) + require("outputs" not in job, f"{name} must not export credentials through job outputs") run_configuration(job, name) require(job.get("continue-on-error", "false") == "false", f"{name} must fail on execution errors") @@ -196,6 +225,7 @@ def verify(source, repository, repository_id): and helper_run(upload, "upload"), "upload must execute after auth with only the returned temporary token") for name in (verify_name, publish_name): + confined_step_references(jobs[name], upload_index if name == publish_name else None) require(jobs[name].get("env", {}).get("GH_READ_TOKEN") == "${{ github.token }}" and "GH_INTENT_TOKEN" not in jobs[name].get("env", {}) and "GH_INTENT_TOKEN" not in workflow.get("env", {}), @@ -265,6 +295,7 @@ def verify_source_release(source, repository): signers = [] for name, job in jobs.items(): require(isinstance(job, dict), "invalid source release job") + job_execution_shape(job, f"source {name}") permissions = job.get("permissions", workflow["permissions"]) require(isinstance(permissions, dict), "invalid source permissions") if permissions.get("id-token", "none") != "none" or permissions.get("attestations", "none") != "none": diff --git a/tests/validate-crate-trusted-publishing.sh b/tests/validate-crate-trusted-publishing.sh index f74e901..0cd963e 100755 --- a/tests/validate-crate-trusted-publishing.sh +++ b/tests/validate-crate-trusted-publishing.sh @@ -106,7 +106,7 @@ jq '.crates_io_trusted_publishing[0] |= ( "${temporary}/bin/validate" >/dev/null reset_policy for filter in \ - '.crates_io_trusted_publishing[0].environment_state = "observed"' \ + '.crates_io_trusted_publishing[0] |= (.source_state="pending-reviewed-workflow" | .environment_state="observed")' \ '.crates_io_trusted_publishing[0].publisher_registration = {state:"owner-observed-registration", observed_on:"2026-02-31", evidence_location:"atrinik/github-settings#owner-setup"}' \ '.crates_io_trusted_publishing[0] |= (.source_state="observed-reviewed-workflow" | .environment_state="observed" | .publisher_registration={state:"owner-observed-registration",observed_on:"2099-01-01",evidence_location:"atrinik/github-settings#owner-setup"})'; do assert_rejected "${filter}" @@ -119,6 +119,13 @@ jq '.crate_dependency_update_app |= (.status="owner-observed" | .app_id=85500002 reset_policy for filter in \ '.crate_dependency_update_app.permissions.administration = "write"' \ + '.crate_dependency_update_app.consumers += ["atrinik/classic/.github/workflows/update-content.yml"]' \ + '.crate_dependency_update_app.key_created_on = "2099-01-01"' \ + '.crate_dependency_update_app.last_verified_on = null' \ + '.crate_dependency_update_app.rotate_by = "2026-10-10"' \ + '.crate_dependency_update_app.rotate_by = "2027-01-10"' \ + '.crate_dependency_update_app.rotation_cadence_days = 365' \ + '.crate_dependency_update_app.rotation_owner = "unknown"' \ '.crate_dependency_update_app.events = ["push"]' \ '.crate_dependency_update_app.webhook_active = true' \ '.crate_dependency_update_app.homepage_url = "https://example.test"' \ diff --git a/tests/validate-manual-credentials.sh b/tests/validate-manual-credentials.sh index feffe2c..39b05bd 100755 --- a/tests/validate-manual-credentials.sh +++ b/tests/validate-manual-credentials.sh @@ -28,6 +28,7 @@ reset_manual_settings() { .github_packages_actions_access[] |= (.grant_state="pending-owner-ui-read-grant" | .observed_on=null | .evidence_location=null) | .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")' \ "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" diff --git a/tests/verify-manual-settings.sh b/tests/verify-manual-settings.sh index 845125e..a5410d7 100755 --- a/tests/verify-manual-settings.sh +++ b/tests/verify-manual-settings.sh @@ -6,6 +6,12 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) temporary=$(mktemp -d) trap 'rm -rf "${temporary}"' EXIT mkdir "${temporary}/bin" +fixture_root=${temporary}/pending-fixture +mkdir -p "${fixture_root}/bin" +cp "${root}/bin/validate" "${root}/bin/verify-manual-settings" "${fixture_root}/bin/" +cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${root}/lib" "${fixture_root}/" +jq '.crates_io_trusted_publishing[] |= (.source_state="pending-reviewed-workflow" | .environment_state="pending")' \ + "${root}/config/manual-settings.json" >"${fixture_root}/config/manual-settings.json" cat >"${temporary}/bin/gh" <<'EOF' #!/usr/bin/env bash @@ -754,7 +760,7 @@ chmod +x "${temporary}/bin/gh" run_verify() { local scenario=$1 - local verification_root=${2:-${root}} + local verification_root=${2:-${fixture_root}} PATH="${temporary}/bin:${PATH}" \ FAKE_GH_LOG="${temporary}/gh.log" \ @@ -783,12 +789,12 @@ for scenario in crate-repository-drift crate-unprotected crate-ref-drift \ done observed_root=${temporary}/observed-crate mkdir -p "${observed_root}/bin" -cp "${root}/bin/validate" "${root}/bin/verify-manual-settings" "${observed_root}/bin/" +cp "${root}/bin/validate" "${fixture_root}/bin/verify-manual-settings" "${observed_root}/bin/" cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${root}/lib" "${observed_root}/" jq '.crates_io_trusted_publishing[0] |= ( .source_state = "observed-reviewed-workflow" | .environment_state = "observed" | .publisher_registration = {state:"owner-observed-registration",observed_on:"2026-08-31", - evidence_location:"atrinik/github-settings#owner-setup"})' "${root}/config/manual-settings.json" \ + evidence_location:"atrinik/github-settings#owner-setup"})' "${fixture_root}/config/manual-settings.json" \ >"${observed_root}/config/manual-settings.json" observed_output=$(run_verify present "${observed_root}") future_output=$(run_verify crate-workflow-future-pin "${observed_root}") @@ -811,11 +817,12 @@ done planned_updater_root=${temporary}/planned-updater mkdir -p "${planned_updater_root}/bin" -cp "${root}/bin/validate" "${root}/bin/verify-manual-settings" "${planned_updater_root}/bin/" +cp "${root}/bin/validate" "${fixture_root}/bin/verify-manual-settings" "${planned_updater_root}/bin/" cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${planned_updater_root}/" 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 | - .credential_state="pending-name-metadata")' "${root}/config/manual-settings.json" \ + .credential_state="pending-name-metadata")' "${fixture_root}/config/manual-settings.json" \ >"${planned_updater_root}/config/manual-settings.json" 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 @@ -823,12 +830,12 @@ if run_verify updater-present "${planned_updater_root}" >"${temporary}/updater-p fi updater_root=${temporary}/observed-updater mkdir -p "${updater_root}/bin" -cp "${root}/bin/validate" "${root}/bin/verify-manual-settings" "${updater_root}/bin/" +cp "${root}/bin/validate" "${fixture_root}/bin/verify-manual-settings" "${updater_root}/bin/" 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")' \ - "${root}/config/manual-settings.json" >"${updater_root}/config/manual-settings.json" + "${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 'KEEP atrinik/editor crate dependency App credential name metadata' <<<"${updater_output}" @@ -894,7 +901,7 @@ grep -Fq 'page=2' "${temporary}/gh.log" environment_page_root=${temporary}/environment-page mkdir -p "${environment_page_root}/bin" -cp "${root}/bin/validate" "${root}/bin/verify-manual-settings" \ +cp "${root}/bin/validate" "${fixture_root}/bin/verify-manual-settings" \ "${environment_page_root}/bin/" cp -R "${root}/config" "${environment_page_root}/config" cp -R "${root}/community-health" "${environment_page_root}/community-health" @@ -905,7 +912,7 @@ jq ' name: ("branch-" + tostring), type: "branch" }] + [{name: "main", type: "branch"}]) -' "${root}/config/manual-settings.json" \ +' "${fixture_root}/config/manual-settings.json" \ >"${environment_page_root}/config/manual-settings.json" : >"${temporary}/gh.log" output=$(PATH="${temporary}/bin:${PATH}" \ @@ -919,7 +926,7 @@ grep -Fq 'deployment-branch-policies?per_page=100&page=2' "${temporary}/gh.log" shared_root=${temporary}/shared-repository mkdir -p "${shared_root}/bin" -cp "${root}/bin/validate" "${root}/bin/verify-manual-settings" \ +cp "${root}/bin/validate" "${fixture_root}/bin/verify-manual-settings" \ "${shared_root}/bin/" cp -R "${root}/config" "${shared_root}/config" cp -R "${root}/community-health" "${shared_root}/community-health" @@ -929,7 +936,7 @@ jq ' .github_actions_credentials[0] | .secret_name = "SECOND_SETTINGS_TOKEN" ] -' "${root}/config/manual-settings.json" \ +' "${fixture_root}/config/manual-settings.json" \ >"${shared_root}/config/manual-settings.json" : >"${temporary}/gh.log" output=$(PATH="${temporary}/bin:${PATH}" \ @@ -1153,7 +1160,7 @@ fi 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-11 \ - "${root}/bin/verify-manual-settings" \ + "${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 exit 1 diff --git a/tests/verify_crate_workflow.py b/tests/verify_crate_workflow.py index 27632d4..f7e3c2c 100644 --- a/tests/verify_crate_workflow.py +++ b/tests/verify_crate_workflow.py @@ -98,6 +98,9 @@ def test_registry_token_expressions_are_confined_to_the_upload_binding(self): token_references = ( "${{ steps.auth.outputs.token }}", "${{ Steps.AUTH.outputs.token }}", + "${{ steps['auth'].outputs['token'] }}", + "${{ steps [ 'auth' ] . outputs [ 'token' ] }}", + "${{ format('}}{0}', steps.auth.outputs.token) }}", "${{ toJSON(steps) }}", "${{ steps[inputs.x].outputs.token }}", "${{ steps.auth.outputs.token }} ", @@ -141,6 +144,9 @@ def inject(job, location, token): def test_only_the_exact_upload_token_expression_is_allowed(self): variants = ( "${{ Steps.AUTH.outputs.token }}", + "${{ steps['auth'].outputs['token'] }}", + "${{ steps [ 'auth' ] . outputs [ 'token' ] }}", + "${{ format('}}{0}', steps.auth.outputs.token) }}", "${{ toJSON(steps) }}", "${{ steps[inputs.x].outputs.token }}", "${{ steps.auth.outputs.token }} ", From 0a42cec862aa2e0ed3b17390528600dfcfd7f124 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:14:35 -0500 Subject: [PATCH 12/16] test(governance): keep pending crate fixture valid --- tests/publish-maintenance-branch.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/publish-maintenance-branch.sh b/tests/publish-maintenance-branch.sh index c2ccada..b3c9c6f 100755 --- a/tests/publish-maintenance-branch.sh +++ b/tests/publish-maintenance-branch.sh @@ -1699,7 +1699,9 @@ assert_setup_failed_before_mutation() { pending_setup_root=${temporary}/crate-pending-setup crate_setup_root "${pending_setup_root}" jq '(.crates_io_trusted_publishing[] | select(.repository == "atrinik/protocol") | - .source_state) = "pending-reviewed-workflow"' \ + .source_state) = "pending-reviewed-workflow" | + (.crates_io_trusted_publishing[] | select(.repository == "atrinik/protocol") | + .environment_state) = "pending"' \ "${pending_setup_root}/config/manual-settings.json" >"${temporary}/pending-manual.json" mv "${temporary}/pending-manual.json" "${pending_setup_root}/config/manual-settings.json" pending_setup_log=${temporary}/crate-pending-setup.jsonl From 7323c187c7608d83b126bab3cd1e9a445be24f7f Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:18:56 -0500 Subject: [PATCH 13/16] test(governance): copy complete crate setup fixture --- tests/publish-maintenance-branch.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/publish-maintenance-branch.sh b/tests/publish-maintenance-branch.sh index b3c9c6f..0239ac0 100755 --- a/tests/publish-maintenance-branch.sh +++ b/tests/publish-maintenance-branch.sh @@ -1684,7 +1684,8 @@ crate_setup_root() { cp "${source_root}/bin/publish" "${source_root}/bin/validate" \ "${source_root}/bin/verify-manual-settings" "${destination}/bin/" cp "${source_root}/lib/verify_crate_workflow.py" "${destination}/lib/" - cp -R "${source_root}/config" "${destination}/" + cp -R "${source_root}/config" "${source_root}/community-health" \ + "${source_root}/.github" "${destination}/" cp -R "${source_root}/tests/fixtures" "${destination}/tests/" } From 49923265fe9a63bad3d280e80b4d500a319c0998 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:24:51 -0500 Subject: [PATCH 14/16] test(governance): use portable credential log scanning --- tests/check-project-health.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/check-project-health.sh b/tests/check-project-health.sh index 7d12f4c..fe31462 100755 --- a/tests/check-project-health.sh +++ b/tests/check-project-health.sh @@ -801,7 +801,7 @@ fi grep -Fq '2026-08-07T01:00:00Z' "${temporary}/old-success.out" grep -Fq 'actions/runs/1' "${temporary}/old-success.out" -if rg -n 'health-token|settings-token' "${temporary}"/*.out \ +if grep -En 'health-token|settings-token' "${temporary}"/*.out \ "${temporary}"/*.err "${temporary}/step-summary" >/dev/null; then echo "error: health output exposed credential material" >&2 exit 1 From 5760135cdd93507f278107adc073bd0c4b688d36 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:25:12 -0500 Subject: [PATCH 15/16] test(governance): supply mock publisher credential --- tests/publish-maintenance-branch.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/publish-maintenance-branch.sh b/tests/publish-maintenance-branch.sh index 0239ac0..b4e7f3a 100755 --- a/tests/publish-maintenance-branch.sh +++ b/tests/publish-maintenance-branch.sh @@ -2,6 +2,11 @@ set -euo pipefail +# This harness supplies a fake `gh` on PATH for every publisher invocation. +# Keep its CI credential synthetic even when the surrounding test runner +# deliberately clears GH_TOKEN to exercise other scripts' production gate. +export GH_TOKEN=test-token + root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) temporary=$(mktemp -d) trap 'rm -rf "${temporary}"' EXIT From 1222b00dddb3ae18f682040d840c18ab8849b24c Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 11 Oct 2026 14:31:49 -0500 Subject: [PATCH 16/16] test(governance): keep mock identity compatible with runner jq --- tests/publish-maintenance-branch.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/publish-maintenance-branch.sh b/tests/publish-maintenance-branch.sh index b4e7f3a..6a50db7 100755 --- a/tests/publish-maintenance-branch.sh +++ b/tests/publish-maintenance-branch.sh @@ -380,7 +380,7 @@ case "${endpoint}|${jq_filter}" in repos/atrinik/protocol\| | repos/atrinik/renderer\| | repos/atrinik/content-toolkit\|) repository=${endpoint#repos/atrinik/} jq -cn --arg repository "${repository}" --argjson id "$(crate_repository_id "${repository}")" \ - '{id: $id, full_name: "atrinik/" + $repository, archived: false, default_branch: "main"}' + '{id: $id, full_name: ("atrinik/" + $repository), archived: false, default_branch: "main"}' ;; repos/atrinik/protocol/branches/main\| | repos/atrinik/renderer/branches/main\| | repos/atrinik/content-toolkit/branches/main\|) printf '{"name":"main","protected":true,"commit":{"sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}\n'