@ondeinference / cli / commits / 725f204

Route every release through the crates.io workflow

The wrapper workflows each triggered on their own tag push, so a tag started five publishes at once with nothing sequencing them. npm, NuGet and PyPI could publish a version whose crate publish had just failed, and release-pub could run against a GitHub Release whose assets were not uploaded yet. Follow the smbcloud-cli shape instead: release-crates.yml is the only workflow that reacts to a tag. It verifies the tag against Cargo.toml and pubspec.yaml, publishes the crate, then dispatches the distribution workflows with that same tag. Downstream dispatches now pass ref: <tag> rather than ref: main, so they run the workflow definition from the release state. Also pin the crates workflow to rust-toolchain.toml instead of @stable, and skip the publish when the version is already on crates.io. Co-Authored-By: siGit Code <297239231+sigitc@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Seto Elkahfi committed Jul 22, 2026 at 21:57 UTC 725f204b6904c8b302ab2fc813eb95275e96578b
8 files changed +174 -41
.agents/skills/distribution/SKILL.md
+28
@@ -110,6 +110,34 @@ If a wrapper uses one naming scheme and a workflow uploads another, the installe
110
111 ## Release workflow principles
112
113 +### One tag trigger, then fan out
114 +
115 +`release-crates.yml` is the only workflow that reacts to a tag push. It verifies the
116 +tag against `Cargo.toml` and `pubspec.yaml`, publishes the crate, then dispatches
117 +`release-github`, `release-npm`, `release-nuget`, and `release-pypi` with the same tag.
118 +`release-github` in turn dispatches `release-homebrew` and `release-pub` after the
119 +release assets exist.
120 +
121 +```
122 +tag push
123 + └─ release-crates (verify -> publish -> dispatch)
124 + ├─ release-github ─┬─ release-homebrew
125 + │ └─ release-pub
126 + ├─ release-npm
127 + ├─ release-nuget
128 + └─ release-pypi
129 +```
130 +
131 +This mirrors `smbcloud-cli`. Two properties matter:
132 +
133 +- crates.io is the gate. If verify or publish fails, no wrapper publishes a version
134 + that has no crate behind it.
135 +- every dispatch passes `ref: <tag>`, not `ref: main`, so downstream workflows run the
136 + workflow definition from the release state instead of whatever `main` happens to be.
137 +
138 +Do not add `push: tags:` to a wrapper workflow. Several workflows racing on the same tag
139 +is what lets the pub.dev launcher publish before its GitHub Release assets exist.
140 +
141 ### Build first, package second
142
143 Always build native binaries in a target matrix first.
.github/workflows/release-crates.yml
+134 -21
@@ -1,13 +1,22 @@
1 name: Release crates.io
2
3 +# This is the only workflow that reacts to a tag push. It verifies the tag,
4 +# publishes the crate, and then dispatches every distribution workflow with the
5 +# same tag. Wrappers never publish for a crate release that failed.
6 +
7 on:
8 push:
9 tags:
6 - - "v*"
10 + - "v*.*.*"
11 workflow_dispatch:
12 + inputs:
13 + tag:
14 + description: "Release tag (e.g. v0.4.0)"
15 + required: true
16
17 permissions:
18 contents: read
19 + actions: write
20
21 concurrency:
22 group: release-crates-${{ github.ref }}
@@ -26,44 +35,69 @@ jobs:
35 name: Verify release
36 runs-on: ubuntu-latest
37 outputs:
29 - version: ${{ steps.package.outputs.version }}
30 - tag: ${{ steps.tag.outputs.tag }}
38 + version: ${{ steps.release.outputs.version }}
39 + tag: ${{ steps.release.outputs.tag }}
40 steps:
41 - name: Checkout
42 uses: actions/checkout@v6
43 + with:
44 + ref: ${{ github.event.inputs.tag || github.ref }}
45 +
46 + - name: Read Rust toolchain
47 + shell: bash
48 + run: |
49 + rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)"
50 + if [ -z "$rust_toolchain" ]; then
51 + echo "Failed to read Rust toolchain from rust-toolchain.toml" >&2
52 + exit 1
53 + fi
54 + echo "RUST_TOOLCHAIN=${rust_toolchain}" >> "$GITHUB_ENV"
55
56 - name: Install Rust toolchain
36 - uses: dtolnay/rust-toolchain@stable
57 + uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9
58 with:
59 + toolchain: ${{ env.RUST_TOOLCHAIN }}
60 components: clippy, rustfmt
61
62 - name: Cache cargo registry and build artifacts
63 uses: Swatinem/rust-cache@v2
64
43 - - name: Read package version
44 - id: package
65 + - name: Set the release version
66 + id: release
67 shell: bash
68 run: |
47 - version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)"
48 - if [ -z "$version" ]; then
49 - echo "Failed to read package version from Cargo.toml" >&2
50 - exit 1
69 + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
70 + release_tag="${GITHUB_REF_NAME}"
71 + else
72 + release_tag="${{ github.event.inputs.tag }}"
73 + release_tag="${release_tag#refs/tags/}"
74 fi
52 - echo "version=$version" >> "$GITHUB_OUTPUT"
75 + release_version="${release_tag#v}"
76
54 - - name: Read git tag version
55 - id: tag
77 + echo "tag=${release_tag}" >> "$GITHUB_OUTPUT"
78 + echo "version=${release_version}" >> "$GITHUB_OUTPUT"
79 +
80 + - name: Ensure tag matches Cargo.toml version
81 shell: bash
82 run: |
58 - ref="${GITHUB_REF_NAME}"
59 - tag="${ref#v}"
60 - echo "tag=$tag" >> "$GITHUB_OUTPUT"
83 + version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)"
84 + if [ -z "$version" ]; then
85 + echo "::error::Failed to read package version from Cargo.toml"
86 + exit 1
87 + fi
88 + if [ "$version" != "${{ steps.release.outputs.version }}" ]; then
89 + echo "::error::Cargo.toml is ${version} but the tag is ${{ steps.release.outputs.version }}"
90 + exit 1
91 + fi
92
62 - - name: Ensure tag matches Cargo.toml version
63 - if: github.event_name == 'push'
93 + - name: Ensure the pub.dev wrapper matches the tag
94 shell: bash
95 run: |
66 - test "${{ steps.package.outputs.version }}" = "${{ steps.tag.outputs.tag }}"
96 + pub_version="$(sed -n 's/^version: \(.*\)/\1/p' pub/onde_cli/pubspec.yaml | head -n 1)"
97 + if [ "$pub_version" != "${{ steps.release.outputs.version }}" ]; then
98 + echo "::error::pubspec.yaml is ${pub_version} but the tag is ${{ steps.release.outputs.version }}"
99 + exit 1
100 + fi
101
102 - name: Check formatting
103 run: cargo fmt --all -- --check
@@ -91,9 +125,19 @@ jobs:
125 steps:
126 - name: Checkout
127 uses: actions/checkout@v6
128 + with:
129 + ref: ${{ github.event.inputs.tag || github.ref }}
130 +
131 + - name: Read Rust toolchain
132 + shell: bash
133 + run: |
134 + rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)"
135 + echo "RUST_TOOLCHAIN=${rust_toolchain}" >> "$GITHUB_ENV"
136
137 - name: Install Rust toolchain
96 - uses: dtolnay/rust-toolchain@stable
138 + uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9
139 + with:
140 + toolchain: ${{ env.RUST_TOOLCHAIN }}
141
142 - name: Cache cargo registry and build artifacts
143 uses: Swatinem/rust-cache@v2
@@ -101,4 +145,73 @@ jobs:
145 - name: Publish crate
146 env:
147 CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
104 - run: cargo publish --locked
148 + shell: bash
149 + run: |
150 + version="${{ needs.verify.outputs.version }}"
151 + if curl -sSf "https://crates.io/api/v1/crates/onde-cli/${version}" >/dev/null 2>&1; then
152 + echo "onde-cli ${version} is already on crates.io, skipping publish"
153 + exit 0
154 + fi
155 + cargo publish --locked
156 +
157 + dispatch-distributions:
158 + name: Trigger distribution workflows
159 + needs: [verify, publish]
160 + runs-on: ubuntu-latest
161 +
162 + steps:
163 + - name: Trigger GitHub release
164 + uses: actions/github-script@v7
165 + with:
166 + script: |
167 + await github.rest.actions.createWorkflowDispatch({
168 + owner: context.repo.owner,
169 + repo: context.repo.repo,
170 + workflow_id: 'release-github.yml',
171 + ref: '${{ needs.verify.outputs.tag }}',
172 + inputs: {
173 + tag: '${{ needs.verify.outputs.tag }}'
174 + }
175 + })
176 +
177 + - name: Trigger npm release
178 + uses: actions/github-script@v7
179 + with:
180 + script: |
181 + await github.rest.actions.createWorkflowDispatch({
182 + owner: context.repo.owner,
183 + repo: context.repo.repo,
184 + workflow_id: 'release-npm.yml',
185 + ref: '${{ needs.verify.outputs.tag }}',
186 + inputs: {
187 + tag: '${{ needs.verify.outputs.tag }}'
188 + }
189 + })
190 +
191 + - name: Trigger NuGet release
192 + uses: actions/github-script@v7
193 + with:
194 + script: |
195 + await github.rest.actions.createWorkflowDispatch({
196 + owner: context.repo.owner,
197 + repo: context.repo.repo,
198 + workflow_id: 'release-nuget.yml',
199 + ref: '${{ needs.verify.outputs.tag }}',
200 + inputs: {
201 + tag: '${{ needs.verify.outputs.tag }}'
202 + }
203 + })
204 +
205 + - name: Trigger PyPI release
206 + uses: actions/github-script@v7
207 + with:
208 + script: |
209 + await github.rest.actions.createWorkflowDispatch({
210 + owner: context.repo.owner,
211 + repo: context.repo.repo,
212 + workflow_id: 'release-pypi.yml',
213 + ref: '${{ needs.verify.outputs.tag }}',
214 + inputs: {
215 + tag: '${{ needs.verify.outputs.tag }}'
216 + }
217 + })
.github/workflows/release-github.yml
+4 -6
@@ -1,13 +1,11 @@
1 name: GitHub Release
2
3 +# Dispatched by release-crates.yml once the crate publish succeeds.
4 on:
4 - push:
5 - tags:
6 - - "v*.*.*"
5 workflow_dispatch:
6 inputs:
7 tag:
10 - description: "Release tag (e.g. v0.3.1)"
8 + description: "Release tag (e.g. v0.4.0)"
9 required: true
10
11 permissions:
@@ -184,7 +182,7 @@ jobs:
182 owner: context.repo.owner,
183 repo: context.repo.repo,
184 workflow_id: 'release-homebrew.yml',
187 - ref: 'main',
185 + ref: '${{ steps.tag.outputs.tag }}',
186 inputs: {
187 tag: '${{ steps.tag.outputs.tag }}'
188 }
@@ -199,7 +197,7 @@ jobs:
197 owner: context.repo.owner,
198 repo: context.repo.repo,
199 workflow_id: 'release-pub.yml',
202 - ref: 'main',
200 + ref: '${{ steps.tag.outputs.tag }}',
201 inputs: {
202 tag: '${{ steps.tag.outputs.tag }}'
203 }
.github/workflows/release-homebrew.yml
+1 -1
@@ -4,7 +4,7 @@ on:
4 workflow_dispatch:
5 inputs:
6 tag:
7 - description: "Release tag (e.g. v0.3.1)"
7 + description: "Release tag (e.g. v0.4.0)"
8 required: true
9
10 permissions:
.github/workflows/release-npm.yml
+2 -4
@@ -1,13 +1,11 @@
1 name: NPM Release
2
3 +# Dispatched by release-crates.yml once the crate publish succeeds.
4 on:
4 - push:
5 - tags:
6 - - "v*.*.*"
5 workflow_dispatch:
6 inputs:
7 tag:
10 - description: "Release tag (e.g. v0.3.1)"
8 + description: "Release tag (e.g. v0.4.0)"
9 required: true
10
11 permissions:
.github/workflows/release-nuget.yml
+2 -4
@@ -1,13 +1,11 @@
1 name: NuGet Release
2
3 +# Dispatched by release-crates.yml once the crate publish succeeds.
4 on:
4 - push:
5 - tags:
6 - - "v*.*.*"
5 workflow_dispatch:
6 inputs:
7 tag:
10 - description: "Release tag (e.g. v0.3.1)"
8 + description: "Release tag (e.g. v0.4.0)"
9 required: true
10
11 permissions:
.github/workflows/release-pub.yml
+1 -1
@@ -4,7 +4,7 @@ on:
4 workflow_dispatch:
5 inputs:
6 tag:
7 - description: "Release tag (e.g. v0.3.1)"
7 + description: "Release tag (e.g. v0.4.0)"
8 required: true
9
10 permissions:
.github/workflows/release-pypi.yml
+2 -4
@@ -1,13 +1,11 @@
1 name: PyPI Release
2
3 +# Dispatched by release-crates.yml once the crate publish succeeds.
4 on:
4 - push:
5 - tags:
6 - - "v*.*.*"
5 workflow_dispatch:
6 inputs:
7 tag:
10 - description: "Release tag (e.g. v0.3.1)"
8 + description: "Release tag (e.g. v0.4.0)"
9 required: true
10
11 permissions: