1 ---
2 name: ci-cd
3 description: Use when creating, debugging, or improving GitHub Actions workflows for this repo — especially the crates.io release orchestrator and the downstream CLI and SDK release workflows for PyPI, npm, GitHub Releases, Homebrew tap, and NuGet. Also covers SDK distribution README branding alignment across npm, PyPI, and the main repo. Covers cargo-workspaces publish flow, release orchestration, 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 release orchestration
9
10 | Role | Workflow file |
11 | ----------------------------------- | ---------------------------------------- |
12 | Workspace crates publish entrypoint | `.github/workflows/release-crate.yml` |
13 | GitHub Release binaries | `.github/workflows/release-github.yml` |
14 | Homebrew tap update | `.github/workflows/release-homebrew.yml` |
15 | CLI npm release | `.github/workflows/release-npm.yml` |
16 | CLI PyPI release | `.github/workflows/release-pypi.yml` |
17 | CLI NuGet release | `.github/workflows/release-nuget.yml` |
18 | SDK npm release | `.github/workflows/release-sdk-npm.yml` |
19 | SDK PyPI release | `.github/workflows/release-sdk-pypi.yml` |
20 | SDK Ruby gem release | `.github/workflows/release-sdk-gem.yml` |
21
22 `release-crate.yml` is the top of the chain. It publishes Rust crates first, then fans out to the CLI distribution workflows, and then conditionally triggers SDK workflows when the published crate set says they need to run.
23
24 ## Source of truth for distribution channel READMEs
25
26 | File | Published as |
27 | ---------------------------- | ------------------------------------------------- |
28 | `README.MD` | GitHub repo — canonical brand voice |
29 | `npm/smbcloud-cli/README.md` | npmjs.com — `@smbcloud/cli` |
30 | `pypi/README.md` | pypi.org — `smbcloud-cli` |
31 | `npm/README.md.tmpl` | npmjs.com — `@smbcloud/cli-*` (platform packages) |
32
33 When the main `README.MD` changes its tagline, logo, quick start, or value proposition, update all three distribution READMEs to match.
34
35 ---
36
37 ## Universal workflow patterns
38
39 The repo now has two kinds of release workflows.
40
41 ### Trigger shape
42
43 **Orchestrator workflow:**
44
45 `release-crate.yml` is the only release workflow that should run directly from a tag push.
46
47 ```yaml
48 on:
49 push:
50 tags:
51 - "v*.*.*"
52 workflow_dispatch:
53 inputs:
54 tag:
55 description: "Release tag (e.g. v0.3.35)"
56 required: true
57 ```
58
59 **Downstream workflows:**
60
61 Everything else in the release chain should be `workflow_dispatch` only. The orchestrator passes the tag in explicitly.
62
63 ```yaml
64 on:
65 workflow_dispatch:
66 inputs:
67 tag:
68 description: "Release tag (e.g. v0.3.35)"
69 required: true
70 ```
71
72 ### Checkout — always include the ref override
73
74 ```yaml
75 - name: Checkout
76 uses: actions/checkout@v6
77 with:
78 ref: ${{ github.event.inputs.tag || github.ref }}
79 ```
80
81 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.
82
83 ### Version resolution
84
85 For simple downstream workflows, resolving `RELEASE_VERSION` is enough:
86
87 ```yaml
88 - name: Set the release version
89 shell: bash
90 run: |
91 if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
92 release_version="${GITHUB_REF_NAME#v}"
93 else
94 release_version="${{ github.event.inputs.tag }}"
95 release_version="${release_version#v}"
96 fi
97
98 echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV"
99 ```
100
101 For the orchestrator, resolve both the full tag and the stripped version:
102
103 ```yaml
104 - name: Set the release version
105 id: release
106 shell: bash
107 run: |
108 if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
109 release_tag="${GITHUB_REF_NAME}"
110 release_version="${GITHUB_REF_NAME#v}"
111 else
112 release_tag="${{ github.event.inputs.tag }}"
113 release_tag="${release_tag#refs/tags/}"
114 release_version="${release_tag#v}"
115 fi
116
117 echo "RELEASE_TAG=${release_tag}" >> "$GITHUB_ENV"
118 echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV"
119 echo "tag=${release_tag}" >> "$GITHUB_OUTPUT"
120 ```
121
122 Never read the version from `Cargo.toml` with `sed` in the `else` branch. That quietly turns a tag-driven release into a whatever-is-on-branch release.
123
124 ### Toolchain — always read from `rust-toolchain.toml`
125
126 ```yaml
127 - name: Read Rust toolchain
128 shell: bash
129 run: |
130 rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)"
131 if [ -z "$rust_toolchain" ]; then
132 echo "Failed to read Rust toolchain from rust-toolchain.toml" >&2
133 exit 1
134 fi
135
136 echo "RUST_TOOLCHAIN=${rust_toolchain}" >> "$GITHUB_ENV"
137
138 - name: Install toolkit
139 uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9
140 with:
141 toolchain: ${{ env.RUST_TOOLCHAIN }}
142 ```
143
144 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.
145
146 ### Rust cache — use a per-target key
147
148 ```yaml
149 - name: Setup Rust cache
150 uses: Swatinem/rust-cache@v2
151 with:
152 key: <job-name>-${{ matrix.target }}
153 ```
154
155 Without a unique key, cache entries from different targets can collide and corrupt each other.
156
157 ### Idempotency — check before publishing
158
159 Every publish step must be safe to rerun.
160
161 **crates.io workspace release:**
162
163 This repo no longer publishes a single crate with `cargo publish --package ...` in CI. The crates workflow uses `cargo workspaces plan --skip-published --json` to decide whether there is anything left to do.
164
165 ```yaml
166 - name: Build unpublished crate plan
167 id: plan
168 shell: bash
169 env:
170 CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
171 run: |
172 cargo workspaces plan --skip-published --json --token "${CARGO_REGISTRY_TOKEN}" > plan.json
173 plan_count="$(jq 'length' plan.json)"
174 echo "count=${plan_count}" >> "$GITHUB_OUTPUT"
175
176 - name: Publish workspace crates to crates.io
177 if: steps.plan.outputs.count != '0'
178 run: |
179 cargo workspaces publish \
180 --publish-as-is \
181 --yes \
182 --locked \
183 --allow-branch "*" \
184 --publish-interval 15 \
185 --token "${CARGO_REGISTRY_TOKEN}"
186 ```
187
188 If the plan is empty, stop cleanly and let the workflow say so.
189
190 **PyPI:**
191
192 ```yaml
193 - name: Check whether release already exists on PyPI
194 id: pypi-check
195 shell: bash
196 run: |
197 if curl -fsS "https://pypi.org/pypi/smbcloud-cli/${RELEASE_VERSION}/json" >/dev/null 2>&1; then
198 echo "exists=true" >> "$GITHUB_OUTPUT"
199 else
200 echo "exists=false" >> "$GITHUB_OUTPUT"
201 fi
202
203 - name: Publish distribution
204 if: steps.pypi-check.outputs.exists != 'true'
205 uses: pypa/gh-action-pypi-publish@release/v1
206 with:
207 skip-existing: true
208 ```
209
210 `skip-existing: true` is a safety net for partial re-runs where some wheels already exist but others do not.
211
212 **npm:** The check is inline in the publish shell script:
213
214 ```bash
215 if npm view "${npm_package_name}@${node_version}" version >/dev/null 2>&1; then
216 echo "${npm_package_name}@${node_version} already exists on npm, skipping publish"
217 exit 0
218 fi
219 npm publish --access public
220 ```
221
222 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.
223
224 ### Version guard — match the check to the workflow
225
226 For single-package workflows, checking one package against the tag is still fine.
227
228 For the crates orchestrator, check the workspace members under `/crates` as a group and be honest about what you are guarding. The point is to catch a bad tag early, not to pretend this is a single-package publish flow.
229
230 ### Artifact actions
231
232 Use `actions/upload-artifact@v7` and `actions/download-artifact@v7` consistently across all workflows.
233
234 ---
235
236 ## Workspace scoping — use `--package` for build jobs, not for the crates orchestrator
237
238 For normal build jobs, do not build the whole workspace by accident.
239
240 This workspace includes `smbcloud-auth-sdk-py`, a PyO3 `cdylib` that requires Python symbols at link time. Building the full workspace on platforms without a matching Python interpreter causes:
241
242 ```
243 ld: symbol(s) not found for architecture arm64
244 clang: error: linker command failed with exit code 1
245 ```
246
247 Scope cargo build commands like this:
248
249 ```bash
250 cargo build --release --locked --target <target> --package smbcloud-cli
251 ```
252
253 The crates release orchestrator is the exception. That workflow is supposed to operate at the workspace root with `cargo workspaces plan` and `cargo workspaces publish`.
254
255 The PyPI workflow is also exempt because maturin is already pointed at the right crate via `manifest-path`.
256
257 ---
258
259 ## Platform matrix
260
261 ### Current runners and targets
262
263 | Platform | Runner | Target | Notes |
264 | ------------- | ------------------ | --------------------------- | -------------------------- |
265 | Linux x86_64 | `ubuntu-latest` | `x86_64-unknown-linux-gnu` | Native |
266 | Linux arm64 | `ubuntu-24.04-arm` | `aarch64-unknown-linux-gnu` | Native — do not use QEMU |
267 | Windows x64 | `windows-2022` | `x86_64-pc-windows-msvc` | Native |
268 | Windows arm64 | `windows-2022` | `aarch64-pc-windows-msvc` | Do not use for PyPI wheels |
269 | macOS x64 | `macos-15-intel` | `x86_64-apple-darwin` | Native Intel runner |
270 | macOS arm64 | `macos-latest` | `aarch64-apple-darwin` | Native Apple Silicon |
271
272 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. Windows arm64 is the exception: GitHub's hosted Windows runners only provide x64 Python, so maturin will refuse to build an `aarch64-pc-windows-msvc` wheel when the interpreter reports `win-amd64`.
273
274 ### Do not use QEMU for arm64 Linux
275
276 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.
277
278 ---
279
280 ## OpenSSL vendoring
281
282 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:
283
284 ```
285 Could not find directory of OpenSSL installation
286 ```
287
288 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.
289
290 ### Fix applied
291
292 `Cargo.toml` (workspace) — declares the vendored feature once:
293
294 ```toml
295 openssl = { version = "0.10", features = ["vendored"] }
296 ```
297
298 `crates/cli/Cargo.toml` — activates it only on non-Windows targets:
299
300 ```toml
301 [target.'cfg(not(windows))'.dependencies]
302 openssl = { workspace = true }
303 ```
304
305 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).
306
307 ### Per-platform behaviour
308
309 | Platform | SSL backend | `openssl-sys/vendored` | OpenSSL compiled from source |
310 | ------------- | ------------------- | ---------------------- | ---------------------------- |
311 | Linux (gnu) | vendored OpenSSL | ✅ | ✅ |
312 | macOS arm64 | vendored OpenSSL | ✅ | ✅ |
313 | macOS x64 | vendored OpenSSL | ✅ | ✅ |
314 | Windows x64 | WinHTTP (OS native) | ❌ | ❌ |
315 | Windows arm64 | WinHTTP (OS native) | ❌ | ❌ |
316
317 ---
318
319 ## manylinux containers and Perl
320
321 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.
322
323 ### Symptom
324
325 ```
326 Can't locate IPC/Cmd.pm in @INC
327 Can't locate Time/Piece.pm in @INC
328 ```
329
330 ### Fix
331
332 ```yaml
333 - name: Build wheel
334 uses: PyO3/maturin-action@v1
335 with:
336 before-script-linux: yum install -y perl-core
337 ```
338
339 `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.
340
341 `before-script-linux` runs inside the manylinux Docker container, not the host runner. It is ignored on macOS and Windows runners.
342
343 ### manylinux2014 uses `yum`, not `apt`
344
345 The manylinux2014 image is CentOS 7 based. Use `yum`. Using `apt` fails with exit code 127 ("command not found").
346
347 ### manylinux target per architecture
348
349 | Architecture | manylinux target | Container base | Package manager |
350 | ------------ | ---------------- | ---------------------------- | --------------- |
351 | x86_64 | `2014` | CentOS 7 | `yum` |
352 | aarch64 | `2014` | CentOS 7 (via native runner) | `yum` |
353
354 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.
355
356 ---
357
358 ## Trusted publishing
359
360 ### PyPI — uses OIDC trusted publishing
361
362 ```yaml
363 permissions:
364 id-token: write
365 contents: read
366
367 - name: Publish distribution
368 uses: pypa/gh-action-pypi-publish@release/v1
369 ```
370
371 No `PYPI_TOKEN` secret needed. The action exchanges the GitHub OIDC JWT for a short-lived PyPI token automatically.
372
373 **Each PyPI package × workflow combination needs its own trusted publisher.** The CLI package (`smbcloud-cli`) is configured for `release-pypi.yml`, but the SDK package (`smbcloud-sdk-auth`) needs a separate trusted publisher pointing at `release-sdk-pypi.yml`. Configure it at `https://pypi.org/manage/project/<package>/settings/publishing/` with:
374
375 - Owner: `smbcloudXYZ`
376 - Repository: `smbcloud-cli`
377 - Workflow name: the exact `.yml` filename that publishes that package
378 - Environment: leave blank
379
380 If the trusted publisher is missing, the publish step fails with `invalid-publisher: valid token, but no corresponding publisher`.
381
382 ### npm — trusted publishing, configured per package
383
384 The npm workflows (`release-npm.yml`, `release-sdk-npm.yml`, `release-xcrs-npm.yml`) publish over OIDC. There is no `NPM_TOKEN` secret and no `NODE_AUTH_TOKEN` on the publish steps. Each workflow needs:
385
386 ```yaml
387 permissions:
388 id-token: write
389 contents: read
390 ```
391
392 npm >= 11.5.1 and Node >= 22.14.0 are required, which `.nvmrc` covers.
393
394 The trusted publisher is set on each package, not on the `@smbcloud` scope, and it names one workflow file. Every platform package needs its own entry:
395
396 ```sh
397 npm trust github @smbcloud/cli-linux-arm64 --repo smbcloudXYZ/smbcloud-cli --file release-npm.yml
398 ```
399
400 A package without one fails at `npm publish` while its siblings succeed. `@smbcloud/cli-linux-arm64` was missed this way: it kept publishing on the stored token until the token died, and for eight releases starting at v0.5.3 its 404 also skipped the base `@smbcloud/cli` job, which `needs` every platform job.
401
402 A 404 on `npm publish` does not mean the package does not exist. npm returns 404 for auth failures to avoid leaking package existence.
403
404 `npm trust` only works on a package that already exists, so the first version of a new package name has to be published by hand. See the `smbcloud-cli-release` skill for details.
405
406 ### crates.io — uses `CARGO_REGISTRY_TOKEN`
407
408 ```yaml
409 - name: Publish workspace crates to crates.io
410 env:
411 CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
412 run: |
413 cargo workspaces publish \
414 --publish-as-is \
415 --yes \
416 --locked \
417 --allow-branch "*" \
418 --publish-interval 15 \
419 --token "${CARGO_REGISTRY_TOKEN}"
420 ```
421
422 This repo uses `cargo-workspaces` for the Rust release flow. CI is not doing the interactive version bump step. It is publishing the already-prepared tagged workspace state.
423
424 crates.io also supports OIDC trusted publishing, but that is separate from the current workflow.
425
426 ---
427
428 ## Split build + release job pattern
429
430 For the GitHub Release workflow, separate the build matrix from the release step:
431
432 ```yaml
433 jobs:
434 build:
435 strategy:
436 matrix: ...
437 steps:
438 - name: Build binary
439 run: cargo build ...
440 - name: Upload artifact
441 uses: actions/upload-artifact@v7
442
443 release:
444 needs: build
445 runs-on: ubuntu-latest
446 steps:
447 - name: Download all artifacts
448 uses: actions/download-artifact@v7
449 with:
450 pattern: "binary-*"
451 merge-multiple: true
452 - name: Release
453 uses: softprops/action-gh-release@v2
454 with:
455 files: release/*
456 ```
457
458 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.
459
460 ---
461
462 ## Chaining workflows — use explicit dispatches
463
464 Do not rely on GitHub events magically fanning out through the release chain. Keep the chain explicit.
465
466 GitHub does not fire downstream repository events the way people expect when the upstream action used `GITHUB_TOKEN`. If you want one release workflow to kick off another, dispatch it yourself.
467
468 The current release chain is:
469
470 - `release-crate.yml` publishes workspace crates
471 - then dispatches CLI distribution workflows:
472 - `release-github.yml`
473 - `release-npm.yml`
474 - `release-nuget.yml`
475 - `release-pypi.yml`
476 - then conditionally dispatches SDK workflows based on the crate publish plan:
477 - `release-sdk-pypi.yml`
478 - `release-sdk-npm.yml`
479 - `release-sdk-gem.yml`
480 - `release-github.yml` dispatches `release-homebrew.yml` after the GitHub Release assets exist
481
482 This pattern needs `actions: write` in the upstream workflow permissions.
483
484 ```yaml
485 permissions:
486 contents: write
487 actions: write
488
489 - name: Trigger Homebrew release
490 uses: actions/github-script@v7
491 with:
492 script: |
493 await github.rest.actions.createWorkflowDispatch({
494 owner: context.repo.owner,
495 repo: context.repo.repo,
496 workflow_id: 'release-homebrew.yml',
497 ref: context.payload.repository.default_branch,
498 inputs: {
499 tag: '${{ steps.tag.outputs.tag }}'
500 }
501 })
502 ```
503
504 A few practical rules:
505
506 - downstream workflows must expose `workflow_dispatch` with a `tag` input
507 - dispatch from the repo default branch so the workflow definition comes from the current release lane
508 - still check out the requested tag inside the downstream workflow so the built code matches the release
509 - do not dispatch Homebrew until the GitHub Release job has uploaded the assets it needs
510
511 This is a fire-and-forget chain. If you want one green check to mean the whole release tree passed, you need a more involved callback or polling design. Right now the pattern is good enough, but it is not magic.
512
513 ---
514
515 ## Homebrew tap release pattern
516
517 `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.
518
519 ### Key principle: compute SHA256 once, at build time
520
521 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.
522
523 **In `release-github.yml` (build job, macOS legs only):**
524
525 ```yaml
526 - name: Package macOS tarball for Homebrew
527 if: contains(matrix.target, 'apple-darwin')
528 shell: bash
529 run: |
530 ARCHIVE_NAME="${PROJECT_NAME}-${{ matrix.name }}.tar.gz"
531
532 mkdir -p staging-brew
533 cp "./release/${PROJECT_NAME}-${{ matrix.name }}" "staging-brew/${PROJECT_NAME}"
534 strip "staging-brew/${PROJECT_NAME}"
535
536 tar -C staging-brew -czf "${ARCHIVE_NAME}" "${PROJECT_NAME}"
537
538 # Compute and save the checksum alongside the archive
539 shasum -a 256 "${ARCHIVE_NAME}" | awk '{print $1}' > "${ARCHIVE_NAME}.sha256"
540
541 - name: Upload macOS Homebrew artifacts
542 if: contains(matrix.target, 'apple-darwin')
543 uses: actions/upload-artifact@v7
544 with:
545 name: homebrew-${{ matrix.name }}
546 path: |
547 ${{ env.PROJECT_NAME }}-${{ matrix.name }}.tar.gz
548 ${{ env.PROJECT_NAME }}-${{ matrix.name }}.tar.gz.sha256
549 ```
550
551 Both the `.tar.gz` and the `.sha256` file are downloaded in the release job and attached to the GitHub Release via `files: release/*`.
552
553 **In `release-homebrew.yml` (single job):**
554
555 ```yaml
556 - name: Read SHA256 checksums from release
557 id: sha
558 env:
559 GH_TOKEN: ${{ github.token }}
560 shell: bash
561 run: |
562 mkdir -p artifacts
563
564 for attempt in 1 2 3 4 5; do
565 if gh release download "${{ steps.release.outputs.tag }}" \
566 --repo "${{ env.REPO }}" \
567 --pattern "smb-macos-*.tar.gz.sha256" \
568 --dir artifacts/; then
569 break
570 fi
571
572 if [ "$attempt" -eq 5 ]; then
573 echo "Failed to download Homebrew checksum artifacts from the GitHub release." >&2
574 exit 1
575 fi
576
577 echo "Release assets are not visible yet. Waiting 10 seconds..."
578 sleep 10
579 done
580 ```
581
582 `gh` is pre-installed on GitHub-hosted runners. Add a small retry loop here because the GitHub Release exists first and the Homebrew workflow arrives a moment later. Usually that gap is tiny. Sometimes it is not.
583
584 ### Formula URL convention
585
586 The formula `url:` references the release asset directly. Homebrew fetches it at install time — CI never downloads it:
587
588 ```ruby
589 on_macos do
590 on_arm do
591 url 'https://github.com/smbcloudXYZ/smbcloud-cli/releases/download/v0.3.36/smb-macos-arm64.tar.gz'
592 sha256 '<computed during build>'
593 end
594 on_intel do
595 url 'https://github.com/smbcloudXYZ/smbcloud-cli/releases/download/v0.3.36/smb-macos-amd64.tar.gz'
596 sha256 '<computed during build>'
597 end
598 end
599 ```
600
601 The tarball must contain the binary named `smb` (not `smb-macos-arm64`) because the formula's install step is `bin.install 'smb'`.
602
603 ### Homebrew tap permissions
604
605 `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.
606
607 ---
608
609 ## `fail-fast: false`
610
611 Always set `fail-fast: false` on release build matrices:
612
613 ```yaml
614 strategy:
615 fail-fast: false
616 matrix: ...
617 ```
618
619 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.
620
621 ---
622
623 ## SDK distribution README branding
624
625 Every distribution channel README must stay aligned with the main `README.MD`. The three elements that must always match exactly:
626
627 - **Logo** — `https://avatars.githubusercontent.com/u/89791739?s=200&v=4`, width 128
628 - **Tagline** — "Deploy to the cloud in one command."
629 - **Quick start** — `smb login` → `smb init` → `smb deploy`
630
631 ### Structure per README type
632
633 **Wrapper package** (`npm/smbcloud-cli/README.md` → `@smbcloud/cli`, `pypi/README.md` → `smbcloud-cli`):
634
635 1. Logo + tagline + nav links + badges
636 2. About section (copy from main README verbatim)
637 3. Primary install method for that channel first (`npm install -g` or `pip install`)
638 4. Quick start
639 5. All other install methods (Homebrew, npm, pip, shell, PowerShell)
640 6. Documentation link
641 7. Platform support table
642 8. Source & Issues pointer to GitHub
643 9. License
644
645 The PyPI README leads with the PyPI badge; the npm README leads with the npm badge. Both include the full badge row.
646
647 **Platform binary package** (`npm/README.md.tmpl` → `@smbcloud/cli-darwin-arm64`, etc.):
648
649 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.
650
651 ### Platform support table
652
653 Keep this table consistent across all READMEs:
654
655 ```md
656 | Platform | Architecture |
657 | ------------- | ------------ |
658 | macOS | arm64, x64 |
659 | Linux (glibc) | arm64, x64 |
660 | Windows | arm64, x64 |
661 ```
662
663 Update it whenever a new platform is added to the release matrix.
664
665 ### PyPI-specific copy
666
667 Add this line to distinguish the pip install from npm:
668
669 > This package installs the native `smb` executable for your platform directly — no Node.js, no Docker, no runtime dependencies.
670
671 ### Template variable in `README.md.tmpl`
672
673 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.
674
675 ---
676
677 ## SDK trigger rules
678
679 Use the crates publish plan to decide whether an SDK release should run.
680
681 Current mappings:
682
683 - **SDK PyPI** runs when the plan includes any of:
684 - `smbcloud-auth-sdk-py`
685 - `smbcloud-auth-sdk`
686 - `smbcloud-model`
687 - `smbcloud-network`
688 - **SDK npm** runs when the plan includes any of:
689 - `smbcloud-auth-sdk-wasm`
690 - `smbcloud-auth-sdk`
691 - `smbcloud-network`
692 - `smbcloud-networking`
693 - **SDK Ruby gem** runs when the plan includes any of:
694 - `smbcloud-auth-sdk`
695 - `smbcloud-model`
696 - `smbcloud-network`
697
698 These are based on the actual wrappers in `sdk/`:
699
700 - `sdk/python/pyproject.toml` points maturin at `crates/smbcloud-auth-sdk-py/Cargo.toml`
701 - `sdk/npm/smbcloud-auth/prepare-package.mjs` builds from `crates/smbcloud-auth-sdk-wasm`
702 - `sdk/gems/auth/ext/auth/Cargo.toml` pulls published crates from crates.io
703
704 One practical warning: the Ruby gem release depends on crates.io propagation. If crates were just published, wait for them to appear before building the gem.
705
706 ## Common mistakes
707
708 **SDK npm version out of sync with Rust crate version**
709 `sdk/npm/smbcloud-auth/package.json` must have the same version as `crates/smbcloud-auth-sdk-wasm/Cargo.toml`. The `prepare-package.mjs` script compares them at build time and throws a hard error on mismatch. When bumping workspace crate versions for a release, always update the npm package version in the same commit. Forgetting this causes the `check-npm-sdk` CI job and the `release-sdk-npm` workflow to fail.
710
711 **SDK Ruby gem versions out of sync with workspace crate versions**
712 Three files must be bumped together for each gem in `sdk/gems/`:
713
714 - `lib/<gem>/version.rb` — the gem version (`Auth::VERSION`, `Model::VERSION`)
715 - `ext/<gem>/Cargo.toml` — the native extension crate version
716 - `ext/<gem>/Cargo.toml` dependencies — the `smbcloud-*` version constraints (e.g. `"0.3"` → `"0.4"`)
717
718 After bumping, regenerate lockfiles with `cargo generate-lockfile` and `bundle lock` inside each gem directory. The `release-sdk-gem` workflow checks `Auth::VERSION` against the tag and fails on mismatch.
719
720 **Tagging a release on a feature branch instead of `development`**
721 Always tag on `development` (the mainline branch). Tagging on a feature branch leaves `development` without the release commit, making git history confusing and future releases error-prone. Merge the feature branch into `development` first, then tag. If a tag was already placed on the wrong commit, move it with `git tag -f v<version>` and `git push origin v<version> --force`.
722
723 **"Re-run all jobs" on an old workflow run rebuilds from the old commit**
724 GitHub Actions workflow re-runs always use the commit the run was originally dispatched with. If you moved a tag after the run was created, the re-run still checks out the old commit. Use "Re-run failed jobs" only when the code on that commit is correct and only an external issue (e.g. missing trusted publisher) caused the failure. If the code itself was fixed, dispatch a fresh run instead: `gh workflow run <workflow>.yml -f tag=v<version>`.
725
726 **Building the full workspace in release workflows**
727 Always pass `--package smbcloud-cli` to `cargo build` and `cargo publish`. Omitting it builds `smbcloud-auth-sdk-py` (PyO3 cdylib) which fails on platforms without a matching Python interpreter.
728
729 **`exit 0` in a separate step does not skip the next step**
730 `exit 0` only terminates the current `run` shell. The following step runs regardless. Use `$GITHUB_OUTPUT` + `if:` conditions for inter-step flow control.
731
732 **Hardcoded toolchain version**
733 Never hardcode `toolchain: 1.92` or use `@stable`. Always read from `rust-toolchain.toml`.
734
735 **Missing `ref:` on checkout for `workflow_dispatch`**
736 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.
737
738 **Using `sed` on `Cargo.toml` as the version fallback**
739 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.
740
741 **QEMU for arm64 Linux**
742 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`.
743
744 **Windows arm64 PyPI wheels on hosted runners**
745 Maturin needs a matching Python interpreter for wheel generation. On GitHub-hosted Windows runners, the available interpreter reports `win-amd64`, so an `aarch64-pc-windows-msvc` wheel build gets skipped and then fails. Unless you have an arm64 Windows runner with arm64 Python installed, leave Windows arm64 out of the PyPI wheel matrix.
746
747 **`yum` vs `apt` in manylinux**
748 manylinux2014 is CentOS 7. Use `yum`, not `apt`. For manylinux_2_28 (AlmaLinux 8) use `dnf`.
749
750 **Missing `User-Agent` on crates.io API requests**
751 crates.io rejects curl requests without a `User-Agent` header. Always include `-H "User-Agent: smbcloud-cli-release-workflow"`.
752
753 **Enabling vendored OpenSSL unconditionally on Windows**
754 `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.
755
756 **Distribution README out of sync with main README**
757 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.
758
759 **Platform support table missing new targets**
760 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.
761
762 **Use workflow-level `env:` for compile-time secrets, not `$GITHUB_ENV` export steps**
763 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.
764
765 Declare the secret in the workflow-level `env:` block:
766
767 ```yaml
768 env:
769 CLI_CLIENT_SECRET: ${{ secrets.CLI_CLIENT_SECRET }}
770 CARGO_TERM_COLOR: always
771 ```
772
773 Never add a dedicated "Export build secret" step for this purpose.
774
775 **manylinux Docker containers require `docker-options` to forward env vars**
776 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.
777
778 Fix: add `docker-options: -e CLI_CLIENT_SECRET` to the `Build wheel` step:
779
780 ```yaml
781 - name: Build wheel
782 uses: PyO3/maturin-action@v1
783 with:
784 target: ${{ matrix.build.TARGET }}
785 manylinux: ${{ matrix.build.MANYLINUX || 'off' }}
786 working-directory: pypi
787 args: --release --locked --compatibility pypi --out dist
788 before-script-linux: yum install -y perl-core
789 docker-options: -e CLI_CLIENT_SECRET
790 ```
791
792 `-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.
793
794 **PyPI blocks short and protocol-reserved package names**
795 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.
796
797 **Multiple release workflows all listening to tag pushes**
798 Only `release-crate.yml` should run directly from a release tag. The downstream workflows should be `workflow_dispatch` only. If they all listen to `push.tags`, they race each other and you lose control of the order.
799
800 **`on: release: types: [published]` does not fire when the release is created by automation**
801 When `softprops/action-gh-release` creates a GitHub Release using the implicit `GITHUB_TOKEN`, GitHub does not dispatch the `release` event to other workflows. The downstream workflow just never runs. Use `workflow_dispatch` via `actions/github-script@v7` instead, and give the upstream workflow `actions: write`.
802
803 **Downloading full tarballs in the Homebrew workflow to compute SHA256**
804 The Homebrew formula only needs the SHA256 hash. Compute it during the build in `release-github.yml`, attach the `.sha256` files to the GitHub Release, and have `release-homebrew.yml` download only those tiny files. Do not re-download full binaries and do not rebuild Rust code there.
805
806 **Forgetting that Homebrew depends on the GitHub Release assets**
807 Homebrew cannot run first. It needs the `.sha256` files attached by `release-github.yml`. Keep that dependency explicit.
808
809 **Starting the Ruby gem release before crates.io has caught up**
810 `sdk/gems/auth/ext/auth/Cargo.toml` pulls `smbcloud-auth-sdk`, `smbcloud-model`, and `smbcloud-network` from crates.io. If the gem workflow starts immediately after the crates publish job, the index may not be ready yet. Add a small wait-and-retry loop against the crates.io API before the gem build starts.
811
812 **Rebuilding macOS binaries in `release-homebrew.yml`**
813 `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.
814
815 **Homebrew tarball contains wrong binary name**
816 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.