From cc36a33392c05cbdca35643cbce2ce73d12770d9 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Fri, 29 May 2026 14:09:21 +0200 Subject: [PATCH 1/2] ci(release): build + publish Conan binaries on Linux, macOS, Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/release.yml | 100 +++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index feb0037..117be05 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,29 @@ name: Release # Triggers on tag push (e.g. `git push origin v0.2.0`). Builds the Conan -# package, uploads it to the project's Cloudsmith remote, and publishes a -# GitHub Release with auto-generated notes. +# package on every consumer platform (Linux, macOS, Windows), uploads each +# binary to the project's Cloudsmith remote, and publishes a GitHub Release +# with auto-generated notes. # -# Secrets required (set in repo Settings → Secrets and variables → Actions): -# CLOUDSMITH_USER — Cloudsmith username (e.g. "davide-faconti") -# CLOUDSMITH_API_KEY — Cloudsmith API key with write access to the +# Why a matrix: Cloudsmith stores one binary package per platform/compiler +# (package_id). A single ubuntu job only ever publishes a Linux/gcc binary, so +# macOS and Windows consumers are forced to build plotjuggler_core from source +# via `--build=missing`. That is slow and, on macOS specifically, fragile: a +# from-source build depends on the recipe's exported sources being intact in +# the cache, which breaks (e.g. after a version is re-published with a new +# recipe revision) with "exports_sources but sources not found in local cache". +# Publishing a binary per platform means consumers download instead of compile. +# +# Secrets required (set in repo Settings -> Secrets and variables -> Actions): +# CLOUDSMITH_USER - Cloudsmith username (e.g. "davide-faconti") +# CLOUDSMITH_API_KEY - Cloudsmith API key with write access to the # plotjuggler/plotjuggler repository # -# Manual trigger: workflow_dispatch lets you re-run for an existing tag if -# the first attempt failed (e.g. flaky upload). Set `tag` input to e.g. v0.1.0. +# Manual trigger: workflow_dispatch lets you re-run for an existing tag if the +# first attempt failed (e.g. flaky upload on one platform). Set `tag` input to +# e.g. v0.1.0. NOTE: do not *move* an already-released tag to a new commit — +# cut a new patch version instead. Moving a tag changes the recipe revision on +# the remote and orphans any consumer mid-resolve. on: push: @@ -27,10 +40,18 @@ concurrency: cancel-in-progress: false # never cancel an in-flight release jobs: - release: + # --------------------------------------------------------------------------- + # Resolve the version/tag once and verify it matches conanfile.py, so the + # build matrix and the GitHub Release all agree on a single source of truth. + # --------------------------------------------------------------------------- + prepare: runs-on: ubuntu-22.04 permissions: - contents: write # required to create the GitHub Release + contents: read + outputs: + ref: ${{ steps.ref.outputs.ref }} + tag: ${{ steps.ref.outputs.tag }} + version: ${{ steps.ref.outputs.version }} steps: - name: Resolve ref id: ref @@ -52,13 +73,6 @@ jobs: with: ref: ${{ steps.ref.outputs.ref }} - - uses: conan-io/setup-conan@v1 - - - name: Detect Conan profile - run: | - conan profile detect --force - conan profile show - - name: Verify recipe version matches tag # Prevents the common footgun of tagging vX.Y.Z but forgetting to bump # `version` in conanfile.py. Fails fast before we publish. @@ -70,7 +84,40 @@ jobs: fi echo "Version match: ${recipe_version}" + # --------------------------------------------------------------------------- + # Build + upload one Conan binary per consumer platform. fail-fast: false so + # a transient failure on one OS still publishes the others (re-run the + # workflow_dispatch for the failed leg); the GitHub Release is gated on ALL + # legs succeeding so we never advertise an incomplete release. + # --------------------------------------------------------------------------- + build: + needs: prepare + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-15-intel, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash # Git Bash on Windows; conan/cmake are on PATH everywhere + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.ref }} + + - uses: conan-io/setup-conan@v1 + + - name: Detect Conan profile + run: | + conan profile detect --force + conan profile show + - name: Build Conan package + # Same settings consumers use (-s build_type=Release -s + # compiler.cppstd=20) so the published package_id matches what + # downstream `conan install` resolves on each platform. run: | conan create . \ --build=missing \ @@ -91,19 +138,32 @@ jobs: conan remote login plotjuggler-cloudsmith "$CLOUDSMITH_USER" -p "$CLOUDSMITH_API_KEY" - name: Upload package to Cloudsmith + # Each matrix leg uploads the recipe + its own binary. The recipe + # revision is identical across platforms (same recipe content), so + # concurrent recipe uploads are idempotent — only the per-platform + # binary differs. `--check` verifies integrity before/after transfer. run: | - conan upload "plotjuggler_core/${{ steps.ref.outputs.version }}" \ + conan upload "plotjuggler_core/${{ needs.prepare.outputs.version }}" \ -r plotjuggler-cloudsmith \ --confirm \ --check + # --------------------------------------------------------------------------- + # Cut the GitHub Release once, only after every platform binary is published. + # --------------------------------------------------------------------------- + github-release: + needs: [prepare, build] + runs-on: ubuntu-22.04 + permissions: + contents: write # required to create the GitHub Release + steps: - name: Create GitHub Release # softprops/action-gh-release: handles auto-generated notes + idempotent # re-runs (skips if a release for the tag already exists). uses: softprops/action-gh-release@v2 with: - tag_name: ${{ steps.ref.outputs.tag }} - name: plotjuggler_core ${{ steps.ref.outputs.tag }} + tag_name: ${{ needs.prepare.outputs.tag }} + name: plotjuggler_core ${{ needs.prepare.outputs.tag }} generate_release_notes: true body: | ## Install via Conan @@ -115,7 +175,7 @@ jobs: Add to your `conanfile.py` / `conanfile.txt`: ```python - requires = ("plotjuggler_core/${{ steps.ref.outputs.version }}",) + requires = ("plotjuggler_core/${{ needs.prepare.outputs.version }}",) ``` Link in CMake: From dd3f525c48656715e838e39e34d4e9fbbd774674 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Fri, 29 May 2026 14:51:55 +0200 Subject: [PATCH 2/2] ci(release): guard against re-publishing a version with changed sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a prepare-job guard that refuses to publish plotjuggler_core/ 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) --- .github/workflows/release.yml | 77 ++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 117be05..d821f12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,10 @@ name: Release # first attempt failed (e.g. flaky upload on one platform). Set `tag` input to # e.g. v0.1.0. NOTE: do not *move* an already-released tag to a new commit — # cut a new patch version instead. Moving a tag changes the recipe revision on -# the remote and orphans any consumer mid-resolve. +# the remote and orphans any consumer mid-resolve. The `prepare` job enforces +# this: it refuses to publish a version that already exists on Cloudsmith under +# a different recipe revision (override with the `allow_republish` input only to +# repair a botched release). on: push: @@ -34,6 +37,11 @@ on: tag: description: 'Tag to (re-)release (e.g. v0.1.0). Must already exist.' required: true + allow_republish: + description: 'Bypass the re-publish guard (only to repair a botched release of an existing version)' + type: boolean + default: false + required: false concurrency: group: release-${{ github.ref }} @@ -73,6 +81,8 @@ jobs: with: ref: ${{ steps.ref.outputs.ref }} + - uses: conan-io/setup-conan@v1 + - name: Verify recipe version matches tag # Prevents the common footgun of tagging vX.Y.Z but forgetting to bump # `version` in conanfile.py. Fails fast before we publish. @@ -84,6 +94,61 @@ jobs: fi echo "Version match: ${recipe_version}" + - name: Guard against re-publishing an existing version with different sources + # The incident this matrix fixes was triggered by *moving* a released tag: + # v0.5.0 was published twice from two different commits, so the second run + # produced a new recipe revision that replaced the first and orphaned any + # consumer that had resolved the original mid-build. This guard computes + # the recipe revision THIS release would publish and compares it to what is + # already on Cloudsmith for this version: + # * version not published yet -> proceed (first release) + # * same recipe revision present -> proceed (idempotent re-run after a + # flaky/partial upload) + # * a different revision present -> FAIL (sources changed under an + # already-released version) + # prepare runs on Linux (LF); the build matrix forces an LF checkout, so + # both compute the same canonical recipe revision. + env: + CLOUDSMITH_USER: ${{ secrets.CLOUDSMITH_USER }} + CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} + run: | + if [[ "${{ inputs.allow_republish }}" == "true" ]]; then + echo "allow_republish=true -> skipping the re-publish guard" + exit 0 + fi + version="${{ steps.ref.outputs.version }}" + + # Recipe revision this release would publish (deterministic from the + # checked-out sources, independent of build settings). + conan profile detect --force >/dev/null 2>&1 || true + local_rrev=$(conan export . --format=json \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['reference'].split('#',1)[1])") + echo "This release would publish recipe revision: ${local_rrev}" + + conan remote add plotjuggler-cloudsmith https://conan.cloudsmith.io/plotjuggler/plotjuggler --force + if [[ -n "$CLOUDSMITH_USER" && -n "$CLOUDSMITH_API_KEY" ]]; then + conan remote login plotjuggler-cloudsmith "$CLOUDSMITH_USER" -p "$CLOUDSMITH_API_KEY" + fi + + conan list "plotjuggler_core/${version}#*" -r plotjuggler-cloudsmith --format=json \ + > /tmp/remote_revs.json 2>/dev/null || true + + if grep -q "RECIPEUNKNOWN" /tmp/remote_revs.json; then + echo "Version ${version} is not yet published — first release, proceeding." + elif grep -q '"error"' /tmp/remote_revs.json; then + echo "::warning::Unexpected error reading published revisions for ${version} (transient?). Proceeding without the guard." + cat /tmp/remote_revs.json + elif grep -q "${local_rrev}" /tmp/remote_revs.json; then + echo "Recipe revision ${local_rrev} is already published for ${version} — idempotent re-run, proceeding." + else + echo "::error::plotjuggler_core/${version} is already published with a different recipe revision." + echo "::error::This build would publish ${local_rrev}, which is not on the remote. Refusing to overwrite a released version." + echo "::error::Cut a new version, or re-run via workflow_dispatch with allow_republish=true to repair a botched release." + echo "Currently published for ${version}:" + conan list "plotjuggler_core/${version}#*" -r plotjuggler-cloudsmith 2>/dev/null || true + exit 1 + fi + # --------------------------------------------------------------------------- # Build + upload one Conan binary per consumer platform. fail-fast: false so # a transient failure on one OS still publishes the others (re-run the @@ -103,6 +168,16 @@ jobs: run: shell: bash # Git Bash on Windows; conan/cmake are on PATH everywhere steps: + - name: Force LF line endings (consistent recipe revision across OSes) + # No .gitattributes in the repo, so on Windows git would convert text + # files to CRLF on checkout, changing the recipe revision hash and + # splitting the package across two revisions. Force LF everywhere so every + # matrix leg computes and uploads the SAME recipe revision (matching the + # one prepare's guard validated on Linux). + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - uses: actions/checkout@v4 with: ref: ${{ needs.prepare.outputs.ref }}