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, Homebrew tap, or Debian packages. Also covers SDK distribution README branding alignment across npm, PyPI, and the main repo. Covers OpenSSL vendoring, Windows vs non-Windows platform-conditional dependencies, manylinux containers, Perl module gaps, workspace scoping, native vs QEMU arm64 runners, trusted publishing, artifact actions, toolchain parity, idempotency patterns, Homebrew formula generation, and cross-workflow chaining.
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 | Homebrew tap | `.github/workflows/release-homebrew.yml` |
17
18 All four workflows share the same structural patterns. When editing one, check the others for consistency.
19
20 ## Source of truth for distribution channel READMEs
21
22 | File | Published as |
23 | ---------------------------- | ------------------------------------------------- |
24 | `README.MD` | GitHub repo — canonical brand voice |
25 | `npm/smbcloud-cli/README.md` | npmjs.com — `@smbcloud/cli` |
26 | `pypi/README.md` | pypi.org — `smbcloud-cli` |
27 | `npm/README.md.tmpl` | npmjs.com — `@smbcloud/cli-*` (platform packages) |
28
29 When the main `README.MD` changes its tagline, logo, quick start, or value proposition, update all three distribution READMEs to match.
30
31 ---
32
33 ## Universal workflow patterns
34
35 Every release workflow must follow these patterns identically.
36
37 ### Trigger
38
39 ```yaml
40 on:
41 push:
42 tags:
43 - "v*.*.*"
44 workflow_dispatch:
45 inputs:
46 tag:
47 description: "Release tag (e.g. v0.3.35)"
48 required: true
49 ```
50
51 ### Checkout — always include the ref override
52
53 ```yaml
54 - name: Checkout
55 uses: actions/checkout@v6
56 with:
57 ref: ${{ github.event.inputs.tag || github.ref }}
58 ```
59
60 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.
61
62 ### Version resolution
63
64 ```yaml
65 - name: Set the release version
66 shell: bash
67 run: |
68 if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
69 release_version="${GITHUB_REF_NAME#v}"
70 else
71 release_version="${{ github.event.inputs.tag }}"
72 release_version="${release_version#v}"
73 fi
74
75 echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV"
76 ```
77
78 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.
79
80 ### Toolchain — always read from `rust-toolchain.toml`
81
82 ```yaml
83 - name: Read Rust toolchain
84 shell: bash
85 run: |
86 rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)"
87 if [ -z "$rust_toolchain" ]; then
88 echo "Failed to read Rust toolchain from rust-toolchain.toml" >&2
89 exit 1
90 fi
91
92 echo "RUST_TOOLCHAIN=${rust_toolchain}" >> "$GITHUB_ENV"
93
94 - name: Install toolkit
95 uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9
96 with:
97 toolchain: ${{ env.RUST_TOOLCHAIN }}
98 ```
99
100 Never hardcode the toolchain version or use `@stable`. The pinned SHA on `dtolnay/rust-toolchain` is required because the action does not publish semver tags — its only named ref is `@master`. Pinning to a commit SHA makes the action immutable: it will always refer to that exact tree regardless of what the maintainer pushes later, and it cannot be silently replaced if the account were ever compromised. For first-party actions (`actions/checkout`, `actions/upload-artifact`, etc.) the `@v6`, `@v7` tags are maintained under a strict semver contract by GitHub, so floating major-version tags are safe there.
101
102 ### Rust cache — use a per-target key
103
104 ```yaml
105 - name: Setup Rust cache
106 uses: Swatinem/rust-cache@v2
107 with:
108 key: <job-name>-${{ matrix.target }}
109 ```
110
111 Without a unique key, cache entries from different targets can collide and corrupt each other.
112
113 ### Idempotency — check before publishing
114
115 Every publish step must be gated so re-runs do not fail on already-published versions.
116
117 **crates.io:**
118
119 ```yaml
120 - name: Check whether release already exists on crates.io
121 id: crates-check
122 shell: bash
123 run: |
124 if curl -fsS \
125 -H "User-Agent: smbcloud-cli-release-workflow" \
126 "https://crates.io/api/v1/crates/smbcloud-cli/${RELEASE_VERSION}" \
127 >/dev/null 2>&1; then
128 echo "exists=true" >> "$GITHUB_OUTPUT"
129 else
130 echo "exists=false" >> "$GITHUB_OUTPUT"
131 fi
132
133 - name: Publish to crates.io
134 if: steps.crates-check.outputs.exists != 'true'
135 ...
136 ```
137
138 The `User-Agent` header is required — crates.io rejects requests without one.
139
140 **PyPI:**
141
142 ```yaml
143 - name: Check whether release already exists on PyPI
144 id: pypi-check
145 shell: bash
146 run: |
147 if curl -fsS "https://pypi.org/pypi/smbcloud-cli/${RELEASE_VERSION}/json" >/dev/null 2>&1; then
148 echo "exists=true" >> "$GITHUB_OUTPUT"
149 else
150 echo "exists=false" >> "$GITHUB_OUTPUT"
151 fi
152
153 - name: Publish distribution
154 if: steps.pypi-check.outputs.exists != 'true'
155 uses: pypa/gh-action-pypi-publish@release/v1
156 with:
157 skip-existing: true
158 ```
159
160 `skip-existing: true` is a safety net for partial re-runs where some wheels already exist but others do not.
161
162 **npm:** The check is inline in the publish shell script:
163
164 ```bash
165 if npm view "${npm_package_name}@${node_version}" version >/dev/null 2>&1; then
166 echo "${npm_package_name}@${node_version} already exists on npm, skipping publish"
167 exit 0
168 fi
169 npm publish --access public
170 ```
171
172 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.
173
174 ### Version guard — verify Cargo.toml matches the tag
175
176 ```yaml
177 - name: Verify Cargo.toml version matches tag
178 shell: bash
179 run: |
180 CARGO_VERSION=$(cargo metadata --no-deps --format-version 1 \
181 | jq -r '.packages[] | select(.name == "smbcloud-cli") | .version')
182
183 if [[ "${CARGO_VERSION}" != "${RELEASE_VERSION}" ]]; then
184 echo "::error::Version mismatch — bump version in Cargo.toml before releasing."
185 exit 1
186 fi
187 ```
188
189 ### Artifact actions
190
191 Use `actions/upload-artifact@v7` and `actions/download-artifact@v7` consistently across all workflows.
192
193 ---
194
195 ## Workspace scoping — always use `--package`
196
197 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.
198
199 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:
200
201 ```
202 ld: symbol(s) not found for architecture arm64
203 clang: error: linker command failed with exit code 1
204 ```
205
206 Always scope cargo commands:
207
208 ```bash
209 cargo build --release --locked --target <target> --package smbcloud-cli
210 cargo publish --locked --package smbcloud-cli
211 ```
212
213 The PyPI workflow is exempt because maturin is already pointed at `crates/cli/Cargo.toml` via `manifest-path` in `pypi/pyproject.toml`.
214
215 ---
216
217 ## Platform matrix
218
219 ### Current runners and targets
220
221 | Platform | Runner | Target | Notes |
222 | ------------- | ------------------ | --------------------------- | ------------------------- |
223 | Linux x86_64 | `ubuntu-latest` | `x86_64-unknown-linux-gnu` | Native |
224 | Linux arm64 | `ubuntu-24.04-arm` | `aarch64-unknown-linux-gnu` | Native — do not use QEMU |
225 | Windows x64 | `windows-2022` | `x86_64-pc-windows-msvc` | Native |
226 | Windows arm64 | `windows-2022` | `aarch64-pc-windows-msvc` | Cross-compile on x64 host |
227 | macOS x64 | `macos-15-intel` | `x86_64-apple-darwin` | Native Intel runner |
228 | macOS arm64 | `macos-latest` | `aarch64-apple-darwin` | Native Apple Silicon |
229
230 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.
231
232 ### Do not use QEMU for arm64 Linux
233
234 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.
235
236 ---
237
238 ## OpenSSL vendoring
239
240 This repo uses `git2` which depends on `libgit2-sys` → `openssl-sys`. On non-Windows platforms without system OpenSSL headers (manylinux containers, macOS cross-compilation), the build fails with:
241
242 ```
243 Could not find directory of OpenSSL installation
244 ```
245
246 On Windows, `libgit2-sys` uses **WinHTTP** (built into Windows) for HTTPS — OpenSSL is not needed at all. Enabling `vendored` on Windows causes `openssl-src` to invoke Perl to compile OpenSSL from source, which fails because the Windows runner Perl is missing required modules.
247
248 ### Fix applied
249
250 `Cargo.toml` (workspace) — declares the vendored feature once:
251
252 ```toml
253 openssl = { version = "0.10", features = ["vendored"] }
254 ```
255
256 `crates/cli/Cargo.toml` — activates it only on non-Windows targets:
257
258 ```toml
259 [target.'cfg(not(windows))'.dependencies]
260 openssl = { workspace = true }
261 ```
262
263 This activates `openssl-sys/vendored` via Cargo feature unification on Linux and macOS (where it is needed), and leaves the `openssl` crate completely absent from the Windows dependency graph (where WinHTTP handles SSL natively).
264
265 ### Per-platform behaviour
266
267 | Platform | SSL backend | `openssl-sys/vendored` | OpenSSL compiled from source |
268 | ------------- | ------------------- | ---------------------- | ---------------------------- |
269 | Linux (gnu) | vendored OpenSSL | ✅ | ✅ |
270 | macOS arm64 | vendored OpenSSL | ✅ | ✅ |
271 | macOS x64 | vendored OpenSSL | ✅ | ✅ |
272 | Windows x64 | WinHTTP (OS native) | ❌ | ❌ |
273 | Windows arm64 | WinHTTP (OS native) | ❌ | ❌ |
274
275 ---
276
277 ## manylinux containers and Perl
278
279 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.
280
281 ### Symptom
282
283 ```
284 Can't locate IPC/Cmd.pm in @INC
285 Can't locate Time/Piece.pm in @INC
286 ```
287
288 ### Fix
289
290 ```yaml
291 - name: Build wheel
292 uses: PyO3/maturin-action@v1
293 with:
294 before-script-linux: yum install -y perl-core
295 ```
296
297 `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.
298
299 `before-script-linux` runs inside the manylinux Docker container, not the host runner. It is ignored on macOS and Windows runners.
300
301 ### manylinux2014 uses `yum`, not `apt`
302
303 The manylinux2014 image is CentOS 7 based. Use `yum`. Using `apt` fails with exit code 127 ("command not found").
304
305 ### manylinux target per architecture
306
307 | Architecture | manylinux target | Container base | Package manager |
308 | ------------ | ---------------- | ---------------------------- | --------------- |
309 | x86_64 | `2014` | CentOS 7 | `yum` |
310 | aarch64 | `2014` | CentOS 7 (via native runner) | `yum` |
311
312 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.
313
314 ---
315
316 ## Trusted publishing
317
318 ### PyPI — uses OIDC trusted publishing
319
320 ```yaml
321 permissions:
322 id-token: write
323 contents: read
324
325 - name: Publish distribution
326 uses: pypa/gh-action-pypi-publish@release/v1
327 ```
328
329 No `PYPI_TOKEN` secret needed. The action exchanges the GitHub OIDC JWT for a short-lived PyPI token automatically.
330
331 ### npm — does NOT support trusted publishing natively
332
333 npm has no OIDC-based trusted publishing equivalent to PyPI. The `id-token: write` permission in the npm workflow is unused for auth.
334
335 Authentication requires a stored secret:
336
337 ```yaml
338 - name: Publish to NPM
339 env:
340 NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
341 ```
342
343 `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).
344
345 The 404 on `npm publish` does not mean the package does not exist. npm returns 404 for auth failures to avoid leaking package existence.
346
347 **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.
348
349 ### crates.io — uses `CARGO_REGISTRY_TOKEN`
350
351 ```yaml
352 - name: Publish to crates.io
353 env:
354 CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
355 run: cargo publish --locked --package smbcloud-cli
356 ```
357
358 crates.io also supports OIDC trusted publishing but the setup is separate from this workflow.
359
360 ---
361
362 ## Split build + release job pattern
363
364 For the GitHub Release workflow, separate the build matrix from the release step:
365
366 ```yaml
367 jobs:
368 build:
369 strategy:
370 matrix: ...
371 steps:
372 - name: Build binary
373 run: cargo build ...
374 - name: Upload artifact
375 uses: actions/upload-artifact@v7
376
377 release:
378 needs: build
379 runs-on: ubuntu-latest
380 steps:
381 - name: Download all artifacts
382 uses: actions/download-artifact@v7
383 with:
384 pattern: "binary-*"
385 merge-multiple: true
386 - name: Release
387 uses: softprops/action-gh-release@v2
388 with:
389 files: release/*
390 ```
391
392 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.
393
394 ---
395
396 ## Chaining workflows — `GITHUB_TOKEN` cannot fire downstream events
397
398 GitHub intentionally does **not** fire `release`, `push`, `pull_request`, or any other repository events when the action that creates them is authenticated with `GITHUB_TOKEN`. This is a deliberate anti-loop safeguard.
399
400 **Symptom:** `release-github.yml` uses `softprops/action-gh-release` (with the implicit `GITHUB_TOKEN`) to publish a release. `release-homebrew.yml` has `on: release: types: [published]`. The Homebrew workflow never runs.
401
402 **Fix:** Have the upstream workflow dispatch the downstream one explicitly via the GitHub API after it completes. This requires `actions: write` in `permissions`.
403
404 ```yaml
405 # release-github.yml
406
407 permissions:
408 contents: write
409 actions: write # required for createWorkflowDispatch
410
411 # At the end of the release job, after softprops/action-gh-release:
412 - name: Trigger Homebrew release
413 uses: actions/github-script@v7
414 with:
415 script: |
416 await github.rest.actions.createWorkflowDispatch({
417 owner: context.repo.owner,
418 repo: context.repo.repo,
419 workflow_id: 'release-homebrew.yml',
420 ref: 'main',
421 inputs: {
422 tag: '${{ steps.tag.outputs.tag }}'
423 }
424 })
425 ```
426
427 The downstream workflow (`release-homebrew.yml`) must expose a `workflow_dispatch` trigger with a `tag` input — it no longer needs `on: release: types: [published]` (which is dead code in this pattern).
428
429 **Alternative:** Use a Personal Access Token (PAT) with `repo` scope in `softprops/action-gh-release` via `token: ${{ secrets.RELEASE_TOKEN }}`. A PAT is treated as a real user action and does fire the `release` event. This avoids changing permissions but requires managing an extra secret.
430
431 ---
432
433 ## Homebrew tap release pattern
434
435 `release-homebrew.yml` is triggered by `release-github.yml` via `workflow_dispatch` (see above). It runs a single cheap `ubuntu-latest` job — no Rust compilation, no macOS runners.
436
437 ### Key principle: compute SHA256 once, at build time
438
439 The Homebrew formula's `sha256` field is the checksum of the tarball that Homebrew downloads at `brew install` time. The tarball lives at the GitHub Release URL. CI only needs to _write_ that checksum into `cli.rb` — it does not need to download the tarball again.
440
441 **In `release-github.yml` (build job, macOS legs only):**
442
443 ```yaml
444 - name: Package macOS tarball for Homebrew
445 if: contains(matrix.target, 'apple-darwin')
446 shell: bash
447 run: |
448 ARCHIVE_NAME="${PROJECT_NAME}-${{ matrix.name }}.tar.gz"
449
450 mkdir -p staging-brew
451 cp "./release/${PROJECT_NAME}-${{ matrix.name }}" "staging-brew/${PROJECT_NAME}"
452 strip "staging-brew/${PROJECT_NAME}"
453
454 tar -C staging-brew -czf "${ARCHIVE_NAME}" "${PROJECT_NAME}"
455
456 # Compute and save the checksum alongside the archive
457 shasum -a 256 "${ARCHIVE_NAME}" | awk '{print $1}' > "${ARCHIVE_NAME}.sha256"
458
459 - name: Upload macOS Homebrew artifacts
460 if: contains(matrix.target, 'apple-darwin')
461 uses: actions/upload-artifact@v7
462 with:
463 name: homebrew-${{ matrix.name }}
464 path: |
465 ${{ env.PROJECT_NAME }}-${{ matrix.name }}.tar.gz
466 ${{ env.PROJECT_NAME }}-${{ matrix.name }}.tar.gz.sha256
467 ```
468
469 Both the `.tar.gz` and the `.sha256` file are downloaded in the release job and attached to the GitHub Release via `files: release/*`.
470
471 **In `release-homebrew.yml` (single job):**
472
473 ```yaml
474 - name: Read SHA256 checksums from release
475 id: sha
476 env:
477 GH_TOKEN: ${{ github.token }}
478 shell: bash
479 run: |
480 mkdir -p artifacts
481
482 # Download only the tiny .sha256 text files — not the full tarballs
483 gh release download "${{ steps.release.outputs.tag }}" \
484 --repo "${{ env.REPO }}" \
485 --pattern "smb-macos-*.tar.gz.sha256" \
486 --dir artifacts/
487
488 ARM64_SHA=$(cat artifacts/smb-macos-arm64.tar.gz.sha256)
489 AMD64_SHA=$(cat artifacts/smb-macos-amd64.tar.gz.sha256)
490
491 echo "arm64=${ARM64_SHA}" >> "$GITHUB_OUTPUT"
492 echo "amd64=${AMD64_SHA}" >> "$GITHUB_OUTPUT"
493 ```
494
495 `gh` (GitHub CLI) is pre-installed on all GitHub-hosted runners. `GH_TOKEN: ${{ github.token }}` is sufficient for downloading from a public repo's releases.
496
497 ### Formula URL convention
498
499 The formula `url:` references the release asset directly. Homebrew fetches it at install time — CI never downloads it:
500
501 ```ruby
502 on_macos do
503 on_arm do
504 url 'https://github.com/smbcloudXYZ/smbcloud-cli/releases/download/v0.3.36/smb-macos-arm64.tar.gz'
505 sha256 '<computed during build>'
506 end
507 on_intel do
508 url 'https://github.com/smbcloudXYZ/smbcloud-cli/releases/download/v0.3.36/smb-macos-amd64.tar.gz'
509 sha256 '<computed during build>'
510 end
511 end
512 ```
513
514 The tarball must contain the binary named `smb` (not `smb-macos-arm64`) because the formula's install step is `bin.install 'smb'`.
515
516 ### Homebrew tap permissions
517
518 `release-homebrew.yml` only needs `contents: read` on `smbcloud-cli`. Writing to the tap repo (`smbcloudXYZ/homebrew-tap`) is authenticated via `secrets.HOMEBREW_TAP_TOKEN` in the `actions/checkout` step, not via workflow-level permissions.
519
520 ---
521
522 ## `fail-fast: false`
523
524 Always set `fail-fast: false` on release build matrices:
525
526 ```yaml
527 strategy:
528 fail-fast: false
529 matrix: ...
530 ```
531
532 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.
533
534 ---
535
536 ## SDK distribution README branding
537
538 Every distribution channel README must stay aligned with the main `README.MD`. The three elements that must always match exactly:
539
540 - **Logo** — `https://avatars.githubusercontent.com/u/89791739?s=200&v=4`, width 128
541 - **Tagline** — "Deploy to the cloud in one command."
542 - **Quick start** — `smb login` → `smb init` → `smb deploy`
543
544 ### Structure per README type
545
546 **Wrapper package** (`npm/smbcloud-cli/README.md` → `@smbcloud/cli`, `pypi/README.md` → `smbcloud-cli`):
547
548 1. Logo + tagline + nav links + badges
549 2. About section (copy from main README verbatim)
550 3. Primary install method for that channel first (`npm install -g` or `pip install`)
551 4. Quick start
552 5. All other install methods (Homebrew, npm, pip, shell, PowerShell)
553 6. Documentation link
554 7. Platform support table
555 8. Source & Issues pointer to GitHub
556 9. License
557
558 The PyPI README leads with the PyPI badge; the npm README leads with the npm badge. Both include the full badge row.
559
560 **Platform binary package** (`npm/README.md.tmpl` → `@smbcloud/cli-darwin-arm64`, etc.):
561
562 Keep it minimal. Show the logo and tagline for brand recognition, then immediately redirect to `@smbcloud/cli`. Do not repeat install instructions — the user landed on the wrong package.
563
564 ### Platform support table
565
566 Keep this table consistent across all READMEs:
567
568 ```md
569 | Platform | Architecture |
570 | ------------- | ------------ |
571 | macOS | arm64, x64 |
572 | Linux (glibc) | arm64, x64 |
573 | Windows | arm64, x64 |
574 ```
575
576 Update it whenever a new platform is added to the release matrix.
577
578 ### PyPI-specific copy
579
580 Add this line to distinguish the pip install from npm:
581
582 > This package installs the native `smb` executable for your platform directly — no Node.js, no Docker, no runtime dependencies.
583
584 ### Template variable in `README.md.tmpl`
585
586 The platform package template uses `${node_pkg}` as a shell-style variable. This is substituted at release time by the npm publish script. Do not replace it with a hardcoded package name.
587
588 ---
589
590 ## Common mistakes
591
592 **Building the full workspace in release workflows**
593 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.
594
595 **`exit 0` in a separate step does not skip the next step**
596 `exit 0` only terminates the current `run` shell. The following step runs regardless. Use `$GITHUB_OUTPUT` + `if:` conditions for inter-step flow control.
597
598 **Hardcoded toolchain version**
599 Never hardcode `toolchain: 1.92` or use `@stable`. Always read from `rust-toolchain.toml`.
600
601 **Missing `ref:` on checkout for `workflow_dispatch`**
602 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.
603
604 **Using `sed` on `Cargo.toml` as the version fallback**
605 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.
606
607 **QEMU for arm64 Linux**
608 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`.
609
610 **`yum` vs `apt` in manylinux**
611 manylinux2014 is CentOS 7. Use `yum`, not `apt`. For manylinux_2_28 (AlmaLinux 8) use `dnf`.
612
613 **Missing `User-Agent` on crates.io API requests**
614 crates.io rejects curl requests without a `User-Agent` header. Always include `-H "User-Agent: smbcloud-cli-release-workflow"`.
615
616 **Enabling vendored OpenSSL unconditionally on Windows**
617 `openssl = { features = ["vendored"] }` as an unconditional dependency forces `openssl-src` to compile OpenSSL from source on Windows. Windows builds fail because the runner Perl is missing modules required by OpenSSL's `Configure` script. Use `[target.'cfg(not(windows))'.dependencies]` so the `openssl` crate is never pulled into the Windows dependency graph. `libgit2-sys` uses WinHTTP on Windows and does not need OpenSSL.
618
619 **Distribution README out of sync with main README**
620 When the main `README.MD` changes its tagline, logo URL, quick start commands, or value proposition, the npm and PyPI READMEs must be updated in the same commit. Stale distribution READMEs show outdated branding on npmjs.com and pypi.org — the highest-visibility surfaces for new users discovering the CLI.
621
622 **Platform support table missing new targets**
623 When a new platform is added to any release matrix (e.g. `linux-arm64`), update the platform support table in `npm/smbcloud-cli/README.md` and `pypi/README.md` in the same PR.
624
625 **Use workflow-level `env:` for compile-time secrets, not `$GITHUB_ENV` export steps**
626 Compile-time secrets like `CLI_CLIENT_SECRET` (read by `env!()`) must be available to every `cargo` invocation across all platforms. Writing to `$GITHUB_ENV` in a separate step is unreliable: it does not propagate into Docker containers, and on Windows runners the default PowerShell shell does not expand `"$GITHUB_ENV"` correctly.
627
628 Declare the secret in the workflow-level `env:` block:
629
630 ```yaml
631 env:
632 CLI_CLIENT_SECRET: ${{ secrets.CLI_CLIENT_SECRET }}
633 CARGO_TERM_COLOR: always
634 ```
635
636 Never add a dedicated "Export build secret" step for this purpose.
637
638 **manylinux Docker containers require `docker-options` to forward env vars**
639 Even with the secret in the workflow-level `env:` block, maturin-action's manylinux builds run inside a Docker container that does not automatically inherit the runner's environment. The secret is visible to the action process (shown as `CLI_CLIENT_SECRET: ***` in the step log) but is not forwarded into the container unless explicitly requested.
640
641 Fix: add `docker-options: -e CLI_CLIENT_SECRET` to the `Build wheel` step:
642
643 ```yaml
644 - name: Build wheel
645 uses: PyO3/maturin-action@v1
646 with:
647 target: ${{ matrix.build.TARGET }}
648 manylinux: ${{ matrix.build.MANYLINUX || 'off' }}
649 working-directory: pypi
650 args: --release --locked --compatibility pypi --out dist
651 before-script-linux: yum install -y perl-core
652 docker-options: -e CLI_CLIENT_SECRET
653 ```
654
655 `-e CLI_CLIENT_SECRET` tells `docker run` to forward that variable from the runner process into the container. Windows and macOS legs are unaffected (they do not use Docker). Without this, `env!("CLI_CLIENT_SECRET")` fails only on Linux manylinux legs while all other platforms succeed — making the root cause easy to miss.
656
657 **PyPI blocks short and protocol-reserved package names**
658 PyPI maintains a blocklist of names that are too generic or conflict with well-known protocols and tools. `smb` is blocked because it is the name of the Windows SMB/CIFS file sharing protocol. Attempting to publish a package named `smb` returns `400 The name 'smb' isn't allowed`. There is no workaround — the name cannot be registered regardless of who owns it. If you need `uvx <name>` to work without `--from`, the package name and binary name must match AND the package name must not be on the blocklist. For this repo, `uvx --from smbcloud-cli smb` is the correct form.
659
660 **`on: release: types: [published]` does not fire when the release is created by automation**
661 When `softprops/action-gh-release` (or any action) creates a GitHub Release using the implicit `GITHUB_TOKEN`, GitHub does not dispatch the `release` event to other workflows. The downstream workflow simply never runs — no error, no log entry. Use `workflow_dispatch` via `actions/github-script@v7` to chain workflows explicitly, and add `actions: write` to the upstream workflow's `permissions`.
662
663 **Downloading full tarballs in the Homebrew workflow to compute SHA256**
664 The Homebrew formula only needs the SHA256 hash — Homebrew itself downloads the tarball at install time. Compute the SHA256 during the build in `release-github.yml`, save it as a `.sha256` file, attach it to the GitHub Release, and then have `release-homebrew.yml` download only those tiny text files with `gh release download --pattern "*.sha256"`. Do not re-download multi-megabyte binaries and do not re-run Rust compilation.
665
666 **Rebuilding macOS binaries in `release-homebrew.yml`**
667 `release-github.yml` already builds macOS binaries. Running a second Rust compilation in `release-homebrew.yml` wastes two macOS runner slots and risks producing binaries with different checksums if the environment differs. Build once, reuse the output.
668
669 **Homebrew tarball contains wrong binary name**
670 The formula uses `bin.install 'smb'`. The tarball must contain the binary named `smb`, not `smb-macos-arm64`. Strip and repackage correctly in the "Package macOS tarball for Homebrew" step.