crates.io
Detection signals
Section titled “Detection signals”crates.io is present when a Cargo.toml or a Cargo.lock turns up anywhere in the checkout.
crates.io is shipped when a Cargo.toml carries a [package] section with name and version, publish is not set to false, and publish evidence exists: a release workflow running cargo publish, or an existing page on crates.io for that name.
Sources: The manifest format, Workspaces.
Three cases decide most arguments:
- A virtual manifest declares no package. Cargo’s own words: “a
Cargo.tomlfile can be created with a[workspace]section but without a[package]section… This is called a virtual manifest.” The workspace root is present, and each member answers the shipped question for itself. publish = falsesettles the shipped answer, and Cargo documents it as preventing publishing entirely.publish = ["some-registry-name"]is the other half of the same field and means the opposite: the crate ships, to the registry named, and crates.io is not it. An omittedversionhas the same practical effect aspublish = false, which Cargo records as preventing “a package from being published to a registry by mistake”.- An internal
Cargo.tomlfor anxtaskhelper, a fuzz target, or a benchmark harness is present. So is aCargo.lockon its own. Neither says anything about what the repository distributes.
Release track
Section titled “Release track”crates.io takes the registry-push track. cargo publish uploads a built .crate file to the registry under a credential, so there is an upload to secure and a credential to scope, which is what assigns the track. The roster records "track": "registry-push" for crates.io and the release area’s preamble names crates.io in its registry-push list.
Verified 2026-07-31 against https://doc.rust-lang.org/cargo/reference/manifest.html and https://doc.rust-lang.org/cargo/reference/workspaces.html.
Version badge
Section titled “Version badge”Shields.io serves this one at img.shields.io/crates/v/CRATE, taking the crate name as its only path parameter.
The default label is crates.io, dot included. Keep it: the dot is part of the registry’s name rather than a stray character, and rewriting it to crates names something else.
Link the badge to the crate page:
[](https://crates.io/crates/CRATE)Shields.io also serves crates/msrv, crates/l, crates/size, and a download count for the same crate. None of them belongs in the row. The minimum supported Rust version is a fact worth stating, and it belongs in the facts list where it can carry a policy sentence beside it, rather than in a badge that states a number with no policy.
Install command
Section titled “Install command”cargo add CRATEcargo add writes the dependency into Cargo.toml and picks the constraint, and the registry is where it looks unless a --path or --git option sends it somewhere else. A crate that ships a binary shows cargo install CRATE instead, because cargo add on a binary crate adds a dependency nobody will call.
Name the crate exactly as published. A crate whose library name differs from its package name, which happens whenever the package name carries a hyphen, also needs the use path in the usage example to show the underscored form, so check the example against the name rather than assuming they match.
Verified 2026-07-31 against cargo-add, cargo-install, Cargo targets, and services/crates/crates-version.service.js plus services/crates/crates-base.js in badges/shields.
License declaration (R-COM-01)
Section titled “License declaration (R-COM-01)”Cargo.toml declares it in the [package] table. license holds an SPDX 2.3 license expression, and the Cargo manifest reference is specific about the vocabulary: the name must be a known license from SPDX license list 3.20. The common dual-licensed Rust crate therefore reads license = "MIT OR Apache-2.0", which is one expression rather than two declarations.
license-file is the alternative, not an addition. It holds a path relative to the Cargo.toml pointing at the text of a nonstandard license, and the reference says it may be specified in lieu of license. Cargo’s own note records that crates.io requires one of the two to be set, so a crate that reaches the registry always declared something.
That leaves R-COM-01 with two shapes to read. Where license is set, the expression is the manifest side and the root license file is the other. Where license-file is set instead, the manifest names a path rather than a license, so follow the path and read the file it names; a license-file pointing somewhere other than the root license file is worth raising, because the crate then ships one document while the repository advertises another.
Source: The manifest format.
Funding platform name
Section titled “Funding platform name”Tidelift lists no platform for this ecosystem, so there is no tidelift value a Rust crate can write.
GitHub’s own documentation for the funding file enumerates the accepted tidelift platform names. It names six, every one of them another ecosystem’s registry; Rust and crates.io appear nowhere in it. That is why the roster at skills/oss-audit/ecosystems.json carries no tidelift key for this ecosystem.
Source: Displaying a sponsor button in your repository.
The strongest documented fallback is the rest of the same file. The github, open_collective, liberapay, and custom keys are forge-level rather than ecosystem-level, so they reach a Rust project exactly as they reach any other, and custom takes up to four arbitrary URLs where the maintainer already collects funding somewhere the file has no key for.
No rule in STANDARD.md requires a funding file at all, so this gap costs a repository nothing when it is scored; what it costs is one option a maintainer in another ecosystem would have. Tidelift adding a Rust platform, and GitHub’s funding documentation listing it, is what would retire it.
Verified 2026-07-31 against The manifest format and Displaying a sponsor button in your repository.
Toolchain and matrix (R-CI-03)
Section titled “Toolchain and matrix (R-CI-03)”The Rust project publishes no first-party GitHub Actions setup action. Its own continuous integration chapter drives the toolchain with rustup, which is preinstalled on GitHub-hosted runners:
strategy: matrix: toolchain: [stable, beta, nightly]steps: - uses: actions/checkout@v7 # oss-harden pins this to a commit SHA - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - run: cargo test --verboseThe support claim lives in rust-version in [package] of Cargo.toml, the minimum supported Rust version. Note what it is and is not: it is a floor, not a range, so the matrix above tests the release channels while the floor needs its own check. Cargo’s book documents that check as cargo hack check --rust-version, which needs cargo-hack, a third-party tool the maintainer vets before it enters a workflow. Where the project ships no rust-version, it makes no claim to cover and the channel matrix is the whole of it.
On GitLab, the same chapter uses image: rust:latest for the stable job and image: rustlang/rust:nightly with allow_failure: true for nightly, so a nightly regression reports without failing the pipeline.
Sources: The Cargo Book, continuous integration, The Cargo Book, the rust-version field, rust-lang/docker-rust.
Dependency caching (R-CI-04)
Section titled “Dependency caching (R-CI-04)”Cargo documents which parts of CARGO_HOME are safe to reuse across builds, and caching the whole directory is explicitly not it: registry/src/ holds sources extracted from the .crate archives already in registry/cache/, so caching everything stores each dependency twice and pays the recompression and upload for it. The documented set is .crates.toml, .crates2.json, bin/, registry/index/, registry/cache/, and git/db/.
target/ is build output rather than package-manager download data, so it is outside what this rule’s caching guidance covers, and it is also the directory whose reuse most often produces a stale result.
Key on Cargo.lock. Cargo’s FAQ says committing it is a per-package decision rather than a rule, and that a library’s lockfile does not reach its consumers, who resolve from Cargo.toml. So a crate that commits no lockfile has nothing to key a cache on and gets no cache step; a workspace that commits one keys on it.
On both forges this is an explicit cache rather than a setup action’s built-in one, because no first-party action exists to carry it. On GitLab set CARGO_HOME to a project-relative directory first, because GitLab caches only paths inside the project directory.
Sources: The Cargo Book, caching the Cargo home in CI, The Cargo Book, FAQ, GitLab CI/CD YAML reference, cache.
Test command (R-CI-06)
Section titled “Test command (R-CI-06)”cargo test is the invocation, and the Cargo book’s own CI examples use cargo test --verbose on both forges. Rust declares the suite by layout rather than in the manifest: unit tests live in #[cfg(test)] modules beside the code, integration tests in tests/, and documentation examples run as doc tests, and cargo test discovers all three without configuration. So the absence of a test entry in Cargo.toml says nothing; look for the directories and the attribute instead.
A workspace runs cargo test --workspace, and a crate with optional features needs the feature combinations it claims to support named on the command line, because the default run tests only the default features.
Sources: The Cargo Book, continuous integration.
Verified 2026-07-31 against https://doc.rust-lang.org/cargo/guide/continuous-integration.html, https://doc.rust-lang.org/cargo/guide/cargo-home.html, https://doc.rust-lang.org/cargo/reference/rust-version.html, https://doc.rust-lang.org/cargo/faq.html, and https://github.com/rust-lang/docker-rust.
Automated dependency updates (R-SEC-03)
Section titled “Automated dependency updates (R-SEC-03)”Dependabot’s cargo value carries version updates and security updates, and private Cargo registries are supported, so a project pulling from a private registry still gets both. Vendored dependencies are not supported.
On GitLab the Renovate manager is cargo.
Lockfile and frozen install (R-SEC-08)
Section titled “Lockfile and frozen install (R-SEC-08)”Cargo writes Cargo.lock without being asked, and cargo new tracks it in version control by default. cargo build --locked asserts that the same dependencies and versions are used as when the lockfile was generated, and exits with an error when the lockfile is missing or when Cargo would have changed it. --frozen is documented as --locked plus --offline, so use --locked unless the job deliberately runs with no network.
Cargo’s own FAQ is worth reading before reporting a library crate that does not commit the file. It says whether you track Cargo.lock “is dependent on the needs of your package”, and it makes the sharper point that determinism here “can give a false sense of security because Cargo.lock does not affect the consumers of your package, only Cargo.toml does that”. That is an argument about what a consumer resolves, not a direction to leave the file out, and Cargo’s default is to track it, so a Rust repository is inside R-SEC-08 either way and a missing Cargo.lock is a finding rather than a convention.
What breaks the first time you commit the lockfile
Section titled “What breaks the first time you commit the lockfile”Generate the lock under the resolver setting that respects rust-version. Where the project declares one and tests more than one toolchain, resolver.incompatible-rust-versions decides what lands in the file. It takes allow, which treats a rust-version-incompatible release like any other, or fallback, which considers one “only if no other version matched”. Under allow the resolver picks the highest semver-compatible release and records it, so a lock generated on a current toolchain can name a dependency the project’s own minimum cannot build, and the oldest job in the matrix is where that surfaces. The default follows the resolver version rather than being fixed, so read the project’s before assuming which applies.
Platform is not a dimension here. Cargo resolves platform-specific dependencies “as-if all platforms are enabled”, ignoring the cfg expression, so one lock covers every target and a lock written on macOS builds on a Linux runner unchanged. Feature resolver version 2 narrows this for features alone: a feature enabled only through a [target.'cfg(windows)'] entry is not enabled when the build is not for Windows. That changes what compiles, not what the lock records.
One workspace keeps one lock. All packages in a workspace “share a common Cargo.lock file which resides in the workspace root”, so a multi-crate repository gives an updater a single file to find, and there is no analogue of the extra locks Bundler and Gradle projects accumulate.
Static analysis (R-SEC-09)
Section titled “Static analysis (R-SEC-09)”CodeQL lists Rust among its supported languages, for the 2021 and 2024 editions, and its analysis needs rustup and cargo available; features from nightly toolchains are not supported. GitLab’s SAST table does not list Rust among the languages its analyzers cover by default, and its guidance there is a custom ruleset, so on GitLab this is the project’s own analyzer as a required check rather than a shipped one.
Vulnerability watch (R-SEC-11)
Section titled “Vulnerability watch (R-SEC-11)”The dependency graph parses Cargo.lock as its recommended file, with Cargo.toml as an additional file. The Cargo row marks static transitive dependencies, Dependabot graph jobs, and automatic dependency submission all unsupported.
Advisories come from the GitHub Advisory Database, which names this ecosystem Rust against the crates.io registry.
osv-scanner reads Cargo.lock, and when it scans a container image it also recovers Rust binaries built with cargo-auditable.
Verified 2026-07-31 against Dependabot supported ecosystems and repositories, Dependency graph supported package ecosystems, GitHub Advisory Database, cargo build, Cargo FAQ, Cargo dependency resolution, Cargo workspaces, Cargo configuration, CodeQL supported languages and frameworks, GitLab SAST, Renovate managers, and osv-scanner supported languages and lockfiles.
Concrete flow for the decisions SKILL.md makes, for a crate published to crates.io. crates.io accepts two trusted publishing providers: GitHub Actions and GitLab CI/CD. Its GitLab CI/CD support covers projects on gitlab.com only, with no path for a self-managed instance; confirm that scope in the documentation linked below before writing the GitLab flow. Trusted publishing requires the crate to already exist on crates.io; the very first publish of a new crate still needs an interactive cargo publish with a personal API token, from the maintainer’s own machine.
Source: crates.io Docs, Trusted Publishing and Rust Blog, crates.io development update, 2026-01-21.
Contents
Section titled “Contents”- Gather facts (Step 1)
- Configure trusted publishing (Step 2)
- Write the hardened release workflow (Step 3)
- Gate on manual approval (Step 4)
- Verify provenance (Step 5): a gap, not a check
- Describe and sign what the release attaches (Step 6)
Gather facts (Step 1)
Section titled “Gather facts (Step 1)”Read Cargo.toml’s package.repository field for the owner and repository, falling back to git remote get-url origin. Check whether the crate is already published with curl -s -o /dev/null -w '%{http_code}' https://crates.io/api/v1/crates/<name> (or check the crate’s page directly); anything other than 200 means it needs the first interactive publish before any of the flow below applies.
Configure trusted publishing (Step 2)
Section titled “Configure trusted publishing (Step 2)”Both forges are supported and both are configured from the same crate settings page, so read the section for the forge detected in Step 1 and take the field names from there.
GitHub Actions
Section titled “GitHub Actions”Open https://crates.io/crates/<name>/settings/new-trusted-publisher, choose GitHub, and enter:
- Repository owner: the owner from Step 1
- Repository name: the repository name from Step 1
- Workflow filename:
release.yml, the filename only, not the full path - Environment: the approval environment name from
SKILL.mdStep 4, for examplerelease
In the workflow, the publish job needs:
permissions: id-token: writesteps: - uses: rust-lang/crates-io-auth-action@v1 id: auth - run: cargo publish env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}rust-lang/crates-io-auth-action exchanges the workflow’s OIDC token for a 30-minute publish token; no CARGO_REGISTRY_TOKEN secret needs to exist in the repository.
GitLab CI/CD
Section titled “GitLab CI/CD”At the same settings page, choose GitLab and enter:
- Namespace: the GitLab username or group path (nested groups are supported, for example
group/subgroup) - Project: the project name
- Workflow filepath: the full path to the pipeline file, for example
.gitlab-ci.yml - Environment: the approval environment name from Step 4
In the pipeline, the publish job needs:
environment: name: releaseid_tokens: CRATES_IO_ID_TOKEN: aud: crates.ioThe environment name has to match the form’s Environment field exactly. crates.io compares the environment claim in the OIDC token against what the form recorded, and that string comparison is the whole of the check: GitLab also emits an environment_protected claim, and crates.io does not read it, so naming an environment is not on its own a restriction. Make it a protected environment with deploy access restricted to whoever is allowed to release, which GitLab offers on Premium and Ultimate. On a tier without protected environments any job in the project can claim the name, and the claim is then self-asserted. That is the difference between a control and a label here, because the form binds namespace, project, and workflow filepath and no ref at all: with the environment unprotected, a pipeline on any branch a contributor can push satisfies every claim crates.io checks.
crates.io has no equivalent to crates-io-auth-action for GitLab; the pipeline exchanges the OIDC token for a publish token itself. The exchange needs curl and jq. Use a project-controlled release image that contains the project’s exact Rust toolchain plus these tools, and pin the image by digest. Build and provenance-check that image before adding id_tokens to the job. Do not run apt-get after the job has received its OIDC token.
#!/bin/shset -euset +xresponse=$(curl --fail-with-body --silent --show-error \ --request POST https://crates.io/api/v1/trusted_publishing/tokens \ -H "Content-Type: application/json" \ -H "User-Agent: gitlab-trusted-publishing (<maintainer email>)" \ --data "$(jq -cn --arg jwt "$CRATES_IO_ID_TOKEN" '{jwt:$jwt}')")token=$(printf '%s' "$response" | jq -er '.token | select(type == "string" and length > 0)')export CARGO_REGISTRY_TOKEN=$tokenunset token response CRATES_IO_ID_TOKENSave that as exchange-token.sh and source it with . ./exchange-token.sh in the same shell that runs cargo publish; executing it as a child process loses the exported variable. The script disables command tracing, fails on HTTP or JSON errors, never prints the short-lived token or error response, and removes the original OIDC token from the environment.
Sourcing it also leaves set -eu active for the rest of the job’s script: block, which is not what the reader asked for and is worth knowing before the next command runs. Keep errexit: a publish step that continues past a failed command is the failure this whole flow exists to prevent, and GitLab’s own default is to stop on a nonzero exit anyway. nounset is the one to scope, because the remaining commands are the project’s own and any of them reading an unset optional variable now aborts the job with a message about the variable rather than about the publish. Add set +u as the script’s last line. A subshell is not the fix here: the token has to survive into the parent shell, which is the reason the script is sourced at all.
--fail-with-body needs curl 7.76 or later; an older curl rejects it as an unknown option and the exchange fails before it reaches crates.io. Check the release image’s curl version when you pin its digest. This follows crates.io’s documented exchange endpoint while closing the token leak in its minimal example. Re-read that endpoint’s documentation before every implementation, because GitLab support is the newer of the two providers.
Write the hardened release workflow (Step 3)
Section titled “Write the hardened release workflow (Step 3)”Cargo does not accept a prebuilt .crate path for publishing. Run cargo publish --locked --dry-run in an uncredentialed job so dependency resolution, build scripts, packaging, and verification finish before approval. The publish job then authenticates and runs cargo publish --locked --no-verify, which repackages and uploads without building dependencies:
name: Releaseon: push: tags: - 'v*'jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false - run: cargo test --locked --all-features # oss-ci decides the actual command from CONTRIBUTING.md (R-CI-02) - run: test "${GITHUB_REF_NAME#v}" = "$(cargo metadata --format-version 1 --no-deps | jq -er --arg n "<crate name>" '.packages[] | select(.name == $n) | .version')" - run: cargo publish --locked --dry-run
publish: runs-on: ubuntu-latest needs: [test] environment: release permissions: contents: read id-token: write attestations: write artifact-metadata: write steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: rust-lang/crates-io-auth-action@v1 id: auth - run: cargo publish --locked --no-verify env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} - uses: actions/attest@v4 with: subject-path: target/package/*.crateThe version check above names the crate in <crate name> and strips one leading v from the tag; derive both from what the repository actually does, since a workspace publishes more than one name and a project tagging 1.2.3 has no prefix to strip. jq -e is what makes an unmatched name fail the step rather than compare the tag against an empty string. jq is preinstalled on GitHub-hosted Ubuntu runners, and on GitLab it comes from the release image the token exchange already requires. Use the package selector and version source discovered in Step 1 for workspaces. The publish command creates the exact .crate it uploads under target/package/; the following attestation therefore covers the uploaded bytes. --no-verify is safe only because the required dry run already succeeded for the same tag commit and toolchain.
--locked on every Cargo invocation above is R-SEC-08, whose Check: asks that every CI install use the package manager’s current frozen mode. It asserts the same dependencies and versions as when Cargo.lock was generated and exits non-zero rather than updating the lockfile, so a workflow this skill writes without it fails a rule the kit owns and an audit run afterwards will score it. Use --locked rather than --frozen, which Cargo documents as --locked plus --offline, unless the job deliberately runs with no network.
oss-harden pins every uses: line above to a commit SHA and sets the test job’s minimal permissions; this skill includes the publish permissions required for authentication and provenance. On GitLab CI/CD, give the publish job the id_tokens block above, source exchange-token.sh, then run cargo publish --locked --no-verify, restricted to version tags with rules:. The preceding uncredentialed job must run the same version check and cargo publish --locked --dry-run.
If an existing workflow reads a CARGO_REGISTRY_TOKEN from repository secrets, remove that now and tell the user to delete the secret and revoke the token on crates.io once the new flow is verified.
Gate on manual approval (Step 4)
Section titled “Gate on manual approval (Step 4)”Pin the publish job to environment: release as above, and create that environment at https://github.com/<owner>/<repo>/settings/environments/new with required reviewers, or, on GitLab Premium or Ultimate, as a protected environment with approval rules. GitHub required reviewers work for public repositories on current plans; private or internal repositories need GitHub Enterprise Cloud. crates.io has no registry-side approval fallback, so report R-PUB-04 as unmet when the forge plan provides no native gate.
Create it with the API rather than the form. Reviewers and the tag policy are both settable, so nothing here needs a browser.
ENV=releaseGHUID=$(gh api user --jq .id)gh api -X PUT "repos/{owner}/{repo}/environments/$ENV" \ -F wait_timer=0 \ -F prevent_self_review=false \ -f 'reviewers[][type]=User' -F "reviewers[][id]=$GHUID" \ -F 'deployment_branch_policy[protected_branches]=false' \ -F 'deployment_branch_policy[custom_branch_policies]=true'gh api -X POST "repos/{owner}/{repo}/environments/$ENV/deployment-branch-policies" \ -f 'name=v*' -f type=tagThree details decide whether that runs. gh api substitutes {owner} and {repo} from the checkout it runs in. Use -F for the booleans and the reviewer id, because -f sends every value as a string and the endpoint rejects a quoted boolean. Do not name the shell variable UID: zsh marks it read only, so the assignment fails before gh runs.
reviewers[][id] takes a numeric user or team id rather than a login. A team needs type=Team and that team’s id.
Verify provenance (Step 5): a gap, not a check
Section titled “Verify provenance (Step 5): a gap, not a check”crates.io publishes no build provenance for this skill to verify against: no cryptographic signature on a published crate, no attestation object, and nothing comparable to npm’s npm audit signatures or PyPI’s PEP 740 integrity endpoint. Read the trusted publishing documentation linked above before reporting that, and treat the closed RFC below as a rejected proposal rather than a pending one. Trusted publishing itself still gives real value here, an OIDC-verified link between the publish action and the repository and workflow that ran it, but that link lives in crates.io’s internal audit trail, not in anything the registry serves back for a consumer to check.
Source: rust-lang/rfcs#3403, Sigstore-based signing for crates.io (closed, not merged).
The strongest substitute on GitHub is the actions/attest step in the workflow above. This needs the gh CLI installed and authenticated against the host holding the repository; gh auth status prints the active account per host. Download the exact published .crate, then verify it:
gh attestation verify <name>-<version>.crate --repo <owner>/<repo>crates.io does not surface or link to that attestation. GitLab CI/CD has no equivalent forge attestation in this skill’s scope, so R-PUB-03 remains unmet there. Report both limitations plainly; trusted publishing proves the publishing workflow’s identity, not the provenance of the uploaded bytes.
Describe and sign what the release attaches (Step 6)
Section titled “Describe and sign what the release attaches (Step 6)”Only for a release that attaches a built asset to the forge release. The .crate this workflow uploads is attested in the publish job in Step 3, and the source archives GitHub generates for a tag are not built assets. What this section covers is compiled binaries, which a crate shipping a command-line tool commonly attaches and a library crate does not.
Build those binaries in an uncredentialed job that carries no registry token, alongside the test job, and hand them on as a workflow artifact. Do not build them in the publish job: everything running there could publish the crate, which is why Step 3 keeps that job down to the token exchange and cargo publish.
Two rules apply here and they ask for different things. R-PUB-05 wants an inventory of what went into the asset, in SPDX or CycloneDX. R-PUB-06 wants the assets signed, or listed by hash in a signed manifest. A manifest of hashes answers the second and nothing about the first, so do not report R-PUB-05 as met by publishing one.
This reference names no SBOM generator for Rust. The ones in common use are third-party tools rather than part of the toolchain, and a tool that reads the dependency tree inside the release workflow is one the maintainer vets before it goes there. What answers R-PUB-05 without one, on GitHub, is the forge’s own export of the repository’s dependency graph, which is already SPDX and needs nothing installed. The gh api step below writes it into dist/, so it ships as a release asset for R-PUB-05 and is listed in SHA256SUMS and attested alongside the binaries for R-PUB-06:
github-release: runs-on: ubuntu-latest needs: [publish] permissions: contents: write id-token: write attestations: write artifact-metadata: write steps: - uses: actions/download-artifact@v8 with: name: binaries path: dist/ - run: gh api repos/${{ github.repository }}/dependency-graph/sbom --jq .sbom > dist/sbom.spdx.json env: GH_TOKEN: ${{ github.token }} - run: (cd dist && sha256sum *) > SHA256SUMS - uses: actions/attest@v4 with: subject-checksums: SHA256SUMS - run: gh release upload "$GITHUB_REF_NAME" dist/* SHA256SUMS env: GH_TOKEN: ${{ github.token }}State both of the export’s limits to the maintainer rather than leaving them to be discovered. It is GitHub only, so a GitLab project keeps this gap and R-PUB-05 stays unmet there with the reason named. And it covers the repository’s declared dependency graph rather than what is linked into the asset, which is close for a Rust binary, because the crates linked into it come from that same graph, and silent about the system libraries it links against. The graph resolves past the direct dependencies only where a lockfile is committed, which R-SEC-08 already requires. Read the output back once with gh api repos/<owner>/<repo>/dependency-graph/sbom --jq '.sbom.packages | length' before reporting the rule met: a graph the forge does not parse for this ecosystem returns a near-empty package list rather than an error.
sha256sum runs from inside dist/ so the names it writes are the names the assets carry on the release. A manifest listing dist/tool-x86_64-linux cannot be checked against a downloaded tool-x86_64-linux.
subject-checksums makes every file listed in SHA256SUMS a subject of the attestation in its own right, by name and digest, and it takes the format sha256sum writes. Attesting SHA256SUMS itself with subject-path instead would leave a consumer able to verify the manifest and nothing about the assets it lists. This matters more here than elsewhere, because a downloaded binary is the whole of what most consumers of a tool crate ever run.
A consumer verifies an asset, then checks the rest of the download against the manifest:
gh attestation verify <asset> --repo <owner>/<repo>sha256sum -c SHA256SUMSRun the first command against each asset downloaded, never against SHA256SUMS, which is a subject of nothing. It proves that this workflow in this repository produced those exact bytes, and it prints the signing workflow it accepted; --signer-workflow <owner>/<repo>/.github/workflows/release.yml pins that, so an attestation from any other workflow in the repository fails. The second command is what a consumer without gh has. It proves the files match a list published beside them, and nothing about who produced either.
gh release upload fails when no release exists for the tag. Either create it in this job with gh release create "$GITHUB_REF_NAME" --generate-notes before the upload, or have the maintainer publish the release from the tag first. This reference does not choose between them, because release notes are the project’s own.
The four grants above are copied exactly, on this job only, and needs: [publish] is what keeps the assets behind the approval gate. The workflow’s top-level block stays contents: read. Narrowing anything else, pinning each uses: to a commit SHA, and auditing the result are oss-harden’s.
On GitLab CI/CD none of this applies: the forge attestation above is GitHub’s. A GitLab release can carry the same SHA256SUMS, generated the same way, but nothing signs it, so say that rather than presenting the file as provenance.
Step 7 is in SKILL.md: read each R-PUB rule’s Check: line against what this file produced, and fix what fails before reporting done.
Verified 2026-07-31 against the source of the crates.io trusted publishing docs page, rust-lang/crates.io, svelte/src/routes/docs/trusted-publishing. Read it there rather than at https://crates.io/docs/trusted-publishing, which renders client side and returns an empty shell to anything that fetches it.
Version sources (R-CHG-03)
Section titled “Version sources (R-CHG-03)”Cargo.toml’s [package] version is the source for a single crate. In a workspace the number usually lives once, in the root manifest’s [workspace.package] table, with each member writing version.workspace = true to inherit it; there the root is the source and a member manifest states nothing to compare.
Cargo.lock sits in the workspace root and is shared by every member, so a repository that commits it, which an application does and a library usually does not, has one more file that moves when a version does.
A workspace whose members set their own versions has several release units. Check each member against its own tag and its own changelog rather than expecting one number across the repository.
Version syntax (R-CHG-02)
Section titled “Version syntax (R-CHG-02)”Cargo versions are SemVer, and the deviation is in what counts as compatible rather than in how the string is spelled. Cargo uses the convention that only changes in the left-most non-zero component are incompatible, and its own guide states the consequence for initial development: releases starting 0.y.z treat a change in y as a major release and a change in z as a minor one, and every 0.0.z release is a major change.
That is the same shape R-CHG-02 already applies before 1.0.0, so a crate following Cargo’s convention and a crate following this standard reach the same bump. What Cargo adds is that the resolver enforces it: a caret requirement written against a 0.1 crate will not select 0.2, so an incompatible change shipped as 0.1.x reaches users who asked not to receive one.
Major version in package identity (R-CHG-07)
Section titled “Major version in package identity (R-CHG-07)”crates.io does not encode the major version in package identity. A crate name is stable across majors and a single crate holds every version of it, so R-CHG-07 does not reach this ecosystem. What carries the major instead is the requirement a consumer writes, which is why the compatibility convention above is the thing to get right.
Withdrawing a release (R-CHG-01)
Section titled “Withdrawing a release (R-CHG-01)”cargo yank --version <version> removes the version from the registry’s index. Cargo states plainly what that does not do: “This command does not delete any data, and the crate will still be available for download via the registry’s download link”, and “Existing lock files or direct downloads are not affected”. What changes is selection, because Cargo will not use a yanked version for any new project or checkout without a pre-existing lockfile. cargo yank --undo puts the version back into the index.
Two consequences worth stating to a maintainer. Yanking is not a recall: bytes already fetched stay fetched, and the guide says yanking cannot stop further spreading. And crates.io offers no deletion, so the version number stays taken and the fix ships as a new version.
[YANKED] on the changelog heading maps to a yank, and comes off again if the yank is undone.
Verified 2026-07-31 against cargo yank, SemVer compatibility, and Workspaces.