NuGet releases are produced by .github/workflows/release.yml from version tags. This document describes the chain,
how to prepare and publish a release, and how anyone can verify one afterwards.
Current state. The last published version is 1.0.0, released before this chain existed: its GitHub release carries the nupkg only (no SBOM or checksums) and is not immutable. The 2.0 line has not been released. There is no automated qualification gate: before tagging a stable release, the maintainer confirms against the audit's Q02-Q10, Q40 and Q41 register (analyses/20 of the audit dossier) that the release is not claiming host qualification it has not earned, and records any open row as a known limitation in the release notes.
verify ─► ci ─► attest ─► draft-release ─► publish ─► verify-publication ─► finalize-release
│ │ │ │ │ │
│ │ │ │ │ └ Release published and verified
│ │ │ │ └ nuget.org serves the attested package, repository-signed
│ │ │ └ push to nuget.org after approval of the nuget environment
│ │ └ draft release that already carries every asset
│ └ SBOM and its attestation, provenance attestation, SHA256SUMS
└ build and test the tag in Debug and Release, pack once, test that exact nupkg
| Job | What it does |
|---|---|
verify |
Checks the SemVer tag, that it points to the first-parent history of main, that the version is not on nuget.org yet, and extracts the release notes from CHANGELOG.md. |
ci |
Runs ci.yml on the tag. The Release leg packs CheatEngine.SDK.<version>.nupkg (the file name must match the tag), runs the packaging tests on that exact file (CESDK_PACKAGED_UMBRELLA_NUPKG), and uploads it as nuget-package. |
attest |
Extracts the SPDX 2.2 SBOM embedded in the nupkg, creates the SLSA provenance attestation and the SBOM attestation of the nupkg (predicate https://spdx.dev/Document/v2.2), verifies both, and writes SHA256SUMS. |
draft-release |
Creates a draft release that already carries every asset, or completes an existing draft. It never uploads to a published release. |
publish |
Waits for approval of the nuget environment, checks the nupkg against SHA256SUMS, logs in through NuGet trusted publishing and pushes. |
verify-publication |
Waits until nuget.org lists the version, downloads the repository-signed copy and checks it: dotnet nuget verify --all, and every zip entry byte-identical to the attested package except the added .signature.p7s. |
finalize-release |
Publishes the release, then verifies what a consumer downloads (SHA256SUMS, gh release verify, gh attestation verify). |
The nuget-package artifact is the release artifact: the file the packaging tests consumed is the file that is
attested, attached to the release and pushed to nuget.org. Never upload a locally built package: nuget.org versions
are immutable.
Why draft-first. Once an
immutable release
is published, its tag cannot move and its assets cannot be added, changed or deleted, so every asset must be attached
to the draft. That is why the attestations are created before publication: the draft must carry their bundles.
finalize-release only flips the draft to published once nuget.org has served the attested package.
One release has three package identities: the attested asset, its NuGet content hash, and the nuget.org signed copy.
verify-publication checks all three exist and agree; none of them is written to a separate manifest. The published
1.0.0 is the worked example (verified on 2026-09-23):
| Identity | What it is | 1.0.0 |
|---|---|---|
| Attested asset SHA-256 | SHA-256 of the unsigned nupkg CI packed: the GitHub release asset and the subject of the attestations. | 99bf90101cd13e0183c94759e43badc6a1e719ffc3e2c9fd0b93490abdac0632 |
| NuGet content hash | SHA-512 (base64) of that same unsigned file. It is the contentHash consumer lock files store and validate on restore (NU1403). |
n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA== |
| nuget.org signed file | nuget.org repository-signs every package, so the file it serves has different bytes: the same entries plus .signature.p7s. |
SHA-256 3e8c98583ac71af25a5bd7053e7583fbafcd196139fae7c0b04bae9b40a7cd33, SHA-512 1a2B/E6reX5e636hfdb+Zdj3kT6817DuNES1RWvprhRyuyztE/56Zk2iHOMQIKpGH+O2Va8rYJxXXXTVq5aN9Q== |
The 1.0.0 signed copy has 28 entries and the asset 27; every common entry is byte-identical, and
dotnet nuget verify --all on the signed copy reports the content hash above. The native bridge inside 1.0.0
(build/native/cheatengine-sdk-lua-bridge.dll) has SHA-256
da08c2ba03019da3a8c432ef061d5d6133fd2169ba3a6a8e9ac903353856d994 and source fingerprint
8a63e00c7dd941212e7ef8c13d8c97f73142c5154bfbe5dbc5459e7131bb789b:2871368515be4c6fd235e49e793d5557e7c50229fcc8fbfd903efd39f9b754a8.
Create an organization-owned trusted publishing policy under the CheatEngine organization at
https://www.nuget.org/account/trustedpublishing with these exact values:
| Field | Value |
|---|---|
| Policy owner | CheatEngine (organization) |
| Repository owner | CheatEngineNet |
| Repository | CheatEngine.SDK |
| Workflow file | release.yml |
| Environment | nuget |
| Scope | Push new packages and package versions |
| Package glob | CheatEngine.SDK |
The GitHub nuget environment must contain an environment secret named NUGET_USER. Its value must be the exact
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. The workflow exchanges GitHub's OIDC token for a
short-lived NuGet API key through NuGet/login
(trusted publishing); it never stores 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, and the login runs right before the push because the temporary key lives one hour.
The maintainer applies these settings directly in the repository's GitHub settings:
- Environment
nuget: the maintainers who approve publications as required reviewers, with self-review allowed (prevent_self_review: false) while a single maintainer releases; no administrator bypass; deployments limited to tags matchingv*.*.*; theNUGET_USERsecret. - A tag ruleset on
v*: release tags cannot be moved or deleted. - Immutable releases: enable them only after this
release.ymlis onmain. The previous workflow uploaded onto an existing published release, which an immutable release refuses. Until they are enabled,finalize-releasewarns that the release has no release attestation and skipsgh release verify.
As observed on 2026-09-23, the nuget environment had a branch policy only (no required reviewer) and immutable
releases were disabled.
-
Changelog. Move the completed entries from
[Unreleased]to a## [X.Y.Z] - YYYY-MM-DDsection ofCHANGELOG.mdand 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[Unreleased]. Keep the four kinds of change the audit distinguishes apart, because each one breaks consumers differently:### Added: extensions (a new wrapper, option or API);### Changed: semantic corrections (the meaning of a result or boolean, a Lua arity or index convention, the allowed thread, ownership conditions, an error category);### Security: refusal hardening (an operation that used to proceed and is now refused);### Deployment: package, native bridge, runtime policy or load-profile changes.
### Removedand### Deprecatedare used as Keep a Changelog defines them. Add a### Qualification waiverssection when the qualification gate needs one. -
Version line. The 2.0 line is set by
MinVerMinimumMajorMinorinDirectory.Build.props(2.0); the exact version comes from thev<major>.<minor>.<patch>tag. -
API compatibility. Every pack validates
lib/net10.0against the published 1.0.0 package, andsrc/CheatEngine.SDK/CompatibilitySuppressions.xmllists exactly the intentional breaks; nothing else may differ. Every suppressed break has a### Changed,### Removedor### Securityentry in the version's section. -
Local validation from the repository root, with .NET SDK 10.0.401:
dotnet restore CheatEngine.SDK.slnx --locked-mode dotnet build CheatEngine.SDK.slnx -c Debug --no-restore dotnet test --solution CheatEngine.SDK.slnx -c Debug --no-build --fail-skips on --filter-not-trait "Category=Packaging" dotnet build-server shutdown dotnet build CheatEngine.SDK.slnx -c Release --no-restore dotnet pack src/CheatEngine.SDK -c Release -o artifacts/nuget -p:MinVerVersionOverride=2.0.0 --no-restore $env:CESDK_PACKAGED_UMBRELLA_NUPKG = (Resolve-Path artifacts/nuget/CheatEngine.SDK.2.0.0.nupkg).Path dotnet test --solution CheatEngine.SDK.slnx -c Release --no-build --fail-skips on Remove-Item Env:CESDK_PACKAGED_UMBRELLA_NUPKG
Replace
2.0.0with the version you rehearse; the pack needs nuget.org once for package validation. The Release test run consumes exactly the packed file, as CI's Release leg does (exact-package run). This local package is a rehearsal: never upload or publish it. -
Merge the release pull request (squash) once
CI / Gatepasses.
A stable release follows Checkpoint F of the audit: a user must know what they can load, with which version and which
limits. There is no automated gate for this: before tagging a stable release, the maintainer checks the audit's Q02-Q10,
Q40 and Q41 register (analyses/20 of the audit dossier) against what this tree actually qualifies, and adds a
### Qualification waivers section to the version's CHANGELOG entry for any row that stays open, as - Qxx: <reason>.
The waivers are part of the release notes, so every consumer sees them.
C1 and C2 results are never presented as host qualification: a C1 cell never stands in for a required C3 level.
Start Release manually from a branch (Actions → Release → Run workflow, or
gh workflow run release.yml --ref <branch>). The dry run executes verify (a notice instead of the tag checks), the
full ci, and attest without attestations. It uploads nuget-package and attestation-bundles (SBOM and
SHA256SUMS), creates no draft and publishes nothing.
The release commit must first land on the first-parent history of main; the workflow rejects a tag from another
commit. From an up-to-date main checkout:
git tag -a v2.0.0 -m "Release 2.0.0"
git push origin v2.0.0The tag starts the chain of the overview. When draft-release finishes, the draft release already shows
the nupkg, the SBOM, both attestation bundles and SHA256SUMS. publish then waits for a required reviewer to
approve the nuget deployment on the run page. After the push, nuget.org validates and indexes the package, which can
take several minutes; verify-publication polls for up to 35 minutes. finalize-release publishes the GitHub release
only after nuget.org serves the attested package, and its step summary shows the immutability status and the asset
checksums.
Anyone can check a release without trusting this repository's word:
$v = '2.0.0'
gh release download "v$v" -R CheatEngineNet/CheatEngine.SDK -D release
Set-Location release
# Immutable release: the release attestation covers the tag, the commit and every asset.
gh release verify "v$v" -R CheatEngineNet/CheatEngine.SDK
gh release verify-asset "v$v" "CheatEngine.SDK.$v.nupkg" -R CheatEngineNet/CheatEngine.SDK
# Build provenance and SBOM of the package, signed by this workflow for this tag.
$identity = @('-R', 'CheatEngineNet/CheatEngine.SDK',
'--signer-workflow', 'CheatEngineNet/CheatEngine.SDK/.github/workflows/release.yml',
'--source-ref', "refs/tags/v$v", '--deny-self-hosted-runners')
gh attestation verify "CheatEngine.SDK.$v.nupkg" @identity
gh attestation verify "CheatEngine.SDK.$v.nupkg" @identity --predicate-type https://spdx.dev/Document/v2.2
# Checksums (sha256sum -c SHA256SUMS on Linux and macOS).
Get-Content SHA256SUMS | ForEach-Object {
$hash, $name = $_ -split ' ', 2
if ((Get-FileHash $name -Algorithm SHA256).Hash -ne $hash) { throw "$name does not match SHA256SUMS." }
}- The attestation bundles attached to the release (
*.provenance.sigstore.json,*.sbom.sigstore.json) letgh attestation verifycheck an asset against a bundle file with--bundle <file>instead of fetching the attestation from GitHub (gh attestation verify, verifying a release). - A consumer lock file holds the NuGet content hash: the
contentHashofCheatEngine.SDKinpackages.lock.jsonequals the base64 SHA-512 of the release asset ([Convert]::ToBase64String([Security.Cryptography.SHA512]::HashData([IO.File]::ReadAllBytes("CheatEngine.SDK.$v.nupkg")))). dotnet nuget verify --allon the file downloaded from nuget.org reports a nuget.org repository signature and the same content hash (dotnet nuget verify). Never run it on the GitHub asset: that file is unsigned by design and fails with NU3004.- The
verify-publicationjob automates these nuget.org checks directly in the workflow.
Reproducibility. The promise is at the level of the DLLs and the native bridge: MinVer stamps the tag version,
ContinuousIntegrationBuild normalizes paths, and the source fingerprint embedded in the bridge names its C source and
build script. The nupkg itself is not byte-reproducible, because the SBOM it embeds has a generated document namespace
and creation time. The attested nupkg is therefore the identity of a release; a rebuild from the tag reproduces its
assemblies and bridge, not the nupkg bytes. A packed bridge that differs from the committed, audited one
(native/cheatengine-sdk-lua-bridge/bridge-audit-manifest.json) is reported as drift, never hidden and never a
failure: CI rebuilds the bridge with its pinned toolset.
On main, in one pull request after the release:
- Raise
MinVerMinimumMajorMinorinDirectory.Build.propsto the next development line (afterv2.0.0:2.1), so untagged builds become2.1.0-alpha.0.N. - Set
PackageValidationBaselineVersioninsrc/CheatEngine.SDK/CheatEngine.SDK.csprojto the released version. - Delete
src/CheatEngine.SDK/CompatibilitySuppressions.xml: the new baseline already contains those changes. - For each shipping library, apply the
*REMOVED*lines ofPublicAPI.Unshipped.txttoPublicAPI.Shipped.txt, move the other lines intoPublicAPI.Shipped.txt(ordinally sorted), and leavePublicAPI.Unshipped.txtwith its#nullable enableheader only. - Open a new empty
[Unreleased]section inCHANGELOG.md.
Use Re-run failed jobs only. Completed jobs are not repeated and the re-run reuses the artifacts of the original attempt, so the tested, attested, attached and pushed package stays the same file. Artifacts are kept 90 days: re-run within that window.
- A draft that misses assets receives them.
- A push of an existing version is skipped as a duplicate.
- A release that is already published is never uploaded to: when it carries the same assets,
draft-releasereports it and succeeds, andfinalize-releaseonly verifies it. - Re-run all jobs rebuilds a new nupkg (its SBOM differs), so
draft-releaserefuses a draft that holds the earlier one. If that draft was never published, delete it and re-run. Once the version is on nuget.org, a full re-run stops inverify. - If
verify-publicationtimes out while nuget.org is still validating, re-run it. If it reports a different package, stop: do not publish the draft, and investigate the push.