@smbcloud / cli / commits / 2ee05ba

Cross-build and smoke-test the SDK gems in CI

ci.yml only compiled the auth gem on x86_64 Linux and never built model, so a break in crates/smbcloud-* or in a non-Linux build showed up only when the release workflow ran. CI now cross-builds auth and model for all four release platforms against the workspace crates, using the oldest and newest Ruby the release ships. Each prebuilt gem is installed and required on a native runner, and the source gem is built, checked for the files cargo needs, and installed. A paths filter skips all of it when nothing the gems build from changed. Tag pushes no longer start a second Tests run for a commit the branch push already covered. Superseded runs on feature branches are cancelled; development and main are never cancelled. release-sdk-gem.yml gets a concurrency group (PR runs cancel each other, a release for a tag never does) and its pull_request trigger now fires only for edits to the workflow itself. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 24, 2026 at 12:57 UTC 2ee05ba8508c58c3bbae158c35aa2899becef009
2 files changed +221 -30
.github/workflows/ci.yml
+212 -27
@@ -1,16 +1,29 @@
1 name: Tests
2
3 -# Runs on every push.
3 +# Runs on every branch push (not on tag pushes: a release tag re-tests a SHA the
4 +# branch push already covered, and the release workflows handle tags).
5 # Covers:
6 # - All Rust workspace members (fmt, clippy, tests)
7 # - smbcloud-cli + xcrs clippy on Windows and macOS (cfg-gated code)
8 # - smbcloud-auth-sdk-wasm (wasm32-unknown-unknown cargo check)
9 # - sdk/python → maturin wheel build (smbcloud-sdk-auth)
10 # - sdk/npm → wasm-pack + prepare-package.mjs (@smbcloud/sdk-auth)
10 -# - sdk/gems → rb_sys native-extension compile (smbcloud-auth gem)
11 +# - sdk/gems → smbcloud-auth and smbcloud-model, cross-built for
12 +# x86_64-linux, aarch64-linux, arm64-darwin and x86_64-darwin
13 +# against the workspace crates (oldest and newest shipped
14 +# Ruby), then installed and required on native runners; the
15 +# source gem is built, its file list checked and installed
16 # - nuget → .NET tool launcher build (SmbCloud.Cli)
17
13 -on: push
18 +on:
19 + push:
20 + branches: ["**"] # tag pushes (v*) re-test a SHA the branch push already covers
21 +
22 +# Superseded pushes to a feature branch are cancelled. development and main are
23 +# never cancelled, so every commit there keeps a result.
24 +concurrency:
25 + group: ${{ github.workflow }}-${{ github.ref }}
26 + cancel-in-progress: ${{ github.ref != 'refs/heads/development' && github.ref != 'refs/heads/main' }}
27
28 env:
29 CLI_CLIENT_SECRET: ${{ secrets.CLI_CLIENT_SECRET }}
@@ -226,10 +239,45 @@ jobs:
239 - name: Build .NET tool launcher
240 run: dotnet build nuget/smbcloud-cli/SmbCloud.Cli.csproj --configuration Release
241
229 - # ── Ruby Gem ────────────────────────────────────────────────────────────────
230 - check-ruby-gem:
231 - name: SDK — Ruby gem (smbcloud-auth)
242 + # ── Ruby Gems ───────────────────────────────────────────────────────────────
243 + # Skip the gem jobs when nothing they build from changed.
244 + changes:
245 + name: Changes — detect gem inputs
246 runs-on: ubuntu-latest
247 + outputs:
248 + gems: ${{ steps.filter.outputs.gems }}
249 + steps:
250 + - name: Checkout
251 + uses: actions/checkout@v6
252 +
253 + - name: Filter changed paths
254 + id: filter
255 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
256 + with:
257 + filters: |
258 + gems:
259 + - "sdk/gems/auth/**"
260 + - "sdk/gems/model/**"
261 + - "crates/**"
262 + - "Cargo.toml"
263 + - "Cargo.lock"
264 + - "rust-toolchain.toml"
265 + - ".ruby-version"
266 + - ".github/workflows/ci.yml"
267 +
268 + # Cross-builds each gem like release-sdk-gem.yml, but against the workspace
269 + # crates (not the published ones) and only for the oldest and newest Ruby the
270 + # release ships. Those bracket the C-API differences.
271 + check-ruby-gems:
272 + name: SDK — ${{ matrix.gem }} gem (${{ matrix.platform }})
273 + needs: changes
274 + if: needs.changes.outputs.gems == 'true'
275 + runs-on: ubuntu-latest
276 + strategy:
277 + fail-fast: false
278 + matrix:
279 + gem: [auth, model]
280 + platform: [x86_64-linux, aarch64-linux, arm64-darwin, x86_64-darwin]
281 steps:
282 - name: Checkout
283 uses: actions/checkout@v6
@@ -249,43 +297,180 @@ jobs:
297 with:
298 toolchain: ${{ env.RUST_TOOLCHAIN }}
299
252 - # The gem's ext Cargo workspace (sdk/gems/auth) is separate from the main
253 - # workspace and pulls smbcloud-* crates from crates.io. Its transitive
254 - # deps may link against system OpenSSL, so we install libssl-dev to be safe.
255 - - name: Install system dependencies
256 - run: sudo apt-get update -q && sudo apt-get install -y libssl-dev pkg-config
300 + # CI validates the unreleased workspace version. Derive the local crate
301 + # patches from the extension manifest; release builds deliberately
302 + # resolve published crates after the release workflow has run. The
303 + # whole checkout is mounted into the rb-sys-dock container, so the
304 + # relative paths resolve there too.
305 + - name: Use workspace Rust crates for the gem check
306 + shell: bash
307 + run: |
308 + mkdir -p sdk/gems/${{ matrix.gem }}/.cargo
309 + {
310 + echo "[patch.crates-io]"
311 + awk -F '=' '/^smbcloud-[[:alnum:]-]+[[:space:]]*=/ {
312 + name = $1
313 + gsub(/[[:space:]]/, "", name)
314 + print name " = { path = \"../../../crates/" name "\" }"
315 + }' sdk/gems/${{ matrix.gem }}/ext/${{ matrix.gem }}/Cargo.toml
316 + } > sdk/gems/${{ matrix.gem }}/.cargo/config.toml
317 + cargo generate-lockfile --manifest-path sdk/gems/${{ matrix.gem }}/Cargo.toml
318 +
319 + - name: Setup Ruby
320 + uses: ruby/setup-ruby@v1
321 + with:
322 + ruby-version: .ruby-version
323 + bundler-cache: true
324 + working-directory: sdk/gems/${{ matrix.gem }}
325
258 - # Swatinem/rust-cache scoped to the gem's own Cargo workspace so its
259 - # registry deps are cached independently from the main workspace.
260 - - name: Setup Rust cache (gem workspace)
261 - uses: Swatinem/rust-cache@v2
326 + - name: Cross-compile native gem
327 + id: cross-gem
328 + uses: oxidize-rb/actions/cross-gem@v1
329 with:
263 - key: check-ruby-gem
264 - workspaces: sdk/gems/auth
330 + platform: ${{ matrix.platform }}
331 + ruby-versions: "3.1,4.0"
332 + working-directory: sdk/gems/${{ matrix.gem }}
333 + cache-version: ci-${{ matrix.gem }}
334 +
335 + - name: Upload gem artifact
336 + uses: actions/upload-artifact@v7
337 + with:
338 + name: ci-gem-${{ matrix.gem }}-${{ matrix.platform }}
339 + path: ${{ steps.cross-gem.outputs.gem-path }}
340 + retention-days: 1
341 +
342 + # Install each prebuilt gem on a native runner and require it, which takes
343 + # the real load path (the per-Ruby lib/<gem>/<x.y>/ bundle first). Catches
344 + # load-time breakage such as a dyld-rejected install name.
345 + smoke-test-ruby-gems:
346 + name: Smoke — ${{ matrix.gem }} ${{ matrix.platform }} (Ruby ${{ matrix.ruby }})
347 + needs: check-ruby-gems
348 + runs-on: ${{ matrix.runner }}
349 + strategy:
350 + fail-fast: false
351 + matrix:
352 + gem: [auth, model]
353 + ruby: ["3.1", "4.0"]
354 + platform: [x86_64-linux, aarch64-linux, arm64-darwin]
355 + include:
356 + - platform: x86_64-linux
357 + runner: ubuntu-latest
358 + - platform: aarch64-linux
359 + runner: ubuntu-24.04-arm
360 + - platform: arm64-darwin
361 + runner: macos-latest
362 + - gem: auth
363 + constant: SmbCloud::Auth
364 + - gem: model
365 + constant: Model
366 + steps:
367 + - name: Setup Ruby
368 + uses: ruby/setup-ruby@v1
369 + with:
370 + ruby-version: ${{ matrix.ruby }}
371 +
372 + - name: Download gem artifact
373 + uses: actions/download-artifact@v7
374 + with:
375 + name: ci-gem-${{ matrix.gem }}-${{ matrix.platform }}
376 + path: gem
377 +
378 + - name: Install the gem
379 + shell: bash
380 + run: gem install --local gem/*.gem
381 +
382 + # No checkout in this job, so only the installed gem can satisfy the require.
383 + - name: Require the gem
384 + shell: bash
385 + run: ruby -e 'require "${{ matrix.gem }}"; puts ${{ matrix.constant }}'
386 +
387 + # Builds the source gem from git ls-files and installs it, compiling from
388 + # source once. Catches spec.files problems (symlinks, missing Cargo files)
389 + # that only surface on a user's machine.
390 + check-ruby-source-gems:
391 + name: SDK — ${{ matrix.gem }} source gem
392 + needs: changes
393 + if: needs.changes.outputs.gems == 'true'
394 + runs-on: ubuntu-latest
395 + strategy:
396 + fail-fast: false
397 + matrix:
398 + gem: [auth, model]
399 + steps:
400 + - name: Checkout
401 + uses: actions/checkout@v6
402 +
403 + - name: Read Rust toolchain
404 + shell: bash
405 + run: |
406 + rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)"
407 + if [ -z "$rust_toolchain" ]; then
408 + echo "Failed to read Rust toolchain from rust-toolchain.toml" >&2
409 + exit 1
410 + fi
411 + echo "RUST_TOOLCHAIN=${rust_toolchain}" >> "$GITHUB_ENV"
412 +
413 + - name: Install toolkit
414 + uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9
415 + with:
416 + toolchain: ${{ env.RUST_TOOLCHAIN }}
417
266 - # CI validates the unreleased workspace version. Derive the local crate
267 - # patches from the extension manifest; release builds deliberately
268 - # resolve published crates after the release workflow has run.
418 - name: Use workspace Rust crates for the gem check
419 shell: bash
420 run: |
272 - mkdir -p sdk/gems/auth/.cargo
421 + mkdir -p sdk/gems/${{ matrix.gem }}/.cargo
422 {
423 echo "[patch.crates-io]"
424 awk -F '=' '/^smbcloud-[[:alnum:]-]+[[:space:]]*=/ {
425 name = $1
426 gsub(/[[:space:]]/, "", name)
427 print name " = { path = \"../../../crates/" name "\" }"
279 - }' sdk/gems/auth/ext/auth/Cargo.toml
280 - } > sdk/gems/auth/.cargo/config.toml
428 + }' sdk/gems/${{ matrix.gem }}/ext/${{ matrix.gem }}/Cargo.toml
429 + } > sdk/gems/${{ matrix.gem }}/.cargo/config.toml
430
431 - name: Setup Ruby
432 uses: ruby/setup-ruby@v1
433 with:
434 ruby-version: .ruby-version
435 bundler-cache: true
287 - working-directory: sdk/gems/auth
436 + working-directory: sdk/gems/${{ matrix.gem }}
437
289 - - name: Compile native extension
290 - working-directory: sdk/gems/auth
291 - run: bundle exec rake compile
438 + # `gem build`, not `rake build`: the latter depends on :compile.
439 + - name: Build the source gem
440 + working-directory: sdk/gems/${{ matrix.gem }}
441 + run: |
442 + mkdir -p pkg
443 + gem build ${{ matrix.gem }}.gemspec --output pkg/${{ matrix.gem }}-source.gem
444 +
445 + - name: Check the source gem file list
446 + working-directory: sdk/gems/${{ matrix.gem }}
447 + shell: bash
448 + run: |
449 + files="$(gem spec pkg/${{ matrix.gem }}-source.gem files | sed -n 's/^- //p')"
450 + echo "$files"
451 + fail=0
452 + require() {
453 + if ! grep -Eq "$1" <<<"$files"; then
454 + echo "::error::source gem is missing $2" >&2
455 + fail=1
456 + fi
457 + }
458 + require "^ext/${{ matrix.gem }}/extconf\.rb$" "ext/${{ matrix.gem }}/extconf.rb"
459 + require '^ext/.*\.rs$' "ext/**/*.rs"
460 + require '(^|/)Cargo\.toml$' "Cargo.toml"
461 + require '(^|/)Cargo\.lock$' "Cargo.lock"
462 + if grep -Eq '(^|/)(target|tmp)/' <<<"$files"; then
463 + echo "::error::source gem contains target/ or tmp/ paths" >&2
464 + fail=1
465 + fi
466 + while IFS= read -r f; do
467 + if [ -L "$f" ]; then
468 + echo "::error::source gem contains the symlink $f" >&2
469 + fail=1
470 + fi
471 + done <<<"$files"
472 + exit "$fail"
473 +
474 + - name: Install the source gem
475 + working-directory: sdk/gems/${{ matrix.gem }}
476 + run: gem install --local pkg/${{ matrix.gem }}-source.gem
.github/workflows/release-sdk-gem.yml
+9 -3
@@ -8,7 +8,9 @@ name: SDK Ruby Gem Release
8 # release). Each native gem is fat: one extension per Ruby minor >= 3.1. A
9 # source gem covers every other platform and Ruby.
10 #
11 -# Pull requests that touch the gems build everything but publish nothing.
11 +# Pull requests that touch this workflow build everything but publish nothing.
12 +# Gem and crate changes are covered by ci.yml, which builds against the
13 +# workspace crates.
14 #
15 # Required secrets:
16 # RUBYGEMS_API_KEY — RubyGems.org API key with push access to smbcloud-auth and
@@ -25,13 +27,17 @@ on:
27 required: false
28 pull_request:
29 paths:
28 - - "sdk/gems/auth/**"
29 - - "sdk/gems/model/**"
30 - ".github/workflows/release-sdk-gem.yml"
31
32 permissions:
33 contents: read
34
35 +# PR dry runs cancel their superseded runs. A release for a tag is serialized
36 +# and never cancelled halfway through push-gems.
37 +concurrency:
38 + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || inputs.tag }}
39 + cancel-in-progress: ${{ github.event_name == 'pull_request' }}
40 +
41 env:
42 CARGO_TERM_COLOR: always
43