11name : Release
22
33# Triggers on tag push (e.g. `git push origin v0.2.0`). Builds the Conan
4- # package, uploads it to the project's Cloudsmith remote, and publishes a
5- # GitHub Release with auto-generated notes.
4+ # package on every consumer platform (Linux, macOS, Windows), uploads each
5+ # binary to the project's Cloudsmith remote, and publishes a GitHub Release
6+ # with auto-generated notes.
67#
7- # Secrets required (set in repo Settings → Secrets and variables → Actions):
8- # CLOUDSMITH_USER — Cloudsmith username (e.g. "davide-faconti")
9- # CLOUDSMITH_API_KEY — Cloudsmith API key with write access to the
8+ # Why a matrix: Cloudsmith stores one binary package per platform/compiler
9+ # (package_id). A single ubuntu job only ever publishes a Linux/gcc binary, so
10+ # macOS and Windows consumers are forced to build plotjuggler_core from source
11+ # via `--build=missing`. That is slow and, on macOS specifically, fragile: a
12+ # from-source build depends on the recipe's exported sources being intact in
13+ # the cache, which breaks (e.g. after a version is re-published with a new
14+ # recipe revision) with "exports_sources but sources not found in local cache".
15+ # Publishing a binary per platform means consumers download instead of compile.
16+ #
17+ # Secrets required (set in repo Settings -> Secrets and variables -> Actions):
18+ # CLOUDSMITH_USER - Cloudsmith username (e.g. "davide-faconti")
19+ # CLOUDSMITH_API_KEY - Cloudsmith API key with write access to the
1020# plotjuggler/plotjuggler repository
1121#
12- # Manual trigger: workflow_dispatch lets you re-run for an existing tag if
13- # the first attempt failed (e.g. flaky upload). Set `tag` input to e.g. v0.1.0.
22+ # Manual trigger: workflow_dispatch lets you re-run for an existing tag if the
23+ # first attempt failed (e.g. flaky upload on one platform). Set `tag` input to
24+ # e.g. v0.1.0. NOTE: do not *move* an already-released tag to a new commit —
25+ # cut a new patch version instead. Moving a tag changes the recipe revision on
26+ # the remote and orphans any consumer mid-resolve. The `prepare` job enforces
27+ # this: it refuses to publish a version that already exists on Cloudsmith under
28+ # a different recipe revision (override with the `allow_republish` input only to
29+ # repair a botched release).
1430
1531on :
1632 push :
2137 tag :
2238 description : ' Tag to (re-)release (e.g. v0.1.0). Must already exist.'
2339 required : true
40+ allow_republish :
41+ description : ' Bypass the re-publish guard (only to repair a botched release of an existing version)'
42+ type : boolean
43+ default : false
44+ required : false
2445
2546concurrency :
2647 group : release-${{ github.ref }}
2748 cancel-in-progress : false # never cancel an in-flight release
2849
2950jobs :
30- release :
51+ # ---------------------------------------------------------------------------
52+ # Resolve the version/tag once and verify it matches conanfile.py, so the
53+ # build matrix and the GitHub Release all agree on a single source of truth.
54+ # ---------------------------------------------------------------------------
55+ prepare :
3156 runs-on : ubuntu-22.04
3257 permissions :
33- contents : write # required to create the GitHub Release
58+ contents : read
59+ outputs :
60+ ref : ${{ steps.ref.outputs.ref }}
61+ tag : ${{ steps.ref.outputs.tag }}
62+ version : ${{ steps.ref.outputs.version }}
3463 steps :
3564 - name : Resolve ref
3665 id : ref
5483
5584 - uses : conan-io/setup-conan@v1
5685
57- - name : Detect Conan profile
58- run : |
59- conan profile detect --force
60- conan profile show
61-
6286 - name : Verify recipe version matches tag
6387 # Prevents the common footgun of tagging vX.Y.Z but forgetting to bump
6488 # `version` in conanfile.py. Fails fast before we publish.
@@ -70,7 +94,105 @@ jobs:
7094 fi
7195 echo "Version match: ${recipe_version}"
7296
97+ - name : Guard against re-publishing an existing version with different sources
98+ # The incident this matrix fixes was triggered by *moving* a released tag:
99+ # v0.5.0 was published twice from two different commits, so the second run
100+ # produced a new recipe revision that replaced the first and orphaned any
101+ # consumer that had resolved the original mid-build. This guard computes
102+ # the recipe revision THIS release would publish and compares it to what is
103+ # already on Cloudsmith for this version:
104+ # * version not published yet -> proceed (first release)
105+ # * same recipe revision present -> proceed (idempotent re-run after a
106+ # flaky/partial upload)
107+ # * a different revision present -> FAIL (sources changed under an
108+ # already-released version)
109+ # prepare runs on Linux (LF); the build matrix forces an LF checkout, so
110+ # both compute the same canonical recipe revision.
111+ env :
112+ CLOUDSMITH_USER : ${{ secrets.CLOUDSMITH_USER }}
113+ CLOUDSMITH_API_KEY : ${{ secrets.CLOUDSMITH_API_KEY }}
114+ run : |
115+ if [[ "${{ inputs.allow_republish }}" == "true" ]]; then
116+ echo "allow_republish=true -> skipping the re-publish guard"
117+ exit 0
118+ fi
119+ version="${{ steps.ref.outputs.version }}"
120+
121+ # Recipe revision this release would publish (deterministic from the
122+ # checked-out sources, independent of build settings).
123+ conan profile detect --force >/dev/null 2>&1 || true
124+ local_rrev=$(conan export . --format=json \
125+ | python3 -c "import json,sys; print(json.load(sys.stdin)['reference'].split('#',1)[1])")
126+ echo "This release would publish recipe revision: ${local_rrev}"
127+
128+ conan remote add plotjuggler-cloudsmith https://conan.cloudsmith.io/plotjuggler/plotjuggler --force
129+ if [[ -n "$CLOUDSMITH_USER" && -n "$CLOUDSMITH_API_KEY" ]]; then
130+ conan remote login plotjuggler-cloudsmith "$CLOUDSMITH_USER" -p "$CLOUDSMITH_API_KEY"
131+ fi
132+
133+ conan list "plotjuggler_core/${version}#*" -r plotjuggler-cloudsmith --format=json \
134+ > /tmp/remote_revs.json 2>/dev/null || true
135+
136+ if grep -q "RECIPEUNKNOWN" /tmp/remote_revs.json; then
137+ echo "Version ${version} is not yet published — first release, proceeding."
138+ elif grep -q '"error"' /tmp/remote_revs.json; then
139+ echo "::warning::Unexpected error reading published revisions for ${version} (transient?). Proceeding without the guard."
140+ cat /tmp/remote_revs.json
141+ elif grep -q "${local_rrev}" /tmp/remote_revs.json; then
142+ echo "Recipe revision ${local_rrev} is already published for ${version} — idempotent re-run, proceeding."
143+ else
144+ echo "::error::plotjuggler_core/${version} is already published with a different recipe revision."
145+ echo "::error::This build would publish ${local_rrev}, which is not on the remote. Refusing to overwrite a released version."
146+ echo "::error::Cut a new version, or re-run via workflow_dispatch with allow_republish=true to repair a botched release."
147+ echo "Currently published for ${version}:"
148+ conan list "plotjuggler_core/${version}#*" -r plotjuggler-cloudsmith 2>/dev/null || true
149+ exit 1
150+ fi
151+
152+ # ---------------------------------------------------------------------------
153+ # Build + upload one Conan binary per consumer platform. fail-fast: false so
154+ # a transient failure on one OS still publishes the others (re-run the
155+ # workflow_dispatch for the failed leg); the GitHub Release is gated on ALL
156+ # legs succeeding so we never advertise an incomplete release.
157+ # ---------------------------------------------------------------------------
158+ build :
159+ needs : prepare
160+ permissions :
161+ contents : read
162+ strategy :
163+ fail-fast : false
164+ matrix :
165+ os : [ubuntu-22.04, macos-15-intel, windows-latest]
166+ runs-on : ${{ matrix.os }}
167+ defaults :
168+ run :
169+ shell : bash # Git Bash on Windows; conan/cmake are on PATH everywhere
170+ steps :
171+ - name : Force LF line endings (consistent recipe revision across OSes)
172+ # No .gitattributes in the repo, so on Windows git would convert text
173+ # files to CRLF on checkout, changing the recipe revision hash and
174+ # splitting the package across two revisions. Force LF everywhere so every
175+ # matrix leg computes and uploads the SAME recipe revision (matching the
176+ # one prepare's guard validated on Linux).
177+ run : |
178+ git config --global core.autocrlf false
179+ git config --global core.eol lf
180+
181+ - uses : actions/checkout@v4
182+ with :
183+ ref : ${{ needs.prepare.outputs.ref }}
184+
185+ - uses : conan-io/setup-conan@v1
186+
187+ - name : Detect Conan profile
188+ run : |
189+ conan profile detect --force
190+ conan profile show
191+
73192 - name : Build Conan package
193+ # Same settings consumers use (-s build_type=Release -s
194+ # compiler.cppstd=20) so the published package_id matches what
195+ # downstream `conan install` resolves on each platform.
74196 run : |
75197 conan create . \
76198 --build=missing \
@@ -91,19 +213,32 @@ jobs:
91213 conan remote login plotjuggler-cloudsmith "$CLOUDSMITH_USER" -p "$CLOUDSMITH_API_KEY"
92214
93215 - name : Upload package to Cloudsmith
216+ # Each matrix leg uploads the recipe + its own binary. The recipe
217+ # revision is identical across platforms (same recipe content), so
218+ # concurrent recipe uploads are idempotent — only the per-platform
219+ # binary differs. `--check` verifies integrity before/after transfer.
94220 run : |
95- conan upload "plotjuggler_core/${{ steps.ref .outputs.version }}" \
221+ conan upload "plotjuggler_core/${{ needs.prepare .outputs.version }}" \
96222 -r plotjuggler-cloudsmith \
97223 --confirm \
98224 --check
99225
226+ # ---------------------------------------------------------------------------
227+ # Cut the GitHub Release once, only after every platform binary is published.
228+ # ---------------------------------------------------------------------------
229+ github-release :
230+ needs : [prepare, build]
231+ runs-on : ubuntu-22.04
232+ permissions :
233+ contents : write # required to create the GitHub Release
234+ steps :
100235 - name : Create GitHub Release
101236 # softprops/action-gh-release: handles auto-generated notes + idempotent
102237 # re-runs (skips if a release for the tag already exists).
103238 uses : softprops/action-gh-release@v2
104239 with :
105- tag_name : ${{ steps.ref .outputs.tag }}
106- name : plotjuggler_core ${{ steps.ref .outputs.tag }}
240+ tag_name : ${{ needs.prepare .outputs.tag }}
241+ name : plotjuggler_core ${{ needs.prepare .outputs.tag }}
107242 generate_release_notes : true
108243 body : |
109244 ## Install via Conan
@@ -115,7 +250,7 @@ jobs:
115250 Add to your `conanfile.py` / `conanfile.txt`:
116251
117252 ```python
118- requires = ("plotjuggler_core/${{ steps.ref .outputs.version }}",)
253+ requires = ("plotjuggler_core/${{ needs.prepare .outputs.version }}",)
119254 ```
120255
121256 Link in CMake:
0 commit comments