Skip to content

Commit aa1dcbe

Browse files
ci(release): publish Conan binaries for Linux, macOS, and Windows (#108)
* ci(release): build + publish Conan binaries on Linux, macOS, Windows The release job ran only on ubuntu-22.04, so Cloudsmith carried a single Linux/gcc binary. macOS and Windows consumers therefore had to build plotjuggler_core from source via --build=missing — slow, and on macOS brittle: from-source builds depend on the recipe's exported sources being intact in the cache, which breaks (e.g. after a version is re-published under a new recipe revision) with "exports_sources but sources not found in local cache". Restructure into prepare -> build (matrix) -> github-release: - prepare resolves the version/tag once and verifies it matches conanfile.py - build fans out over [ubuntu-22.04, macos-15-intel, windows-latest], each running conan create + conan upload with the same Release/cppstd=20 settings consumers use, so the published package_id matches downstream - github-release cuts the GitHub Release once, gated on all platforms With a binary per platform, downstream macOS/Windows CI downloads instead of compiling, eliminating the from-source fragility entirely. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(release): guard against re-publishing a version with changed sources Add a prepare-job guard that refuses to publish plotjuggler_core/<version> when it is already on Cloudsmith under a *different* recipe revision — the exact footgun that broke macOS CI (v0.5.0 was published twice from two different commits, replacing the recipe revision mid-resolve). It compares the recipe revision this release would publish against the remote: - not published -> proceed (first release) - same revision present -> proceed (idempotent re-run after a partial upload) - a different revision -> fail Override with the workflow_dispatch `allow_republish` input to repair a botched release. Also force an LF checkout in the build matrix: with no .gitattributes, Windows would otherwise check out CRLF and compute a different recipe revision than Linux/macOS, splitting one version across two revisions and defeating the matrix. LF everywhere keeps the revision identical across all legs and matching the one the guard validates on Linux. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a0d100c commit aa1dcbe

1 file changed

Lines changed: 153 additions & 18 deletions

File tree

.github/workflows/release.yml

Lines changed: 153 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
11
name: 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

1531
on:
1632
push:
@@ -21,16 +37,29 @@ on:
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

2546
concurrency:
2647
group: release-${{ github.ref }}
2748
cancel-in-progress: false # never cancel an in-flight release
2849

2950
jobs:
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
@@ -54,11 +83,6 @@ jobs:
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

Comments
 (0)