diff --git a/AGENTS.md b/AGENTS.md index 8b88e5e..10ed425 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,13 @@ 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. - 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/README.md b/README.md index 49b9819..8402a95 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,8 @@ read-only and are skipped on later runs. owner-UI verification state. Provider credentials and account coordinates remain outside GitHub and this repository. - GitHub Actions defaults to read-only, cannot approve pull requests, and may - use only Atrinik, GitHub, Codecov coverage, and explicitly allowed Docker - actions. + use only Atrinik, GitHub, Codecov coverage, explicitly allowed Docker + actions, and the exact reviewed crates.io authentication action commit. - Historical repositories listed in `config/repositories.json` are archived. The five former standalone classic component repositories are already archived read-only after their history, active work, issues, and release @@ -1075,3 +1075,70 @@ must not list an obsolete `atrinik/classic` Actions-access grant; remove only 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 +`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. +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. diff --git a/bin/publish b/bin/publish index a08b010..4052acf 100755 --- a/bin/publish +++ b/bin/publish @@ -1379,4 +1379,9 @@ while IFS= read -r external_app; do repositories=$(jq -r '[.repositories[].repository] | join(", ")' <<<"${external_app}") echo "MANUAL ${app_slug} installation ${installation_id} selected repositories: ${repositories}; publisher does not apply" done < <(jq -c '.external_provider_apps[]' "${root}/config/manual-settings.json") +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" +done < <(jq -c '.crates_io_trusted_publishing[]' "${root}/config/manual-settings.json") echo "Manual confirmation required for config/manual-settings.json." diff --git a/bin/validate b/bin/validate index 14a7864..db70f03 100755 --- a/bin/validate +++ b/bin/validate @@ -286,6 +286,7 @@ jq -e '. == {state: "not-configured"}' \ jq -e ' (keys == [ "codecov_github_app_repositories", + "crates_io_trusted_publishing", "external_provider_apps", "github_actions_apps", "github_actions_credentials", @@ -766,6 +767,47 @@ 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. +jq -e \ + --slurpfile repositories "${repositories_config}" \ + --slurpfile actions "${root}/config/actions-selected.json" ' + .github_actions_environments as $environments | + .crates_io_trusted_publishing as $publishers | + ($publishers | type == "array" and length <= 1) and + all($publishers[]; + (keys == ["activation_state", "authentication_action", "crate", "environment", + "provider", "repository", "repository_id", "runbook", "workflow_filename", + "workflow_ref", "workflow_trigger"]) and + .activation_state == "pending-source-release" 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 + .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 + .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: [{type: "User", id: 3865595, login: "zoeyrose"}], + reviewer_requirement: "repository-maintainer", + secret_names: [], variable_names: [] + } + ) and + all($publishers[]; . as $publisher | + all($environments[]; + .repository != $publisher.repository or .environment != $publisher.environment.name)) +' "${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 c453c28..331ee54 100755 --- a/bin/verify-manual-settings +++ b/bin/verify-manual-settings @@ -406,6 +406,36 @@ 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. +while IFS= read -r crate_record; do + repository=$(jq -r '.repository' <<<"${crate_record}") + repository_id=$(jq -r '.repository_id' <<<"${crate_record}") + if [[ ${repository%%/*} != "${organization}" ]]; then + 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 + exit 1 + fi + reviewer_permission=$(github_api "read crate publishing reviewer permission" \ + "repos/${repository}/collaborators/zoeyrose/permission") + if ! jq -e '.permission == "admin" or .permission == "maintain"' \ + <<<"${reviewer_permission}" >/dev/null; then + echo "error: crate publishing reviewer is not a repository maintainer" >&2 + exit 1 + 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 repository=$(jq -r '.repository' <<<"${environment_record}") repository_owner=${repository%%/*} diff --git a/config/actions-selected.json b/config/actions-selected.json index a61dad5..47b5dd9 100644 --- a/config/actions-selected.json +++ b/config/actions-selected.json @@ -6,6 +6,7 @@ "codecov/codecov-action@*", "docker/login-action@*", "docker/setup-buildx-action@*", - "docker/build-push-action@*" + "docker/build-push-action@*", + "rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18" ] } diff --git a/config/manual-settings.json b/config/manual-settings.json index 321892a..bb0404d 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -44,6 +44,45 @@ "renderer", "server" ], + "crates_io_trusted_publishing": [ + { + "activation_state": "pending-source-release", + "authentication_action": "rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18", + "crate": "atrinik-protocol", + "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": [ + { + "type": "User", + "id": 3865595, + "login": "zoeyrose" + } + ], + "reviewer_requirement": "repository-maintainer", + "secret_names": [], + "variable_names": [] + }, + "provider": "github", + "repository": "atrinik/protocol", + "repository_id": 1327106950, + "runbook": "README.md#protocol-crate-trusted-publishing", + "workflow_filename": "publish-crate.yml", + "workflow_ref": "refs/heads/main", + "workflow_trigger": "workflow_dispatch" + } + ], "external_provider_apps": [ { "app_id": 85455, diff --git a/tests/validate-crate-trusted-publishing.sh b/tests/validate-crate-trusted-publishing.sh new file mode 100755 index 0000000..c2c8c50 --- /dev/null +++ b/tests/validate-crate-trusted-publishing.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +set -euo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +temporary=$(mktemp -d) +trap 'rm -rf "${temporary}"' EXIT +mkdir -p "${temporary}/bin" +cp "${root}/bin/validate" "${temporary}/bin/validate" +cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${temporary}/" + +reset_policy() { + cp "${root}/config/manual-settings.json" "${temporary}/config/manual-settings.json" + cp "${root}/config/actions-selected.json" "${temporary}/config/actions-selected.json" +} + +assert_rejected() { + local filter=$1 + jq "${filter}" "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" + if "${temporary}/bin/validate" >/dev/null 2>&1; then + echo "error: accepted invalid crate publishing policy: ${filter}" >&2 + exit 1 + fi + reset_policy +} + +"${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].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].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 = 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 = "*"' \ + '.crates_io_trusted_publishing[0].environment.deployment_branch_policy.patterns[0].type = "tag"' \ + '.crates_io_trusted_publishing[0].environment.deployment_branch_policy.patterns += [{name:"v*",type:"tag"}]' \ + '.crates_io_trusted_publishing[0].environment.secret_names = ["CARGO_REGISTRY_TOKEN"]' \ + '.crates_io_trusted_publishing[0].environment.variable_names = ["UNREVIEWED"]' \ + '.crates_io_trusted_publishing[0].token = "forbidden-value"' \ + '.crates_io_trusted_publishing[0].release_sha = "invented"' \ + '.crates_io_trusted_publishing[0].crate_sha256 = "invented"' \ + '.crates_io_trusted_publishing += [.crates_io_trusted_publishing[0]]'; do + assert_rejected "${filter}" +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 + 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 + exit 1 +fi +reset_policy +jq '.patterns_allowed += ["rust-lang/*"]' "${root}/config/actions-selected.json" \ + >"${temporary}/config/actions-selected.json" +if "${temporary}/bin/validate" >/dev/null 2>&1; then + echo "error: accepted broader Rust action access beside the exact pin" >&2 + exit 1 +fi +reset_policy +# 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." diff --git a/tests/verify-manual-settings.sh b/tests/verify-manual-settings.sh index 411a28e..76c6f77 100755 --- a/tests/verify-manual-settings.sh +++ b/tests/verify-manual-settings.sh @@ -71,6 +71,28 @@ graphql) }}}' fi ;; +repos/atrinik/protocol) + repository_id=1327106950 + if [[ ${FAKE_GH_SCENARIO} == crate-repository-drift ]]; then + repository_id=1 + fi + 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 + if [[ ${FAKE_GH_SCENARIO} == crate-reviewer-permission ]]; then + permission=read + fi + jq -n --arg permission "${permission}" '{permission: $permission}' + ;; repos/atrinik/github-settings) if [[ ${FAKE_GH_SCENARIO} == identity-drift ]]; then jq -n '{ @@ -631,6 +653,18 @@ run_verify() { : >"${temporary}/gh.log" output=$(run_verify present) +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-reviewer-drift crate-reviewer-permission; do + if run_verify "${scenario}" >"${temporary}/${scenario}.out" 2>"${temporary}/${scenario}.err"; then + echo "error: verifier accepted ${scenario}" >&2 + exit 1 + fi +done + grep -Fq 'KEEP atrinik/github-settings repository Actions secret ATRINIK_SETTINGS_TOKEN' \ <<<"${output}" grep -Fq 'KEEP atrinik/classic environment discord-release metadata' <<<"${output}"