Skip to content
oss-kit
GitHubInstall

NuGet

NuGet is present when a *.csproj, *.fsproj, or *.vbproj turns up anywhere in the checkout. A packages.lock.json beside one is a second present signal and never the only one to rely on, because NuGet’s lockfile is opt-in: it appears only when RestorePackagesWithLockFile is set or an empty file already exists, so its absence says nothing about whether the ecosystem is in the repository.

NuGet is shipped when a project packs and a publish step exists: a release workflow running dotnet pack and dotnet nuget push, or an existing page on nuget.org for the project’s PackageId. NuGet documents PackageId as defaulting to $(AssemblyName), so a shipped package often names itself nowhere in the project file.

Sources: Create a NuGet package using MSBuild, NuGet pack and restore as MSBuild targets.

Three cases decide most arguments:

  • IsPackable proves the shipped answer in one direction only. NuGet documents it as “a Boolean value that specifies whether the project can be packed. The default value is true”, so false settles the question and its absence settles nothing.
  • Read Directory.Build.props before reading a single project file. It sets properties for every project beneath it, IsPackable among them, so a solution can turn packing off for a whole directory of test and sample projects in one line that no .csproj repeats.
  • A test project, a sample application, or a benchmark project is present. So is a packages.lock.json on its own. Neither says anything about what the repository distributes.

NuGet takes the registry-push track. dotnet nuget push uploads a built .nupkg to the registry under an API key, so there is an upload to secure and a credential to scope, which is what assigns the track. The roster records "track": "registry-push" for NuGet and the release area’s preamble names NuGet in its registry-push list.

Verified 2026-07-31 against https://learn.microsoft.com/en-us/nuget/create-packages/creating-a-package-msbuild, https://learn.microsoft.com/en-us/nuget/reference/msbuild-targets, and https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files.

Shields.io serves this one at img.shields.io/nuget/v/PACKAGE. The v is a variant rather than a fixed word: v reports the latest stable version and vpre reports the latest version including pre-releases, so img.shields.io/nuget/vpre/PACKAGE is the pre-release badge and needs no query parameter.

The default label is nuget. Keep it.

Link the badge to the package page:

[![nuget](https://img.shields.io/nuget/v/PACKAGE)](https://www.nuget.org/packages/PACKAGE)
dotnet package add PACKAGE

That is the current form. Microsoft renamed the command in the .NET 10 SDK, from the verb-first dotnet add package to the noun-first dotnet package add, and documents the older form as what to use on the .NET 9 SDK and earlier. Both add a <PackageReference> to the project file and then restore.

Which one a README shows follows from what the project supports. A package targeting current .NET shows the noun-first form alone. A package whose readers are still on an older SDK shows the verb-first form, or both with the SDK each one needs, because a reader on .NET 9 who pastes the noun-first form gets an error that says nothing about SDK versions.

Verified 2026-07-31 against dotnet package add and services/nuget/nuget.service.js in badges/shields.

The project file declares it through one of two MSBuild properties. PackageLicenseExpression holds an SPDX license identifier or expression, <PackageLicenseExpression>MIT</PackageLicenseExpression>, and corresponds to <license type="expression"> in the packed nuspec. PackageLicenseFile holds the path to a license file inside the package, for a custom license or one SPDX has not assigned an identifier to, and corresponds to <license type="file">.

Three details decide most readings. Only one of PackageLicenseExpression, PackageLicenseFile, and the deprecated PackageLicenseUrl may be set at a time, so a project file states its license exactly once. A referenced license file has to be packed explicitly, with a <None Include="LICENSE" Pack="true" PackagePath=""/> item; setting the property alone puts nothing in the package. And NuGet and MSBuild treat a path with no extension as a directory, which is why an extensionless LICENSE needs that item spelled out rather than inferred.

So the manifest side of R-COM-01 is PackageLicenseExpression in every .csproj, .fsproj, or .vbproj the repository packs from, and the file side is the root license file. A solution packing several projects has one value per project. Where PackageLicenseFile is set instead, the project names a path rather than a license, so read the file at that path and check it is the same document the repository root carries. A project still setting only PackageLicenseUrl is on a deprecated property and states its license nowhere the package can carry it.

Source: NuGet pack and restore as MSBuild targets.

Tidelift’s platform name for this ecosystem is nuget, so the GitHub funding file’s entry reads tidelift: nuget/<package-id> against the package ID published to nuget.org. The accepted key format is PLATFORM-NAME/PACKAGE-NAME, described in github.md beside the rest of the accepted keys.

The roster at skills/oss-audit/ecosystems.json records "tidelift": "nuget" and is the canonical copy. This line exists because a single-skill install of oss-community does not carry that file; where the two disagree, the roster is right and this line is corrected to it.

Verified 2026-07-31 against NuGet pack and restore as MSBuild targets and Displaying a sponsor button in your repository.

On GitHub Actions, actions/setup-dotnet installs one or more .NET SDK versions. The support claim lives in the project file: TargetFramework for a single target and TargetFrameworks for several, each a target framework moniker such as net10.0 or netstandard2.0. A library that targets more than one moniker claims all of them, and that list is what the matrix covers.

strategy:
matrix:
dotnet: ['8.0.x', '9.0.x', '10.0.x']
steps:
- uses: actions/setup-dotnet@v6 # oss-harden pins this to a commit SHA
with:
dotnet-version: ${{ matrix.dotnet }}

There is a trap in that matrix the README calls out. Unless a concrete version is pinned in global.json, the SDK selects the latest installed version, including versions preinstalled on the runner, so every matrix cell can silently build with the same newest SDK and report a green matrix that tested one version. The README’s answer is to write a temporary global.json per cell. Multi-targeting is the other half: a project with TargetFrameworks builds every moniker from one SDK, so the matrix over SDK versions and the target list in the project file are different axes and both have to be read.

On GitLab, run the job in Microsoft’s .NET SDK image and vary the tag with parallel:matrix.

Sources: actions/setup-dotnet, Target frameworks in SDK-style projects, dotnet/dotnet-docker.

actions/setup-dotnet has a cache input, off by default, that caches the NuGet global-packages folder and keys it on the hash of packages.lock.json found in the repository root, with cache-dependency-path for other layouts.

The precondition is the thing to check first. NuGet lock files are opt-in: the roster records mode: opt-in, enabled by the MSBuild property RestorePackagesWithLockFile or by an existing packages.lock.json. The action does not degrade gracefully without one; its README says that if the lock file does not exist, the action throws an error. So a project with no lock file either turns lock files on or runs without cache: true, and turning them on is a change to the project, not to CI.

- uses: actions/setup-dotnet@v6 # oss-harden pins this to a commit SHA
with:
dotnet-version: ${{ matrix.dotnet }}
cache: true
- run: dotnet restore --locked-mode

Two documented consequences travel with that cache. It restores only the global-packages folder, which commonly surfaces as error NU1403 on restore, and the README’s answer is the DisableImplicitNuGetFallbackFolder property. And NUGET_PACKAGES relocates the folder, which the README recommends on runners that ship large preinstalled libraries; on GitLab it is required rather than recommended, because GitLab caches only paths inside the project directory and the default folder sits under the home directory.

Sources: actions/setup-dotnet, Package references in project files, GitLab CI/CD YAML reference, cache.

dotnet test builds the solution and runs the tests. From the .NET 10 SDK it runs them under either VSTest or Microsoft.Testing.Platform, and the runner is selected in global.json:

{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}

Before .NET 10 there is no choice and VSTest is used. The available command-line options differ between the two, so read global.json before copying flags from an example. The suite itself is declared by the presence of a test project, which is discovered through the solution rather than named in CI, so dotnet test at the solution root is the whole invocation for most repositories.

Sources: dotnet test.

Verified 2026-07-31 against https://github.com/actions/setup-dotnet, https://learn.microsoft.com/en-us/dotnet/standard/frameworks, https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-test, https://github.com/dotnet/dotnet-docker, and https://docs.gitlab.com/ci/yaml/.

Dependabot’s nuget value carries version updates and security updates, with private repositories and private registries supported and vendoring not. Two version facts sit next to each other in GitHub’s own table and note, so quote both rather than picking one: the supported versions column reads <=6.12.0, and the NuGet note says Dependabot does not run the NuGet CLI and “does support most features up until version 6.8.0”. A project relying on a newer restore feature can therefore be updated by a resolver that does not implement it.

On GitLab the Renovate manager is nuget.

The lockfile exists and is off by default. NuGet writes packages.lock.json at the project root once the MSBuild property RestorePackagesWithLockFile is set, or once an empty packages.lock.json is present for it to pick up. The frozen restore is dotnet restore --locked-mode, or msbuild -t:restore -p:RestoreLockedMode=true.

That default is what to report, not an exemption. The tooling publishes a lockfile format, so this ecosystem is inside R-SEC-08 rather than outside it, and a project with no packages.lock.json has one property to set rather than a limitation to record.

The one case the rule does not reach is the one NuGet’s own documentation carves out: a library project other people depend on, or a common code project other projects depend on, should not check the lock file in, while an application at the start of the dependency chain should. Establish which the repository is before scoring it. A library without the file is following its ecosystem’s documented convention; an application without it is the finding.

What breaks the first time you commit the lockfile

Section titled “What breaks the first time you commit the lockfile”

The SDK that writes the lock decides what is in it, so pin the SDK. Package pruning removes transitive packages already carried by the shared framework, and its default moves with the toolchain: opt-in under the .NET 9 SDK, and on by default under the .NET 10 SDK for every framework of a project targeting .NET 10 or newer. NuGet states the consequence directly, that enabling pruning “may lead to fewer packages than before pruning” once the lock is regenerated. Restore was also rewritten in 6.12 and the new resolver made the default, with RestoreUseLegacyDependencyResolver as the way back. Commit a global.json fixing the SDK version, and regenerate the lock on that same version, or a job on another SDK writes a lock the reviewer did not intend.

A solution commits many lock files, and the updater maintains a subset. The default is one packages.lock.json per project root, and NuGetLockFilePath allows a custom path, with packages.<project_name>.lock.json as the documented form where several projects share a directory. Locked mode restores exactly what the file lists or fails, so a lock left stale by a partial update turns the build red. Dependabot’s tracker carries this as an open defect: it does not update the lock for a project whose graph changes only because a referenced project changed, which is the normal shape under central package management. Give the project one command that restores every project with --force-evaluate, and expect to run it on an updater’s pull request.

Multi-targeting is the platform question here. NuGet “produces a separate dependency graph for each framework”, and the lock records each. Adding a target framework changes the defined dependencies, which is exactly what locked mode refuses, so a new framework needs the lock regenerated in the same change rather than afterwards.

Verified 2026-07-31 against NuGet PackageReference in project files and dependabot-core issue 13950.

CodeQL supports C#, so CodeQL default setup covers this ecosystem on GitHub, and the dependency graph’s NuGet row names the same family, .NET languages such as C#, F#, and Visual Basic, plus C++. GitLab’s SAST table lists C# under its Semgrep-based analyzer with GitLab-managed rules.

The dependency graph parses the project files, .csproj, .vbproj, .nuspec, .vcxproj, and .fsproj, with packages.config as an additional file. packages.lock.json is not in that table, so committing the lockfile does not widen what the graph sees. What does widen it is automatic dependency submission, which the NuGet row supports; it is a repository setting with no API, described in github.md under the controls with no endpoint.

Advisories come from the GitHub Advisory Database, which names this ecosystem NuGet against the nuget.org registry.

osv-scanner reads packages.lock.json, packages.config, and deps.json, so the lockfile the graph ignores is exactly the file a scanner can use.

Verified 2026-07-31 against Dependabot supported ecosystems and repositories, Dependency graph supported package ecosystems, GitHub Advisory Database, Package references in project files, CodeQL supported languages and frameworks, GitLab SAST, Renovate managers, and osv-scanner supported languages and lockfiles.

Concrete flow for the decisions SKILL.md makes, for a package published to nuget.org. NuGet documents one OIDC provider for trusted publishing: GitHub Actions. The exchange runs through the NuGet/login action, which trades the workflow’s OIDC token for a temporary API key valid for one hour. Each OIDC token yields exactly one API key and can be used once, so the login step belongs immediately before the push rather than at the top of the job.

Rollout is gradual. NuGet’s own documentation says the Trusted Publishing option “might not be available to you yet”, so confirm it appears under the account menu on nuget.org before writing a workflow that depends on it, and fall back to Step 2’s stored-key path only when it does not.

Source: Microsoft Learn, Trusted Publishing on nuget.org.

Read every project file the repository packs: *.csproj, *.fsproj, or *.vbproj. The package identity is the PackageId property, falling back to the assembly name when it is unset, and the version is the Version property or the VersionPrefix and VersionSuffix pair. A solution with more than one packable project publishes more than one package; enumerate them, since a trusted publishing policy is owned per account or organization and each package still has its own owners.

Get the owner and repository from RepositoryUrl in the project file, falling back to git remote get-url origin. Note the nuget.org profile name of the account that will own the policy, because the NuGet/login action takes that username and not an email address.

Check whether a package is already published:

curl -s -o /dev/null -w '%{http_code}' https://api.nuget.org/v3-flatcontainer/<lowercased id>/index.json

Anything other than 200 means the ID is unclaimed on nuget.org, or reserved under somebody else’s ID prefix reservation. Note also that nuget.org does not support deleting a published package in the general case, so a wrong version is unlisted rather than removed; that is what makes the gate in Step 4 load-bearing here.

Only one of the two sections below is a flow. NuGet documents GitHub Actions and says nothing about GitLab, so a GitLab project takes the fallback rather than a shorter version of the same setup.

Sign in to nuget.org, open Trusted Publishing from the username menu, and add a policy. The fields are case-insensitive:

  • Repository Owner: the owner from Step 1
  • Repository: the repository name from Step 1
  • Workflow File: release.yml, the filename only, not the .github/workflows/ path
  • Environment: the approval environment name from SKILL.md Step 4, for example release

Leaving Environment empty means the policy matches any run of that workflow, approved or not. Fill it in, for the same reason the other registries’ environment fields exist: it is the field that makes the approval gate in Step 4 part of the registry’s identity check rather than a forge convenience.

A policy is owned by an individual user or by an organization, and it applies to every package that owner owns. That is a wider blast radius than the per-package trusted publisher entries npm, PyPI, and RubyGems use, and it is worth saying out loud to the maintainer: one policy plus one compromised workflow file reaches the whole account. Choose the owner deliberately, and keep the workflow filename specific. A policy whose creator later leaves the owning organization goes inactive until they are added back.

In the workflow, the publish job needs:

permissions:
contents: read
id-token: write
steps:
- uses: NuGet/login@v1
id: login
with:
user: ${{ secrets.NUGET_USER }} # the nuget.org profile name, not an email address

The step’s NUGET_API_KEY output is the temporary key, consumed as ${{ steps.login.outputs.NUGET_API_KEY }}. No long-lived NUGET_API_KEY secret needs to exist in the repository. The username in user: is not a credential, and the documentation recommends holding it in a secret anyway; either is defensible, so present it as the maintainer’s choice rather than a requirement.

NuGet’s trusted publishing documentation describes GitHub Actions throughout and mentions GitLab CI/CD nowhere. That is silence, not a documented refusal, so read the page before reporting on it and do not assume a GitLab flow exists because other registries have one.

Source: Microsoft Learn, Trusted Publishing on nuget.org.

Where the documentation names no GitLab provider, this is the strongest alternative:

Create a scoped API key on nuget.org, from the API Keys page under the username menu. Select the narrowest scope that still publishes, which is push only new package versions where the package already exists, and set the glob pattern to the exact package ID rather than to *. Every key carries an expiry; set the shortest one that fits the release cadence, and note that the scope cannot be edited afterwards while the package list can. Store the key as a GitLab CI/CD variable that is both masked and protected, so it is redacted from job logs and reachable only from a protected branch or tag. Put the publish job behind a GitLab protected environment with approval rules, the same gate Step 4 uses elsewhere, so the key’s presence in the pipeline is not on its own enough to publish. Register a code signing certificate and sign the package, so the uploaded bytes carry an identity the key alone does not give them.

Source for the key scopes and the glob pattern: Microsoft Learn, Scoped API keys.

This is below the bar R-PUB-02 sets, because a scoped, expiring key is still a credential that can leak, where trusted publishing stores nothing at all. Take it only while the documentation names no GitLab provider, and re-read that page before each release process is written.

Write the hardened release workflow (Step 3)

Section titled “Write the hardened release workflow (Step 3)”

The publish job restores nothing, builds nothing, and calls no third-party action beyond the login above and the artifact download: anything running in a job that holds a NuGet API key can publish the package. Pack in a separate job, hand the .nupkg and its symbol package across as an artifact, and let the publish job push exactly those bytes.

name: Release
on:
push:
tags:
- 'v*'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '<the exact SDK version from global.json>'
cache: false
- run: dotnet restore --locked-mode
- run: dotnet test --no-restore # oss-ci decides the actual command from CONTRIBUTING.md (R-CI-02)
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '<the exact SDK version from global.json>'
cache: false
- run: dotnet restore --locked-mode
- run: dotnet pack --no-restore -c Release -o artifacts
- run: test -f "artifacts/<PackageId>.${GITHUB_REF_NAME#v}.nupkg"
- uses: actions/upload-artifact@v7
with:
name: nupkg
path: artifacts/
retention-days: 1
publish:
runs-on: ubuntu-latest
needs: [test, pack]
environment: release
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@v8
with:
name: nupkg
path: artifacts/
- uses: actions/setup-dotnet@v6
with:
dotnet-version: '<the exact SDK version from global.json>'
cache: false
- uses: NuGet/login@v1
id: login
with:
user: ${{ secrets.NUGET_USER }}
- run: >
dotnet nuget push "artifacts/*.nupkg"
--api-key ${{ steps.login.outputs.NUGET_API_KEY }}
--source https://api.nuget.org/v3/index.json

The version check is the file test rather than a property query, because dotnet pack names the output <PackageId>.<Version>.nupkg: if the tag and the project version disagree, the expected file is simply not there. Replace <PackageId> with the identity from Step 1 and adapt the v prefix strip to the repository’s actual tag format. A solution packing several packages needs one test per package, not a glob.

--locked-mode needs packages.lock.json committed, which NuGet does not create unless asked; R-SEC-08 and oss-harden own that decision, including NuGet’s own guidance that a library other projects depend on should not check the lock file in. Drop the flag rather than inventing a lock file here, and hand the gap to oss-harden.

oss-harden pins every uses: line above to a commit SHA and sets the test and build jobs’ minimal permissions, including the contents: read this skill left off them. This skill writes only the grants a job needs to authenticate, publish, and attest.

If an existing workflow reads a NUGET_API_KEY from repository secrets, remove it from the YAML now and tell the user to delete the secret and revoke the key on nuget.org once the new flow is verified.

Pin the publish job to environment: release as above, and create that environment at https://github.com/<owner>/<repo>/settings/environments/new with required reviewers naming at least one person other than an automation account. Required reviewers work for public repositories on current GitHub plans; private or internal repositories need GitHub Enterprise Cloud.

Create it with the API rather than the form. Reviewers and the tag policy are both settable, so nothing here needs a browser.

ENV=release
GHUID=$(gh api user --jq .id)
gh api -X PUT "repos/{owner}/{repo}/environments/$ENV" \
-F wait_timer=0 \
-F prevent_self_review=false \
-f 'reviewers[][type]=User' -F "reviewers[][id]=$GHUID" \
-F 'deployment_branch_policy[protected_branches]=false' \
-F 'deployment_branch_policy[custom_branch_policies]=true'
gh api -X POST "repos/{owner}/{repo}/environments/$ENV/deployment-branch-policies" \
-f 'name=v*' -f type=tag

Three details decide whether that runs. gh api substitutes {owner} and {repo} from the checkout it runs in. Use -F for the booleans and the reviewer id, because -f sends every value as a string and the endpoint rejects a quoted boolean. Do not name the shell variable UID: zsh marks it read only, so the assignment fails before gh runs.

reviewers[][id] takes a numeric user or team id rather than a login. A team needs type=Team and that team’s id.

nuget.org has no registry-side approval gate: there is no staging area a maintainer approves out of, and a successful push is immediately live. Report R-PUB-04 as unmet when the forge plan provides no native gate, rather than substituting an unverified approval action. The approval matters more here than in a registry with an unpublish window, because nuget.org does not support deleting a published package in the general case.

Because the trusted publishing policy carries an Environment field, the environment is doing double duty: it gates the run and it narrows which runs the registry will mint a key for. Set both, and check that the string matches exactly.

Verify provenance (Step 5): a gap, not a check

Section titled “Verify provenance (Step 5): a gap, not a check”

nuget.org serves no build provenance. There is no attestation object, no SLSA statement, and nothing comparable to npm’s npm audit signatures or PyPI’s Integrity API. What it does serve is signatures, and the difference is worth stating precisely to the maintainer, because a signed package reads like provenance and is not: a signature says an identity vouched for these bytes, and provenance says which commit and workflow produced them.

Two signatures exist. An author signature is one the project applies itself with dotnet nuget sign, using a code signing certificate from a public certificate authority; nuget.org rejects self-issued certificates, and the certificate must be registered on the account before a signed package is accepted. A repository signature is one nuget.org adds, and it records the owning account, which is why nuget.org’s own documentation warns that renaming an account leaves the old username embedded in the repository signature of every version already published.

Source: Microsoft Learn, Signing NuGet packages and Microsoft Learn, nuget.org FAQ.

The strongest substitute for provenance is a forge attestation over the exact .nupkg that was pushed. Add it in a job after the publish, attesting the same artifact the publish job downloaded, and verify it against the downloaded package:

gh attestation verify <id>.<version>.nupkg --repo <owner>/<repo>

nuget.org does not surface or link to that attestation, so a consumer has to know to look for it. Signing the package as well is worth doing where the project already holds a certificate, because the signature travels with the package and the attestation does not.

This sits below R-PUB-03, which asks for provenance tied to the exact published artifact. Report it as unmet with the registry limitation named. It retires the day nuget.org serves an attestation for a published version.

Describe and sign what the release attaches (Step 6)

Section titled “Describe and sign what the release attaches (Step 6)”

Only for a release that attaches a built asset to the forge release. Pushing to nuget.org attaches nothing to the forge, and the source archives GitHub generates for a tag are not built assets, so a project that only publishes goes to Step 7 instead.

Two rules apply here and they ask for different things. R-PUB-05 wants an inventory of what went into the asset, in SPDX or CycloneDX. R-PUB-06 wants the assets signed, or listed by hash in a signed manifest. A manifest of hashes answers the second and nothing about the first, so do not report R-PUB-05 as met by publishing one.

This reference names no SBOM generator for .NET. The ones in common use are separate tools rather than part of the SDK, and a tool that reads the dependency tree inside the release workflow is one the maintainer vets before it goes there. What answers R-PUB-05 without one, on GitHub, is the forge’s own export of the repository’s dependency graph, which is already SPDX and needs nothing installed. The gh api step below writes it into dist/, so it ships as a release asset for R-PUB-05 and is listed in SHA256SUMS and attested alongside the packages for R-PUB-06:

github-release:
runs-on: ubuntu-latest
needs: [publish]
permissions:
contents: write
id-token: write
attestations: write
artifact-metadata: write
steps:
- uses: actions/download-artifact@v8
with:
name: nupkg
path: dist/
- run: gh api repos/${{ github.repository }}/dependency-graph/sbom --jq .sbom > dist/sbom.spdx.json
env:
GH_TOKEN: ${{ github.token }}
- run: (cd dist && sha256sum *) > SHA256SUMS
- uses: actions/attest@v4
with:
subject-checksums: SHA256SUMS
- run: gh release upload "$GITHUB_REF_NAME" dist/* SHA256SUMS
env:
GH_TOKEN: ${{ github.token }}

State both of the export’s limits to the maintainer rather than leaving them to be discovered. It is GitHub only, so a GitLab project keeps this gap and R-PUB-05 stays unmet there with the reason named. And it covers the repository’s declared dependency graph rather than what is inside the asset, which is exact for a .nupkg that declares its dependencies rather than bundling them, and an approximation where the build embeds them. The graph resolves past the direct dependencies only where packages.lock.json is committed; R-SEC-08 places a library package, which NuGet’s own guidance tells not to commit one, outside that requirement, so a library’s export lists its direct dependencies alone and the report should say which of the two it is. Read the output back once with gh api repos/<owner>/<repo>/dependency-graph/sbom --jq '.sbom.packages | length' before reporting the rule met: a graph the forge does not parse for this ecosystem returns a near-empty package list rather than an error.

sha256sum runs from inside dist/ so the names it writes are the names the assets carry on the release. subject-checksums makes every file in the manifest a subject of the attestation in its own right, by name and digest; attesting SHA256SUMS itself with subject-path would leave a consumer able to verify the manifest and nothing about the assets it lists.

A consumer verifies an asset, then checks the rest of the download against the manifest:

gh attestation verify <asset> --repo <owner>/<repo>
sha256sum -c SHA256SUMS

Run the first command against each asset downloaded, never against SHA256SUMS, which is a subject of nothing. --signer-workflow <owner>/<repo>/.github/workflows/release.yml pins which workflow the attestation must have come from.

A third job is what makes the grants above safe, so copy the job boundary along with them. The build job runs dotnet pack against the project’s own targets, so giving it release-asset writes and an attestation identity is exactly the credential split Step 3 exists to enforce, and it would write assets before the approval gate. The publish job holds the registry key. Only a separate job satisfies both, and needs: [publish] is what keeps the assets behind the gate.

The four grants above are copied exactly, on this job only, and the workflow’s top-level block stays contents: read. Narrowing anything else, pinning each uses: to a commit SHA, and auditing the result are oss-harden’s. On GitLab CI/CD the forge attestation is unavailable, so a GitLab release can carry the same SHA256SUMS with nothing signing it; say that rather than presenting the file as provenance.

A new trusted publishing policy can start out temporarily active for seven days, which the interface shows, and which usually happens on a private GitHub repository. If no publish happens inside that window the policy goes inactive, and the window can be restarted at any time. The reason is worth passing on to the maintainer rather than treating as a quirk: nuget.org needs the GitHub repository and owner IDs to bind the policy, and it only learns them from a successful publish. Without them, somebody could delete a repository, recreate it under the same name, and publish as if nothing had changed. Once a publish supplies the IDs, the policy becomes permanent.

Step 7 is in SKILL.md: read each R-PUB rule’s Check: line against what this file produced, and fix what fails before reporting done.

Verified 2026-07-31 against Microsoft Learn, Trusted Publishing on nuget.org, Microsoft Learn, Scoped API keys, Microsoft Learn, Signing NuGet packages, and Microsoft Learn, nuget.org FAQ.

The version is stated in the project file or in a .nuspec, and nuget.org rejects any upload that lacks an exact version number, so one of the two always carries it. In an SDK-style project it is the Version property in the .csproj, .fsproj, or .vbproj, or the VersionPrefix and VersionSuffix pair where the project builds a prerelease suffix separately.

A repository shipping several packages usually hoists the number into Directory.Build.props, which every project underneath imports. That file is then the source and no project file states a version, so a search for <Version> in the project files finds nothing and reports a false gap. Check for it before concluding a repository has no version source.

NuGet follows Semantic Versioning 2.0.0, supported by NuGet 4.3.0 and later and by Visual Studio 2017 version 15.3 and later, and NuGetVersion diverges from it in three documented ways. It supports a fourth Revision segment, so the full form is Major.Minor.Patch.Revision, for compatibility with System.Version. It requires only the major segment, treating the rest as zero, so 1, 1.0, 1.0.0, and 1.0.0.0 are all accepted and equal. And it compares prerelease labels case-insensitively, so 1.0.0-alpha and 1.0.0-Alpha are the same version.

Restore normalizes on top of that: leading zeros are removed, a zero fourth part is dropped so 1.0.0.0 is treated as 1.0.0, and build metadata is removed so 1.0.7+r3456 is treated as 1.0.7. Two strings that normalize to the same version are the same package to NuGet, and NuGet says a repository holding 1.0 should not also host 1.0.0 as a separate package. For R-CHG-03 that means comparing normalized forms, and the way to avoid the question is to write the plain three-part version in the project file, the tag, and the changelog heading.

One publishing consequence belongs in a release decision rather than in a syntax note. A version is SemVer 2.0.0 specific when its prerelease label is dot-separated, as in 1.0.0-alpha.1, or when it carries build metadata, and so is a package any of whose dependency ranges names such a version. Upload one of those to nuget.org and it is invisible to clients older than NuGet 4.3.0.

Major version in package identity (R-CHG-07)

Section titled “Major version in package identity (R-CHG-07)”

NuGet does not encode the major version in package identity. A package identifier is stable across majors and holds every version of the package, so R-CHG-07 does not reach this ecosystem. A project that puts a number in the identifier has chosen a name, and nothing in NuGet ties that number to the released major or checks it.

“nuget.org does not support permanent deletion of packages”, because deleting one would break every project that restores it. What exists instead is unlisting, done from the package management page on the site.

An unlisted version disappears from search, from the package page, and from the Visual Studio UI, and stays downloadable and installable by exact version so restore keeps working. Two paths still surface it: a floating version such as 1.0.0-* resolves to an unlisted package when it is the latest match, and the catalog contains unlisted packages, so replication carries them. Unlisting reduces new adoption; it does not withdraw the bytes.

Deprecation is the separate, louder marker. It applies per version, carries a message, and can name an alternative package, and NuGet points at it as what to reach for when a version cannot be deleted. Use both where a version is actively harmful: unlist it so nobody finds it, and deprecate it so everyone already on it is told.

Deletion happens only through the NuGet team, in exceptional situations such as copyright infringement or potentially harmful content, reported through the package page.

[YANKED] on the changelog heading maps to an unlisting, and to an unlisting plus a deprecation where consumers need to be told why.

Verified 2026-07-31 against Package versioning and Deleting packages.