diff --git a/.github/workflows/rust-publish.yml b/.github/workflows/rust-publish.yml index 8375462..cdab048 100644 --- a/.github/workflows/rust-publish.yml +++ b/.github/workflows/rust-publish.yml @@ -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 @@ -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 @@ -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' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 952141d..77f1089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/Cargo.toml b/Cargo.toml index b664dde..c055f11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "apify-client" -version = "0.2.2" +version = "0.2.3" authors = ["Apify Technologies "] description = "An experimental, AI-generated and AI-maintained Rust client for the Apify API (https://apify.com)." license = "Apache-2.0" diff --git a/README.md b/README.md index 1478f3a..23c3d9d 100644 --- a/README.md +++ b/README.md @@ -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