Skip to content

ci: publish rs-materials via crates.io trusted publishing (OIDC) - #245

Merged
gerchowl merged 4 commits into
mainfrom
chore/crates-io-trusted-publishing
Aug 21, 2026
Merged

gerchowl merged 4 commits into
mainfrom
chore/crates-io-trusted-publishing

Conversation

@gerchowl

@gerchowl gerchowl commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Description

rs-materials/v0.3.0 is tagged, tested green, and not published — run 32396698845 failed with 403 Forbidden: authentication failed. CARGO_REGISTRY_TOKEN was set 2026-03-24 and is no longer valid. Rotating it buys another finite window and another silent expiry at the worst possible moment. crates.io has supported trusted publishing since 2025-07 and this repo already publishes to PyPI that way, so the Rust side was the odd one out, not the norm. This PR moves it to OIDC, pins which refs are allowed to publish, and documents both.

Type of Change

  • chore / refactor / test / ci / build / style — No release

Required

  • Tests pass locally (uv run pytest)
  • Self-reviewed the diff
  • No new warnings or errors in the changed code
  • Linked to an issue in the Refs: line at the bottom (or explained why none)

Additional Notes

What changed

  • OIDC instead of a stored token. rust-lang/crates-io-auth-action@v1 exchanges the job's GitHub identity for a crates.io token valid ~30 min; the action's post step revokes it. Nothing to rotate, nothing to expire, no credential at rest.
  • New crates-io environment, mirroring pypi in release.yml.
  • Deployment branch policies on both environments. Trusted publishing authenticates the workflow, not the ref — the OIDC claim says nothing about which commit is checked out. Combined with workflow_dispatch that would let any branch publish whatever its manifest claimed. pypi now allows main, v*, pymat-mcp/v*; crates-io allows main, rs-materials/v*.
  • --locked replaces --allow-dirty. cargo publish refreshes the crates.io index, which can rewrite the tracked mat-rs/Cargo.lock and trip cargo's own dirty-tree check — that is the v0.2.0 failure --allow-dirty silenced. Silencing the check would also silence a genuinely modified tree being published. --locked removes the cause and keeps the check. Also added to the test job so a stale lockfile fails before the publish gate rather than after it.
  • workflow_dispatch on all three publishing workflows. Re-running a failed tag run replays the workflow file as it was at that tag, so a fix has no effect on a re-run; without dispatch the only retry path is deleting and re-pushing the tag.
  • RELEASE_PROCESS.md documents both registries' auth, the ref policy, and the retry path.

vars.CRATES_IO_PUBLISH_ENABLED stays as a kill switch — now independent of credentials rather than a stand-in for them.

Review rounds

Three fresh-context review passes; the second returned a blocker.

Round Finding Fix
1 Job-level permissions replaces the workflow-level block, so the publish job had contents: none; checkout worked only via anonymous clone on a public repo restated contents: read
1 workflow_dispatch + no environment protection = any branch can publish deployment branch policies on both envs
1 Doc claimed no long-lived token is in repo secrets; the dead one is still there reworded to what is true, noted pending deletion
1 Retry recipe said --ref main, which only publishes the tagged version while main points at the release commit --ref <tag> as the general case, --ref main as the exception
2 — blocker publish-mcp.yml also deploys to pypi; the new policy allowed only main + v*, and * does not cross /, so the next pymat-mcp/v* tag would build green then fail "Branch not allowed to deploy" added pymat-mcp/v*; audited all 3 env users against all 3 tag patterns
3 gh workflow run --ref <tag> reads the trigger from the target ref, so the documented example tag (which predates workflow_dispatch:) could not be dispatched at all doc names both cases where --ref <tag> won't work

Manual step this PR cannot do

One-time, at https://crates.io/crates/rs-materials/settings → Trusted Publishing → Add:

Field Value
Repository owner MorePET
Repository name mat
Workflow filename release-rs-materials.yml
Environment crates-io

crates.io requires the crate to already exist (0.1.0 and 0.2.0 are published) and the configuring user to be an owner.

Verification after merge

gh workflow run release-rs-materials.yml --ref main

--ref main rather than the tag, because the v0.3.0 tag's workflow file has no dispatch trigger. main is the v0.3.0 release commit, so the published content is identical to the tag. Once green: gh secret delete CARGO_REGISTRY_TOKEN.

Checks run locally

  • yamllint --strict on all three workflows — clean
  • pymarkdown -c .pymarkdown scan RELEASE_PROCESS.md — back to the pre-existing baseline (5 MD013 hits, all on untouched lines)
  • cargo publish --manifest-path mat-rs/Cargo.toml --locked --dry-run — exit 0, packages 25 files, leaves the tree clean
  • rust-lang/crates-io-auth-action v1 tag and its token output verified against the action's published action.yml
  • Live gh api .../deployment-branch-policies output for both environments matches the table in RELEASE_PROCESS.md

Refs: N/A — release infrastructure only; unblocks the stalled rs-materials/v0.3.0 publish (run 32396698845).

https://claude.ai/code/session_01PuXPyTpjcG1B65fpaz1jCX

The v0.3.0 publish failed with `403 authentication failed`: the
`CARGO_REGISTRY_TOKEN` secret was minted 2026-03-24 and is no longer
valid. Rotating it would buy another finite window and another silent
expiry; crates.io has supported trusted publishing since 2025-07, and
this repo already publishes to PyPI that way.

- `rust-lang/crates-io-auth-action@v1` exchanges the job's GitHub OIDC
  identity for a crates.io token valid ~30 min, revoked in the action's
  post step. No secret to rotate, nothing to expire.
- New `crates-io` GitHub environment, mirroring `pypi` in release.yml.
  A trusted publisher can be scoped to an environment, and it gives one
  place to add an approval rule without editing the workflow.
- `--locked` replaces `--allow-dirty`. `cargo publish` refreshes the
  crates.io index, which can rewrite the tracked `mat-rs/Cargo.lock` and
  trip cargo's own dirty-tree check — that was the v0.2.0 failure the
  `--allow-dirty` fix silenced. Silencing the check would also have
  silenced a genuinely modified tree. `--locked` removes the cause and
  keeps the check. Also applied to the `test` job so a stale lockfile
  fails before the publish gate rather than after it.
- `workflow_dispatch` on both publish workflows. Re-running a failed tag
  run replays the workflow file as it was at that tag, so a fix to the
  workflow only takes effect on a fresh run; without dispatch the only
  retry path is deleting and re-pushing the tag.

Requires a one-time trusted-publisher entry at
https://crates.io/crates/rs-materials/settings naming MorePET/mat,
`release-rs-materials.yml`, and environment `crates-io`. The stale
`CARGO_REGISTRY_TOKEN` secret should be deleted once a publish succeeds.

Claude-Session: https://claude.ai/code/session_01PuXPyTpjcG1B65fpaz1jCX
Review of #245 found four real gaps:

- Trusted Publishing authenticates the workflow, not the ref. Combined
  with `workflow_dispatch` that let any branch publish whatever its
  manifest claimed. Both environments now carry a deployment branch
  policy: `pypi` allows `main` + `v*`, `crates-io` allows `main` +
  `rs-materials/v*`. `main` is allowed only because it is the
  workflow-file-fix retry path.
- Job-level `permissions` replaces the workflow-level block rather than
  merging, so the publish job had `contents: none`. Checkout worked
  anyway via anonymous clone — but only because the repo is public.
  Restated `contents: read` explicitly.
- RELEASE_PROCESS.md asserted no long-lived token is in repo secrets;
  the dead `CARGO_REGISTRY_TOKEN` is still there until a publish
  succeeds. Reworded to what is true — no publishing job reads one —
  and noted the pending deletion.
- The retry recipe said `--ref main`, which only publishes the tagged
  version while main still points at the release commit. `--ref <tag>`
  is the general case; `--ref main` is the exception for a broken
  workflow file, and the doc now separates the two.

Claude-Session: https://claude.ai/code/session_01PuXPyTpjcG1B65fpaz1jCX
Review blocker: `publish-mcp.yml` also deploys to the `pypi`
environment, and the branch policy added in b57b78f allowed only `main`
and `v*`. Deployment-branch patterns do not let `*` cross `/`, so the
next `pymat-mcp/v*` tag would have built green and then failed with
"Branch not allowed to deploy" — a workflow that was never opened in
the diff, broken by a policy meant to protect a different one.

- Added tag pattern `pymat-mcp/v*` to the `pypi` environment.
- Added `workflow_dispatch` to publish-mcp.yml so the retry path this
  PR introduces applies to all three publishing workflows, not two.
- RELEASE_PROCESS.md lists publish-mcp.yml in the auth table, corrects
  the allowed-refs table, and states the rule that produced the bug:
  every tag prefix deploying to an environment needs its own pattern.

Audited: three workflows use an environment, three tag patterns exist,
all three are now covered.

Claude-Session: https://claude.ai/code/session_01PuXPyTpjcG1B65fpaz1jCX
`gh workflow run --ref <tag>` reads the trigger list from the target
ref, so a tag whose workflow file has no `workflow_dispatch:` cannot be
dispatched at all. The example named `rs-materials/v0.3.0`, which is
exactly such a tag — the recipe would have failed for the one release
it was written to unblock.

Claude-Session: https://claude.ai/code/session_01PuXPyTpjcG1B65fpaz1jCX
@gerchowl
gerchowl merged commit 29a6b2b into main Aug 21, 2026
30 of 32 checks passed
@gerchowl
gerchowl deleted the chore/crates-io-trusted-publishing branch August 21, 2026 14:15
gerchowl added a commit that referenced this pull request Aug 21, 2026
rs-materials 0.3.0 published via trusted publishing (run 32497790484),
so the dead token secret was deleted. `gh secret list` is now empty.

Verified the published artifact rather than trusting the green run: a
fresh crate depending on rs-materials = "0.3.0" from crates.io loads
169 materials and 32 surfaces, resolves lyso.Ce n@420nm = 1.82 and the
measured Ce self-absorption length 588 mm, and reads 7 declared
absences — i.e. the symlinked data TOMLs survived `cargo package` into
the tarball, which was the one thing a green publish would not prove.

Refs: N/A — follow-up to #245.

Claude-Session: https://claude.ai/code/session_01PuXPyTpjcG1B65fpaz1jCX
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant