Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ on:
description: Tag whose binaries to rebuild; this path never publishes
required: true

# Only the job that creates the GitHub release writes anything; everything
# else, including the job holding the registry token, only reads.
# Only the job that creates the GitHub release writes to the repository.
# The publish job also asks for id-token: write, which grants it nothing
# here: it is what lets the job request the OIDC token crates.io trades for
# a short-lived registry token.
permissions:
contents: read

Expand Down Expand Up @@ -211,8 +213,11 @@ jobs:
if: github.event_name == 'push'
needs: [resolve, prepare, release]
runs-on: ubuntu-latest
# Holds the registry token, so it can be gated on an approval.
environment: crates-io
permissions:
# Checkout and the tag lookup, as everywhere else in this workflow.
contents: read
# Only for the OIDC token; it is not a permission on the repository.
id-token: write
steps:
# The commit prepare packaged, not the tag: checkout resolves a tag
# afresh, so a force-push during an approval wait would land here.
Expand Down Expand Up @@ -258,23 +263,30 @@ jobs:
fi
echo "crate matches the prepared one: $got"

# Says whether the crate can be published at all, before the token is
# used for anything, so "cannot publish" and "upload failed" stay
# distinguishable in the log.
# Says whether the crate can be published at all, before a token
# exists, so "cannot publish" and "upload failed" stay distinguishable
# in the log.
- name: Dry run
run: cargo publish --locked --dry-run

# Again, here: between the release job's check and this step lies the
# approval wait on the crates-io environment, which can be hours. An
# upload is the one step of this pipeline that cannot be taken back.
# Again, here: the whole cross-build matrix lies between the release
# job's check and this step, and an upload is the one step of this
# pipeline that cannot be taken back.
- name: Check the tag still points at this commit
env:
GH_TOKEN: ${{ github.token }}
run: .github/scripts/check-tag-target.sh
"${{ github.ref_name }}"
"${{ needs.resolve.outputs.source }}"

# crates.io mints the token from this job's OIDC identity, so nothing
# long-lived is stored anywhere. It is good for 30 minutes, which is
# why it is fetched here and not before the two cargo builds above;
# the action's post step revokes it when the job ends.
- id: auth
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5

- name: Publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
run: cargo publish --locked
48 changes: 29 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,8 @@ one chain and stops at the first step that fails:
out byte for byte the one step 3 validated. `cargo` cannot upload a
`.crate` it did not just build, so this comparison is what ties the two
together. A dry run goes first, so a crate that cannot be published is
distinguishable from an upload that failed.
distinguishable from an upload that failed. Only then does the job ask
crates.io for a publishing token, through Trusted Publishing.

Running the workflow by hand rebuilds the binaries for a tag that is already
out; that path never publishes.
Expand Down Expand Up @@ -332,24 +333,33 @@ date `--version` prints.

### crates.io credentials

One-time setup, and only the account that owns the crate can do it:

1. Create an API token at <https://crates.io/settings/tokens> with the
`publish-new` and `publish-update` scopes, restricted to the
`librespeed-cli` crate. Once the first version is out, `publish-update`
alone is enough.
2. Add an environment named `crates-io` under Settings → Environments and
store the token in it as the secret `CARGO_REGISTRY_TOKEN`. Required
reviewers on that environment make a release wait for an approval before
the token is handed out.

This is the setup for the first release only. Trusted Publishing is where
this should end up: crates.io mints a short-lived token for a workflow it
trusts, so the publish job needs no stored secret at all — it gains
`id-token: write` permission and takes its token from
`rust-lang/crates-io-auth-action@v1`. It can only be configured for a crate
that already exists, which is why the first version goes out with a token.
Once `librespeed-cli` is on the registry, switch and delete the secret.
There are none. The publish job authenticates with
[Trusted Publishing](https://crates.io/docs/trusted-publishing): it asks
GitHub for an OIDC token naming the repository and the workflow file, and
`rust-lang/crates-io-auth-action` trades that with crates.io for a registry
token that lasts 30 minutes and is revoked when the job ends. That is what
`id-token: write` on the job is for; it grants nothing in this repository.

The crate side is one-time setup, and only an owner of the crate can do it,
under the crate's Settings → Trusted Publishing → Add, publisher GitHub:

| Field | Value |
| --- | --- |
| Repository owner | `librespeed` |
| Repository name | `speedtest-cli-rust` |
| Workflow filename | `release.yml` |
| Environment name | leave empty |

The environment field is optional, and left empty it mints a token for any
run of that workflow on this repository. Naming one would narrow that
further, but only an environment with protection rules is worth the second
place to keep in step, and configuring those needs repository admin.

Trusted Publishing cannot be configured for a crate that does not exist yet,
so the first version went out with an API token from
<https://crates.io/settings/tokens>, stored on the `crates-io` environment as
the secret `CARGO_REGISTRY_TOKEN`. Nothing reads that secret any more and it
can be deleted.

## License

Expand Down
Loading