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, InsecurelyGeneratedTokenRevoked → StatusCode::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).
- 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).
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.
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.
- 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
Refs: #345, #344, #342, PF-039, PF-023, PF-040.
Context
Issue #345 proposed probing
CARGO_REGISTRY_TOKENinversion-gatewith an authenticated read against crates.io so a dead token fails the release before any registry write. That probe cannot be built. Verified live againstrust-lang/crates.iomainon 2026-09-07:GET /api/v1/meisAuthCheck::only_cookie()(src/controllers/user/me.rs:38-41; the route's utoipa annotation declaressecurity(("cookie" = []))). A valid API token, scoped or not, is rejected with HTTP 403this action can only be performed on the crates.io website(src/auth.rs:136-144;forbidden()iscustom(StatusCode::FORBIDDEN, …)insrc/util/errors.rs:60-62).The given API token does not match the format used by crates.io…(src/auth.rs:295,InsecurelyGeneratedTokenRevoked→StatusCode::UNAUTHORIZED,src/util/errors/json.rs:116-121). A well-formed token that is not in the database (deleted or revoked) gets HTTP 403authentication failed(src/auth.rs:297-303). Live probe withAuthorization: bogus→ 401, consistent with the source.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 403this token does not have the required permissionsbecause the route declares no endpoint scope. There is no read-only probe for a scoped publish token.User-Agentheader; without onesrc/middleware/require_user_agent.rs:35-47returns 403 with a plaintext body before authentication runs.Consequence: the non-empty guard in
version-gateis the strongest general check the workflow can make on this secret. A dead token is first detected at the firstcargo publish— fail-before-write, but only after the full build matrix has been paid for. Precedent: v0.4.0 run 33569514359 attempt 1 failed atPublish mds-corewith 403, nothing was published, andgh run rerun --failedcompleted 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-actionv1.0.5 (2026-06-16, commitc6f97d42243bad5fab37ca0427f495c86d5b1a18) exchanges the GitHub OIDC token for a 30-minute crates.io token viaPOST /api/v1/trusted_publishing/tokens; the action's post step revokes it. The mint endpoint validates repository owner/name, workflow filename, and environment; it rejectspull_request_targetandworkflow_runevents. Configuration is per crate, and the crate must already be published (both of ours are, at 0.4.2).mds-coreandmds-cli: ownerdean0x, repositorymdscript, workflow filenamerelease.yml, environment blank (same shape as the PyPI record).publish-crates: addpermissions: { id-token: write, contents: read }and a step--token "${{ steps.crates-auth.outputs.token }}"to bothcargo publishcalls. The 30-minute lifetime covers publish + the bounded 5-minute index poll + the second publish.version-gate: run the same action once, UNGUARDED (every event, includingpull_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.CARGO_REGISTRY_TOKEN; remove the-zguard and theenv:binding fromversion-gate; rewrite spec S3 inscripts/__test__/release-auth-probe.spec.mjsto assert the probe step with a positive control; update RELEASING.md prerequisite 3 and the credential-probe paragraph.Acceptance criteria
mds-coreandmds-cli.version-gateprobe succeeds on a branch dispatch AND fails on a deliberately wrong record (PF-013 positive control) — a probe never observed rejecting is not evidence.grep -n CARGO_REGISTRY_TOKEN .github/workflows/release.ymlis empty.npm run test:gatesgreen.if:on an unguarded job (ADR-013),needs:arrays stay inline, no literal${{inside comments orrun:text, merge via the command printed byscripts/verify-pr-checks.mjs.Refs: #345, #344, #342, PF-039, PF-023, PF-040.