Publishing

Publishing is plain cargo publish --registry acme. What matters is where it runs: from GitHub Actions, every version gets provenance you can verify.

Trusted publishing from GitHub Actions

Add a workflow that publishes when you push a version tag:

.github/workflows/publish.yml
name: publish
on:
  push:
    tags: ["v*"]
permissions:
  id-token: write
  contents: read
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: cargo-bins/cargo-binstall@<commit sha>   # pin to a commit
      - run: cargo binstall --no-confirm cargo-credential-privatecrates
      - run: cargo publish --registry acme

What happens when it runs:

  1. Cargo packages the crate and asks the credential provider for a publish token, passing the crate’s name, version and SHA-256 checksum.
  2. The provider requests an Actions OIDC token whose audience binds it to exactly those: https://acme.privatecrates.dev/publish/story_engine/0.2.0/<sha256>. It is used once and never cached.
  3. The registry accepts the upload only if the checksum of the bytes it received matches the audience, the job ran in the crate’s owning repository, and its workflow (and environment, if required) is allowed by the crate’s owners file.
  4. The crate is stored as an immutable GitHub release in your storage repository, together with the OIDC token as story_engine-0.2.0.provenance.jwt: a statement signed by GitHub, not by us, that this workflow run published exactly these bytes.

Because the token names one version and one checksum, it cannot be replayed to publish anything else. Publishing a workspace works the same way: Cargo asks for one token per crate. Many crates can live in one repository: each crate’s package.repository names that repository (a link to the crate’s directory, such as https://github.com/acme/mono/tree/main/crates/foo, also works), and each gets its own owners file.

A crate’s first publish

A new crate is first published from CI too, so its first version always has provenance. Its Cargo.toml must name a repository in your organisation, and the workflow must run in that repository:

Cargo.toml
[package]
name = "story_engine"
version = "0.2.0"
# The owning repository: it must be in your organisation.
# In a monorepo, a link to the crate's directory works too.
repository = "https://github.com/acme/story-engine"
# Publish only to your registry, never to crates.io by accident.
publish = ["acme"]

The registry then creates the crate’s owners file, with that repository as the owner and the publishing workflow as the only one allowed.

The owners file

Each crate has owners/<name>.toml in the storage repository. Changing who may publish is an administrator’s edit to this file, by pull request; there is deliberately no API for it.

owners/story_engine.toml
repository_id = 123456789               # survives renames
repository = "acme/story-engine"        # for humans
publish_workflows = ["publish.yml"]     # may publish
publish_environment = "crates"          # optional
allow_manual_publish = false            # the default
  • repository_id is authoritative, so renaming or transferring the repository inside the organisation breaks nothing.
  • publish_environment requires the job to run in a GitHub environment, so its protection rules apply: a required reviewer gives you a two-person rule, enforced by GitHub.

Publishing from a developer’s machine

By default it is refused, with instructions:

cargo publish output
error: story_engine is published from CI only, so every version has verifiable provenance.
       Add .github/workflows/publish.yml (see https://acme.privatecrates.dev/login#publish) and push a tag.

A crate can opt in with allow_manual_publish = true in its owners file. Then anyone with push access to the owning repository can publish from their machine. Those versions have no provenance, and the verifier and search label them as manual publishes.

Yanking

Anyone with push access to the owning repository can yank or unyank a version from their machine, without provenance: yanking has to be quick, changes no bytes and can be undone.

shell
cargo yank --registry acme --version 0.2.0 story_engine
cargo yank --registry acme --version 0.2.0 --undo story_engine

Versions are permanent

Every version is kept forever: Cargo needs yanked versions to stay downloadable for existing lockfiles, and immutable releases make the history tamper-proof. A version number can never be reused, so fix a bad release by publishing a new version.

Names that exist on crates.io

A private crate whose name also exists on crates.io is a dependency-confusion risk. Publishing such a name is refused unless name_clash = "warn" is set in privatecrates.toml. Someone could also claim your crate’s name on crates.io later, so the registry checks daily and the verifier reports clashes. For names that matter, reserve them on crates.io with a placeholder crate.