diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68ff955..e41a0cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,459 +1,459 @@ -name: CI - -# Reusable pipeline for pull-request-ci.yml, main-ci.yml and release.yml. Callers use the job id `ci` named `CI`, so -# the only required check is "CI / Gate". -# -# build-test (Debug, Release) ─► sonar ─┐ -# aot ──────────────────────────────────┤ -# lint ─────────────────────────────────┤ -# format ───────────────────────────────┼─► gate -# dependency-review ────────────────────┤ -# lock-files ───────────────────────────┘ -# -# The Release leg packs before it tests: the package-consumption tests install the very files CI uploads. Jobs exchange -# artifacts instead of rebuilding (names reserved, nothing else is uploaded): -# nuget-packages build-test (Release) → sonar, release.yml -# coverage build-test (Debug) → sonar -# test-results- build-test → humans -# test-dumps-, binlogs-* on failure only → humans -# -# No job here uses a NuGet cache: release.yml reaches every job. - -on: - workflow_call: - inputs: - sonar: - description: Run the SonarQube Cloud analysis. Fork pull requests and Dependabot always skip it (SONAR_EXPECTED). - type: boolean - default: false - package-version: - description: When set (release), the Release leg must produce exactly ..nupkg for every Client package. - type: string - default: '' - package-retention-days: - description: Days to keep the nuget-packages 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: - build-test: - name: Build and test (${{ matrix.configuration }}) - 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 # the package version comes from tags and history - persist-credentials: false - - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - with: - restore: CheatEngine.Client.slnx - - # No --warnaserror: it would also promote the NuGet audit warnings replayed at build (NU1901/NU1902) and override - # the audit policy. TreatWarningsAsErrors (Directory.Build.props) keeps compiler and analyzer strictness. - - name: Build - run: | - dotnet build CheatEngine.Client.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." - } - - # Every .nupkg must embed its SPDX SBOM (_manifest/spdx_2.2/manifest.spdx.json): the pack generates it - # unconditionally (eng/Shipping.props, eng/Templates.props) and release.yml attests it. The seven ids produce - # exactly one nupkg each and a snupkg for every one but the two with no build output (Client, Templates); when a - # release names the exact version, every file must be named "..nupkg" / ".snupkg". - - name: Pack - id: pack - if: matrix.configuration == 'Release' - env: - PACKAGE_VERSION: ${{ inputs.package-version }} - run: | - $ErrorActionPreference = 'Stop' - dotnet pack CheatEngine.Client.slnx -c Release --no-build -o artifacts/nuget -bl:artifacts/logs/pack-Release.binlog - if ($LASTEXITCODE -ne 0) { - throw "Client package creation failed with exit code $LASTEXITCODE." - } - - # The seven ids, each declared once with whether it ships a symbol package (release.yml lists the same ids in - # PACKAGE_IDS). - $shipsSymbols = [ordered]@{ - 'CheatEngine.Client' = $false - 'CheatEngine.Client.Abstractions' = $true - 'CheatEngine.Client.Core' = $true - 'CheatEngine.Client.Extensions.DependencyInjection' = $true - 'CheatEngine.Client.Fluent' = $true - 'CheatEngine.Client.Hosting' = $true - 'CheatEngine.Client.Templates' = $false - } - $ids = @($shipsSymbols.Keys) - $noSymbols = @($ids | Where-Object { -not $shipsSymbols[$_] }) - $nupkgs = @(Get-ChildItem artifacts/nuget -Filter '*.nupkg') - $snupkgs = @(Get-ChildItem artifacts/nuget -Filter '*.snupkg') - if ($nupkgs.Count -ne $ids.Count -or $snupkgs.Count -ne ($ids.Count - $noSymbols.Count)) { - throw "Expected $($ids.Count) nupkg and $($ids.Count - $noSymbols.Count) snupkg in artifacts/nuget, found $($nupkgs.Count) nupkg and $($snupkgs.Count) snupkg." - } - - Add-Type -AssemblyName System.IO.Compression.FileSystem - foreach ($id in $ids) { - $expectedName = if ($env:PACKAGE_VERSION) { "$id.$env:PACKAGE_VERSION.nupkg" } else { $null } - $nupkg = if ($expectedName) { Join-Path artifacts/nuget $expectedName } else { ($nupkgs | Where-Object BaseName -Match "^$([regex]::Escape($id))\.\d") | Select-Object -First 1 -ExpandProperty FullName } - if (-not $nupkg -or -not (Test-Path -LiteralPath $nupkg -PathType Leaf)) { - throw "artifacts/nuget has no package for $id$(if ($expectedName) { " named $expectedName" })." - } - - $archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $nupkg).Path) - try { - if ($null -eq $archive.GetEntry('_manifest/spdx_2.2/manifest.spdx.json')) { - throw "$nupkg has no embedded SBOM at _manifest/spdx_2.2/manifest.spdx.json." - } - } - finally { - $archive.Dispose() - } - } - - Write-Output "Verified $($nupkgs.Count) nupkg and $($snupkgs.Count) snupkg, each embedding its SBOM." - - # The Release Test step consumes these exact packages (CHEATENGINE_CLIENT_PACKAGE_SOURCE), never a package it - # packs itself: a self-pack would test different bits than the ones this run uploads. - $absolute = (Resolve-Path artifacts/nuget).Path - "package-source=$absolute" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - # The solution build compiles the benchmarks; listing them proves discovery. Benchmarks never run in the Gate. - - name: Benchmarks discovery - if: matrix.configuration == 'Release' - run: | - $ErrorActionPreference = 'Stop' - $listed = @(dotnet run --project tests/CheatEngine.Client.Benchmarks/CheatEngine.Client.Benchmarks.csproj -c Release --no-build -- --list flat --artifacts artifacts/benchmarks) - if ($LASTEXITCODE -ne 0) { - throw "Benchmark discovery failed with exit code $LASTEXITCODE." - } - $benchmarks = @($listed | Where-Object { $_ -match '^CheatEngine\.Client\.Benchmarks\.' }) - if ($benchmarks.Count -eq 0) { - throw "The benchmark switcher listed no benchmark:`n$($listed -join "`n")" - } - "Discovered $($benchmarks.Count) benchmarks." | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - - # One parallel run over every tests/**/*.Tests module. A skip fails the run (--fail-skips on). Debug collects one - # coverage report per module and leaves the package-consumption tests to Release by trait filter, never Skip. - # Release hands those tests the packages the Pack step produced. Both legs exclude the live qualification tests - # by trait: they start a sandboxed Cheat Engine and run only on a maintainer workstation that opts in. The hang - # dump fires after 15 minutes without test activity, well below the job timeout. - - name: Test - env: - PACKAGE_SOURCE: ${{ steps.pack.outputs.package-source }} - run: | - $ErrorActionPreference = 'Stop' - $options = @( - '--solution', 'CheatEngine.Client.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', - '--filter-not-trait', 'Category=LiveQualification' - ) - if ($env:CONFIGURATION -eq 'Debug') { - $options += '--coverage', '--coverage-output-format', 'xml', '--filter-not-trait', 'Category=PackageConsumption' - } - else { - # Set only here: the smoke tests fail on a set-but-empty value, and the Debug leg has no packages. - if ([string]::IsNullOrEmpty($env:PACKAGE_SOURCE)) { - throw 'The Pack step exported no package-source.' - } - $env:CHEATENGINE_CLIENT_PACKAGE_SOURCE = $env:PACKAGE_SOURCE - } - dotnet test @options - if ($LASTEXITCODE -ne 0) { - throw "$env:CONFIGURATION tests failed with exit code $LASTEXITCODE." - } - - - name: Upload packages - if: matrix.configuration == 'Release' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: nuget-packages - path: | - artifacts/nuget/*.nupkg - artifacts/nuget/*.snupkg - 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 - - # Crash dumps, hang dumps and the test sequence logs that name the tests running at the time. - - name: Upload test dumps - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: test-dumps-${{ matrix.configuration }} - path: | - ${{ env.RESULTS }}/**/*.dmp - ${{ env.RESULTS }}/**/*.log - if-no-files-found: ignore - retention-days: 5 - - - 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 - - # A trim and Native AOT graph probe only: it never loads anything into Cheat Engine. - aot: - name: Native AOT publication probe - runs-on: windows-2025 - timeout-minutes: 25 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 # the package version comes from tags and history - persist-credentials: false - - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - with: - restore: tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj - - # The project pins its RuntimeIdentifier (win-x64), so publish needs no --runtime. - - name: Publish and run Native AOT probe - run: | - $ErrorActionPreference = 'Stop' - $output = Join-Path $PWD 'artifacts/aot-probe' - dotnet publish tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj -c Release --no-restore -o $output -bl:artifacts/logs/aot-publish.binlog - if ($LASTEXITCODE -ne 0) { - throw "Native AOT probe publish failed with exit code $LASTEXITCODE." - } - $probe = Join-Path $output 'CheatEngine.Client.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." - } - - - 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 - - # Pull requests fail on the quality gate; main only reports it. SONAR_EXPECTED below must stay identical to the gate's. - 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, so the result depends on the commit alone; the online audits run in zizmor-online.yml. - - name: Run zizmor - uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 - with: - version: 1.30.1 - online-audits: false - advanced-security: false - config: .github/zizmor.yml - - 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 - - # Whitespace needs no restore or MSBuild workspace, and the CRLF checkout (.gitattributes) matches .editorconfig. - # Style rules are enforced by the build (EnforceCodeStyleInBuild). - - 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). Fix it with: dotnet format whitespace --folder" - } - - # Always runs, so the Gate never has to accept a skip: the review itself runs on pull requests only. - dependency-review: - name: Dependency review - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Checkout - 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 - - - name: Nothing to review - if: github.event_name != 'pull_request' - env: - EVENT: ${{ github.event_name }} - run: Write-Host "::notice title=Dependency review::Dependency review compares a pull request with its base; a $env:EVENT run has nothing to compare." - - 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 - - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - - # Windows only: Native AOT lock sections record host-RID ILCompiler packages. A locked restore fails outright - # when a committed packages.lock.json no longer matches its project graph. - - name: Verify lock files - run: | - dotnet restore CheatEngine.Client.slnx --locked-mode - if ($LASTEXITCODE -ne 0) { - throw "Locked restore failed with exit code $LASTEXITCODE. The committed packages.lock.json files do not match the project graph: regenerate the affected ones with 'dotnet restore --force-evaluate' and commit the result." - } - - gate: - name: Gate - # always(): a failed or cancelled job must turn the required check red instead of skipping it. - if: always() - needs: [ build-test, aot, 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. Only sonar has a required result that depends on the event: success when - # SONAR_EXPECTED is true, skipped otherwise (fork, Dependabot, release call). A sonar run that was not - # expected means its condition drifted from SONAR_EXPECTED, which fails too. - $ErrorActionPreference = 'Stop' - $needs = $env:NEEDS | ConvertFrom-Json -AsHashtable - $sonarExpected = $env:SONAR_EXPECTED -eq 'true' - $failed = [Collections.Generic.List[string]]::new() - $rows = foreach ($job in @($needs.Keys | Sort-Object)) { - $result = $needs[$job].result - $required = 'success' - $reason = 'every job must succeed' - if ($job -eq 'sonar') { - if ($sonarExpected) { - $reason = 'SONAR_EXPECTED=true' - if ($result -eq 'skipped' -and $needs['build-test'].result -ne 'success') { - $reason = 'SONAR_EXPECTED=true; skipped because build-test did not succeed' - } - } - else { - $required = 'skipped' - $reason = "SONAR_EXPECTED=false ($env:EVENT run: fork, Dependabot or release call)" - } - } - if ($result -ne $required) { - $failed.Add("$job ($result, required $required)") - } - "| $job | $result | $required | $reason |" - } - @('### Gate', '', '| Job | Result | Required | Reason |', '| --- | --- | --- | --- |') + $rows | - Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - if ($failed.Count -gt 0) { - Write-Host "::error::Not as required: $($failed -join ', ')." - exit 1 - } +name: CI + +# Reusable pipeline for pull-request-ci.yml, main-ci.yml and release.yml. Callers use the job id `ci` named `CI`, so +# the only required check is "CI / Gate". +# +# build-test (Debug, Release) ─► sonar ─┐ +# aot ──────────────────────────────────┤ +# lint ─────────────────────────────────┤ +# format ───────────────────────────────┼─► gate +# dependency-review ────────────────────┤ +# lock-files ───────────────────────────┘ +# +# The Release leg packs before it tests: the package-consumption tests install the very files CI uploads. Jobs exchange +# artifacts instead of rebuilding (names reserved, nothing else is uploaded): +# nuget-packages build-test (Release) → sonar, release.yml +# coverage build-test (Debug) → sonar +# test-results- build-test → humans +# test-dumps-, binlogs-* on failure only → humans +# +# No job here uses a NuGet cache: release.yml reaches every job. + +on: + workflow_call: + inputs: + sonar: + description: Run the SonarQube Cloud analysis. Fork pull requests and Dependabot always skip it (SONAR_EXPECTED). + type: boolean + default: false + package-version: + description: When set (release), the Release leg must produce exactly ..nupkg for every Client package. + type: string + default: '' + package-retention-days: + description: Days to keep the nuget-packages 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: + build-test: + name: Build and test (${{ matrix.configuration }}) + 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 # the package version comes from tags and history + persist-credentials: false + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + with: + restore: CheatEngine.Client.slnx + + # No --warnaserror: it would also promote the NuGet audit warnings replayed at build (NU1901/NU1902) and override + # the audit policy. TreatWarningsAsErrors (Directory.Build.props) keeps compiler and analyzer strictness. + - name: Build + run: | + dotnet build CheatEngine.Client.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." + } + + # Every .nupkg must embed its SPDX SBOM (_manifest/spdx_2.2/manifest.spdx.json): the pack generates it + # unconditionally (eng/Shipping.props, eng/Templates.props) and release.yml attests it. The seven ids produce + # exactly one nupkg each and a snupkg for every one but the two with no build output (Client, Templates); when a + # release names the exact version, every file must be named "..nupkg" / ".snupkg". + - name: Pack + id: pack + if: matrix.configuration == 'Release' + env: + PACKAGE_VERSION: ${{ inputs.package-version }} + run: | + $ErrorActionPreference = 'Stop' + dotnet pack CheatEngine.Client.slnx -c Release --no-build -o artifacts/nuget -bl:artifacts/logs/pack-Release.binlog + if ($LASTEXITCODE -ne 0) { + throw "Client package creation failed with exit code $LASTEXITCODE." + } + + # The seven ids, each declared once with whether it ships a symbol package (release.yml lists the same ids in + # PACKAGE_IDS). + $shipsSymbols = [ordered]@{ + 'CheatEngine.Client' = $false + 'CheatEngine.Client.Abstractions' = $true + 'CheatEngine.Client.Core' = $true + 'CheatEngine.Client.Extensions.DependencyInjection' = $true + 'CheatEngine.Client.Fluent' = $true + 'CheatEngine.Client.Hosting' = $true + 'CheatEngine.Client.Templates' = $false + } + $ids = @($shipsSymbols.Keys) + $noSymbols = @($ids | Where-Object { -not $shipsSymbols[$_] }) + $nupkgs = @(Get-ChildItem artifacts/nuget -Filter '*.nupkg') + $snupkgs = @(Get-ChildItem artifacts/nuget -Filter '*.snupkg') + if ($nupkgs.Count -ne $ids.Count -or $snupkgs.Count -ne ($ids.Count - $noSymbols.Count)) { + throw "Expected $($ids.Count) nupkg and $($ids.Count - $noSymbols.Count) snupkg in artifacts/nuget, found $($nupkgs.Count) nupkg and $($snupkgs.Count) snupkg." + } + + Add-Type -AssemblyName System.IO.Compression.FileSystem + foreach ($id in $ids) { + $expectedName = if ($env:PACKAGE_VERSION) { "$id.$env:PACKAGE_VERSION.nupkg" } else { $null } + $nupkg = if ($expectedName) { Join-Path artifacts/nuget $expectedName } else { ($nupkgs | Where-Object BaseName -Match "^$([regex]::Escape($id))\.\d") | Select-Object -First 1 -ExpandProperty FullName } + if (-not $nupkg -or -not (Test-Path -LiteralPath $nupkg -PathType Leaf)) { + throw "artifacts/nuget has no package for $id$(if ($expectedName) { " named $expectedName" })." + } + + $archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $nupkg).Path) + try { + if ($null -eq $archive.GetEntry('_manifest/spdx_2.2/manifest.spdx.json')) { + throw "$nupkg has no embedded SBOM at _manifest/spdx_2.2/manifest.spdx.json." + } + } + finally { + $archive.Dispose() + } + } + + Write-Output "Verified $($nupkgs.Count) nupkg and $($snupkgs.Count) snupkg, each embedding its SBOM." + + # The Release Test step consumes these exact packages (CHEATENGINE_CLIENT_PACKAGE_SOURCE), never a package it + # packs itself: a self-pack would test different bits than the ones this run uploads. + $absolute = (Resolve-Path artifacts/nuget).Path + "package-source=$absolute" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + # The solution build compiles the benchmarks; listing them proves discovery. Benchmarks never run in the Gate. + - name: Benchmarks discovery + if: matrix.configuration == 'Release' + run: | + $ErrorActionPreference = 'Stop' + $listed = @(dotnet run --project tests/CheatEngine.Client.Benchmarks/CheatEngine.Client.Benchmarks.csproj -c Release --no-build -- --list flat --artifacts artifacts/benchmarks) + if ($LASTEXITCODE -ne 0) { + throw "Benchmark discovery failed with exit code $LASTEXITCODE." + } + $benchmarks = @($listed | Where-Object { $_ -match '^CheatEngine\.Client\.Benchmarks\.' }) + if ($benchmarks.Count -eq 0) { + throw "The benchmark switcher listed no benchmark:`n$($listed -join "`n")" + } + "Discovered $($benchmarks.Count) benchmarks." | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + + # One parallel run over every tests/**/*.Tests module. A skip fails the run (--fail-skips on). Debug collects one + # coverage report per module and leaves the package-consumption tests to Release by trait filter, never Skip. + # Release hands those tests the packages the Pack step produced. Both legs exclude the live qualification tests + # by trait: they start a sandboxed Cheat Engine and run only on a maintainer workstation that opts in. The hang + # dump fires after 15 minutes without test activity, well below the job timeout. + - name: Test + env: + PACKAGE_SOURCE: ${{ steps.pack.outputs.package-source }} + run: | + $ErrorActionPreference = 'Stop' + $options = @( + '--solution', 'CheatEngine.Client.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', + '--filter-not-trait', 'Category=LiveQualification' + ) + if ($env:CONFIGURATION -eq 'Debug') { + $options += '--coverage', '--coverage-output-format', 'xml', '--filter-not-trait', 'Category=PackageConsumption' + } + else { + # Set only here: the smoke tests fail on a set-but-empty value, and the Debug leg has no packages. + if ([string]::IsNullOrEmpty($env:PACKAGE_SOURCE)) { + throw 'The Pack step exported no package-source.' + } + $env:CHEATENGINE_CLIENT_PACKAGE_SOURCE = $env:PACKAGE_SOURCE + } + dotnet test @options + if ($LASTEXITCODE -ne 0) { + throw "$env:CONFIGURATION tests failed with exit code $LASTEXITCODE." + } + + - name: Upload packages + if: matrix.configuration == 'Release' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: nuget-packages + path: | + artifacts/nuget/*.nupkg + artifacts/nuget/*.snupkg + 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 + + # Crash dumps, hang dumps and the test sequence logs that name the tests running at the time. + - name: Upload test dumps + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: test-dumps-${{ matrix.configuration }} + path: | + ${{ env.RESULTS }}/**/*.dmp + ${{ env.RESULTS }}/**/*.log + if-no-files-found: ignore + retention-days: 5 + + - 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 + + # A trim and Native AOT graph probe only: it never loads anything into Cheat Engine. + aot: + name: Native AOT publication probe + runs-on: windows-2025 + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # the package version comes from tags and history + persist-credentials: false + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + with: + restore: tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj + + # The project pins its RuntimeIdentifier (win-x64), so publish needs no --runtime. + - name: Publish and run Native AOT probe + run: | + $ErrorActionPreference = 'Stop' + $output = Join-Path $PWD 'artifacts/aot-probe' + dotnet publish tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj -c Release --no-restore -o $output -bl:artifacts/logs/aot-publish.binlog + if ($LASTEXITCODE -ne 0) { + throw "Native AOT probe publish failed with exit code $LASTEXITCODE." + } + $probe = Join-Path $output 'CheatEngine.Client.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." + } + + - 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 + + # Pull requests fail on the quality gate; main only reports it. SONAR_EXPECTED below must stay identical to the gate's. + 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, so the result depends on the commit alone; the online audits run in zizmor-online.yml. + - name: Run zizmor + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + version: 1.30.1 + online-audits: false + advanced-security: false + config: .github/zizmor.yml + + 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 + + # Whitespace needs no restore or MSBuild workspace, and the CRLF checkout (.gitattributes) matches .editorconfig. + # Style rules are enforced by the build (EnforceCodeStyleInBuild). + - 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). Fix it with: dotnet format whitespace --folder" + } + + # Always runs, so the Gate never has to accept a skip: the review itself runs on pull requests only. + dependency-review: + name: Dependency review + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + 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 + + - name: Nothing to review + if: github.event_name != 'pull_request' + env: + EVENT: ${{ github.event_name }} + run: Write-Host "::notice title=Dependency review::Dependency review compares a pull request with its base; a $env:EVENT run has nothing to compare." + + 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 + + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + # Windows only: Native AOT lock sections record host-RID ILCompiler packages. A locked restore fails outright + # when a committed packages.lock.json no longer matches its project graph. + - name: Verify lock files + run: | + dotnet restore CheatEngine.Client.slnx --locked-mode + if ($LASTEXITCODE -ne 0) { + throw "Locked restore failed with exit code $LASTEXITCODE. The committed packages.lock.json files do not match the project graph: regenerate the affected ones with 'dotnet restore --force-evaluate' and commit the result." + } + + gate: + name: Gate + # always(): a failed or cancelled job must turn the required check red instead of skipping it. + if: always() + needs: [ build-test, aot, 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. Only sonar has a required result that depends on the event: success when + # SONAR_EXPECTED is true, skipped otherwise (fork, Dependabot, release call). A sonar run that was not + # expected means its condition drifted from SONAR_EXPECTED, which fails too. + $ErrorActionPreference = 'Stop' + $needs = $env:NEEDS | ConvertFrom-Json -AsHashtable + $sonarExpected = $env:SONAR_EXPECTED -eq 'true' + $failed = [Collections.Generic.List[string]]::new() + $rows = foreach ($job in @($needs.Keys | Sort-Object)) { + $result = $needs[$job].result + $required = 'success' + $reason = 'every job must succeed' + if ($job -eq 'sonar') { + if ($sonarExpected) { + $reason = 'SONAR_EXPECTED=true' + if ($result -eq 'skipped' -and $needs['build-test'].result -ne 'success') { + $reason = 'SONAR_EXPECTED=true; skipped because build-test did not succeed' + } + } + else { + $required = 'skipped' + $reason = "SONAR_EXPECTED=false ($env:EVENT run: fork, Dependabot or release call)" + } + } + if ($result -ne $required) { + $failed.Add("$job ($result, required $required)") + } + "| $job | $result | $required | $reason |" + } + @('### Gate', '', '| Job | Result | Required | Reason |', '| --- | --- | --- | --- |') + $rows | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + if ($failed.Count -gt 0) { + Write-Host "::error::Not as required: $($failed -join ', ')." + exit 1 + } diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a5f4c35..a545a56 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: cache: 'false' - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: csharp build-mode: manual @@ -68,13 +68,13 @@ jobs: } - name: Analyze - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: category: /language:csharp - name: Upload binlog if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: binlogs-codeql-csharp path: artifacts/logs/*.binlog @@ -95,13 +95,13 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: actions build-mode: none queries: security-extended - name: Analyze - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: category: /language:actions diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f2b6a4..6f08df9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,988 +1,988 @@ -name: Release - -run-name: Release ${{ github.ref_name }}${{ github.event_name == 'workflow_dispatch' && ' (dry run)' || '' }} - -# verify ─► ci ─► stage ─► attest ─► draft-release ─► publish ─► verify-publication ─► finalize-release -# -# The seven packages that ci builds, tests and uploads as nuget-packages are the exact files that are staged with their -# SBOMs and SHA256SUMS, attested, attached to the draft release and pushed to nuget.org. Nothing is published before a -# draft release carries every asset, and the release itself is published only after nuget.org serves the attested -# packages, so the flow works with immutable releases. Only a tag push of this repository releases: a workflow_dispatch -# run, even one started from a tag, is a dry run that verifies, builds and stages the SBOMs and SHA256SUMS with a -# read-only token, and never attests, drafts or publishes. -# No job restores from or saves to a NuGet cache. RELEASING.md describes the procedure and every check. - -on: - push: - tags: - - "v*.*.*" - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -permissions: - contents: read - -defaults: - run: - shell: pwsh - -# The seven package ids, each declared once, in dependency order: every package comes after the Client packages it -# depends on, the order publish pushes them in. Every list of packages in this workflow is derived from this one. -env: - PACKAGE_IDS: >- - CheatEngine.Client.Abstractions - CheatEngine.Client.Fluent - CheatEngine.Client.Core - CheatEngine.Client.Extensions.DependencyInjection - CheatEngine.Client.Hosting - CheatEngine.Client - CheatEngine.Client.Templates - -jobs: - verify: - name: Verify tag - runs-on: windows-2025 - timeout-minutes: 10 - outputs: - version: ${{ steps.tag.outputs.version }} - prerelease: ${{ steps.tag.outputs.prerelease }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Set up .NET - uses: ./.github/actions/setup-dotnet - with: - cache: 'false' - - # Any event but a push is a dry run with empty outputs. A tag push needs a SemVer tag on the first-parent history - # of main, and none of the seven ids may already have that version on nuget.org (a version can never be - # replaced, and a full re-run after a publication would build different bytes for an immutable version). - - name: Verify tag - id: tag - env: - EVENT_NAME: ${{ github.event_name }} - REF_TYPE: ${{ github.ref_type }} - REF_NAME: ${{ github.ref_name }} - run: | - $ErrorActionPreference = 'Stop' - if ($env:EVENT_NAME -ne 'push') { - Write-Output "::notice::Dry run ($env:EVENT_NAME on $env:REF_TYPE $env:REF_NAME): the run builds and tests, and never attests, drafts a release or publishes." - 'version=', 'prerelease=' | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - exit 0 - } - if ($env:REF_TYPE -ne 'tag') { - throw "A release run started by a push must come from a v*.*.* tag; $env:REF_TYPE $env:REF_NAME is not a tag." - } - - $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 ($env:REF_NAME -cnotmatch $semver) { - throw "Tag $env:REF_NAME is not a release tag. Use v.. with an optional SemVer prerelease such as -rc.1, and no build metadata." - } - $version = $Matches['version'] - - # Only a commit on the first-parent line of main, the merged head, may be released: a squash merge gives the - # pull request new commits, so a tag on a branch commit would release something main never contained. - $commit = git rev-parse --verify "refs/tags/$env:REF_NAME^{commit}" - if ($LASTEXITCODE -ne 0) { - throw "Tag $env:REF_NAME does not resolve to a commit (exit code $LASTEXITCODE)." - } - $landed = @(git rev-list --first-parent refs/remotes/origin/main) - if ($LASTEXITCODE -ne 0) { - throw "The checkout has no refs/remotes/origin/main to compare the tag with (exit code $LASTEXITCODE); check out with fetch-depth 0." - } - if ($landed -notcontains $commit) { - throw "Tag $env:REF_NAME points to $commit, which is not on the first-parent history of main. Tag the merged head of main." - } - - $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) - foreach ($id in $packageIds) { - $uri = "https://api.nuget.org/v3-flatcontainer/$($id.ToLowerInvariant())/index.json" - $index = Invoke-RestMethod -Uri $uri -SkipHttpErrorCheck -StatusCodeVariable status -MaximumRetryCount 3 -RetryIntervalSec 5 - if ($status -eq 200 -and (@($index.versions) -contains $version.ToLowerInvariant())) { - throw "$id $version is already on nuget.org. Re-run only the failed jobs of the original run, or tag a new version." - } - if ($status -ne 200 -and $status -ne 404) { - throw "Reading $uri returned HTTP $status." - } - } - - $prerelease = if ($version.Contains('-')) { 'true' } else { 'false' } - Write-Output "Releasing $version (prerelease: $prerelease) from $commit." - "version=$version", "prerelease=$prerelease" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - # The GitHub release notes are the CHANGELOG.md section of the version: '## [X.Y.Z] - '. A prerelease may - # ship before its entries leave '## [Unreleased]', so it falls back to that section; a stable 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).Replace("`r`n", "`n") - $sections = @($env:VERSION) - if ($env:PRERELEASE -eq 'true') { - $sections += 'Unreleased' - } - - $notes = '' - foreach ($section in $sections) { - $pattern = '(?ms)^## \[' + [regex]::Escape($section) + '\][^\n]*\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) { - throw "CHANGELOG.md has no non-empty section for $($sections -join ' or '). Move the release entries under '## [$env:VERSION] - ' before tagging." - } - - [string[]] $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) - [System.Array]::Sort($packageIds, [System.StringComparer]::Ordinal) - $packages = $packageIds | ForEach-Object { "- [$_ $env:VERSION](https://www.nuget.org/packages/$_/$env:VERSION)" } - - New-Item -ItemType Directory -Path artifacts/release-notes -Force | Out-Null - $text = (@($notes, '', '## Packages', '') + @($packages) + @('', - 'The release assets include SHA256SUMS, the SPDX SBOM of each package and their sigstore attestation bundles. RELEASING.md explains how to verify them.')) -join "`n" - [System.IO.File]::WriteAllText('artifacts/release-notes/release-notes.md', $text + "`n", [System.Text.UTF8Encoding]::new($false)) - Write-Output "Wrote the release notes of $env:VERSION." - - - 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/release-notes.md - if-no-files-found: error - retention-days: 90 - - # Build and test 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 and is never passed here. - ci: - name: CI - needs: verify - uses: ./.github/workflows/ci.yml - with: - package-version: ${{ needs.verify.outputs.version }} - package-retention-days: 90 - - # Every run, dry runs included, with a read-only token and no OIDC token: extracts the SPDX SBOM each package embeds - # and writes SHA256SUMS over the packages, the symbol packages and the SBOMs. A dry run ends here, with those files in - # the release-staging artifact; a release attests exactly the files this job staged. - stage: - name: Stage SBOMs and checksums - needs: [ verify, ci ] - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: read - env: - VERSION: ${{ needs.verify.outputs.version }} - steps: - - name: Download packages - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: nuget-packages - path: artifacts/nuget - - # Every package embeds its SPDX 2.2 document at _manifest/spdx_2.2/manifest.spdx.json. Its exact bytes are - # extracted as ..spdx.json, after checking that it describes that package and version, so attest - # signs (actions/attest sbom-path) and the release carries the document the package holds. - - name: Stage the SBOMs and SHA256SUMS - run: | - $ErrorActionPreference = 'Stop' - Add-Type -AssemblyName System.IO.Compression.FileSystem - $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) - - # A release names its version (verify); a dry run takes the version MinVer gave the packages. - $version = $env:VERSION - if (-not $version) { - $pattern = '^' + [regex]::Escape($packageIds[0]) + '\.(?[0-9][0-9A-Za-z.-]*)\.nupkg$' - $found = @(Get-ChildItem -LiteralPath artifacts/nuget -File | ForEach-Object { [regex]::Match($_.Name, $pattern) } | Where-Object Success) - if ($found.Count -ne 1) { - throw "nuget-packages must hold exactly one $($packageIds[0]) package, found $($found.Count)." - } - $version = $found[0].Groups['version'].Value - } - - # Exactly the seven packages of that version and symbol packages of them. - $packages = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) - foreach ($id in $packageIds) { - [void] $packages.Add("$id.$version.nupkg") - if (-not (Test-Path -LiteralPath "artifacts/nuget/$id.$version.nupkg" -PathType Leaf)) { - throw "nuget-packages has no $id.$version.nupkg." - } - } - foreach ($file in @(Get-ChildItem -LiteralPath artifacts/nuget -File)) { - $symbols = $file.Extension -ceq '.snupkg' -and $packages.Contains("$($file.BaseName).nupkg") - if (-not $packages.Contains($file.Name) -and -not $symbols) { - throw "nuget-packages holds $($file.Name), which is neither a package nor a symbol package of the seven ids at $version." - } - } - - $staging = Join-Path $PWD 'artifacts/staging' - New-Item -ItemType Directory -Path $staging -Force | Out-Null - foreach ($id in $packageIds) { - $archive = [System.IO.Compression.ZipFile]::OpenRead((Join-Path $PWD "artifacts/nuget/$id.$version.nupkg")) - try { - $entry = $archive.GetEntry('_manifest/spdx_2.2/manifest.spdx.json') - if ($null -eq $entry) { - throw "$id.$version.nupkg has no _manifest/spdx_2.2/manifest.spdx.json." - } - $stream = $entry.Open() - try { - $memory = [System.IO.MemoryStream]::new() - $stream.CopyTo($memory) - $bytes = $memory.ToArray() - } - finally { - $stream.Dispose() - } - } - finally { - $archive.Dispose() - } - - $document = [System.Text.Encoding]::UTF8.GetString($bytes).TrimStart([char] 0xFEFF) | ConvertFrom-Json - $root = @($document.packages | Where-Object { $_.SPDXID -eq 'SPDXRef-RootPackage' }) - if ($document.spdxVersion -ne 'SPDX-2.2' -or $root.Count -ne 1 -or $root[0].name -ne $id -or $root[0].versionInfo -ne $version) { - throw "The SBOM inside $id.$version.nupkg does not describe $id $version as SPDX-2.2." - } - [System.IO.File]::WriteAllBytes((Join-Path $staging "$id.$version.spdx.json"), $bytes) - } - - # sha256sum format (' ', sorted ordinally by name, LF). - $assets = [System.Collections.Generic.SortedDictionary[string, string]]::new([System.StringComparer]::Ordinal) - foreach ($file in @(Get-ChildItem -LiteralPath (Join-Path $PWD 'artifacts/nuget'), $staging -File)) { - $assets.Add($file.Name, $file.FullName) - } - $lines = foreach ($name in $assets.Keys) { - '{0} {1}' -f (Get-FileHash -LiteralPath $assets[$name] -Algorithm SHA256).Hash.ToLowerInvariant(), $name - } - [System.IO.File]::WriteAllText((Join-Path $staging 'SHA256SUMS'), (($lines -join "`n") + "`n"), [System.Text.UTF8Encoding]::new($false)) - - $run = if ($env:VERSION) { "Release $version" } else { "Dry run of $version (nothing is attested, drafted or published)" } - $summary = @( - '### Staged SBOMs and checksums', - '', - "$run. The release-staging artifact holds the $($packageIds.Count) SBOMs and SHA256SUMS.", - '', - '| Asset | SHA-256 |', - '| --- | --- |' - ) + @($lines | ForEach-Object { $hash, $name = $_ -split ' ', 2; "| ``$name`` | ``$hash`` |" }) - $summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - - - name: Upload the staged SBOMs and SHA256SUMS - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-staging - path: artifacts/staging - if-no-files-found: error - retention-days: 90 - - # attest, draft-release and publish share one guard: a tag push of this repository that verify accepted. Every later - # job needs one of them, so a dry run skips the whole release path. - attest: - name: Attest packages - needs: [ verify, ci, stage ] - if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.Client' && needs.verify.outputs.version != '' - runs-on: ubuntu-24.04 - timeout-minutes: 15 - permissions: - contents: read - id-token: write # sign the attestations with the workflow identity - attestations: write # store them in the repository - env: - TAG: ${{ github.ref_name }} - VERSION: ${{ needs.verify.outputs.version }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Download packages - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: nuget-packages - path: artifacts/nuget - - - name: Download the staged SBOMs and SHA256SUMS - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: release-staging - path: artifacts/attestations - - # This job signs exactly what stage checked and listed: every package, symbol package and SBOM has the SHA-256 - # of the staged SHA256SUMS, and nothing else is there. The staged SHA256SUMS is then removed; it is written - # again over every release asset once the attestation bundles exist. - - name: Check the staged files - run: | - $ErrorActionPreference = 'Stop' - $sums = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) - foreach ($line in @(Get-Content -LiteralPath artifacts/attestations/SHA256SUMS)) { - if ($line -cnotmatch '^(?[0-9a-f]{64}) (?[^\s/\\]+)$' -or -not $sums.TryAdd($Matches['name'], $Matches['hash'])) { - throw "The staged SHA256SUMS has a malformed or repeated line: '$line'." - } - } - $files = @(Get-ChildItem artifacts/nuget, artifacts/attestations -File | Where-Object Name -cne 'SHA256SUMS') - foreach ($file in $files) { - $actual = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() - if (-not $sums.ContainsKey($file.Name) -or $sums[$file.Name] -cne $actual) { - throw "$($file.Name) has SHA-256 $actual, which the staged SHA256SUMS does not list for it." - } - } - if ($files.Count -ne $sums.Count) { - throw "The staged SHA256SUMS lists $($sums.Count) files, but $($files.Count) are here." - } - foreach ($id in @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ })) { - foreach ($name in "$id.$env:VERSION.nupkg", "$id.$env:VERSION.spdx.json") { - if (-not $sums.ContainsKey($name)) { - throw "stage did not stage $name." - } - } - } - Remove-Item -LiteralPath artifacts/attestations/SHA256SUMS - - # One provenance attestation covers the seven packages and the five symbol packages. - - name: Attest build provenance - id: provenance - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: | - artifacts/nuget/*.nupkg - artifacts/nuget/*.snupkg - - # actions/attest takes one SBOM per call, so the seven SBOM steps below are numbered: SBOM n attests the package - # at position n of PACKAGE_IDS, which therefore must name exactly seven ids. - - name: Map the SBOM attestations to the packages - id: subjects - run: | - $ErrorActionPreference = 'Stop' - $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) - if ($packageIds.Count -ne 7) { - throw "The seven SBOM attestation steps need exactly seven ids in PACKAGE_IDS, found $($packageIds.Count)." - } - $outputs = for ($index = 0; $index -lt $packageIds.Count; $index++) { - "package-$($index + 1)=artifacts/nuget/$($packageIds[$index]).$env:VERSION.nupkg" - "sbom-$($index + 1)=artifacts/attestations/$($packageIds[$index]).$env:VERSION.spdx.json" - } - $outputs | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - - name: Attest SBOM 1 - id: sbom-1 - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: ${{ steps.subjects.outputs.package-1 }} - sbom-path: ${{ steps.subjects.outputs.sbom-1 }} - - - name: Attest SBOM 2 - id: sbom-2 - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: ${{ steps.subjects.outputs.package-2 }} - sbom-path: ${{ steps.subjects.outputs.sbom-2 }} - - - name: Attest SBOM 3 - id: sbom-3 - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: ${{ steps.subjects.outputs.package-3 }} - sbom-path: ${{ steps.subjects.outputs.sbom-3 }} - - - name: Attest SBOM 4 - id: sbom-4 - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: ${{ steps.subjects.outputs.package-4 }} - sbom-path: ${{ steps.subjects.outputs.sbom-4 }} - - - name: Attest SBOM 5 - id: sbom-5 - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: ${{ steps.subjects.outputs.package-5 }} - sbom-path: ${{ steps.subjects.outputs.sbom-5 }} - - - name: Attest SBOM 6 - id: sbom-6 - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: ${{ steps.subjects.outputs.package-6 }} - sbom-path: ${{ steps.subjects.outputs.sbom-6 }} - - - name: Attest SBOM 7 - id: sbom-7 - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: ${{ steps.subjects.outputs.package-7 }} - sbom-path: ${{ steps.subjects.outputs.sbom-7 }} - - # The bundles become release assets, named after what they attest. - - name: Collect the attestation bundles - env: - PROVENANCE: ${{ steps.provenance.outputs.bundle-path }} - SBOM_1: ${{ steps.sbom-1.outputs.bundle-path }} - SBOM_2: ${{ steps.sbom-2.outputs.bundle-path }} - SBOM_3: ${{ steps.sbom-3.outputs.bundle-path }} - SBOM_4: ${{ steps.sbom-4.outputs.bundle-path }} - SBOM_5: ${{ steps.sbom-5.outputs.bundle-path }} - SBOM_6: ${{ steps.sbom-6.outputs.bundle-path }} - SBOM_7: ${{ steps.sbom-7.outputs.bundle-path }} - run: | - $ErrorActionPreference = 'Stop' - $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) - $bundles = [ordered]@{ "CheatEngine.Client.$env:VERSION.provenance.sigstore.json" = $env:PROVENANCE } - for ($index = 0; $index -lt $packageIds.Count; $index++) { - $bundles["$($packageIds[$index]).$env:VERSION.sbom.sigstore.json"] = [Environment]::GetEnvironmentVariable("SBOM_$($index + 1)") - } - foreach ($name in $bundles.Keys) { - if (-not $bundles[$name] -or -not (Test-Path -LiteralPath $bundles[$name] -PathType Leaf)) { - throw "The attestation bundle for $name was not produced." - } - Copy-Item -LiteralPath $bundles[$name] -Destination (Join-Path artifacts/attestations $name) - } - - # The attestations are checked where they are made, before anything is drafted or pushed: each subject against - # its bundle and against the attestations stored in the repository, with the identity that RELEASING.md gives - # consumers (this repository, the release.yml signer workflow, the tag and a hosted runner). - - name: Verify the attestations - env: - GH_TOKEN: ${{ github.token }} - run: | - $ErrorActionPreference = 'Stop' - $identity = @( - '--repo', 'CheatEngineNet/CheatEngine.Client', - '--signer-workflow', 'CheatEngineNet/CheatEngine.Client/.github/workflows/release.yml', - '--source-ref', "refs/tags/$env:TAG", - '--deny-self-hosted-runners' - ) - $provenance = "artifacts/attestations/CheatEngine.Client.$env:VERSION.provenance.sigstore.json" - foreach ($subject in @(Get-ChildItem artifacts/nuget -File | Sort-Object Name)) { - gh attestation verify $subject.FullName @identity --predicate-type 'https://slsa.dev/provenance/v1' --bundle $provenance - if ($LASTEXITCODE -ne 0) { - throw "The build provenance of $($subject.Name) did not verify against its bundle (exit code $LASTEXITCODE)." - } - gh attestation verify $subject.FullName @identity --predicate-type 'https://slsa.dev/provenance/v1' - if ($LASTEXITCODE -ne 0) { - throw "The build provenance of $($subject.Name) did not verify in the repository (exit code $LASTEXITCODE)." - } - } - foreach ($id in @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ })) { - $package = "artifacts/nuget/$id.$env:VERSION.nupkg" - $sbom = "artifacts/attestations/$id.$env:VERSION.sbom.sigstore.json" - gh attestation verify $package @identity --predicate-type 'https://spdx.dev/Document/v2.2' --bundle $sbom - if ($LASTEXITCODE -ne 0) { - throw "The SBOM attestation of $id.$env:VERSION.nupkg did not verify against its bundle (exit code $LASTEXITCODE)." - } - gh attestation verify $package @identity --predicate-type 'https://spdx.dev/Document/v2.2' - if ($LASTEXITCODE -ne 0) { - throw "The SBOM attestation of $id.$env:VERSION.nupkg did not verify in the repository (exit code $LASTEXITCODE)." - } - } - - # sha256sum format (' ', sorted ordinally by name, LF) over every release asset: the packages, - # their symbol packages, the SBOMs and the attestation bundles. It travels with the bundles, so draft-release - # attaches it and publish checks every package against it before anything is pushed. - - name: Write SHA256SUMS - run: | - $ErrorActionPreference = 'Stop' - $assets = [System.Collections.Generic.SortedDictionary[string, string]]::new([System.StringComparer]::Ordinal) - foreach ($file in @(Get-ChildItem artifacts/nuget, artifacts/attestations -File)) { - $assets.Add($file.Name, $file.FullName) - } - $lines = foreach ($name in $assets.Keys) { - '{0} {1}' -f (Get-FileHash -LiteralPath $assets[$name] -Algorithm SHA256).Hash.ToLowerInvariant(), $name - } - [System.IO.File]::WriteAllText((Join-Path $PWD 'artifacts/attestations/SHA256SUMS'), (($lines -join "`n") + "`n"), [System.Text.UTF8Encoding]::new($false)) - Write-Output "Wrote SHA256SUMS ($($assets.Count) assets)." - - - name: Upload the attestation bundles, SBOMs and SHA256SUMS - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: attestation-bundles - path: artifacts/attestations - if-no-files-found: error - retention-days: 90 - - draft-release: - name: Draft GitHub release - needs: [ verify, ci, attest ] - if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.Client' && needs.verify.outputs.version != '' - runs-on: ubuntu-24.04 - timeout-minutes: 20 - permissions: - contents: write # create the draft release and upload its assets - # GH_TOKEN (contents: write) is set only on the steps that call gh. - env: - GH_REPO: ${{ github.repository }} - TAG: ${{ github.ref_name }} - PRERELEASE: ${{ needs.verify.outputs.prerelease }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Download packages - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: nuget-packages - path: artifacts/release - - - name: Download the attestation bundles, SBOMs and SHA256SUMS - 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/release-notes - - # Immutable releases lock a release's tag and assets once it is published, so the release is always created as a - # draft, filled completely, and only published by finalize-release after nuget.org serves the packages. Assets - # are never patched onto an existing release (that path breaks immutable releases): every draft already on the - # tag (left by an interrupted run or an earlier build; GitHub allows several) is deleted first, and the draft is - # created again from this run's files. gh release delete resolves a draft by its tag, so no release id is ever - # needed (the id of gh release view is a GraphQL node id that the REST API rejects), and it keeps the git tag. - # Nothing of a draft is public. A published release never receives assets: the job fails before anything is - # created. - - name: Create the draft release - env: - GH_TOKEN: ${{ github.token }} - run: | - $ErrorActionPreference = 'Stop' - $files = @(Get-ChildItem artifacts/release -File | ForEach-Object FullName) - # The list includes drafts for this token (contents: write). - $list = gh release list --limit 1000 --json tagName,isDraft,createdAt - if ($LASTEXITCODE -ne 0) { - throw "Listing the releases of $env:GH_REPO failed with exit code $LASTEXITCODE." - } - $onTag = @(($list -join "`n") | ConvertFrom-Json | Where-Object { $_.tagName -ceq $env:TAG }) - if (@($onTag | Where-Object { -not $_.isDraft }).Count -gt 0) { - throw "Release $env:TAG is already published, and a published release never receives assets. Tag a new version." - } - - foreach ($draft in $onTag) { - Write-Output "::warning::A draft release of $env:TAG (created $($draft.createdAt)) already exists; it is deleted and created again from this run's files. The tag stays." - gh release delete $env:TAG --yes - if ($LASTEXITCODE -ne 0) { - throw "Deleting the draft release $env:TAG failed with exit code $LASTEXITCODE." - } - } - - $createOptions = @('--repo', $env:GH_REPO, '--draft', '--verify-tag', '--title', $env:TAG, '--notes-file', 'artifacts/release-notes/release-notes.md') - if ($env:PRERELEASE -eq 'true') { - $createOptions += '--prerelease' - } - gh release create $env:TAG @files @createOptions - if ($LASTEXITCODE -ne 0) { - throw "Creating the draft release $env:TAG failed with exit code $LASTEXITCODE." - } - Write-Output "Created the draft release $env:TAG with $($files.Count) assets." - - # 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, ci, draft-release ] - if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.Client' && needs.verify.outputs.version != '' - runs-on: windows-2025 - timeout-minutes: 20 - environment: - name: nuget # required reviewers approve the publication; the environment only admits v*.*.* tags - url: https://www.nuget.org/packages/CheatEngine.Client/${{ needs.verify.outputs.version }} - permissions: - contents: read - id-token: write # exchange the OIDC token for a short-lived NuGet API key - env: - VERSION: ${{ needs.verify.outputs.version }} - steps: - # Only the .NET setup action and global.json: this job runs no repository script next to its credentials. - - name: Checkout the .NET setup - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - sparse-checkout: | - .github/actions - global.json - sparse-checkout-cone-mode: false - - - name: Set up .NET - uses: ./.github/actions/setup-dotnet - with: - cache: 'false' - - - name: Download packages - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: nuget-packages - path: artifacts/nuget - - - name: Download SHA256SUMS - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: attestation-bundles - path: artifacts/release-assets - - # Before any credential exists: every package and symbol package this job pushes has the SHA-256 that the - # SHA256SUMS of the draft release lists for it, and every package SHA256SUMS lists is here to be pushed. - - name: Check the packages against SHA256SUMS - run: | - $ErrorActionPreference = 'Stop' - $sums = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) - foreach ($line in @(Get-Content -LiteralPath artifacts/release-assets/SHA256SUMS)) { - if ($line -cnotmatch '^(?[0-9a-f]{64}) (?[^\s/\\]+)$' -or -not $sums.TryAdd($Matches['name'], $Matches['hash'])) { - throw "SHA256SUMS has a malformed or repeated line: '$line'." - } - } - - $packages = @(Get-ChildItem artifacts/nuget -File) - foreach ($package in $packages) { - if ($package.Extension -cnotin '.nupkg', '.snupkg') { - throw "The nuget-packages artifact holds $($package.Name), which is neither a .nupkg nor a .snupkg." - } - $actual = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() - if (-not $sums.ContainsKey($package.Name) -or $sums[$package.Name] -cne $actual) { - throw "$($package.Name) has SHA-256 $actual, which SHA256SUMS does not list for it." - } - Write-Output "$($package.Name): $actual" - } - - $pushed = @($packages | ForEach-Object Name) - $missing = @($sums.Keys | Where-Object { ($_.EndsWith('.nupkg', [System.StringComparison]::Ordinal) -or $_.EndsWith('.snupkg', [System.StringComparison]::Ordinal)) -and $pushed -cnotcontains $_ }) - if ($missing.Count -gt 0) { - throw "SHA256SUMS lists packages that the nuget-packages artifact does not hold: $($missing -join ', ')." - } - - # The user is the nuget.org profile name that created the trusted publishing policy, not an e-mail address. The - # key lasts one hour, so the login comes right before the pushes. - - name: NuGet login - id: login - uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 - with: - user: ${{ secrets.NUGET_USER }} - - # Dependency order: every package is pushed after the Client packages it depends on. --no-symbols keeps the - # symbol packages for the next step, so all seven packages are live before the first symbol package is pushed. - # --skip-duplicate lets a re-run of this job finish a partial publication. - - name: Push the seven packages - env: - NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} - run: | - $ErrorActionPreference = 'Stop' - foreach ($id in @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ })) { - $package = "artifacts/nuget/$id.$env:VERSION.nupkg" - if (-not (Test-Path -LiteralPath $package -PathType Leaf)) { - throw "$package is missing from the nuget-packages artifact." - } - dotnet nuget push $package --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --no-symbols --skip-duplicate - if ($LASTEXITCODE -ne 0) { - throw "Pushing $id $env:VERSION failed with exit code $LASTEXITCODE." - } - } - - # Recovery: when this step fails, the seven packages are already live, and a version on nuget.org can never be - # replaced. Re-run the failed publish job: the package pushes above are skipped as duplicates, and only the symbol - # packages are pushed again, with --skip-duplicate for those nuget.org already accepted. - - name: Push the symbol packages - env: - NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} - run: | - $ErrorActionPreference = 'Stop' - $symbols = @(Get-ChildItem artifacts/nuget -File -Filter '*.snupkg' | Sort-Object Name) - foreach ($symbol in $symbols) { - dotnet nuget push $symbol.FullName --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate - if ($LASTEXITCODE -ne 0) { - throw "Pushing the symbol package $($symbol.Name) failed with exit code $LASTEXITCODE." - } - } - Write-Output "Pushed $($symbols.Count) symbol packages." - - verify-publication: - name: Verify publication - needs: [ verify, publish ] - runs-on: windows-2025 - timeout-minutes: 60 - env: - VERSION: ${{ needs.verify.outputs.version }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Set up .NET - uses: ./.github/actions/setup-dotnet - with: - cache: 'false' - - - name: Download packages - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: nuget-packages - path: artifacts/nuget - - # Resolves PackageBaseAddress/3.0.0 from the nuget.org service index, never a hard-coded flat container URL. For - # each of the seven packages, polls the flat container until it lists the version (validation and indexing take - # minutes), downloads the served file, and fails unless 'dotnet nuget verify --all' accepts it with the nuget.org - # repository signature (type Repository, service index https://api.nuget.org/v3/index.json), the content hash it - # prints equals the SHA-512 of the attested, unsigned package (the value consumers' lock files record), and the - # served file holds exactly the attested entries, byte for byte, plus .signature.p7s. - - name: Verify the published packages - run: | - $ErrorActionPreference = 'Stop' - Add-Type -AssemblyName System.IO.Compression.FileSystem - # The checks read the English output of dotnet nuget verify, whatever the runner locale. - $env:DOTNET_CLI_UI_LANGUAGE = 'en' - $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) - $deadline = [DateTime]::UtcNow.AddMinutes(45) - $lowerVersion = $env:VERSION.ToLowerInvariant() - $work = Join-Path ([System.IO.Path]::GetTempPath()) ('published-' + [guid]::NewGuid().ToString('N')) - New-Item -ItemType Directory -Path $work | Out-Null - - $serviceIndex = Invoke-RestMethod -Uri 'https://api.nuget.org/v3/index.json' -MaximumRetryCount 5 -RetryIntervalSec 10 - $resource = @($serviceIndex.resources | Where-Object { $_.'@type' -ceq 'PackageBaseAddress/3.0.0' }) | Select-Object -First 1 - if ($null -eq $resource) { - throw "The nuget.org service index has no PackageBaseAddress/3.0.0 resource." - } - $base = ([string] $resource.'@id').TrimEnd('/') + '/' - Write-Output "nuget.org PackageBaseAddress: $base" - - function Get-EntryHash { - param([string] $Path) - $hashes = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) - $archive = [System.IO.Compression.ZipFile]::OpenRead($Path) - try { - foreach ($entry in $archive.Entries) { - if ($entry.FullName.EndsWith('/')) { - continue - } - $stream = $entry.Open() - try { - $hashes.Add($entry.FullName, [Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($stream))) - } - finally { - $stream.Dispose() - } - } - } - finally { - $archive.Dispose() - } - return $hashes - } - - try { - foreach ($id in $packageIds) { - $attested = Join-Path $PWD "artifacts/nuget/$id.$env:VERSION.nupkg" - if (-not (Test-Path -LiteralPath $attested -PathType Leaf)) { - throw "The attested package $id.$env:VERSION.nupkg is missing from artifacts/nuget." - } - - $lowerId = $id.ToLowerInvariant() - while ($true) { - $index = Invoke-RestMethod -Uri "$base$lowerId/index.json" -SkipHttpErrorCheck -StatusCodeVariable status -MaximumRetryCount 3 -RetryIntervalSec 5 - if ($status -eq 200 -and (@($index.versions) -contains $lowerVersion)) { - break - } - if ([DateTime]::UtcNow -gt $deadline) { - throw "nuget.org does not list $id $env:VERSION after 45 minutes (last HTTP status $status)." - } - Write-Output "Waiting for nuget.org to list $id $env:VERSION (HTTP $status)." - Start-Sleep -Seconds 30 - } - - $signed = Join-Path $work "$lowerId.$lowerVersion.nupkg" - Invoke-WebRequest -Uri "$base$lowerId/$lowerVersion/$lowerId.$lowerVersion.nupkg" -OutFile $signed -MaximumRetryCount 3 -RetryIntervalSec 5 - $verification = (@(dotnet nuget verify --all $signed -v n 2>&1) | ForEach-Object { "$_" }) -join "`n" - if ($LASTEXITCODE -ne 0) { - throw "dotnet nuget verify --all failed for $id $env:VERSION with exit code ${LASTEXITCODE}:`n$verification" - } - if ($verification -cnotmatch '(?m)^Signature type: Repository\s*$' -or $verification -cnotmatch '(?m)^Service index: https://api\.nuget\.org/v3/index\.json\s*$') { - throw "dotnet nuget verify did not report the nuget.org repository signature of $id $env:VERSION. Its output was:`n$verification" - } - $expected = [Convert]::ToBase64String([System.Security.Cryptography.SHA512]::HashData([System.IO.File]::ReadAllBytes($attested))) - if ($verification -cnotmatch '(?m)^Content hash:\s*(?\S+)\s*$' -or $Matches['hash'] -cne $expected) { - throw "$id $env:VERSION on nuget.org does not have the content hash of the attested package ($expected)." - } - - $attestedEntries = Get-EntryHash -Path $attested - $signedEntries = Get-EntryHash -Path $signed - if ($attestedEntries.ContainsKey('.signature.p7s') -or -not $signedEntries.Remove('.signature.p7s')) { - throw "$id $env:VERSION on nuget.org has no .signature.p7s entry, or the attested package already had one." - } - $names = [System.Collections.Generic.HashSet[string]]::new([string[]] @($attestedEntries.Keys), [System.StringComparer]::Ordinal) - $names.UnionWith([string[]] @($signedEntries.Keys)) - $differences = @($names | Where-Object { -not $attestedEntries.ContainsKey($_) -or -not $signedEntries.ContainsKey($_) -or $attestedEntries[$_] -cne $signedEntries[$_] }) - if ($differences.Count -gt 0) { - throw "$id $env:VERSION on nuget.org differs from the attested package in: $($differences -join ', ')." - } - - Write-Output "nuget.org serves the attested $id $env:VERSION with its repository signature." - } - } - finally { - Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue - } - - finalize-release: - name: Finalize GitHub release - needs: [ verify, draft-release, verify-publication ] - runs-on: ubuntu-24.04 - timeout-minutes: 20 - permissions: - contents: write # publish the draft release - attestations: read # verify the attestations - # GH_TOKEN (contents: write) is set only on the steps that call gh. - env: - GH_REPO: ${{ github.repository }} - TAG: ${{ github.ref_name }} - VERSION: ${{ needs.verify.outputs.version }} - # attest verified the attestations before anything became public; this job verifies what consumers download. - steps: - # gh release view and gh release edit find a draft by its tag, which the REST lookup by tag never returns (it - # serves published releases only). A release that is already published, on a re-run, is verified again but never - # edited. - - name: Read the release state - id: state - env: - GH_TOKEN: ${{ github.token }} - run: | - $ErrorActionPreference = 'Stop' - $view = gh release view $env:TAG --json isDraft,isImmutable - if ($LASTEXITCODE -ne 0) { - throw "Reading the release $env:TAG failed with exit code $LASTEXITCODE." - } - $state = ($view -join "`n") | ConvertFrom-Json - $draft = ([bool] $state.isDraft).ToString().ToLowerInvariant() - Write-Output "Release $env:TAG is a draft: $draft; immutable: $(([bool] $state.isImmutable).ToString().ToLowerInvariant())." - "draft=$draft" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - - name: Publish the release - if: steps.state.outputs.draft == 'true' - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release edit $env:TAG --draft=false - if ($LASTEXITCODE -ne 0) { - throw "Publishing the release $env:TAG failed with exit code $LASTEXITCODE." - } - - # Verifies what consumers download, not the local artifacts: every asset against SHA256SUMS, the release - # attestation when the release is immutable (immutable releases are a repository setting, RELEASING.md), and the - # provenance and SBOM attestations with the identity that attest checked. - - name: Verify the published release - env: - GH_TOKEN: ${{ github.token }} - run: | - $ErrorActionPreference = 'Stop' - $view = gh release view $env:TAG --json isDraft,isImmutable - if ($LASTEXITCODE -ne 0) { - throw "Reading the release $env:TAG failed with exit code $LASTEXITCODE." - } - $state = ($view -join "`n") | 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 - if ($LASTEXITCODE -ne 0) { - throw "Downloading the assets of $env:TAG failed with exit code $LASTEXITCODE." - } - - # Every line of SHA256SUMS names an asset with that SHA-256, and every other asset has a line. - $sumsPath = Join-Path $published 'SHA256SUMS' - if (-not (Test-Path -LiteralPath $sumsPath -PathType Leaf)) { - throw "Release $env:TAG has no SHA256SUMS asset." - } - $sums = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) - foreach ($line in [System.IO.File]::ReadAllLines($sumsPath)) { - if ($line -cnotmatch '^(?[0-9a-f]{64}) (?[^\s/\\]+)$' -or -not $sums.TryAdd($Matches['name'], $Matches['hash'])) { - throw "SHA256SUMS of $env:TAG has a malformed or repeated line: '$line'." - } - } - foreach ($asset in @(Get-ChildItem -LiteralPath $published -File | Where-Object Name -cne 'SHA256SUMS')) { - if (-not $sums.ContainsKey($asset.Name)) { - throw "Release $env:TAG has the asset $($asset.Name), which SHA256SUMS does not list." - } - } - [string[]] $names = @($sums.Keys) - [System.Array]::Sort($names, [System.StringComparer]::Ordinal) - foreach ($name in $names) { - $path = Join-Path $published $name - if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { - throw "SHA256SUMS lists $name, which release $env:TAG does not carry." - } - $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -cne $sums[$name]) { - throw "The published $name has SHA-256 $actual; SHA256SUMS lists $($sums[$name])." - } - } - $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) - $packages = @($packageIds | ForEach-Object { "$_.$env:VERSION.nupkg" }) - $symbols = @($names | Where-Object { $_.EndsWith('.snupkg', [System.StringComparison]::Ordinal) }) - $expected = @($packageIds | ForEach-Object { "$_.$env:VERSION.nupkg", "$_.$env:VERSION.spdx.json", "$_.$env:VERSION.sbom.sigstore.json" }) + - "CheatEngine.Client.$env:VERSION.provenance.sigstore.json" - $missing = @($expected | Where-Object { -not $sums.ContainsKey($_) }) - if ($missing.Count -gt 0) { - throw "Release $env:TAG lacks the assets $($missing -join ', ')." - } - - # Only an immutable release has a release attestation. SHA256SUMS, checked above, ties every other asset to it. - if ($state.isImmutable) { - gh release verify $env:TAG - if ($LASTEXITCODE -ne 0) { - throw "gh release verify $env:TAG failed with exit code $LASTEXITCODE." - } - foreach ($name in @($packages) + @($symbols) + 'SHA256SUMS') { - gh release verify-asset $env:TAG (Join-Path $published $name) - if ($LASTEXITCODE -ne 0) { - throw "gh release verify-asset $env:TAG $name failed with exit code $LASTEXITCODE." - } - } - } - else { - Write-Output "::warning::Immutable releases are not enabled for $env:GH_REPO, so release $env:TAG has no release attestation to verify. Enable them (RELEASING.md)." - } - - $identity = @( - '--repo', 'CheatEngineNet/CheatEngine.Client', - '--signer-workflow', 'CheatEngineNet/CheatEngine.Client/.github/workflows/release.yml', - '--source-ref', "refs/tags/$env:TAG", - '--deny-self-hosted-runners' - ) - foreach ($name in @($packages) + @($symbols)) { - gh attestation verify (Join-Path $published $name) @identity --predicate-type 'https://slsa.dev/provenance/v1' - if ($LASTEXITCODE -ne 0) { - throw "The build provenance attestation of the published $name did not verify (exit code $LASTEXITCODE)." - } - } - foreach ($name in $packages) { - gh attestation verify (Join-Path $published $name) @identity --predicate-type 'https://spdx.dev/Document/v2.2' - if ($LASTEXITCODE -ne 0) { - throw "The SBOM attestation of the published $name did not verify (exit code $LASTEXITCODE)." - } - } - - $summary = @( - "### Release $env:TAG", - '', - "Published; immutable: $(([bool] $state.isImmutable).ToString().ToLowerInvariant()). Every asset matches SHA256SUMS; the provenance of each package and symbol package and the SBOM attestation of each package verify with the release.yml identity.", - '', - '| Asset | SHA-256 |', - '| --- | --- |' - ) + @($names | ForEach-Object { "| ``$_`` | ``$($sums[$_])`` |" }) - $summary | 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 ─► stage ─► attest ─► draft-release ─► publish ─► verify-publication ─► finalize-release +# +# The seven packages that ci builds, tests and uploads as nuget-packages are the exact files that are staged with their +# SBOMs and SHA256SUMS, attested, attached to the draft release and pushed to nuget.org. Nothing is published before a +# draft release carries every asset, and the release itself is published only after nuget.org serves the attested +# packages, so the flow works with immutable releases. Only a tag push of this repository releases: a workflow_dispatch +# run, even one started from a tag, is a dry run that verifies, builds and stages the SBOMs and SHA256SUMS with a +# read-only token, and never attests, drafts or publishes. +# No job restores from or saves to a NuGet cache. RELEASING.md describes the procedure and every check. + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +defaults: + run: + shell: pwsh + +# The seven package ids, each declared once, in dependency order: every package comes after the Client packages it +# depends on, the order publish pushes them in. Every list of packages in this workflow is derived from this one. +env: + PACKAGE_IDS: >- + CheatEngine.Client.Abstractions + CheatEngine.Client.Fluent + CheatEngine.Client.Core + CheatEngine.Client.Extensions.DependencyInjection + CheatEngine.Client.Hosting + CheatEngine.Client + CheatEngine.Client.Templates + +jobs: + verify: + name: Verify tag + runs-on: windows-2025 + timeout-minutes: 10 + outputs: + version: ${{ steps.tag.outputs.version }} + prerelease: ${{ steps.tag.outputs.prerelease }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up .NET + uses: ./.github/actions/setup-dotnet + with: + cache: 'false' + + # Any event but a push is a dry run with empty outputs. A tag push needs a SemVer tag on the first-parent history + # of main, and none of the seven ids may already have that version on nuget.org (a version can never be + # replaced, and a full re-run after a publication would build different bytes for an immutable version). + - name: Verify tag + id: tag + env: + EVENT_NAME: ${{ github.event_name }} + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:EVENT_NAME -ne 'push') { + Write-Output "::notice::Dry run ($env:EVENT_NAME on $env:REF_TYPE $env:REF_NAME): the run builds and tests, and never attests, drafts a release or publishes." + 'version=', 'prerelease=' | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + exit 0 + } + if ($env:REF_TYPE -ne 'tag') { + throw "A release run started by a push must come from a v*.*.* tag; $env:REF_TYPE $env:REF_NAME is not a tag." + } + + $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 ($env:REF_NAME -cnotmatch $semver) { + throw "Tag $env:REF_NAME is not a release tag. Use v.. with an optional SemVer prerelease such as -rc.1, and no build metadata." + } + $version = $Matches['version'] + + # Only a commit on the first-parent line of main, the merged head, may be released: a squash merge gives the + # pull request new commits, so a tag on a branch commit would release something main never contained. + $commit = git rev-parse --verify "refs/tags/$env:REF_NAME^{commit}" + if ($LASTEXITCODE -ne 0) { + throw "Tag $env:REF_NAME does not resolve to a commit (exit code $LASTEXITCODE)." + } + $landed = @(git rev-list --first-parent refs/remotes/origin/main) + if ($LASTEXITCODE -ne 0) { + throw "The checkout has no refs/remotes/origin/main to compare the tag with (exit code $LASTEXITCODE); check out with fetch-depth 0." + } + if ($landed -notcontains $commit) { + throw "Tag $env:REF_NAME points to $commit, which is not on the first-parent history of main. Tag the merged head of main." + } + + $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) + foreach ($id in $packageIds) { + $uri = "https://api.nuget.org/v3-flatcontainer/$($id.ToLowerInvariant())/index.json" + $index = Invoke-RestMethod -Uri $uri -SkipHttpErrorCheck -StatusCodeVariable status -MaximumRetryCount 3 -RetryIntervalSec 5 + if ($status -eq 200 -and (@($index.versions) -contains $version.ToLowerInvariant())) { + throw "$id $version is already on nuget.org. Re-run only the failed jobs of the original run, or tag a new version." + } + if ($status -ne 200 -and $status -ne 404) { + throw "Reading $uri returned HTTP $status." + } + } + + $prerelease = if ($version.Contains('-')) { 'true' } else { 'false' } + Write-Output "Releasing $version (prerelease: $prerelease) from $commit." + "version=$version", "prerelease=$prerelease" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + # The GitHub release notes are the CHANGELOG.md section of the version: '## [X.Y.Z] - '. A prerelease may + # ship before its entries leave '## [Unreleased]', so it falls back to that section; a stable 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).Replace("`r`n", "`n") + $sections = @($env:VERSION) + if ($env:PRERELEASE -eq 'true') { + $sections += 'Unreleased' + } + + $notes = '' + foreach ($section in $sections) { + $pattern = '(?ms)^## \[' + [regex]::Escape($section) + '\][^\n]*\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) { + throw "CHANGELOG.md has no non-empty section for $($sections -join ' or '). Move the release entries under '## [$env:VERSION] - ' before tagging." + } + + [string[]] $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) + [System.Array]::Sort($packageIds, [System.StringComparer]::Ordinal) + $packages = $packageIds | ForEach-Object { "- [$_ $env:VERSION](https://www.nuget.org/packages/$_/$env:VERSION)" } + + New-Item -ItemType Directory -Path artifacts/release-notes -Force | Out-Null + $text = (@($notes, '', '## Packages', '') + @($packages) + @('', + 'The release assets include SHA256SUMS, the SPDX SBOM of each package and their sigstore attestation bundles. RELEASING.md explains how to verify them.')) -join "`n" + [System.IO.File]::WriteAllText('artifacts/release-notes/release-notes.md', $text + "`n", [System.Text.UTF8Encoding]::new($false)) + Write-Output "Wrote the release notes of $env:VERSION." + + - 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/release-notes.md + if-no-files-found: error + retention-days: 90 + + # Build and test 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 and is never passed here. + ci: + name: CI + needs: verify + uses: ./.github/workflows/ci.yml + with: + package-version: ${{ needs.verify.outputs.version }} + package-retention-days: 90 + + # Every run, dry runs included, with a read-only token and no OIDC token: extracts the SPDX SBOM each package embeds + # and writes SHA256SUMS over the packages, the symbol packages and the SBOMs. A dry run ends here, with those files in + # the release-staging artifact; a release attests exactly the files this job staged. + stage: + name: Stage SBOMs and checksums + needs: [ verify, ci ] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + env: + VERSION: ${{ needs.verify.outputs.version }} + steps: + - name: Download packages + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: nuget-packages + path: artifacts/nuget + + # Every package embeds its SPDX 2.2 document at _manifest/spdx_2.2/manifest.spdx.json. Its exact bytes are + # extracted as ..spdx.json, after checking that it describes that package and version, so attest + # signs (actions/attest sbom-path) and the release carries the document the package holds. + - name: Stage the SBOMs and SHA256SUMS + run: | + $ErrorActionPreference = 'Stop' + Add-Type -AssemblyName System.IO.Compression.FileSystem + $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) + + # A release names its version (verify); a dry run takes the version MinVer gave the packages. + $version = $env:VERSION + if (-not $version) { + $pattern = '^' + [regex]::Escape($packageIds[0]) + '\.(?[0-9][0-9A-Za-z.-]*)\.nupkg$' + $found = @(Get-ChildItem -LiteralPath artifacts/nuget -File | ForEach-Object { [regex]::Match($_.Name, $pattern) } | Where-Object Success) + if ($found.Count -ne 1) { + throw "nuget-packages must hold exactly one $($packageIds[0]) package, found $($found.Count)." + } + $version = $found[0].Groups['version'].Value + } + + # Exactly the seven packages of that version and symbol packages of them. + $packages = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($id in $packageIds) { + [void] $packages.Add("$id.$version.nupkg") + if (-not (Test-Path -LiteralPath "artifacts/nuget/$id.$version.nupkg" -PathType Leaf)) { + throw "nuget-packages has no $id.$version.nupkg." + } + } + foreach ($file in @(Get-ChildItem -LiteralPath artifacts/nuget -File)) { + $symbols = $file.Extension -ceq '.snupkg' -and $packages.Contains("$($file.BaseName).nupkg") + if (-not $packages.Contains($file.Name) -and -not $symbols) { + throw "nuget-packages holds $($file.Name), which is neither a package nor a symbol package of the seven ids at $version." + } + } + + $staging = Join-Path $PWD 'artifacts/staging' + New-Item -ItemType Directory -Path $staging -Force | Out-Null + foreach ($id in $packageIds) { + $archive = [System.IO.Compression.ZipFile]::OpenRead((Join-Path $PWD "artifacts/nuget/$id.$version.nupkg")) + try { + $entry = $archive.GetEntry('_manifest/spdx_2.2/manifest.spdx.json') + if ($null -eq $entry) { + throw "$id.$version.nupkg has no _manifest/spdx_2.2/manifest.spdx.json." + } + $stream = $entry.Open() + try { + $memory = [System.IO.MemoryStream]::new() + $stream.CopyTo($memory) + $bytes = $memory.ToArray() + } + finally { + $stream.Dispose() + } + } + finally { + $archive.Dispose() + } + + $document = [System.Text.Encoding]::UTF8.GetString($bytes).TrimStart([char] 0xFEFF) | ConvertFrom-Json + $root = @($document.packages | Where-Object { $_.SPDXID -eq 'SPDXRef-RootPackage' }) + if ($document.spdxVersion -ne 'SPDX-2.2' -or $root.Count -ne 1 -or $root[0].name -ne $id -or $root[0].versionInfo -ne $version) { + throw "The SBOM inside $id.$version.nupkg does not describe $id $version as SPDX-2.2." + } + [System.IO.File]::WriteAllBytes((Join-Path $staging "$id.$version.spdx.json"), $bytes) + } + + # sha256sum format (' ', sorted ordinally by name, LF). + $assets = [System.Collections.Generic.SortedDictionary[string, string]]::new([System.StringComparer]::Ordinal) + foreach ($file in @(Get-ChildItem -LiteralPath (Join-Path $PWD 'artifacts/nuget'), $staging -File)) { + $assets.Add($file.Name, $file.FullName) + } + $lines = foreach ($name in $assets.Keys) { + '{0} {1}' -f (Get-FileHash -LiteralPath $assets[$name] -Algorithm SHA256).Hash.ToLowerInvariant(), $name + } + [System.IO.File]::WriteAllText((Join-Path $staging 'SHA256SUMS'), (($lines -join "`n") + "`n"), [System.Text.UTF8Encoding]::new($false)) + + $run = if ($env:VERSION) { "Release $version" } else { "Dry run of $version (nothing is attested, drafted or published)" } + $summary = @( + '### Staged SBOMs and checksums', + '', + "$run. The release-staging artifact holds the $($packageIds.Count) SBOMs and SHA256SUMS.", + '', + '| Asset | SHA-256 |', + '| --- | --- |' + ) + @($lines | ForEach-Object { $hash, $name = $_ -split ' ', 2; "| ``$name`` | ``$hash`` |" }) + $summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + + - name: Upload the staged SBOMs and SHA256SUMS + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-staging + path: artifacts/staging + if-no-files-found: error + retention-days: 90 + + # attest, draft-release and publish share one guard: a tag push of this repository that verify accepted. Every later + # job needs one of them, so a dry run skips the whole release path. + attest: + name: Attest packages + needs: [ verify, ci, stage ] + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.Client' && needs.verify.outputs.version != '' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write # sign the attestations with the workflow identity + attestations: write # store them in the repository + env: + TAG: ${{ github.ref_name }} + VERSION: ${{ needs.verify.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download packages + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: nuget-packages + path: artifacts/nuget + + - name: Download the staged SBOMs and SHA256SUMS + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-staging + path: artifacts/attestations + + # This job signs exactly what stage checked and listed: every package, symbol package and SBOM has the SHA-256 + # of the staged SHA256SUMS, and nothing else is there. The staged SHA256SUMS is then removed; it is written + # again over every release asset once the attestation bundles exist. + - name: Check the staged files + run: | + $ErrorActionPreference = 'Stop' + $sums = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + foreach ($line in @(Get-Content -LiteralPath artifacts/attestations/SHA256SUMS)) { + if ($line -cnotmatch '^(?[0-9a-f]{64}) (?[^\s/\\]+)$' -or -not $sums.TryAdd($Matches['name'], $Matches['hash'])) { + throw "The staged SHA256SUMS has a malformed or repeated line: '$line'." + } + } + $files = @(Get-ChildItem artifacts/nuget, artifacts/attestations -File | Where-Object Name -cne 'SHA256SUMS') + foreach ($file in $files) { + $actual = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + if (-not $sums.ContainsKey($file.Name) -or $sums[$file.Name] -cne $actual) { + throw "$($file.Name) has SHA-256 $actual, which the staged SHA256SUMS does not list for it." + } + } + if ($files.Count -ne $sums.Count) { + throw "The staged SHA256SUMS lists $($sums.Count) files, but $($files.Count) are here." + } + foreach ($id in @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ })) { + foreach ($name in "$id.$env:VERSION.nupkg", "$id.$env:VERSION.spdx.json") { + if (-not $sums.ContainsKey($name)) { + throw "stage did not stage $name." + } + } + } + Remove-Item -LiteralPath artifacts/attestations/SHA256SUMS + + # One provenance attestation covers the seven packages and the five symbol packages. + - name: Attest build provenance + id: provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: | + artifacts/nuget/*.nupkg + artifacts/nuget/*.snupkg + + # actions/attest takes one SBOM per call, so the seven SBOM steps below are numbered: SBOM n attests the package + # at position n of PACKAGE_IDS, which therefore must name exactly seven ids. + - name: Map the SBOM attestations to the packages + id: subjects + run: | + $ErrorActionPreference = 'Stop' + $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) + if ($packageIds.Count -ne 7) { + throw "The seven SBOM attestation steps need exactly seven ids in PACKAGE_IDS, found $($packageIds.Count)." + } + $outputs = for ($index = 0; $index -lt $packageIds.Count; $index++) { + "package-$($index + 1)=artifacts/nuget/$($packageIds[$index]).$env:VERSION.nupkg" + "sbom-$($index + 1)=artifacts/attestations/$($packageIds[$index]).$env:VERSION.spdx.json" + } + $outputs | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Attest SBOM 1 + id: sbom-1 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.subjects.outputs.package-1 }} + sbom-path: ${{ steps.subjects.outputs.sbom-1 }} + + - name: Attest SBOM 2 + id: sbom-2 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.subjects.outputs.package-2 }} + sbom-path: ${{ steps.subjects.outputs.sbom-2 }} + + - name: Attest SBOM 3 + id: sbom-3 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.subjects.outputs.package-3 }} + sbom-path: ${{ steps.subjects.outputs.sbom-3 }} + + - name: Attest SBOM 4 + id: sbom-4 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.subjects.outputs.package-4 }} + sbom-path: ${{ steps.subjects.outputs.sbom-4 }} + + - name: Attest SBOM 5 + id: sbom-5 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.subjects.outputs.package-5 }} + sbom-path: ${{ steps.subjects.outputs.sbom-5 }} + + - name: Attest SBOM 6 + id: sbom-6 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.subjects.outputs.package-6 }} + sbom-path: ${{ steps.subjects.outputs.sbom-6 }} + + - name: Attest SBOM 7 + id: sbom-7 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.subjects.outputs.package-7 }} + sbom-path: ${{ steps.subjects.outputs.sbom-7 }} + + # The bundles become release assets, named after what they attest. + - name: Collect the attestation bundles + env: + PROVENANCE: ${{ steps.provenance.outputs.bundle-path }} + SBOM_1: ${{ steps.sbom-1.outputs.bundle-path }} + SBOM_2: ${{ steps.sbom-2.outputs.bundle-path }} + SBOM_3: ${{ steps.sbom-3.outputs.bundle-path }} + SBOM_4: ${{ steps.sbom-4.outputs.bundle-path }} + SBOM_5: ${{ steps.sbom-5.outputs.bundle-path }} + SBOM_6: ${{ steps.sbom-6.outputs.bundle-path }} + SBOM_7: ${{ steps.sbom-7.outputs.bundle-path }} + run: | + $ErrorActionPreference = 'Stop' + $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) + $bundles = [ordered]@{ "CheatEngine.Client.$env:VERSION.provenance.sigstore.json" = $env:PROVENANCE } + for ($index = 0; $index -lt $packageIds.Count; $index++) { + $bundles["$($packageIds[$index]).$env:VERSION.sbom.sigstore.json"] = [Environment]::GetEnvironmentVariable("SBOM_$($index + 1)") + } + foreach ($name in $bundles.Keys) { + if (-not $bundles[$name] -or -not (Test-Path -LiteralPath $bundles[$name] -PathType Leaf)) { + throw "The attestation bundle for $name was not produced." + } + Copy-Item -LiteralPath $bundles[$name] -Destination (Join-Path artifacts/attestations $name) + } + + # The attestations are checked where they are made, before anything is drafted or pushed: each subject against + # its bundle and against the attestations stored in the repository, with the identity that RELEASING.md gives + # consumers (this repository, the release.yml signer workflow, the tag and a hosted runner). + - name: Verify the attestations + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $identity = @( + '--repo', 'CheatEngineNet/CheatEngine.Client', + '--signer-workflow', 'CheatEngineNet/CheatEngine.Client/.github/workflows/release.yml', + '--source-ref', "refs/tags/$env:TAG", + '--deny-self-hosted-runners' + ) + $provenance = "artifacts/attestations/CheatEngine.Client.$env:VERSION.provenance.sigstore.json" + foreach ($subject in @(Get-ChildItem artifacts/nuget -File | Sort-Object Name)) { + gh attestation verify $subject.FullName @identity --predicate-type 'https://slsa.dev/provenance/v1' --bundle $provenance + if ($LASTEXITCODE -ne 0) { + throw "The build provenance of $($subject.Name) did not verify against its bundle (exit code $LASTEXITCODE)." + } + gh attestation verify $subject.FullName @identity --predicate-type 'https://slsa.dev/provenance/v1' + if ($LASTEXITCODE -ne 0) { + throw "The build provenance of $($subject.Name) did not verify in the repository (exit code $LASTEXITCODE)." + } + } + foreach ($id in @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ })) { + $package = "artifacts/nuget/$id.$env:VERSION.nupkg" + $sbom = "artifacts/attestations/$id.$env:VERSION.sbom.sigstore.json" + gh attestation verify $package @identity --predicate-type 'https://spdx.dev/Document/v2.2' --bundle $sbom + if ($LASTEXITCODE -ne 0) { + throw "The SBOM attestation of $id.$env:VERSION.nupkg did not verify against its bundle (exit code $LASTEXITCODE)." + } + gh attestation verify $package @identity --predicate-type 'https://spdx.dev/Document/v2.2' + if ($LASTEXITCODE -ne 0) { + throw "The SBOM attestation of $id.$env:VERSION.nupkg did not verify in the repository (exit code $LASTEXITCODE)." + } + } + + # sha256sum format (' ', sorted ordinally by name, LF) over every release asset: the packages, + # their symbol packages, the SBOMs and the attestation bundles. It travels with the bundles, so draft-release + # attaches it and publish checks every package against it before anything is pushed. + - name: Write SHA256SUMS + run: | + $ErrorActionPreference = 'Stop' + $assets = [System.Collections.Generic.SortedDictionary[string, string]]::new([System.StringComparer]::Ordinal) + foreach ($file in @(Get-ChildItem artifacts/nuget, artifacts/attestations -File)) { + $assets.Add($file.Name, $file.FullName) + } + $lines = foreach ($name in $assets.Keys) { + '{0} {1}' -f (Get-FileHash -LiteralPath $assets[$name] -Algorithm SHA256).Hash.ToLowerInvariant(), $name + } + [System.IO.File]::WriteAllText((Join-Path $PWD 'artifacts/attestations/SHA256SUMS'), (($lines -join "`n") + "`n"), [System.Text.UTF8Encoding]::new($false)) + Write-Output "Wrote SHA256SUMS ($($assets.Count) assets)." + + - name: Upload the attestation bundles, SBOMs and SHA256SUMS + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: attestation-bundles + path: artifacts/attestations + if-no-files-found: error + retention-days: 90 + + draft-release: + name: Draft GitHub release + needs: [ verify, ci, attest ] + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.Client' && needs.verify.outputs.version != '' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write # create the draft release and upload its assets + # GH_TOKEN (contents: write) is set only on the steps that call gh. + env: + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + PRERELEASE: ${{ needs.verify.outputs.prerelease }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download packages + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: nuget-packages + path: artifacts/release + + - name: Download the attestation bundles, SBOMs and SHA256SUMS + 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/release-notes + + # Immutable releases lock a release's tag and assets once it is published, so the release is always created as a + # draft, filled completely, and only published by finalize-release after nuget.org serves the packages. Assets + # are never patched onto an existing release (that path breaks immutable releases): every draft already on the + # tag (left by an interrupted run or an earlier build; GitHub allows several) is deleted first, and the draft is + # created again from this run's files. gh release delete resolves a draft by its tag, so no release id is ever + # needed (the id of gh release view is a GraphQL node id that the REST API rejects), and it keeps the git tag. + # Nothing of a draft is public. A published release never receives assets: the job fails before anything is + # created. + - name: Create the draft release + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $files = @(Get-ChildItem artifacts/release -File | ForEach-Object FullName) + # The list includes drafts for this token (contents: write). + $list = gh release list --limit 1000 --json tagName,isDraft,createdAt + if ($LASTEXITCODE -ne 0) { + throw "Listing the releases of $env:GH_REPO failed with exit code $LASTEXITCODE." + } + $onTag = @(($list -join "`n") | ConvertFrom-Json | Where-Object { $_.tagName -ceq $env:TAG }) + if (@($onTag | Where-Object { -not $_.isDraft }).Count -gt 0) { + throw "Release $env:TAG is already published, and a published release never receives assets. Tag a new version." + } + + foreach ($draft in $onTag) { + Write-Output "::warning::A draft release of $env:TAG (created $($draft.createdAt)) already exists; it is deleted and created again from this run's files. The tag stays." + gh release delete $env:TAG --yes + if ($LASTEXITCODE -ne 0) { + throw "Deleting the draft release $env:TAG failed with exit code $LASTEXITCODE." + } + } + + $createOptions = @('--repo', $env:GH_REPO, '--draft', '--verify-tag', '--title', $env:TAG, '--notes-file', 'artifacts/release-notes/release-notes.md') + if ($env:PRERELEASE -eq 'true') { + $createOptions += '--prerelease' + } + gh release create $env:TAG @files @createOptions + if ($LASTEXITCODE -ne 0) { + throw "Creating the draft release $env:TAG failed with exit code $LASTEXITCODE." + } + Write-Output "Created the draft release $env:TAG with $($files.Count) assets." + + # 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, ci, draft-release ] + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.Client' && needs.verify.outputs.version != '' + runs-on: windows-2025 + timeout-minutes: 20 + environment: + name: nuget # required reviewers approve the publication; the environment only admits v*.*.* tags + url: https://www.nuget.org/packages/CheatEngine.Client/${{ needs.verify.outputs.version }} + permissions: + contents: read + id-token: write # exchange the OIDC token for a short-lived NuGet API key + env: + VERSION: ${{ needs.verify.outputs.version }} + steps: + # Only the .NET setup action and global.json: this job runs no repository script next to its credentials. + - name: Checkout the .NET setup + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: | + .github/actions + global.json + sparse-checkout-cone-mode: false + + - name: Set up .NET + uses: ./.github/actions/setup-dotnet + with: + cache: 'false' + + - name: Download packages + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: nuget-packages + path: artifacts/nuget + + - name: Download SHA256SUMS + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: attestation-bundles + path: artifacts/release-assets + + # Before any credential exists: every package and symbol package this job pushes has the SHA-256 that the + # SHA256SUMS of the draft release lists for it, and every package SHA256SUMS lists is here to be pushed. + - name: Check the packages against SHA256SUMS + run: | + $ErrorActionPreference = 'Stop' + $sums = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + foreach ($line in @(Get-Content -LiteralPath artifacts/release-assets/SHA256SUMS)) { + if ($line -cnotmatch '^(?[0-9a-f]{64}) (?[^\s/\\]+)$' -or -not $sums.TryAdd($Matches['name'], $Matches['hash'])) { + throw "SHA256SUMS has a malformed or repeated line: '$line'." + } + } + + $packages = @(Get-ChildItem artifacts/nuget -File) + foreach ($package in $packages) { + if ($package.Extension -cnotin '.nupkg', '.snupkg') { + throw "The nuget-packages artifact holds $($package.Name), which is neither a .nupkg nor a .snupkg." + } + $actual = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + if (-not $sums.ContainsKey($package.Name) -or $sums[$package.Name] -cne $actual) { + throw "$($package.Name) has SHA-256 $actual, which SHA256SUMS does not list for it." + } + Write-Output "$($package.Name): $actual" + } + + $pushed = @($packages | ForEach-Object Name) + $missing = @($sums.Keys | Where-Object { ($_.EndsWith('.nupkg', [System.StringComparison]::Ordinal) -or $_.EndsWith('.snupkg', [System.StringComparison]::Ordinal)) -and $pushed -cnotcontains $_ }) + if ($missing.Count -gt 0) { + throw "SHA256SUMS lists packages that the nuget-packages artifact does not hold: $($missing -join ', ')." + } + + # The user is the nuget.org profile name that created the trusted publishing policy, not an e-mail address. The + # key lasts one hour, so the login comes right before the pushes. + - name: NuGet login + id: login + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 + with: + user: ${{ secrets.NUGET_USER }} + + # Dependency order: every package is pushed after the Client packages it depends on. --no-symbols keeps the + # symbol packages for the next step, so all seven packages are live before the first symbol package is pushed. + # --skip-duplicate lets a re-run of this job finish a partial publication. + - name: Push the seven packages + env: + NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} + run: | + $ErrorActionPreference = 'Stop' + foreach ($id in @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ })) { + $package = "artifacts/nuget/$id.$env:VERSION.nupkg" + if (-not (Test-Path -LiteralPath $package -PathType Leaf)) { + throw "$package is missing from the nuget-packages artifact." + } + dotnet nuget push $package --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --no-symbols --skip-duplicate + if ($LASTEXITCODE -ne 0) { + throw "Pushing $id $env:VERSION failed with exit code $LASTEXITCODE." + } + } + + # Recovery: when this step fails, the seven packages are already live, and a version on nuget.org can never be + # replaced. Re-run the failed publish job: the package pushes above are skipped as duplicates, and only the symbol + # packages are pushed again, with --skip-duplicate for those nuget.org already accepted. + - name: Push the symbol packages + env: + NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} + run: | + $ErrorActionPreference = 'Stop' + $symbols = @(Get-ChildItem artifacts/nuget -File -Filter '*.snupkg' | Sort-Object Name) + foreach ($symbol in $symbols) { + dotnet nuget push $symbol.FullName --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate + if ($LASTEXITCODE -ne 0) { + throw "Pushing the symbol package $($symbol.Name) failed with exit code $LASTEXITCODE." + } + } + Write-Output "Pushed $($symbols.Count) symbol packages." + + verify-publication: + name: Verify publication + needs: [ verify, publish ] + runs-on: windows-2025 + timeout-minutes: 60 + env: + VERSION: ${{ needs.verify.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up .NET + uses: ./.github/actions/setup-dotnet + with: + cache: 'false' + + - name: Download packages + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: nuget-packages + path: artifacts/nuget + + # Resolves PackageBaseAddress/3.0.0 from the nuget.org service index, never a hard-coded flat container URL. For + # each of the seven packages, polls the flat container until it lists the version (validation and indexing take + # minutes), downloads the served file, and fails unless 'dotnet nuget verify --all' accepts it with the nuget.org + # repository signature (type Repository, service index https://api.nuget.org/v3/index.json), the content hash it + # prints equals the SHA-512 of the attested, unsigned package (the value consumers' lock files record), and the + # served file holds exactly the attested entries, byte for byte, plus .signature.p7s. + - name: Verify the published packages + run: | + $ErrorActionPreference = 'Stop' + Add-Type -AssemblyName System.IO.Compression.FileSystem + # The checks read the English output of dotnet nuget verify, whatever the runner locale. + $env:DOTNET_CLI_UI_LANGUAGE = 'en' + $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) + $deadline = [DateTime]::UtcNow.AddMinutes(45) + $lowerVersion = $env:VERSION.ToLowerInvariant() + $work = Join-Path ([System.IO.Path]::GetTempPath()) ('published-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $work | Out-Null + + $serviceIndex = Invoke-RestMethod -Uri 'https://api.nuget.org/v3/index.json' -MaximumRetryCount 5 -RetryIntervalSec 10 + $resource = @($serviceIndex.resources | Where-Object { $_.'@type' -ceq 'PackageBaseAddress/3.0.0' }) | Select-Object -First 1 + if ($null -eq $resource) { + throw "The nuget.org service index has no PackageBaseAddress/3.0.0 resource." + } + $base = ([string] $resource.'@id').TrimEnd('/') + '/' + Write-Output "nuget.org PackageBaseAddress: $base" + + function Get-EntryHash { + param([string] $Path) + $hashes = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + $archive = [System.IO.Compression.ZipFile]::OpenRead($Path) + try { + foreach ($entry in $archive.Entries) { + if ($entry.FullName.EndsWith('/')) { + continue + } + $stream = $entry.Open() + try { + $hashes.Add($entry.FullName, [Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($stream))) + } + finally { + $stream.Dispose() + } + } + } + finally { + $archive.Dispose() + } + return $hashes + } + + try { + foreach ($id in $packageIds) { + $attested = Join-Path $PWD "artifacts/nuget/$id.$env:VERSION.nupkg" + if (-not (Test-Path -LiteralPath $attested -PathType Leaf)) { + throw "The attested package $id.$env:VERSION.nupkg is missing from artifacts/nuget." + } + + $lowerId = $id.ToLowerInvariant() + while ($true) { + $index = Invoke-RestMethod -Uri "$base$lowerId/index.json" -SkipHttpErrorCheck -StatusCodeVariable status -MaximumRetryCount 3 -RetryIntervalSec 5 + if ($status -eq 200 -and (@($index.versions) -contains $lowerVersion)) { + break + } + if ([DateTime]::UtcNow -gt $deadline) { + throw "nuget.org does not list $id $env:VERSION after 45 minutes (last HTTP status $status)." + } + Write-Output "Waiting for nuget.org to list $id $env:VERSION (HTTP $status)." + Start-Sleep -Seconds 30 + } + + $signed = Join-Path $work "$lowerId.$lowerVersion.nupkg" + Invoke-WebRequest -Uri "$base$lowerId/$lowerVersion/$lowerId.$lowerVersion.nupkg" -OutFile $signed -MaximumRetryCount 3 -RetryIntervalSec 5 + $verification = (@(dotnet nuget verify --all $signed -v n 2>&1) | ForEach-Object { "$_" }) -join "`n" + if ($LASTEXITCODE -ne 0) { + throw "dotnet nuget verify --all failed for $id $env:VERSION with exit code ${LASTEXITCODE}:`n$verification" + } + if ($verification -cnotmatch '(?m)^Signature type: Repository\s*$' -or $verification -cnotmatch '(?m)^Service index: https://api\.nuget\.org/v3/index\.json\s*$') { + throw "dotnet nuget verify did not report the nuget.org repository signature of $id $env:VERSION. Its output was:`n$verification" + } + $expected = [Convert]::ToBase64String([System.Security.Cryptography.SHA512]::HashData([System.IO.File]::ReadAllBytes($attested))) + if ($verification -cnotmatch '(?m)^Content hash:\s*(?\S+)\s*$' -or $Matches['hash'] -cne $expected) { + throw "$id $env:VERSION on nuget.org does not have the content hash of the attested package ($expected)." + } + + $attestedEntries = Get-EntryHash -Path $attested + $signedEntries = Get-EntryHash -Path $signed + if ($attestedEntries.ContainsKey('.signature.p7s') -or -not $signedEntries.Remove('.signature.p7s')) { + throw "$id $env:VERSION on nuget.org has no .signature.p7s entry, or the attested package already had one." + } + $names = [System.Collections.Generic.HashSet[string]]::new([string[]] @($attestedEntries.Keys), [System.StringComparer]::Ordinal) + $names.UnionWith([string[]] @($signedEntries.Keys)) + $differences = @($names | Where-Object { -not $attestedEntries.ContainsKey($_) -or -not $signedEntries.ContainsKey($_) -or $attestedEntries[$_] -cne $signedEntries[$_] }) + if ($differences.Count -gt 0) { + throw "$id $env:VERSION on nuget.org differs from the attested package in: $($differences -join ', ')." + } + + Write-Output "nuget.org serves the attested $id $env:VERSION with its repository signature." + } + } + finally { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue + } + + finalize-release: + name: Finalize GitHub release + needs: [ verify, draft-release, verify-publication ] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: write # publish the draft release + attestations: read # verify the attestations + # GH_TOKEN (contents: write) is set only on the steps that call gh. + env: + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + VERSION: ${{ needs.verify.outputs.version }} + # attest verified the attestations before anything became public; this job verifies what consumers download. + steps: + # gh release view and gh release edit find a draft by its tag, which the REST lookup by tag never returns (it + # serves published releases only). A release that is already published, on a re-run, is verified again but never + # edited. + - name: Read the release state + id: state + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $view = gh release view $env:TAG --json isDraft,isImmutable + if ($LASTEXITCODE -ne 0) { + throw "Reading the release $env:TAG failed with exit code $LASTEXITCODE." + } + $state = ($view -join "`n") | ConvertFrom-Json + $draft = ([bool] $state.isDraft).ToString().ToLowerInvariant() + Write-Output "Release $env:TAG is a draft: $draft; immutable: $(([bool] $state.isImmutable).ToString().ToLowerInvariant())." + "draft=$draft" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Publish the release + if: steps.state.outputs.draft == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release edit $env:TAG --draft=false + if ($LASTEXITCODE -ne 0) { + throw "Publishing the release $env:TAG failed with exit code $LASTEXITCODE." + } + + # Verifies what consumers download, not the local artifacts: every asset against SHA256SUMS, the release + # attestation when the release is immutable (immutable releases are a repository setting, RELEASING.md), and the + # provenance and SBOM attestations with the identity that attest checked. + - name: Verify the published release + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $view = gh release view $env:TAG --json isDraft,isImmutable + if ($LASTEXITCODE -ne 0) { + throw "Reading the release $env:TAG failed with exit code $LASTEXITCODE." + } + $state = ($view -join "`n") | 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 + if ($LASTEXITCODE -ne 0) { + throw "Downloading the assets of $env:TAG failed with exit code $LASTEXITCODE." + } + + # Every line of SHA256SUMS names an asset with that SHA-256, and every other asset has a line. + $sumsPath = Join-Path $published 'SHA256SUMS' + if (-not (Test-Path -LiteralPath $sumsPath -PathType Leaf)) { + throw "Release $env:TAG has no SHA256SUMS asset." + } + $sums = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + foreach ($line in [System.IO.File]::ReadAllLines($sumsPath)) { + if ($line -cnotmatch '^(?[0-9a-f]{64}) (?[^\s/\\]+)$' -or -not $sums.TryAdd($Matches['name'], $Matches['hash'])) { + throw "SHA256SUMS of $env:TAG has a malformed or repeated line: '$line'." + } + } + foreach ($asset in @(Get-ChildItem -LiteralPath $published -File | Where-Object Name -cne 'SHA256SUMS')) { + if (-not $sums.ContainsKey($asset.Name)) { + throw "Release $env:TAG has the asset $($asset.Name), which SHA256SUMS does not list." + } + } + [string[]] $names = @($sums.Keys) + [System.Array]::Sort($names, [System.StringComparer]::Ordinal) + foreach ($name in $names) { + $path = Join-Path $published $name + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "SHA256SUMS lists $name, which release $env:TAG does not carry." + } + $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -cne $sums[$name]) { + throw "The published $name has SHA-256 $actual; SHA256SUMS lists $($sums[$name])." + } + } + $packageIds = @($env:PACKAGE_IDS -split '\s+' | Where-Object { $_ }) + $packages = @($packageIds | ForEach-Object { "$_.$env:VERSION.nupkg" }) + $symbols = @($names | Where-Object { $_.EndsWith('.snupkg', [System.StringComparison]::Ordinal) }) + $expected = @($packageIds | ForEach-Object { "$_.$env:VERSION.nupkg", "$_.$env:VERSION.spdx.json", "$_.$env:VERSION.sbom.sigstore.json" }) + + "CheatEngine.Client.$env:VERSION.provenance.sigstore.json" + $missing = @($expected | Where-Object { -not $sums.ContainsKey($_) }) + if ($missing.Count -gt 0) { + throw "Release $env:TAG lacks the assets $($missing -join ', ')." + } + + # Only an immutable release has a release attestation. SHA256SUMS, checked above, ties every other asset to it. + if ($state.isImmutable) { + gh release verify $env:TAG + if ($LASTEXITCODE -ne 0) { + throw "gh release verify $env:TAG failed with exit code $LASTEXITCODE." + } + foreach ($name in @($packages) + @($symbols) + 'SHA256SUMS') { + gh release verify-asset $env:TAG (Join-Path $published $name) + if ($LASTEXITCODE -ne 0) { + throw "gh release verify-asset $env:TAG $name failed with exit code $LASTEXITCODE." + } + } + } + else { + Write-Output "::warning::Immutable releases are not enabled for $env:GH_REPO, so release $env:TAG has no release attestation to verify. Enable them (RELEASING.md)." + } + + $identity = @( + '--repo', 'CheatEngineNet/CheatEngine.Client', + '--signer-workflow', 'CheatEngineNet/CheatEngine.Client/.github/workflows/release.yml', + '--source-ref', "refs/tags/$env:TAG", + '--deny-self-hosted-runners' + ) + foreach ($name in @($packages) + @($symbols)) { + gh attestation verify (Join-Path $published $name) @identity --predicate-type 'https://slsa.dev/provenance/v1' + if ($LASTEXITCODE -ne 0) { + throw "The build provenance attestation of the published $name did not verify (exit code $LASTEXITCODE)." + } + } + foreach ($name in $packages) { + gh attestation verify (Join-Path $published $name) @identity --predicate-type 'https://spdx.dev/Document/v2.2' + if ($LASTEXITCODE -ne 0) { + throw "The SBOM attestation of the published $name did not verify (exit code $LASTEXITCODE)." + } + } + + $summary = @( + "### Release $env:TAG", + '', + "Published; immutable: $(([bool] $state.isImmutable).ToString().ToLowerInvariant()). Every asset matches SHA256SUMS; the provenance of each package and symbol package and the SBOM attestation of each package verify with the release.yml identity.", + '', + '| Asset | SHA-256 |', + '| --- | --- |' + ) + @($names | ForEach-Object { "| ``$_`` | ``$($sums[$_])`` |" }) + $summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index f5bad3e..d551dcf 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -35,20 +35,20 @@ jobs: # No repo_token: rulesets are readable with the default token. - 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 results - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: scorecard-results path: results.sarif retention-days: 5 - name: Upload to code scanning - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: sarif_file: results.sarif diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index aa66efd..93e91fd 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -1,237 +1,237 @@ -name: Sonar - -# Reusable SonarQube Cloud analysis, called by ci.yml after build-test when SONAR_EXPECTED is true. It reuses the -# coverage and nuget-packages artifacts of the same run; only the instrumented build is repeated, because the scanner -# must observe a compilation. -# -# CI-based analysis only: Automatic Analysis must stay OFF for the project in SonarQube Cloud (Administration > Analysis -# Method). SonarQube Cloud exposes no API to read that setting; with both methods on, scanner begin fails, which is the -# earliest authoritative check. - -on: - workflow_call: - inputs: - project-key: - description: SonarQube Cloud project key. - type: string - default: CheatEngineNet_CheatEngine.Client - organization: - description: SonarQube Cloud organization key. - type: string - default: cheatenginenet - wait-quality-gate: - description: Fail the job when the quality gate fails. Pull requests and manual runs wait for it; main only reports 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. The same JDK 21 distribution as CheatEngine.SDK keeps scanner behaviour - # reproducible across both repositories. - - 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. - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - - # Every event may restore the analyzer cache; only main saves it (last step), so a pull request can read a - # main-branch cache but never publish one. - - name: Restore Sonar analyzer cache - id: sonar-cache-restore - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ${{ runner.temp }}/sonar-user-home/cache - key: sonar-analyzers-${{ runner.os }}-${{ runner.arch }}-${{ env.SONAR_SCANNER_VERSION }}-v1 - - - name: Download coverage - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: coverage - path: ${{ runner.temp }}/coverage - - - name: Download packages - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: nuget-packages - path: ${{ runner.temp }}/packages - - # 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, in locked mode: the analysis build is --no-restore, so no pull-request-supplied - # source or version can take part while scanner credentials are configured. - - name: Restore from NuGet.org (locked) - env: - NUGET_CONFIG: ${{ steps.nuget-config.outputs.path }} - run: | - dotnet restore CheatEngine.Client.slnx --locked-mode --configfile $env:NUGET_CONFIG - if ($LASTEXITCODE -ne 0) { - throw "Sonar restore failed with exit code $LASTEXITCODE." - } - - - 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 }}"}' - SONAR_USER_HOME: ${{ runner.temp }}/sonar-user-home - 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 - } - # The new-code period follows sonar.projectVersion: the core version of the facade package built in this run - # (0.1.0 for 0.1.0-alpha.0.37). The digit right after "CheatEngine.Client." excludes the other package ids. - $packages = @(Get-ChildItem -Path "$env:RUNNER_TEMP/packages" -Filter 'CheatEngine.Client.*.nupkg' -File | - Where-Object { $_.Name -match '^CheatEngine\.Client\.\d' }) - if ($packages.Count -ne 1 -or $packages[0].Name -notmatch '^CheatEngine\.Client\.(?\d+\.\d+\.\d+)') { - Write-Host '::error::The nuget-packages artifact must hold exactly one CheatEngine.Client facade 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 the - # sources stay free of Sonar-only attributes and suppressions. - $ignoredIssues = @( - @{ Key = 'noLinq'; Rule = 'csharpsquid:S3267'; Resource = '**/*.cs' } - @{ Key = 'unsafeInterop'; Rule = 'csharpsquid:S6640'; Resource = '**/*.cs' } - @{ Key = 'nullableFlow'; Rule = 'csharpsquid:S8970'; Resource = '**/*.cs' } - @{ Key = 'apiOverloadLayout'; Rule = 'csharpsquid:S4136'; Resource = 'libs/**' } - @{ Key = 'generatorInstances'; Rule = 'csharpsquid:S2325'; Resource = 'source-generators/**' } - @{ Key = 'emittedFragments'; Rule = 'csharpsquid:S1192'; Resource = 'source-generators/**' } - @{ Key = 'generatorComments'; Rule = 'csharpsquid:S125'; Resource = 'source-generators/**' } - # The qualification fault switch makes an activation-scoped resource throw from Dispose on purpose (Q06, Q43). - @{ Key = 'harnessFaultInjection'; Rule = 'csharpsquid:S3877'; Resource = 'tests/CheatEngine.Client.LivePlugin.Qualification/QualificationPlugin.cs' } - ) - $arguments = @( - "/k:$env:SONAR_PROJECT_KEY" - "/o:$env:SONAR_ORGANIZATION" - "/v:$version" - '/d:sonar.exclusions=artifacts/**,tests/CheatEngine.Client.Benchmarks/**' - # CI publishes managed coverage of the shipping code only: tests and build tooling stay out of the coverage - # metric instead of presenting an incomplete report as if it covered them. Every excluded path exists - # (the repository has no documentation folder; its Markdown files are not analysed code). - # Shipping code is excluded file by file, never by a folder pattern, so every other file in libs/** keeps - # full coverage accounting. Each listed file is an SDK production adapter that no hosted test can reach - # (xUnit has no attached Cheat Engine process or Lua state): its lines are untestable, not merely - # untested, and its non-SDK branches keep the tests they allow next to it (for example - # SdkTableRecordMutationPortTests). A lot that adds an Sdk*Port.cs adds its own entry here. - '/d:sonar.coverage.exclusions=tests/**,eng/**,libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs,libs/CheatEngine.Client.Core/Domains/SdkAobScanPort.cs,libs/CheatEngine.Client.Core/Domains/IMemoryCodecContextPort.cs,libs/CheatEngine.Client.Core/Domains/SdkRuntimeObservationPort.cs,libs/CheatEngine.Client.Core/Domains/SdkProcessSelectionPort.cs,libs/CheatEngine.Client.Core/Domains/SdkInspectionPort.cs,libs/CheatEngine.Client.Core/Domains/SdkTableFilePort.cs,libs/CheatEngine.Client.Core/Domains/SdkTableRecordLookupPort.cs,libs/CheatEngine.Client.Core/Domains/ValueScanning/SdkValueScanPort.cs,libs/CheatEngine.Client.Core/Domains/Allocations/SdkAllocationPort.cs,libs/CheatEngine.Client.Core/Domains/Assembly/SdkAutoAssemblerPort.cs,libs/CheatEngine.Client.Core/Domains/Assembly/SdkInstructionPort.cs' - # Never set sonar.dotnet.excludeTestProjects: the scanner then strips every analyzer, including source - # generators, from test projects, and their [GeneratedRegex]/[LoggerMessage] partial methods fail with - # CS8795 (https://github.com/SonarSource/sonar-scanner-msbuild/issues/1469). Test projects are analysed as - # test code instead. - "/d:sonar.cs.vscoveragexml.reportsPaths=$coverage/**/*.xml" - "/d:sonar.issue.ignore.multicriteria=$(($ignoredIssues.Key) -join ',')" - "/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." - } - - # Debug, like the coverage leg. The scanner turns TreatWarningsAsErrors off for this build, which is why - # build-test, not this job, gates compilation. - - name: Build - env: - SONAR_USER_HOME: ${{ runner.temp }}/sonar-user-home - run: | - dotnet build CheatEngine.Client.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 }}"}' - SONAR_USER_HOME: ${{ runner.temp }}/sonar-user-home - run: | - & "$env:RUNNER_TEMP/sonar-scanner/dotnet-sonarscanner.exe" end - if ($LASTEXITCODE -ne 0) { - throw "SonarScanner end failed with exit code $LASTEXITCODE." - } - - # The only cache a release-reachable workflow touches: analyzer plugins, saved from main only. - - name: Save Sonar analyzer cache from main - if: >- - ${{ success() - && github.ref == 'refs/heads/main' - && steps.sonar-cache-restore.outputs.cache-hit != 'true' }} - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ${{ runner.temp }}/sonar-user-home/cache - key: sonar-analyzers-${{ runner.os }}-${{ runner.arch }}-${{ env.SONAR_SCANNER_VERSION }}-v1 +name: Sonar + +# Reusable SonarQube Cloud analysis, called by ci.yml after build-test when SONAR_EXPECTED is true. It reuses the +# coverage and nuget-packages artifacts of the same run; only the instrumented build is repeated, because the scanner +# must observe a compilation. +# +# CI-based analysis only: Automatic Analysis must stay OFF for the project in SonarQube Cloud (Administration > Analysis +# Method). SonarQube Cloud exposes no API to read that setting; with both methods on, scanner begin fails, which is the +# earliest authoritative check. + +on: + workflow_call: + inputs: + project-key: + description: SonarQube Cloud project key. + type: string + default: CheatEngineNet_CheatEngine.Client + organization: + description: SonarQube Cloud organization key. + type: string + default: cheatenginenet + wait-quality-gate: + description: Fail the job when the quality gate fails. Pull requests and manual runs wait for it; main only reports 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. The same JDK 21 distribution as CheatEngine.SDK keeps scanner behaviour + # reproducible across both repositories. + - 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. + - name: Setup .NET + uses: ./.github/actions/setup-dotnet + + # Every event may restore the analyzer cache; only main saves it (last step), so a pull request can read a + # main-branch cache but never publish one. + - name: Restore Sonar analyzer cache + id: sonar-cache-restore + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/sonar-user-home/cache + key: sonar-analyzers-${{ runner.os }}-${{ runner.arch }}-${{ env.SONAR_SCANNER_VERSION }}-v1 + + - name: Download coverage + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: coverage + path: ${{ runner.temp }}/coverage + + - name: Download packages + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: nuget-packages + path: ${{ runner.temp }}/packages + + # 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, in locked mode: the analysis build is --no-restore, so no pull-request-supplied + # source or version can take part while scanner credentials are configured. + - name: Restore from NuGet.org (locked) + env: + NUGET_CONFIG: ${{ steps.nuget-config.outputs.path }} + run: | + dotnet restore CheatEngine.Client.slnx --locked-mode --configfile $env:NUGET_CONFIG + if ($LASTEXITCODE -ne 0) { + throw "Sonar restore failed with exit code $LASTEXITCODE." + } + + - 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 }}"}' + SONAR_USER_HOME: ${{ runner.temp }}/sonar-user-home + 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 + } + # The new-code period follows sonar.projectVersion: the core version of the facade package built in this run + # (0.1.0 for 0.1.0-alpha.0.37). The digit right after "CheatEngine.Client." excludes the other package ids. + $packages = @(Get-ChildItem -Path "$env:RUNNER_TEMP/packages" -Filter 'CheatEngine.Client.*.nupkg' -File | + Where-Object { $_.Name -match '^CheatEngine\.Client\.\d' }) + if ($packages.Count -ne 1 -or $packages[0].Name -notmatch '^CheatEngine\.Client\.(?\d+\.\d+\.\d+)') { + Write-Host '::error::The nuget-packages artifact must hold exactly one CheatEngine.Client facade 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 the + # sources stay free of Sonar-only attributes and suppressions. + $ignoredIssues = @( + @{ Key = 'noLinq'; Rule = 'csharpsquid:S3267'; Resource = '**/*.cs' } + @{ Key = 'unsafeInterop'; Rule = 'csharpsquid:S6640'; Resource = '**/*.cs' } + @{ Key = 'nullableFlow'; Rule = 'csharpsquid:S8970'; Resource = '**/*.cs' } + @{ Key = 'apiOverloadLayout'; Rule = 'csharpsquid:S4136'; Resource = 'libs/**' } + @{ Key = 'generatorInstances'; Rule = 'csharpsquid:S2325'; Resource = 'source-generators/**' } + @{ Key = 'emittedFragments'; Rule = 'csharpsquid:S1192'; Resource = 'source-generators/**' } + @{ Key = 'generatorComments'; Rule = 'csharpsquid:S125'; Resource = 'source-generators/**' } + # The qualification fault switch makes an activation-scoped resource throw from Dispose on purpose (Q06, Q43). + @{ Key = 'harnessFaultInjection'; Rule = 'csharpsquid:S3877'; Resource = 'tests/CheatEngine.Client.LivePlugin.Qualification/QualificationPlugin.cs' } + ) + $arguments = @( + "/k:$env:SONAR_PROJECT_KEY" + "/o:$env:SONAR_ORGANIZATION" + "/v:$version" + '/d:sonar.exclusions=artifacts/**,tests/CheatEngine.Client.Benchmarks/**' + # CI publishes managed coverage of the shipping code only: tests and build tooling stay out of the coverage + # metric instead of presenting an incomplete report as if it covered them. Every excluded path exists + # (the repository has no documentation folder; its Markdown files are not analysed code). + # Shipping code is excluded file by file, never by a folder pattern, so every other file in libs/** keeps + # full coverage accounting. Each listed file is an SDK production adapter that no hosted test can reach + # (xUnit has no attached Cheat Engine process or Lua state): its lines are untestable, not merely + # untested, and its non-SDK branches keep the tests they allow next to it (for example + # SdkTableRecordMutationPortTests). A lot that adds an Sdk*Port.cs adds its own entry here. + '/d:sonar.coverage.exclusions=tests/**,eng/**,libs/CheatEngine.Client.Core/Domains/SdkTableRecordMutationPort.cs,libs/CheatEngine.Client.Core/Domains/SdkAobScanPort.cs,libs/CheatEngine.Client.Core/Domains/IMemoryCodecContextPort.cs,libs/CheatEngine.Client.Core/Domains/SdkRuntimeObservationPort.cs,libs/CheatEngine.Client.Core/Domains/SdkProcessSelectionPort.cs,libs/CheatEngine.Client.Core/Domains/SdkInspectionPort.cs,libs/CheatEngine.Client.Core/Domains/SdkTableFilePort.cs,libs/CheatEngine.Client.Core/Domains/SdkTableRecordLookupPort.cs,libs/CheatEngine.Client.Core/Domains/ValueScanning/SdkValueScanPort.cs,libs/CheatEngine.Client.Core/Domains/Allocations/SdkAllocationPort.cs,libs/CheatEngine.Client.Core/Domains/Assembly/SdkAutoAssemblerPort.cs,libs/CheatEngine.Client.Core/Domains/Assembly/SdkInstructionPort.cs' + # Never set sonar.dotnet.excludeTestProjects: the scanner then strips every analyzer, including source + # generators, from test projects, and their [GeneratedRegex]/[LoggerMessage] partial methods fail with + # CS8795 (https://github.com/SonarSource/sonar-scanner-msbuild/issues/1469). Test projects are analysed as + # test code instead. + "/d:sonar.cs.vscoveragexml.reportsPaths=$coverage/**/*.xml" + "/d:sonar.issue.ignore.multicriteria=$(($ignoredIssues.Key) -join ',')" + "/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." + } + + # Debug, like the coverage leg. The scanner turns TreatWarningsAsErrors off for this build, which is why + # build-test, not this job, gates compilation. + - name: Build + env: + SONAR_USER_HOME: ${{ runner.temp }}/sonar-user-home + run: | + dotnet build CheatEngine.Client.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 }}"}' + SONAR_USER_HOME: ${{ runner.temp }}/sonar-user-home + run: | + & "$env:RUNNER_TEMP/sonar-scanner/dotnet-sonarscanner.exe" end + if ($LASTEXITCODE -ne 0) { + throw "SonarScanner end failed with exit code $LASTEXITCODE." + } + + # The only cache a release-reachable workflow touches: analyzer plugins, saved from main only. + - name: Save Sonar analyzer cache from main + if: >- + ${{ success() + && github.ref == 'refs/heads/main' + && steps.sonar-cache-restore.outputs.cache-hit != 'true' }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/sonar-user-home/cache + key: sonar-analyzers-${{ runner.os }}-${{ runner.arch }}-${{ env.SONAR_SCANNER_VERSION }}-v1