1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
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