diff --git a/.coderabbit.yaml b/.coderabbit.yaml index f238dc0e..9c37eff4 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -4,8 +4,9 @@ language: en-US early_access: false reviews: - # Keep automated review useful for real defects without turning the App into a second formatter or approval process. - profile: chill + # Assertive and advisory: CodeRabbit reviews thoroughly but never approves, requests changes or sets a required + # status. The only merge requirement is the CI / Gate check. + profile: assertive request_changes_workflow: false review_details: false review_progress: true @@ -13,7 +14,8 @@ reviews: fail_commit_status: false high_level_summary: true high_level_summary_instructions: >- - Summarize only material SDK contract, native ABI, generator, test, packaging, or CI/CD effects. Keep it concise. + Summarize only material SDK contract, native ABI, Lua stack, generator, analyzer, test, packaging, or CI/CD + effects. Keep it concise. collapse_walkthrough: true changed_files_summary: false sequence_diagrams: false @@ -31,16 +33,67 @@ reviews: abort_on_close: true slop_detection: enabled: false - # This repository uses the GitHub App for review, not a CodeRabbit coding agent or autonomous follow-up changes. pre_merge_checks: + # Every check is a warning: none of them can block a merge. title: - mode: 'off' + mode: warning + requirements: >- + Pull requests are squash-merged and the title becomes the commit subject: a short imperative sentence such as + "Fix CI validation findings", at most 72 characters, no trailing period. description: - mode: 'off' + mode: warning issue_assessment: - mode: 'off' + mode: 'off' # issues are disabled on this repository docstrings: - mode: 'off' + mode: 'off' # CS1591 already fails the build for undocumented public APIs + custom_checks: + - name: Native ABI evidence + mode: warning + instructions: >- + Applies only when the pull request changes files under libs/CheatEngine.SDK.Abi/, native/ or + tests/native-abi-fixture/, or changes a StructLayout, FieldOffset, function-pointer signature, + UnmanagedCallersOnly or LibraryImport declaration anywhere; otherwise pass. Pass when the description or the + changed documentation names the upstream Cheat Engine source (file and symbol, pinned to a commit or tested + release), the Cheat Engine version, the x64 architecture and the calling convention, and the change adds or + updates size, offset or export tests in tests/CheatEngine.SDK.Abi.Tests or tests/native-abi-fixture. Fail + when a layout, export or signature changes without that evidence, or when fixture evidence is presented as + live Cheat Engine host qualification. + - name: Lua stack balance tests + mode: warning + instructions: >- + Applies only when the pull request changes code that pushes, pops, calls or references values on a Lua stack + in libs/CheatEngine.SDK.Lua/, libs/CheatEngine.SDK.Lua.Interop/, + source-generators/CheatEngine.SDK.SourceGenerators.LuaBindings/ or native/cheatengine-sdk-lua-bridge/; + otherwise pass. Pass when tests assert that the stack top is restored on success and on every touched failure + path (conversion failure, callback exception, protected-call error) and that registry references and + callbacks are released. Fail when such code changes without those assertions, or when a raw Lua call that + can raise is added outside a protected boundary. + - name: Public API documentation and changelog + mode: warning + instructions: >- + Applies when public or protected API in libs/, src/, analyzers/ or source-generators/ is added, removed or + changes signature or behavior, or when an analyzer diagnostic identifier or message changes; otherwise pass. + Pass when every new public member has XML documentation, the owning project's sibling README.md is updated + where it documents the contract, CHANGELOG.md has a matching entry under [Unreleased] (removals and behavior + changes marked as breaking), and diagnostic changes also update analyzers/docs and analyzer release tracking. + Fail otherwise. + - name: Dependency direction + mode: warning + instructions: >- + Fail when any project, source file or package reference references CheatEngine.Client, CheatEngine.Mcp or + their namespaces or packages, when Client-level policy (fluent APIs, dependency-injection registration, + application workflows) is added to this SDK, or when a shipping project under src/ or libs/ gains a new + PackageReference without a reason stated in the description. Otherwise pass. + - name: Workflow hygiene + mode: warning + instructions: >- + Applies only to changes under .github/; otherwise pass. Fail when an action is referenced by tag or branch + instead of a full 40-character commit SHA with a version comment; pull_request_target is used; + actions/checkout omits persist-credentials: false; a permission is widened without a comment giving the + reason; a PowerShell step runs a native command (dotnet, xmake, git, gh, tar, actionlint) without checking + $LASTEXITCODE; SONAR_TOKEN or NUGET_USER can reach fork or Dependabot runs; a job added to ci.yml is missing + from the Gate's needs; or NuGet/login moves out of the release.yml publish job that uses environment nuget. + Otherwise pass. finishing_touches: docstrings: enabled: false @@ -57,6 +110,7 @@ reviews: auto_incremental_review: true drafts: false base_branches: [ main ] + ignore_usernames: [ 'dependabot[bot]' ] # Exclude only generated outputs and non-reviewable binary payloads. Source, specifications, tests, CI and docs stay in scope. path_filters: - '!artifacts/**' @@ -102,19 +156,30 @@ reviews: and binary compatibility. Fluent developer workflows and application policy belong in CheatEngine.Client. - path: 'tests/**' instructions: >- - Tests use xUnit v3 with Microsoft.Testing.Platform. Debug CI rejects skipped tests. Distinguish fixture, - package and NativeAOT probes from actual live Cheat Engine host qualification. + Tests use xUnit v3 with Microsoft.Testing.Platform. CI runs the whole solution once in Debug and once in + Release with --fail-skips on, so a skipped test fails both. Distinguish fixture, package and NativeAOT probes + from actual live Cheat Engine host qualification. + - path: 'CHANGELOG.md' + instructions: >- + Keep a Changelog 1.1.0. The release workflow publishes the body of the "## [X.Y.Z]" section (or [Unreleased] + for a prerelease) as the GitHub release notes, so keep version headings exact and link references at the end. - path: '.github/**' instructions: >- - Preserve the discover/native/build/test/pack/AOT/gate DAG and its bridge, coverage and NuGet artifact flows. - Require least-privilege permissions and pinned action SHAs. actionlint already runs in pull-request CI; do not - ask for a duplicate CodeRabbit actionlint run. Never use pull_request_target to check out or execute code from - forks. Sonar secrets must remain unavailable to forks. + pull-request-ci.yml, main-ci.yml and release.yml are thin callers of the reusable ci.yml (native, build-test + matrix Debug/Release, aot, sonar through sonar.yml, lint, gate) and must stay thin. Jobs exchange artifacts + instead of redoing work: lua-protection-bridge and classic-abi-fixture-facts from native; nuget-package, + coverage and test-results- from build-test; release-notes from the release verify job. Do not + reintroduce per-project test matrices, a second test run of the same configuration, or rebuilds of what an + upstream job produced. NativeBridgePeAuditTests asserts the native job text. The Gate evaluates toJSON(needs) + and only sonar may be skipped. NuGet/login stays in release.yml with environment nuget (trusted publishing + binding). Require SHA-pinned actions, least privilege, persist-credentials: false and $LASTEXITCODE checks. + actionlint already runs in CI; do not ask for a duplicate CodeRabbit actionlint run. Never use + pull_request_target. Sonar secrets must stay unavailable to forks and Dependabot. tools: # CodeRabbit is used as the GitHub App; pipeline failures are surfaced through its GitHub Checks integration. github-checks: enabled: true - # pull-request-ci.yml is the deterministic actionlint owner. + # The ci.yml lint job is the deterministic actionlint owner. actionlint: enabled: false @@ -122,6 +187,10 @@ chat: auto_reply: true knowledge_base: + code_guidelines: + enabled: true + filePatterns: + - CONTRIBUTING.md automatic_linking_mode: disabled linked_repositories: - repository: CheatEngineNet/CheatEngine.Client diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ca5b83e3..d7cffe44 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,34 +1,29 @@ -## Outcome and linked issue +## Summary -Closes + -## Scope and architectural ownership +## Scope and ownership -Describe resulting behavior, affected contracts, and exclusions. SDK owns CE integration; Client owns workflows and -policy. + -## Dependencies and containing artifacts +## Validation -Link upstream prerequisites without closing them. Identify the SDK package containing every consumed primitive. +| Check | Evidence (command, run link or artifact) | Result | +|------------------------|--------------------------------------------------------------|--------------| +| CI / Gate | | Pending | +| Local build and tests | | Not executed | +| Packed package | | Not executed | +| Live Cheat Engine host | | Not executed | -## Validation actually performed +## Compatibility and release impact -| Check | Command / profile | Actual result | Evidence | -|-----------------|-------------------|---------------|----------| -| Unit / fixture | | Not executed | | -| Packed consumer | | Not executed | | -| Live host | | Not executed | | -| AOT publication | | Not executed | | + -## Compatibility, lifetime and partial effects +## Checklist -Explain public API or behavior changes, ownership, target switches, cleanup, cancellation and migration. - -## Documentation and review checklist - -- [ ] Scope is focused; existing repository style and contribution rules are preserved. -- [ ] Relevant regression evidence is attached; pending gates remain explicit. -- [ ] Ownership, provenance, and raw-state exposure match the supported consumer boundary of the affected layer. -- [ ] Capability and artifact claims match actual results. -- [ ] Documentation and release impact are recorded. -- [ ] No automatic merge, release, protection change or unsupported capability activation is requested. +- [ ] The change is focused and follows CONTRIBUTING.md. +- [ ] Tests cover the change; no skipped test hides a failure. +- [ ] Consumer-visible changes are recorded under `[Unreleased]` in CHANGELOG.md. +- [ ] Affected READMEs and documentation are updated in this pull request. +- [ ] The claims above match the actual CI and local results; live-host limitations are stated. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb9e046b..ebfac757 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,23 +1,35 @@ name: CI +# Reusable pipeline for pull-request-ci.yml, main-ci.yml and release.yml. +# +# native ─► build-test (Debug, Release) ─► sonar ─┐ +# └► aot ─────────────────────────────────┼─► gate (the only required check: "CI / Gate") +# lint ──────────────────────────────────────────┘ +# +# Jobs never rebuild what an upstream job produced; they exchange artifacts: +# lua-protection-bridge, classic-abi-fixture-facts native → build-test, aot +# coverage build-test (Debug) → sonar +# nuget-package build-test (Release) → sonar, release publish, reviewers +# test-results- build-test → humans + on: workflow_call: inputs: - collect-coverage: - description: Emit Visual Studio XML coverage from Debug test jobs and upload it as coverage-. - type: boolean - default: false - upload-package: - description: Upload the packed package as the nuget-package artifact. - type: boolean - default: false - test-release: - description: Also run every test project against the Release build, which is what ships. + sonar: + description: Run the SonarQube Cloud analysis. Merge-queue runs, fork pull requests and Dependabot always skip it. type: boolean default: false + package-version: + description: When set, the Release leg must produce exactly CheatEngine.SDK..nupkg. + type: string + default: '' + package-retention-days: + description: Days to keep the nuget-package artifact. + type: number + default: 7 secrets: SONAR_TOKEN: - description: SonarQube Cloud token for protected same-repository analysis. + description: SonarQube Cloud analysis token. Needed only when sonar is true. required: false permissions: @@ -28,45 +40,6 @@ defaults: shell: pwsh jobs: - discover: - name: Discover tests - runs-on: windows-latest - timeout-minutes: 5 - outputs: - matrix: ${{ steps.projects.outputs.matrix }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - sparse-checkout: tests - persist-credentials: false - - - name: Find test projects - id: projects - env: - TEST_RELEASE: ${{ inputs.test-release }} - # Mirrors eng/Tests.props: a test project is any tests/**/*.Tests project, so a new one needs no edit here. - run: | - $projects = @(Get-ChildItem -Path tests -Filter '*.Tests.csproj' -Recurse -File | Sort-Object Name) - if ($projects.Count -eq 0) { throw 'No *.Tests.csproj project found under tests.' } - $configurations = if ($env:TEST_RELEASE -eq 'true') { 'Debug', 'Release' } else { 'Debug' } - $include = foreach ($project in $projects) { - foreach ($configuration in $configurations) { - $suffix = if ($configuration -eq 'Release') { '-release' } else { '' } - [ordered]@{ - name = $project.BaseName - project = [IO.Path]::GetRelativePath($PWD.Path, $project.FullName).Replace('\', '/') - configuration = $configuration - label = "$($project.BaseName)$(if ($suffix) { ' (Release)' })" - artifact = "$($project.BaseName)$suffix" - } - } - } - $matrix = ConvertTo-Json -Compress -InputObject ([ordered]@{ include = @($include) }) - "matrix=$matrix" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - Write-Host "Found $($projects.Count) test projects." - $projects | ForEach-Object { Write-Host " $($_.BaseName)" } - native: name: Build native bridge runs-on: windows-latest @@ -78,7 +51,7 @@ jobs: persist-credentials: false - name: Setup xmake - uses: xmake-io/github-action-setup-xmake@3a1a5dddfc7fa625d9a698738334bf55655a861a # v1 + uses: xmake-io/github-action-setup-xmake@3a1a5dddfc7fa625d9a698738334bf55655a861a # v1.2.5 with: xmake-version: '3.0.9' @@ -169,6 +142,7 @@ jobs: # This compiles the C++ transcription under MSVC x64 and validates its emitted facts. It proves the checked-in # header fixture and managed layout numbers agree; it does not contact or qualify a live Cheat Engine host. + # The Debug build-test leg compares these facts with the managed ABI measurements. - name: Build and validate classic ABI fixture run: | $ErrorActionPreference = 'Stop' @@ -177,30 +151,6 @@ jobs: throw "Classic ABI fixture build failed with exit code $LASTEXITCODE." } - # The fixture's fixed facts are first schema-validated in its build script. Build the managed ABI test app and - # pass that same file to its direct comparer so C++ x64 measurements are checked against managed sizeof, offset, - # and alignment measurements in one CI execution. The executable is used deliberately: native MTP currently - # discovers this xUnit v3 app reliably through its generated host, while the SDK command only sees its module. - - name: Setup .NET for classic ABI fact comparison - uses: ./.github/actions/setup-dotnet - with: - restore: tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj - - - name: Compare native fixture facts with managed ABI measurements - env: - CE77_NATIVE_ABI_FACTS_PATH: ${{ github.workspace }}/artifacts/native-abi-fixture/ce77-native-abi-facts.txt - CE77_NATIVE_ABI_REQUIRED: 'true' - run: | - $ErrorActionPreference = 'Stop' - dotnet build tests/CheatEngine.SDK.Abi.Tests/CheatEngine.SDK.Abi.Tests.csproj -c Debug --no-restore --disable-build-servers - if ($LASTEXITCODE -ne 0) { - throw "Managed ABI fact comparer build failed with exit code $LASTEXITCODE." - } - & ./artifacts/bin/CheatEngine.SDK.Abi.Tests/debug/CheatEngine.SDK.Abi.Tests.exe --fail-skips on - if ($LASTEXITCODE -ne 0) { - throw "Managed ABI fact comparer exited with code $LASTEXITCODE." - } - - name: Upload classic ABI fixture facts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -209,16 +159,23 @@ jobs: if-no-files-found: error retention-days: 14 - build: - name: Build + build-test: + name: Build and test (${{ matrix.configuration }}) needs: native runs-on: windows-latest - timeout-minutes: 20 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + configuration: [ Debug, Release ] + env: + CONFIGURATION: ${{ matrix.configuration }} + RESULTS: artifacts/test-results/${{ matrix.configuration }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 # MinVer computes the version from tags and history + fetch-depth: 0 # MinVer and the packaging tests need tags and full history persist-credentials: false - name: Setup .NET @@ -232,159 +189,92 @@ jobs: name: lua-protection-bridge path: native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native - # Tests run against Debug, where some guards exist only. Release is what ships. - - name: Build Debug - run: | - dotnet build CheatEngine.SDK.slnx --no-restore -c Debug - if ($LASTEXITCODE -ne 0) { - throw "Debug solution build failed with exit code $LASTEXITCODE." - } - - # Do not rely only on the per-project matrix below: native MTP must also discover every test executable when - # invoked through the solution. The preceding build is deliberately part of this job, so --no-build cannot - # silently exercise stale or incomplete test-host output. - - name: Test Debug solution discovery - run: | - dotnet test --solution CheatEngine.SDK.slnx --no-build --no-restore -c Debug --fail-skips on - if ($LASTEXITCODE -ne 0) { - throw "Debug solution test discovery failed with exit code $LASTEXITCODE." - } + - name: Use classic ABI fixture facts + if: matrix.configuration == 'Debug' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: classic-abi-fixture-facts + path: artifacts/native-abi-fixture - - name: Build Release + - name: Build run: | - dotnet build CheatEngine.SDK.slnx --no-restore -c Release + dotnet build CheatEngine.SDK.slnx -c $env:CONFIGURATION --no-restore if ($LASTEXITCODE -ne 0) { - throw "Release solution build failed with exit code $LASTEXITCODE." + throw "$env:CONFIGURATION solution build failed with exit code $LASTEXITCODE." } - test: - name: Test ${{ matrix.label }} - needs: [ discover, native ] - runs-on: windows-latest - timeout-minutes: 10 - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.discover.outputs.matrix) }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 # MinVer computes the version from tags and history - persist-credentials: false - - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - - - name: Use CI-built native bridge - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: lua-protection-bridge - path: native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native - - # A skipped test fails the Debug job: NativeLua tests skip when the bundled Lua DLL does not bind, and a green check must not hide that. - # Release keeps one legitimate skip, a guard that only exists in Debug. + # One parallel run over every tests/**/*.Tests project of the solution, which also proves that the solution + # discovers every test module. A skip fails both configurations: NativeLua tests skip when the bundled Lua DLL + # does not bind, and a green check must not hide that. In Debug, the facts the native job built make the managed + # ABI comparison mandatory, and every module writes its own GUID-named coverage report. - name: Test - env: - PROJECT: ${{ matrix.project }} - CONFIGURATION: ${{ matrix.configuration }} - RESULTS: artifacts/test-results/${{ matrix.artifact }} - COLLECT_COVERAGE: ${{ inputs.collect-coverage }} run: | - $options = '--project', $env:PROJECT, '-c', $env:CONFIGURATION, '--report-trx', '--results-directory', $env:RESULTS - if ($env:CONFIGURATION -eq 'Debug') { $options += '--fail-skips', 'on' } - if ($env:CONFIGURATION -eq 'Debug' -and $env:COLLECT_COVERAGE -eq 'true') { $options += '--coverage', '--coverage-output-format', 'xml', '--coverage-output', 'coverage.xml' } + $options = @( + '--solution', 'CheatEngine.SDK.slnx', '-c', $env:CONFIGURATION, '--no-build', '--results-directory', $env:RESULTS, + '--fail-skips', 'on', '--report-trx', '--report-gh', '--report-gh-groups', 'off' + ) + if ($env:CONFIGURATION -eq 'Debug') { + $env:CE77_NATIVE_ABI_FACTS_PATH = Join-Path $env:GITHUB_WORKSPACE 'artifacts/native-abi-fixture/ce77-native-abi-facts.txt' + $env:CE77_NATIVE_ABI_REQUIRED = 'true' + $options += '--coverage', '--coverage-output-format', 'xml' + } dotnet test @options if ($LASTEXITCODE -ne 0) { - throw "Test project '$env:PROJECT' failed with exit code $LASTEXITCODE." + throw "$env:CONFIGURATION tests failed with exit code $LASTEXITCODE." } - - - name: Summarize results - if: ${{ !cancelled() }} - env: - NAME: ${{ matrix.label }} - RESULTS: artifacts/test-results/${{ matrix.artifact }} - run: | - $files = @(Get-ChildItem -Path $env:RESULTS -Filter *.trx -File -ErrorAction SilentlyContinue) - if ($files.Count -eq 0) { Write-Host '::warning::The test run produced no TRX report.'; return } - $lines = foreach ($file in $files) { - $results = @(([xml](Get-Content -LiteralPath $file.FullName -Raw)).TestRun.Results.UnitTestResult | Where-Object { $_ }) - $passed = @($results | Where-Object outcome -eq 'Passed').Count - $skipped = @($results | Where-Object outcome -eq 'NotExecuted').Count - $failed = @($results | Where-Object { $_.outcome -notin 'Passed', 'NotExecuted' }) - "### $env:NAME" - '' - '| Total | Passed | Failed | Skipped |' - '| ---: | ---: | ---: | ---: |' - "| $($results.Count) | $passed | $($failed.Count) | $skipped |" - if ($failed.Count -gt 0) { - '' - $failed | Select-Object -First 20 | ForEach-Object { "- ``$($_.testName)``" } - $rest = @($failed | Select-Object -Skip 20).Count - if ($rest -gt 0) { "- and $rest more" } + if ($env:CONFIGURATION -eq 'Debug') { + $modules = @(Get-ChildItem -LiteralPath $env:RESULTS -Filter *.trx -File).Count + $reports = @(Get-ChildItem -LiteralPath $env:RESULTS -Filter *.xml -File).Count + if ($reports -eq 0 -or $reports -ne $modules) { + throw "Expected one coverage report per test module ($modules), found $reports." } } - $lines | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - - - name: Upload test results - if: ${{ !cancelled() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: test-results-${{ matrix.artifact }} - path: artifacts/test-results/${{ matrix.artifact }}/*.trx - if-no-files-found: warn - retention-days: 14 - - - name: Upload coverage - if: ${{ inputs.collect-coverage && matrix.configuration == 'Debug' && !cancelled() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: coverage-${{ matrix.name }} - path: artifacts/test-results/${{ matrix.artifact }}/coverage.xml - if-no-files-found: error - retention-days: 7 - - pack: - name: Pack - needs: native - runs-on: windows-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 # MinVer computes the version from tags and history - persist-credentials: false - - - name: Setup .NET - uses: ./.github/actions/setup-dotnet - - - name: Use CI-built native bridge - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: lua-protection-bridge - path: native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native + # The shipped package comes from the build the tests just ran against. The pack is incremental: nothing recompiles. - name: Pack + if: matrix.configuration == 'Release' + env: + PACKAGE_VERSION: ${{ inputs.package-version }} run: | - dotnet pack src/CheatEngine.SDK -c Release -o artifacts/nuget + dotnet pack src/CheatEngine.SDK -c Release --no-restore -o artifacts/nuget if ($LASTEXITCODE -ne 0) { throw "SDK package creation failed with exit code $LASTEXITCODE." } - - - name: Verify package - run: | $packages = @(Get-ChildItem -Path artifacts/nuget -Filter *.nupkg -File) - if ($packages.Count -ne 1) { throw "Expected one package in artifacts/nuget, found $($packages.Count)." } - Write-Host "Packed $($packages[0].Name)." + if ($packages.Count -ne 1) { + throw "Expected one package in artifacts/nuget, found $($packages.Count)." + } + if ($env:PACKAGE_VERSION -and $packages[0].Name -ne "CheatEngine.SDK.$env:PACKAGE_VERSION.nupkg") { + throw "Packed $($packages[0].Name), but the release requires CheatEngine.SDK.$env:PACKAGE_VERSION.nupkg." + } + "Packed ``$($packages[0].Name)``." | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - name: Upload package - if: ${{ inputs.upload-package }} + if: matrix.configuration == 'Release' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: nuget-package path: artifacts/nuget/*.nupkg if-no-files-found: error - retention-days: 90 + 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 aot: name: Native AOT publication probe @@ -467,24 +357,29 @@ jobs: & $harness --load --acknowledge-process-resident-load if ($LASTEXITCODE -ne 0) { throw "Native AOT loader harness load exited with code $LASTEXITCODE." } + # Secrets never reach fork or Dependabot runs, and a merge-queue branch is analysed again once it lands on main. + # Pull requests fail on the quality gate; main only reports it. sonar: name: Sonar - needs: [ native, test ] - if: ${{ inputs.collect-coverage }} + 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: true + wait-quality-gate: ${{ github.event_name == 'pull_request' }} secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - lint-workflows: + lint: name: Lint workflows - if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft == false }} - runs-on: windows-latest + runs-on: ubuntu-latest timeout-minutes: 5 env: ACTIONLINT_VERSION: 1.7.12 - ACTIONLINT_SHA256: 6e7241b51e6817ea6a047693d8e6fed13b31819c9a0dd6c5a726e1592d22f6e9 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 # linux_amd64, official checksums file steps: - name: Checkout workflow definitions uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -495,57 +390,47 @@ jobs: - name: Run actionlint run: | $ErrorActionPreference = 'Stop' - $archive = Join-Path $env:RUNNER_TEMP 'actionlint.zip' - $url = "https://github.com/rhysd/actionlint/releases/download/v$env:ACTIONLINT_VERSION/actionlint_$($env:ACTIONLINT_VERSION)_windows_amd64.zip" + $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." } - $destination = Join-Path $env:RUNNER_TEMP 'actionlint' - Expand-Archive -LiteralPath $archive -DestinationPath $destination - & (Join-Path $destination 'actionlint.exe') -color + 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." } gate: name: Gate - if: ${{ always() }} - needs: [ discover, native, build, test, pack, aot, sonar, lint-workflows ] - runs-on: windows-latest + # always(): a failed or cancelled job must turn the required check red instead of skipping it. + if: always() + needs: [ native, build-test, aot, sonar, lint ] + runs-on: ubuntu-latest timeout-minutes: 5 permissions: { } steps: - name: Check results env: - DISCOVER_RESULT: ${{ needs.discover.result }} - NATIVE_RESULT: ${{ needs.native.result }} - BUILD_RESULT: ${{ needs.build.result }} - TEST_RESULT: ${{ needs.test.result }} - PACK_RESULT: ${{ needs.pack.result }} - AOT_RESULT: ${{ needs.aot.result }} - SONAR_RESULT: ${{ needs.sonar.result }} - SONAR_REQUIRED: ${{ inputs.collect-coverage }} - LINT_RESULT: ${{ needs.lint-workflows.result }} + NEEDS: ${{ toJSON(needs) }} run: | - $results = [ordered]@{ - discover = $env:DISCOVER_RESULT - native = $env:NATIVE_RESULT - build = $env:BUILD_RESULT - test = $env:TEST_RESULT - pack = $env:PACK_RESULT - aot = $env:AOT_RESULT - sonar = $env:SONAR_RESULT - 'lint-workflows' = $env:LINT_RESULT - } - $rows = $results.GetEnumerator() | ForEach-Object { "| $($_.Key) | $($_.Value) |" } - '| Job | Result |', '| --- | --- |', $rows | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 - $failed = @($results.GetEnumerator() | Where-Object { - $_.Key -in 'discover', 'native', 'build', 'test', 'pack', 'aot' -and $_.Value -ne 'success' -or - $_.Key -eq 'sonar' -and $env:SONAR_REQUIRED -eq 'true' -and $_.Value -ne 'success' -or - $_.Key -eq 'lint-workflows' -and $_.Value -ne 'success' - } | ForEach-Object Key) + # Every job must succeed. Only sonar may be skipped: its own condition turns it off for releases, the merge + # queue, forks and Dependabot. A sonar skipped because build-test failed is caught by build-test's result. + $mayBeSkipped = @('sonar') + $needs = $env:NEEDS | ConvertFrom-Json -AsHashtable + $failed = [Collections.Generic.List[string]]::new() + $rows = foreach ($job in @($needs.Keys | Sort-Object)) { + $result = $needs[$job].result + $allowed = if ($job -in $mayBeSkipped) { 'success', 'skipped' } else { 'success' } + if ($result -notin $allowed) { $failed.Add("$job ($result)") } + "| $job | $result |" + } + @('| Job | Result |', '| --- | --- |') + $rows | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 if ($failed.Count -gt 0) { Write-Host "::error::Not successful: $($failed -join ', ')." exit 1 diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 08cc25a8..baaadf1f 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -7,9 +7,7 @@ on: types: [ checks_requested ] workflow_dispatch: -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false +# No concurrency group: every main commit and merge group keeps its own complete run. permissions: contents: read @@ -19,6 +17,6 @@ jobs: name: CI uses: ./.github/workflows/ci.yml with: - collect-coverage: ${{ vars.SONAR_CI_ENABLED == 'true' && github.ref == 'refs/heads/main' }} + sonar: true # ci.yml skips it for the merge queue and only reports the quality gate outside pull requests secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index 0b1f45e0..c3502c32 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -4,6 +4,7 @@ on: pull_request: types: [ opened, synchronize, reopened, ready_for_review ] +# A new push supersedes the previous run of the same pull request. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true @@ -14,13 +15,10 @@ permissions: jobs: ci: name: CI - if: github.event.pull_request.draft == false + # Drafts do not run CI. "Ready for review" starts it; until then the required CI / Gate check stays pending. + if: ${{ !github.event.pull_request.draft }} uses: ./.github/workflows/ci.yml with: - collect-coverage: >- - ${{ vars.SONAR_CI_ENABLED == 'true' - && github.event.pull_request.draft == false - && github.event.pull_request.head.repo.full_name == github.repository - && github.event.pull_request.user.login != 'dependabot[bot]' }} + sonar: true # ci.yml still skips Sonar for fork and Dependabot pull requests, which receive no secrets secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd159261..4c0a2acd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,9 @@ name: Release run-name: Release ${{ github.ref_name }}${{ github.event_name == 'workflow_dispatch' && ' (dry run)' || '' }} +# verify ─► ci (build and test the tag, pack the tested build) ─► publish (manual approval) ─► github-release +# The nuget-package artifact built and tested by ci is the exact file pushed to nuget.org and attached to the release. + on: push: tags: @@ -15,6 +18,10 @@ concurrency: permissions: contents: read +defaults: + run: + shell: pwsh + jobs: verify: name: Verify tag @@ -34,7 +41,6 @@ jobs: # Only a commit on the first-parent line of main, the merged head, may be released. - name: Verify tag id: tag - shell: pwsh run: | $ErrorActionPreference = 'Stop' @@ -65,51 +71,81 @@ jobs: exit 1 } + # A full re-run after a successful publish would rebuild a different file for an immutable version. Re-run + # only the failed jobs of the original run: they reuse its artifacts. + try { + $index = Invoke-RestMethod -Uri 'https://api.nuget.org/v3-flatcontainer/cheatengine.sdk/index.json' -MaximumRetryCount 3 -RetryIntervalSec 5 + } + catch { + Write-Host "::error::Could not read the published CheatEngine.SDK versions from nuget.org: $($_.Exception.Message)" + exit 1 + } + if (@($index.versions) -contains $version.ToLowerInvariant()) { + Write-Host "::error::CheatEngine.SDK $version is already on nuget.org. Re-run only the failed jobs of the original run, or tag a new version." + exit 1 + } + $prerelease = if ($version.Contains('-')) { 'true' } else { 'false' } "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 "prerelease=$prerelease" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - ci: - name: CI - needs: verify - uses: ./.github/workflows/ci.yml - with: - upload-package: true - test-release: true - - package: - name: Check package - needs: [ verify, ci ] - runs-on: windows-latest - timeout-minutes: 5 - steps: - - name: Download package - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: nuget-package - path: artifacts/nuget - - - name: Check contents - shell: pwsh + # The GitHub release notes are the CHANGELOG section of the version. A prerelease may ship before its entries + # leave [Unreleased]; a final release may not. + - name: Extract release notes + if: steps.tag.outputs.version != '' env: - VERSION: ${{ needs.verify.outputs.version }} + VERSION: ${{ steps.tag.outputs.version }} + PRERELEASE: ${{ steps.tag.outputs.prerelease }} run: | $ErrorActionPreference = 'Stop' - $names = @(Get-ChildItem -Path artifacts/nuget -File | ForEach-Object Name) - $expected = if ($env:VERSION) { "CheatEngine.SDK.${env:VERSION}.nupkg" } else { 'CheatEngine.SDK.*.nupkg' } - if ($names.Count -ne 1 -or $names[0] -notlike $expected) { - Write-Host "::error::The package artifact must contain exactly $expected, but it contains: $($names -join ', ')." + $changelog = Get-Content -LiteralPath CHANGELOG.md -Raw + $sections = @($env:VERSION) + if ($env:PRERELEASE -eq 'true') { $sections += 'Unreleased' } + $notes = '' + foreach ($section in $sections) { + $pattern = '(?ms)^## \[' + [regex]::Escape($section) + '\][^\r\n]*\r?\n(?.*?)(?=^## \[|^\[[^\]]+\]:|\z)' + $match = [regex]::Match($changelog, $pattern) + if ($match.Success -and $match.Groups['body'].Value.Trim()) { + $notes = $match.Groups['body'].Value.Trim() + break + } + } + if (-not $notes) { + Write-Host "::error file=CHANGELOG.md::CHANGELOG.md has no non-empty section for $($sections -join ' or '). Move the release entries under '## [$env:VERSION] - ' before tagging." exit 1 } + New-Item -ItemType Directory -Path artifacts -Force | Out-Null + @($notes, '', "NuGet package: https://www.nuget.org/packages/CheatEngine.SDK/$env:VERSION") -join "`n" | + Set-Content -LiteralPath artifacts/release-notes.md -Encoding utf8NoBOM + + - name: Upload release notes + if: steps.tag.outputs.version != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-notes + path: artifacts/release-notes.md + if-no-files-found: error + retention-days: 90 + + # Build and test on the tag commit: MinVer stamps the tag version at compile time, so a main build cannot be + # promoted. Sonar already analysed this commit on main. + ci: + name: CI + needs: verify + uses: ./.github/workflows/ci.yml + with: + package-version: ${{ needs.verify.outputs.version }} + package-retention-days: 90 + # 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, package ] + needs: [ verify, ci ] if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'CheatEngineNet/CheatEngine.SDK' runs-on: windows-latest timeout-minutes: 15 environment: - name: nuget + name: nuget # required reviewers approve the publication; the environment only admits v*.*.* tags url: https://www.nuget.org/packages/CheatEngine.SDK/${{ needs.verify.outputs.version }} permissions: contents: read @@ -118,7 +154,7 @@ jobs: env: DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: true - VERSION: ${{ needs.verify.outputs.version }} + PACKAGE: artifacts/nuget/CheatEngine.SDK.${{ needs.verify.outputs.version }}.nupkg steps: - name: Download package uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -126,11 +162,6 @@ jobs: name: nuget-package path: artifacts/nuget - - name: Attest provenance - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: artifacts/nuget/CheatEngine.SDK.${{ env.VERSION }}.nupkg - # The user is the nuget.org profile name that registered the trusted publishing policy, not an email address. - name: NuGet login id: login @@ -140,10 +171,19 @@ jobs: # A re-run after a partial failure must not fail on a version that is already published. - name: Push package - shell: pwsh env: NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} - run: dotnet nuget push "artifacts/nuget/CheatEngine.SDK.${env:VERSION}.nupkg" --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate + run: | + dotnet nuget push $env:PACKAGE --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate + if ($LASTEXITCODE -ne 0) { + throw "NuGet push failed with exit code $LASTEXITCODE." + } + + # After the push, so a failed push never leaves an attestation for a package that was not published. + - name: Attest provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ env.PACKAGE }} github-release: name: Create GitHub release @@ -165,17 +205,27 @@ jobs: name: nuget-package path: artifacts/nuget + - name: Download release notes + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-notes + path: artifacts + - name: Create release - shell: pwsh run: | - $name = "CheatEngine.SDK.${env:VERSION}.nupkg" + $name = "CheatEngine.SDK.$env:VERSION.nupkg" + $package = "artifacts/nuget/$name" $attached = gh release view $env:TAG --json assets --jq '.assets[].name' 2>$null if ($LASTEXITCODE -ne 0) { - $options = @('--verify-tag', '--generate-notes', '--notes', "NuGet package: https://www.nuget.org/packages/CheatEngine.SDK/$env:VERSION") + $options = @('--verify-tag', '--title', $env:TAG, '--notes-file', 'artifacts/release-notes.md') if ($env:PRERELEASE -eq 'true') { $options += '--prerelease' } - gh release create $env:TAG "artifacts/nuget/$name" @options + gh release create $env:TAG $package @options } elseif ($attached -notcontains $name) { - gh release upload $env:TAG "artifacts/nuget/$name" + gh release upload $env:TAG $package } else { Write-Host "::notice::Release $env:TAG already carries $name." + exit 0 + } + if ($LASTEXITCODE -ne 0) { + throw "Creating the GitHub release failed with exit code $LASTEXITCODE." } diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index db215816..8c7d4c2c 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -1,5 +1,8 @@ name: Sonar +# Reusable SonarQube Cloud analysis, called by ci.yml after build-test. It reuses the coverage and nuget-package +# artifacts of the same run; only the instrumented build is repeated, because the scanner must observe a compilation. + on: workflow_call: inputs: @@ -12,20 +15,18 @@ on: type: string default: cheatenginenet wait-quality-gate: - description: Fail the job when the quality gate fails. + description: Fail the job when the quality gate fails. Pull requests wait for it; main only reports it. type: boolean default: true secrets: SONAR_TOKEN: - description: SonarQube Cloud token. - required: true + description: SonarQube Cloud analysis token. The job fails fast when it is empty. + required: false permissions: contents: read env: - DOTNET_NOLOGO: true - DOTNET_CLI_TELEMETRY_OPTOUT: true SONAR_SCANNER_VERSION: 11.3.0 defaults: @@ -42,32 +43,21 @@ jobs: 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: | - $ErrorActionPreference = 'Stop' - if ([string]::IsNullOrWhiteSpace($env:SONAR_TOKEN)) { - throw 'SONAR_TOKEN is required when SONAR_CI_ENABLED is true.' - } - - # CI analysis and Automatic Analysis cannot run for the same SonarQube Cloud project. Keep this preflight before - # checkout so a PR cannot turn a configuration failure into arbitrary checkout code running in this tokened job. - - name: Require CI-based analysis - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: | - $ErrorActionPreference = 'Stop' - $uri = "https://sonarcloud.io/api/settings/values?component=$env:SONAR_PROJECT_KEY&keys=sonar.autoscan.enabled" - $response = Invoke-RestMethod -Uri $uri -Headers @{ Authorization = "Bearer $env:SONAR_TOKEN" } -TimeoutSec 30 -MaximumRetryCount 2 -RetryIntervalSec 3 - if (@($response.settings | Where-Object { $_.key -eq 'sonar.autoscan.enabled' -and $_.value -eq 'true' })) { - throw "Automatic Analysis is enabled on $env:SONAR_PROJECT_KEY. Disable it in SonarQube Cloud: Administration > Analysis Method before enabling SONAR_CI_ENABLED." + 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 + fetch-depth: 0 # SCM blame for new-code detection persist-credentials: false # SonarScanner for .NET uses Java. Pin the same JDK 21 distribution as CheatEngine.Client so scanner behavior is @@ -78,25 +68,24 @@ jobs: distribution: zulu java-version: '21' - - name: Install pinned .NET SDK - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - global-json-file: global.json + # SDK and CLI settings only: the restore below must not use the checked-out nuget.config. + - name: Setup .NET + uses: ./.github/actions/setup-dotnet - - name: Use CI-built native bridge + - name: Download coverage uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: lua-protection-bridge - path: native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native + name: coverage + path: ${{ runner.temp }}/coverage - - name: Download coverage + - name: Download package uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: coverage-* - path: ${{ runner.temp }}/coverage + name: nuget-package + path: ${{ runner.temp }}/package - # Do not use the checked-out nuget.config to resolve tooling. This job later uses SONAR_TOKEN, so the scanner - # package and every restored dependency must come only from nuget.org, not a source a pull request can redirect. + # 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: | @@ -133,7 +122,8 @@ jobs: 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. + # 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 }}"}' @@ -141,10 +131,18 @@ jobs: $coverage = "$env:RUNNER_TEMP/coverage" $reports = @(Get-ChildItem -Path $coverage -Filter *.xml -Recurse -File -ErrorAction SilentlyContinue) if ($reports.Count -eq 0) { - Write-Host '::error::No coverage report was downloaded. Call ci.yml with collect-coverage set to true.' + Write-Host '::error::The coverage artifact holds no report.' + exit 1 + } + # The new-code period follows sonar.projectVersion. The core version of the package built in this run (1.0.1 + # for 1.0.1-alpha.0.37) changes only at a release, so new code on main means changes since the last release. + $packages = @(Get-ChildItem -Path "$env:RUNNER_TEMP/package" -Filter 'CheatEngine.SDK.*.nupkg' -File) + if ($packages.Count -ne 1 -or $packages[0].Name -notmatch '^CheatEngine\.SDK\.(?\d+\.\d+\.\d+)') { + Write-Host '::error::The nuget-package artifact must hold exactly one CheatEngine.SDK package.' exit 1 } - Write-Host "Coverage reports: $($reports.Count)" + $version = $Matches['version'] + Write-Host "Coverage reports: $($reports.Count); project version: $version" # These findings conflict with deliberate repository contracts. Keep them in the scanner configuration so # production and test sources do not need Sonar-only attributes or code changes. $ignoredIssues = @( @@ -179,6 +177,7 @@ jobs: $arguments = @( "/k:$env:SONAR_PROJECT_KEY" "/o:$env:SONAR_ORGANIZATION" + "/v:$version" '/d:sonar.exclusions=artifacts/**,tests/CheatEngine.SDK.Benchmarks/**,tests/CheatEngine.SDK.LivePlugin/**' # CI publishes managed coverage only. Keep build-time tooling and test-only sources out of the product # coverage metric instead of presenting an incomplete report as if it covered those paths. @@ -197,6 +196,7 @@ jobs: throw "SonarScanner begin failed with exit code $LASTEXITCODE." } + # The scanner turns TreatWarningsAsErrors off for this build, which is why build-test, not this job, gates. - name: Build run: | dotnet build CheatEngine.SDK.slnx -c Debug --no-restore --no-incremental --disable-build-servers diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index b283127b..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,59 +0,0 @@ -# Repository Guidelines - -## Project Structure & Module Organization - -CheatEngine.SDK is a Windows x64, .NET 10 SDK for Cheat Engine 7.7 plugins. - -- `libs/`: layered assemblies for annotations, ABI definitions, Lua interop, Lua operations, engine APIs, and hosting. -- `src/CheatEngine.SDK/`: umbrella NuGet package and consumer build properties. -- `source-generators/` and `analyzers/`: generated bindings, entry points, diagnostics, and code fixes. -- `native/`: bundled Cheat Engine Lua test DLL and the source plus prebuilt Windows x64 Lua protection bridge. -- `tests/`: matching test projects, shared native fixtures, benchmarks, and `CheatEngine.SDK.LivePlugin`. -- `exemples/`: guides, recipes, and API documentation; preserve this directory spelling. -- `eng/` and `.github/`: shared build configuration and CI. Treat `artifacts/` as generated output. - -## Build, Test, and Development Commands - -Use the SDK selected by `global.json` (10.0.401, `latestFeature`). Ordinary managed builds use the checked-in bridge -binary and need no C toolchain. Only bridge maintainers and CI rebuild it, using xmake and a Windows x64 C compiler. - -```powershell -dotnet restore CheatEngine.SDK.slnx -dotnet build CheatEngine.SDK.slnx -c Debug --no-restore -dotnet test --solution CheatEngine.SDK.slnx -c Debug --fail-skips on -dotnet test --solution CheatEngine.SDK.slnx -c Release -dotnet pack src/CheatEngine.SDK -c Release -o artifacts/nuget -``` - -These restore dependencies, compile the solution, validate both configurations, and produce the package. For host -testing, follow `tests/CheatEngine.SDK.LivePlugin/README.md`; keep the complete plugin output together, including -`cheatengine-sdk-lua-bridge.dll`. Configure Cheat Engine to use .NET 10 explicitly. - -## Coding Style & Naming Conventions - -Follow `.editorconfig`: UTF-8; C# uses tabs for logical nesting and spaces only for continuation alignment; -configuration/project files use two spaces. `.gitattributes` owns line-ending normalization for the working tree. Use -file-scoped namespaces, explicit accessibility, PascalCase public members, and existing local naming patterns. Preserve -native Lua identifiers and ABI layouts. - -Builds enforce compiler and analyzer diagnostics as errors, with configured exceptions. Document public APIs and provide -a README beside every project. - -**LINQ is forbidden**, including query expressions and `System.Linq` operators. Use explicit loops and collection APIs -to control allocations and iteration costs. Avoid unrelated refactors. - -## Testing Guidelines - -Tests use xUnit v3 with Microsoft.Testing.Platform. Name tests `Subject_condition_expected`; add focused regressions for -behavioral fixes. Debug validation rejects skips; Release permits the existing Debug-only guard skip. Coverage can be -collected with `--coverage --coverage-output-format xml`. - -Native tests use the bundled Lua DLL. Preserve stack balance, callback lifetimes, ownership, and native error -boundaries. Distinguish fixture results from live Cheat Engine verification. - -## Commit & Pull Request Guidelines - -History uses imperative subjects such as `Fix CI validation findings`; no conventional-commit prefix is required. Do not -add `Co-authored-by` trailers to commits. Keep commits focused. PR descriptions should explain the problem, resulting -behavior, relevant issues, validation commands/results, and remaining live-host limitations. Update affected -documentation and report build, test, and package results before requesting review. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 311c0495..ae4836dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ Run these commands from the repository root: dotnet restore CheatEngine.SDK.slnx dotnet build CheatEngine.SDK.slnx -c Debug --no-restore dotnet test --solution CheatEngine.SDK.slnx -c Debug --fail-skips on -dotnet test --solution CheatEngine.SDK.slnx -c Release +dotnet test --solution CheatEngine.SDK.slnx -c Release --fail-skips on ``` To create the package locally: @@ -30,9 +30,27 @@ To create the package locally: dotnet pack src/CheatEngine.SDK -c Release -o artifacts/nuget ``` -The CI workflow builds Debug and Release, and tests each `tests/**/*.Tests.csproj` project. Debug tests treat skipped -tests as failures; Release permits the repository's existing Debug-only guard skip. For manual host validation, use -the [live-plugin guide](tests/CheatEngine.SDK.LivePlugin/README.md). +For manual host validation, use the [live-plugin guide](tests/CheatEngine.SDK.LivePlugin/README.md). + +## Continuous integration + +Pull requests run `Pull request CI`, pushes to `main` run `Main CI`, and version tags run `Release`. All three call the +reusable [`ci.yml`](.github/workflows/ci.yml), which builds each thing once and passes it on as an artifact: + +1. `native` rebuilds the Lua bridge twice to prove it is reproducible, checks the checked-in DLL against its source, + and builds the classic ABI fixture facts. +2. `build-test` builds the solution once per configuration (Debug and Release) and runs every + `tests/**/*.Tests` project in a single `dotnet test --solution` run. A skipped test fails both configurations. + Debug also collects coverage and compares the ABI fixture facts with the managed layouts; Release packs the tested + build as the `nuget-package` artifact, which you can download from the run. +3. `aot` publishes and runs the Native AOT probes. +4. `sonar` analyzes the code with SonarQube Cloud from the Debug coverage. It runs for branches of this repository only; + fork and Dependabot pull requests skip it. The quality gate fails pull requests and is only reported on `main`. +5. `lint` runs actionlint on the workflows. + +`CI / Gate` is the only required check: it fails when any job fails, and only `sonar` may be skipped. Drafts do not +run CI until they are marked ready for review. CodeRabbit reviews every pull request, but its findings and pre-merge +checks are advisory. ## Style and analyzers @@ -55,9 +73,11 @@ rules and their fixes. 2. Make the smallest change that solves the problem. 3. Run the relevant build, test, and package commands. 4. Open a pull request against `main`; do not push directly to the protected branch. +5. Record consumer-visible changes under `[Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md). -PR descriptions should state the problem, resulting behavior, related issues, validation commands and results, and any -remaining live-host limitations. Include documentation changes that the work requires. +Fill in the pull request template: state the problem, resulting behavior, validation commands and results, and any +remaining live-host limitations. Include documentation changes that the work requires. Pull requests are +squash-merged once `CI / Gate` passes, so the pull request title becomes the commit subject on `main`. ## Commits @@ -68,5 +88,6 @@ are not required. Do not add `Co-authored-by` trailers. Versions are derived by MinVer from the nearest `v*` tag; the current minimum major/minor line is `1.0`, as configured in [`Directory.Build.props`](Directory.Build.props). Pushing a valid `v..` tag (an optional SemVer -prerelease is allowed) starts the release workflow. After verification, CI, and package checks, that workflow publishes -`CheatEngine.SDK` to NuGet and creates a GitHub release. +prerelease is allowed) starts the release workflow. It builds and tests the tag, waits for manual approval on the +`nuget` environment, publishes the tested package to NuGet, and creates a GitHub release whose notes are the +`CHANGELOG.md` section of that version. See [`RELEASING.md`](RELEASING.md). diff --git a/CheatEngine.SDK.slnx b/CheatEngine.SDK.slnx index 82b0bfe7..5948c8b7 100644 --- a/CheatEngine.SDK.slnx +++ b/CheatEngine.SDK.slnx @@ -23,6 +23,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 21135554..f07b0ced 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,6 +20,8 @@ + + diff --git a/RELEASING.md b/RELEASING.md index b189e699..978a97fe 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,8 +1,14 @@ # Releasing CheatEngine.SDK -NuGet releases are produced by `.github/workflows/release.yml` from version tags. The workflow verifies, builds, tests, -packs, attests, and publishes the package before creating the matching GitHub release. Do not upload a locally built -package manually: nuget.org versions are immutable, and the workflow artifact is the release artifact. +NuGet releases are produced by `.github/workflows/release.yml` from version tags: + +```text +verify ─► ci (build and test the tag, pack the tested build) ─► publish (manual approval) ─► github-release +``` + +The `nuget-package` artifact that the `ci` job builds and tests is the exact file pushed to nuget.org, attested, and +attached to the GitHub release. Do not upload a locally built package manually: nuget.org versions are immutable, and +the workflow artifact is the release artifact. ## One-time trusted publishing setup @@ -23,12 +29,16 @@ The GitHub `nuget` environment must contain an environment secret named `NUGET_U nuget.org username of the administrator who created the policy, currently `AriusII`, not the organization name and not an email address. Organization membership alone does not make another username valid for that policy; if a different administrator recreates it, update `NUGET_USER` to that policy creator. Restrict the environment to deployment tags -matching `v*.*.*`. The workflow exchanges GitHub's OIDC token for a one-use, short-lived NuGet API key through -`NuGet/login`; it must not store a long-lived NuGet API key. +matching `v*.*.*` and add the maintainers who approve publications as required reviewers. The workflow exchanges +GitHub's OIDC token for a one-use, short-lived NuGet API key through `NuGet/login`; it must not store a long-lived NuGet +API key. Because the policy names `release.yml` and `nuget`, the login and push steps must stay in the `publish` job of +that file. ## Prepare a release -1. Move the completed entries from `Unreleased` to a versioned section in `CHANGELOG.md` and use the release date. +1. Move the completed entries from `Unreleased` to a `## [X.Y.Z] - YYYY-MM-DD` section in `CHANGELOG.md` and add its + link reference. The body of that section becomes the GitHub release notes: a stable tag fails without it. A + prerelease tag uses its own `## [X.Y.Z-rc.N]` section when present, otherwise the `[Unreleased]` section. 2. Update version-specific examples and analyzer release tracking when the public baseline changes. 3. Set `MinVerMinimumMajorMinor` in `Directory.Build.props` to the release line. The exact version still comes from the `v..` tag. @@ -38,12 +48,17 @@ matching `v*.*.*`. The workflow exchanges GitHub's OIDC token for a one-use, sho dotnet restore CheatEngine.SDK.slnx dotnet build CheatEngine.SDK.slnx -c Debug --no-restore dotnet test --solution CheatEngine.SDK.slnx -c Debug --fail-skips on - dotnet test --solution CheatEngine.SDK.slnx -c Release + dotnet test --solution CheatEngine.SDK.slnx -c Release --fail-skips on dotnet pack src/CheatEngine.SDK -c Release -o artifacts/nuget -p:MinVerVersionOverride=1.0.0 --no-restore ``` Replace `1.0.0` only in the local pack command when rehearsing another release. Inspect the resulting `.nupkg` as a ZIP archive and confirm its ID, version, README, license, assemblies, analyzers, build assets, and native bridge. +5. Merge the release pull request (squash) once `CI / Gate` passes. + +To rehearse the pipeline without publishing, start `Release` manually from a branch (**Actions → Release → Run +workflow**, or `gh workflow run release.yml --ref `). The dry run executes `verify` and the full `ci` job, then +skips `publish` and `github-release`. ## Publish @@ -55,9 +70,25 @@ git tag -a v1.0.0 -m "Release 1.0.0" git push origin v1.0.0 ``` -The tag starts the `Release` workflow. Confirm that all jobs pass, then verify both the -[NuGet package](https://www.nuget.org/packages/CheatEngine.SDK) and the generated GitHub release. NuGet validation and -search indexing can take several minutes. +The tag starts the `Release` workflow: + +1. `verify` checks the SemVer tag, that it points to `main`, that the version is not already on nuget.org, and extracts + the release notes from `CHANGELOG.md`. +2. `ci` builds and tests the tag in Debug and Release and packs `CheatEngine.SDK..nupkg` from the tested + Release build. The Release leg fails if the file name does not match the tag. +3. `publish` waits for a required reviewer to approve the `nuget` deployment in the run page, then pushes the package + and attests its provenance. +4. `github-release` creates the GitHub release from the extracted notes, attaches the package, and marks prereleases. + +Verify both the [NuGet package](https://www.nuget.org/packages/CheatEngine.SDK) and the GitHub release afterwards. +NuGet validation and search indexing can take several minutes. After a successful release, raise `MinVerMinimumMajorMinor` to the next development line and commit that change on `main`. For example, after `v1.0.0`, use `1.1` so subsequent untagged builds become `1.1.0-alpha.0.N`. + +## Re-running a release + +Use **Re-run failed jobs** only. Completed jobs are not repeated and the re-run reuses the artifacts of the original +attempt, so the pushed package, its attestation, and the release asset stay the same file. A push of an existing +version is skipped as a duplicate, and an existing GitHub release only receives a missing asset. **Re-run all jobs** +after a successful publish stops in `verify`, because the version is already on nuget.org. diff --git a/eng/Tests.props b/eng/Tests.props index a9d7ad9d..b962f32b 100644 --- a/eng/Tests.props +++ b/eng/Tests.props @@ -22,6 +22,7 @@ + diff --git a/libs/CheatEngine.SDK.Abi/README.md b/libs/CheatEngine.SDK.Abi/README.md index 8b337c92..168a61bd 100644 --- a/libs/CheatEngine.SDK.Abi/README.md +++ b/libs/CheatEngine.SDK.Abi/README.md @@ -67,7 +67,7 @@ and [pinned The source index records the installed-file hashes reviewed for the historic CE 7.7 baseline. The independently compiled fixture is deliberately more limited: it compiles a checked-in transcription of the pinned upstream C-header subset under MSVC x64, validates 104 facts, and compares its `sizeof`, `offsetof`, alignment, export, and topology facts -with a versioned expectation. The native CI job also passes that facts file into a compiled managed test, which measures +with a versioned expectation. The Debug CI test run also passes that facts file into a compiled managed test, which measures the matching managed record sizes, offsets, and alignments directly. It is therefore a `compiled-transcription-fixture` proof, not proof that a live CE host loads a slot, uses a given Pascal boolean width, or provides a non-null table entry. The records stay internal until a diff --git a/libs/CheatEngine.SDK.Annotations/Lua/LuaClassAttribute.cs b/libs/CheatEngine.SDK.Annotations/Lua/LuaClassAttribute.cs index f79aa8c6..da49b715 100644 --- a/libs/CheatEngine.SDK.Annotations/Lua/LuaClassAttribute.cs +++ b/libs/CheatEngine.SDK.Annotations/Lua/LuaClassAttribute.cs @@ -20,14 +20,13 @@ namespace CheatEngine.SDK.Annotations.Lua; /// Cheat Engine class. Instances are immutable and may be used from any thread. /// /// -/// Usage. is : a derived wrapper -/// stands for a different, more derived Cheat Engine class and has to name it; inheriting the attribute would make -/// it claim the name of its base. is -/// : -/// a wrapper type stands for exactly one Cheat Engine class. +/// Usage. The attribute uses the framework default for +/// so metadata consumers can observe it through an inherited +/// wrapper when appropriate. is +/// : a wrapper type stands for exactly one Cheat Engine class. /// /// -[AttributeUsage(AttributeTargets.Struct, Inherited = false)] +[AttributeUsage(AttributeTargets.Struct)] public sealed class LuaClassAttribute : Attribute { /// diff --git a/libs/CheatEngine.SDK.Annotations/Lua/LuaMarshallerAttribute.cs b/libs/CheatEngine.SDK.Annotations/Lua/LuaMarshallerAttribute.cs index 8df6b4c5..f7575dd6 100644 --- a/libs/CheatEngine.SDK.Annotations/Lua/LuaMarshallerAttribute.cs +++ b/libs/CheatEngine.SDK.Annotations/Lua/LuaMarshallerAttribute.cs @@ -20,7 +20,7 @@ namespace CheatEngine.SDK.Annotations.Lua; /// generated wrapper restores the Lua stack before returning. /// /// -[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.ReturnValue, Inherited = false)] +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.ReturnValue)] public sealed class LuaMarshallerAttribute : Attribute { /// Initializes the attribute with the concrete static marshaller type. diff --git a/libs/CheatEngine.SDK.Annotations/Lua/LuaPropertyAttribute.cs b/libs/CheatEngine.SDK.Annotations/Lua/LuaPropertyAttribute.cs index 17145ed5..5c75b0db 100644 --- a/libs/CheatEngine.SDK.Annotations/Lua/LuaPropertyAttribute.cs +++ b/libs/CheatEngine.SDK.Annotations/Lua/LuaPropertyAttribute.cs @@ -18,15 +18,13 @@ namespace CheatEngine.SDK.Annotations.Lua; /// Instances are immutable and may be used from any thread. /// /// -/// Usage. is : the attribute asks -/// for -/// the accessors of the one declaration that carries it; an override is another declaration with accessors of its -/// own. is : one managed property -/// maps to -/// one Cheat Engine property. +/// Usage. The attribute uses the framework default for +/// so metadata consumers can observe it through an inherited +/// property when appropriate. is +/// : one managed property maps to one Cheat Engine property. /// /// -[AttributeUsage(AttributeTargets.Property, Inherited = false)] +[AttributeUsage(AttributeTargets.Property)] public sealed class LuaPropertyAttribute : Attribute { /// diff --git a/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs b/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs index 78da8071..2a3f924c 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs @@ -1,6 +1,6 @@ using System; -using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Engine.Objects; diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs index b77904e1..f138f20b 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs @@ -172,7 +172,8 @@ private static partial LuaOperationStatus RegisterCore([LuaMarshaller(typeof(Sym private static partial LuaOperationStatus UnregisterCore([LuaMarshaller(typeof(SymbolName))] SymbolName name); [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", - Justification = "ReleaseOwned is the single atomic lease-cleanup transaction and must preserve its state ordering.")] + Justification = + "ReleaseOwned is the single atomic lease-cleanup transaction and must preserve its state ordering.")] internal static SymbolRegistrationReleaseOutcome ReleaseOwned(SymbolRegistrationLease lease) { lock (SOwnedRegistrationGate) diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs index 2a21df26..7ae6d686 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs @@ -603,7 +603,8 @@ public MemoryScanMaterializationStatus TryCopyResults(Span des [MainThreadOnly] [RequiresPluginEnabled] [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", - Justification = "This bounded materialization operation keeps its cancellation and ownership milestones together.")] + Justification = + "This bounded materialization operation keeps its cancellation and ownership milestones together.")] public MemoryScanMaterializationStatus TryCopyResultsCancellable(Span destination, out ulong totalCount, out int written, CancellationToken cancellationToken) { diff --git a/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs index 0f5119bf..3de7baa4 100644 --- a/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs +++ b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs @@ -15,6 +15,8 @@ using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.State; +using static System.Threading.Volatile; + namespace CheatEngine.SDK.Lua.Runtime; /// @@ -89,7 +91,7 @@ public static unsafe class LuaRuntime internal static Action? OperationAdmissionClosedForTesting; /// Gets a value indicating whether a host binding is attached. Lock-free; any thread. - public static bool IsAttached => Volatile.Read(ref s_services) is not null; + public static bool IsAttached => Read(ref s_services) is not null; /// /// Gets whether the calling thread currently owns an admitted Lua operation. Internal lifecycle code uses this @@ -127,7 +129,7 @@ public static int StateGeneration public static LuaStateIdentity CurrentStateIdentity { [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => UnpackIdentity(Volatile.Read(ref s_identity)); + get => UnpackIdentity(Read(ref s_identity)); } /// @@ -138,13 +140,13 @@ public static bool IsMainThread { get { - LuaHostServices? services = Volatile.Read(ref s_services); + LuaHostServices? services = Read(ref s_services); return services is not null && services.MainThreadId == Environment.CurrentManagedThreadId; } } /// Gets the attached binding, or while detached. - public static LuaHostBinding CurrentBinding => Volatile.Read(ref s_services)?.Binding ?? default; + public static LuaHostBinding CurrentBinding => Read(ref s_services)?.Binding ?? default; /// /// Acquires a Lua state together with a lifecycle admission that spans the whole synchronous operation. @@ -167,7 +169,7 @@ public static LuaRuntimeOperation AcquireOperation() return new LuaRuntimeOperation(state, true); } - if (Volatile.Read(ref s_services) is null) + if (Read(ref s_services) is null) { ThrowDetached(); } @@ -242,7 +244,7 @@ public static LuaRuntimeOperation AcquireOperation(LuaState state) throw new ArgumentException("A supplied Lua operation state cannot be null.", nameof(state)); } - if (Volatile.Read(ref s_services) is null) + if (Read(ref s_services) is null) { ThrowDetached(); } @@ -288,7 +290,7 @@ public static LuaStatus TryPushGeneratedFunction(LuaState state, LuaNativeFuncti { using LuaRuntimeOperation operation = EnterStateOperation(state); LuaStateIdentity identity = CurrentStateIdentity; - bool requiresAttachedRuntime = Volatile.Read(ref s_services) is not null; + bool requiresAttachedRuntime = Read(ref s_services) is not null; return state.TryPushGeneratedFunction(thunk, identity, requiresAttachedRuntime); } @@ -331,7 +333,7 @@ public static void Attach(in LuaHostBinding binding) { LuaStateIdentity identity = CurrentStateIdentity; PublishIdentity(unchecked(identity.AttachEpoch + 1), identity.StateGeneration); - Volatile.Write(ref s_services, new LuaHostServices(binding)); + Write(ref s_services, new LuaHostServices(binding)); LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); } } @@ -341,7 +343,7 @@ public static void Attach(in LuaHostBinding binding) // If replacement cleanup failed, s_services still names the previous usable binding. Reopen it rather // than stranding every caller behind the admission gate until a later lifecycle call happens to retry. OpenOperationAdmission(); - if (Volatile.Read(ref s_services) is not null) + if (s_services != null) { LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); } @@ -422,7 +424,7 @@ public static void Detach() { LuaHostSubscriptionRegistry.DetachAll(new LuaState(services.Provider())); LuaCallbackRegistry.DetachAll(services); - Volatile.Write(ref s_services, null); + Write(ref s_services, null); detachSucceeded = true; } finally @@ -503,7 +505,7 @@ public static bool TryAcquireState(out LuaState state) public static void PushHostObject(LuaState state, nint nativeObject) { using LuaRuntimeOperation operation = EnterStateOperation(state); - LuaHostServices? services = Volatile.Read(ref s_services); + LuaHostServices? services = Read(ref s_services); if (services is null) { ThrowDetached(); @@ -530,7 +532,7 @@ public static void PushHostObject(LuaState state, nint nativeObject) /// internal static LuaRuntimeOperation EnterStateOperation(LuaState state) { - if (t_transitionDepth != 0 || t_operationDepth != 0 || Volatile.Read(ref s_services) is null) + if (t_transitionDepth != 0 || t_operationDepth != 0 || Read(ref s_services) is null) { return default; } @@ -558,13 +560,13 @@ internal static bool TryEnterCallbackOperation(out LuaRuntimeOperation operation // A callback that is re-entered by an already admitted Lua operation shares that outer lease. The lifecycle // transition owner is different: admitting plugin code there would let a finalizer/metamethod re-enter after // CloseOperationAdmissionAndDrain has established exclusive cleanup. - if (t_transitionDepth != 0 && Volatile.Read(ref s_services) is not null) + if (t_transitionDepth != 0 && Read(ref s_services) is not null) { operation = default; return false; } - if (t_operationDepth != 0 || Volatile.Read(ref s_services) is null) + if (t_operationDepth != 0 || Read(ref s_services) is null) { operation = default; return true; @@ -588,7 +590,7 @@ internal static bool TryEnterCallbackOperation(out LuaRuntimeOperation operation internal static void CloseHostSubscriptionAdmissionAndDrain() { ThrowIfTransitionFromCurrentOperation(); - if (Volatile.Read(ref s_services) is null) + if (Read(ref s_services) is null) { return; } @@ -601,8 +603,8 @@ internal static void CloseHostSubscriptionAdmissionAndDrain() // before user code; one admitted before the boundary retains its lease until its unmanaged thunk returns. internal static bool IsGeneratedFunctionRegistrationCurrent(int attachEpoch, int stateGeneration) { - return Volatile.Read(ref s_services) is not null - && Volatile.Read(ref s_identity) == PackIdentity(attachEpoch, stateGeneration) + return Read(ref s_services) is not null + && Read(ref s_identity) == PackIdentity(attachEpoch, stateGeneration) && IsOperationAdmissionOpen(); } @@ -623,7 +625,7 @@ internal static void CloseOperationAdmissionAndDrain() s_acceptOperations = false; } - Volatile.Read(ref OperationAdmissionClosedForTesting)?.Invoke(); + Read(ref OperationAdmissionClosedForTesting)?.Invoke(); SOperationsDrained.Wait(); } @@ -640,7 +642,7 @@ internal static void CompleteStateReset() s_resetTransitionActive = false; EndTransition(); OpenOperationAdmission(); - if (Volatile.Read(ref s_services) is not null) + if (Read(ref s_services) is not null) { LuaHostSubscriptionRegistry.OpenRegistrationAdmission(); } @@ -825,7 +827,7 @@ private static void ThrowIfTransitionFromCurrentOperation() [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void PublishIdentity(int attachEpoch, int stateGeneration) { - Volatile.Write(ref s_identity, PackIdentity(attachEpoch, stateGeneration)); + Write(ref s_identity, PackIdentity(attachEpoch, stateGeneration)); } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/tests/CheatEngine.SDK.Abi.Tests/README.md b/tests/CheatEngine.SDK.Abi.Tests/README.md index fbdba0e1..5b9487e0 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/README.md +++ b/tests/CheatEngine.SDK.Abi.Tests/README.md @@ -26,15 +26,15 @@ numbers are literals next to the assertion, never derived from the code under te | Address-of arithmetic | `Layout.SizeOf()` and `Layout.OffsetOf` measure the size and every field offset. | | Raw bytes | The packed 36-byte init record is written into a guard-filled buffer at an aligned and an odd address. The 48-byte exports record is built as raw bytes, then read through the struct. | | Host simulation | `&Method` of a real `[UnmanagedCallersOnly]` `Stdcall` function is stored in every typed function-pointer slot, then called through the field. | -| Native-fact comparison | The native CI job provides the checked `ce77-native-abi-facts.txt` and sets its required gate; a compiled managed test measures every fixture-covered layout and compares its size, alignment, and offsets to that output. | +| Native-fact comparison | The native CI job builds the checked `ce77-native-abi-facts.txt`; the Debug build-test job passes it in and sets the required gate. A compiled managed test measures every fixture-covered layout and compares its size, alignment, and offsets to that output. | The test assembly applies `[assembly: DisableRuntimeMarshalling]`, so calls take the path a plugin takes. `BoolCallBoundaryTests` calls through pointers whose signature differs by one substitution (`int` for `Bool32`, `byte` for `Bool8`). Every branch of `AbiArchitecture` is tested through its internal overloads. -`NativeAbiFixtureManagedComparisonTests` has no local fixture dependency. The native CI job supplies -`CE77_NATIVE_ABI_FACTS_PATH` and sets `CE77_NATIVE_ABI_REQUIRED=true`, which makes a missing facts path fail the -comparison gate. Ordinary managed runs omit the required mode and can omit the facts path; that local opt-out is +`NativeAbiFixtureManagedComparisonTests` has no local fixture dependency. The Debug build-test CI job downloads the +facts the native job built, supplies `CE77_NATIVE_ABI_FACTS_PATH` and sets `CE77_NATIVE_ABI_REQUIRED=true` for its +solution test run, which makes a missing facts path fail the comparison gate. Ordinary managed runs omit the required mode and can omit the facts path; that local opt-out is intentional and is not a substitute for an exact-host test. - `AssemblyConformanceTests` compares an expected-size table with the public structs in both directions, then runs the diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs index eba8512b..d10b460b 100644 --- a/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs @@ -20,7 +20,8 @@ internal static class LiveProbeAuthorization private const string ManifestVariable = "CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE"; [SuppressMessage("Meziantou.Analyzer", "MA0051:Method is too long", - Justification = "Authorization evaluation keeps all fail-closed checks and diagnostics in one auditable sequence.")] + Justification = + "Authorization evaluation keeps all fail-closed checks and diagnostics in one auditable sequence.")] internal static AuthorizationDecision Evaluate() { if (!TryAuthorizeManifest(out AuthorizationManifest manifest, out string manifestFailure)) diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs index 96a0e112..7a50be69 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBindings.Tests/Generator/LuaObjectOutputTests.cs @@ -36,11 +36,11 @@ public readonly partial struct Scan """; [Fact] - public void Lua_annotation_usage_is_explicit_and_excludes_global_properties() + public void Lua_annotation_usage_uses_default_inheritance_and_excludes_global_properties() { AttributeUsageAttribute luaClass = AttributeUsage(typeof(LuaClassAttribute)); Assert.Equal(AttributeTargets.Struct, luaClass.ValidOn); - Assert.False(luaClass.Inherited); + Assert.True(luaClass.Inherited); Assert.False(luaClass.AllowMultiple); AttributeUsageAttribute luaGlobal = AttributeUsage(typeof(LuaGlobalAttribute)); @@ -48,6 +48,7 @@ public void Lua_annotation_usage_is_explicit_and_excludes_global_properties() Assert.False(luaGlobal.Inherited); Assert.False(luaGlobal.AllowMultiple); + Assert.True(AttributeUsage(typeof(LuaPropertyAttribute)).Inherited); Assert.False(AttributeUsage(typeof(LuaMethodAttribute)).AllowMultiple); Assert.False(AttributeUsage(typeof(LuaPropertyAttribute)).AllowMultiple); } diff --git a/tests/native-abi-fixture/README.md b/tests/native-abi-fixture/README.md index 3d839c6b..2be201e3 100644 --- a/tests/native-abi-fixture/README.md +++ b/tests/native-abi-fixture/README.md @@ -64,7 +64,8 @@ sentinel.plugin_version.outer_guard=passed ``` The script writes `ce77-native-abi-facts.txt` and validates every emitted key and value with -`Validate-Facts.ps1`. CI invokes this fixture in the native job, then passes its facts path to the compiled managed ABI -test with `CE77_NATIVE_ABI_REQUIRED=true`; that required mode fails the comparison gate if the path is absent. Ordinary +`Validate-Facts.ps1`. CI invokes this fixture in the native job and publishes the facts as the +`classic-abi-fixture-facts` artifact; the Debug build-test job passes that path to the compiled managed ABI test with +`CE77_NATIVE_ABI_REQUIRED=true`; that required mode fails the comparison gate if the path is absent. Ordinary ABI tests remain pure .NET tests in `tests/CheatEngine.SDK.Abi.Tests`: their local opt-out does not require a C++ compiler or the facts file.