Verify release provenance¶
This guide verifies release artifacts, not a checkout of this repository. Use it for a GitHub Release or for the non-publishing release-rehearsal candidate. A rehearsal is sufficient to authorize the first publication; the live GitHub Release and NuGet.org checks are post-publication confirmation.
What each mechanism proves¶
| Mechanism | Trust boundary | What it does not prove |
|---|---|---|
| NuGet trusted publishing | The release workflow was authorized to obtain short-lived NuGet publishing credentials. | The provenance of arbitrary downloaded bytes. |
| Candidate manifest | Exact pre-publication project-controlled .nupkg and .snupkg inventory. |
Its own authenticity or the identity of a later NuGet.org download. |
| Derived checksums | A convenient rendering of the canonical manifest. | A second digest authority. |
| GitHub build provenance | Signed binding of an exact subject to this repository, workflow, and source commit. | Package quality, security, or a formal SLSA level. |
| NuGet.org repository signing | NuGet.org's integrity/trust semantics for its downloadable primary package. | A project-controlled author signature. |
| NuGet author signing | A separate project-controlled X.509 signature. | GitHub provenance or NuGet.org repository signing. |
| SBOM | A component inventory for an artifact. | Provenance or an automatically accurate package-to-component mapping. |
The canonical package-manifest.json inventories project-controlled package
subjects only. package-checksums.txt is derived from it and is deliberately
outside that inventory: making either evidence file hash itself would create a
recursive authority. GitHub attests the manifest and checksum files as separate
outer evidence subjects.
Verify GitHub Release or rehearsal assets¶
Install a current GitHub CLI, jq, and work in one
empty directory. Use exactly one of the following acquisition paths. Each path
downloads package-manifest.json, package-checksums.txt, and every
.nupkg/.snupkg for one release or rehearsal, and sets SOURCE_COMMIT from
the selected GitHub object. Do not mix assets from different runs, versions,
or commits.
export REPOSITORY=eugenemalaschuk-source/arch-linter-net
export WORKFLOW="$REPOSITORY/.github/workflows/release-nuget.yml"
Obtain a GitHub Release¶
Use the exact release tag shown on the GitHub Release page. The API lookup resolves that tag to the commit whose SHA is recorded in the candidate manifest:
export RELEASE_TAG='<release-tag>'
gh release download "$RELEASE_TAG" \
--repo "$REPOSITORY" \
--dir . \
--pattern 'package-manifest.json' \
--pattern 'package-checksums.txt' \
--pattern '*.nupkg' \
--pattern '*.snupkg'
export SOURCE_COMMIT="$(gh api "repos/$REPOSITORY/commits/$RELEASE_TAG" --jq '.sha')"
if [ -z "$SOURCE_COMMIT" ] || [ "$SOURCE_COMMIT" = "null" ]; then
echo "Could not resolve the release tag to a source commit." >&2
exit 1
fi
Obtain a release-rehearsal candidate¶
Use the workflow run ID and candidate version from the rehearsal run. The
artifact name is the one used by release-nuget.yml:
export RUN_ID='<rehearsal-run-id>'
export CANDIDATE_VERSION='<candidate-version>'
gh run download "$RUN_ID" \
--repo "$REPOSITORY" \
--name "nuget-candidate-$CANDIDATE_VERSION" \
--dir .
export SOURCE_COMMIT="$(gh run view "$RUN_ID" \
--repo "$REPOSITORY" \
--json headSha \
--jq '.headSha')"
if [ -z "$SOURCE_COMMIT" ] || [ "$SOURCE_COMMIT" = "null" ]; then
echo "Could not resolve the rehearsal run head SHA." >&2
exit 1
fi
The two acquisition blocks are alternatives: run only the block matching the artifact being verified, then continue with the common trust-order steps below.
1. Authenticate evidence before using it¶
Do not treat a downloaded manifest or checksum file as trusted metadata until its own attestation verifies. Run both commands from the asset directory:
gh attestation verify package-manifest.json \
--repo "$REPOSITORY" \
--signer-workflow "$WORKFLOW" \
--source-digest "$SOURCE_COMMIT"
gh attestation verify package-checksums.txt \
--repo "$REPOSITORY" \
--signer-workflow "$WORKFLOW" \
--source-digest "$SOURCE_COMMIT"
An absent, modified, wrong-repository, wrong-workflow, or wrong-commit evidence file fails this step. Stop rather than using its digest inventory.
2. Check the verified inventory and GitHub asset bytes¶
After both evidence files verify, inspect the expected release identity and paired package/symbol inventory:
jq '{schema, version, source_commit,
packages: [.packages[] | {id, version, package: .package.file, symbols: .symbols.file}]}' \
package-manifest.json
jq -e --arg commit "$SOURCE_COMMIT" \
'.schema == "checkpoint-b-candidate-manifest/v2" and .source_commit == $commit' \
package-manifest.json
Confirm the displayed package IDs and version are the release you intended to
verify. The manifest-selected filenames are the complete package/symbol
subject set. Compare that set with every downloaded .nupkg and .snupkg
before calculating any hashes; this fails closed on both missing and extra
release assets:
jq -r '.packages[] | .package.file, .symbols.file' package-manifest.json \
| LC_ALL=C sort -u > expected-package-subjects.txt
for asset in ./*.nupkg ./*.snupkg; do
if [ -f "$asset" ]; then
printf '%s\n' "${asset#./}"
fi
done | LC_ALL=C sort -u > actual-package-subjects.txt
if ! diff -u expected-package-subjects.txt actual-package-subjects.txt; then
echo "Package/symbol release assets do not match the verified manifest." >&2
exit 1
fi
An empty release directory, a missing manifest-selected file, or an extra
.nupkg/.snupkg therefore stops the procedure before a package can be
accepted. Now derive the expected SHA-256 lines from the verified manifest and
compare both the derived checksum evidence and the downloaded
project-controlled package bytes:
jq -r '.packages[] | .package, .symbols | "\(.sha256) \(.file)"' \
package-manifest.json > expected-package-subjects.sha256
diff -u expected-package-subjects.sha256 \
<(grep -Ev '^#|^$' package-checksums.txt)
sha256sum --check expected-package-subjects.sha256
On Windows, use Get-FileHash -Algorithm SHA256 <asset-path> and compare each
result to the verified manifest's corresponding sha256 value. The
package-checksums.txt rendering is convenience evidence only; the verified
manifest remains the canonical package-subject inventory.
3. Verify every package and symbol attestation¶
The byte match does not replace each subject's own provenance. Verify every
manifest-selected .nupkg and .snupkg independently:
while IFS= read -r subject; do
gh attestation verify "$subject" \
--repo "$REPOSITORY" \
--signer-workflow "$WORKFLOW" \
--source-digest "$SOURCE_COMMIT"
done < <(jq -r '.packages[] | .package.file, .symbols.file' package-manifest.json)
Successful verification binds each exact file digest to the repository,
release-nuget.yml, and source commit. Keep the successful output with your
release evidence if you need an audit trail.
Expected failures¶
These checks are intentionally fail-closed:
- Modifying a package or symbol makes
sha256sum --checkfail and makes itsgh attestation verifysubject digest fail. - Modifying
package-manifest.jsonorpackage-checksums.txtmakes its own GitHub attestation verification fail before it can be trusted. - Removing an asset or adding one not named by the verified manifest makes the filename-set diff fail before hashing or attestation; do not substitute a similarly named file.
- If the NuGet output directory contains zero or more than one
.nupkg, package discovery fails closed before identity or signature checks. - Downloading from a source other than NuGet.org, or changing the downloaded
package's embedded ID/version, makes the source or
.nuspecidentity check fail before signature verification.
Verify a package downloaded from NuGet.org¶
This is a distinct post-publication path. NuGet.org repository-signs submitted
primary .nupkg files, adding a repository signature (or a countersignature
when an author signature exists). That is expected platform behavior: the raw
SHA-256 of a NuGet.org-downloaded .nupkg is therefore not expected to
equal the pre-upload manifest digest or GitHub Release attachment digest.
Start this path in a fresh empty directory, separate from any GitHub Release
asset directory. Set the expected primary package ID and version, and use only
https://api.nuget.org/v3/index.json as the source. Create the consumer
configuration there so this path does not depend on a machine- or user-level
NuGet configuration. The packageSources entry pins
the source, signatureValidationMode=require makes signature policy
mandatory, and dotnet nuget trust source obtains the current NuGet.org
repository certificates from its v3 service index. The identity check below
uses only the Python 3 standard library:
cat > nuget.config <<'EOF'
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<config>
<add key="signatureValidationMode" value="require" />
</config>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>
EOF
dotnet nuget trust source nuget.org \
--source-url https://api.nuget.org/v3/index.json \
--configfile ./nuget.config
dotnet nuget trust list --configfile ./nuget.config
Review the list and keep the generated trustedSigners repository entry with
the verification record. If NuGet.org rotates its repository certificates,
rerun the trust source command to refresh this consumer policy. The
generated block must contain a repository whose serviceIndex is
https://api.nuget.org/v3/index.json and one or more current certificate
entries with hashAlgorithm="SHA256" and
allowUntrustedRoot="false"; do not hand-copy stale certificate fingerprints.
The configuration is intentionally explicit: --configfile makes these
packageSources and trustedSigners settings the only settings used by the
verification command. Then verify the downloaded primary package with NuGet's
supported signature tooling. Downloading through the pinned source is part of
the check; do not substitute a package obtained from an unrecorded URL:
This path requires the .NET 10.0.2xx SDK or later because dotnet package download is available starting with that SDK line. See the dotnet package
download command
for the supported SDK and command options.
export PACKAGE_ID=ArchLinterNet.Core
export PACKAGE_VERSION='<version>'
export NUGET_SOURCE=https://api.nuget.org/v3/index.json
dotnet package download "$PACKAGE_ID@$PACKAGE_VERSION" \
--source "$NUGET_SOURCE" \
--configfile ./nuget.config \
--prerelease \
--output ./nuget-package
mapfile -d '' -t downloaded_packages < <(
find ./nuget-package -type f -name '*.nupkg' -print0
)
if [ "${#downloaded_packages[@]}" -ne 1 ]; then
printf 'Expected exactly one downloaded .nupkg, found %s.\n' \
"${#downloaded_packages[@]}" >&2
exit 1
fi
export PACKAGE_FILE="${downloaded_packages[0]}"
python3 - "$PACKAGE_FILE" "$PACKAGE_ID" "$PACKAGE_VERSION" <<'PY'
from __future__ import annotations
import sys
from pathlib import Path
from zipfile import ZipFile
from xml.etree import ElementTree
package_path = Path(sys.argv[1])
expected_id, expected_version = sys.argv[2:]
def local_name(tag: str) -> str:
return tag.rsplit("}", maxsplit=1)[-1]
def child_text(parent: ElementTree.Element, name: str) -> str | None:
for child in parent:
if local_name(child.tag) == name:
return (child.text or "").strip()
return None
with ZipFile(package_path) as package:
nuspecs = [name for name in package.namelist() if name.lower().endswith(".nuspec")]
if len(nuspecs) != 1:
raise SystemExit(f"Expected exactly one .nuspec in {package_path}, found {len(nuspecs)}.")
root = ElementTree.fromstring(package.read(nuspecs[0]))
metadata = next((child for child in root if local_name(child.tag) == "metadata"), None)
if metadata is None:
raise SystemExit(f"Package {package_path} has no metadata element.")
actual_id = child_text(metadata, "id")
actual_version = child_text(metadata, "version")
if (actual_id, actual_version) != (expected_id, expected_version):
raise SystemExit(
f"Package identity mismatch: expected {expected_id}@{expected_version}, "
f"found {actual_id}@{actual_version}."
)
print(f"Verified package identity: {actual_id}@{actual_version}")
PY
dotnet nuget verify "$PACKAGE_FILE" \
--all \
--configfile ./nuget.config
The dotnet package download command proves the selected source and requested
ID/version, the embedded .nuspec check proves the downloaded bytes carry that
same identity, and dotnet nuget verify validates the repository signature
under the explicit trusted-signer configuration. See the
.NET verification command
and NuGet trust-boundary guidance
for supported platform and trusted-signer configuration details. The
NuGet trust command
and nuget.config reference
describe the trustedSigners repository and certificate properties used above.
Do not strip or rewrite signatures to manufacture raw-byte equality, and do not
report the expected repository-signing difference as tampering. .snupkg
behavior is not assumed to mirror the primary package: check it only through
the supported NuGet.org symbol-server contract after a real release exists.
Project decisions¶
NuGet author signing: deferred¶
The project does not currently produce project-controlled NuGet author signatures. GitHub provenance and NuGet.org repository signing are not author signatures. Deferral is deliberate for a solo-maintained project: author signing needs an owned code-signing certificate, secure key custody, renewal/rotation, NuGet.org signer registration, and RFC3161-compatible timestamping.
If author signing is selected later, it needs a separate implementation issue
and release-order review. It must sign the final .nupkg before the
candidate manifest freezes its digest and before GitHub attests that subject;
NuGet.org can then repository-countersign the submitted package.
Package-level SBOM: deferred¶
The project does not currently publish package-level SBOMs. A repository-wide or low-confidence inventory would overstate its precision: a useful SBOM must reproducibly map each shipped package, including source-only and build assets, to its actual components.
If an SBOM is selected later, a separate focused issue must define the package mapping, deterministic generation/versioning, freeze point relative to the manifest, and whether each SBOM becomes a separately attested release-evidence subject. No SBOM or provenance badge is added merely for a score, and this guide makes no formal SLSA-level claim.
Post-publication confirmation¶
After the first real release, rerun the GitHub-asset steps against its release attachments, use the NuGet.org path for the primary package, and confirm live symbol-package behavior through the supported service contract. Any unexpected signature, identity, or attestation failure is a release defect; this follow-up does not block the publication that creates the live artifacts.