Skip to content

ci(release): migrate crates.io publishing to Trusted Publishing (OIDC) — retires CARGO_REGISTRY_TOKEN and closes the unprobeable-token gap (#345) #368

Description

@dean0x

Context

Issue #345 proposed probing CARGO_REGISTRY_TOKEN in version-gate with an authenticated read against crates.io so a dead token fails the release before any registry write. That probe cannot be built. Verified live against rust-lang/crates.io main on 2026-09-07:

  • GET /api/v1/me is AuthCheck::only_cookie() (src/controllers/user/me.rs:38-41; the route's utoipa annotation declares security(("cookie" = []))). A valid API token, scoped or not, is rejected with HTTP 403 this action can only be performed on the crates.io website (src/auth.rs:136-144; forbidden() is custom(StatusCode::FORBIDDEN, …) in src/util/errors.rs:60-62).
  • A token that fails crates.io's format check gets HTTP 401 The given API token does not match the format used by crates.io… (src/auth.rs:295, InsecurelyGeneratedTokenRevokedStatusCode::UNAUTHORIZED, src/util/errors/json.rs:116-121). A well-formed token that is not in the database (deleted or revoked) gets HTTP 403 authentication failed (src/auth.rs:297-303). Live probe with Authorization: bogus → 401, consistent with the source.
  • The only token-accepting read-only route is GET /api/v1/me/tokens/{id} (src/controllers/token.rs:269-282, AuthCheck::default()), and it accepts only legacy unscoped tokens. Any scoped token, which is what a least-privilege publish secret should be, is rejected there with HTTP 403 this token does not have the required permissions because the route declares no endpoint scope. There is no read-only probe for a scoped publish token.
  • Every request needs a User-Agent header; without one src/middleware/require_user_agent.rs:35-47 returns 403 with a plaintext body before authentication runs.

Consequence: the non-empty guard in version-gate is the strongest general check the workflow can make on this secret. A dead token is first detected at the first cargo publish — fail-before-write, but only after the full build matrix has been paid for. Precedent: v0.4.0 run 33569514359 attempt 1 failed at Publish mds-core with 403, nothing was published, and gh run rerun --failed completed the release (PF-023 amendment 2026-09-02; PF-039).

#345 is being closed as won't-fix-as-filed and re-scoped to documentation in the B2 PR (PR #369). This issue tracks the durable fix.

Proposal — crates.io Trusted Publishing (OIDC)

crates.io Trusted Publishing is GA. rust-lang/crates-io-auth-action v1.0.5 (2026-06-16, commit c6f97d42243bad5fab37ca0427f495c86d5b1a18) exchanges the GitHub OIDC token for a 30-minute crates.io token via POST /api/v1/trusted_publishing/tokens; the action's post step revokes it. The mint endpoint validates repository owner/name, workflow filename, and environment; it rejects pull_request_target and workflow_run events. Configuration is per crate, and the crate must already be published (both of ours are, at 0.4.2).

  1. Configure Trusted Publishing on crates.io for BOTH mds-core and mds-cli: owner dean0x, repository mdscript, workflow filename release.yml, environment blank (same shape as the PyPI record).
  2. publish-crates: add permissions: { id-token: write, contents: read } and a step
    - id: crates-auth
      uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18  # v1.0.5
    (a node24 JavaScript action, so a commit-SHA pin is correct; PF-040 applies only to Docker-trampoline actions). Then pass --token "${{ steps.crates-auth.outputs.token }}" to both cargo publish calls. The 30-minute lifetime covers publish + the bounded 5-minute index poll + the second publish.
  3. version-gate: run the same action once, UNGUARDED (every event, including pull_request), as an exchange probe. The token is unused and the post step revokes it. Same shape as the PyPI OIDC probe (PF-039): a missing or mismatched crates.io record fails the dry run before any irreversible publish.
  4. Retire the secret after the first OIDC tag ships: delete and revoke CARGO_REGISTRY_TOKEN; remove the -z guard and the env: binding from version-gate; rewrite spec S3 in scripts/__test__/release-auth-probe.spec.mjs to assert the probe step with a positive control; update RELEASING.md prerequisite 3 and the credential-probe paragraph.

Acceptance criteria

  • crates.io shows the Trusted Publisher on both mds-core and mds-cli.
  • The version-gate probe succeeds on a branch dispatch AND fails on a deliberately wrong record (PF-013 positive control) — a probe never observed rejecting is not evidence.
  • A tag publishes both crates via OIDC; the post-step revocation is visible in the job log.
  • grep -n CARGO_REGISTRY_TOKEN .github/workflows/release.yml is empty.
  • S3 rewritten; npm run test:gates green.
  • RELEASING.md and CHANGELOG updated.
  • Verifier constraints respected: no job-level if: on an unguarded job (ADR-013), needs: arrays stay inline, no literal ${{ inside comments or run: text, merge via the command printed by scripts/verify-pr-checks.mjs.

Refs: #345, #344, #342, PF-039, PF-023, PF-040.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    github_actionsPull requests that update GitHub Actions codetech-debtTechnical debt

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions