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 10ed425..e7feb82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,18 +111,64 @@ 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 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 + 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. + 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. 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 + 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. 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 + `CRATE_DEPENDENCY_UPDATE_APP_PRIVATE_KEY` and variable + `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. 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 @@ -153,9 +199,16 @@ - 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. 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 b203d15..35f85ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,8 +97,11 @@ 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. 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 @@ -125,3 +128,47 @@ 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 +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 +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. 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. 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. 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. 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 +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 8402a95..6d6312c 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. +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. @@ -795,43 +797,113 @@ 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 +## 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. 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. + +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. +Qualification includes build, packaging, draft assembly, retry/recovery, +actual immutable-release upload gating, and publication paths. + +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: -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. - -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. -2. Merge this governance policy. Run `bin/publish` and inspect the exact - `PUT /orgs/atrinik/settings/immutable-releases` payload. It must select only - `[1327289971]`. -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: - - ```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]' - gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ - repos/atrinik/classic/immutable-releases - ``` +```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 @@ -1076,69 +1148,268 @@ 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 +## 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. +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. + +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. +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 +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 | +| --- | --- | --- | +| `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 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 +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 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 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. 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. + 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. 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 both merged publication/source-signing workflows. Record + `source_state: observed-reviewed-workflow` only after that observation. +3. The fourteen renderer and toolkit names above require a first owner-local + publication because Trusted Publishing cannot create their initial crate + records. Prepare, review, and verify the exact release archives, package + dependency order, versions, digests, native prerequisites, and registry + readiness first. The owner uses a narrowly scoped local API token only for + that initial bootstrap, then revokes it. Exclude the renderer CLI + `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 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 +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, 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. +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 +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 +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. Protocol registration is owner-observed with the successful manual 0.2 run; +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 + +`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 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 +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 +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..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}" @@ -1381,7 +1407,16 @@ 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") +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 db70f03..3647f6c 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 | @@ -286,6 +289,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", @@ -725,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 ( @@ -748,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 @@ -763,51 +769,168 @@ 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_owner != "atrinik" or .package_type != "container" or .package_name != "linux-build") end) ) ' "${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", - "workflow_ref", "workflow_trigger"]) and - .activation_state == "pending-source-release" and - .authentication_action == "rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18" and + (.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_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 + .bootstrap_policy == "owner-local-api-token-once-then-oidc-only" 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 - .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 + ($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 + $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#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" then .source_state == "observed-reviewed-workflow" 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", "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 + $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 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) +' "${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..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%%/*} @@ -406,8 +417,44 @@ 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. +# 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}") +fi + +# 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,26 +462,46 @@ 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 - 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}") + 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) + 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}" --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" + onboarding_status=PENDING + 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}")" + 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 --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}") @@ -535,6 +602,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 +646,31 @@ 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 --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 \ @@ -769,6 +869,65 @@ 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 '.credential_state' <<<"${app_record}") == observed-name-metadata ]]; 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 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. # shellcheck disable=SC2016 pins_response=$(github_api \ 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 bb0404d..ec30b1e 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,147 @@ }, "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": "observed", + "publisher_registration": { + "state": "owner-observed-registration", + "observed_on": "2026-10-11", + "evidence_location": "atrinik/protocol/actions/runs/38156755961" }, "provider": "github", "repository": "atrinik/protocol", "repository_id": 1327106950, - "runbook": "README.md#protocol-crate-trusted-publishing", + "runbook": "README.md#automatic-crate-trusted-publishing", + "source_evidence_policy": "github-attested-protected-main-manifest", + "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", + "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": "observed", + "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_evidence_policy": "github-attested-protected-main-manifest", + "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", + "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-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": "observed", + "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_evidence_policy": "github-attested-protected-main-manifest", + "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", "workflow_ref": "refs/heads/main", - "workflow_trigger": "workflow_dispatch" + "workflow_trigger": "workflow_dispatch", + "release_approval": "reviewed-source-and-version-merge" } ], "external_provider_apps": [ @@ -303,8 +429,95 @@ "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." + "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": 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", + "repository_scope_verification": "app_authenticated_repository_api", + "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": "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, + "credential_state": "observed-name-metadata", + "workflow_activation": { + "state": "pending-reviewed-workflows", + "observed_on": null, + "evidence_location": null + } + } } diff --git a/lib/verify_crate_workflow.py b/lib/verify_crate_workflow.py new file mode 100644 index 0000000..6801b92 --- /dev/null +++ b/lib/verify_crate_workflow.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +"""Verify the executable OIDC publication contract on exact-main workflow bytes.""" + +import argparse +import re +import shlex +import sys +from pathlib import Path + +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 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 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 + 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") + run_configuration(workflow, "workflow") + 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(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") + 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}") + 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") + 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(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) + 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" + 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") + 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") + 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] + 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") == 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 + 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") + 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] + 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): + 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", {}), + "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 in (verify_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}") + 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") + 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": + 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) + 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 and inert source signing contracts verified; immutable actions {action}, {signer_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..fe31462 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 @@ -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 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/publish-maintenance-branch.sh b/tests/publish-maintenance-branch.sh index fda3923..6a50db7 100755 --- a/tests/publish-maintenance-branch.sh +++ b/tests/publish-maintenance-branch.sh @@ -2,9 +2,22 @@ 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 +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' @@ -66,6 +79,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}" @@ -255,10 +324,15 @@ 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|"*) - jq '{enforced_repositories}' "${GH_IMMUTABLE_STATE}" + immutable_release_snapshot ;; "orgs/atrinik/settings/immutable-releases/repositories?per_page=100|"*) jq ' @@ -268,6 +342,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' @@ -283,6 +377,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"}]' @@ -1524,4 +1653,158 @@ grep -F \ "${identity_drift_output}" >/dev/null jq -s -e 'all(.[]; .method == "GET")' "${identity_drift_log}" >/dev/null +# 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 \ + 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" "${source_root}/community-health" \ + "${source_root}/.github" "${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" | + (.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 +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} \ + 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 c2c8c50..0cd963e 100755 --- a/tests/validate-crate-trusted-publishing.sh +++ b/tests/validate-crate-trusted-publishing.sh @@ -25,21 +25,30 @@ 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].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"' \ + '.crates_io_trusted_publishing[0].publisher_registration.state = "active"' \ + '.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"]' \ + '.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 = "*"' \ @@ -57,14 +66,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 @@ -75,9 +84,69 @@ 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" +"${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" | + .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] |= (.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}" +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.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"' \ + '.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.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. 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/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..39b05bd 100755 --- a/tests/validate-manual-credentials.sh +++ b/tests/validate-manual-credentials.sh @@ -23,8 +23,15 @@ 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} | + .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" } rewrite_manual_settings() { @@ -36,6 +43,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-manual-package-access.sh b/tests/validate-manual-package-access.sh index 11549e4..56c7940 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,32 @@ 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"' \ + '.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/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-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 04514a0..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 @@ -79,26 +85,110 @@ 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/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 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 + 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=$(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} ;; + 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-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)" \ + '{type:"file",encoding:"base64",content:$content}' ;; -repos/atrinik/protocol/collaborators/zoeyrose/permission) - permission=admin - role_name=admin +repos/atrinik/protocol/contents/.github/workflows/release.yml\?ref=*) + source=$(cat "${FAKE_CRATE_FIXTURES}/crate-source-release.yml") 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 ;; + 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 permission "${permission}" --arg role_name "${role_name}" \ - '{permission: $permission, role_name: $role_name}' + 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 @@ -243,7 +333,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 + .), @@ -315,6 +405,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=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 + [[ ${FAKE_GH_SCENARIO} != updater-selection ]] || updater_selection=all planned_control=false [[ ${FAKE_GH_SCENARIO} == planned-control-installed ]] && planned_control=true jq -n \ @@ -333,6 +429,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 +464,12 @@ repos/atrinik/classic/pages) events: ["workflow_run"], suspended_at: null }] else [] end) + + (if $updater_installed then [{ + 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 + }] else [] end) ) as $installations | {total_count: ($installations | length), installations: $installations}' fi @@ -376,7 +481,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, @@ -408,6 +513,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 }] }' ;; @@ -649,32 +760,93 @@ chmod +x "${temporary}/bin/gh" run_verify() { local scenario=$1 + local verification_root=${2:-${fixture_root}} 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-08-31 \ - "${root}/bin/verify-manual-settings" + ATRINIK_VALIDATION_TODAY=2026-10-11 \ + "${verification_root}/bin/verify-manual-settings" } : >"${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 -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 \ + 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 fi done +observed_root=${temporary}/observed-crate +mkdir -p "${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"})' "${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}") +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 \ + 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 + exit 1 + fi +done + +planned_updater_root=${temporary}/planned-updater +mkdir -p "${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")' "${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 + exit 1 +fi +updater_root=${temporary}/observed-updater +mkdir -p "${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")' \ + "${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}" +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 + exit 1 + fi +done grep -Fq 'KEEP atrinik/github-settings repository Actions secret ATRINIK_SETTINGS_TOKEN' \ <<<"${output}" @@ -729,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" @@ -740,21 +912,21 @@ 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}" \ 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" 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" @@ -764,14 +936,14 @@ 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}" \ 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 ]] @@ -987,8 +1159,8 @@ 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 \ - "${root}/bin/verify-manual-settings" \ + GITHUB_ACTIONS=true GH_TOKEN='' ATRINIK_VALIDATION_TODAY=2026-10-11 \ + "${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 new file mode 100644 index 0000000..f7e3c2c --- /dev/null +++ b/tests/verify_crate_workflow.py @@ -0,0 +1,194 @@ +"""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_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 }}", + "${{ 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 }} ", + ) + + 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 }}", + "${{ 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 }} ", + ) + 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"}}), + 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()