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
140 changes: 137 additions & 3 deletions .github/workflows/rust-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,49 @@ 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.
on:
workflow_dispatch:
inputs:
dry_run:
description: 'Run `cargo publish --dry-run` only (no actual release).'
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 is hard to undo.
# 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. The crates.io registry token is provided separately as a repository secret.
permissions:
contents: 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
Expand All @@ -47,6 +71,42 @@ jobs:
- 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

# Fail early with a clear message if the registry token is not configured, instead of
# letting `cargo publish` fail later with a less obvious authentication error.
- name: Require CARGO_REGISTRY_TOKEN secret
Expand All @@ -59,13 +119,87 @@ jobs:
fi

# Always verify packaging works (this also runs as part of `cargo publish`, but doing it
# explicitly surfaces packaging problems before any registry interaction).
# explicitly surfaces packaging problems before any registry interaction or tag creation).
- name: Verify package (dry run)
env:
# The crates.io registry token is stored as a repository secret.
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
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

- 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' }}
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@ All notable changes to the Rust Apify API client are documented here. The format
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres
to [Semantic Versioning](https://semver.org/).

## [0.2.3] - 2026-06-22

Publishing compliance for the updated client requirements (apify-client-orchestration PR #9),
which added: "Manual release workflow also creates a tagged GitHub release." No changes to the
public interface; release-workflow behaviour only.

### Changed
- CI: the manually triggered `Publish Rust client to crates.io` workflow
(`.github/workflows/rust-publish.yml`) now also tags the released commit and creates a matching
GitHub release, in addition to publishing to crates.io. The release tag (`vX.Y.Z`) is derived
from the single source of truth (the `version` field in `Cargo.toml`, which is also what
`CLIENT_VERSION` exposes via `CARGO_PKG_VERSION`), validated to be bare semver, and checked
against existing local/remote tags so a release can never silently clobber a prior one. The
workflow now requires the `master` branch, requests `contents: write` permission, and creates
the GitHub release via `gh` using the default `GITHUB_TOKEN` repository secret. The release notes
are extracted from the matching `CHANGELOG.md` section (falling back to a one-liner if absent).
The tag and release are created before `cargo publish` so the immutable git tag/release stay
consistent with the crate version even if the (unrepeatable) publish step fails. The GitHub
release step is idempotent (updates an existing release rather than failing), and the README
documents the "delete the tag and re-run" recovery procedure for a post-tag/pre-publish failure.
The `dry_run` input now also skips tag and release creation. This mirrors the Go client's
`go-publish.yml`.

## [0.2.2] - 2026-06-22

Publishing compliance for the updated client requirements (apify-client-orchestration PR #7).
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "apify-client"
version = "0.2.2"
version = "0.2.3"
authors = ["Apify Technologies <support@apify.com>"]
description = "An experimental, AI-generated and AI-maintained Rust client for the Apify API (https://apify.com)."
license = "Apache-2.0"
Expand Down
48 changes: 44 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,50 @@ println!("client {CLIENT_VERSION}, built against API spec {API_SPEC_VERSION}");
The crate is distributed on [crates.io](https://crates.io/crates/apify-client). The
[`Publish Rust client to crates.io`](.github/workflows/rust-publish.yml) workflow is the release
mechanism: a maintainer triggers it manually (`workflow_dispatch`), it runs the same
fmt/clippy/build quality gate as CI, verifies packaging with `cargo publish --dry-run`, then runs
`cargo publish`. The registry token is read only from the `CARGO_REGISTRY_TOKEN` repository secret,
and a `dry_run` input allows a packaging-only run with no actual release. Bump `version` in
`Cargo.toml` before releasing.
fmt/clippy/build quality gate as CI, verifies packaging with `cargo publish --dry-run`, then tags
the released commit (`vX.Y.Z`, derived from the `version` in `Cargo.toml`), creates a matching
GitHub release whose notes are the corresponding `CHANGELOG.md` section (falling back to a generated
one-liner if that section is missing), and finally runs `cargo publish`.

The workflow **only runs from `master`** — it hard-fails on any other ref — and refuses to run if
the resolved tag already exists, so a release can never clobber a prior one. It also fails early
with a clear message if the `CARGO_REGISTRY_TOKEN` secret is missing. A `dry_run` input runs all
checks but performs no publish, tag, or release.

Prerequisites and steps to cut a release:

1. Configure the `CARGO_REGISTRY_TOKEN` repository secret with a crates.io API token (one-time
setup). The tag and GitHub release use the default `GITHUB_TOKEN`, so no other secret is needed.
2. Bump `version` in `Cargo.toml` and add a matching dated entry to `CHANGELOG.md` (the release
notes are extracted from that section), then merge to `master`.
3. Trigger the workflow from `master`.

The tag is pushed and the GitHub release created before `cargo publish`, because the crates.io
publish is the only truly unrepeatable step — failing before it leaves the tag and release
consistent with the crate version. The GitHub-release step is idempotent (on a re-run it updates an
existing release rather than failing), so it never needs manual cleanup.

**Recovering from a failed release.** If the run fails *after* the tag was pushed but *before*
`cargo publish` succeeded (e.g. a transient registry error), the tag now exists, so a plain re-run
is blocked by the "tag already exists" guard. The one thing that unblocks the re-run is **deleting
the tag** — the existing GitHub release does not need deleting (the idempotent release step will
update it on the next run). Delete the remote tag and re-trigger the workflow (replace `vX.Y.Z`
with the actual release version, e.g. `v0.2.3`):

```bash
# Replace vX.Y.Z with the real version, e.g. v0.2.3.
git push origin :refs/tags/vX.Y.Z # delete the remote tag — this is what clears the guard
```

`git push origin :refs/tags/vX.Y.Z` deletes only the tag, which is all that is required. If you
also want to remove the GitHub release (optional, cosmetic, and independent of the required tag
deletion), use `gh release delete vX.Y.Z --yes` — without `--cleanup-tag` it removes only the
release and leaves the tag handling to the command above. (Alternatively, `gh release delete
vX.Y.Z --cleanup-tag --yes` is an all-in-one that deletes the release *and* the tag in a single
step, replacing the `git push origin :refs/tags/...` command above rather than adding to it.)

If `cargo publish` itself already succeeded, that version is permanent on crates.io; bump the
`version` in `Cargo.toml` for the next release instead of re-running.

## Examples

Expand Down
Loading