Refactor release workflows for orchestrator model
- Make `release-crate.yml` the only workflow triggered by tag push - All downstream release workflows now run only via `workflow_dispatch` - Add outputs to orchestrator for downstream triggers - Update documentation in `.agents/skills/ci-cd/SKILL.md` to clarify orchestration - Add retry logic for Homebrew asset download
keypair34 committed
May 3, 2026 at 17:38 UTC
c67e311c5ac147c03d71ed5f236c69d68ea75d81
16 files changed
+502
-142
.agents/skills/ci-cd/SKILL.md
+184
-74
@@ -1,21 +1,25 @@
1
---
2
name: ci-cd
3
-description: Use when creating, debugging, or improving GitHub Actions workflows for this repo — especially release workflows for crates.io, PyPI, npm, GitHub Releases, Homebrew tap, or Debian packages. Also covers SDK distribution README branding alignment across npm, PyPI, and the main repo. Covers OpenSSL vendoring, Windows vs non-Windows platform-conditional dependencies, manylinux containers, Perl module gaps, workspace scoping, native vs QEMU arm64 runners, trusted publishing, artifact actions, toolchain parity, idempotency patterns, Homebrew formula generation, and cross-workflow chaining.
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 all release workflows
8
+## Source of truth for release orchestration
9
10
-| Distribution | Workflow file |
11
-| ----------------------- | ---------------------------------------- |
12
-| crates.io | `.github/workflows/release-crate.yml` |
13
-| PyPI | `.github/workflows/release-pypi.yml` |
14
-| npm | `.github/workflows/release-npm.yml` |
15
-| GitHub Release binaries | `.github/workflows/release-github.yml` |
16
-| Homebrew tap | `.github/workflows/release-homebrew.yml` |
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
18
-All four workflows share the same structural patterns. When editing one, check the others for consistency.
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
@@ -32,9 +36,13 @@ When the main `README.MD` changes its tagline, logo, quick start, or value propo
36
37
## Universal workflow patterns
38
35
-Every release workflow must follow these patterns identically.
39
+The repo now has two kinds of release workflows.
40
37
-### Trigger
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:
@@ -48,6 +56,19 @@ on:
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
@@ -61,6 +82,8 @@ Without `ref:`, a `workflow_dispatch` run checks out the default branch HEAD, no
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
@@ -75,7 +98,28 @@ Without `ref:`, a `workflow_dispatch` run checks out the default branch HEAD, no
98
echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV"
99
```
100
78
-Never read the version from `Cargo.toml` with `sed` in the `else` branch. That silently publishes the current `Cargo.toml` version regardless of the tag input.
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
@@ -112,30 +156,36 @@ Without a unique key, cache entries from different targets can collide and corru
156
157
### Idempotency — check before publishing
158
115
-Every publish step must be gated so re-runs do not fail on already-published versions.
159
+Every publish step must be safe to rerun.
160
117
-**crates.io:**
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
120
-- name: Check whether release already exists on crates.io
121
- id: crates-check
166
+- name: Build unpublished crate plan
167
+ id: plan
168
shell: bash
169
+ env:
170
+ CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
171
run: |
124
- if curl -fsS \
125
- -H "User-Agent: smbcloud-cli-release-workflow" \
126
- "https://crates.io/api/v1/crates/smbcloud-cli/${RELEASE_VERSION}" \
127
- >/dev/null 2>&1; then
128
- echo "exists=true" >> "$GITHUB_OUTPUT"
129
- else
130
- echo "exists=false" >> "$GITHUB_OUTPUT"
131
- fi
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
133
-- name: Publish to crates.io
134
- if: steps.crates-check.outputs.exists != 'true'
135
- ...
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
138
-The `User-Agent` header is required — crates.io rejects requests without one.
188
+If the plan is empty, stop cleanly and let the workflow say so.
189
190
**PyPI:**
191
@@ -171,20 +221,11 @@ npm publish --access public
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
174
-### Version guard — verify Cargo.toml matches the tag
224
+### Version guard — match the check to the workflow
225
176
-```yaml
177
-- name: Verify Cargo.toml version matches tag
178
- shell: bash
179
- run: |
180
- CARGO_VERSION=$(cargo metadata --no-deps --format-version 1 \
181
- | jq -r '.packages[] | select(.name == "smbcloud-cli") | .version')
226
+For single-package workflows, checking one package against the tag is still fine.
227
183
- if [[ "${CARGO_VERSION}" != "${RELEASE_VERSION}" ]]; then
184
- echo "::error::Version mismatch — bump version in Cargo.toml before releasing."
185
- exit 1
186
- fi
187
-```
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
@@ -192,9 +233,9 @@ Use `actions/upload-artifact@v7` and `actions/download-artifact@v7` consistently
233
234
---
235
195
-## Workspace scoping — always use `--package`
236
+## Workspace scoping — use `--package` for build jobs, not for the crates orchestrator
237
197
-The workspace root `Cargo.toml` is a **virtual manifest** (no `[package]` section). Running `cargo build` or `cargo publish` from the workspace root without `--package` will either fail or build every workspace member.
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
@@ -203,14 +244,15 @@ ld: symbol(s) not found for architecture arm64
244
clang: error: linker command failed with exit code 1
245
```
246
206
-Always scope cargo commands:
247
+Scope cargo build commands like this:
248
249
```bash
250
cargo build --release --locked --target <target> --package smbcloud-cli
210
-cargo publish --locked --package smbcloud-cli
251
```
252
213
-The PyPI workflow is exempt because maturin is already pointed at `crates/cli/Cargo.toml` via `manifest-path` in `pypi/pyproject.toml`.
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
@@ -349,13 +391,22 @@ The 404 on `npm publish` does not mean the package does not exist. npm returns 4
391
### crates.io — uses `CARGO_REGISTRY_TOKEN`
392
393
```yaml
352
-- name: Publish to crates.io
394
+- name: Publish workspace crates to crates.io
395
env:
396
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
355
- run: cargo publish --locked --package smbcloud-cli
397
+ run: |
398
+ cargo workspaces publish \
399
+ --publish-as-is \
400
+ --yes \
401
+ --locked \
402
+ --allow-branch "*" \
403
+ --publish-interval 15 \
404
+ --token "${CARGO_REGISTRY_TOKEN}"
405
```
406
358
-crates.io also supports OIDC trusted publishing but the setup is separate from this workflow.
407
+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.
408
+
409
+crates.io also supports OIDC trusted publishing, but that is separate from the current workflow.
410
411
---
412
@@ -393,22 +444,33 @@ This ensures all platform binaries are collected before any are attached to the
444
445
---
446
396
-## Chaining workflows — `GITHUB_TOKEN` cannot fire downstream events
447
+## Chaining workflows — use explicit dispatches
448
398
-GitHub intentionally does **not** fire `release`, `push`, `pull_request`, or any other repository events when the action that creates them is authenticated with `GITHUB_TOKEN`. This is a deliberate anti-loop safeguard.
449
+Do not rely on GitHub events magically fanning out through the release chain. Keep the chain explicit.
450
400
-**Symptom:** `release-github.yml` uses `softprops/action-gh-release` (with the implicit `GITHUB_TOKEN`) to publish a release. `release-homebrew.yml` has `on: release: types: [published]`. The Homebrew workflow never runs.
451
+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.
452
402
-**Fix:** Have the upstream workflow dispatch the downstream one explicitly via the GitHub API after it completes. This requires `actions: write` in `permissions`.
453
+The current release chain is:
454
404
-```yaml
405
-# release-github.yml
455
+- `release-crate.yml` publishes workspace crates
456
+- then dispatches CLI distribution workflows:
457
+ - `release-github.yml`
458
+ - `release-npm.yml`
459
+ - `release-nuget.yml`
460
+ - `release-pypi.yml`
461
+- then conditionally dispatches SDK workflows based on the crate publish plan:
462
+ - `release-sdk-pypi.yml`
463
+ - `release-sdk-npm.yml`
464
+ - `release-sdk-gem.yml`
465
+- `release-github.yml` dispatches `release-homebrew.yml` after the GitHub Release assets exist
466
467
+This pattern needs `actions: write` in the upstream workflow permissions.
468
+
469
+```yaml
470
permissions:
471
contents: write
409
- actions: write # required for createWorkflowDispatch
472
+ actions: write
473
411
-# At the end of the release job, after softprops/action-gh-release:
474
- name: Trigger Homebrew release
475
uses: actions/github-script@v7
476
with:
@@ -417,16 +479,21 @@ permissions:
479
owner: context.repo.owner,
480
repo: context.repo.repo,
481
workflow_id: 'release-homebrew.yml',
420
- ref: 'main',
482
+ ref: context.payload.repository.default_branch,
483
inputs: {
484
tag: '${{ steps.tag.outputs.tag }}'
485
}
486
})
487
```
488
427
-The downstream workflow (`release-homebrew.yml`) must expose a `workflow_dispatch` trigger with a `tag` input — it no longer needs `on: release: types: [published]` (which is dead code in this pattern).
489
+A few practical rules:
490
429
-**Alternative:** Use a Personal Access Token (PAT) with `repo` scope in `softprops/action-gh-release` via `token: ${{ secrets.RELEASE_TOKEN }}`. A PAT is treated as a real user action and does fire the `release` event. This avoids changing permissions but requires managing an extra secret.
491
+- downstream workflows must expose `workflow_dispatch` with a `tag` input
492
+- dispatch from the repo default branch so the workflow definition comes from the current release lane
493
+- still check out the requested tag inside the downstream workflow so the built code matches the release
494
+- do not dispatch Homebrew until the GitHub Release job has uploaded the assets it needs
495
+
496
+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.
497
498
---
499
@@ -479,20 +546,25 @@ Both the `.tar.gz` and the `.sha256` file are downloaded in the release job and
546
run: |
547
mkdir -p artifacts
548
482
- # Download only the tiny .sha256 text files — not the full tarballs
483
- gh release download "${{ steps.release.outputs.tag }}" \
484
- --repo "${{ env.REPO }}" \
485
- --pattern "smb-macos-*.tar.gz.sha256" \
486
- --dir artifacts/
487
-
488
- ARM64_SHA=$(cat artifacts/smb-macos-arm64.tar.gz.sha256)
489
- AMD64_SHA=$(cat artifacts/smb-macos-amd64.tar.gz.sha256)
490
-
491
- echo "arm64=${ARM64_SHA}" >> "$GITHUB_OUTPUT"
492
- echo "amd64=${AMD64_SHA}" >> "$GITHUB_OUTPUT"
549
+ for attempt in 1 2 3 4 5; do
550
+ if gh release download "${{ steps.release.outputs.tag }}" \
551
+ --repo "${{ env.REPO }}" \
552
+ --pattern "smb-macos-*.tar.gz.sha256" \
553
+ --dir artifacts/; then
554
+ break
555
+ fi
556
+
557
+ if [ "$attempt" -eq 5 ]; then
558
+ echo "Failed to download Homebrew checksum artifacts from the GitHub release." >&2
559
+ exit 1
560
+ fi
561
+
562
+ echo "Release assets are not visible yet. Waiting 10 seconds..."
563
+ sleep 10
564
+ done
565
```
566
495
-`gh` (GitHub CLI) is pre-installed on all GitHub-hosted runners. `GH_TOKEN: ${{ github.token }}` is sufficient for downloading from a public repo's releases.
567
+`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.
568
569
### Formula URL convention
570
@@ -587,6 +659,35 @@ The platform package template uses `${node_pkg}` as a shell-style variable. This
659
660
---
661
662
+## SDK trigger rules
663
+
664
+Use the crates publish plan to decide whether an SDK release should run.
665
+
666
+Current mappings:
667
+
668
+- **SDK PyPI** runs when the plan includes any of:
669
+ - `smbcloud-auth-sdk-py`
670
+ - `smbcloud-auth-sdk`
671
+ - `smbcloud-model`
672
+ - `smbcloud-network`
673
+- **SDK npm** runs when the plan includes any of:
674
+ - `smbcloud-auth-sdk-wasm`
675
+ - `smbcloud-auth-sdk`
676
+ - `smbcloud-network`
677
+ - `smbcloud-networking`
678
+- **SDK Ruby gem** runs when the plan includes any of:
679
+ - `smbcloud-auth-sdk`
680
+ - `smbcloud-model`
681
+ - `smbcloud-network`
682
+
683
+These are based on the actual wrappers in `sdk/`:
684
+
685
+- `sdk/python/pyproject.toml` points maturin at `crates/smbcloud-auth-sdk-py/Cargo.toml`
686
+- `sdk/npm/smbcloud-auth/prepare-package.mjs` builds from `crates/smbcloud-auth-sdk-wasm`
687
+- `sdk/gems/auth/ext/auth/Cargo.toml` pulls published crates from crates.io
688
+
689
+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.
690
+
691
## Common mistakes
692
693
**Building the full workspace in release workflows**
@@ -657,11 +758,20 @@ Fix: add `docker-options: -e CLI_CLIENT_SECRET` to the `Build wheel` step:
758
**PyPI blocks short and protocol-reserved package names**
759
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.
760
761
+**Multiple release workflows all listening to tag pushes**
762
+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.
763
+
764
**`on: release: types: [published]` does not fire when the release is created by automation**
661
-When `softprops/action-gh-release` (or any action) creates a GitHub Release using the implicit `GITHUB_TOKEN`, GitHub does not dispatch the `release` event to other workflows. The downstream workflow simply never runs — no error, no log entry. Use `workflow_dispatch` via `actions/github-script@v7` to chain workflows explicitly, and add `actions: write` to the upstream workflow's `permissions`.
765
+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`.
766
767
**Downloading full tarballs in the Homebrew workflow to compute SHA256**
664
-The Homebrew formula only needs the SHA256 hash — Homebrew itself downloads the tarball at install time. Compute the SHA256 during the build in `release-github.yml`, save it as a `.sha256` file, attach it to the GitHub Release, and then have `release-homebrew.yml` download only those tiny text files with `gh release download --pattern "*.sha256"`. Do not re-download multi-megabyte binaries and do not re-run Rust compilation.
768
+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.
769
+
770
+**Forgetting that Homebrew depends on the GitHub Release assets**
771
+Homebrew cannot run first. It needs the `.sha256` files attached by `release-github.yml`. Keep that dependency explicit.
772
+
773
+**Starting the Ruby gem release before crates.io has caught up**
774
+`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.
775
776
**Rebuilding macOS binaries in `release-homebrew.yml`**
777
`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.
.github/workflows/release-crate.yml
+207
-23
@@ -12,6 +12,7 @@ on:
12
13
permissions:
14
contents: read
15
+ actions: write
16
17
env:
18
CARGO_TERM_COLOR: always
@@ -19,26 +20,39 @@ env:
20
21
jobs:
22
publish:
22
- name: Publish to crates.io
23
+ name: Publish workspace crates to crates.io
24
runs-on: ubuntu-latest
25
+ outputs:
26
+ tag: ${{ steps.release.outputs.tag }}
27
+ published_count: ${{ steps.plan.outputs.count }}
28
+ publish_cli_sdk_pypi: ${{ steps.downstream.outputs.publish_cli_sdk_pypi }}
29
+ publish_cli_sdk_npm: ${{ steps.downstream.outputs.publish_cli_sdk_npm }}
30
+ publish_cli_sdk_gem: ${{ steps.downstream.outputs.publish_cli_sdk_gem }}
31
32
steps:
33
- name: Checkout
34
uses: actions/checkout@v6
35
with:
36
+ fetch-depth: 0
37
ref: ${{ github.event.inputs.tag || github.ref }}
38
39
- name: Set the release version
40
+ id: release
41
shell: bash
42
run: |
43
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
44
+ release_tag="${GITHUB_REF_NAME}"
45
release_version="${GITHUB_REF_NAME#v}"
46
else
37
- release_version="${{ github.event.inputs.tag }}"
38
- release_version="${release_version#v}"
47
+ release_tag="${{ github.event.inputs.tag }}"
48
+ release_tag="${release_tag#refs/tags/}"
49
+ release_version="${release_tag#v}"
50
fi
51
52
+ echo "RELEASE_TAG=${release_tag}" >> "$GITHUB_ENV"
53
echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV"
54
+ echo "tag=${release_tag}" >> "$GITHUB_OUTPUT"
55
+ echo "version=${release_version}" >> "$GITHUB_OUTPUT"
56
57
- name: Read Rust toolchain
58
shell: bash
@@ -61,37 +75,207 @@ jobs:
75
with:
76
key: publish
77
64
- - name: Verify Cargo.toml version matches tag
78
+ - name: Install cargo-workspaces
79
+ shell: bash
80
+ run: cargo install cargo-workspaces --version 0.4.2 --locked
81
+
82
+ - name: Verify workspace crate versions match tag
83
shell: bash
84
run: |
67
- CARGO_VERSION=$(cargo metadata --no-deps --format-version 1 \
68
- | jq -r '.packages[] | select(.name == "smbcloud-cli") | .version')
85
+ cargo metadata --no-deps --format-version 1 > metadata.json
86
+
87
+ crate_count="$(jq '[.packages[] | select(.manifest_path | contains("/crates/"))] | length' metadata.json)"
88
+ if [[ "$crate_count" == "0" ]]; then
89
+ echo "::error::No workspace crates found under /crates."
90
+ exit 1
91
+ fi
92
70
- echo "Cargo.toml version : ${CARGO_VERSION}"
71
- echo "Release tag version: ${RELEASE_VERSION}"
93
+ mismatches="$({
94
+ jq -r --arg release_version "${RELEASE_VERSION}" '
95
+ .packages[]
96
+ | select(.manifest_path | contains("/crates/"))
97
+ | select(.version != $release_version)
98
+ | "\(.name) \(.version)"' metadata.json
99
+ } || true)"
100
73
- if [[ "${CARGO_VERSION}" != "${RELEASE_VERSION}" ]]; then
74
- echo "::error::Version mismatch — bump version in Cargo.toml before releasing."
101
+ if [[ -n "$mismatches" ]]; then
102
+ echo "::error::These crates do not match tag version ${RELEASE_VERSION}:"
103
+ echo "$mismatches"
104
exit 1
105
fi
106
78
- - name: Check whether release already exists on crates.io
79
- id: crates-check
107
+ - name: Build unpublished crate plan
108
+ id: plan
109
shell: bash
110
+ env:
111
+ CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
112
run: |
82
- if curl -fsS \
83
- -H "User-Agent: smbcloud-cli-release-workflow" \
84
- "https://crates.io/api/v1/crates/smbcloud-cli/${RELEASE_VERSION}" \
85
- >/dev/null 2>&1; then
86
- echo "smbcloud-cli ${RELEASE_VERSION} already exists on crates.io, skipping publish"
87
- echo "exists=true" >> "$GITHUB_OUTPUT"
88
- else
89
- echo "exists=false" >> "$GITHUB_OUTPUT"
113
+ if [[ -z "${CARGO_REGISTRY_TOKEN}" ]]; then
114
+ echo "::error::CARGO_REGISTRY_TOKEN is required."
115
+ exit 1
116
fi
117
92
- - name: Publish to crates.io
93
- if: steps.crates-check.outputs.exists != 'true'
118
+ cargo workspaces plan --skip-published --json --token "${CARGO_REGISTRY_TOKEN}" > plan.json
119
+ cat plan.json
120
+
121
+ plan_count="$(jq 'length' plan.json)"
122
+ echo "count=${plan_count}" >> "$GITHUB_OUTPUT"
123
+
124
+ - name: Derive downstream workflow triggers
125
+ id: downstream
126
+ shell: bash
127
+ run: |
128
+ publish_cli_sdk_pypi="$(jq -r 'map(.name) | any(. == "smbcloud-auth-sdk-py" or . == "smbcloud-auth-sdk" or . == "smbcloud-model" or . == "smbcloud-network")' plan.json)"
129
+ publish_cli_sdk_npm="$(jq -r 'map(.name) | any(. == "smbcloud-auth-sdk-wasm" or . == "smbcloud-auth-sdk" or . == "smbcloud-network" or . == "smbcloud-networking")' plan.json)"
130
+ publish_cli_sdk_gem="$(jq -r 'map(.name) | any(. == "smbcloud-auth-sdk" or . == "smbcloud-model" or . == "smbcloud-network")' plan.json)"
131
+
132
+ echo "publish_cli_sdk_pypi=${publish_cli_sdk_pypi}" >> "$GITHUB_OUTPUT"
133
+ echo "publish_cli_sdk_npm=${publish_cli_sdk_npm}" >> "$GITHUB_OUTPUT"
134
+ echo "publish_cli_sdk_gem=${publish_cli_sdk_gem}" >> "$GITHUB_OUTPUT"
135
+
136
+ echo "SDK PyPI trigger: ${publish_cli_sdk_pypi}"
137
+ echo "SDK npm trigger: ${publish_cli_sdk_npm}"
138
+ echo "SDK gem trigger: ${publish_cli_sdk_gem}"
139
+
140
+ - name: Publish workspace crates to crates.io
141
+ if: steps.plan.outputs.count != '0'
142
shell: bash
143
env:
144
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
97
- run: cargo publish --locked --package smbcloud-cli
145
+ run: |
146
+ cargo workspaces publish \
147
+ --publish-as-is \
148
+ --yes \
149
+ --locked \
150
+ --allow-branch "*" \
151
+ --publish-interval 15 \
152
+ --token "${CARGO_REGISTRY_TOKEN}"
153
+
154
+ - name: Nothing to publish
155
+ if: steps.plan.outputs.count == '0'
156
+ run: echo "All workspace crate versions in this tag are already published."
157
+
158
+ dispatch-distributions:
159
+ name: Trigger CLI distribution workflows
160
+ needs: publish
161
+ runs-on: ubuntu-latest
162
+
163
+ steps:
164
+ - name: Trigger GitHub CLI release
165
+ uses: actions/github-script@v7
166
+ with:
167
+ script: |
168
+ await github.rest.actions.createWorkflowDispatch({
169
+ owner: context.repo.owner,
170
+ repo: context.repo.repo,
171
+ workflow_id: 'release-github.yml',
172
+ ref: context.payload.repository.default_branch,
173
+ inputs: {
174
+ tag: '${{ needs.publish.outputs.tag }}'
175
+ }
176
+ })
177
+
178
+ - name: Trigger npm CLI release
179
+ uses: actions/github-script@v7
180
+ with:
181
+ script: |
182
+ await github.rest.actions.createWorkflowDispatch({
183
+ owner: context.repo.owner,
184
+ repo: context.repo.repo,
185
+ workflow_id: 'release-npm.yml',
186
+ ref: context.payload.repository.default_branch,
187
+ inputs: {
188
+ tag: '${{ needs.publish.outputs.tag }}'
189
+ }
190
+ })
191
+
192
+ - name: Trigger NuGet CLI release
193
+ uses: actions/github-script@v7
194
+ with:
195
+ script: |
196
+ await github.rest.actions.createWorkflowDispatch({
197
+ owner: context.repo.owner,
198
+ repo: context.repo.repo,
199
+ workflow_id: 'release-nuget.yml',
200
+ ref: context.payload.repository.default_branch,
201
+ inputs: {
202
+ tag: '${{ needs.publish.outputs.tag }}'
203
+ }
204
+ })
205
+
206
+ - name: Trigger PyPI CLI release
207
+ uses: actions/github-script@v7
208
+ with:
209
+ script: |
210
+ await github.rest.actions.createWorkflowDispatch({
211
+ owner: context.repo.owner,
212
+ repo: context.repo.repo,
213
+ workflow_id: 'release-pypi.yml',
214
+ ref: context.payload.repository.default_branch,
215
+ inputs: {
216
+ tag: '${{ needs.publish.outputs.tag }}'
217
+ }
218
+ })
219
+
220
+ dispatch-sdk-pypi:
221
+ name: Trigger SDK PyPI release
222
+ needs: publish
223
+ if: needs.publish.outputs.publish_cli_sdk_pypi == 'true'
224
+ runs-on: ubuntu-latest
225
+
226
+ steps:
227
+ - name: Trigger SDK PyPI release workflow
228
+ uses: actions/github-script@v7
229
+ with:
230
+ script: |
231
+ await github.rest.actions.createWorkflowDispatch({
232
+ owner: context.repo.owner,
233
+ repo: context.repo.repo,
234
+ workflow_id: 'release-sdk-pypi.yml',
235
+ ref: context.payload.repository.default_branch,
236
+ inputs: {
237
+ tag: '${{ needs.publish.outputs.tag }}'
238
+ }
239
+ })
240
+
241
+ dispatch-sdk-npm:
242
+ name: Trigger SDK npm release
243
+ needs: publish
244
+ if: needs.publish.outputs.publish_cli_sdk_npm == 'true'
245
+ runs-on: ubuntu-latest
246
+
247
+ steps:
248
+ - name: Trigger SDK npm release workflow
249
+ uses: actions/github-script@v7
250
+ with:
251
+ script: |
252
+ await github.rest.actions.createWorkflowDispatch({
253
+ owner: context.repo.owner,
254
+ repo: context.repo.repo,
255
+ workflow_id: 'release-sdk-npm.yml',
256
+ ref: context.payload.repository.default_branch,
257
+ inputs: {
258
+ tag: '${{ needs.publish.outputs.tag }}'
259
+ }
260
+ })
261
+
262
+ dispatch-sdk-gem:
263
+ name: Trigger SDK Ruby gem release
264
+ needs: publish
265
+ if: needs.publish.outputs.publish_cli_sdk_gem == 'true'
266
+ runs-on: ubuntu-latest
267
+
268
+ steps:
269
+ - name: Trigger SDK Ruby gem release workflow
270
+ uses: actions/github-script@v7
271
+ with:
272
+ script: |
273
+ await github.rest.actions.createWorkflowDispatch({
274
+ owner: context.repo.owner,
275
+ repo: context.repo.repo,
276
+ workflow_id: 'release-sdk-gem.yml',
277
+ ref: context.payload.repository.default_branch,
278
+ inputs: {
279
+ tag: '${{ needs.publish.outputs.tag }}'
280
+ }
281
+ })
.github/workflows/release-github.yml
+1
-4
@@ -1,9 +1,6 @@
1
name: GitHub CLI Release
2
3
on:
4
- push:
5
- tags:
6
- - "v*.*.*"
4
workflow_dispatch:
5
inputs:
6
tag:
@@ -182,7 +179,7 @@ jobs:
179
owner: context.repo.owner,
180
repo: context.repo.repo,
181
workflow_id: 'release-homebrew.yml',
185
- ref: 'main',
182
+ ref: context.payload.repository.default_branch,
183
inputs: {
184
tag: '${{ steps.tag.outputs.tag }}'
185
}
.github/workflows/release-homebrew.yml
+16
-4
@@ -37,10 +37,22 @@ jobs:
37
run: |
38
mkdir -p artifacts
39
40
- gh release download "${{ steps.release.outputs.tag }}" \
41
- --repo "${{ env.REPO }}" \
42
- --pattern "smb-macos-*.tar.gz.sha256" \
43
- --dir artifacts/
40
+ for attempt in 1 2 3 4 5; do
41
+ if gh release download "${{ steps.release.outputs.tag }}" \
42
+ --repo "${{ env.REPO }}" \
43
+ --pattern "smb-macos-*.tar.gz.sha256" \
44
+ --dir artifacts/; then
45
+ break
46
+ fi
47
+
48
+ if [ "$attempt" -eq 5 ]; then
49
+ echo "Failed to download Homebrew checksum artifacts from the GitHub release." >&2
50
+ exit 1
51
+ fi
52
+
53
+ echo "Release assets not visible yet. Retrying in 10 seconds..."
54
+ sleep 10
55
+ done
56
57
ARM64_SHA=$(cat artifacts/smb-macos-arm64.tar.gz.sha256)
58
AMD64_SHA=$(cat artifacts/smb-macos-amd64.tar.gz.sha256)
.github/workflows/release-npm.yml
-3
@@ -1,9 +1,6 @@
1
name: NPM CLI Release
2
3
on:
4
- push:
5
- tags:
6
- - "v*.*.*"
4
workflow_dispatch:
5
inputs:
6
tag:
.github/workflows/release-nuget.yml
-3
@@ -1,9 +1,6 @@
1
name: NuGet CLI Release
2
3
on:
4
- push:
5
- tags:
6
- - "v*.*.*"
4
workflow_dispatch:
5
inputs:
6
tag:
.github/workflows/release-pypi.yml
-3
@@ -1,9 +1,6 @@
1
name: PyPI CLI Release
2
3
on:
4
- push:
5
- tags:
6
- - "v*.*.*"
4
workflow_dispatch:
5
inputs:
6
tag:
.github/workflows/release-sdk-gem.yml
+44
-8
@@ -8,9 +8,6 @@ name: SDK Ruby Gem Release
8
# RUBYGEMS_API_KEY — RubyGems.org API key with push access to smbcloud-auth
9
10
on:
11
- push:
12
- tags:
13
- - "v*.*.*"
11
workflow_dispatch:
12
inputs:
13
tag:
@@ -32,11 +29,19 @@ jobs:
29
fail-fast: false
30
matrix:
31
include:
35
- - { name: linux-x64, runner: ubuntu-latest, platform: x86_64-linux }
36
- - { name: linux-arm64, runner: ubuntu-24.04-arm, platform: aarch64-linux }
37
- - { name: darwin-arm64, runner: macos-latest, platform: arm64-darwin }
38
- - { name: darwin-x64, runner: macos-15-intel, platform: x86_64-darwin }
39
- - { name: win-x64, runner: windows-2022, platform: x64-mingw-ucrt }
32
+ - { name: linux-x64, runner: ubuntu-latest, platform: x86_64-linux }
33
+ - {
34
+ name: linux-arm64,
35
+ runner: ubuntu-24.04-arm,
36
+ platform: aarch64-linux,
37
+ }
38
+ - { name: darwin-arm64, runner: macos-latest, platform: arm64-darwin }
39
+ - {
40
+ name: darwin-x64,
41
+ runner: macos-15-intel,
42
+ platform: x86_64-darwin,
43
+ }
44
+ - { name: win-x64, runner: windows-2022, platform: x64-mingw-ucrt }
45
46
steps:
47
- name: Checkout
@@ -44,6 +49,37 @@ jobs:
49
with:
50
ref: ${{ github.event.inputs.tag || github.ref }}
51
52
+ - name: Set the release version
53
+ shell: bash
54
+ run: |
55
+ if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
56
+ release_version="${GITHUB_REF_NAME#v}"
57
+ else
58
+ release_version="${{ github.event.inputs.tag }}"
59
+ release_version="${release_version#v}"
60
+ fi
61
+ echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV"
62
+
63
+ - name: Wait for Rust crate dependencies on crates.io
64
+ shell: bash
65
+ run: |
66
+ for crate_name in smbcloud-auth-sdk smbcloud-model smbcloud-network; do
67
+ for attempt in 1 2 3 4 5 6; do
68
+ if curl -fsS "https://crates.io/api/v1/crates/${crate_name}/${RELEASE_VERSION}" >/dev/null 2>&1; then
69
+ echo "${crate_name} ${RELEASE_VERSION} is available on crates.io"
70
+ break
71
+ fi
72
+
73
+ if [ "$attempt" -eq 6 ]; then
74
+ echo "Timed out waiting for ${crate_name} ${RELEASE_VERSION} on crates.io" >&2
75
+ exit 1
76
+ fi
77
+
78
+ echo "Waiting for ${crate_name} ${RELEASE_VERSION} to appear on crates.io..."
79
+ sleep 15
80
+ done
81
+ done
82
+
83
- name: Read Rust toolchain
84
shell: bash
85
run: |
.github/workflows/release-sdk-npm.yml
-3
@@ -7,9 +7,6 @@ name: SDK npm Release
7
# NPM_TOKEN — npm access token with publish rights to @smbcloud/sdk-auth
8
9
on:
10
- push:
11
- tags:
12
- - "v*.*.*"
10
workflow_dispatch:
11
inputs:
12
tag:
.github/workflows/release-sdk-pypi.yml
+7
-5
@@ -1,12 +1,10 @@
1
name: SDK PyPI Release
2
3
# Publishes smbcloud-sdk-auth (sdk/python) to PyPI.
4
-# Triggered automatically on semver tags or manually via workflow_dispatch.
4
+# Triggered by the crates release workflow when wrapped Rust crates change,
5
+# or manually via workflow_dispatch.
6
7
on:
7
- push:
8
- tags:
9
- - "v*.*.*"
8
workflow_dispatch:
9
inputs:
10
tag:
@@ -51,7 +49,11 @@ jobs:
49
OS: windows-2022,
50
TARGET: aarch64-pc-windows-msvc,
51
}
54
- - { NAME: darwin-x64, OS: macos-15-intel, TARGET: x86_64-apple-darwin }
52
+ - {
53
+ NAME: darwin-x64,
54
+ OS: macos-15-intel,
55
+ TARGET: x86_64-apple-darwin,
56
+ }
57
- {
58
NAME: darwin-arm64,
59
OS: macos-latest,
Cargo.lock
+7
-7
@@ -2220,10 +2220,6 @@ version = "1.0.23"
2220
source = "registry+https://github.com/rust-lang/crates.io-index"
2221
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
2222
2223
-[[package]]
2224
-name = "s6n"
2225
-version = "0.3.39"
2226
-
2223
[[package]]
2224
name = "same-file"
2225
version = "1.0.6"
@@ -2520,7 +2516,7 @@ dependencies = [
2516
2517
[[package]]
2518
name = "smbcloud-cli"
2523
-version = "0.3.39"
2519
+version = "0.3.40"
2520
dependencies = [
2521
"anyhow",
2522
"chrono",
@@ -2603,7 +2599,7 @@ dependencies = [
2599
2600
[[package]]
2601
name = "smbcloud-model"
2606
-version = "0.3.39"
2602
+version = "0.3.40"
2603
dependencies = [
2604
"chrono",
2605
"log",
@@ -2632,7 +2628,7 @@ dependencies = [
2628
2629
[[package]]
2630
name = "smbcloud-networking"
2635
-version = "0.3.39"
2631
+version = "0.3.40"
2632
dependencies = [
2633
"smbcloud-network",
2634
"url-builder",
@@ -2656,6 +2652,10 @@ dependencies = [
2652
"thiserror 2.0.18",
2653
]
2654
2655
+[[package]]
2656
+name = "smbcloud-s6n"
2657
+version = "0.3.39"
2658
+
2659
[[package]]
2660
name = "smbcloud-utils"
2661
version = "0.3.39"
Makefile
new
+31
@@ -0,0 +1,31 @@
1
+.PHONY: help release patch minor major custom
2
+
3
+help:
4
+ @echo "Release commands:"
5
+ @echo " make patch"
6
+ @echo " make minor"
7
+ @echo " make major"
8
+ @echo " make custom VERSION=0.3.40"
9
+
10
+release:
11
+ @test -n "$(BUMP)" || (echo "BUMP is required" && exit 1)
12
+ @if [ "$(BUMP)" = "custom" ] && [ -z "$(VERSION)" ]; then echo "VERSION is required for custom releases"; exit 1; fi
13
+ @if [ "$(BUMP)" = "custom" ]; then \
14
+ cargo workspaces version custom "$(VERSION)" --yes --no-git-push; \
15
+ else \
16
+ cargo workspaces version "$(BUMP)" --yes --no-git-push; \
17
+ fi
18
+ @echo "Release version prepared locally."
19
+ @echo "Next: git push origin $$(git branch --show-current) && git push origin --tags"
20
+
21
+patch:
22
+ @$(MAKE) release BUMP=patch
23
+
24
+minor:
25
+ @$(MAKE) release BUMP=minor
26
+
27
+major:
28
+ @$(MAKE) release BUMP=major
29
+
30
+custom:
31
+ @$(MAKE) release BUMP=custom VERSION="$(VERSION)"
crates/cli/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
1
[package]
2
name = "smbcloud-cli"
3
-version = "0.3.39"
3
+version = "0.3.40"
4
edition = "2021"
5
authors = ["Seto Elkahfi <hej@setoelkahfi.se>"]
6
description = "smbCloud command line interface."
crates/smbcloud-model/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
1
[package]
2
name = "smbcloud-model"
3
-version = "0.3.39"
3
+version = "0.3.40"
4
edition = "2021"
5
authors = ["Seto Elkahfi <hej@setoelkahfi.se>"]
6
description = "Command line tool for deploying NextJS app on the smbCloud platform."
crates/smbcloud-networking/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
1
[package]
2
name = "smbcloud-networking"
3
-version = "0.3.39"
3
+version = "0.3.40"
4
edition = "2021"
5
readme = "README.md"
6
authors = ["Seto Elkahfi <hej@setoelkahfi.se>"]
crates/smbcloud-s6n/Cargo.toml
+2
-2
@@ -1,6 +1,6 @@
1
[package]
2
-name = "s6n"
3
-description = "S6n an S3-compatible object storage for smbCloud."
2
+name = "smbcloud-s6n"
3
+description = "S6n is an S3-compatible object storage for smbCloud."
4
version = "0.3.39"
5
edition = "2024"
6
readme = "README.md"