@smbcloud / cli / commits / 7ca5774

Add CI/CD skill guide for GitHub Actions release workflows

Add detailed SKILL.md covering best practices and patterns for release workflows targeting crates.io, PyPI, npm, and GitHub Releases. Update release-github.yml to follow these patterns: matrix expansion, fail-fast: false, ref override on checkout, toolchain/version resolution from rust-toolchain.toml, per-target cache keys, and workspace scoping with --package.

Seto Elkahfi committed Apr 6, 2026 at 12:08 UTC 7ca5774e21be264cb159fed5ef2330cac28f45c6
2 files changed +497 -29
.agents/skills/ci-cd/SKILL.md new
+409
@@ -0,0 +1,409 @@
1 +---
2 +name: ci-cd
3 +description: Use when creating, debugging, or improving GitHub Actions workflows for this repo — especially release workflows for crates.io, PyPI, npm, GitHub Releases, or Debian packages. Covers OpenSSL vendoring, manylinux containers, Perl module gaps, workspace scoping, native vs QEMU arm64 runners, trusted publishing, artifact actions, toolchain parity, and idempotency patterns.
4 +---
5 +
6 +# CI/CD
7 +
8 +## Source of truth for all release workflows
9 +
10 +| Distribution | Workflow file |
11 +| ----------------------- | -------------------------------------- |
12 +| crates.io | `.github/workflows/release-crate.yml` |
13 +| PyPI | `.github/workflows/release-pypi.yml` |
14 +| npm | `.github/workflows/release-npm.yml` |
15 +| GitHub Release binaries | `.github/workflows/release-github.yml` |
16 +
17 +All four workflows share the same structural patterns. When editing one, check the others for consistency.
18 +
19 +---
20 +
21 +## Universal workflow patterns
22 +
23 +Every release workflow must follow these patterns identically.
24 +
25 +### Trigger
26 +
27 +```yaml
28 +on:
29 + push:
30 + tags:
31 + - "v*.*.*"
32 + workflow_dispatch:
33 + inputs:
34 + tag:
35 + description: "Release tag (e.g. v0.3.35)"
36 + required: true
37 +```
38 +
39 +### Checkout — always include the ref override
40 +
41 +```yaml
42 +- name: Checkout
43 + uses: actions/checkout@v6
44 + with:
45 + ref: ${{ github.event.inputs.tag || github.ref }}
46 +```
47 +
48 +Without `ref:`, a `workflow_dispatch` run checks out the default branch HEAD, not the tagged commit. The source code and the version would silently diverge.
49 +
50 +### Version resolution
51 +
52 +```yaml
53 +- name: Set the release version
54 + shell: bash
55 + run: |
56 + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
57 + release_version="${GITHUB_REF_NAME#v}"
58 + else
59 + release_version="${{ github.event.inputs.tag }}"
60 + release_version="${release_version#v}"
61 + fi
62 +
63 + echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV"
64 +```
65 +
66 +Never read the version from `Cargo.toml` with `sed` in the `else` branch. That silently publishes the current `Cargo.toml` version regardless of the tag input.
67 +
68 +### Toolchain — always read from `rust-toolchain.toml`
69 +
70 +```yaml
71 +- name: Read Rust toolchain
72 + shell: bash
73 + run: |
74 + rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)"
75 + if [ -z "$rust_toolchain" ]; then
76 + echo "Failed to read Rust toolchain from rust-toolchain.toml" >&2
77 + exit 1
78 + fi
79 +
80 + echo "RUST_TOOLCHAIN=${rust_toolchain}" >> "$GITHUB_ENV"
81 +
82 +- name: Install toolkit
83 + uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9
84 + with:
85 + toolchain: ${{ env.RUST_TOOLCHAIN }}
86 +```
87 +
88 +Never hardcode the toolchain version or use `@stable`. The pinned SHA on `dtolnay/rust-toolchain` prevents silent breakage from upstream action changes.
89 +
90 +### Rust cache — use a per-target key
91 +
92 +```yaml
93 +- name: Setup Rust cache
94 + uses: Swatinem/rust-cache@v2
95 + with:
96 + key: <job-name>-${{ matrix.target }}
97 +```
98 +
99 +Without a unique key, cache entries from different targets can collide and corrupt each other.
100 +
101 +### Idempotency — check before publishing
102 +
103 +Every publish step must be gated so re-runs do not fail on already-published versions.
104 +
105 +**crates.io:**
106 +
107 +```yaml
108 +- name: Check whether release already exists on crates.io
109 + id: crates-check
110 + shell: bash
111 + run: |
112 + if curl -fsS \
113 + -H "User-Agent: smbcloud-cli-release-workflow" \
114 + "https://crates.io/api/v1/crates/smbcloud-cli/${RELEASE_VERSION}" \
115 + >/dev/null 2>&1; then
116 + echo "exists=true" >> "$GITHUB_OUTPUT"
117 + else
118 + echo "exists=false" >> "$GITHUB_OUTPUT"
119 + fi
120 +
121 +- name: Publish to crates.io
122 + if: steps.crates-check.outputs.exists != 'true'
123 + ...
124 +```
125 +
126 +The `User-Agent` header is required — crates.io rejects requests without one.
127 +
128 +**PyPI:**
129 +
130 +```yaml
131 +- name: Check whether release already exists on PyPI
132 + id: pypi-check
133 + shell: bash
134 + run: |
135 + if curl -fsS "https://pypi.org/pypi/smbcloud-cli/${RELEASE_VERSION}/json" >/dev/null 2>&1; then
136 + echo "exists=true" >> "$GITHUB_OUTPUT"
137 + else
138 + echo "exists=false" >> "$GITHUB_OUTPUT"
139 + fi
140 +
141 +- name: Publish distribution
142 + if: steps.pypi-check.outputs.exists != 'true'
143 + uses: pypa/gh-action-pypi-publish@release/v1
144 + with:
145 + skip-existing: true
146 +```
147 +
148 +`skip-existing: true` is a safety net for partial re-runs where some wheels already exist but others do not.
149 +
150 +**npm:** The check is inline in the publish shell script:
151 +
152 +```bash
153 +if npm view "${npm_package_name}@${node_version}" version >/dev/null 2>&1; then
154 + echo "${npm_package_name}@${node_version} already exists on npm, skipping publish"
155 + exit 0
156 +fi
157 +npm publish --access public
158 +```
159 +
160 +The `exit 0` inside the `run` block is correct here because it prevents the `npm publish` line below it in the same script from executing. This is different from the PyPI anti-pattern where `exit 0` in a separate step does not prevent the next step from running.
161 +
162 +### Version guard — verify Cargo.toml matches the tag
163 +
164 +```yaml
165 +- name: Verify Cargo.toml version matches tag
166 + shell: bash
167 + run: |
168 + CARGO_VERSION=$(cargo metadata --no-deps --format-version 1 \
169 + | jq -r '.packages[] | select(.name == "smbcloud-cli") | .version')
170 +
171 + if [[ "${CARGO_VERSION}" != "${RELEASE_VERSION}" ]]; then
172 + echo "::error::Version mismatch — bump version in Cargo.toml before releasing."
173 + exit 1
174 + fi
175 +```
176 +
177 +### Artifact actions
178 +
179 +Use `actions/upload-artifact@v7` and `actions/download-artifact@v7` consistently across all workflows.
180 +
181 +---
182 +
183 +## Workspace scoping — always use `--package`
184 +
185 +The workspace root `Cargo.toml` is a **virtual manifest** (no `[package]` section). Running `cargo build` or `cargo publish` from the workspace root without `--package` will either fail or build every workspace member.
186 +
187 +This workspace includes `smbcloud-auth-py`, a PyO3 `cdylib` that requires Python symbols at link time. Building the full workspace on platforms without a matching Python interpreter causes:
188 +
189 +```
190 +ld: symbol(s) not found for architecture arm64
191 +clang: error: linker command failed with exit code 1
192 +```
193 +
194 +Always scope cargo commands:
195 +
196 +```bash
197 +cargo build --release --locked --target <target> --package smbcloud-cli
198 +cargo publish --locked --package smbcloud-cli
199 +```
200 +
201 +The PyPI workflow is exempt because maturin is already pointed at `crates/cli/Cargo.toml` via `manifest-path` in `pypi/pyproject.toml`.
202 +
203 +---
204 +
205 +## Platform matrix
206 +
207 +### Current runners and targets
208 +
209 +| Platform | Runner | Target | Notes |
210 +| ------------- | ------------------ | --------------------------- | ------------------------- |
211 +| Linux x86_64 | `ubuntu-latest` | `x86_64-unknown-linux-gnu` | Native |
212 +| Linux arm64 | `ubuntu-24.04-arm` | `aarch64-unknown-linux-gnu` | Native — do not use QEMU |
213 +| Windows x64 | `windows-2022` | `x86_64-pc-windows-msvc` | Native |
214 +| Windows arm64 | `windows-2022` | `aarch64-pc-windows-msvc` | Cross-compile on x64 host |
215 +| macOS x64 | `macos-15-intel` | `x86_64-apple-darwin` | Native Intel runner |
216 +| macOS arm64 | `macos-latest` | `aarch64-apple-darwin` | Native Apple Silicon |
217 +
218 +For npm and GitHub Release, use native runners. For PyPI (maturin), the macOS x64 build uses `macos-latest` with cross-compilation because maturin handles it transparently inside its container.
219 +
220 +### Do not use QEMU for arm64 Linux
221 +
222 +When `ubuntu-latest` (x86_64) targets `aarch64-unknown-linux-gnu` via maturin, the manylinux container runs under QEMU emulation. This is slow and causes container toolchain mismatches. Use `ubuntu-24.04-arm` (native arm64 runner) instead.
223 +
224 +---
225 +
226 +## OpenSSL vendoring
227 +
228 +This repo uses `git2` which depends on `libgit2-sys` → `openssl-sys`. On platforms without system OpenSSL headers (manylinux containers, macOS cross-compilation), the build fails with:
229 +
230 +```
231 +Could not find directory of OpenSSL installation
232 +```
233 +
234 +### Fix applied
235 +
236 +`Cargo.toml` (workspace):
237 +
238 +```toml
239 +openssl = { version = "0.10", features = ["vendored"] }
240 +```
241 +
242 +`crates/cli/Cargo.toml`:
243 +
244 +```toml
245 +openssl = { workspace = true }
246 +```
247 +
248 +Adding `openssl` as a direct dependency with `features = ["vendored"]` activates `openssl-sys/vendored` globally via Cargo feature unification. This causes `openssl-src` to compile OpenSSL from source for the exact target triple, requiring no system headers.
249 +
250 +---
251 +
252 +## manylinux containers and Perl
253 +
254 +The vendored OpenSSL build compiles OpenSSL from source by running `perl ./Configure`. The manylinux2014 container (CentOS 7) has a stripped Perl installation missing core modules.
255 +
256 +### Symptom
257 +
258 +```
259 +Can't locate IPC/Cmd.pm in @INC
260 +Can't locate Time/Piece.pm in @INC
261 +```
262 +
263 +### Fix
264 +
265 +```yaml
266 +- name: Build wheel
267 + uses: PyO3/maturin-action@v1
268 + with:
269 + before-script-linux: yum install -y perl-core
270 +```
271 +
272 +`perl-core` is the CentOS 7 meta-package that installs all standard Perl core modules. Install it once rather than chasing individual missing modules — each new OpenSSL version can require additional ones.
273 +
274 +`before-script-linux` runs inside the manylinux Docker container, not the host runner. It is ignored on macOS and Windows runners.
275 +
276 +### manylinux2014 uses `yum`, not `apt`
277 +
278 +The manylinux2014 image is CentOS 7 based. Use `yum`. Using `apt` fails with exit code 127 ("command not found").
279 +
280 +### manylinux target per architecture
281 +
282 +| Architecture | manylinux target | Container base | Package manager |
283 +| ------------ | ---------------- | ---------------------------- | --------------- |
284 +| x86_64 | `2014` | CentOS 7 | `yum` |
285 +| aarch64 | `2014` | CentOS 7 (via native runner) | `yum` |
286 +
287 +Do not use `manylinux: "2014"` for aarch64 with an x86_64 host — the cross-compilation container does not have `yum`. Use a native `ubuntu-24.04-arm` runner with `manylinux: "2014"` instead.
288 +
289 +---
290 +
291 +## Trusted publishing
292 +
293 +### PyPI — uses OIDC trusted publishing
294 +
295 +```yaml
296 +permissions:
297 + id-token: write
298 + contents: read
299 +
300 +- name: Publish distribution
301 + uses: pypa/gh-action-pypi-publish@release/v1
302 +```
303 +
304 +No `PYPI_TOKEN` secret needed. The action exchanges the GitHub OIDC JWT for a short-lived PyPI token automatically.
305 +
306 +### npm — does NOT support trusted publishing natively
307 +
308 +npm has no OIDC-based trusted publishing equivalent to PyPI. The `id-token: write` permission in the npm workflow is unused for auth.
309 +
310 +Authentication requires a stored secret:
311 +
312 +```yaml
313 +- name: Publish to NPM
314 + env:
315 + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
316 +```
317 +
318 +`setup-node` with `registry-url` writes an `.npmrc` referencing `${NODE_AUTH_TOKEN}`. That variable must be in the step's `env` block at publish time. If it is missing, `npm publish` silently has no auth and returns 404 for new packages (existing packages that pass the `npm view` idempotency check are never attempted, masking the missing token).
319 +
320 +The 404 on `npm publish` does not mean the package does not exist. npm returns 404 for auth failures to avoid leaking package existence.
321 +
322 +**npm trusted publishing (future):** npm does support OIDC trusted publishing via `npm trust github`. It requires the package to exist on npm first. Configure it per-package after the first manual publish. See `smbcloud-cli-release` skill for details.
323 +
324 +### crates.io — uses `CARGO_REGISTRY_TOKEN`
325 +
326 +```yaml
327 +- name: Publish to crates.io
328 + env:
329 + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
330 + run: cargo publish --locked --package smbcloud-cli
331 +```
332 +
333 +crates.io also supports OIDC trusted publishing but the setup is separate from this workflow.
334 +
335 +---
336 +
337 +## Split build + release job pattern
338 +
339 +For the GitHub Release workflow, separate the build matrix from the release step:
340 +
341 +```yaml
342 +jobs:
343 + build:
344 + strategy:
345 + matrix: ...
346 + steps:
347 + - name: Build binary
348 + run: cargo build ...
349 + - name: Upload artifact
350 + uses: actions/upload-artifact@v7
351 +
352 + release:
353 + needs: build
354 + runs-on: ubuntu-latest
355 + steps:
356 + - name: Download all artifacts
357 + uses: actions/download-artifact@v7
358 + with:
359 + pattern: "binary-*"
360 + merge-multiple: true
361 + - name: Release
362 + uses: softprops/action-gh-release@v2
363 + with:
364 + files: release/*
365 +```
366 +
367 +This ensures all platform binaries are collected before any are attached to the GitHub Release. If a single platform build fails, no partial release is created.
368 +
369 +---
370 +
371 +## `fail-fast: false`
372 +
373 +Always set `fail-fast: false` on release build matrices:
374 +
375 +```yaml
376 +strategy:
377 + fail-fast: false
378 + matrix: ...
379 +```
380 +
381 +The default `fail-fast: true` cancels all in-progress jobs as soon as one fails. For release builds, you want every platform to complete so you can diagnose all failures in a single run rather than fixing them one at a time.
382 +
383 +---
384 +
385 +## Common mistakes
386 +
387 +**Building the full workspace in release workflows**
388 +Always pass `--package smbcloud-cli` to `cargo build` and `cargo publish`. Omitting it builds `smbcloud-auth-py` (PyO3 cdylib) which fails on platforms without a matching Python interpreter.
389 +
390 +**`exit 0` in a separate step does not skip the next step**
391 +`exit 0` only terminates the current `run` shell. The following step runs regardless. Use `$GITHUB_OUTPUT` + `if:` conditions for inter-step flow control.
392 +
393 +**Hardcoded toolchain version**
394 +Never hardcode `toolchain: 1.92` or use `@stable`. Always read from `rust-toolchain.toml`.
395 +
396 +**Missing `ref:` on checkout for `workflow_dispatch`**
397 +Without `ref: ${{ github.event.inputs.tag || github.ref }}`, a manual dispatch checks out the default branch, not the tag. The compiled binary will be from the wrong commit.
398 +
399 +**Using `sed` on `Cargo.toml` as the version fallback**
400 +This reads whatever version is currently in `Cargo.toml` on the checked-out branch, which may not match the tag being re-published. Use the `tag` input directly.
401 +
402 +**QEMU for arm64 Linux**
403 +Using `ubuntu-latest` (x86_64) to build `aarch64-unknown-linux-gnu` via QEMU inside manylinux is slow and causes container mismatch errors. Use `ubuntu-24.04-arm`.
404 +
405 +**`yum` vs `apt` in manylinux**
406 +manylinux2014 is CentOS 7. Use `yum`, not `apt`. For manylinux_2_28 (AlmaLinux 8) use `dnf`.
407 +
408 +**Missing `User-Agent` on crates.io API requests**
409 +crates.io rejects curl requests without a `User-Agent` header. Always include `-H "User-Agent: smbcloud-cli-release-workflow"`.
.github/workflows/release-github.yml
+88 -29
@@ -5,50 +5,85 @@ on:
5 tags:
6 - "v*.*.*"
7 workflow_dispatch:
8 -
9 -env:
10 - # The smbcloud-cli binary name
11 - PROJECT_NAME: smb
8 + inputs:
9 + tag:
10 + description: "Release tag (e.g. v0.3.35)"
11 + required: true
12
13 permissions:
14 contents: write
15
16 +env:
17 + PROJECT_NAME: smb
18 + CARGO_TERM_COLOR: always
19 +
20 jobs:
21 build:
22 + name: Build binary (${{ matrix.name }})
23 runs-on: ${{ matrix.runner }}
24
25 strategy:
26 + fail-fast: false
27 matrix:
28 include:
29 - name: linux-amd64
30 runner: ubuntu-latest
31 target: x86_64-unknown-linux-gnu
32 + - name: linux-arm64
33 + runner: ubuntu-24.04-arm
34 + target: aarch64-unknown-linux-gnu
35 - name: win-amd64
36 runner: windows-latest
37 target: x86_64-pc-windows-msvc
29 - #- name: macos-amd64
30 - # runner: macos-latest
31 - # target: x86_64-apple-darwin
38 + - name: win-arm64
39 + runner: windows-latest
40 + target: aarch64-pc-windows-msvc
41 + - name: macos-amd64
42 + runner: macos-15-intel
43 + target: x86_64-apple-darwin
44 - name: macos-arm64
45 runner: macos-latest
46 target: aarch64-apple-darwin
47
48 steps:
49 - name: Checkout
38 - uses: actions/checkout@v3
39 -
40 - - name: Install Rust
41 - uses: dtolnay/rust-toolchain@stable
50 + uses: actions/checkout@v6
51 with:
43 - toolchain: 1.92
44 - targets: "${{ matrix.target }}"
52 + ref: ${{ github.event.inputs.tag || github.ref }}
53
46 - #- name: Install OpenSSL
47 - # if: matrix.name == 'macos-amd64'
48 - # run: brew install openssl@3
54 + - name: Set the release version
55 + shell: bash
56 + run: |
57 + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
58 + release_version="${GITHUB_REF_NAME#v}"
59 + else
60 + release_version="${{ github.event.inputs.tag }}"
61 + release_version="${release_version#v}"
62 + fi
63 +
64 + echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV"
65 +
66 + - name: Read Rust toolchain
67 + shell: bash
68 + run: |
69 + rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)"
70 + if [ -z "$rust_toolchain" ]; then
71 + echo "Failed to read Rust toolchain from rust-toolchain.toml" >&2
72 + exit 1
73 + fi
74
50 - - name: Setup Cache
75 + echo "RUST_TOOLCHAIN=${rust_toolchain}" >> "$GITHUB_ENV"
76 +
77 + - name: Install toolkit
78 + uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9
79 + with:
80 + toolchain: ${{ env.RUST_TOOLCHAIN }}
81 + targets: ${{ matrix.target }}
82 +
83 + - name: Setup Rust cache
84 uses: Swatinem/rust-cache@v2
85 + with:
86 + key: github-${{ matrix.target }}
87
88 - name: Create .env file
89 run: |
@@ -56,10 +91,11 @@ jobs:
91 echo CLI_CLIENT_SECRET=${{ secrets.CLI_CLIENT_SECRET }} >> .env
92 cat .env
93
59 - - name: Build Binary
60 - run: cargo build --verbose --locked --release --target ${{ matrix.target }}
94 + - name: Build binary
95 + shell: bash
96 + run: cargo build --locked --release --target ${{ matrix.target }} --package smbcloud-cli
97
62 - - name: Release Binary
98 + - name: Package binary
99 shell: bash
100 run: |
101 BIN_SUFFIX=""
@@ -67,21 +103,44 @@ jobs:
103 BIN_SUFFIX=".exe"
104 fi
105
70 - # The built binary output location
106 BIN_OUTPUT="target/${{ matrix.target }}/release/${PROJECT_NAME}${BIN_SUFFIX}"
72 -
73 - # Define a better name for the final binary
107 BIN_RELEASE="${PROJECT_NAME}-${{ matrix.name }}${BIN_SUFFIX}"
75 - BIN_RELEASE_VERSIONED="${PROJECT_NAME}-${{ github.ref_name }}-${{ matrix.name }}${BIN_SUFFIX}"
108
77 - # Move the built binary where you want it
78 - DEST_DIR="./release"
109 + mkdir -p ./release
110 + mv "${BIN_OUTPUT}" "./release/${BIN_RELEASE}"
111 +
112 + - name: Upload artifact
113 + uses: actions/upload-artifact@v7
114 + with:
115 + name: binary-${{ matrix.name }}
116 + path: release/*
117
80 - mkdir -p "${DEST_DIR}"
81 - mv "${BIN_OUTPUT}" "${DEST_DIR}/${BIN_RELEASE}"
118 + release:
119 + name: Create GitHub Release
120 + needs: build
121 + runs-on: ubuntu-latest
122 + if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
123 +
124 + steps:
125 + - name: Resolve tag
126 + id: tag
127 + shell: bash
128 + run: |
129 + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
130 + echo "tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT"
131 + else
132 + echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
133 + fi
134 +
135 + - name: Download all artifacts
136 + uses: actions/download-artifact@v7
137 + with:
138 + pattern: "binary-*"
139 + path: release
140 + merge-multiple: true
141
142 - name: Release
143 uses: softprops/action-gh-release@v2
85 - if: github.ref_type == 'tag'
144 with:
145 + tag_name: ${{ steps.tag.outputs.tag }}
146 files: release/*