dotnet add package is an act of trust in a stranger. For a package with a company behind it, the trust is in the company. For Orkeon there is no company ā a single maintainer publishing under a pseudonym, no foundation behind it, no logo of a Fortune 500 customer on the README. So the question is not who vouches for this but what can I check myself, with tools I already have, without taking anyone's word. This article is the list, command by command, each one run against a real artefact before it was written down. The central one, gh attestation verify, has two twists: it fails on every package you download from nuget.org, by design, and it passed on two versions of my own analyzer that, on most machines, did nothing at all. Part 1, My coding agent wrote outside the repo, is about what the bytes do once they run; this is about where they came from.
What the chain establishes, and what it does not
Every Orkeon release artefact ā the nine packages on nuget.org, the app and CLI archives per platform, the .deb packages for amd64 and arm64 and the one that installs the apt signing key, the two MSIs ā is built by a public GitHub Actions workflow and attested: a statement saying this exact file, by SHA-256, was produced by this workflow file, at this tag, at this commit, in this repository, in this run, signed through Sigstore with the workflow's GitHub identity and recorded in a public transparency log. Five mechanisms, each doing one job:
| Mechanism | Where | What it proves |
|---|---|---|
| Trusted Publishing (OIDC) | publish.yml |
No long-lived NuGet API key exists |
| Build provenance (SLSA v1) | publish.yml, release.yml |
These bytes came from this workflow, tag and commit |
ContinuousIntegrationBuild=true |
publish.yml |
Packed bytes don't depend on the runner's layout |
SHA256SUMS manifests |
Release assets | The download matches the upload |
| Pinned actions and images | All workflows | What ran is what the repository names |
Trusted Publishing (OIDC) to nuget.org. The step NuGet.org login (NuGet/login) in publish.yml mints a short-lived key for that one workflow run, and the push uses it. There is no long-lived NuGet API key anywhere ā none can leak, none needs rotating.
Build provenance attestation (SLSA v1). In publish.yml, the step Attest the packages and the SBOM covers every *.nupkg; in release.yml, Attest the release assets covers every archive, .deb, MSI and the SBOM. Each digest is recorded in a signed statement naming the workflow, the tag, the commit and the run. A file with a different digest has no statement.
ContinuousIntegrationBuild=true at pack time. Set on the dotnet pack line of publish.yml, it normalises paths inside PDBs and assemblies, so the packed bytes do not depend on the runner's directory layout. It is a deterministic-build setting ā not a promise that you can rebuild identical bytes yourself; the SDK, the runner image and the NuGet graph would all have to match.
SHA256SUMS manifests. Published as release assets (SHA256SUMS, SHA256SUMS.msi), they check the integrity of what you downloaded against what the workflow uploaded. Cheap and offline ā but a manifest is just another release asset: what ties each file it lists to a workflow is that file's attestation.
Pinned actions and base images. Every third-party uses: is a commit SHA and every external FROM a digest, so the actions and base images that ran are byte for byte the ones the repository names; a tag moved upstream changes nothing.
What it does not establish: anything about the quality of the code. An attestation says the bytes came out of .github/workflows/publish.yml at commit X ā not that commit X is bug-free, safe to run with your credentials, or reviewed by anyone. The threat model in SECURITY.md is for that; so is the rest of this series. Nor does it cover everything the project publishes: the builds of main pushed to GitHub Packages for early testers carry no attestation, and neither does the runners container image each tag pushes to GHCR ā the guide says so. The chain covers the packages and the Release assets of a v* tag, nothing else.
An attestation names a workflow, so what that workflow refuses to do matters as much as what it does. In order, publish.yml:
- checks ā live, against the API, not the frozen event payload ā that the repository is public, because
actions/attest-build-provenanceneeds a public repository (or a paid plan): an::error::after two seconds rather than a failure after eighteen minutes of build, test and pack; - refuses a tag that does not match
src/Directory.Build.propsā av1.0.0-rc.5tag on a tree that saysrc.4stops there, before anything is restored; - refuses a tree whose
CHANGELOG.mdis not cut or whosePublicAPI.Unshipped.txtfiles are not empty; - packs, then refuses a lineup package that depends on an
Orkeon.*id outside the lineup ā at rc.1 and rc.2, two packages on nuget.org did, and restoring either failed onNU1101, a dependency nobody had pushed; - generates the SBOM and attests packages and SBOM in one statement, before the first push sends anything anywhere;
- pushes to GitHub Packages, then the nine lineup packages to nuget.org ā
Orkeonfirst, because five of the other eight depend on it and NuGet does not order pushes for you.
If the NUGET_USER variable that names the nuget.org policy is unset, step 6 stops after GitHub Packages with a SKIPPED warning you can read in the run log ā not a silent run that publishes to GitHub Packages only and calls it a release. And once the Release is public, release.yml downloads every published asset again and checks both manifests against them ā what you download is checked after the upload, not only before it.
Verify a release asset
Release assets are attested as uploaded: the file you download from the Releases page has the digest in the statement. Two commands, one for integrity and one for provenance:
VER=1.0.0-rc.5
BASE="https://github.com/Orkeon/orkeon/releases/download/v$VER"
curl -fsSL -O "$BASE/orkeon-cli-$VER-osx-arm64.tar.gz" -O "$BASE/SHA256SUMS"
# 1. integrity ā the manifest travelled with the file
sha256sum --check --ignore-missing SHA256SUMS
# orkeon-cli-1.0.0-rc.5-osx-arm64.tar.gz: OK
# 2. provenance ā GitHub's signed statement about this exact digest (gh >= 2.49, logged in)
gh attestation verify "orkeon-cli-$VER-osx-arm64.tar.gz" --repo Orkeon/orkeon
The second command prints, in a terminal, the workflow that built it (.github/workflows/release.yml@refs/tags/v1.0.0-rc.5) and fails on any byte difference. Without gh, ask the public API directly ā no token, no login, for a public repository. The statements come back base64-encoded; jq decodes them:
# After the block above, on the archive it downloaded
VER=1.0.0-rc.5
D=$(sha256sum "orkeon-cli-$VER-osx-arm64.tar.gz" | cut -d' ' -f1)
curl -fsSL "https://api.github.com/repos/Orkeon/orkeon/attestations/sha256:$D" |
jq -r '.attestations[].bundle.dsseEnvelope.payload | @base64d | fromjson
| "\(.predicateType) \(.subject | length) subjects"'
# https://in-toto.io/attestation/release/v0.2 17 subjects
# https://slsa.dev/provenance/v1 14 subjects
No line ā curl reports a 404, or the list is empty ā means no statement for these bytes. The API shows you what GitHub holds; checking the signature on it is what gh adds.
The slsa.dev/provenance/v1 line is the workflow's own statement ā builder release.yml@refs/tags/v1.0.0-rc.5 ā and its fourteen subjects are the five app archives, the three CLI archives, both .deb packages, the apt keyring package, both MSIs and the SBOM. That is the shape to expect: one provenance statement per workflow run, all its outputs as subjects, so a file missing from the list was not attested by that run. The two SHA256SUMS manifests are the deliberate exception ā not subjects themselves, while every file they list is.
The other line is not one the workflow wrote. Orkeon Releases are immutable: once published, an asset can be neither replaced nor deleted, and GitHub itself attests the Release ā seventeen subjects: the tag pkg:github/Orkeon/orkeon@v1.0.0-rc.5, the fourteen files above, and this time the two manifests too. The provenance says which run built these bytes; the release statement says these are the bytes this tag published, and they cannot change.
On Debian and Ubuntu, the .deb packages usually arrive through apt rather than from the Releases page. There the trust is placed once, when you add the source: the installation block compares the downloaded keyring with the SHA-256 printed in SECURITY.md, apt checks every update against that key afterwards, and dpkg -V orkeon prints nothing when every installed file still matches. The packages in the apt archive are the Release assets, downloaded from the published Release and checked against it, so the commands above apply to them unchanged.
Verify a NuGet package ā and the trap
Two things are true at once about a package on nuget.org, and the second surprises people.
First, nuget.org repository-signs every package it accepts. dotnet nuget verify --all checks that signature ā it proves the bytes were not altered after nuget.org received them. Orkeon packages carry no author signature; the repository signature is the only one.
Second, repository signing changes the file. nuget.org appends a .signature.p7s entry to the archive and rewrites the zip directory around it, so the digest of the package you download is not the digest GitHub attested ā the attestation covers the bytes dotnet pack produced, before the push. gh attestation verify on a .nupkg downloaded from nuget.org therefore always fails, and that failure is not evidence of tampering. It is evidence of nuget.org doing its job.
The signature is always appended last, so the original bytes are recoverable exactly ā no re-zipping ā with a standard-library script that ships in the repository:
VER=1.0.0-rc.5
curl -fsSL -o "Orkeon.$VER.nupkg" \
"https://api.nuget.org/v3-flatcontainer/orkeon/$VER/orkeon.$VER.nupkg"
# 1. the nuget.org repository signature ā intact since nuget.org stored it
dotnet nuget verify --all "Orkeon.$VER.nupkg"
# Signature type: Repository ā CN=NuGet.org Repository by Microsoft ā¦
# 2. the bytes as attested ā strip the appended repository signature
# (from a clone of the repository, see "Try it")
python3 scripts/nupkg-unsign.py "Orkeon.$VER.nupkg"
# Orkeon.1.0.0-rc.5.unsigned.nupkg sha256:a6c81a0c77a451e0af8408d5a7774c4c8527bcfbae213f2d4c005550701548e8
# 3. provenance of those bytes
gh attestation verify "Orkeon.$VER.unsigned.nupkg" --repo Orkeon/orkeon
scripts/nupkg-unsign.py keeps everything before the signature's local header, drops the signature's central-directory record, and writes an end-of-central-directory record with the counts, size and offset put back. It refuses to guess: if the signature is not the last entry of the archive it stops rather than emit something that would fail verification anyway. Measured on Orkeon.1.0.0-rc.5.nupkg: the recovered digest (a6c81a0cā¦1548e8) has one statement, builder .github/workflows/publish.yml@refs/tags/v1.0.0-rc.5, twelve subjects ā the nine packages pushed to nuget.org at that tag, the two that only reach GitHub Packages (Orkeon.ConsoleApp, Orkeon.Generators), and the SBOM. That middle detail is the attestation being literal: it lists what the workflow built, not what reached nuget.org. A tagged version downloaded from GitHub Packages is not repository-signed and verifies directly, without the strip.
The software bill of materials
Since v1.0.0-rc.4, every release ships a CycloneDX SBOM next to its assets ā orkeon-<version>.sbom.cdx.json, the NuGet dependency graph of Orkeon.sln, transitive packages included and test projects excluded. The CycloneDX dotnet tool (pinned to 6.2.0, not a third-party action) generates it on the runner that packaged the archives, right after them, and it is attested in the same step as the archives, so one statement covers the binaries and the dependency graph they were built from. publish.yml runs the same tool right after the pack and attests that second file with the packages ā its own digest, in its own statement, kept as a workflow-run artefact (sbom) because nuget.org has no slot for an SBOM. The copy to scan is the Release's; verify it like any other asset:
VER=1.0.0-rc.5
curl -fsSL -O "https://github.com/Orkeon/orkeon/releases/download/v$VER/orkeon-$VER.sbom.cdx.json"
gh attestation verify "orkeon-$VER.sbom.cdx.json" --repo Orkeon/orkeon
The rc.5 file lists 276 components under CycloneDX spec 1.7. It is a dependency graph, not a vulnerability scan: feed it to the scanner you already use. What the attestation adds is that the graph you scan is the graph the release was built from, not one regenerated on a laptop afterwards.
When a check fails
sha256summismatch ā the download is incomplete or altered; download again from the Releases page, never from a mirror.gh attestation verifyfails on a release asset whoseSHA256SUMSline matched ā the manifest and the file agree with each other but not with any workflow run. Do not install; report it through the security policy.gh attestation verifyfails on a.nupkgdownloaded from nuget.org ā expected; strip the repository signature first. If it still fails after the strip, report it.- The API returns a statement whose
workflow.repositoryis nothttps://github.com/Orkeon/orkeonā the bytes were built elsewhere; report it.
What the analyzer taught me about all of the above
Here is the sentence I would want a supply-chain article to include, and the reason this one can. Orkeon.Compliance.Vfs ā the Roslyn analyzer part 1 shows you adding to your own project ā shipped twice before it loaded everywhere: 1.0.0-rc.3 to GitHub Packages only and 1.0.0-rc.4 to nuget.org, both fully attested and fully inert for most of the people who would have installed them. They were compiled against Roslyn 5.9, newer than the compiler of the .NET 10 SDK most machines had (5.6), which reports a warning, CS9057, and skips the analyzer; a fresh project with File.ReadAllText built clean. On a 10.0.4xx SDK, which ships Roslyn 5.9, the same rc.4 bytes do fire ā whether the package worked depended on the machine installing it. Both versions are subjects of their tag's publish.yml statement, so the provenance check passes on both, on every machine. The chain did exactly what it promises ā these bytes came from that workflow at that tag ā and said nothing about whether the bytes work, because that is not what provenance is.
Anyone who tells you an attestation makes a package safe is selling the wrong word. It makes a package traceable. The rc.4 statement names the commit the package was built from; from there, the history of one line in the analyzer's csproj tells the rest in three commits. On 2026-09-11, a fix compiles the analyzer against Roslyn 4.8, the compiler of the first .NET 8 SDK. On 2026-09-21, a routine dependency bump moves that line back to 5.9 ā the repository's own compiler, so inside the repository the analyzer still loaded ā and rc.4 is tagged the same day. On 2026-09-22 the line is back on 4.8, and 1.0.0-rc.5 ships it, loaded by any .NET 10 SDK. No attestation could have caught that bump. But you can walk from the bytes you installed to the line that broke them without taking my word for a single step.
Try it
# From a clone of the repository (for scripts/nupkg-unsign.py)
git clone --depth 1 https://github.com/Orkeon/orkeon && cd orkeon
VER=1.0.0-rc.5
# A package: signature, recovered bytes, provenance
curl -fsSL -o "Orkeon.$VER.nupkg" "https://api.nuget.org/v3-flatcontainer/orkeon/$VER/orkeon.$VER.nupkg"
dotnet nuget verify --all "Orkeon.$VER.nupkg"
python3 scripts/nupkg-unsign.py "Orkeon.$VER.nupkg"
gh attestation verify "Orkeon.$VER.unsigned.nupkg" --repo Orkeon/orkeon
# The SBOM of the same release
curl -fsSL -O "https://github.com/Orkeon/orkeon/releases/download/v$VER/orkeon-$VER.sbom.cdx.json"
gh attestation verify "orkeon-$VER.sbom.cdx.json" --repo Orkeon/orkeon
The two workflows are .github/workflows/publish.yml and release.yml in the repository at github.com/Orkeon/orkeon, and docs/guides/verify-what-you-install.md is the reference page for these checks, without the story. If a command on that page stops passing on a published tag, that is a bug with a public issue's name on it ā and if it passes on bytes it should not, that is a report for the security policy in SECURITY.md, not a public issue. Part 3, The System.IO you cannot call, picks up where this leaves off: once you know where the bytes came from, what they are allowed to touch.
Have a goat day š
Join the conversation.