From d0551802ee5c7837ec5606db657725d1949a5ddc Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:15:26 +0700 Subject: [PATCH] ci: a one-shot bootstrap workflow for termlens-cli's first publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trusted Publishing is configured per crate, on a crate that already exists, so the first publish of a new name has nothing to configure it against. release.yml knows this — it publishes termlens-cli only once crates.io answers 200 for it and warns otherwise — which leaves exactly one publish that has to happen another way. This is that publish and nothing else: dispatch-only, a typed confirmation because the action cannot be undone, checkout of a tag rather than a branch, the same tag-matches-version guard release.yml puts in front of every publish, and `--locked` so what goes out is the tested tree. Its verify build resolves `termlens` from crates.io rather than the path, which is what a consumer does. Deliberately not wired to the `release` environment. That environment exists so an OIDC token cannot be minted from a branch and its policy admits only `v*` refs; CARGO_REGISTRY_TOKEN is a repository secret, so the environment would add no protection here while blocking the dispatch. zizmor says at pedantic that this should use Trusted Publishing. It is right, and it is the one workflow that cannot — recorded in .github/zizmor.yml with that rationale rather than waved through. The header carries the teardown: link Trusted Publishing for termlens-cli, revoke the token, delete the secret, delete this file. Every release after that goes through release.yml with no secret at all. Refs #255 Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- .github/workflows/publish-cli.yml | 83 +++++++++++++++++++++++++++++++ .github/zizmor.yml | 11 ++++ 2 files changed, 94 insertions(+) create mode 100644 .github/workflows/publish-cli.yml diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml new file mode 100644 index 0000000..3a29820 --- /dev/null +++ b/.github/workflows/publish-cli.yml @@ -0,0 +1,83 @@ +# One-shot bootstrap: the first `termlens-cli` publish (#255). +# +# Every other publish in this repository is crates.io Trusted Publishing — +# release.yml exchanges a job's OIDC token for a short-lived one and no +# secret is stored. That cannot be used here: Trusted Publishing is +# configured per crate, on a crate that already exists, so the first publish +# of a new name has nothing to configure it against. release.yml therefore +# publishes termlens-cli only when crates.io already knows it, and warns +# otherwise. +# +# This workflow closes that gap exactly once, with a token, from a tag. Once +# it has run: link Trusted Publishing for termlens-cli (crates.io → +# termlens-cli → Settings → Trusted Publishing → GitHub, repository +# vyncint/termlens, workflow release.yml, environment release), then revoke +# the token, delete the CARGO_REGISTRY_TOKEN secret and delete this file. +# Every release after that goes through release.yml with no secret at all. +# +# Deliberately not wired to the `release` environment: that environment +# exists so an OIDC token cannot be minted from a branch, and its policy +# admits only `v*` refs. CARGO_REGISTRY_TOKEN is a repository secret, so the +# environment would add no protection here while blocking the dispatch. +name: publish-cli (bootstrap) + +on: + workflow_dispatch: + inputs: + ref: + description: The tag to publish from. Its version must match the manifest. + required: true + default: v0.10.0 + confirm: + description: Type the crate name to confirm this irreversible publish. + required: true + default: "" + +permissions: + contents: read + +concurrency: + group: publish-cli + cancel-in-progress: false + +jobs: + publish: + name: publish termlens-cli + runs-on: ubuntu-latest + steps: + - name: Refuse an unconfirmed dispatch + env: + CONFIRM: ${{ inputs.confirm }} + run: | + if [ "$CONFIRM" != "termlens-cli" ]; then + echo "::error::type 'termlens-cli' in the confirm box; a publish cannot be undone" + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 + with: + toolchain: stable + # The same guard release.yml puts in front of every publish: the crate + # that goes out must be the version the tag names, or the registry gets + # a number that means nothing. + - name: Compare the ref against the crate version + env: + REF: ${{ inputs.ref }} + run: | + tag="${REF#v}" + version="$(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[] | select(.name == "termlens-cli") | .version')" + echo "ref=${tag} crate=${version}" + if [ "$tag" != "$version" ]; then + echo "::error::${REF} does not match termlens-cli ${version}" + exit 1 + fi + # --locked so the published crate is the tested tree, not whatever the + # index offers today. The verify build resolves `termlens` from + # crates.io rather than the path, which is what a consumer will do. + - run: cargo publish -p termlens-cli --locked + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/zizmor.yml b/.github/zizmor.yml index b6dabed..9bbcf87 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -14,3 +14,14 @@ rules: - release.yml - install.yml - windows.yml + - publish-cli.yml + + # Correct advice that this one workflow cannot take, which is the whole + # reason it exists. Trusted Publishing is configured per crate on a crate + # that already exists, so the *first* publish of a new name has nothing to + # configure it against and must use a token. Every other publish here is + # Trusted Publishing (release.yml), and this file is deleted along with the + # secret once crates.io knows termlens-cli. + use-trusted-publishing: + ignore: + - publish-cli.yml