feature/fix-clippy-on-stable-channel
claude/smbcloud-github-app-k9641p
development
feature/apps
feature/build-release
feature/ci-docs
feature/ci-ruby-gems
feature/cicd
feature/completion
feature/dist-nuget
feature/fix-ci-sdk-pypi-rubygem
feature/fix-clippy-on-stable-channel
feature/fix-deployment-script
feature/github-email
feature/gresiq-v1
feature/project-new-structure
feature/rsync-deploy
feature/ruby-runner
feature/sdk-npm
feature/sdk-workflows
feature/smbcloud-cli-release-npm
feature/smbcloud-deploy
feature/wasm
main
release/v0.7.4
| 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 — does NOT support trusted publishing natively |
| 383 | |
| 384 | npm has no OIDC-based trusted publishing equivalent to PyPI. The `id-token: write` permission in the npm workflow is unused for auth. |
| 385 | |
| 386 | Authentication requires a stored secret: |
| 387 | |
| 388 | ```yaml |
| 389 | - name: Publish to NPM |
| 390 | env: |
| 391 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} |
| 392 | ``` |
| 393 | |
| 394 | `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). |
| 395 | |
| 396 | The 404 on `npm publish` does not mean the package does not exist. npm returns 404 for auth failures to avoid leaking package existence. |
| 397 | |
| 398 | **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. |
| 399 | |
| 400 | ### crates.io — uses `CARGO_REGISTRY_TOKEN` |
| 401 | |
| 402 | ```yaml |
| 403 | - name: Publish workspace crates to crates.io |
| 404 | env: |
| 405 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} |
| 406 | run: | |
| 407 | cargo workspaces publish \ |
| 408 | --publish-as-is \ |
| 409 | --yes \ |
| 410 | --locked \ |
| 411 | --allow-branch "*" \ |
| 412 | --publish-interval 15 \ |
| 413 | --token "${CARGO_REGISTRY_TOKEN}" |
| 414 | ``` |
| 415 | |
| 416 | 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. |
| 417 | |
| 418 | crates.io also supports OIDC trusted publishing, but that is separate from the current workflow. |
| 419 | |
| 420 | --- |
| 421 | |
| 422 | ## Split build + release job pattern |
| 423 | |
| 424 | For the GitHub Release workflow, separate the build matrix from the release step: |
| 425 | |
| 426 | ```yaml |
| 427 | jobs: |
| 428 | build: |
| 429 | strategy: |
| 430 | matrix: ... |
| 431 | steps: |
| 432 | - name: Build binary |
| 433 | run: cargo build ... |
| 434 | - name: Upload artifact |
| 435 | uses: actions/upload-artifact@v7 |
| 436 | |
| 437 | release: |
| 438 | needs: build |
| 439 | runs-on: ubuntu-latest |
| 440 | steps: |
| 441 | - name: Download all artifacts |
| 442 | uses: actions/download-artifact@v7 |
| 443 | with: |
| 444 | pattern: "binary-*" |
| 445 | merge-multiple: true |
| 446 | - name: Release |
| 447 | uses: softprops/action-gh-release@v2 |
| 448 | with: |
| 449 | files: release/* |
| 450 | ``` |
| 451 | |
| 452 | 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. |
| 453 | |
| 454 | --- |
| 455 | |
| 456 | ## Chaining workflows — use explicit dispatches |
| 457 | |
| 458 | Do not rely on GitHub events magically fanning out through the release chain. Keep the chain explicit. |
| 459 | |
| 460 | 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. |
| 461 | |
| 462 | The current release chain is: |
| 463 | |
| 464 | - `release-crate.yml` publishes workspace crates |
| 465 | - then dispatches CLI distribution workflows: |
| 466 | - `release-github.yml` |
| 467 | - `release-npm.yml` |
| 468 | - `release-nuget.yml` |
| 469 | - `release-pypi.yml` |
| 470 | - then conditionally dispatches SDK workflows based on the crate publish plan: |
| 471 | - `release-sdk-pypi.yml` |
| 472 | - `release-sdk-npm.yml` |
| 473 | - `release-sdk-gem.yml` |
| 474 | - `release-github.yml` dispatches `release-homebrew.yml` after the GitHub Release assets exist |
| 475 | |
| 476 | This pattern needs `actions: write` in the upstream workflow permissions. |
| 477 | |
| 478 | ```yaml |
| 479 | permissions: |
| 480 | contents: write |
| 481 | actions: write |
| 482 | |
| 483 | - name: Trigger Homebrew release |
| 484 | uses: actions/github-script@v7 |
| 485 | with: |
| 486 | script: | |
| 487 | await github.rest.actions.createWorkflowDispatch({ |
| 488 | owner: context.repo.owner, |
| 489 | repo: context.repo.repo, |
| 490 | workflow_id: 'release-homebrew.yml', |
| 491 | ref: context.payload.repository.default_branch, |
| 492 | inputs: { |
| 493 | tag: '${{ steps.tag.outputs.tag }}' |
| 494 | } |
| 495 | }) |
| 496 | ``` |
| 497 | |
| 498 | A few practical rules: |
| 499 | |
| 500 | - downstream workflows must expose `workflow_dispatch` with a `tag` input |
| 501 | - dispatch from the repo default branch so the workflow definition comes from the current release lane |
| 502 | - still check out the requested tag inside the downstream workflow so the built code matches the release |
| 503 | - do not dispatch Homebrew until the GitHub Release job has uploaded the assets it needs |
| 504 | |
| 505 | 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. |
| 506 | |
| 507 | --- |
| 508 | |
| 509 | ## Homebrew tap release pattern |
| 510 | |
| 511 | `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. |
| 512 | |
| 513 | ### Key principle: compute SHA256 once, at build time |
| 514 | |
| 515 | 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. |
| 516 | |
| 517 | **In `release-github.yml` (build job, macOS legs only):** |
| 518 | |
| 519 | ```yaml |
| 520 | - name: Package macOS tarball for Homebrew |
| 521 | if: contains(matrix.target, 'apple-darwin') |
| 522 | shell: bash |
| 523 | run: | |
| 524 | ARCHIVE_NAME="${PROJECT_NAME}-${{ matrix.name }}.tar.gz" |
| 525 | |
| 526 | mkdir -p staging-brew |
| 527 | cp "./release/${PROJECT_NAME}-${{ matrix.name }}" "staging-brew/${PROJECT_NAME}" |
| 528 | strip "staging-brew/${PROJECT_NAME}" |
| 529 | |
| 530 | tar -C staging-brew -czf "${ARCHIVE_NAME}" "${PROJECT_NAME}" |
| 531 | |
| 532 | # Compute and save the checksum alongside the archive |
| 533 | shasum -a 256 "${ARCHIVE_NAME}" | awk '{print $1}' > "${ARCHIVE_NAME}.sha256" |
| 534 | |
| 535 | - name: Upload macOS Homebrew artifacts |
| 536 | if: contains(matrix.target, 'apple-darwin') |
| 537 | uses: actions/upload-artifact@v7 |
| 538 | with: |
| 539 | name: homebrew-${{ matrix.name }} |
| 540 | path: | |
| 541 | ${{ env.PROJECT_NAME }}-${{ matrix.name }}.tar.gz |
| 542 | ${{ env.PROJECT_NAME }}-${{ matrix.name }}.tar.gz.sha256 |
| 543 | ``` |
| 544 | |
| 545 | Both the `.tar.gz` and the `.sha256` file are downloaded in the release job and attached to the GitHub Release via `files: release/*`. |
| 546 | |
| 547 | **In `release-homebrew.yml` (single job):** |
| 548 | |
| 549 | ```yaml |
| 550 | - name: Read SHA256 checksums from release |
| 551 | id: sha |
| 552 | env: |
| 553 | GH_TOKEN: ${{ github.token }} |
| 554 | shell: bash |
| 555 | run: | |
| 556 | mkdir -p artifacts |
| 557 | |
| 558 | for attempt in 1 2 3 4 5; do |
| 559 | if gh release download "${{ steps.release.outputs.tag }}" \ |
| 560 | --repo "${{ env.REPO }}" \ |
| 561 | --pattern "smb-macos-*.tar.gz.sha256" \ |
| 562 | --dir artifacts/; then |
| 563 | break |
| 564 | fi |
| 565 | |
| 566 | if [ "$attempt" -eq 5 ]; then |
| 567 | echo "Failed to download Homebrew checksum artifacts from the GitHub release." >&2 |
| 568 | exit 1 |
| 569 | fi |
| 570 | |
| 571 | echo "Release assets are not visible yet. Waiting 10 seconds..." |
| 572 | sleep 10 |
| 573 | done |
| 574 | ``` |
| 575 | |
| 576 | `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. |
| 577 | |
| 578 | ### Formula URL convention |
| 579 | |
| 580 | The formula `url:` references the release asset directly. Homebrew fetches it at install time — CI never downloads it: |
| 581 | |
| 582 | ```ruby |
| 583 | on_macos do |
| 584 | on_arm do |
| 585 | url 'https://github.com/smbcloudXYZ/smbcloud-cli/releases/download/v0.3.36/smb-macos-arm64.tar.gz' |
| 586 | sha256 '<computed during build>' |
| 587 | end |
| 588 | on_intel do |
| 589 | url 'https://github.com/smbcloudXYZ/smbcloud-cli/releases/download/v0.3.36/smb-macos-amd64.tar.gz' |
| 590 | sha256 '<computed during build>' |
| 591 | end |
| 592 | end |
| 593 | ``` |
| 594 | |
| 595 | The tarball must contain the binary named `smb` (not `smb-macos-arm64`) because the formula's install step is `bin.install 'smb'`. |
| 596 | |
| 597 | ### Homebrew tap permissions |
| 598 | |
| 599 | `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. |
| 600 | |
| 601 | --- |
| 602 | |
| 603 | ## `fail-fast: false` |
| 604 | |
| 605 | Always set `fail-fast: false` on release build matrices: |
| 606 | |
| 607 | ```yaml |
| 608 | strategy: |
| 609 | fail-fast: false |
| 610 | matrix: ... |
| 611 | ``` |
| 612 | |
| 613 | 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. |
| 614 | |
| 615 | --- |
| 616 | |
| 617 | ## SDK distribution README branding |
| 618 | |
| 619 | Every distribution channel README must stay aligned with the main `README.MD`. The three elements that must always match exactly: |
| 620 | |
| 621 | - **Logo** — `https://avatars.githubusercontent.com/u/89791739?s=200&v=4`, width 128 |
| 622 | - **Tagline** — "Deploy to the cloud in one command." |
| 623 | - **Quick start** — `smb login` → `smb init` → `smb deploy` |
| 624 | |
| 625 | ### Structure per README type |
| 626 | |
| 627 | **Wrapper package** (`npm/smbcloud-cli/README.md` → `@smbcloud/cli`, `pypi/README.md` → `smbcloud-cli`): |
| 628 | |
| 629 | 1. Logo + tagline + nav links + badges |
| 630 | 2. About section (copy from main README verbatim) |
| 631 | 3. Primary install method for that channel first (`npm install -g` or `pip install`) |
| 632 | 4. Quick start |
| 633 | 5. All other install methods (Homebrew, npm, pip, shell, PowerShell) |
| 634 | 6. Documentation link |
| 635 | 7. Platform support table |
| 636 | 8. Source & Issues pointer to GitHub |
| 637 | 9. License |
| 638 | |
| 639 | The PyPI README leads with the PyPI badge; the npm README leads with the npm badge. Both include the full badge row. |
| 640 | |
| 641 | **Platform binary package** (`npm/README.md.tmpl` → `@smbcloud/cli-darwin-arm64`, etc.): |
| 642 | |
| 643 | 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. |
| 644 | |
| 645 | ### Platform support table |
| 646 | |
| 647 | Keep this table consistent across all READMEs: |
| 648 | |
| 649 | ```md |
| 650 | | Platform | Architecture | |
| 651 | | ------------- | ------------ | |
| 652 | | macOS | arm64, x64 | |
| 653 | | Linux (glibc) | arm64, x64 | |
| 654 | | Windows | arm64, x64 | |
| 655 | ``` |
| 656 | |
| 657 | Update it whenever a new platform is added to the release matrix. |
| 658 | |
| 659 | ### PyPI-specific copy |
| 660 | |
| 661 | Add this line to distinguish the pip install from npm: |
| 662 | |
| 663 | > This package installs the native `smb` executable for your platform directly — no Node.js, no Docker, no runtime dependencies. |
| 664 | |
| 665 | ### Template variable in `README.md.tmpl` |
| 666 | |
| 667 | 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. |
| 668 | |
| 669 | --- |
| 670 | |
| 671 | ## SDK trigger rules |
| 672 | |
| 673 | Use the crates publish plan to decide whether an SDK release should run. |
| 674 | |
| 675 | Current mappings: |
| 676 | |
| 677 | - **SDK PyPI** runs when the plan includes any of: |
| 678 | - `smbcloud-auth-sdk-py` |
| 679 | - `smbcloud-auth-sdk` |
| 680 | - `smbcloud-model` |
| 681 | - `smbcloud-network` |
| 682 | - **SDK npm** runs when the plan includes any of: |
| 683 | - `smbcloud-auth-sdk-wasm` |
| 684 | - `smbcloud-auth-sdk` |
| 685 | - `smbcloud-network` |
| 686 | - `smbcloud-networking` |
| 687 | - **SDK Ruby gem** runs when the plan includes any of: |
| 688 | - `smbcloud-auth-sdk` |
| 689 | - `smbcloud-model` |
| 690 | - `smbcloud-network` |
| 691 | |
| 692 | These are based on the actual wrappers in `sdk/`: |
| 693 | |
| 694 | - `sdk/python/pyproject.toml` points maturin at `crates/smbcloud-auth-sdk-py/Cargo.toml` |
| 695 | - `sdk/npm/smbcloud-auth/prepare-package.mjs` builds from `crates/smbcloud-auth-sdk-wasm` |
| 696 | - `sdk/gems/auth/ext/auth/Cargo.toml` pulls published crates from crates.io |
| 697 | |
| 698 | 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. |
| 699 | |
| 700 | ## Common mistakes |
| 701 | |
| 702 | **SDK npm version out of sync with Rust crate version** |
| 703 | `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. |
| 704 | |
| 705 | **SDK Ruby gem versions out of sync with workspace crate versions** |
| 706 | Three files must be bumped together for each gem in `sdk/gems/`: |
| 707 | |
| 708 | - `lib/<gem>/version.rb` — the gem version (`Auth::VERSION`, `Model::VERSION`) |
| 709 | - `ext/<gem>/Cargo.toml` — the native extension crate version |
| 710 | - `ext/<gem>/Cargo.toml` dependencies — the `smbcloud-*` version constraints (e.g. `"0.3"` → `"0.4"`) |
| 711 | |
| 712 | 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. |
| 713 | |
| 714 | **Tagging a release on a feature branch instead of `development`** |
| 715 | 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`. |
| 716 | |
| 717 | **"Re-run all jobs" on an old workflow run rebuilds from the old commit** |
| 718 | 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>`. |
| 719 | |
| 720 | **Building the full workspace in release workflows** |
| 721 | 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. |
| 722 | |
| 723 | **`exit 0` in a separate step does not skip the next step** |
| 724 | `exit 0` only terminates the current `run` shell. The following step runs regardless. Use `$GITHUB_OUTPUT` + `if:` conditions for inter-step flow control. |
| 725 | |
| 726 | **Hardcoded toolchain version** |
| 727 | Never hardcode `toolchain: 1.92` or use `@stable`. Always read from `rust-toolchain.toml`. |
| 728 | |
| 729 | **Missing `ref:` on checkout for `workflow_dispatch`** |
| 730 | 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. |
| 731 | |
| 732 | **Using `sed` on `Cargo.toml` as the version fallback** |
| 733 | 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. |
| 734 | |
| 735 | **QEMU for arm64 Linux** |
| 736 | 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`. |
| 737 | |
| 738 | **Windows arm64 PyPI wheels on hosted runners** |
| 739 | 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. |
| 740 | |
| 741 | **`yum` vs `apt` in manylinux** |
| 742 | manylinux2014 is CentOS 7. Use `yum`, not `apt`. For manylinux_2_28 (AlmaLinux 8) use `dnf`. |
| 743 | |
| 744 | **Missing `User-Agent` on crates.io API requests** |
| 745 | crates.io rejects curl requests without a `User-Agent` header. Always include `-H "User-Agent: smbcloud-cli-release-workflow"`. |
| 746 | |
| 747 | **Enabling vendored OpenSSL unconditionally on Windows** |
| 748 | `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. |
| 749 | |
| 750 | **Distribution README out of sync with main README** |
| 751 | 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. |
| 752 | |
| 753 | **Platform support table missing new targets** |
| 754 | 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. |
| 755 | |
| 756 | **Use workflow-level `env:` for compile-time secrets, not `$GITHUB_ENV` export steps** |
| 757 | 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. |
| 758 | |
| 759 | Declare the secret in the workflow-level `env:` block: |
| 760 | |
| 761 | ```yaml |
| 762 | env: |
| 763 | CLI_CLIENT_SECRET: ${{ secrets.CLI_CLIENT_SECRET }} |
| 764 | CARGO_TERM_COLOR: always |
| 765 | ``` |
| 766 | |
| 767 | Never add a dedicated "Export build secret" step for this purpose. |
| 768 | |
| 769 | **manylinux Docker containers require `docker-options` to forward env vars** |
| 770 | 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. |
| 771 | |
| 772 | Fix: add `docker-options: -e CLI_CLIENT_SECRET` to the `Build wheel` step: |
| 773 | |
| 774 | ```yaml |
| 775 | - name: Build wheel |
| 776 | uses: PyO3/maturin-action@v1 |
| 777 | with: |
| 778 | target: ${{ matrix.build.TARGET }} |
| 779 | manylinux: ${{ matrix.build.MANYLINUX || 'off' }} |
| 780 | working-directory: pypi |
| 781 | args: --release --locked --compatibility pypi --out dist |
| 782 | before-script-linux: yum install -y perl-core |
| 783 | docker-options: -e CLI_CLIENT_SECRET |
| 784 | ``` |
| 785 | |
| 786 | `-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. |
| 787 | |
| 788 | **PyPI blocks short and protocol-reserved package names** |
| 789 | 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. |
| 790 | |
| 791 | **Multiple release workflows all listening to tag pushes** |
| 792 | 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. |
| 793 | |
| 794 | **`on: release: types: [published]` does not fire when the release is created by automation** |
| 795 | 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`. |
| 796 | |
| 797 | **Downloading full tarballs in the Homebrew workflow to compute SHA256** |
| 798 | 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. |
| 799 | |
| 800 | **Forgetting that Homebrew depends on the GitHub Release assets** |
| 801 | Homebrew cannot run first. It needs the `.sha256` files attached by `release-github.yml`. Keep that dependency explicit. |
| 802 | |
| 803 | **Starting the Ruby gem release before crates.io has caught up** |
| 804 | `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. |
| 805 | |
| 806 | **Rebuilding macOS binaries in `release-homebrew.yml`** |
| 807 | `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. |
| 808 | |
| 809 | **Homebrew tarball contains wrong binary name** |
| 810 | 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. |