diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bef841..c7ea73b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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. @@ -258,15 +263,15 @@ 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 }} @@ -274,7 +279,14 @@ jobs: "${{ 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 diff --git a/README.md b/README.md index 85cc7fd..69bc6ef 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 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 +, stored on the `crates-io` environment as +the secret `CARGO_REGISTRY_TOKEN`. Nothing reads that secret any more and it +can be deleted. ## License