From 2d4002a56697f4ee0875b7ba32fefaeaf4e37417 Mon Sep 17 00:00:00 2001 From: Zack Maril Date: Sun, 30 Aug 2026 07:22:46 -0400 Subject: [PATCH] ci: take the fleet's release workflow jawohl publishes to crates.io by hand today, which is why 0.2.0 has been sitting unreleased. This is the fleet's release workflow and publish script, arriving through ordnung rather than written here. A tag is the whole trigger: the workflow checks it against Cargo.toml, drafts a GitHub release, and publishes the crate. The archive, checksum and install-smoke jobs skip themselves, because jawohl has no binary target for them to work on. Publishing authenticates over OIDC through crates.io Trusted Publishing, so no registry token is stored here. That needs two things outside this PR: a `crates-io` environment in this repository's settings, and a trusted publisher on crates.io naming this workflow file and that environment. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0144PU3MssnfbM5tZiqXW7d3 --- .github/workflows/release.yml | 225 ++++++++++++++++++++++++++++++++++ scripts/publish.sh | 137 +++++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100755 scripts/publish.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1f5125a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,225 @@ +name: release (rust) + +# A tag is the whole trigger. Everything the installer reads — the archives, the +# checksums, and the "latest" pointer — is produced here, so a release is never +# assembled by hand. +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: Existing tag to build a release for + required: true + +permissions: + contents: read + +# Never run two releases at once, and never cancel one in flight: a cancelled +# `cargo publish` can leave a version uploaded that can never be reused. +concurrency: + group: release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +env: + TAG: ${{ inputs.tag || github.ref_name }} + +jobs: + # The release is created as a draft and published only after every archive and + # the checksum file are in place. A draft is not returned by the "latest" + # endpoint, so `install.sh` can never see a half-uploaded release. + draft: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + outputs: + # What this repository publishes, answered where the checkout is. Neither + # can be a job-level `if: hashFiles(...)`: that reads a workspace which + # does not exist before checkout, and a workflow whose expression cannot + # be evaluated does not start at all — no jobs, no release, on a tag push + # nobody is watching. + crate: ${{ steps.publishes.outputs.crate }} + binary: ${{ steps.publishes.outputs.binary }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.TAG }} + - id: publishes + run: | + if [ -f scripts/publish.sh ]; then + echo "crate=true" >> "$GITHUB_OUTPUT" + else + echo "crate=false" >> "$GITHUB_OUTPUT" + echo "no scripts/publish.sh; this release ends at the smoke test" + fi + # A library crate has nothing to put in an archive, nothing to + # install, and nothing to smoke-test. Cargo is asked rather than the + # file tree, because a binary target can be declared several ways. + if cargo metadata --no-deps --format-version 1 | + jq -e '[.packages[].targets[] | select(any(.kind[]; . == "bin"))] | length > 0' >/dev/null; then + echo "binary=true" >> "$GITHUB_OUTPUT" + else + echo "binary=false" >> "$GITHUB_OUTPUT" + echo "no binary targets; this release is a crate publish only" + fi + - name: Check the tag against Cargo.toml + run: | + crate=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1) + if [ "$TAG" != "v${crate}" ]; then + echo "tag ${TAG} does not match Cargo.toml version ${crate}" >&2 + exit 1 + fi + - name: Create the draft release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + if ! gh release view "$TAG" >/dev/null 2>&1; then + gh release create "$TAG" --draft --title "$TAG" --generate-notes + fi + + build: + needs: draft + if: needs.draft.outputs.binary == 'true' + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-apple-darwin + os: macos-latest + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.TAG }} + # Both Linux targets cross-compile from an ordinary x86_64 host, because + # .cargo/config.toml links them with rust-lld against a self-contained + # musl. No cross toolchain, container, or apt package is involved. + - run: rustup target add ${{ matrix.target }} + # No build cache here, deliberately: a cache is writable from other + # workflows, and these binaries are the release. Cold builds are the + # price of artifacts nothing else could have written to (zizmor's + # cache-poisoning audit). + - run: cargo build --release --locked --target ${{ matrix.target }} + - name: Package + run: | + stage=$(mktemp -d) + cp "target/${{ matrix.target }}/release/jawohl" "$stage/" + cp README.md LICENSE "$stage/" + tar -czf "jawohl-${TAG}-${{ matrix.target }}.tar.gz" -C "$stage" \ + jawohl README.md LICENSE + - name: Upload + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: gh release upload "$TAG" "jawohl-${TAG}-${{ matrix.target }}.tar.gz" --clobber + + # Runs for every release, because un-drafting is not a binary concern: a + # library's release would otherwise stay a draft forever. Only the archives + # are conditional. `always()` is what lets a skipped `build` through; without + # it a skipped dependency skips this job too. + publish: + needs: [draft, build] + if: >- + always() && needs.draft.result == 'success' + && (needs.build.result == 'success' || needs.build.result == 'skipped') + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + # The checksums are computed from what the release actually holds rather + # than from what each build job believes it uploaded. + - name: Checksum every archive + if: needs.draft.outputs.binary == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + gh release download "$TAG" --pattern '*.tar.gz' --dir assets + cd assets && sha256sum ./*.tar.gz | sed 's|\./||' > SHA256SUMS + cat SHA256SUMS + gh release upload "$TAG" SHA256SUMS --clobber + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: gh release edit "$TAG" --draft=false --latest + + # Install the release that was just published, on every supported platform, + # through both the script and the action. Called rather than triggered: a + # release published by GITHUB_TOKEN raises no event that starts a workflow. + smoke: + needs: [draft, publish] + if: >- + always() && needs.publish.result == 'success' + && needs.draft.outputs.binary == 'true' + permissions: + contents: read + uses: ./.github/workflows/install-smoke.yml + with: + version: ${{ inputs.tag || github.ref_name }} + + # Publish the same commit to crates.io, after the binaries have been proven + # to install and run. crates.io is the one place a version can never be + # replaced, so it goes last and everything checkable is checked first. + # + # The logic lives in scripts/publish.sh so the same path can be run by hand, + # and so this job is a caller rather than a second definition of it. + crate: + needs: [draft, publish, smoke] + # Only repositories that publish a crate carry the script; everyone else + # ends at the smoke test. The answer comes from the draft job, which has + # the checkout — see the note on its outputs. + # + # A skipped smoke job means either "no binary in this repository" or "the + # binary never got that far", and those must not read alike. `publish` + # tells them apart: it succeeds when the archives are up or when there were + # none to build, and is skipped when the build failed. + if: >- + always() && needs.draft.outputs.crate == 'true' + && needs.publish.result == 'success' + && (needs.smoke.result == 'success' || needs.smoke.result == 'skipped') + runs-on: ubuntu-latest + timeout-minutes: 20 + # One place to require a human approval before an irreversible publish: + # Settings > Environments > crates-io > Required reviewers. + environment: crates-io + permissions: + id-token: write + env: + # `secrets` cannot be read from an `if`, so the question of whether one + # exists is answered here, where it can. + HAS_STORED_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN != '' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.tag || github.ref_name }} + # Trusted publishing is the normal path and needs no secret. The stored + # token exists only to bootstrap: a crate's first version cannot be + # published this way, because crates.io will not attach a trusted + # publisher to a crate that does not exist yet. Delete the secret once + # the trusted publisher is configured and this step takes over again. + - uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 + id: auth + if: env.HAS_STORED_TOKEN == 'false' + - name: Publish + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token || secrets.CARGO_REGISTRY_TOKEN }} + run: | + if [ "${HAS_STORED_TOKEN}" = "true" ]; then + echo "publishing with the stored bootstrap token; delete it once trusted publishing is configured" + else + echo "publishing with a short-lived trusted-publishing token" + fi + scripts/publish.sh --execute diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 0000000..826c7a2 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Publish jawohl to crates.io. +# +# Publishing is irreversible: a version can be yanked but never deleted, and a +# name/version pair can never be reused. The shape of this script follows from +# that. +# +# - Dry run is the default. Uploading takes --execute. +# - A version the registry already has is skipped rather than attempted, so +# re-running a release for an existing tag is a no-op instead of a failure. +# - Existing versions are read from the sparse index rather than the web API, +# because the index is what cargo itself resolves against, and because +# --index lets the whole path be rehearsed against a local registry. +# - The version is whatever Cargo.toml says. Nothing here computes or bumps +# it; the release tag is checked against it in the release workflow. +# +# Usage: +# scripts/publish.sh # dry run: package and verify +# scripts/publish.sh --execute # real publish; needs a token +# scripts/publish.sh --execute --registry local --index ./idx +# +# --dry-run package and compile the tarball; never uploads (default) +# --execute actually publish +# --registry NAME publish to a cargo registry other than crates.io +# --index URL where to enumerate existing versions; an https:// base or a +# local sparse-index directory. Defaults to index.crates.io. +# --allow-dirty package with uncommitted changes present +# +# Environment: +# CARGO_REGISTRY_TOKEN required for --execute against crates.io. +# CARGO_REGISTRIES__TOKEN ... or for --execute --registry . +# Neither is ever logged. +# +# Fleet-managed by conf (.ordnung/managed/publishing/rust/publish.sh): edit it +# there. The crate name is substituted from the repository name, so a crate +# named differently from its repository needs a copy of its own. +# +# straitjacket-allow-file:no-comments — this is the procedure for the one +# action in the repository that cannot be undone, and sh has no +# documentation-comment syntax to hoist the reasoning into. +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +CRATE=jawohl +MODE=dry-run +REGISTRY="" +INDEX_BASE="https://index.crates.io" +ALLOW_DIRTY=0 + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) MODE=dry-run ;; + --execute) MODE=execute ;; + --registry) REGISTRY=${2:?--registry needs a name}; shift ;; + --index) INDEX_BASE=${2:?--index needs a url or directory}; shift ;; + --allow-dirty) ALLOW_DIRTY=1 ;; + -h|--help) sed -n '2,33p' "${BASH_SOURCE[0]}"; exit 0 ;; + -*) echo "publish: unknown flag $1" >&2; exit 2 ;; + *) echo "publish: unexpected argument $1" >&2; exit 2 ;; + esac + shift +done + +cd "$ROOT" + +VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1) +[ -n "$VERSION" ] || { echo "publish: no version in Cargo.toml" >&2; exit 1; } + +# cargo reads the token for a named registry from CARGO_REGISTRIES__TOKEN, +# and only the default registry from CARGO_REGISTRY_TOKEN. +if [ -n "$REGISTRY" ]; then + reg_upper=${REGISTRY^^}; reg_upper=${reg_upper//-/_} + TOKEN_VAR="CARGO_REGISTRIES_${reg_upper}_TOKEN" +else + TOKEN_VAR=CARGO_REGISTRY_TOKEN +fi +if [ "$MODE" = execute ] && [ -z "${!TOKEN_VAR:-}" ]; then + cat >&2 < Secrets and variables > Actions > New repository secret + Name: CARGO_REGISTRY_TOKEN + Value: a crates.io API token with publish-new, scoped to $CRATE + + Once the crate exists on crates.io, configure Trusted Publishing instead + and delete that secret: the release workflow then authenticates over OIDC + with a token that lives under an hour. See notes/field_guide.md. +MSG + exit 1 +fi + +# The sparse index lays a name out as //, and each +# line is one published version. +index_path() { + local name=$1 + case ${#name} in + 1) printf '1/%s\n' "$name" ;; + 2) printf '2/%s\n' "$name" ;; + 3) printf '3/%s/%s\n' "${name:0:1}" "$name" ;; + *) printf '%s/%s/%s\n' "${name:0:2}" "${name:2:2}" "$name" ;; + esac +} + +# Whether the registry already carries this version. +# +# A missing entry is the normal case for a first publish and is not an error; +# anything else that goes wrong reads as "not published", and cargo refuses the +# upload on its own if that guess was wrong. +already_published() { + local path body + path=$(index_path "$CRATE") + if [ -d "$INDEX_BASE" ]; then + [ -f "${INDEX_BASE}/${path}" ] || return 1 + body=$(cat "${INDEX_BASE}/${path}") + else + body=$(curl -sSf "${INDEX_BASE}/${path}" 2>/dev/null) || return 1 + fi + # A herestring rather than a pipe: `grep -q` exits at the first match, and + # under `set -o pipefail` the SIGPIPE that gives the writer would become the + # status of the whole pipeline, turning a hit into a miss. + grep -q "\"vers\"[[:space:]]*:[[:space:]]*\"${VERSION}\"" <<<"$body" +} + +if already_published; then + echo "publish: ${CRATE} ${VERSION} is already on the registry; nothing to do" + exit 0 +fi + +# Written as `if` rather than `test && ...`: under `set -e` a false test as a +# bare statement aborts the script. +args=(publish --locked) +if [ "$MODE" = dry-run ]; then args+=(--dry-run); fi +if [ -n "$REGISTRY" ]; then args+=(--registry "$REGISTRY"); fi +if [ "$ALLOW_DIRTY" = 1 ]; then args+=(--allow-dirty); fi + +echo "publish: ${MODE} ${CRATE} ${VERSION}" +cargo "${args[@]}"