Skip to content

Publish Rust client to crates.io #5

Publish Rust client to crates.io

Publish Rust client to crates.io #5

Workflow file for this run

name: Publish Rust client to crates.io
# Language-specific publish workflow for the Rust client. It is triggered manually only
# (workflow_dispatch) so a maintainer deliberately decides when a release is cut. Publishing
# to the crates.io registry is the language-specific distribution standard for Rust.
#
# In addition to publishing the crate, this workflow tags the released commit and creates a
# matching GitHub release. The release tag is derived from the single source of truth (the
# `version` field in Cargo.toml, which is also what `CLIENT_VERSION` reads via CARGO_PKG_VERSION)
# so the published crate version, the git tag, and the in-code client version can never disagree.
#
# Authentication to crates.io uses Trusted Publishing (OIDC) via `rust-lang/crates-io-auth-action`,
# the registry's recommended mechanism — no long-lived crates.io API token is stored as a secret.
# A Trusted Publisher must be configured for the crate on crates.io (this repository + this
# workflow file). The only repository secret still used is the built-in GITHUB_TOKEN, for pushing
# the tag and creating the GitHub release.
on:
workflow_dispatch:
inputs:
dry_run:
description: 'Run all checks but do not publish, create/push the tag, or create the release.'
type: boolean
default: false
# Never allow two publish runs to race; a half-finished publish to a registry or a duplicate
# release tag is hard to undo.
concurrency:
group: rust-publish
cancel-in-progress: false
# The default GITHUB_TOKEN needs write access to push the release tag and create the GitHub
# release. `id-token: write` lets the job mint a short-lived OpenID Connect token that
# `rust-lang/crates-io-auth-action` exchanges for an ephemeral crates.io publish token
# (Trusted Publishing) — so no long-lived crates.io registry secret is stored in the repo.
permissions:
contents: write
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history so the tag points at the real commit and tag lookups work.
fetch-depth: 0
# A published crate version and its release tag are immutable, so a release must only ever
# be cut from master. Refuse to run from any other ref, even when dispatched manually, so a
# maintainer cannot accidentally publish an unmerged feature-branch commit as a version.
- name: Require master branch
run: |
if [ "${GITHUB_REF}" != "refs/heads/master" ]; then
echo "::error::Publishing is only allowed from master, but this run is on '${GITHUB_REF}'."
exit 1
fi
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo registry and build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.toml') }}
# Gate the release on the same quality bar as CI so a broken build can never be published.
- name: Check formatting
run: cargo fmt --all -- --check
- name: Clippy (deny warnings)
run: cargo clippy --all-targets -- -D warnings
- name: Build
run: cargo build --verbose
# Derive the release tag from the single source of truth (Cargo.toml `version`, which is also
# what CLIENT_VERSION exposes via CARGO_PKG_VERSION). Keeping the tag in lock step with the
# crate version means consumers who read the constant and consumers who pull a tag always see
# the same version.
- name: Resolve version from Cargo.toml
id: version
run: |
version=$(cargo metadata --no-deps --format-version 1 \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["version"])')
if [ -z "${version}" ]; then
echo "::error::Could not read the crate version from Cargo.toml."
exit 1
fi
if ! echo "${version}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Crate version '${version}' is not a bare semver (X.Y.Z)."
exit 1
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tag=v${version}" >> "$GITHUB_OUTPUT"
echo "Resolved release tag: v${version}"
# Fail early (before any publish or tag creation) if this version was already released, so a
# publish run can never silently no-op or clobber an existing release.
- name: Ensure tag does not already exist
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "::error::Tag ${TAG} already exists locally."
exit 1
fi
if git ls-remote --exit-code --tags origin "${TAG}" >/dev/null 2>&1; then
echo "::error::Tag ${TAG} already exists on origin; bump the version in Cargo.toml first."
exit 1
fi
# Always verify packaging works (this also runs as part of `cargo publish`, but doing it
# explicitly surfaces packaging problems before any registry interaction or tag creation).
# No registry credentials are needed for `--dry-run`: it only packages and verifies the
# crate locally without contacting the registry for authentication.
- name: Verify package (dry run)
run: cargo publish --dry-run --verbose
# Ordering rationale (deliberate): the git tag and GitHub release are created and pushed
# BEFORE `cargo publish`. Unlike Go — where pushing the tag *is* the publish, so there is one
# atomic step — Rust has two irreversible-ish actions (push a tag, publish to crates.io) that
# cannot be made a single atomic transaction. We tag first because:
# * The git tag is the canonical, immutable "this commit is version X" marker; deriving it
# and the GitHub release from the source-of-truth version up front guarantees they always
# agree with each other and with the crate version.
# * A failure AFTER tagging but BEFORE publish is cleanly recoverable: delete the just-pushed
# tag and release, then re-run. The "Ensure tag does not already exist" guard makes that
# re-run safe and refuses to clobber.
# * A failure AFTER publish (the genuinely unrecoverable step, since a crates.io version can
# never be re-published) then leaves the tag and release already in place and consistent,
# rather than a published crate with no tag/release. Recovery is just re-running the two
# git/release steps, never the publish.
# If `cargo publish` fails here, recover by deleting the tag/release and bumping the version,
# or by manually completing the publish for the existing tag.
# Tag the released commit so the crate version, git tag, and GitHub release all line up.
- name: Create and push release tag
if: ${{ github.event.inputs.dry_run != 'true' }}
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "${TAG}" -m "Release ${TAG}"
git push origin "${TAG}"
# Extract the matching CHANGELOG section so the GitHub release page shows the actual changes
# for this version rather than a static pointer. Falls back to a one-liner if the section is
# missing, so a forgotten CHANGELOG entry never blocks the release.
- name: Build release notes from CHANGELOG
if: ${{ github.event.inputs.dry_run != 'true' }}
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
# Match the heading as a literal string (index) so the version's dots and brackets are
# not interpreted as a regex/character class.
notes=$(awk -v ver="## [${VERSION}]" '
index($0, ver)==1 {capture=1; next}
capture && /^## / {exit}
capture {print}
' CHANGELOG.md)
if [ -z "$(echo "${notes}" | tr -d '[:space:]')" ]; then
notes="Apify Rust client v${VERSION}. See CHANGELOG.md for details."
fi
{
echo "RELEASE_NOTES<<__EOF__"
echo "${notes}"
echo "__EOF__"
} >> "$GITHUB_ENV"
# Create a GitHub release for the tag. Uses the default GITHUB_TOKEN from repository secrets;
# no personal access token is needed.
# Idempotent: create the release, or update it if one already exists for the tag. This keeps a
# re-run safe in the post-tag/pre-publish recovery path (see the ordering rationale above): if
# a maintainer re-pushes the tag after a partial failure, the release step will not hard-fail
# on an already-existing release.
- name: Create GitHub release
if: ${{ github.event.inputs.dry_run != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
run: |
if gh release view "${TAG}" >/dev/null 2>&1; then
gh release edit "${TAG}" \
--title "${TAG}" \
--notes "${RELEASE_NOTES}"
else
gh release create "${TAG}" \
--title "${TAG}" \
--notes "${RELEASE_NOTES}"
fi
# Trusted Publishing: exchange the GitHub Actions OIDC identity for a short-lived crates.io
# token instead of storing a long-lived registry secret. The action sets a `token` output
# and automatically revokes it in its post step when the job finishes. This requires a
# matching Trusted Publisher to be configured for the crate on crates.io (organization,
# repository and workflow file name).
- name: Authenticate to crates.io (Trusted Publishing)
id: auth
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: rust-lang/crates-io-auth-action@v1
- name: Publish to crates.io
# Skip the actual publish when the run was dispatched as a dry run.
if: ${{ github.event.inputs.dry_run != 'true' }}
env:
# Short-lived token minted by Trusted Publishing (no stored registry secret).
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
run: cargo publish --verbose