From 5a6a7c5af6fc3c87867ad3f039cf9044a4050712 Mon Sep 17 00:00:00 2001 From: TorinKS Date: Sun, 30 Aug 2026 18:18:48 +0300 Subject: [PATCH 1/3] ci: build the native library on VS 2022, not on whatever is latest WinDevices.dll links the dynamic CRT. dumpbin on the published 0.1.1 shows MSVCP140.dll, VCRUNTIME140.dll and VCRUNTIME140_1.dll among its imports, because CMake defaults to /MD and nothing overrides it. The toolset that builds it therefore sets the minimum Visual C++ Redistributable every consumer's machine must carry: v14x runtimes are compatible forward, not backward. windows-latest silently became windows-2025-vs2026, so 0.1.1 was built with the VS 2026 toolset and shipped inside a package consumed by components that are built, and deployed, on VS 2022. It loaded during verification only because this workstation has a current redistributable installed, which a clean endpoint does not. Pin the three jobs that compile to windows-2022. The reporting job and the git-only version job keep windows-latest; neither produces a binary. Also drop the CMake 3.29 pin from increment-version.yaml, which would have failed the same way build-test.yaml did: install.cmd configures without a generator, and a CMake older than the image's Visual Studio falls back to NMake Makefiles. Static linking of the CRT would remove the redistributable question entirely, but it needs the C API checked for heap ownership crossing the DLL boundary first, so it is left to its own issue. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XnzCrAM46SttZnU6msznyN --- .github/workflows/build-test.yaml | 8 +++++++- .github/workflows/increment-version.yaml | 17 ++++++++++++----- .github/workflows/publish-nuget.yaml | 7 ++++++- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index ffa85f4..e69413a 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -17,7 +17,13 @@ on: jobs: build: - runs-on: windows-latest + # Pinned, not windows-latest. windows-latest now provisions + # windows-2025-vs2026, and WinDevices.dll links the dynamic CRT + # (MSVCP140 / VCRUNTIME140), so a binary built with a newer toolset + # requires a redistributable at least as new on the target machine. + # Every other component in this program builds on VS 2022, so the + # native library is built on VS 2022 here too. + runs-on: windows-2022 strategy: matrix: config: [Debug, Release] diff --git a/.github/workflows/increment-version.yaml b/.github/workflows/increment-version.yaml index b87a6f3..9314850 100644 --- a/.github/workflows/increment-version.yaml +++ b/.github/workflows/increment-version.yaml @@ -210,7 +210,10 @@ jobs: build-and-release: needs: increment-version - runs-on: windows-latest + # Pinned to VS 2022: this job runs install.cmd and ships the resulting + # binaries as release assets, so it must use the same toolset as the + # rest of the program rather than whatever windows-latest points at. + runs-on: windows-2022 permissions: contents: write actions: read @@ -225,10 +228,14 @@ jobs: fetch-depth: 0 fetch-tags: true - - name: Setup CMake - uses: jwlawson/actions-setup-cmake@v1.14 - with: - cmake-version: '3.29' + # Use the CMake preinstalled on the runner image rather than pinning a + # version. windows-latest moved to windows-2025-vs2026, and a CMake + # older than the image's Visual Studio cannot name its generator: it + # silently falls back to NMake Makefiles, and install.cmd then fails + # with "CMAKE_CXX_COMPILER not set". Same fix as build-test.yaml. + - name: Report CMake version + shell: cmd + run: cmake --version - name: Build and Install Debug shell: cmd diff --git a/.github/workflows/publish-nuget.yaml b/.github/workflows/publish-nuget.yaml index bf29ca0..6c5ad14 100644 --- a/.github/workflows/publish-nuget.yaml +++ b/.github/workflows/publish-nuget.yaml @@ -23,7 +23,12 @@ on: jobs: pack: - runs-on: windows-latest + # Pinned to VS 2022 deliberately. The native library shipped inside the + # package links the dynamic CRT, so its toolset sets the minimum + # Visual C++ Redistributable every consumer's machine must carry. + # windows-latest is now windows-2025-vs2026, which would silently raise + # that floor for every downstream deployment. + runs-on: windows-2022 permissions: contents: read From 6c6bca61f9d4f94d489564b1a7b8b4036313ef27 Mon Sep 17 00:00:00 2001 From: Torin Date: Sun, 30 Aug 2026 18:29:13 +0300 Subject: [PATCH 2/3] feat(ci): publish to nuget.org when Version Increment completes (#8) Version Increment pushes its tag with GITHUB_TOKEN, and events created with that token deliberately do not start other workflows. The tag-push trigger therefore never fires for an automated release, and publishing had to be dispatched by hand after every version bump. Trigger on workflow_run instead. It fires when Version Increment finishes rather than from a token-created event, so no personal access token has to be stored to make the chain work. Two alternatives were rejected. Duplicating the publish steps inside increment-version.yaml would break Trusted Publishing, because the policy on nuget.org is bound to this workflow's file name. Converting this into a reusable workflow called from there splits the OIDC claim between workflow_ref and job_workflow_ref, and which of the two nuget.org validates is not something to discover through a live publish. Also: - the job is skipped unless the triggering run succeeded, since workflow_run fires on failure too - for workflow_run the checkout pins the triggering run's head_sha rather than the branch tip, which may already have moved - the publish decision moves into one gate step, so the three conditional steps that depend on it cannot drift apart Claude-Session: https://claude.ai/code/session_01XnzCrAM46SttZnU6msznyN Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/publish-nuget.yaml | 42 ++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-nuget.yaml b/.github/workflows/publish-nuget.yaml index 6c5ad14..09f65ab 100644 --- a/.github/workflows/publish-nuget.yaml +++ b/.github/workflows/publish-nuget.yaml @@ -9,6 +9,21 @@ on: push: tags: - 'v*' + + # Version Increment pushes its tag using GITHUB_TOKEN, and events created + # with that token deliberately do not start other workflows, so the tag + # push above never fires for an automated release. workflow_run is not a + # token-created event: it fires when the other workflow finishes, so the + # release path reaches here without a stored PAT. + # + # Kept as a trigger on this file rather than a reusable workflow called + # from Version Increment, because the Trusted Publisher policy is bound to + # this workflow's file name. + workflow_run: + workflows: ["Version Increment"] + types: + - completed + workflow_dispatch: inputs: version: @@ -30,6 +45,10 @@ jobs: # that floor for every downstream deployment. runs-on: windows-2022 + # workflow_run fires whatever the outcome of the run that triggered it, + # so a failed Version Increment must not reach the publisher. + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + permissions: contents: read # Required for NuGet Trusted Publishing: the job mints a short-lived @@ -41,11 +60,28 @@ jobs: - name: Checkout source code uses: actions/checkout@v4 with: + # For workflow_run, check out the commit the triggering run used. + # The default would be the branch tip, which may already have moved. + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }} # Version is derived from the Git tag, so the full history and all # tags must be present. fetch-depth: 0 fetch-tags: true + # One place decides whether this run publishes, so the three steps + # below cannot drift apart. A tag push and a completed Version + # Increment both publish; a manual dispatch publishes only when asked. + - name: Decide whether to publish + id: gate + shell: pwsh + run: | + $publish = + '${{ github.event_name }}' -eq 'workflow_run' -or + '${{ github.ref }}'.StartsWith('refs/tags/v') -or + '${{ inputs.publish }}' -eq 'true' + Write-Host "event=${{ github.event_name }} ref=${{ github.ref }} publish=$publish" + "publish=$($publish.ToString().ToLower())" | Out-File $env:GITHUB_OUTPUT -Append + # The version is derived with `git describe --tags --abbrev=0`, which # returns the nearest *ancestor* tag. On a branch that has moved past # its last tag that silently produces a package claiming a version its @@ -55,7 +91,7 @@ jobs: # This is not hypothetical: 0.1.1 was published from a main that was # six commits ahead of the v0.1.1 tag. - name: Require HEAD to be exactly at a tag - if: (startsWith(github.ref, 'refs/tags/v') || inputs.publish) && inputs.version == '' + if: steps.gate.outputs.publish == 'true' && inputs.version == '' shell: pwsh run: | $tag = git describe --exact-match --tags HEAD 2>$null @@ -141,14 +177,14 @@ explicit version input to publish deliberately. # against owner TorinKS, repository WinDeviceslib and workflow file # publish-nuget.yaml. Renaming this file invalidates that policy. - name: NuGet login (Trusted Publishing) - if: startsWith(github.ref, 'refs/tags/v') || inputs.publish + if: steps.gate.outputs.publish == 'true' id: nuget_login uses: NuGet/login@v1 with: user: ${{ vars.NUGET_USER }} - name: Push to nuget.org - if: startsWith(github.ref, 'refs/tags/v') || inputs.publish + if: steps.gate.outputs.publish == 'true' shell: pwsh env: TEMP_NUGET_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }} From d9b0f41e2dca7429801ed03a69b2cc3810cf442b Mon Sep 17 00:00:00 2001 From: TorinKS Date: Sun, 30 Aug 2026 18:38:12 +0300 Subject: [PATCH 3/3] fix(ci): do not check out an arbitrary commit in the publishing job CodeQL flagged actions/untrusted-checkout at high severity against the checkout I added for the workflow_run path. It is right. A workflow_run job is privileged: it holds id-token: write and can exchange it for a nuget.org publishing token. This job then builds what it checks out, running cmake and dotnet over that tree. Pinning workflow_run.head_sha means executing code from a commit this workflow did not choose, in exactly the context that can publish a package. Version Increment is dispatch-only today, so reaching it needs write access, but the shape is an escalation path to the package feed and should not exist. Take the default checkout instead, which is the default branch and therefore trusted. Version Increment tags a commit on that branch, so the tag is present when this runs. If the branch has moved past the tag in between, the exact-tag guard refuses to publish rather than releasing a mislabelled package - the correct failure, and the reason that guard was added. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XnzCrAM46SttZnU6msznyN --- .github/workflows/publish-nuget.yaml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-nuget.yaml b/.github/workflows/publish-nuget.yaml index 09f65ab..fec9821 100644 --- a/.github/workflows/publish-nuget.yaml +++ b/.github/workflows/publish-nuget.yaml @@ -60,9 +60,20 @@ jobs: - name: Checkout source code uses: actions/checkout@v4 with: - # For workflow_run, check out the commit the triggering run used. - # The default would be the branch tip, which may already have moved. - ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }} + # Deliberately no `ref:`. A workflow_run job is privileged — it + # holds id-token: write and can mint a nuget.org publishing token — + # and this job then builds what it checks out. Checking out + # workflow_run.head_sha would run code from a commit this workflow + # did not choose, in that privileged context: the pattern CodeQL + # flags as actions/untrusted-checkout, and a real escalation path + # to publishing a package. + # + # The default checkout gives the default branch, which is trusted. + # Version Increment tags a commit on that branch, so the tag is + # present. If the branch has moved past the tag in between, the + # exact-tag guard below refuses to publish rather than releasing a + # mislabelled package, which is the correct failure. + # # Version is derived from the Git tag, so the full history and all # tags must be present. fetch-depth: 0