From 1709b8caee4e2a1649f719d786db91fd01055d10 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:12:47 +0000 Subject: [PATCH] Bump the actions group across 1 directory with 8 updates Bumps the actions group with 8 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.1` | `7.0.2` | | [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.1` | `8.0.2` | | [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.6.4` | `0.8.0` | | [github/codeql-action/init](https://github.com/github/codeql-action) | `4.38.2` | `4.39.1` | | [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.38.2` | `4.39.1` | | [ossf/scorecard-action](https://github.com/ossf/scorecard-action) | `2.4.4` | `2.5.0` | | [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) | `4.38.2` | `4.39.1` | | [actions/setup-java](https://github.com/actions/setup-java) | `6.0.1` | `6.1.0` | Updates `actions/upload-artifact` from 7.0.1 to 7.0.2 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9) Updates `actions/download-artifact` from 8.0.1 to 8.0.2 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333) Updates `zizmorcore/zizmor-action` from 0.6.4 to 0.8.0 - [Release notes](https://github.com/zizmorcore/zizmor-action/releases) - [Commits](https://github.com/zizmorcore/zizmor-action/compare/cc914d7f3750a2d13d75c7f184a1060aa0e9d482...53290badfa369cb0bf7ad16342cb62c2fa47ac7b) Updates `github/codeql-action/init` from 4.38.2 to 4.39.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...9b3254085494b55a0f7c4c2b3d2d52cc712ce370) Updates `github/codeql-action/analyze` from 4.38.2 to 4.39.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...9b3254085494b55a0f7c4c2b3d2d52cc712ce370) Updates `ossf/scorecard-action` from 2.4.4 to 2.5.0 - [Release notes](https://github.com/ossf/scorecard-action/releases) - [Changelog](https://github.com/ossf/scorecard-action/blob/main/RELEASE.md) - [Commits](https://github.com/ossf/scorecard-action/compare/2d1146689b8cda280b9bc96326124645441f03bc...fa7117db83c52d787ae5a89f350999701505d78e) Updates `github/codeql-action/upload-sarif` from 4.38.2 to 4.39.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...9b3254085494b55a0f7c4c2b3d2d52cc712ce370) Updates `actions/setup-java` from 6.0.1 to 6.1.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/de7274f081f381c8f8158605e0321c36c376e2e6...b843ebf9b913146b38da2eaa7aeddf76074ae1ad) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: actions/download-artifact dependency-version: 8.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: zizmorcore/zizmor-action dependency-version: 0.8.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: github/codeql-action/init dependency-version: 4.39.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: github/codeql-action/analyze dependency-version: 4.39.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: ossf/scorecard-action dependency-version: 2.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: github/codeql-action/upload-sarif dependency-version: 4.39.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: actions/setup-java dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 1338 ++++++++--------- .github/workflows/codeql.yml | 14 +- .github/workflows/dependency-submission.yml | 526 +++---- .github/workflows/release.yml | 1454 +++++++++---------- .github/workflows/scorecard.yml | 4 +- .github/workflows/sonar.yml | 462 +++--- .github/workflows/zizmor-online.yml | 112 +- 7 files changed, 1955 insertions(+), 1955 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6774cfca..52627685 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,669 +1,669 @@ -name: CI - -# Reusable pipeline for pull-request-ci.yml, main-ci.yml and release.yml. Its gate job is the required check -# "CI / Gate"; the caller job id `ci` (name CI) and the job name Gate are frozen. -# -# native ─┬─► build-test (Debug, Release) ─► sonar ─┐ -# ├─► aot ───────────────────────────────────┤ -# └─► native-host-emulator (Debug build-test leg only) ─┤ -# lint, format, dependency-review, lock-files ───────────────────┴─► gate -# -# Artifacts (the only names this workflow may upload; WorkflowContractTests holds the list): -# lua-protection-bridge, classic-abi-fixture-facts native → build-test, aot -# nuget-package build-test (Release) → sonar, release.yml, reviewers -# coverage build-test (Debug) → sonar -# test-results- build-test → reviewers -# test-dumps-, binlogs-[-] on failure only -# -# No job restores or saves a NuGet cache: every job is reachable from the tag release run. Every restore is locked -# against the committed packages.lock.json files (.github/actions/setup-dotnet). - -on: - workflow_call: - inputs: - sonar: - description: >- - Run the SonarQube Cloud analysis. The gate then requires it (SONAR_EXPECTED), except for fork pull requests - and Dependabot, which receive no secrets. - type: boolean - default: false - package-version: - description: When set, the Release leg must produce exactly CheatEngine.SDK..nupkg. - type: string - default: '' - package-retention-days: - description: Days to keep the nuget-package artifact. - type: number - default: 7 - secrets: - SONAR_TOKEN: - description: SonarQube Cloud analysis token. Needed only when sonar is true. - required: false - -permissions: - contents: read - -defaults: - run: - shell: pwsh - -jobs: - native: - name: Build native bridge - runs-on: windows-2025 - timeout-minutes: 15 - env: - # The bridge toolchain pins. 14.44 is the default toolset of the VS 2022 image and a side-by-side component of the - # VS 2026 image, so the pin holds whichever image windows-2025 maps to. A bump changes the shipped DLL bytes: - # change both values in one reviewed commit (NativeBridgePeAuditTests reads them). - BRIDGE_VS_TOOLSET: '14.44' - BRIDGE_VS_SDKVER: '10.0.26100.0' - outputs: - bridge-sha256: ${{ steps.bridge.outputs.bridge-sha256 }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Setup xmake - uses: xmake-io/github-action-setup-xmake@3a1a5dddfc7fa625d9a698738334bf55655a861a # v1.2.5 - with: - xmake-version: '3.0.9' - - # Configure and build the bridge once with the pinned MSVC toolset and Windows SDK. Every later job downloads - # this lua-protection-bridge artifact. The committed asset is checked separately for its source fingerprint; - # this job does not compare its executable bytes or perform independent reproducibility builds. - - name: Build bridge - id: bridge - run: | - $ErrorActionPreference = 'Stop' - xmake f -P native/cheatengine-sdk-lua-bridge -o artifacts/native/cheatengine-sdk-lua-bridge -p windows -a x64 -m release -y --ccache=n "--vs_toolset=$env:BRIDGE_VS_TOOLSET" "--vs_sdkver=$env:BRIDGE_VS_SDKVER" - if ($LASTEXITCODE -ne 0) { - throw "xmake configuration for the native bridge failed with exit code $LASTEXITCODE." - } - xmake -P native/cheatengine-sdk-lua-bridge -y - if ($LASTEXITCODE -ne 0) { - throw "xmake build of the native bridge failed with exit code $LASTEXITCODE." - } - $dll = 'artifacts/native/cheatengine-sdk-lua-bridge/cheatengine-sdk-lua-bridge.dll' - if (-not (Test-Path -LiteralPath $dll -PathType Leaf)) { - throw "xmake did not produce '$dll'." - } - $hash = (Get-FileHash -LiteralPath $dll -Algorithm SHA256).Hash.ToLowerInvariant() - "bridge-sha256=$hash" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - - name: Upload bridge - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: lua-protection-bridge - path: artifacts/native/cheatengine-sdk-lua-bridge/cheatengine-sdk-lua-bridge.dll - if-no-files-found: error - retention-days: 14 - - # Run after upload so a stale fingerprint still leaves the fresh DLL available to download. This compares - # exported source metadata, which can be copied into another binary; it does not prove executable equivalence. - - name: Verify checked-in bridge matches its source - run: | - $sourceHash = (Get-FileHash -Algorithm SHA256 native/cheatengine-sdk-lua-bridge/cheatengine_sdk_lua_bridge.c).Hash.ToLowerInvariant() - $buildHash = (Get-FileHash -Algorithm SHA256 native/cheatengine-sdk-lua-bridge/xmake.lua).Hash.ToLowerInvariant() - $expected = "$sourceHash`:$buildHash" - $prebuilt = Resolve-Path native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll - $module = [System.Runtime.InteropServices.NativeLibrary]::Load($prebuilt.Path) - try { - $address = [System.Runtime.InteropServices.NativeLibrary]::GetExport($module, 'cheatengine_sdk_lua_bridge_source_fingerprint') - $actual = [System.Runtime.InteropServices.Marshal]::PtrToStringAnsi($address) - } - finally { - [System.Runtime.InteropServices.NativeLibrary]::Free($module) - } - if ($actual -ne $expected) { - throw "The checked-in bridge is stale. Expected source fingerprint '$expected', found '$actual'. Download the lua-protection-bridge artifact from this run and commit it." - } - - # This compiles the C++ transcription under MSVC x64 and validates its emitted facts. It proves the checked-in - # header fixture and managed layout numbers agree; it does not contact or qualify a live Cheat Engine host. - # The Debug build-test leg compares these facts with the managed ABI measurements. - - name: Build and validate classic ABI fixture - run: | - $ErrorActionPreference = 'Stop' - ./tests/native-abi-fixture/build.ps1 -OutputDirectory artifacts/native-abi-fixture - if ($LASTEXITCODE -ne 0) { - throw "Classic ABI fixture build failed with exit code $LASTEXITCODE." - } - - - name: Upload classic ABI fixture facts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: classic-abi-fixture-facts - path: artifacts/native-abi-fixture/ce77-native-abi-facts.txt - if-no-files-found: error - retention-days: 14 - - build-test: - name: Build and test (${{ matrix.configuration }}) - needs: [ native, native-host-emulator ] - runs-on: windows-2025 - timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - configuration: [ Debug, Release ] - env: - CONFIGURATION: ${{ matrix.configuration }} - RESULTS: artifacts/test-results/${{ matrix.configuration }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 # MinVer computes the package version from tags and history - persist-credentials: false - - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - with: - restore: CheatEngine.SDK.slnx - - - name: Use CI-built native bridge - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: lua-protection-bridge - path: native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native - - - name: Use classic ABI fixture facts - if: matrix.configuration == 'Debug' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: classic-abi-fixture-facts - path: artifacts/native-abi-fixture - - - name: Use native host emulator - if: matrix.configuration == 'Debug' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: native-host-emulator - path: artifacts/native-host-emulator - - # One build of the whole solution, which also compiles every harness it lists (LivePlugin, Coexistence, probes). - - name: Build - run: | - dotnet build CheatEngine.SDK.slnx -c $env:CONFIGURATION --no-restore "-bl:artifacts/logs/build-test-$env:CONFIGURATION.binlog" - if ($LASTEXITCODE -ne 0) { - throw "$env:CONFIGURATION solution build failed with exit code $LASTEXITCODE." - } - - # Pack before testing: the packaging tests below consume this exact file, which is then uploaded unchanged. The - # pack is incremental after the build and recompiles nothing. - - name: Pack - id: pack - if: matrix.configuration == 'Release' - env: - PACKAGE_VERSION: ${{ inputs.package-version }} - BRIDGE_SHA256: ${{ needs.native.outputs.bridge-sha256 }} - run: | - $ErrorActionPreference = 'Stop' - dotnet pack src/CheatEngine.SDK -c Release --no-restore -o artifacts/nuget -bl:artifacts/logs/pack-Release.binlog - if ($LASTEXITCODE -ne 0) { - throw "SDK package creation failed with exit code $LASTEXITCODE." - } - - # Pre-publish sanity check: exactly one CheatEngine.SDK..nupkg, matching nuspec identity, the - # embedded SPDX SBOM (Microsoft.Sbom.Targets, CESDK9008) and the CI-built native bridge. - $files = @(Get-ChildItem -LiteralPath artifacts/nuget -Recurse -File) - if ($files.Count -ne 1 -or $files[0].Name -notmatch '^CheatEngine\.SDK\.(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.nupkg$') { - throw "Expected exactly one CheatEngine.SDK..nupkg in 'artifacts/nuget', found: $(($files | ForEach-Object Name) -join ', ')." - } - $package = $files[0] - $fileVersion = $Matches['version'] - if ($env:PACKAGE_VERSION -and $package.Name -cne "CheatEngine.SDK.$($env:PACKAGE_VERSION).nupkg") { - throw "Packed $($package.Name), but the release requires CheatEngine.SDK.$($env:PACKAGE_VERSION).nupkg." - } - $archive = [IO.Compression.ZipFile]::OpenRead($package.FullName) - try { - $nuspecEntry = $archive.GetEntry('CheatEngine.SDK.nuspec') - if ($null -eq $nuspecEntry) { - throw "$($package.Name) has no CheatEngine.SDK.nuspec at its root." - } - $reader = [IO.StreamReader]::new($nuspecEntry.Open()) - try { [xml] $nuspec = $reader.ReadToEnd() } finally { $reader.Dispose() } - if ($nuspec.package.metadata.id -cne 'CheatEngine.SDK' -or $nuspec.package.metadata.version -cne $fileVersion) { - throw "$($package.Name) declares id '$($nuspec.package.metadata.id)' version '$($nuspec.package.metadata.version)' in its nuspec." - } - if ($null -eq $archive.GetEntry('_manifest/spdx_2.2/manifest.spdx.json')) { - throw "$($package.Name) does not embed the SPDX SBOM at _manifest/spdx_2.2/manifest.spdx.json (Microsoft.Sbom.Targets, CESDK9008)." - } - $bridge = $archive.GetEntry('build/native/cheatengine-sdk-lua-bridge.dll') - if ($null -eq $bridge) { - throw "$($package.Name) does not carry the native bridge at build/native/cheatengine-sdk-lua-bridge.dll." - } - $bridgeStream = $bridge.Open() - try { - $bridgeSha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bridgeStream)).ToLowerInvariant() - } finally { - $bridgeStream.Dispose() - } - if ($bridgeSha256 -cne $env:BRIDGE_SHA256.ToLowerInvariant()) { - throw "$($package.Name) packs a bridge with SHA-256 $bridgeSha256, but the native job built $env:BRIDGE_SHA256." - } - } finally { - $archive.Dispose() - } - $sha256 = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() - @("nupkg=$($package.FullName)", "sha256=$sha256", "version=$fileVersion") | - Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - "Packed $($package.Name) (SHA-256 $sha256)." | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - - # One run over every tests/**/*.Tests module of the solution; MTP options go straight to dotnet test (SDK 10). - # A skip fails the run (--fail-skips on): NativeLua tests skip when the bundled Lua DLL does not bind, and a green - # check must not hide that. Hang and crash dumps need the extensions every module gets from eng/Tests.props. - # Debug: the native job's ABI facts make the managed ABI comparison mandatory, every module writes a coverage - # report, and the packaging tests are filtered out by trait (never skipped): they run in the Release leg against - # the packed file (CESDK_PACKAGED_UMBRELLA_NUPKG). - - name: Test - id: test - env: - CESDK_PACKAGED_UMBRELLA_NUPKG: ${{ matrix.configuration == 'Release' && steps.pack.outputs.nupkg || '' }} - PACKED_NUPKG_SHA256: ${{ steps.pack.outputs.sha256 }} - run: | - $ErrorActionPreference = 'Stop' - $options = @( - '--solution', 'CheatEngine.SDK.slnx', '-c', $env:CONFIGURATION, '--no-build', '--results-directory', $env:RESULTS, - '--fail-skips', 'on', '--report-trx', '--report-gh', '--report-gh-groups', 'off', - '--hangdump', '--hangdump-timeout', '15m', '--crashdump' - ) - if ($env:CONFIGURATION -eq 'Debug') { - $env:CE77_NATIVE_ABI_FACTS_PATH = Join-Path $env:GITHUB_WORKSPACE 'artifacts/native-abi-fixture/ce77-native-abi-facts.txt' - $env:CE77_NATIVE_ABI_REQUIRED = 'true' - $env:CESDK_NATIVE_HOST_EMULATOR_DIR = Join-Path $env:GITHUB_WORKSPACE 'artifacts/native-host-emulator' - $env:CESDK_NATIVE_HOST_EMULATOR_REQUIRED = 'true' - $options += '--coverage', '--coverage-output-format', 'xml', '--filter-not-trait', 'Category=Packaging' - } - else { - $consumed = (Get-FileHash -LiteralPath $env:CESDK_PACKAGED_UMBRELLA_NUPKG -Algorithm SHA256).Hash.ToLowerInvariant() - if ($consumed -ne $env:PACKED_NUPKG_SHA256) { - throw "The package changed between Pack ($env:PACKED_NUPKG_SHA256) and Test ($consumed)." - } - "Packaging tests consume ``$(Split-Path -Leaf $env:CESDK_PACKAGED_UMBRELLA_NUPKG)`` (SHA-256 ``$consumed``)." | - Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - } - dotnet test @options - if ($LASTEXITCODE -ne 0) { - throw "$env:CONFIGURATION tests failed with exit code $LASTEXITCODE." - } - - # The uploaded file is the file the tests consumed. - - name: Verify the tested package is unchanged - if: matrix.configuration == 'Release' - env: - PACKAGE: ${{ steps.pack.outputs.nupkg }} - PACKED_NUPKG_SHA256: ${{ steps.pack.outputs.sha256 }} - run: | - $actual = (Get-FileHash -LiteralPath $env:PACKAGE -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $env:PACKED_NUPKG_SHA256) { - throw "The package changed during the tests: packed $env:PACKED_NUPKG_SHA256, now $actual." - } - - - name: Upload package - if: matrix.configuration == 'Release' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: nuget-package - path: artifacts/nuget/*.nupkg - if-no-files-found: error - retention-days: ${{ inputs.package-retention-days }} - - - name: Upload coverage - if: matrix.configuration == 'Debug' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: coverage - path: ${{ env.RESULTS }}/*.xml - if-no-files-found: error - retention-days: 7 - - - name: Upload test results - if: ${{ !cancelled() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: test-results-${{ matrix.configuration }} - path: ${{ env.RESULTS }}/*.trx - if-no-files-found: warn - retention-days: 7 - - - name: Upload test dumps - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: test-dumps-${{ matrix.configuration }} - # Hang and crash dumps, and the crash sequence file listing the tests that were running. - path: | - ${{ env.RESULTS }}/**/*.dmp - ${{ env.RESULTS }}/**/*sequence* - if-no-files-found: ignore - retention-days: 5 - - # Binary logs only on failure: they record the environment the build saw. - # https://learn.microsoft.com/visualstudio/ide/msbuild-logs#provide-msbuild-binary-logs-for-investigation - - name: Upload binary logs - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: binlogs-build-test-${{ matrix.configuration }} - path: artifacts/logs/*.binlog - if-no-files-found: ignore - retention-days: 5 - - aot: - name: Native AOT publication probe - needs: native - runs-on: windows-2025 - timeout-minutes: 25 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 # MinVer computes the version from tags and history - persist-credentials: false - - # The three projects live outside the solution's test run; their lock files carry the win-x64 ILCompiler packages. - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - with: - restore: | - tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj - tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj - tests/CheatEngine.SDK.NativeAotLoaderHarness/CheatEngine.SDK.NativeAotLoaderHarness.csproj - - - name: Use CI-built native bridge - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: lua-protection-bridge - path: native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native - - # This executable is an AOT/trim graph probe only. It is not a plugin candidate for Cheat Engine's hostfxr path. - - name: Publish and run Native AOT probe - run: | - $ErrorActionPreference = 'Stop' - $output = Join-Path $PWD 'artifacts/aot-probe' - dotnet publish tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj -c Release --no-restore -o $output -bl:artifacts/logs/aot-probe.binlog - if ($LASTEXITCODE -ne 0) { - throw "Native AOT probe publish failed with exit code $LASTEXITCODE." - } - $probe = Join-Path $output 'CheatEngine.SDK.AotProbe.exe' - if (-not (Test-Path -LiteralPath $probe -PathType Leaf)) { - throw "Native AOT publish did not produce '$probe'." - } - & $probe - if ($LASTEXITCODE -ne 0) { - throw "Native AOT probe exited with code $LASTEXITCODE." - } - - # This remains a test-library observation. It never starts Cheat Engine, invokes an export, or unloads a NativeAOT DLL. - - name: Publish and inspect Native AOT library probe - run: | - $ErrorActionPreference = 'Stop' - $profileOutput = Join-Path $PWD 'artifacts/nativeaot-loader-profile' - $libraryProject = 'tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj' - $harnessProject = 'tests/CheatEngine.SDK.NativeAotLoaderHarness/CheatEngine.SDK.NativeAotLoaderHarness.csproj' - dotnet publish $libraryProject -c Release --no-restore -o $profileOutput -bl:artifacts/logs/aot-library-probe.binlog - if ($LASTEXITCODE -ne 0) { - throw "Native AOT shared-library publish failed with exit code $LASTEXITCODE." - } - dotnet publish $harnessProject -c Release --no-restore -o $profileOutput -bl:artifacts/logs/aot-loader-harness.binlog - if ($LASTEXITCODE -ne 0) { - throw "Native AOT loader harness publish failed with exit code $LASTEXITCODE." - } - $library = Join-Path $profileOutput 'CheatEngine.SDK.NativeAotLibraryProbe.dll' - $harness = Join-Path $profileOutput 'CheatEngine.SDK.NativeAotLoaderHarness.exe' - if (-not (Test-Path -LiteralPath $library -PathType Leaf)) { - throw "Native AOT shared-library publish did not produce '$library'." - } - if (-not (Test-Path -LiteralPath $harness -PathType Leaf)) { - throw "Native AOT loader harness publish did not produce '$harness'." - } - & $harness --analyze $library - if ($LASTEXITCODE -ne 0) { throw "Native AOT loader harness analysis exited with code $LASTEXITCODE." } - & $harness --load --acknowledge-process-resident-load - if ($LASTEXITCODE -ne 0) { throw "Native AOT loader harness load exited with code $LASTEXITCODE." } - - - name: Upload binary logs - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: binlogs-aot - path: artifacts/logs/*.binlog - if-no-files-found: ignore - retention-days: 5 - - # C2 evidence only: a hostfxr component-route emulator that exercises the same managed entry point Cheat Engine's - # loader would call, never Cheat Engine itself. The Debug build-test leg downloads its output and runs - # NativeHostEmulatorTests against it (tests/native-host-emulator/README.md). - native-host-emulator: - name: Native host emulator - needs: native - runs-on: windows-2025 - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - # Needed only to locate the installed Microsoft.NETCore.App.Host.win-x64 pack's nethost headers/lib/dll; no - # project is restored here. - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - - - name: Build native host emulator - run: | - $ErrorActionPreference = 'Stop' - ./tests/native-host-emulator/build.ps1 -OutputDirectory artifacts/native-host-emulator - if ($LASTEXITCODE -ne 0) { - throw "Native host emulator build failed with exit code $LASTEXITCODE." - } - - - name: Upload native host emulator - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: native-host-emulator - path: artifacts/native-host-emulator - if-no-files-found: error - retention-days: 14 - - # SONAR_EXPECTED: the same expression is the gate's SONAR_EXPECTED variable (env is not available in jobs..if; - # WorkflowContractTests keeps the two texts equal). Secrets never reach fork or Dependabot runs, the dormant merge_group - # clause stays as defense in depth, and release.yml never passes sonar. github.actor is deliberate: a maintainer who - # pushes onto a Dependabot branch has the token and must be analysed. Outside push events the quality gate is awaited. - sonar: - name: Sonar - needs: build-test - if: >- - ${{ inputs.sonar - && github.event_name != 'merge_group' - && github.actor != 'dependabot[bot]' - && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} - uses: ./.github/workflows/sonar.yml - with: - wait-quality-gate: ${{ github.event_name != 'push' }} - secrets: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - lint: - name: Lint - runs-on: ubuntu-24.04 - timeout-minutes: 10 - env: - ACTIONLINT_VERSION: 1.7.12 - ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 # linux_amd64, official checksums file - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Run actionlint - run: | - $ErrorActionPreference = 'Stop' - $archive = Join-Path $env:RUNNER_TEMP 'actionlint.tar.gz' - $url = "https://github.com/rhysd/actionlint/releases/download/v$env:ACTIONLINT_VERSION/actionlint_$($env:ACTIONLINT_VERSION)_linux_amd64.tar.gz" - Invoke-WebRequest -Uri $url -OutFile $archive -MaximumRetryCount 3 -RetryIntervalSec 5 - $actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $env:ACTIONLINT_SHA256) { - throw "actionlint $env:ACTIONLINT_VERSION has SHA-256 $actual, expected $env:ACTIONLINT_SHA256." - } - tar -xzf $archive -C $env:RUNNER_TEMP actionlint - if ($LASTEXITCODE -ne 0) { - throw "Extracting actionlint failed with exit code $LASTEXITCODE." - } - & (Join-Path $env:RUNNER_TEMP 'actionlint') -color - if ($LASTEXITCODE -ne 0) { - throw "actionlint failed with exit code $LASTEXITCODE." - } - - # Offline audits only: the gate must not depend on the GitHub API. The online audits run in an advisory workflow. - - name: Run zizmor - uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 - with: - version: 1.30.1 - online-audits: false - advanced-security: false - annotations: true - config: .github/zizmor.yml - - # Whitespace formatting of every C# file, including projects outside the solution. Style rules are enforced by the - # build itself (EnforceCodeStyleInBuild with warnings as errors). No restore: --folder needs no MSBuild workspace. - format: - name: Format - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - - - name: Verify whitespace formatting - run: | - dotnet format whitespace . --folder --verify-no-changes --exclude artifacts - if ($LASTEXITCODE -ne 0) { - throw "Whitespace formatting differs (exit code $LASTEXITCODE). Run 'dotnet format whitespace . --folder --exclude artifacts' and commit the result." - } - - # Always runs, so the gate never sees it skipped: pull requests are reviewed, every other event records a notice. - dependency-review: - name: Dependency review - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Checkout - if: github.event_name == 'pull_request' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Review dependency changes - if: github.event_name == 'pull_request' - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 - with: - config-file: ./.github/dependency-review-config.yml - comment-summary-in-pr: never - retry-on-snapshot-warnings: true - - - name: Nothing to review - if: github.event_name != 'pull_request' - env: - EVENT: ${{ github.event_name }} - run: Write-Host "::notice title=Dependency review::A $env:EVENT run has no base to compare; dependency review runs on pull requests." - - # The committed packages.lock.json files must be exactly what a fresh restore produces. Windows only: the Native AOT - # lock sections record the host-RID ILCompiler packages. - lock-files: - name: Lock files - runs-on: windows-2025 - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - # The composite action's own locked-mode restore is the verification: it fails with NU1004 the moment a - # committed packages.lock.json no longer matches a fresh restore. CheatEngine.SDK.AotProbe is the only project - # outside the solution (Solution/SolutionInventoryTests.cs). - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - with: - restore: | - CheatEngine.SDK.slnx - tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj - - gate: - name: Gate - # always(): a failed or cancelled job must turn the required check red instead of skipping it. - if: always() - needs: [ native, build-test, aot, native-host-emulator, sonar, lint, format, dependency-review, lock-files ] - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: { } - steps: - - name: Check results - env: - NEEDS: ${{ toJSON(needs) }} - SONAR_EXPECTED: >- - ${{ inputs.sonar - && github.event_name != 'merge_group' - && github.actor != 'dependabot[bot]' - && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} - EVENT: ${{ github.event_name }} - run: | - # Every job must succeed, except sonar, which must match SONAR_EXPECTED: success when it is true, skipped when it - # is false (fork, Dependabot, merge_group or release run). A sonar run that was not expected means the job - # condition and this expression drifted apart, which fails the gate too. No other job may ever be skipped. - $ErrorActionPreference = 'Stop' - if ($env:SONAR_EXPECTED -notin @('true', 'false')) { - Write-Host "::error title=Gate::SONAR_EXPECTED must be 'true' or 'false', got '$env:SONAR_EXPECTED'." - exit 1 - } - $sonarExpected = $env:SONAR_EXPECTED -eq 'true' - $needs = $env:NEEDS | ConvertFrom-Json -AsHashtable - if ($needs.Count -eq 0) { - Write-Host '::error title=Gate::The gate received no job results.' - exit 1 - } - $mismatches = [Collections.Generic.List[string]]::new() - $rows = [Collections.Generic.List[string]]::new() - foreach ($job in @($needs.Keys | Sort-Object)) { - $result = [string] $needs[$job].result - $required = 'success' - $reason = 'every job must succeed' - if ($job -eq 'sonar') { - if ($sonarExpected) { - $reason = "SONAR_EXPECTED=true on ${env:EVENT}: the analysis must run and pass" - if ($result -eq 'skipped') { - $upstream = [string] $needs['build-test'].result - $reason += ($upstream -ne 'success') ? "; skipped because build-test was $upstream" : '; skipped although expected: the sonar condition drifted' - } - } - else { - $required = 'skipped' - $reason = "SONAR_EXPECTED=false on ${env:EVENT} (fork, Dependabot, merge_group or release run)" - if ($result -ne 'skipped') { - $reason += '; it ran although not expected: the sonar condition drifted' - } - } - } - if ($result -ne $required) { - $mismatches.Add("$job is $result, required $required") - } - $rows.Add("| $job | $result | $required | $reason |") - } - @('### Gate', '', '| Job | Result | Required | Reason |', '| --- | --- | --- | --- |') + $rows | - Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - if ($mismatches.Count -gt 0) { - Write-Host "::error title=Gate::$($mismatches -join '; ')." - exit 1 - } - Write-Host "All $($needs.Count) jobs have their required result." +name: CI + +# Reusable pipeline for pull-request-ci.yml, main-ci.yml and release.yml. Its gate job is the required check +# "CI / Gate"; the caller job id `ci` (name CI) and the job name Gate are frozen. +# +# native ─┬─► build-test (Debug, Release) ─► sonar ─┐ +# ├─► aot ───────────────────────────────────┤ +# └─► native-host-emulator (Debug build-test leg only) ─┤ +# lint, format, dependency-review, lock-files ───────────────────┴─► gate +# +# Artifacts (the only names this workflow may upload; WorkflowContractTests holds the list): +# lua-protection-bridge, classic-abi-fixture-facts native → build-test, aot +# nuget-package build-test (Release) → sonar, release.yml, reviewers +# coverage build-test (Debug) → sonar +# test-results- build-test → reviewers +# test-dumps-, binlogs-[-] on failure only +# +# No job restores or saves a NuGet cache: every job is reachable from the tag release run. Every restore is locked +# against the committed packages.lock.json files (.github/actions/setup-dotnet). + +on: + workflow_call: + inputs: + sonar: + description: >- + Run the SonarQube Cloud analysis. The gate then requires it (SONAR_EXPECTED), except for fork pull requests + and Dependabot, which receive no secrets. + type: boolean + default: false + package-version: + description: When set, the Release leg must produce exactly CheatEngine.SDK..nupkg. + type: string + default: '' + package-retention-days: + description: Days to keep the nuget-package artifact. + type: number + default: 7 + secrets: + SONAR_TOKEN: + description: SonarQube Cloud analysis token. Needed only when sonar is true. + required: false + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +jobs: + native: + name: Build native bridge + runs-on: windows-2025 + timeout-minutes: 15 + env: + # The bridge toolchain pins. 14.44 is the default toolset of the VS 2022 image and a side-by-side component of the + # VS 2026 image, so the pin holds whichever image windows-2025 maps to. A bump changes the shipped DLL bytes: + # change both values in one reviewed commit (NativeBridgePeAuditTests reads them). + BRIDGE_VS_TOOLSET: '14.44' + BRIDGE_VS_SDKVER: '10.0.26100.0' + outputs: + bridge-sha256: ${{ steps.bridge.outputs.bridge-sha256 }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup xmake + uses: xmake-io/github-action-setup-xmake@3a1a5dddfc7fa625d9a698738334bf55655a861a # v1.2.5 + with: + xmake-version: '3.0.9' + + # Configure and build the bridge once with the pinned MSVC toolset and Windows SDK. Every later job downloads + # this lua-protection-bridge artifact. The committed asset is checked separately for its source fingerprint; + # this job does not compare its executable bytes or perform independent reproducibility builds. + - name: Build bridge + id: bridge + run: | + $ErrorActionPreference = 'Stop' + xmake f -P native/cheatengine-sdk-lua-bridge -o artifacts/native/cheatengine-sdk-lua-bridge -p windows -a x64 -m release -y --ccache=n "--vs_toolset=$env:BRIDGE_VS_TOOLSET" "--vs_sdkver=$env:BRIDGE_VS_SDKVER" + if ($LASTEXITCODE -ne 0) { + throw "xmake configuration for the native bridge failed with exit code $LASTEXITCODE." + } + xmake -P native/cheatengine-sdk-lua-bridge -y + if ($LASTEXITCODE -ne 0) { + throw "xmake build of the native bridge failed with exit code $LASTEXITCODE." + } + $dll = 'artifacts/native/cheatengine-sdk-lua-bridge/cheatengine-sdk-lua-bridge.dll' + if (-not (Test-Path -LiteralPath $dll -PathType Leaf)) { + throw "xmake did not produce '$dll'." + } + $hash = (Get-FileHash -LiteralPath $dll -Algorithm SHA256).Hash.ToLowerInvariant() + "bridge-sha256=$hash" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Upload bridge + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: lua-protection-bridge + path: artifacts/native/cheatengine-sdk-lua-bridge/cheatengine-sdk-lua-bridge.dll + if-no-files-found: error + retention-days: 14 + + # Run after upload so a stale fingerprint still leaves the fresh DLL available to download. This compares + # exported source metadata, which can be copied into another binary; it does not prove executable equivalence. + - name: Verify checked-in bridge matches its source + run: | + $sourceHash = (Get-FileHash -Algorithm SHA256 native/cheatengine-sdk-lua-bridge/cheatengine_sdk_lua_bridge.c).Hash.ToLowerInvariant() + $buildHash = (Get-FileHash -Algorithm SHA256 native/cheatengine-sdk-lua-bridge/xmake.lua).Hash.ToLowerInvariant() + $expected = "$sourceHash`:$buildHash" + $prebuilt = Resolve-Path native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll + $module = [System.Runtime.InteropServices.NativeLibrary]::Load($prebuilt.Path) + try { + $address = [System.Runtime.InteropServices.NativeLibrary]::GetExport($module, 'cheatengine_sdk_lua_bridge_source_fingerprint') + $actual = [System.Runtime.InteropServices.Marshal]::PtrToStringAnsi($address) + } + finally { + [System.Runtime.InteropServices.NativeLibrary]::Free($module) + } + if ($actual -ne $expected) { + throw "The checked-in bridge is stale. Expected source fingerprint '$expected', found '$actual'. Download the lua-protection-bridge artifact from this run and commit it." + } + + # This compiles the C++ transcription under MSVC x64 and validates its emitted facts. It proves the checked-in + # header fixture and managed layout numbers agree; it does not contact or qualify a live Cheat Engine host. + # The Debug build-test leg compares these facts with the managed ABI measurements. + - name: Build and validate classic ABI fixture + run: | + $ErrorActionPreference = 'Stop' + ./tests/native-abi-fixture/build.ps1 -OutputDirectory artifacts/native-abi-fixture + if ($LASTEXITCODE -ne 0) { + throw "Classic ABI fixture build failed with exit code $LASTEXITCODE." + } + + - name: Upload classic ABI fixture facts + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: classic-abi-fixture-facts + path: artifacts/native-abi-fixture/ce77-native-abi-facts.txt + if-no-files-found: error + retention-days: 14 + + build-test: + name: Build and test (${{ matrix.configuration }}) + needs: [ native, native-host-emulator ] + runs-on: windows-2025 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + configuration: [ Debug, Release ] + env: + CONFIGURATION: ${{ matrix.configuration }} + RESULTS: artifacts/test-results/${{ matrix.configuration }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # MinVer computes the package version from tags and history + persist-credentials: false + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + with: + restore: CheatEngine.SDK.slnx + + - name: Use CI-built native bridge + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: lua-protection-bridge + path: native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native + + - name: Use classic ABI fixture facts + if: matrix.configuration == 'Debug' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: classic-abi-fixture-facts + path: artifacts/native-abi-fixture + + - name: Use native host emulator + if: matrix.configuration == 'Debug' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: native-host-emulator + path: artifacts/native-host-emulator + + # One build of the whole solution, which also compiles every harness it lists (LivePlugin, Coexistence, probes). + - name: Build + run: | + dotnet build CheatEngine.SDK.slnx -c $env:CONFIGURATION --no-restore "-bl:artifacts/logs/build-test-$env:CONFIGURATION.binlog" + if ($LASTEXITCODE -ne 0) { + throw "$env:CONFIGURATION solution build failed with exit code $LASTEXITCODE." + } + + # Pack before testing: the packaging tests below consume this exact file, which is then uploaded unchanged. The + # pack is incremental after the build and recompiles nothing. + - name: Pack + id: pack + if: matrix.configuration == 'Release' + env: + PACKAGE_VERSION: ${{ inputs.package-version }} + BRIDGE_SHA256: ${{ needs.native.outputs.bridge-sha256 }} + run: | + $ErrorActionPreference = 'Stop' + dotnet pack src/CheatEngine.SDK -c Release --no-restore -o artifacts/nuget -bl:artifacts/logs/pack-Release.binlog + if ($LASTEXITCODE -ne 0) { + throw "SDK package creation failed with exit code $LASTEXITCODE." + } + + # Pre-publish sanity check: exactly one CheatEngine.SDK..nupkg, matching nuspec identity, the + # embedded SPDX SBOM (Microsoft.Sbom.Targets, CESDK9008) and the CI-built native bridge. + $files = @(Get-ChildItem -LiteralPath artifacts/nuget -Recurse -File) + if ($files.Count -ne 1 -or $files[0].Name -notmatch '^CheatEngine\.SDK\.(?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.nupkg$') { + throw "Expected exactly one CheatEngine.SDK..nupkg in 'artifacts/nuget', found: $(($files | ForEach-Object Name) -join ', ')." + } + $package = $files[0] + $fileVersion = $Matches['version'] + if ($env:PACKAGE_VERSION -and $package.Name -cne "CheatEngine.SDK.$($env:PACKAGE_VERSION).nupkg") { + throw "Packed $($package.Name), but the release requires CheatEngine.SDK.$($env:PACKAGE_VERSION).nupkg." + } + $archive = [IO.Compression.ZipFile]::OpenRead($package.FullName) + try { + $nuspecEntry = $archive.GetEntry('CheatEngine.SDK.nuspec') + if ($null -eq $nuspecEntry) { + throw "$($package.Name) has no CheatEngine.SDK.nuspec at its root." + } + $reader = [IO.StreamReader]::new($nuspecEntry.Open()) + try { [xml] $nuspec = $reader.ReadToEnd() } finally { $reader.Dispose() } + if ($nuspec.package.metadata.id -cne 'CheatEngine.SDK' -or $nuspec.package.metadata.version -cne $fileVersion) { + throw "$($package.Name) declares id '$($nuspec.package.metadata.id)' version '$($nuspec.package.metadata.version)' in its nuspec." + } + if ($null -eq $archive.GetEntry('_manifest/spdx_2.2/manifest.spdx.json')) { + throw "$($package.Name) does not embed the SPDX SBOM at _manifest/spdx_2.2/manifest.spdx.json (Microsoft.Sbom.Targets, CESDK9008)." + } + $bridge = $archive.GetEntry('build/native/cheatengine-sdk-lua-bridge.dll') + if ($null -eq $bridge) { + throw "$($package.Name) does not carry the native bridge at build/native/cheatengine-sdk-lua-bridge.dll." + } + $bridgeStream = $bridge.Open() + try { + $bridgeSha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bridgeStream)).ToLowerInvariant() + } finally { + $bridgeStream.Dispose() + } + if ($bridgeSha256 -cne $env:BRIDGE_SHA256.ToLowerInvariant()) { + throw "$($package.Name) packs a bridge with SHA-256 $bridgeSha256, but the native job built $env:BRIDGE_SHA256." + } + } finally { + $archive.Dispose() + } + $sha256 = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + @("nupkg=$($package.FullName)", "sha256=$sha256", "version=$fileVersion") | + Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "Packed $($package.Name) (SHA-256 $sha256)." | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + + # One run over every tests/**/*.Tests module of the solution; MTP options go straight to dotnet test (SDK 10). + # A skip fails the run (--fail-skips on): NativeLua tests skip when the bundled Lua DLL does not bind, and a green + # check must not hide that. Hang and crash dumps need the extensions every module gets from eng/Tests.props. + # Debug: the native job's ABI facts make the managed ABI comparison mandatory, every module writes a coverage + # report, and the packaging tests are filtered out by trait (never skipped): they run in the Release leg against + # the packed file (CESDK_PACKAGED_UMBRELLA_NUPKG). + - name: Test + id: test + env: + CESDK_PACKAGED_UMBRELLA_NUPKG: ${{ matrix.configuration == 'Release' && steps.pack.outputs.nupkg || '' }} + PACKED_NUPKG_SHA256: ${{ steps.pack.outputs.sha256 }} + run: | + $ErrorActionPreference = 'Stop' + $options = @( + '--solution', 'CheatEngine.SDK.slnx', '-c', $env:CONFIGURATION, '--no-build', '--results-directory', $env:RESULTS, + '--fail-skips', 'on', '--report-trx', '--report-gh', '--report-gh-groups', 'off', + '--hangdump', '--hangdump-timeout', '15m', '--crashdump' + ) + if ($env:CONFIGURATION -eq 'Debug') { + $env:CE77_NATIVE_ABI_FACTS_PATH = Join-Path $env:GITHUB_WORKSPACE 'artifacts/native-abi-fixture/ce77-native-abi-facts.txt' + $env:CE77_NATIVE_ABI_REQUIRED = 'true' + $env:CESDK_NATIVE_HOST_EMULATOR_DIR = Join-Path $env:GITHUB_WORKSPACE 'artifacts/native-host-emulator' + $env:CESDK_NATIVE_HOST_EMULATOR_REQUIRED = 'true' + $options += '--coverage', '--coverage-output-format', 'xml', '--filter-not-trait', 'Category=Packaging' + } + else { + $consumed = (Get-FileHash -LiteralPath $env:CESDK_PACKAGED_UMBRELLA_NUPKG -Algorithm SHA256).Hash.ToLowerInvariant() + if ($consumed -ne $env:PACKED_NUPKG_SHA256) { + throw "The package changed between Pack ($env:PACKED_NUPKG_SHA256) and Test ($consumed)." + } + "Packaging tests consume ``$(Split-Path -Leaf $env:CESDK_PACKAGED_UMBRELLA_NUPKG)`` (SHA-256 ``$consumed``)." | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + } + dotnet test @options + if ($LASTEXITCODE -ne 0) { + throw "$env:CONFIGURATION tests failed with exit code $LASTEXITCODE." + } + + # The uploaded file is the file the tests consumed. + - name: Verify the tested package is unchanged + if: matrix.configuration == 'Release' + env: + PACKAGE: ${{ steps.pack.outputs.nupkg }} + PACKED_NUPKG_SHA256: ${{ steps.pack.outputs.sha256 }} + run: | + $actual = (Get-FileHash -LiteralPath $env:PACKAGE -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:PACKED_NUPKG_SHA256) { + throw "The package changed during the tests: packed $env:PACKED_NUPKG_SHA256, now $actual." + } + + - name: Upload package + if: matrix.configuration == 'Release' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: nuget-package + path: artifacts/nuget/*.nupkg + if-no-files-found: error + retention-days: ${{ inputs.package-retention-days }} + + - name: Upload coverage + if: matrix.configuration == 'Debug' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: coverage + path: ${{ env.RESULTS }}/*.xml + if-no-files-found: error + retention-days: 7 + + - name: Upload test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: test-results-${{ matrix.configuration }} + path: ${{ env.RESULTS }}/*.trx + if-no-files-found: warn + retention-days: 7 + + - name: Upload test dumps + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: test-dumps-${{ matrix.configuration }} + # Hang and crash dumps, and the crash sequence file listing the tests that were running. + path: | + ${{ env.RESULTS }}/**/*.dmp + ${{ env.RESULTS }}/**/*sequence* + if-no-files-found: ignore + retention-days: 5 + + # Binary logs only on failure: they record the environment the build saw. + # https://learn.microsoft.com/visualstudio/ide/msbuild-logs#provide-msbuild-binary-logs-for-investigation + - name: Upload binary logs + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: binlogs-build-test-${{ matrix.configuration }} + path: artifacts/logs/*.binlog + if-no-files-found: ignore + retention-days: 5 + + aot: + name: Native AOT publication probe + needs: native + runs-on: windows-2025 + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # MinVer computes the version from tags and history + persist-credentials: false + + # The three projects live outside the solution's test run; their lock files carry the win-x64 ILCompiler packages. + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + with: + restore: | + tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj + tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj + tests/CheatEngine.SDK.NativeAotLoaderHarness/CheatEngine.SDK.NativeAotLoaderHarness.csproj + + - name: Use CI-built native bridge + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: lua-protection-bridge + path: native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native + + # This executable is an AOT/trim graph probe only. It is not a plugin candidate for Cheat Engine's hostfxr path. + - name: Publish and run Native AOT probe + run: | + $ErrorActionPreference = 'Stop' + $output = Join-Path $PWD 'artifacts/aot-probe' + dotnet publish tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj -c Release --no-restore -o $output -bl:artifacts/logs/aot-probe.binlog + if ($LASTEXITCODE -ne 0) { + throw "Native AOT probe publish failed with exit code $LASTEXITCODE." + } + $probe = Join-Path $output 'CheatEngine.SDK.AotProbe.exe' + if (-not (Test-Path -LiteralPath $probe -PathType Leaf)) { + throw "Native AOT publish did not produce '$probe'." + } + & $probe + if ($LASTEXITCODE -ne 0) { + throw "Native AOT probe exited with code $LASTEXITCODE." + } + + # This remains a test-library observation. It never starts Cheat Engine, invokes an export, or unloads a NativeAOT DLL. + - name: Publish and inspect Native AOT library probe + run: | + $ErrorActionPreference = 'Stop' + $profileOutput = Join-Path $PWD 'artifacts/nativeaot-loader-profile' + $libraryProject = 'tests/CheatEngine.SDK.NativeAotLibraryProbe/CheatEngine.SDK.NativeAotLibraryProbe.csproj' + $harnessProject = 'tests/CheatEngine.SDK.NativeAotLoaderHarness/CheatEngine.SDK.NativeAotLoaderHarness.csproj' + dotnet publish $libraryProject -c Release --no-restore -o $profileOutput -bl:artifacts/logs/aot-library-probe.binlog + if ($LASTEXITCODE -ne 0) { + throw "Native AOT shared-library publish failed with exit code $LASTEXITCODE." + } + dotnet publish $harnessProject -c Release --no-restore -o $profileOutput -bl:artifacts/logs/aot-loader-harness.binlog + if ($LASTEXITCODE -ne 0) { + throw "Native AOT loader harness publish failed with exit code $LASTEXITCODE." + } + $library = Join-Path $profileOutput 'CheatEngine.SDK.NativeAotLibraryProbe.dll' + $harness = Join-Path $profileOutput 'CheatEngine.SDK.NativeAotLoaderHarness.exe' + if (-not (Test-Path -LiteralPath $library -PathType Leaf)) { + throw "Native AOT shared-library publish did not produce '$library'." + } + if (-not (Test-Path -LiteralPath $harness -PathType Leaf)) { + throw "Native AOT loader harness publish did not produce '$harness'." + } + & $harness --analyze $library + if ($LASTEXITCODE -ne 0) { throw "Native AOT loader harness analysis exited with code $LASTEXITCODE." } + & $harness --load --acknowledge-process-resident-load + if ($LASTEXITCODE -ne 0) { throw "Native AOT loader harness load exited with code $LASTEXITCODE." } + + - name: Upload binary logs + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: binlogs-aot + path: artifacts/logs/*.binlog + if-no-files-found: ignore + retention-days: 5 + + # C2 evidence only: a hostfxr component-route emulator that exercises the same managed entry point Cheat Engine's + # loader would call, never Cheat Engine itself. The Debug build-test leg downloads its output and runs + # NativeHostEmulatorTests against it (tests/native-host-emulator/README.md). + native-host-emulator: + name: Native host emulator + needs: native + runs-on: windows-2025 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Needed only to locate the installed Microsoft.NETCore.App.Host.win-x64 pack's nethost headers/lib/dll; no + # project is restored here. + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: Build native host emulator + run: | + $ErrorActionPreference = 'Stop' + ./tests/native-host-emulator/build.ps1 -OutputDirectory artifacts/native-host-emulator + if ($LASTEXITCODE -ne 0) { + throw "Native host emulator build failed with exit code $LASTEXITCODE." + } + + - name: Upload native host emulator + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: native-host-emulator + path: artifacts/native-host-emulator + if-no-files-found: error + retention-days: 14 + + # SONAR_EXPECTED: the same expression is the gate's SONAR_EXPECTED variable (env is not available in jobs..if; + # WorkflowContractTests keeps the two texts equal). Secrets never reach fork or Dependabot runs, the dormant merge_group + # clause stays as defense in depth, and release.yml never passes sonar. github.actor is deliberate: a maintainer who + # pushes onto a Dependabot branch has the token and must be analysed. Outside push events the quality gate is awaited. + sonar: + name: Sonar + needs: build-test + if: >- + ${{ inputs.sonar + && github.event_name != 'merge_group' + && github.actor != 'dependabot[bot]' + && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + uses: ./.github/workflows/sonar.yml + with: + wait-quality-gate: ${{ github.event_name != 'push' }} + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + lint: + name: Lint + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 # linux_amd64, official checksums file + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run actionlint + run: | + $ErrorActionPreference = 'Stop' + $archive = Join-Path $env:RUNNER_TEMP 'actionlint.tar.gz' + $url = "https://github.com/rhysd/actionlint/releases/download/v$env:ACTIONLINT_VERSION/actionlint_$($env:ACTIONLINT_VERSION)_linux_amd64.tar.gz" + Invoke-WebRequest -Uri $url -OutFile $archive -MaximumRetryCount 3 -RetryIntervalSec 5 + $actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:ACTIONLINT_SHA256) { + throw "actionlint $env:ACTIONLINT_VERSION has SHA-256 $actual, expected $env:ACTIONLINT_SHA256." + } + tar -xzf $archive -C $env:RUNNER_TEMP actionlint + if ($LASTEXITCODE -ne 0) { + throw "Extracting actionlint failed with exit code $LASTEXITCODE." + } + & (Join-Path $env:RUNNER_TEMP 'actionlint') -color + if ($LASTEXITCODE -ne 0) { + throw "actionlint failed with exit code $LASTEXITCODE." + } + + # Offline audits only: the gate must not depend on the GitHub API. The online audits run in an advisory workflow. + - name: Run zizmor + uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0 + with: + version: 1.30.1 + online-audits: false + advanced-security: false + annotations: true + config: .github/zizmor.yml + + # Whitespace formatting of every C# file, including projects outside the solution. Style rules are enforced by the + # build itself (EnforceCodeStyleInBuild with warnings as errors). No restore: --folder needs no MSBuild workspace. + format: + name: Format + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: Verify whitespace formatting + run: | + dotnet format whitespace . --folder --verify-no-changes --exclude artifacts + if ($LASTEXITCODE -ne 0) { + throw "Whitespace formatting differs (exit code $LASTEXITCODE). Run 'dotnet format whitespace . --folder --exclude artifacts' and commit the result." + } + + # Always runs, so the gate never sees it skipped: pull requests are reviewed, every other event records a notice. + dependency-review: + name: Dependency review + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + if: github.event_name == 'pull_request' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Review dependency changes + if: github.event_name == 'pull_request' + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + config-file: ./.github/dependency-review-config.yml + comment-summary-in-pr: never + retry-on-snapshot-warnings: true + + - name: Nothing to review + if: github.event_name != 'pull_request' + env: + EVENT: ${{ github.event_name }} + run: Write-Host "::notice title=Dependency review::A $env:EVENT run has no base to compare; dependency review runs on pull requests." + + # The committed packages.lock.json files must be exactly what a fresh restore produces. Windows only: the Native AOT + # lock sections record the host-RID ILCompiler packages. + lock-files: + name: Lock files + runs-on: windows-2025 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # The composite action's own locked-mode restore is the verification: it fails with NU1004 the moment a + # committed packages.lock.json no longer matches a fresh restore. CheatEngine.SDK.AotProbe is the only project + # outside the solution (Solution/SolutionInventoryTests.cs). + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + with: + restore: | + CheatEngine.SDK.slnx + tests/CheatEngine.SDK.AotProbe/CheatEngine.SDK.AotProbe.csproj + + gate: + name: Gate + # always(): a failed or cancelled job must turn the required check red instead of skipping it. + if: always() + needs: [ native, build-test, aot, native-host-emulator, sonar, lint, format, dependency-review, lock-files ] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: { } + steps: + - name: Check results + env: + NEEDS: ${{ toJSON(needs) }} + SONAR_EXPECTED: >- + ${{ inputs.sonar + && github.event_name != 'merge_group' + && github.actor != 'dependabot[bot]' + && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + EVENT: ${{ github.event_name }} + run: | + # Every job must succeed, except sonar, which must match SONAR_EXPECTED: success when it is true, skipped when it + # is false (fork, Dependabot, merge_group or release run). A sonar run that was not expected means the job + # condition and this expression drifted apart, which fails the gate too. No other job may ever be skipped. + $ErrorActionPreference = 'Stop' + if ($env:SONAR_EXPECTED -notin @('true', 'false')) { + Write-Host "::error title=Gate::SONAR_EXPECTED must be 'true' or 'false', got '$env:SONAR_EXPECTED'." + exit 1 + } + $sonarExpected = $env:SONAR_EXPECTED -eq 'true' + $needs = $env:NEEDS | ConvertFrom-Json -AsHashtable + if ($needs.Count -eq 0) { + Write-Host '::error title=Gate::The gate received no job results.' + exit 1 + } + $mismatches = [Collections.Generic.List[string]]::new() + $rows = [Collections.Generic.List[string]]::new() + foreach ($job in @($needs.Keys | Sort-Object)) { + $result = [string] $needs[$job].result + $required = 'success' + $reason = 'every job must succeed' + if ($job -eq 'sonar') { + if ($sonarExpected) { + $reason = "SONAR_EXPECTED=true on ${env:EVENT}: the analysis must run and pass" + if ($result -eq 'skipped') { + $upstream = [string] $needs['build-test'].result + $reason += ($upstream -ne 'success') ? "; skipped because build-test was $upstream" : '; skipped although expected: the sonar condition drifted' + } + } + else { + $required = 'skipped' + $reason = "SONAR_EXPECTED=false on ${env:EVENT} (fork, Dependabot, merge_group or release run)" + if ($result -ne 'skipped') { + $reason += '; it ran although not expected: the sonar condition drifted' + } + } + } + if ($result -ne $required) { + $mismatches.Add("$job is $result, required $required") + } + $rows.Add("| $job | $result | $required | $reason |") + } + @('### Gate', '', '| Job | Result | Required | Reason |', '| --- | --- | --- | --- |') + $rows | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + if ($mismatches.Count -gt 0) { + Write-Host "::error title=Gate::$($mismatches -join '; ')." + exit 1 + } + Write-Host "All $($needs.Count) jobs have their required result." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ca6767a6..46927061 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -54,7 +54,7 @@ jobs: restore: src/CheatEngine.SDK/CheatEngine.SDK.csproj - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: languages: csharp build-mode: manual @@ -75,13 +75,13 @@ jobs: } - name: Analyze - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: category: /language:csharp - name: Upload binary log if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binlogs-codeql path: artifacts/logs/*.binlog @@ -104,7 +104,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: languages: c-cpp build-mode: none @@ -113,7 +113,7 @@ jobs: trap-caching: false - name: Analyze - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: category: /language:c-cpp @@ -133,7 +133,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: languages: actions build-mode: none @@ -142,6 +142,6 @@ jobs: trap-caching: false - name: Analyze - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: category: /language:actions diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml index 189ba912..ec5003bf 100644 --- a/.github/workflows/dependency-submission.yml +++ b/.github/workflows/dependency-submission.yml @@ -1,263 +1,263 @@ -# Advisory CI-side dependency submission (audit register PR-CQ-45). Automatic NuGet submission needs an -# organization-level setting that cannot be enabled here, and the dependency graph resolves no version from the Central -# Package Management layout on its own (it lists "Microsoft.CodeAnalysis.CSharp >= 0"). -# - detect: read-only token. A pinned, hash-verified Component Detection binary scans the locked restore. The official -# submission action is not used: it downloads the latest Component Detection release at run time and executes it -# next to a contents: write token. -# - submit: the only job with contents: write. It executes no third-party code (download-artifact and gh api only) and -# refuses a snapshot whose commit, ref or correlator is not this run's, because detect ran repository code. -# Pushes to main give dependency review its base snapshot; same-repository pull requests (Dependabot included) submit -# a head snapshot for the pull request head commit. Fork pull requests and drafts never run it. -# detect restores in locked mode, like CI: on a Dependabot pull request whose lock files still miss entries that only -# 'dotnet restore --force-evaluate' regenerates (the SDK-implicit packages), it fails until the maintainer regenerates -# them on the branch and pushes. It is advisory, never a required check. -name: Dependency submission - -on: - push: - branches: [ main ] - pull_request: - types: [ opened, synchronize, reopened, ready_for_review ] - workflow_dispatch: - -# Only the newest snapshot of a pull request matters, so a pull request run cancels the previous one. Runs for main are -# serialized and a running one is never cancelled, so snapshots reach the graph in push order. GitHub keeps at most one -# pending run per group, though: after three quick pushes the middle commit's pending run is replaced by the newest one -# and that commit gets no snapshot. Accepted: the newest commit of main always gets one; dependency review of a pull -# request based on a commit without a snapshot compares against the manifest-only graph and reports snapshot warnings. -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -permissions: - contents: read - -defaults: - run: - shell: pwsh - -jobs: - detect: - name: Detect NuGet dependencies - if: >- - github.event_name != 'pull_request' - || (!github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository) - # Windows: the lock files of the Native AOT projects hold win-x64 ILCompiler sections, so a locked restore needs it. - runs-on: windows-2025 - timeout-minutes: 30 - steps: - # The snapshot describes the commit it names: the pull request head, not the merge commit. - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - - # The step restores itself (locked, with binary logs for the detector). - - name: Set up .NET - uses: ./.github/actions/setup-dotnet - - # GitHub's dependency graph cannot read the Central Package Management layout (it lists NuGet packages as - # ">= 0"), and automatic dependency submission needs an organization-level setting, so the snapshot - # (https://docs.github.com/en/rest/dependency-graph/dependency-submission) is built here: a pinned Component - # Detection binary scans a locked restore of every project, and its manifest becomes one GitHub manifest per - # project (repository-relative path), "direct" for the packages a project references explicitly, "development" - # scope for the detector's development dependencies, and the dependency edges of each project's graph. - - name: Run Component Detection and build the snapshot - env: - SNAPSHOT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - SNAPSHOT_REF: ${{ github.ref }} - SNAPSHOT_CORRELATOR: sdk-nuget - SNAPSHOT_JOB_ID: ${{ github.run_id }} - SNAPSHOT_JOB_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - $ErrorActionPreference = 'Stop' - - # Pinned detector. Bump the three values together after reading the release and its asset digest: - # gh api repos/microsoft/component-detection/releases/tags/v --jq '.assets[] | select(.name == "component-detection-win-x64.exe") | .digest' - $detectorVersion = '8.0.1' - $detectorAsset = 'component-detection-win-x64.exe' - $detectorSha256 = '9539f792cd2ae7d719922db45df763ec4454cc380fbfcbe685da9a90c1b39cf2' - $minimumPackageCount = 40 - $sentinelPackage = 'Microsoft.CodeAnalysis.CSharp' - $solutionFile = 'CheatEngine.SDK.slnx' - - function ConvertTo-RepositoryPath($Location) { - $absolute = if ([IO.Path]::IsPathRooted($Location)) { $Location } else { Join-Path $PWD.Path $Location } - $relative = [IO.Path]::GetRelativePath($PWD.Path, $absolute).Replace('\', '/') - if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or [IO.Path]::IsPathRooted($relative)) { return $null } - return $relative - } - - function Get-PackageUrl($Component) { - # Package URL spec: the NuGet type keeps the name's case; '@' in a name must be percent-encoded. - return "pkg:nuget/$($Component.Name.Replace('@', '%40'))@$($Component.Version)" - } - - function ConvertTo-OrdinalSet($Item) { - $values = [string[]] @($Item | Where-Object { $null -ne $_ }) - $set = [Collections.Generic.HashSet[string]]::new($values, [StringComparer]::Ordinal) - return , $set # the unary comma keeps the set whole: PowerShell would otherwise enumerate it. - } - - $work = Join-Path ([IO.Path]::GetTempPath()) "cheatengine-sdk-snapshot-$([guid]::NewGuid().ToString('N'))" - New-Item -ItemType Directory -Path $work | Out-Null - try { - $detector = Join-Path $work $detectorAsset - $scanManifest = Join-Path $work 'component-detection-manifest.json' - - Invoke-WebRequest -Uri "https://github.com/microsoft/component-detection/releases/download/v$detectorVersion/$detectorAsset" -OutFile $detector -MaximumRetryCount 3 -RetryIntervalSec 5 - $actualSha256 = (Get-FileHash -LiteralPath $detector -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actualSha256 -cne $detectorSha256) { - throw "$detectorAsset v$detectorVersion has SHA-256 $actualSha256, expected $detectorSha256. Refusing to run it." - } - - [xml] $solution = Get-Content -Raw -LiteralPath $solutionFile - $inSolution = @($solution.SelectNodes('//Project') | ForEach-Object { $_.GetAttribute('Path').Replace('\', '/') }) - $outOfSolution = @(git ls-files -- '*.csproj') - if ($LASTEXITCODE -ne 0) { - throw "git ls-files failed with exit code $LASTEXITCODE." - } - $outOfSolution = @($outOfSolution | Where-Object { $_ -notin $inSolution } | Sort-Object) - $targets = @($solutionFile) + $outOfSolution - New-Item -ItemType Directory -Force -Path artifacts/logs | Out-Null - foreach ($target in $targets) { - $binaryLog = "artifacts/logs/dependency-restore-$([IO.Path]::GetFileNameWithoutExtension($target)).binlog" - dotnet restore $target --locked-mode "-bl:$binaryLog" - if ($LASTEXITCODE -ne 0) { - throw "Locked restore of '$target' failed with exit code $LASTEXITCODE." - } - } - - & $detector scan --SourceDirectory $PWD.Path --ManifestFile $scanManifest --DetectorCategories NuGet --Output $work --LogLevel Warning - if ($LASTEXITCODE -ne 0) { - throw "Component Detection failed with exit code $LASTEXITCODE." - } - - $scan = Get-Content -LiteralPath $scanManifest -Raw -Encoding utf8 | ConvertFrom-Json -AsHashtable - $components = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) - foreach ($found in @($scan['componentsFound'])) { - $component = $found['component'] - if ($component['type'] -ceq 'NuGet') { - $components[[string] $component['id']] = [pscustomobject]@{ Name = [string] $component['name']; Version = [string] $component['version'] } - } - } - - $manifests = [ordered]@{} - foreach ($location in @($scan['dependencyGraphs'].Keys | Sort-Object)) { - $relative = ConvertTo-RepositoryPath $location - if ($null -eq $relative) { continue } - - $graph = $scan['dependencyGraphs'][$location] - $explicit = ConvertTo-OrdinalSet $graph['explicitlyReferencedComponentIds'] - $development = ConvertTo-OrdinalSet $graph['developmentDependencies'] - $resolved = [ordered]@{} - foreach ($id in @($graph['graph'].Keys | Sort-Object)) { - if (-not $components.ContainsKey($id)) { continue } - - $children = @(@($graph['graph'][$id]) | Where-Object { $_ -and $components.ContainsKey($_) } | - ForEach-Object { Get-PackageUrl $components[$_] } | Sort-Object -Unique) - $packageUrl = Get-PackageUrl $components[$id] - $resolved[$packageUrl] = [ordered]@{ - package_url = $packageUrl - relationship = if ($explicit.Contains($id)) { 'direct' } else { 'indirect' } - scope = if ($development.Contains($id)) { 'development' } else { 'runtime' } - dependencies = [string[]] $children - } - } - - if ($resolved.Count -gt 0) { - $manifests[$relative] = [ordered]@{ name = $relative; file = [ordered]@{ source_location = $relative }; resolved = $resolved } - } - } - } - finally { - Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue - } - - $job = [ordered]@{ correlator = $env:SNAPSHOT_CORRELATOR; id = $env:SNAPSHOT_JOB_ID; html_url = $env:SNAPSHOT_JOB_URL } - $snapshot = [ordered]@{ - version = 0 - sha = $env:SNAPSHOT_SHA - ref = $env:SNAPSHOT_REF - job = $job - detector = [ordered]@{ name = 'Microsoft Component Detection'; version = $detectorVersion; url = 'https://github.com/microsoft/component-detection' } - scanned = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture) - manifests = $manifests - } - - $packageUrls = @($manifests.Values | ForEach-Object { $_['resolved'].Keys } | Sort-Object -Unique) - if ($packageUrls.Count -lt $minimumPackageCount) { - throw "Only $($packageUrls.Count) NuGet packages detected (at least $minimumPackageCount expected): the scan missed the restore." - } - - [xml] $packageVersions = Get-Content -Raw -LiteralPath Directory.Packages.props - $sentinelVersion = @($packageVersions.SelectNodes('//PackageVersion') | - Where-Object { $_.GetAttribute('Include') -ceq $sentinelPackage } | Select-Object -First 1).GetAttribute('Version') - $sentinelUrl = "pkg:nuget/$sentinelPackage@$sentinelVersion" - if ($packageUrls -cnotcontains $sentinelUrl) { - throw "The snapshot lacks $sentinelUrl, the Roslyn pin of Directory.Packages.props: the scan missed the analyzers and generators." - } - - New-Item -ItemType Directory -Force -Path artifacts/dependency-snapshot | Out-Null - [IO.File]::WriteAllText('artifacts/dependency-snapshot/snapshot.json', ($snapshot | ConvertTo-Json -Depth 20), [Text.UTF8Encoding]::new($false)) - "Detected $($packageUrls.Count) distinct NuGet packages across $($manifests.Count) manifests." | - Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - - - name: Upload snapshot - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: dependency-snapshot - path: artifacts/dependency-snapshot/snapshot.json - if-no-files-found: error - retention-days: 5 - - submit: - name: Submit NuGet dependency graph - needs: detect - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # POST /repos/{owner}/{repo}/dependency-graph/snapshots requires write - steps: - - name: Download snapshot - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: dependency-snapshot - path: snapshot - - # The artifact came from a job that ran repository code: submit it only with the snapshot shape and this run's - # commit, ref and correlator. - - name: Submit - env: - GH_TOKEN: ${{ github.token }} - REPOSITORY: ${{ github.repository }} - SNAPSHOT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - SNAPSHOT_REF: ${{ github.ref }} - SNAPSHOT_CORRELATOR: sdk-nuget - run: | - $ErrorActionPreference = 'Stop' - $snapshot = Get-Content -LiteralPath snapshot/snapshot.json -Raw | ConvertFrom-Json -AsHashtable - $properties = @($snapshot.Keys | Sort-Object) -join ',' - if ($properties -cne 'detector,job,manifests,ref,scanned,sha,version') { - throw "The snapshot artifact has unexpected top-level properties ($properties); nothing was submitted." - } - $job = $snapshot['job'] - $checks = [ordered]@{ - version = $snapshot['version'] -is [long] -and $snapshot['version'] -eq 0 - sha = $snapshot['sha'] -ceq $env:SNAPSHOT_SHA - ref = $snapshot['ref'] -ceq $env:SNAPSHOT_REF - correlator = $job -is [System.Collections.IDictionary] -and $job['correlator'] -ceq $env:SNAPSHOT_CORRELATOR - detector = $snapshot['detector'] -is [System.Collections.IDictionary] - manifests = $snapshot['manifests'] -is [System.Collections.IDictionary] -and $snapshot['manifests'].Count -gt 0 - } - $mismatches = @($checks.Keys | Where-Object { -not $checks[$_] }) - if ($mismatches.Count -gt 0) { - throw "The snapshot artifact does not match this run ($($mismatches -join ', ')); nothing was submitted." - } - gh api --method POST "repos/$env:REPOSITORY/dependency-graph/snapshots" --input snapshot/snapshot.json - if ($LASTEXITCODE -ne 0) { - throw "Dependency snapshot submission failed with exit code $LASTEXITCODE." - } - "Submitted $($snapshot['manifests'].Count) manifests for ``$env:SNAPSHOT_SHA`` ($env:SNAPSHOT_REF)." | - Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 +# Advisory CI-side dependency submission (audit register PR-CQ-45). Automatic NuGet submission needs an +# organization-level setting that cannot be enabled here, and the dependency graph resolves no version from the Central +# Package Management layout on its own (it lists "Microsoft.CodeAnalysis.CSharp >= 0"). +# - detect: read-only token. A pinned, hash-verified Component Detection binary scans the locked restore. The official +# submission action is not used: it downloads the latest Component Detection release at run time and executes it +# next to a contents: write token. +# - submit: the only job with contents: write. It executes no third-party code (download-artifact and gh api only) and +# refuses a snapshot whose commit, ref or correlator is not this run's, because detect ran repository code. +# Pushes to main give dependency review its base snapshot; same-repository pull requests (Dependabot included) submit +# a head snapshot for the pull request head commit. Fork pull requests and drafts never run it. +# detect restores in locked mode, like CI: on a Dependabot pull request whose lock files still miss entries that only +# 'dotnet restore --force-evaluate' regenerates (the SDK-implicit packages), it fails until the maintainer regenerates +# them on the branch and pushes. It is advisory, never a required check. +name: Dependency submission + +on: + push: + branches: [ main ] + pull_request: + types: [ opened, synchronize, reopened, ready_for_review ] + workflow_dispatch: + +# Only the newest snapshot of a pull request matters, so a pull request run cancels the previous one. Runs for main are +# serialized and a running one is never cancelled, so snapshots reach the graph in push order. GitHub keeps at most one +# pending run per group, though: after three quick pushes the middle commit's pending run is replaced by the newest one +# and that commit gets no snapshot. Accepted: the newest commit of main always gets one; dependency review of a pull +# request based on a commit without a snapshot compares against the manifest-only graph and reports snapshot warnings. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +jobs: + detect: + name: Detect NuGet dependencies + if: >- + github.event_name != 'pull_request' + || (!github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository) + # Windows: the lock files of the Native AOT projects hold win-x64 ILCompiler sections, so a locked restore needs it. + runs-on: windows-2025 + timeout-minutes: 30 + steps: + # The snapshot describes the commit it names: the pull request head, not the merge commit. + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + # The step restores itself (locked, with binary logs for the detector). + - name: Set up .NET + uses: ./.github/actions/setup-dotnet + + # GitHub's dependency graph cannot read the Central Package Management layout (it lists NuGet packages as + # ">= 0"), and automatic dependency submission needs an organization-level setting, so the snapshot + # (https://docs.github.com/en/rest/dependency-graph/dependency-submission) is built here: a pinned Component + # Detection binary scans a locked restore of every project, and its manifest becomes one GitHub manifest per + # project (repository-relative path), "direct" for the packages a project references explicitly, "development" + # scope for the detector's development dependencies, and the dependency edges of each project's graph. + - name: Run Component Detection and build the snapshot + env: + SNAPSHOT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + SNAPSHOT_REF: ${{ github.ref }} + SNAPSHOT_CORRELATOR: sdk-nuget + SNAPSHOT_JOB_ID: ${{ github.run_id }} + SNAPSHOT_JOB_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + $ErrorActionPreference = 'Stop' + + # Pinned detector. Bump the three values together after reading the release and its asset digest: + # gh api repos/microsoft/component-detection/releases/tags/v --jq '.assets[] | select(.name == "component-detection-win-x64.exe") | .digest' + $detectorVersion = '8.0.1' + $detectorAsset = 'component-detection-win-x64.exe' + $detectorSha256 = '9539f792cd2ae7d719922db45df763ec4454cc380fbfcbe685da9a90c1b39cf2' + $minimumPackageCount = 40 + $sentinelPackage = 'Microsoft.CodeAnalysis.CSharp' + $solutionFile = 'CheatEngine.SDK.slnx' + + function ConvertTo-RepositoryPath($Location) { + $absolute = if ([IO.Path]::IsPathRooted($Location)) { $Location } else { Join-Path $PWD.Path $Location } + $relative = [IO.Path]::GetRelativePath($PWD.Path, $absolute).Replace('\', '/') + if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or [IO.Path]::IsPathRooted($relative)) { return $null } + return $relative + } + + function Get-PackageUrl($Component) { + # Package URL spec: the NuGet type keeps the name's case; '@' in a name must be percent-encoded. + return "pkg:nuget/$($Component.Name.Replace('@', '%40'))@$($Component.Version)" + } + + function ConvertTo-OrdinalSet($Item) { + $values = [string[]] @($Item | Where-Object { $null -ne $_ }) + $set = [Collections.Generic.HashSet[string]]::new($values, [StringComparer]::Ordinal) + return , $set # the unary comma keeps the set whole: PowerShell would otherwise enumerate it. + } + + $work = Join-Path ([IO.Path]::GetTempPath()) "cheatengine-sdk-snapshot-$([guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $work | Out-Null + try { + $detector = Join-Path $work $detectorAsset + $scanManifest = Join-Path $work 'component-detection-manifest.json' + + Invoke-WebRequest -Uri "https://github.com/microsoft/component-detection/releases/download/v$detectorVersion/$detectorAsset" -OutFile $detector -MaximumRetryCount 3 -RetryIntervalSec 5 + $actualSha256 = (Get-FileHash -LiteralPath $detector -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualSha256 -cne $detectorSha256) { + throw "$detectorAsset v$detectorVersion has SHA-256 $actualSha256, expected $detectorSha256. Refusing to run it." + } + + [xml] $solution = Get-Content -Raw -LiteralPath $solutionFile + $inSolution = @($solution.SelectNodes('//Project') | ForEach-Object { $_.GetAttribute('Path').Replace('\', '/') }) + $outOfSolution = @(git ls-files -- '*.csproj') + if ($LASTEXITCODE -ne 0) { + throw "git ls-files failed with exit code $LASTEXITCODE." + } + $outOfSolution = @($outOfSolution | Where-Object { $_ -notin $inSolution } | Sort-Object) + $targets = @($solutionFile) + $outOfSolution + New-Item -ItemType Directory -Force -Path artifacts/logs | Out-Null + foreach ($target in $targets) { + $binaryLog = "artifacts/logs/dependency-restore-$([IO.Path]::GetFileNameWithoutExtension($target)).binlog" + dotnet restore $target --locked-mode "-bl:$binaryLog" + if ($LASTEXITCODE -ne 0) { + throw "Locked restore of '$target' failed with exit code $LASTEXITCODE." + } + } + + & $detector scan --SourceDirectory $PWD.Path --ManifestFile $scanManifest --DetectorCategories NuGet --Output $work --LogLevel Warning + if ($LASTEXITCODE -ne 0) { + throw "Component Detection failed with exit code $LASTEXITCODE." + } + + $scan = Get-Content -LiteralPath $scanManifest -Raw -Encoding utf8 | ConvertFrom-Json -AsHashtable + $components = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + foreach ($found in @($scan['componentsFound'])) { + $component = $found['component'] + if ($component['type'] -ceq 'NuGet') { + $components[[string] $component['id']] = [pscustomobject]@{ Name = [string] $component['name']; Version = [string] $component['version'] } + } + } + + $manifests = [ordered]@{} + foreach ($location in @($scan['dependencyGraphs'].Keys | Sort-Object)) { + $relative = ConvertTo-RepositoryPath $location + if ($null -eq $relative) { continue } + + $graph = $scan['dependencyGraphs'][$location] + $explicit = ConvertTo-OrdinalSet $graph['explicitlyReferencedComponentIds'] + $development = ConvertTo-OrdinalSet $graph['developmentDependencies'] + $resolved = [ordered]@{} + foreach ($id in @($graph['graph'].Keys | Sort-Object)) { + if (-not $components.ContainsKey($id)) { continue } + + $children = @(@($graph['graph'][$id]) | Where-Object { $_ -and $components.ContainsKey($_) } | + ForEach-Object { Get-PackageUrl $components[$_] } | Sort-Object -Unique) + $packageUrl = Get-PackageUrl $components[$id] + $resolved[$packageUrl] = [ordered]@{ + package_url = $packageUrl + relationship = if ($explicit.Contains($id)) { 'direct' } else { 'indirect' } + scope = if ($development.Contains($id)) { 'development' } else { 'runtime' } + dependencies = [string[]] $children + } + } + + if ($resolved.Count -gt 0) { + $manifests[$relative] = [ordered]@{ name = $relative; file = [ordered]@{ source_location = $relative }; resolved = $resolved } + } + } + } + finally { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue + } + + $job = [ordered]@{ correlator = $env:SNAPSHOT_CORRELATOR; id = $env:SNAPSHOT_JOB_ID; html_url = $env:SNAPSHOT_JOB_URL } + $snapshot = [ordered]@{ + version = 0 + sha = $env:SNAPSHOT_SHA + ref = $env:SNAPSHOT_REF + job = $job + detector = [ordered]@{ name = 'Microsoft Component Detection'; version = $detectorVersion; url = 'https://github.com/microsoft/component-detection' } + scanned = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture) + manifests = $manifests + } + + $packageUrls = @($manifests.Values | ForEach-Object { $_['resolved'].Keys } | Sort-Object -Unique) + if ($packageUrls.Count -lt $minimumPackageCount) { + throw "Only $($packageUrls.Count) NuGet packages detected (at least $minimumPackageCount expected): the scan missed the restore." + } + + [xml] $packageVersions = Get-Content -Raw -LiteralPath Directory.Packages.props + $sentinelVersion = @($packageVersions.SelectNodes('//PackageVersion') | + Where-Object { $_.GetAttribute('Include') -ceq $sentinelPackage } | Select-Object -First 1).GetAttribute('Version') + $sentinelUrl = "pkg:nuget/$sentinelPackage@$sentinelVersion" + if ($packageUrls -cnotcontains $sentinelUrl) { + throw "The snapshot lacks $sentinelUrl, the Roslyn pin of Directory.Packages.props: the scan missed the analyzers and generators." + } + + New-Item -ItemType Directory -Force -Path artifacts/dependency-snapshot | Out-Null + [IO.File]::WriteAllText('artifacts/dependency-snapshot/snapshot.json', ($snapshot | ConvertTo-Json -Depth 20), [Text.UTF8Encoding]::new($false)) + "Detected $($packageUrls.Count) distinct NuGet packages across $($manifests.Count) manifests." | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + + - name: Upload snapshot + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: dependency-snapshot + path: artifacts/dependency-snapshot/snapshot.json + if-no-files-found: error + retention-days: 5 + + submit: + name: Submit NuGet dependency graph + needs: detect + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write # POST /repos/{owner}/{repo}/dependency-graph/snapshots requires write + steps: + - name: Download snapshot + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: dependency-snapshot + path: snapshot + + # The artifact came from a job that ran repository code: submit it only with the snapshot shape and this run's + # commit, ref and correlator. + - name: Submit + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SNAPSHOT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + SNAPSHOT_REF: ${{ github.ref }} + SNAPSHOT_CORRELATOR: sdk-nuget + run: | + $ErrorActionPreference = 'Stop' + $snapshot = Get-Content -LiteralPath snapshot/snapshot.json -Raw | ConvertFrom-Json -AsHashtable + $properties = @($snapshot.Keys | Sort-Object) -join ',' + if ($properties -cne 'detector,job,manifests,ref,scanned,sha,version') { + throw "The snapshot artifact has unexpected top-level properties ($properties); nothing was submitted." + } + $job = $snapshot['job'] + $checks = [ordered]@{ + version = $snapshot['version'] -is [long] -and $snapshot['version'] -eq 0 + sha = $snapshot['sha'] -ceq $env:SNAPSHOT_SHA + ref = $snapshot['ref'] -ceq $env:SNAPSHOT_REF + correlator = $job -is [System.Collections.IDictionary] -and $job['correlator'] -ceq $env:SNAPSHOT_CORRELATOR + detector = $snapshot['detector'] -is [System.Collections.IDictionary] + manifests = $snapshot['manifests'] -is [System.Collections.IDictionary] -and $snapshot['manifests'].Count -gt 0 + } + $mismatches = @($checks.Keys | Where-Object { -not $checks[$_] }) + if ($mismatches.Count -gt 0) { + throw "The snapshot artifact does not match this run ($($mismatches -join ', ')); nothing was submitted." + } + gh api --method POST "repos/$env:REPOSITORY/dependency-graph/snapshots" --input snapshot/snapshot.json + if ($LASTEXITCODE -ne 0) { + throw "Dependency snapshot submission failed with exit code $LASTEXITCODE." + } + "Submitted $($snapshot['manifests'].Count) manifests for ``$env:SNAPSHOT_SHA`` ($env:SNAPSHOT_REF)." | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb842471..2303965d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,727 +1,727 @@ -name: Release - -run-name: Release ${{ github.ref_name }}${{ github.event_name == 'workflow_dispatch' && ' (dry run)' || '' }} - -# verify ─► ci ─► attest ─► draft-release ─► publish ─► verify-publication ─► finalize-release -# -# ci builds and tests the tag and packs the tested build; its nuget-package artifact is the one file that the packaging -# tests consume, that is attested, attached to the release and pushed to nuget.org. The release is created as a draft -# that already carries every asset (an immutable release locks its assets and tag once published) and is published -# only after nuget.org serves the repository-signed package. A workflow_dispatch dry run stops after attest: it builds -# the SBOM and SHA256SUMS, creates no attestation and no release, and publishes nothing. -# See RELEASING.md. - -on: - push: - tags: - - "v*.*.*" - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -permissions: - contents: read - -defaults: - run: - shell: pwsh - -jobs: - verify: - name: Verify tag - runs-on: ubuntu-24.04 - timeout-minutes: 10 - outputs: - version: ${{ steps.tag.outputs.version }} - prerelease: ${{ steps.tag.outputs.prerelease }} - tag: ${{ steps.tag.outputs.tag }} - tree: ${{ steps.tag.outputs.tree }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - # A version on nuget.org can never be replaced, and a squash or rebase merge gives the pull request new commits. - # Only a commit on the first-parent line of main, the merged head, may be released. - - name: Verify tag - id: tag - run: | - $ErrorActionPreference = 'Stop' - - if ($env:GITHUB_REF_TYPE -ne 'tag') { - $tree = git rev-parse --verify 'HEAD^{tree}' - if ($LASTEXITCODE -ne 0) { - throw "Resolving the tree of the dry-run commit failed with exit code $LASTEXITCODE." - } - "tree=$tree" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - Write-Host "::notice::Dry run on branch $env:GITHUB_REF_NAME, so there is no tag to verify." - exit 0 - } - - $semver = '^v(?(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?)$' - if (-not ($env:GITHUB_REF_NAME -cmatch $semver)) { - Write-Host "::error::Tag $env:GITHUB_REF_NAME is not a release tag. Use v.. with an optional SemVer prerelease such as -rc.1, and no build metadata." - exit 1 - } - $version = $Matches['version'] - - $commit = git rev-parse --verify "${env:GITHUB_REF}^{commit}" - if ($LASTEXITCODE -ne 0) { - Write-Host "::error::Tag $env:GITHUB_REF_NAME does not resolve to a commit." - exit 1 - } - $landed = git rev-list --first-parent refs/remotes/origin/main - if ($LASTEXITCODE -ne 0) { - Write-Host '::error::The checkout has no origin/main to compare the tag with.' - exit 1 - } - if ($landed -notcontains $commit) { - Write-Host "::error::Tag $env:GITHUB_REF_NAME points to $commit, which did not land on main. Tag the merged head of main." - exit 1 - } - $tree = git rev-parse --verify "${commit}^{tree}" - if ($LASTEXITCODE -ne 0) { - throw "Resolving the tree of $commit failed with exit code $LASTEXITCODE." - } - - # A full re-run after a successful publish would rebuild a different file for an immutable version. Re-run - # only the failed jobs of the original run: they reuse its artifacts. - try { - $index = Invoke-RestMethod -Uri 'https://api.nuget.org/v3-flatcontainer/cheatengine.sdk/index.json' -MaximumRetryCount 3 -RetryIntervalSec 5 - } - catch { - Write-Host "::error::Could not read the published CheatEngine.SDK versions from nuget.org: $($_.Exception.Message)" - exit 1 - } - if (@($index.versions) -contains $version.ToLowerInvariant()) { - Write-Host "::error::CheatEngine.SDK $version is already on nuget.org. Re-run only the failed jobs of the original run, or tag a new version." - exit 1 - } - - $prerelease = if ($version.Contains('-')) { 'true' } else { 'false' } - @("version=$version", "prerelease=$prerelease", "tag=$env:GITHUB_REF_NAME", "tree=$tree") | - Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - # The GitHub release notes are the CHANGELOG section of the version. A prerelease may ship before its entries - # leave [Unreleased]; a final release may not. - - name: Extract release notes - if: steps.tag.outputs.version != '' - env: - VERSION: ${{ steps.tag.outputs.version }} - PRERELEASE: ${{ steps.tag.outputs.prerelease }} - run: | - $ErrorActionPreference = 'Stop' - $changelog = Get-Content -LiteralPath CHANGELOG.md -Raw - $sections = @($env:VERSION) - if ($env:PRERELEASE -eq 'true') { $sections += 'Unreleased' } - $notes = '' - foreach ($section in $sections) { - $pattern = '(?ms)^## \[' + [regex]::Escape($section) + '\][^\r\n]*\r?\n(?.*?)(?=^## \[|^\[[^\]]+\]:|\z)' - $match = [regex]::Match($changelog, $pattern) - if ($match.Success -and $match.Groups['body'].Value.Trim()) { - $notes = $match.Groups['body'].Value.Trim() - break - } - } - if (-not $notes) { - Write-Host "::error file=CHANGELOG.md::CHANGELOG.md has no non-empty section for $($sections -join ' or '). Move the release entries under '## [$env:VERSION] - ' before tagging." - exit 1 - } - New-Item -ItemType Directory -Path artifacts -Force | Out-Null - @($notes, '', "NuGet package: https://www.nuget.org/packages/CheatEngine.SDK/$env:VERSION") -join "`n" | - Set-Content -LiteralPath artifacts/release-notes.md -Encoding utf8NoBOM - - - name: Upload release notes - if: steps.tag.outputs.version != '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-notes - path: artifacts/release-notes.md - if-no-files-found: error - retention-days: 90 - - # Build and test on the tag commit: MinVer stamps the tag version at compile time, so a main build cannot be - # promoted. Sonar already analysed this commit on main. - ci: - name: CI - needs: verify - uses: ./.github/workflows/ci.yml - with: - package-version: ${{ needs.verify.outputs.version }} - package-retention-days: 90 - - # Attestations are created before publication because the draft must already carry their bundles. On a dry run the - # attest/verify steps below are skipped (their own 'if' guards the exact tag push condition). - attest: - name: Attest and assemble release assets - needs: [ verify, ci ] - runs-on: ubuntu-24.04 - timeout-minutes: 15 - permissions: - contents: read - id-token: write # the Sigstore certificate that signs the attestations is bound to this workflow's OIDC identity - attestations: write # store the provenance and SBOM attestations of the package in this repository - env: - VERSION: ${{ needs.verify.outputs.version }} - TAG: ${{ needs.verify.outputs.tag }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Download package - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: nuget-package - path: artifacts/nuget - - - name: Stage package and SBOM - id: stage - run: | - $ErrorActionPreference = 'Stop' - $packages = @(Get-ChildItem -LiteralPath artifacts/nuget -Filter 'CheatEngine.SDK.*.nupkg' -File) - if ($packages.Count -ne 1) { - throw "nuget-package must hold exactly one CheatEngine.SDK package, found $($packages.Count)." - } - $name = $packages[0].Name - if ($env:VERSION -and $name -cne "CheatEngine.SDK.$env:VERSION.nupkg") { - throw "nuget-package holds $name, but the tag requires CheatEngine.SDK.$env:VERSION.nupkg." - } - $version = $name.Substring('CheatEngine.SDK.'.Length, $name.Length - 'CheatEngine.SDK.'.Length - '.nupkg'.Length) - $release = Join-Path $env:GITHUB_WORKSPACE 'artifacts/release' - New-Item -ItemType Directory -Path $release -Force | Out-Null - Copy-Item -LiteralPath $packages[0].FullName -Destination $release - $package = Join-Path $release $name - - # Microsoft.Sbom.Targets embeds the SPDX 2.2 SBOM at pack time; the release attests that exact document - # (sbom-path derives predicate https://spdx.dev/Document/v2.2 from its spdxVersion), so the bytes are - # extracted, never re-serialized. - $sbomEntry = '_manifest/spdx_2.2/manifest.spdx.json' - $sbom = Join-Path $release "CheatEngine.SDK.$version.spdx.json" - $archive = [IO.Compression.ZipFile]::OpenRead($package) - try { - $entry = $archive.GetEntry($sbomEntry) - if ($null -eq $entry) { - throw "$name embeds no SPDX 2.2 SBOM at $sbomEntry." - } - $stream = $entry.Open() - try { - $document = [IO.MemoryStream]::new() - $stream.CopyTo($document) - $bytes = $document.ToArray() - } finally { - $stream.Dispose() - } - } finally { - $archive.Dispose() - } - $spdxVersion = ([Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json).spdxVersion - if ($spdxVersion -cne 'SPDX-2.2') { - throw "The SBOM of $name declares spdxVersion '$spdxVersion', expected SPDX-2.2." - } - [IO.File]::WriteAllBytes($sbom, $bytes) - @("version=$version", "package=$package", "sbom=$sbom") | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - - name: Attest provenance - id: provenance - if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: ${{ steps.stage.outputs.package }} - - # The predicate type https://spdx.dev/Document/v2.2 is derived from the SBOM's spdxVersion. - - name: Attest SBOM - id: sbom - if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: ${{ steps.stage.outputs.package }} - sbom-path: ${{ steps.stage.outputs.sbom }} - - - name: Verify attestations - if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' - env: - GH_TOKEN: ${{ github.token }} - PACKAGE: ${{ steps.stage.outputs.package }} - STAGED_VERSION: ${{ steps.stage.outputs.version }} - PROVENANCE_BUNDLE: ${{ steps.provenance.outputs.bundle-path }} - SBOM_BUNDLE: ${{ steps.sbom.outputs.bundle-path }} - run: | - $ErrorActionPreference = 'Stop' - $release = Join-Path $env:GITHUB_WORKSPACE 'artifacts/release' - $provenance = Join-Path $release "CheatEngine.SDK.$env:STAGED_VERSION.provenance.sigstore.json" - $sbom = Join-Path $release "CheatEngine.SDK.$env:STAGED_VERSION.sbom.sigstore.json" - Copy-Item -LiteralPath $env:PROVENANCE_BUNDLE -Destination $provenance - Copy-Item -LiteralPath $env:SBOM_BUNDLE -Destination $sbom - $identity = @( - '-R', 'CheatEngineNet/CheatEngine.SDK', - '--signer-workflow', 'CheatEngineNet/CheatEngine.SDK/.github/workflows/release.yml', - '--source-ref', "refs/tags/$env:TAG", - '--deny-self-hosted-runners' - ) - $checks = @( - @('--bundle', $provenance), - @('--bundle', $sbom, '--predicate-type', 'https://spdx.dev/Document/v2.2'), - @(), - @('--predicate-type', 'https://spdx.dev/Document/v2.2') - ) - foreach ($check in $checks) { - gh attestation verify $env:PACKAGE @identity @check - if ($LASTEXITCODE -ne 0) { - throw "gh attestation verify $($check -join ' ') failed with exit code $LASTEXITCODE." - } - } - - # One line per asset: 64 lowercase hex digits, two spaces, the file name (the 'sha256sum -c' / 'shasum -a 256 -c' - # form), sorted ordinally by name. - - name: Write checksums - env: - STAGED_VERSION: ${{ steps.stage.outputs.version }} - run: | - $ErrorActionPreference = 'Stop' - $release = Join-Path $env:GITHUB_WORKSPACE 'artifacts/release' - $prefix = "CheatEngine.SDK.$env:STAGED_VERSION" - $names = @("$prefix.nupkg", "$prefix.spdx.json") - if (Test-Path -LiteralPath (Join-Path $release "$prefix.provenance.sigstore.json")) { - $names += "$prefix.provenance.sigstore.json", "$prefix.sbom.sigstore.json" - } - $names = @($names | Sort-Object) - $lines = foreach ($name in $names) { - $hash = (Get-FileHash -LiteralPath (Join-Path $release $name) -Algorithm SHA256).Hash.ToLowerInvariant() - "$hash $name" - } - [IO.File]::WriteAllText((Join-Path $release 'SHA256SUMS'), (($lines -join "`n") + "`n"), [Text.UTF8Encoding]::new($false)) - - - name: Upload release assets - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: attestation-bundles - path: | - artifacts/release/* - !artifacts/release/*.nupkg - if-no-files-found: error - retention-days: 90 - - # Never uploads to a published release (its assets are immutable once published): a draft receives its missing - # assets, and a published release that already carries the same assets is left untouched. - draft-release: - name: Create draft release - needs: [ verify, attest ] - if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: write # create the draft release and attach its assets - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - TAG: ${{ needs.verify.outputs.tag }} - VERSION: ${{ needs.verify.outputs.version }} - PRERELEASE: ${{ needs.verify.outputs.prerelease }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Download package - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: nuget-package - path: artifacts/release - - - name: Download release assets - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: attestation-bundles - path: artifacts/release - - - name: Download release notes - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: release-notes - path: artifacts - - # No release yet: create a draft with every local asset. A draft release: upload whatever it is still missing. - # A published release (immutable once published): every local asset must already be there with the same - # SHA-256, since nothing may ever be uploaded to it; any other difference needs a maintainer. - - name: Create or complete the draft release - run: | - $ErrorActionPreference = 'Stop' - $release = 'artifacts/release' - $local = [ordered]@{} - foreach ($file in @(Get-ChildItem -LiteralPath $release -File)) { - $local[$file.Name] = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() - } - # Drafts are listed for this token (contents: write), so an absent tag means no release at all. - $releases = gh release list --limit 1000 --json tagName - if ($LASTEXITCODE -ne 0) { - throw "Listing the releases failed with exit code $LASTEXITCODE." - } - $existing = $null - if (@($releases | ConvertFrom-Json | Where-Object { $_.tagName -ceq $env:TAG }).Count -gt 0) { - $view = gh release view $env:TAG --json isDraft,assets - if ($LASTEXITCODE -ne 0) { - throw "Reading the release of $env:TAG failed with exit code $LASTEXITCODE." - } - $existing = $view | ConvertFrom-Json - } - - $names = [string[]]@($local.Keys) - [Array]::Sort($names, [StringComparer]::Ordinal) - $upload = [Collections.Generic.List[string]]::new() - $problems = [Collections.Generic.List[string]]::new() - $action = 'Create' - if ($null -ne $existing) { - $remote = @{} - foreach ($asset in @($existing.assets)) { - $digest = [string] $asset.digest - $remote[[string] $asset.name] = if ($digest -cmatch '^sha256:[0-9a-f]{64}$') { $digest.Substring(7) } else { '' } - } - foreach ($name in $names) { - if (-not $remote.ContainsKey($name)) { - if ($existing.isDraft) { $upload.Add($name) } - else { $problems.Add("the published release has no '$name'") } - continue - } - if ($remote[$name] -cne $local[$name]) { - $problems.Add("'$name' on the release has SHA-256 $($remote[$name]), this run produced $($local[$name])") - } - } - $action = if ($problems.Count -gt 0) { 'Refuse' } elseif ($existing.isDraft) { 'Complete' } else { 'AlreadyPublished' } - } - else { - $upload.AddRange($names) - } - - switch ($action) { - 'Create' { - $options = @('--draft', '--verify-tag', '--title', $env:TAG, '--notes-file', 'artifacts/release-notes.md') - if ($env:PRERELEASE -eq 'true') { $options += '--prerelease' } - $files = @($upload | ForEach-Object { Join-Path $release $_ }) - gh release create $env:TAG @options @files - if ($LASTEXITCODE -ne 0) { - throw "Creating the draft release failed with exit code $LASTEXITCODE." - } - } - 'Complete' { - foreach ($name in $upload) { - gh release upload $env:TAG (Join-Path $release $name) - if ($LASTEXITCODE -ne 0) { - throw "Uploading $name to the draft failed with exit code $LASTEXITCODE." - } - } - } - 'AlreadyPublished' { - Write-Host "::notice::Release $env:TAG is already published with these assets; nothing is uploaded." - } - default { - Write-Host "::error::Release $env:TAG needs a maintainer: $($problems -join '; '). A draft that was never published can be deleted and this job re-run." - exit 1 - } - } - "Draft release ``$env:TAG``: $action ($($local.Count) assets)." | - Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - - # NuGet trusted publishing is bound to this file and the nuget environment, so login and push stay in this job. - publish: - name: Publish to NuGet - needs: [ verify, draft-release ] - if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' - runs-on: windows-2025 - timeout-minutes: 15 - environment: - name: nuget # the maintainer configures its deployment approval and v*.*.* tag policy (RELEASING.md) - url: https://www.nuget.org/packages/CheatEngine.SDK/${{ needs.verify.outputs.version }} - permissions: - contents: read - id-token: write # exchange the OIDC token for a short-lived NuGet API key (trusted publishing) - env: - VERSION: ${{ needs.verify.outputs.version }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - - - name: Download package - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: nuget-package - path: artifacts/release - - - name: Download release assets - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: attestation-bundles - path: artifacts/release - - - name: Check the package against SHA256SUMS - run: | - $ErrorActionPreference = 'Stop' - $name = "CheatEngine.SDK.$env:VERSION.nupkg" - $sums = @{} - foreach ($line in (Get-Content -LiteralPath 'artifacts/release/SHA256SUMS')) { - if ($line -cmatch '^(?[0-9a-f]{64}) (?\S+)$') { - $sums[$Matches['name']] = $Matches['hash'] - } - } - $actual = (Get-FileHash -LiteralPath "artifacts/release/$name" -Algorithm SHA256).Hash.ToLowerInvariant() - if (-not $sums.ContainsKey($name) -or $sums[$name] -cne $actual) { - throw "$name has SHA-256 $actual, which SHA256SUMS of the draft release does not list." - } - Write-Host "Publishing $name with SHA-256 $actual." - - # The temporary API key lives one hour, so the login stays immediately before the push. The user is the - # nuget.org profile name that registered the trusted publishing policy, not an email address. - - name: NuGet login - id: login - uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 - with: - user: ${{ secrets.NUGET_USER }} - - # A re-run after a partial failure must not fail on a version that is already published. - - name: Push package - env: - NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} - run: | - dotnet nuget push "artifacts/release/CheatEngine.SDK.$env:VERSION.nupkg" --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate - if ($LASTEXITCODE -ne 0) { - throw "NuGet push failed with exit code $LASTEXITCODE." - } - - verify-publication: - name: Verify nuget.org publication - needs: [ verify, publish ] - if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' - runs-on: windows-2025 - timeout-minutes: 45 - env: - VERSION: ${{ needs.verify.outputs.version }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - - - name: Download package - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: nuget-package - path: artifacts/release - - # Resolves PackageBaseAddress/3.0.0 from the service index (never a hard-coded base URL), polls the flat - # container until the version is listed (nuget.org validates and indexes a push for several minutes), downloads - # the repository-signed file, runs 'dotnet nuget verify --all' on it, and compares every entry of the two - # archives byte for byte: the signed copy must hold exactly the attested entries plus '.signature.p7s'. - - name: Verify the nuget.org package - run: | - $ErrorActionPreference = 'Stop' - $package = Join-Path $env:GITHUB_WORKSPACE "artifacts/release/CheatEngine.SDK.$env:VERSION.nupkg" - $lowerId = 'cheatengine.sdk' - $lowerVersion = $env:VERSION.Split('+')[0].ToLowerInvariant() - $deadline = [DateTime]::UtcNow.AddMinutes(35) - - $index = Invoke-RestMethod -Uri 'https://api.nuget.org/v3/index.json' -MaximumRetryCount 5 -RetryIntervalSec 10 - $resource = @($index.resources | Where-Object { $_.'@type' -ceq 'PackageBaseAddress/3.0.0' }) | Select-Object -First 1 - if ($null -eq $resource) { - throw "nuget.org's service index has no PackageBaseAddress/3.0.0 resource." - } - $base = ([string] $resource.'@id').TrimEnd('/') + '/' - - $listed = $false - while (-not $listed) { - try { - $listed = @((Invoke-RestMethod -Uri "$base$lowerId/index.json").versions) -ccontains $lowerVersion - } - catch { - $listed = $false - } - if (-not $listed) { - if ([DateTime]::UtcNow -ge $deadline) { - throw "CheatEngine.SDK $env:VERSION is not listed at $base after 35 minutes. nuget.org may still be validating it; re-run this job." - } - Start-Sleep -Seconds 30 - } - } - - $download = Join-Path $env:RUNNER_TEMP 'nuget-org' - New-Item -ItemType Directory -Path $download -Force | Out-Null - $signed = Join-Path $download "$lowerId.$lowerVersion.nupkg" - Invoke-WebRequest -Uri "$base$lowerId/$lowerVersion/$lowerId.$lowerVersion.nupkg" -OutFile $signed - - $verifyOutput = (& dotnet nuget verify --all -v normal $signed 2>&1 | Out-String) - if ($LASTEXITCODE -ne 0) { - throw "'dotnet nuget verify --all' rejected the nuget.org package (exit code $LASTEXITCODE): $verifyOutput" - } - Write-Host $verifyOutput - if (-not $verifyOutput.Contains('https://api.nuget.org/v3/index.json', [StringComparison]::OrdinalIgnoreCase)) { - throw "'dotnet nuget verify' did not report a repository signature from https://api.nuget.org/v3/index.json." - } - - $attested = [Collections.Generic.Dictionary[string, byte[]]]::new() - $attestedArchive = [IO.Compression.ZipFile]::OpenRead($package) - try { - foreach ($entry in $attestedArchive.Entries) { - $stream = $entry.Open() - try { - $memory = [IO.MemoryStream]::new() - $stream.CopyTo($memory) - $attested[$entry.FullName] = $memory.ToArray() - } finally { - $stream.Dispose() - } - } - } finally { - $attestedArchive.Dispose() - } - - $signedArchive = [IO.Compression.ZipFile]::OpenRead($signed) - try { - $signedNames = [Collections.Generic.HashSet[string]]::new([string[]] ($signedArchive.Entries | ForEach-Object FullName)) - foreach ($name in $attested.Keys) { - if (-not $signedNames.Contains($name)) { - throw "The nuget.org package is missing the attested entry '$name'." - } - $entry = $signedArchive.GetEntry($name) - $stream = $entry.Open() - try { - $memory = [IO.MemoryStream]::new() - $stream.CopyTo($memory) - if (-not [Linq.Enumerable]::SequenceEqual($memory.ToArray(), $attested[$name])) { - throw "The nuget.org entry '$name' differs from the attested package." - } - } finally { - $stream.Dispose() - } - } - if (-not $signedNames.Contains('.signature.p7s')) { - throw "The nuget.org package has no repository signature (.signature.p7s)." - } - } finally { - $signedArchive.Dispose() - } - - $signedSha256 = (Get-FileHash -LiteralPath $signed -Algorithm SHA256).Hash.ToLowerInvariant() - Write-Host "nuget.org serves CheatEngine.SDK $env:VERSION repository-signed (SHA-256 $signedSha256), identical to the attested package plus its signature." - - finalize-release: - name: Publish GitHub release - needs: [ verify, attest, verify-publication ] - if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' - runs-on: ubuntu-24.04 - timeout-minutes: 15 - permissions: - contents: write # publish the release - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - TAG: ${{ needs.verify.outputs.tag }} - VERSION: ${{ needs.verify.outputs.version }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Read the release state - id: state - run: | - $ErrorActionPreference = 'Stop' - $view = gh release view $env:TAG --json isDraft - if ($LASTEXITCODE -ne 0) { - throw "Reading the release of $env:TAG failed with exit code $LASTEXITCODE." - } - $isDraft = [bool]($view | ConvertFrom-Json).isDraft - if (-not $isDraft) { - Write-Host "::notice::Release $env:TAG is already published." - } - "draft=$($isDraft.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - - name: Publish the release - if: steps.state.outputs.draft == 'true' - run: | - gh release edit $env:TAG --draft=false - if ($LASTEXITCODE -ne 0) { - throw "Publishing the release failed with exit code $LASTEXITCODE." - } - - # Verifies what consumers download, not the local artifacts. - - name: Verify the published release - run: | - $ErrorActionPreference = 'Stop' - $state = gh release view $env:TAG --json isDraft,isImmutable - if ($LASTEXITCODE -ne 0) { - throw "Reading the release of $env:TAG failed with exit code $LASTEXITCODE." - } - $state = $state | ConvertFrom-Json - if ($state.isDraft) { - throw "Release $env:TAG is still a draft." - } - $published = Join-Path $env:RUNNER_TEMP 'published-release' - gh release download $env:TAG --dir $published --clobber - if ($LASTEXITCODE -ne 0) { - throw "Downloading the assets of $env:TAG failed with exit code $LASTEXITCODE." - } - $sums = @{} - foreach ($line in (Get-Content -LiteralPath (Join-Path $published 'SHA256SUMS'))) { - if ($line -cmatch '^(?[0-9a-f]{64}) (?\S+)$') { - $sums[$Matches['name']] = $Matches['hash'] - } - } - foreach ($name in $sums.Keys) { - $actual = (Get-FileHash -LiteralPath (Join-Path $published $name) -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -cne $sums[$name]) { - throw "The published $name has SHA-256 $actual, SHA256SUMS lists $($sums[$name])." - } - } - $package = Join-Path $published "CheatEngine.SDK.$env:VERSION.nupkg" - if ($state.isImmutable) { - gh release verify $env:TAG - if ($LASTEXITCODE -ne 0) { - throw "gh release verify failed with exit code $LASTEXITCODE." - } - gh release verify-asset $env:TAG $package - if ($LASTEXITCODE -ne 0) { - throw "gh release verify-asset failed with exit code $LASTEXITCODE." - } - } - else { - Write-Host "::warning::Immutable releases are not enabled, so $env:TAG has no release attestation. Enable them once this workflow is on main (RELEASING.md)." - } - $identity = @( - '-R', 'CheatEngineNet/CheatEngine.SDK', - '--signer-workflow', 'CheatEngineNet/CheatEngine.SDK/.github/workflows/release.yml', - '--source-ref', "refs/tags/$env:TAG", - '--deny-self-hosted-runners' - ) - $checks = @( - @($package), - @($package, '--predicate-type', 'https://spdx.dev/Document/v2.2') - ) - foreach ($check in $checks) { - gh attestation verify @check @identity - if ($LASTEXITCODE -ne 0) { - throw "gh attestation verify $([IO.Path]::GetFileName($check[0])) failed with exit code $LASTEXITCODE." - } - } - @( - "### Release $env:TAG", - '', - "Immutable release: $($state.isImmutable)", - '', - '| Asset | SHA-256 |', - '| --- | --- |' - ) + @($sums.Keys | Sort-Object | ForEach-Object { "| ``$_`` | ``$($sums[$_])`` |" }) | - Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 +name: Release + +run-name: Release ${{ github.ref_name }}${{ github.event_name == 'workflow_dispatch' && ' (dry run)' || '' }} + +# verify ─► ci ─► attest ─► draft-release ─► publish ─► verify-publication ─► finalize-release +# +# ci builds and tests the tag and packs the tested build; its nuget-package artifact is the one file that the packaging +# tests consume, that is attested, attached to the release and pushed to nuget.org. The release is created as a draft +# that already carries every asset (an immutable release locks its assets and tag once published) and is published +# only after nuget.org serves the repository-signed package. A workflow_dispatch dry run stops after attest: it builds +# the SBOM and SHA256SUMS, creates no attestation and no release, and publishes nothing. +# See RELEASING.md. + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +jobs: + verify: + name: Verify tag + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + version: ${{ steps.tag.outputs.version }} + prerelease: ${{ steps.tag.outputs.prerelease }} + tag: ${{ steps.tag.outputs.tag }} + tree: ${{ steps.tag.outputs.tree }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + # A version on nuget.org can never be replaced, and a squash or rebase merge gives the pull request new commits. + # Only a commit on the first-parent line of main, the merged head, may be released. + - name: Verify tag + id: tag + run: | + $ErrorActionPreference = 'Stop' + + if ($env:GITHUB_REF_TYPE -ne 'tag') { + $tree = git rev-parse --verify 'HEAD^{tree}' + if ($LASTEXITCODE -ne 0) { + throw "Resolving the tree of the dry-run commit failed with exit code $LASTEXITCODE." + } + "tree=$tree" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Host "::notice::Dry run on branch $env:GITHUB_REF_NAME, so there is no tag to verify." + exit 0 + } + + $semver = '^v(?(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?)$' + if (-not ($env:GITHUB_REF_NAME -cmatch $semver)) { + Write-Host "::error::Tag $env:GITHUB_REF_NAME is not a release tag. Use v.. with an optional SemVer prerelease such as -rc.1, and no build metadata." + exit 1 + } + $version = $Matches['version'] + + $commit = git rev-parse --verify "${env:GITHUB_REF}^{commit}" + if ($LASTEXITCODE -ne 0) { + Write-Host "::error::Tag $env:GITHUB_REF_NAME does not resolve to a commit." + exit 1 + } + $landed = git rev-list --first-parent refs/remotes/origin/main + if ($LASTEXITCODE -ne 0) { + Write-Host '::error::The checkout has no origin/main to compare the tag with.' + exit 1 + } + if ($landed -notcontains $commit) { + Write-Host "::error::Tag $env:GITHUB_REF_NAME points to $commit, which did not land on main. Tag the merged head of main." + exit 1 + } + $tree = git rev-parse --verify "${commit}^{tree}" + if ($LASTEXITCODE -ne 0) { + throw "Resolving the tree of $commit failed with exit code $LASTEXITCODE." + } + + # A full re-run after a successful publish would rebuild a different file for an immutable version. Re-run + # only the failed jobs of the original run: they reuse its artifacts. + try { + $index = Invoke-RestMethod -Uri 'https://api.nuget.org/v3-flatcontainer/cheatengine.sdk/index.json' -MaximumRetryCount 3 -RetryIntervalSec 5 + } + catch { + Write-Host "::error::Could not read the published CheatEngine.SDK versions from nuget.org: $($_.Exception.Message)" + exit 1 + } + if (@($index.versions) -contains $version.ToLowerInvariant()) { + Write-Host "::error::CheatEngine.SDK $version is already on nuget.org. Re-run only the failed jobs of the original run, or tag a new version." + exit 1 + } + + $prerelease = if ($version.Contains('-')) { 'true' } else { 'false' } + @("version=$version", "prerelease=$prerelease", "tag=$env:GITHUB_REF_NAME", "tree=$tree") | + Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + # The GitHub release notes are the CHANGELOG section of the version. A prerelease may ship before its entries + # leave [Unreleased]; a final release may not. + - name: Extract release notes + if: steps.tag.outputs.version != '' + env: + VERSION: ${{ steps.tag.outputs.version }} + PRERELEASE: ${{ steps.tag.outputs.prerelease }} + run: | + $ErrorActionPreference = 'Stop' + $changelog = Get-Content -LiteralPath CHANGELOG.md -Raw + $sections = @($env:VERSION) + if ($env:PRERELEASE -eq 'true') { $sections += 'Unreleased' } + $notes = '' + foreach ($section in $sections) { + $pattern = '(?ms)^## \[' + [regex]::Escape($section) + '\][^\r\n]*\r?\n(?.*?)(?=^## \[|^\[[^\]]+\]:|\z)' + $match = [regex]::Match($changelog, $pattern) + if ($match.Success -and $match.Groups['body'].Value.Trim()) { + $notes = $match.Groups['body'].Value.Trim() + break + } + } + if (-not $notes) { + Write-Host "::error file=CHANGELOG.md::CHANGELOG.md has no non-empty section for $($sections -join ' or '). Move the release entries under '## [$env:VERSION] - ' before tagging." + exit 1 + } + New-Item -ItemType Directory -Path artifacts -Force | Out-Null + @($notes, '', "NuGet package: https://www.nuget.org/packages/CheatEngine.SDK/$env:VERSION") -join "`n" | + Set-Content -LiteralPath artifacts/release-notes.md -Encoding utf8NoBOM + + - name: Upload release notes + if: steps.tag.outputs.version != '' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-notes + path: artifacts/release-notes.md + if-no-files-found: error + retention-days: 90 + + # Build and test on the tag commit: MinVer stamps the tag version at compile time, so a main build cannot be + # promoted. Sonar already analysed this commit on main. + ci: + name: CI + needs: verify + uses: ./.github/workflows/ci.yml + with: + package-version: ${{ needs.verify.outputs.version }} + package-retention-days: 90 + + # Attestations are created before publication because the draft must already carry their bundles. On a dry run the + # attest/verify steps below are skipped (their own 'if' guards the exact tag push condition). + attest: + name: Attest and assemble release assets + needs: [ verify, ci ] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write # the Sigstore certificate that signs the attestations is bound to this workflow's OIDC identity + attestations: write # store the provenance and SBOM attestations of the package in this repository + env: + VERSION: ${{ needs.verify.outputs.version }} + TAG: ${{ needs.verify.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download package + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: nuget-package + path: artifacts/nuget + + - name: Stage package and SBOM + id: stage + run: | + $ErrorActionPreference = 'Stop' + $packages = @(Get-ChildItem -LiteralPath artifacts/nuget -Filter 'CheatEngine.SDK.*.nupkg' -File) + if ($packages.Count -ne 1) { + throw "nuget-package must hold exactly one CheatEngine.SDK package, found $($packages.Count)." + } + $name = $packages[0].Name + if ($env:VERSION -and $name -cne "CheatEngine.SDK.$env:VERSION.nupkg") { + throw "nuget-package holds $name, but the tag requires CheatEngine.SDK.$env:VERSION.nupkg." + } + $version = $name.Substring('CheatEngine.SDK.'.Length, $name.Length - 'CheatEngine.SDK.'.Length - '.nupkg'.Length) + $release = Join-Path $env:GITHUB_WORKSPACE 'artifacts/release' + New-Item -ItemType Directory -Path $release -Force | Out-Null + Copy-Item -LiteralPath $packages[0].FullName -Destination $release + $package = Join-Path $release $name + + # Microsoft.Sbom.Targets embeds the SPDX 2.2 SBOM at pack time; the release attests that exact document + # (sbom-path derives predicate https://spdx.dev/Document/v2.2 from its spdxVersion), so the bytes are + # extracted, never re-serialized. + $sbomEntry = '_manifest/spdx_2.2/manifest.spdx.json' + $sbom = Join-Path $release "CheatEngine.SDK.$version.spdx.json" + $archive = [IO.Compression.ZipFile]::OpenRead($package) + try { + $entry = $archive.GetEntry($sbomEntry) + if ($null -eq $entry) { + throw "$name embeds no SPDX 2.2 SBOM at $sbomEntry." + } + $stream = $entry.Open() + try { + $document = [IO.MemoryStream]::new() + $stream.CopyTo($document) + $bytes = $document.ToArray() + } finally { + $stream.Dispose() + } + } finally { + $archive.Dispose() + } + $spdxVersion = ([Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json).spdxVersion + if ($spdxVersion -cne 'SPDX-2.2') { + throw "The SBOM of $name declares spdxVersion '$spdxVersion', expected SPDX-2.2." + } + [IO.File]::WriteAllBytes($sbom, $bytes) + @("version=$version", "package=$package", "sbom=$sbom") | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Attest provenance + id: provenance + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.stage.outputs.package }} + + # The predicate type https://spdx.dev/Document/v2.2 is derived from the SBOM's spdxVersion. + - name: Attest SBOM + id: sbom + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.stage.outputs.package }} + sbom-path: ${{ steps.stage.outputs.sbom }} + + - name: Verify attestations + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' + env: + GH_TOKEN: ${{ github.token }} + PACKAGE: ${{ steps.stage.outputs.package }} + STAGED_VERSION: ${{ steps.stage.outputs.version }} + PROVENANCE_BUNDLE: ${{ steps.provenance.outputs.bundle-path }} + SBOM_BUNDLE: ${{ steps.sbom.outputs.bundle-path }} + run: | + $ErrorActionPreference = 'Stop' + $release = Join-Path $env:GITHUB_WORKSPACE 'artifacts/release' + $provenance = Join-Path $release "CheatEngine.SDK.$env:STAGED_VERSION.provenance.sigstore.json" + $sbom = Join-Path $release "CheatEngine.SDK.$env:STAGED_VERSION.sbom.sigstore.json" + Copy-Item -LiteralPath $env:PROVENANCE_BUNDLE -Destination $provenance + Copy-Item -LiteralPath $env:SBOM_BUNDLE -Destination $sbom + $identity = @( + '-R', 'CheatEngineNet/CheatEngine.SDK', + '--signer-workflow', 'CheatEngineNet/CheatEngine.SDK/.github/workflows/release.yml', + '--source-ref', "refs/tags/$env:TAG", + '--deny-self-hosted-runners' + ) + $checks = @( + @('--bundle', $provenance), + @('--bundle', $sbom, '--predicate-type', 'https://spdx.dev/Document/v2.2'), + @(), + @('--predicate-type', 'https://spdx.dev/Document/v2.2') + ) + foreach ($check in $checks) { + gh attestation verify $env:PACKAGE @identity @check + if ($LASTEXITCODE -ne 0) { + throw "gh attestation verify $($check -join ' ') failed with exit code $LASTEXITCODE." + } + } + + # One line per asset: 64 lowercase hex digits, two spaces, the file name (the 'sha256sum -c' / 'shasum -a 256 -c' + # form), sorted ordinally by name. + - name: Write checksums + env: + STAGED_VERSION: ${{ steps.stage.outputs.version }} + run: | + $ErrorActionPreference = 'Stop' + $release = Join-Path $env:GITHUB_WORKSPACE 'artifacts/release' + $prefix = "CheatEngine.SDK.$env:STAGED_VERSION" + $names = @("$prefix.nupkg", "$prefix.spdx.json") + if (Test-Path -LiteralPath (Join-Path $release "$prefix.provenance.sigstore.json")) { + $names += "$prefix.provenance.sigstore.json", "$prefix.sbom.sigstore.json" + } + $names = @($names | Sort-Object) + $lines = foreach ($name in $names) { + $hash = (Get-FileHash -LiteralPath (Join-Path $release $name) -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $name" + } + [IO.File]::WriteAllText((Join-Path $release 'SHA256SUMS'), (($lines -join "`n") + "`n"), [Text.UTF8Encoding]::new($false)) + + - name: Upload release assets + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: attestation-bundles + path: | + artifacts/release/* + !artifacts/release/*.nupkg + if-no-files-found: error + retention-days: 90 + + # Never uploads to a published release (its assets are immutable once published): a draft receives its missing + # assets, and a published release that already carries the same assets is left untouched. + draft-release: + name: Create draft release + needs: [ verify, attest ] + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write # create the draft release and attach its assets + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.verify.outputs.tag }} + VERSION: ${{ needs.verify.outputs.version }} + PRERELEASE: ${{ needs.verify.outputs.prerelease }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download package + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: nuget-package + path: artifacts/release + + - name: Download release assets + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: attestation-bundles + path: artifacts/release + + - name: Download release notes + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-notes + path: artifacts + + # No release yet: create a draft with every local asset. A draft release: upload whatever it is still missing. + # A published release (immutable once published): every local asset must already be there with the same + # SHA-256, since nothing may ever be uploaded to it; any other difference needs a maintainer. + - name: Create or complete the draft release + run: | + $ErrorActionPreference = 'Stop' + $release = 'artifacts/release' + $local = [ordered]@{} + foreach ($file in @(Get-ChildItem -LiteralPath $release -File)) { + $local[$file.Name] = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + # Drafts are listed for this token (contents: write), so an absent tag means no release at all. + $releases = gh release list --limit 1000 --json tagName + if ($LASTEXITCODE -ne 0) { + throw "Listing the releases failed with exit code $LASTEXITCODE." + } + $existing = $null + if (@($releases | ConvertFrom-Json | Where-Object { $_.tagName -ceq $env:TAG }).Count -gt 0) { + $view = gh release view $env:TAG --json isDraft,assets + if ($LASTEXITCODE -ne 0) { + throw "Reading the release of $env:TAG failed with exit code $LASTEXITCODE." + } + $existing = $view | ConvertFrom-Json + } + + $names = [string[]]@($local.Keys) + [Array]::Sort($names, [StringComparer]::Ordinal) + $upload = [Collections.Generic.List[string]]::new() + $problems = [Collections.Generic.List[string]]::new() + $action = 'Create' + if ($null -ne $existing) { + $remote = @{} + foreach ($asset in @($existing.assets)) { + $digest = [string] $asset.digest + $remote[[string] $asset.name] = if ($digest -cmatch '^sha256:[0-9a-f]{64}$') { $digest.Substring(7) } else { '' } + } + foreach ($name in $names) { + if (-not $remote.ContainsKey($name)) { + if ($existing.isDraft) { $upload.Add($name) } + else { $problems.Add("the published release has no '$name'") } + continue + } + if ($remote[$name] -cne $local[$name]) { + $problems.Add("'$name' on the release has SHA-256 $($remote[$name]), this run produced $($local[$name])") + } + } + $action = if ($problems.Count -gt 0) { 'Refuse' } elseif ($existing.isDraft) { 'Complete' } else { 'AlreadyPublished' } + } + else { + $upload.AddRange($names) + } + + switch ($action) { + 'Create' { + $options = @('--draft', '--verify-tag', '--title', $env:TAG, '--notes-file', 'artifacts/release-notes.md') + if ($env:PRERELEASE -eq 'true') { $options += '--prerelease' } + $files = @($upload | ForEach-Object { Join-Path $release $_ }) + gh release create $env:TAG @options @files + if ($LASTEXITCODE -ne 0) { + throw "Creating the draft release failed with exit code $LASTEXITCODE." + } + } + 'Complete' { + foreach ($name in $upload) { + gh release upload $env:TAG (Join-Path $release $name) + if ($LASTEXITCODE -ne 0) { + throw "Uploading $name to the draft failed with exit code $LASTEXITCODE." + } + } + } + 'AlreadyPublished' { + Write-Host "::notice::Release $env:TAG is already published with these assets; nothing is uploaded." + } + default { + Write-Host "::error::Release $env:TAG needs a maintainer: $($problems -join '; '). A draft that was never published can be deleted and this job re-run." + exit 1 + } + } + "Draft release ``$env:TAG``: $action ($($local.Count) assets)." | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + + # NuGet trusted publishing is bound to this file and the nuget environment, so login and push stay in this job. + publish: + name: Publish to NuGet + needs: [ verify, draft-release ] + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' + runs-on: windows-2025 + timeout-minutes: 15 + environment: + name: nuget # the maintainer configures its deployment approval and v*.*.* tag policy (RELEASING.md) + url: https://www.nuget.org/packages/CheatEngine.SDK/${{ needs.verify.outputs.version }} + permissions: + contents: read + id-token: write # exchange the OIDC token for a short-lived NuGet API key (trusted publishing) + env: + VERSION: ${{ needs.verify.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: Download package + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: nuget-package + path: artifacts/release + + - name: Download release assets + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: attestation-bundles + path: artifacts/release + + - name: Check the package against SHA256SUMS + run: | + $ErrorActionPreference = 'Stop' + $name = "CheatEngine.SDK.$env:VERSION.nupkg" + $sums = @{} + foreach ($line in (Get-Content -LiteralPath 'artifacts/release/SHA256SUMS')) { + if ($line -cmatch '^(?[0-9a-f]{64}) (?\S+)$') { + $sums[$Matches['name']] = $Matches['hash'] + } + } + $actual = (Get-FileHash -LiteralPath "artifacts/release/$name" -Algorithm SHA256).Hash.ToLowerInvariant() + if (-not $sums.ContainsKey($name) -or $sums[$name] -cne $actual) { + throw "$name has SHA-256 $actual, which SHA256SUMS of the draft release does not list." + } + Write-Host "Publishing $name with SHA-256 $actual." + + # The temporary API key lives one hour, so the login stays immediately before the push. The user is the + # nuget.org profile name that registered the trusted publishing policy, not an email address. + - name: NuGet login + id: login + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 + with: + user: ${{ secrets.NUGET_USER }} + + # A re-run after a partial failure must not fail on a version that is already published. + - name: Push package + env: + NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} + run: | + dotnet nuget push "artifacts/release/CheatEngine.SDK.$env:VERSION.nupkg" --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate + if ($LASTEXITCODE -ne 0) { + throw "NuGet push failed with exit code $LASTEXITCODE." + } + + verify-publication: + name: Verify nuget.org publication + needs: [ verify, publish ] + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' + runs-on: windows-2025 + timeout-minutes: 45 + env: + VERSION: ${{ needs.verify.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: Download package + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: nuget-package + path: artifacts/release + + # Resolves PackageBaseAddress/3.0.0 from the service index (never a hard-coded base URL), polls the flat + # container until the version is listed (nuget.org validates and indexes a push for several minutes), downloads + # the repository-signed file, runs 'dotnet nuget verify --all' on it, and compares every entry of the two + # archives byte for byte: the signed copy must hold exactly the attested entries plus '.signature.p7s'. + - name: Verify the nuget.org package + run: | + $ErrorActionPreference = 'Stop' + $package = Join-Path $env:GITHUB_WORKSPACE "artifacts/release/CheatEngine.SDK.$env:VERSION.nupkg" + $lowerId = 'cheatengine.sdk' + $lowerVersion = $env:VERSION.Split('+')[0].ToLowerInvariant() + $deadline = [DateTime]::UtcNow.AddMinutes(35) + + $index = Invoke-RestMethod -Uri 'https://api.nuget.org/v3/index.json' -MaximumRetryCount 5 -RetryIntervalSec 10 + $resource = @($index.resources | Where-Object { $_.'@type' -ceq 'PackageBaseAddress/3.0.0' }) | Select-Object -First 1 + if ($null -eq $resource) { + throw "nuget.org's service index has no PackageBaseAddress/3.0.0 resource." + } + $base = ([string] $resource.'@id').TrimEnd('/') + '/' + + $listed = $false + while (-not $listed) { + try { + $listed = @((Invoke-RestMethod -Uri "$base$lowerId/index.json").versions) -ccontains $lowerVersion + } + catch { + $listed = $false + } + if (-not $listed) { + if ([DateTime]::UtcNow -ge $deadline) { + throw "CheatEngine.SDK $env:VERSION is not listed at $base after 35 minutes. nuget.org may still be validating it; re-run this job." + } + Start-Sleep -Seconds 30 + } + } + + $download = Join-Path $env:RUNNER_TEMP 'nuget-org' + New-Item -ItemType Directory -Path $download -Force | Out-Null + $signed = Join-Path $download "$lowerId.$lowerVersion.nupkg" + Invoke-WebRequest -Uri "$base$lowerId/$lowerVersion/$lowerId.$lowerVersion.nupkg" -OutFile $signed + + $verifyOutput = (& dotnet nuget verify --all -v normal $signed 2>&1 | Out-String) + if ($LASTEXITCODE -ne 0) { + throw "'dotnet nuget verify --all' rejected the nuget.org package (exit code $LASTEXITCODE): $verifyOutput" + } + Write-Host $verifyOutput + if (-not $verifyOutput.Contains('https://api.nuget.org/v3/index.json', [StringComparison]::OrdinalIgnoreCase)) { + throw "'dotnet nuget verify' did not report a repository signature from https://api.nuget.org/v3/index.json." + } + + $attested = [Collections.Generic.Dictionary[string, byte[]]]::new() + $attestedArchive = [IO.Compression.ZipFile]::OpenRead($package) + try { + foreach ($entry in $attestedArchive.Entries) { + $stream = $entry.Open() + try { + $memory = [IO.MemoryStream]::new() + $stream.CopyTo($memory) + $attested[$entry.FullName] = $memory.ToArray() + } finally { + $stream.Dispose() + } + } + } finally { + $attestedArchive.Dispose() + } + + $signedArchive = [IO.Compression.ZipFile]::OpenRead($signed) + try { + $signedNames = [Collections.Generic.HashSet[string]]::new([string[]] ($signedArchive.Entries | ForEach-Object FullName)) + foreach ($name in $attested.Keys) { + if (-not $signedNames.Contains($name)) { + throw "The nuget.org package is missing the attested entry '$name'." + } + $entry = $signedArchive.GetEntry($name) + $stream = $entry.Open() + try { + $memory = [IO.MemoryStream]::new() + $stream.CopyTo($memory) + if (-not [Linq.Enumerable]::SequenceEqual($memory.ToArray(), $attested[$name])) { + throw "The nuget.org entry '$name' differs from the attested package." + } + } finally { + $stream.Dispose() + } + } + if (-not $signedNames.Contains('.signature.p7s')) { + throw "The nuget.org package has no repository signature (.signature.p7s)." + } + } finally { + $signedArchive.Dispose() + } + + $signedSha256 = (Get-FileHash -LiteralPath $signed -Algorithm SHA256).Hash.ToLowerInvariant() + Write-Host "nuget.org serves CheatEngine.SDK $env:VERSION repository-signed (SHA-256 $signedSha256), identical to the attested package plus its signature." + + finalize-release: + name: Publish GitHub release + needs: [ verify, attest, verify-publication ] + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write # publish the release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.verify.outputs.tag }} + VERSION: ${{ needs.verify.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Read the release state + id: state + run: | + $ErrorActionPreference = 'Stop' + $view = gh release view $env:TAG --json isDraft + if ($LASTEXITCODE -ne 0) { + throw "Reading the release of $env:TAG failed with exit code $LASTEXITCODE." + } + $isDraft = [bool]($view | ConvertFrom-Json).isDraft + if (-not $isDraft) { + Write-Host "::notice::Release $env:TAG is already published." + } + "draft=$($isDraft.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Publish the release + if: steps.state.outputs.draft == 'true' + run: | + gh release edit $env:TAG --draft=false + if ($LASTEXITCODE -ne 0) { + throw "Publishing the release failed with exit code $LASTEXITCODE." + } + + # Verifies what consumers download, not the local artifacts. + - name: Verify the published release + run: | + $ErrorActionPreference = 'Stop' + $state = gh release view $env:TAG --json isDraft,isImmutable + if ($LASTEXITCODE -ne 0) { + throw "Reading the release of $env:TAG failed with exit code $LASTEXITCODE." + } + $state = $state | ConvertFrom-Json + if ($state.isDraft) { + throw "Release $env:TAG is still a draft." + } + $published = Join-Path $env:RUNNER_TEMP 'published-release' + gh release download $env:TAG --dir $published --clobber + if ($LASTEXITCODE -ne 0) { + throw "Downloading the assets of $env:TAG failed with exit code $LASTEXITCODE." + } + $sums = @{} + foreach ($line in (Get-Content -LiteralPath (Join-Path $published 'SHA256SUMS'))) { + if ($line -cmatch '^(?[0-9a-f]{64}) (?\S+)$') { + $sums[$Matches['name']] = $Matches['hash'] + } + } + foreach ($name in $sums.Keys) { + $actual = (Get-FileHash -LiteralPath (Join-Path $published $name) -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -cne $sums[$name]) { + throw "The published $name has SHA-256 $actual, SHA256SUMS lists $($sums[$name])." + } + } + $package = Join-Path $published "CheatEngine.SDK.$env:VERSION.nupkg" + if ($state.isImmutable) { + gh release verify $env:TAG + if ($LASTEXITCODE -ne 0) { + throw "gh release verify failed with exit code $LASTEXITCODE." + } + gh release verify-asset $env:TAG $package + if ($LASTEXITCODE -ne 0) { + throw "gh release verify-asset failed with exit code $LASTEXITCODE." + } + } + else { + Write-Host "::warning::Immutable releases are not enabled, so $env:TAG has no release attestation. Enable them once this workflow is on main (RELEASING.md)." + } + $identity = @( + '-R', 'CheatEngineNet/CheatEngine.SDK', + '--signer-workflow', 'CheatEngineNet/CheatEngine.SDK/.github/workflows/release.yml', + '--source-ref', "refs/tags/$env:TAG", + '--deny-self-hosted-runners' + ) + $checks = @( + @($package), + @($package, '--predicate-type', 'https://spdx.dev/Document/v2.2') + ) + foreach ($check in $checks) { + gh attestation verify @check @identity + if ($LASTEXITCODE -ne 0) { + throw "gh attestation verify $([IO.Path]::GetFileName($check[0])) failed with exit code $LASTEXITCODE." + } + } + @( + "### Release $env:TAG", + '', + "Immutable release: $($state.isImmutable)", + '', + '| Asset | SHA-256 |', + '| --- | --- |' + ) + @($sums.Keys | Sort-Object | ForEach-Object { "| ``$_`` | ``$($sums[$_])`` |" }) | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 08d5a875..ec90b562 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -41,13 +41,13 @@ jobs: # No repo_token: the default token reads the rulesets of a public repository. - name: Run Scorecard - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 with: results_file: results.sarif results_format: sarif publish_results: true - name: Upload to code scanning - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: sarif_file: results.sarif diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 35b44fc6..e6e55057 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -1,231 +1,231 @@ -name: Sonar - -# Reusable SonarQube Cloud analysis, called by ci.yml after build-test when SONAR_EXPECTED is true; the gate then -# requires it to succeed. It reuses the coverage and nuget-package artifacts of the same run; only the instrumented build -# is repeated, because the scanner must observe a compilation. No binary log is kept here: it would record the scanner -# environment. New Code of non-main branches is measured against the reference branch main (a SonarQube Cloud setting). - -on: - workflow_call: - inputs: - project-key: - description: SonarQube Cloud project key. - type: string - default: CheatEngineNet_CheatEngine.SDK - organization: - description: SonarQube Cloud organization key. - type: string - default: cheatenginenet - wait-quality-gate: - description: Fail the job when the quality gate fails. Every event but push waits for it; main pushes only report it. - type: boolean - default: true - secrets: - SONAR_TOKEN: - description: SonarQube Cloud analysis token. The job fails fast when it is empty. - required: false - -permissions: - contents: read - -env: - SONAR_SCANNER_VERSION: 11.3.0 - -defaults: - run: - shell: pwsh - -jobs: - analyze: - name: Analyze - runs-on: windows-2025 - timeout-minutes: 30 - env: - SONAR_PROJECT_KEY: ${{ inputs.project-key }} - SONAR_ORGANIZATION: ${{ inputs.organization }} - SONAR_WAIT_QUALITY_GATE: ${{ inputs.wait-quality-gate }} - steps: - # ci.yml never calls this workflow for forks or Dependabot, so an empty token is a configuration error. Fail - # before the instrumented build instead of after it. - - name: Require token - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: | - if ([string]::IsNullOrEmpty($env:SONAR_TOKEN)) { - Write-Host '::error title=SONAR_TOKEN missing::Add the repository secret SONAR_TOKEN (a SonarQube Cloud analysis token for this project).' - exit 1 - } - - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 # SCM blame for new-code detection - persist-credentials: false - - # SonarScanner for .NET uses Java. Pin the same JDK 21 distribution as CheatEngine.Client so scanner behavior is - # reproducible across the SDK and its high-level consumer. - - name: Set up JDK 21 - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 - with: - distribution: zulu - java-version: '21' - - # SDK and CLI settings only: the restore below must not use the checked-out nuget.config. No package cache. - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - - - name: Download coverage - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: coverage - path: ${{ runner.temp }}/coverage - - - name: Download package - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: nuget-package - path: ${{ runner.temp }}/package - - # Do not use the checked-out nuget.config to resolve tooling. The scanner package and every restored dependency - # must come only from nuget.org, not a source a pull request can redirect. - - name: Create NuGet.org-only configuration - id: nuget-config - run: | - $ErrorActionPreference = 'Stop' - $path = Join-Path $env:RUNNER_TEMP 'sonar-nuget.config' - @' - - - - - - - - '@ | Set-Content -LiteralPath $path -Encoding utf8NoBOM - "path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - # Restore before scanner begin: the subsequent analysis build is --no-restore, so no PR-supplied source can - # participate while scanner credentials are configured. Locked mode: exactly the packages of the committed lock - # files, from nuget.org only. - - name: Restore from NuGet.org - env: - NUGET_CONFIG: ${{ steps.nuget-config.outputs.path }} - run: | - dotnet restore CheatEngine.SDK.slnx --configfile $env:NUGET_CONFIG --locked-mode - if ($LASTEXITCODE -ne 0) { - throw "Sonar restore failed with exit code $LASTEXITCODE. If a dependency changed, regenerate CheatEngine.SDK.slnx's lock file with a forced re-evaluation restore." - } - - - name: Install scanner - env: - NUGET_CONFIG: ${{ steps.nuget-config.outputs.path }} - run: | - dotnet tool install dotnet-sonarscanner --tool-path "$env:RUNNER_TEMP/sonar-scanner" --version $env:SONAR_SCANNER_VERSION --configfile $env:NUGET_CONFIG - if ($LASTEXITCODE -ne 0) { - throw "SonarScanner installation failed with exit code $LASTEXITCODE." - } - - # The begin step ignores the SONAR_TOKEN variable, so the token travels in SONARQUBE_SCANNER_PARAMS, which both - # scanner steps read. It stays off the command line and out of the build step that compiles pull-request code. - - name: Begin analysis - env: - SONARQUBE_SCANNER_PARAMS: '{"sonar.token":"${{ secrets.SONAR_TOKEN }}"}' - run: | - $coverage = "$env:RUNNER_TEMP/coverage" - $reports = @(Get-ChildItem -Path $coverage -Filter *.xml -Recurse -File -ErrorAction SilentlyContinue) - if ($reports.Count -eq 0) { - Write-Host '::error::The coverage artifact holds no report.' - exit 1 - } - # sonar.projectVersion is the core version of the package built in this run (2.0.0 for 2.0.0-alpha.0.37), - # which changes only at a release. - $packages = @(Get-ChildItem -Path "$env:RUNNER_TEMP/package" -Filter 'CheatEngine.SDK.*.nupkg' -File) - if ($packages.Count -ne 1 -or $packages[0].Name -notmatch '^CheatEngine\.SDK\.(?\d+\.\d+\.\d+)') { - Write-Host '::error::The nuget-package artifact must hold exactly one CheatEngine.SDK package.' - exit 1 - } - $version = $Matches['version'] - Write-Host "Coverage reports: $($reports.Count); project version: $version" - # These findings conflict with deliberate repository contracts. Keep them in the scanner configuration so - # production and test sources do not need Sonar-only attributes or code changes. - $ignoredIssues = @( - @{ Key = 'noLinq'; Rule = 'csharpsquid:S3267'; Resource = '**/*.cs' } - @{ Key = 'unsafeInterop'; Rule = 'csharpsquid:S6640'; Resource = '**/*.cs' } - @{ Key = 'nullableFlow'; Rule = 'csharpsquid:S8970'; Resource = '**/*.cs' } - @{ Key = 'luaExportNames'; Rule = 'csharpsquid:S3218'; Resource = 'libs/CheatEngine.SDK.Lua.Interop/Api/**' } - @{ Key = 'luaTypeNames'; Rule = 'csharpsquid:S101'; Resource = 'libs/CheatEngine.SDK.Lua.Interop/Types/**' } - @{ Key = 'interopShape'; Rule = 'csharpsquid:S107'; Resource = 'libs/CheatEngine.SDK.Lua.Interop/Protected/LuaProtectedExports.cs' } - @{ Key = 'callbackDispose'; Rule = 'csharpsquid:S3881'; Resource = 'libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback.cs' } - @{ Key = 'memoryProtectionName'; Rule = 'csharpsquid:S2342'; Resource = 'libs/CheatEngine.SDK.Engine/Enums/MemoryProtection.cs' } - @{ Key = 'descriptorDispatch'; Rule = 'csharpsquid:S1694'; Resource = 'libs/CheatEngine.SDK.Hosting/Bootstrap/PluginDescriptor.cs' } - @{ Key = 'apiOverloadLayout'; Rule = 'csharpsquid:S4136'; Resource = 'libs/**' } - @{ Key = 'roslynInstances'; Rule = 'csharpsquid:S2325'; Resource = 'analyzers/**' } - @{ Key = 'generatorInstances'; Rule = 'csharpsquid:S2325'; Resource = 'source-generators/**' } - @{ Key = 'emittedFragments'; Rule = 'csharpsquid:S1192'; Resource = 'source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallEmitter.cs' } - @{ Key = 'analyzerComments'; Rule = 'csharpsquid:S125'; Resource = 'analyzers/**' } - @{ Key = 'interopComments'; Rule = 'csharpsquid:S125'; Resource = 'libs/CheatEngine.SDK.Lua.Interop/Api/**' } - @{ Key = 'testReflection'; Rule = 'csharpsquid:S3011'; Resource = 'tests/**' } - @{ Key = 'testLiterals'; Rule = 'csharpsquid:S1192'; Resource = 'tests/**' } - @{ Key = 'testGc'; Rule = 'csharpsquid:S1215'; Resource = 'tests/**' } - @{ Key = 'testStaticHooks'; Rule = 'csharpsquid:S2696'; Resource = 'tests/**' } - @{ Key = 'testDoubleDispose'; Rule = 'csharpsquid:S3966'; Resource = 'tests/**' } - @{ Key = 'testBooleanTables'; Rule = 'csharpsquid:S1125'; Resource = 'tests/**' } - @{ Key = 'testComplexity'; Rule = 'csharpsquid:S3776'; Resource = 'tests/**' } - @{ Key = 'testParameters'; Rule = 'csharpsquid:S107'; Resource = 'tests/**' } - @{ Key = 'testMarkerClasses'; Rule = 'csharpsquid:S1118'; Resource = 'tests/**' } - @{ Key = 'testDuplicateScenarios'; Rule = 'csharpsquid:S4144'; Resource = 'tests/**' } - @{ Key = 'testFixedDoubles'; Rule = 'csharpsquid:S3400'; Resource = 'tests/**' } - @{ Key = 'testComments'; Rule = 'csharpsquid:S125'; Resource = 'tests/**' } - ) - $arguments = @( - "/k:$env:SONAR_PROJECT_KEY" - "/o:$env:SONAR_ORGANIZATION" - "/v:$version" - # Not analysed: build output, harnesses that are not product or test code, catalogue tooling and docs. - # Tests stay analysed (the tests/** issue ignores above exist for them) but never count for coverage. - '/d:sonar.exclusions=artifacts/**,tests/CheatEngine.SDK.Benchmarks/**,tests/CheatEngine.SDK.LivePlugin/**,tests/CheatEngine.SDK.QualificationTarget/**,tests/native-host-emulator/**,eng/tools/**,docs/**' - # CI publishes managed coverage only. Keep build-time tooling and test-only sources out of the product - # coverage metric instead of presenting an incomplete report as if it covered those paths. - '/d:sonar.coverage.exclusions=tests/**,eng/**,docs/**' - "/d:sonar.issue.ignore.multicriteria=$(($ignoredIssues.Key) -join ',')" - "/d:sonar.cs.vscoveragexml.reportsPaths=$coverage/**/*.xml" - "/d:sonar.qualitygate.wait=$env:SONAR_WAIT_QUALITY_GATE" - '/d:sonar.qualitygate.timeout=300' - ) - foreach ($issue in $ignoredIssues) { - $arguments += "/d:sonar.issue.ignore.multicriteria.$($issue.Key).ruleKey=$($issue.Rule)" - $arguments += "/d:sonar.issue.ignore.multicriteria.$($issue.Key).resourceKey=$($issue.Resource)" - } - & "$env:RUNNER_TEMP/sonar-scanner/dotnet-sonarscanner.exe" begin @arguments - if ($LASTEXITCODE -ne 0) { - throw "SonarScanner begin failed with exit code $LASTEXITCODE." - } - - # The scanner turns TreatWarningsAsErrors off for this build, which is why build-test, not this job, gates. - - name: Build - run: | - dotnet build CheatEngine.SDK.slnx -c Debug --no-restore --no-incremental --disable-build-servers - if ($LASTEXITCODE -ne 0) { - throw "Sonar analysis build failed with exit code $LASTEXITCODE." - } - - - name: End analysis - env: - SONARQUBE_SCANNER_PARAMS: '{"sonar.token":"${{ secrets.SONAR_TOKEN }}"}' - run: | - & "$env:RUNNER_TEMP/sonar-scanner/dotnet-sonarscanner.exe" end - if ($LASTEXITCODE -ne 0) { - throw "SonarScanner end failed with exit code $LASTEXITCODE." - } - - # Also after a failed quality gate: the summary links the analysis of this pull request or branch. - - name: Link the analysis - if: ${{ !cancelled() }} - env: - PULL_REQUEST: ${{ github.event.pull_request.number }} - BRANCH: ${{ github.ref_name }} - run: | - $scope = if ($env:PULL_REQUEST) { "pullRequest=$env:PULL_REQUEST" } else { "branch=$([Uri]::EscapeDataString($env:BRANCH))" } - $link = "https://sonarcloud.io/summary/new_code?id=$([Uri]::EscapeDataString($env:SONAR_PROJECT_KEY))&$scope" - @('### Sonar', '', "[SonarQube Cloud analysis of $env:SONAR_PROJECT_KEY]($link)") | - Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 +name: Sonar + +# Reusable SonarQube Cloud analysis, called by ci.yml after build-test when SONAR_EXPECTED is true; the gate then +# requires it to succeed. It reuses the coverage and nuget-package artifacts of the same run; only the instrumented build +# is repeated, because the scanner must observe a compilation. No binary log is kept here: it would record the scanner +# environment. New Code of non-main branches is measured against the reference branch main (a SonarQube Cloud setting). + +on: + workflow_call: + inputs: + project-key: + description: SonarQube Cloud project key. + type: string + default: CheatEngineNet_CheatEngine.SDK + organization: + description: SonarQube Cloud organization key. + type: string + default: cheatenginenet + wait-quality-gate: + description: Fail the job when the quality gate fails. Every event but push waits for it; main pushes only report it. + type: boolean + default: true + secrets: + SONAR_TOKEN: + description: SonarQube Cloud analysis token. The job fails fast when it is empty. + required: false + +permissions: + contents: read + +env: + SONAR_SCANNER_VERSION: 11.3.0 + +defaults: + run: + shell: pwsh + +jobs: + analyze: + name: Analyze + runs-on: windows-2025 + timeout-minutes: 30 + env: + SONAR_PROJECT_KEY: ${{ inputs.project-key }} + SONAR_ORGANIZATION: ${{ inputs.organization }} + SONAR_WAIT_QUALITY_GATE: ${{ inputs.wait-quality-gate }} + steps: + # ci.yml never calls this workflow for forks or Dependabot, so an empty token is a configuration error. Fail + # before the instrumented build instead of after it. + - name: Require token + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + if ([string]::IsNullOrEmpty($env:SONAR_TOKEN)) { + Write-Host '::error title=SONAR_TOKEN missing::Add the repository secret SONAR_TOKEN (a SonarQube Cloud analysis token for this project).' + exit 1 + } + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # SCM blame for new-code detection + persist-credentials: false + + # SonarScanner for .NET uses Java. Pin the same JDK 21 distribution as CheatEngine.Client so scanner behavior is + # reproducible across the SDK and its high-level consumer. + - name: Set up JDK 21 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: zulu + java-version: '21' + + # SDK and CLI settings only: the restore below must not use the checked-out nuget.config. No package cache. + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + - name: Download coverage + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: coverage + path: ${{ runner.temp }}/coverage + + - name: Download package + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: nuget-package + path: ${{ runner.temp }}/package + + # Do not use the checked-out nuget.config to resolve tooling. The scanner package and every restored dependency + # must come only from nuget.org, not a source a pull request can redirect. + - name: Create NuGet.org-only configuration + id: nuget-config + run: | + $ErrorActionPreference = 'Stop' + $path = Join-Path $env:RUNNER_TEMP 'sonar-nuget.config' + @' + + + + + + + + '@ | Set-Content -LiteralPath $path -Encoding utf8NoBOM + "path=$path" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + # Restore before scanner begin: the subsequent analysis build is --no-restore, so no PR-supplied source can + # participate while scanner credentials are configured. Locked mode: exactly the packages of the committed lock + # files, from nuget.org only. + - name: Restore from NuGet.org + env: + NUGET_CONFIG: ${{ steps.nuget-config.outputs.path }} + run: | + dotnet restore CheatEngine.SDK.slnx --configfile $env:NUGET_CONFIG --locked-mode + if ($LASTEXITCODE -ne 0) { + throw "Sonar restore failed with exit code $LASTEXITCODE. If a dependency changed, regenerate CheatEngine.SDK.slnx's lock file with a forced re-evaluation restore." + } + + - name: Install scanner + env: + NUGET_CONFIG: ${{ steps.nuget-config.outputs.path }} + run: | + dotnet tool install dotnet-sonarscanner --tool-path "$env:RUNNER_TEMP/sonar-scanner" --version $env:SONAR_SCANNER_VERSION --configfile $env:NUGET_CONFIG + if ($LASTEXITCODE -ne 0) { + throw "SonarScanner installation failed with exit code $LASTEXITCODE." + } + + # The begin step ignores the SONAR_TOKEN variable, so the token travels in SONARQUBE_SCANNER_PARAMS, which both + # scanner steps read. It stays off the command line and out of the build step that compiles pull-request code. + - name: Begin analysis + env: + SONARQUBE_SCANNER_PARAMS: '{"sonar.token":"${{ secrets.SONAR_TOKEN }}"}' + run: | + $coverage = "$env:RUNNER_TEMP/coverage" + $reports = @(Get-ChildItem -Path $coverage -Filter *.xml -Recurse -File -ErrorAction SilentlyContinue) + if ($reports.Count -eq 0) { + Write-Host '::error::The coverage artifact holds no report.' + exit 1 + } + # sonar.projectVersion is the core version of the package built in this run (2.0.0 for 2.0.0-alpha.0.37), + # which changes only at a release. + $packages = @(Get-ChildItem -Path "$env:RUNNER_TEMP/package" -Filter 'CheatEngine.SDK.*.nupkg' -File) + if ($packages.Count -ne 1 -or $packages[0].Name -notmatch '^CheatEngine\.SDK\.(?\d+\.\d+\.\d+)') { + Write-Host '::error::The nuget-package artifact must hold exactly one CheatEngine.SDK package.' + exit 1 + } + $version = $Matches['version'] + Write-Host "Coverage reports: $($reports.Count); project version: $version" + # These findings conflict with deliberate repository contracts. Keep them in the scanner configuration so + # production and test sources do not need Sonar-only attributes or code changes. + $ignoredIssues = @( + @{ Key = 'noLinq'; Rule = 'csharpsquid:S3267'; Resource = '**/*.cs' } + @{ Key = 'unsafeInterop'; Rule = 'csharpsquid:S6640'; Resource = '**/*.cs' } + @{ Key = 'nullableFlow'; Rule = 'csharpsquid:S8970'; Resource = '**/*.cs' } + @{ Key = 'luaExportNames'; Rule = 'csharpsquid:S3218'; Resource = 'libs/CheatEngine.SDK.Lua.Interop/Api/**' } + @{ Key = 'luaTypeNames'; Rule = 'csharpsquid:S101'; Resource = 'libs/CheatEngine.SDK.Lua.Interop/Types/**' } + @{ Key = 'interopShape'; Rule = 'csharpsquid:S107'; Resource = 'libs/CheatEngine.SDK.Lua.Interop/Protected/LuaProtectedExports.cs' } + @{ Key = 'callbackDispose'; Rule = 'csharpsquid:S3881'; Resource = 'libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback.cs' } + @{ Key = 'memoryProtectionName'; Rule = 'csharpsquid:S2342'; Resource = 'libs/CheatEngine.SDK.Engine/Enums/MemoryProtection.cs' } + @{ Key = 'descriptorDispatch'; Rule = 'csharpsquid:S1694'; Resource = 'libs/CheatEngine.SDK.Hosting/Bootstrap/PluginDescriptor.cs' } + @{ Key = 'apiOverloadLayout'; Rule = 'csharpsquid:S4136'; Resource = 'libs/**' } + @{ Key = 'roslynInstances'; Rule = 'csharpsquid:S2325'; Resource = 'analyzers/**' } + @{ Key = 'generatorInstances'; Rule = 'csharpsquid:S2325'; Resource = 'source-generators/**' } + @{ Key = 'emittedFragments'; Rule = 'csharpsquid:S1192'; Resource = 'source-generators/CheatEngine.SDK.SourceGenerators.Shared/LuaEmit/LuaGlobalCallEmitter.cs' } + @{ Key = 'analyzerComments'; Rule = 'csharpsquid:S125'; Resource = 'analyzers/**' } + @{ Key = 'interopComments'; Rule = 'csharpsquid:S125'; Resource = 'libs/CheatEngine.SDK.Lua.Interop/Api/**' } + @{ Key = 'testReflection'; Rule = 'csharpsquid:S3011'; Resource = 'tests/**' } + @{ Key = 'testLiterals'; Rule = 'csharpsquid:S1192'; Resource = 'tests/**' } + @{ Key = 'testGc'; Rule = 'csharpsquid:S1215'; Resource = 'tests/**' } + @{ Key = 'testStaticHooks'; Rule = 'csharpsquid:S2696'; Resource = 'tests/**' } + @{ Key = 'testDoubleDispose'; Rule = 'csharpsquid:S3966'; Resource = 'tests/**' } + @{ Key = 'testBooleanTables'; Rule = 'csharpsquid:S1125'; Resource = 'tests/**' } + @{ Key = 'testComplexity'; Rule = 'csharpsquid:S3776'; Resource = 'tests/**' } + @{ Key = 'testParameters'; Rule = 'csharpsquid:S107'; Resource = 'tests/**' } + @{ Key = 'testMarkerClasses'; Rule = 'csharpsquid:S1118'; Resource = 'tests/**' } + @{ Key = 'testDuplicateScenarios'; Rule = 'csharpsquid:S4144'; Resource = 'tests/**' } + @{ Key = 'testFixedDoubles'; Rule = 'csharpsquid:S3400'; Resource = 'tests/**' } + @{ Key = 'testComments'; Rule = 'csharpsquid:S125'; Resource = 'tests/**' } + ) + $arguments = @( + "/k:$env:SONAR_PROJECT_KEY" + "/o:$env:SONAR_ORGANIZATION" + "/v:$version" + # Not analysed: build output, harnesses that are not product or test code, catalogue tooling and docs. + # Tests stay analysed (the tests/** issue ignores above exist for them) but never count for coverage. + '/d:sonar.exclusions=artifacts/**,tests/CheatEngine.SDK.Benchmarks/**,tests/CheatEngine.SDK.LivePlugin/**,tests/CheatEngine.SDK.QualificationTarget/**,tests/native-host-emulator/**,eng/tools/**,docs/**' + # CI publishes managed coverage only. Keep build-time tooling and test-only sources out of the product + # coverage metric instead of presenting an incomplete report as if it covered those paths. + '/d:sonar.coverage.exclusions=tests/**,eng/**,docs/**' + "/d:sonar.issue.ignore.multicriteria=$(($ignoredIssues.Key) -join ',')" + "/d:sonar.cs.vscoveragexml.reportsPaths=$coverage/**/*.xml" + "/d:sonar.qualitygate.wait=$env:SONAR_WAIT_QUALITY_GATE" + '/d:sonar.qualitygate.timeout=300' + ) + foreach ($issue in $ignoredIssues) { + $arguments += "/d:sonar.issue.ignore.multicriteria.$($issue.Key).ruleKey=$($issue.Rule)" + $arguments += "/d:sonar.issue.ignore.multicriteria.$($issue.Key).resourceKey=$($issue.Resource)" + } + & "$env:RUNNER_TEMP/sonar-scanner/dotnet-sonarscanner.exe" begin @arguments + if ($LASTEXITCODE -ne 0) { + throw "SonarScanner begin failed with exit code $LASTEXITCODE." + } + + # The scanner turns TreatWarningsAsErrors off for this build, which is why build-test, not this job, gates. + - name: Build + run: | + dotnet build CheatEngine.SDK.slnx -c Debug --no-restore --no-incremental --disable-build-servers + if ($LASTEXITCODE -ne 0) { + throw "Sonar analysis build failed with exit code $LASTEXITCODE." + } + + - name: End analysis + env: + SONARQUBE_SCANNER_PARAMS: '{"sonar.token":"${{ secrets.SONAR_TOKEN }}"}' + run: | + & "$env:RUNNER_TEMP/sonar-scanner/dotnet-sonarscanner.exe" end + if ($LASTEXITCODE -ne 0) { + throw "SonarScanner end failed with exit code $LASTEXITCODE." + } + + # Also after a failed quality gate: the summary links the analysis of this pull request or branch. + - name: Link the analysis + if: ${{ !cancelled() }} + env: + PULL_REQUEST: ${{ github.event.pull_request.number }} + BRANCH: ${{ github.ref_name }} + run: | + $scope = if ($env:PULL_REQUEST) { "pullRequest=$env:PULL_REQUEST" } else { "branch=$([Uri]::EscapeDataString($env:BRANCH))" } + $link = "https://sonarcloud.io/summary/new_code?id=$([Uri]::EscapeDataString($env:SONAR_PROJECT_KEY))&$scope" + @('### Sonar', '', "[SonarQube Cloud analysis of $env:SONAR_PROJECT_KEY]($link)") | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 diff --git a/.github/workflows/zizmor-online.yml b/.github/workflows/zizmor-online.yml index 9c91a118..73f0c815 100644 --- a/.github/workflows/zizmor-online.yml +++ b/.github/workflows/zizmor-online.yml @@ -1,56 +1,56 @@ -# Advisory online workflow audits (audit register PR-CQ-19): the audits that need the GitHub API -# (known-vulnerable-actions, impostor-commit, ref-confusion, ref-version-mismatch, stale-action-refs), uploaded to code -# scanning under the "zizmor" category. Not part of CI / Gate: in SARIF mode zizmor exits 0 with findings -# (https://docs.zizmor.sh/usage/). The blocking, offline zizmor run lives in ci.yml (job lint) and pins the same -# version. zizmor discovers .github/zizmor.yml by itself (https://docs.zizmor.sh/configuration/). -# The schedule matters: the vulnerable-actions data changes without any commit. -name: zizmor online audits - -on: - push: - branches: [ main ] - pull_request: - types: [ opened, synchronize, reopened, ready_for_review ] - schedule: - - cron: '41 5 * * 1' - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -permissions: - contents: read - -defaults: - run: - shell: pwsh - -jobs: - zizmor: - name: zizmor (online) - # Drafts wait; fork pull requests get a read-only token and cannot upload SARIF. - if: >- - github.event_name != 'pull_request' - || (!github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository) - runs-on: ubuntu-24.04 # the action runs zizmor in a container - timeout-minutes: 10 - permissions: - contents: read - security-events: write # upload zizmor SARIF - actions: read # the SARIF upload reads workflow run metadata - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - # Inputs: https://github.com/zizmorcore/zizmor-action (action.yml at v0.6.4). The tool version is pinned next to - # the action, so a new zizmor release cannot change the findings without a commit. - - name: Run zizmor - uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 - with: - version: 1.30.1 - online-audits: true - advanced-security: true - persona: regular +# Advisory online workflow audits (audit register PR-CQ-19): the audits that need the GitHub API +# (known-vulnerable-actions, impostor-commit, ref-confusion, ref-version-mismatch, stale-action-refs), uploaded to code +# scanning under the "zizmor" category. Not part of CI / Gate: in SARIF mode zizmor exits 0 with findings +# (https://docs.zizmor.sh/usage/). The blocking, offline zizmor run lives in ci.yml (job lint) and pins the same +# version. zizmor discovers .github/zizmor.yml by itself (https://docs.zizmor.sh/configuration/). +# The schedule matters: the vulnerable-actions data changes without any commit. +name: zizmor online audits + +on: + push: + branches: [ main ] + pull_request: + types: [ opened, synchronize, reopened, ready_for_review ] + schedule: + - cron: '41 5 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +jobs: + zizmor: + name: zizmor (online) + # Drafts wait; fork pull requests get a read-only token and cannot upload SARIF. + if: >- + github.event_name != 'pull_request' + || (!github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-24.04 # the action runs zizmor in a container + timeout-minutes: 10 + permissions: + contents: read + security-events: write # upload zizmor SARIF + actions: read # the SARIF upload reads workflow run metadata + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Inputs: https://github.com/zizmorcore/zizmor-action (action.yml at v0.6.4). The tool version is pinned next to + # the action, so a new zizmor release cannot change the findings without a commit. + - name: Run zizmor + uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0 + with: + version: 1.30.1 + online-audits: true + advanced-security: true + persona: regular