Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion .github/workflows/release-nuget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,67 @@ permissions:
id-token: write

jobs:
# This workflow hands `version` straight to the reusable publish job, which pushes to nuget.org -
# an irreversible act, since nuget.org permits unlisting but not deletion. release.yml guards the
# same publish behind a validate job; this one did not, so a dispatch could publish a version that
# disagreed with the assemblies it shipped, or leave consumer-template pinning a different release.
# The guards are duplicated rather than shared because a reusable workflow cannot gate its own
# caller, and a guard that the caller may skip is not a guard.
validate:
name: Validate release inputs
if: github.repository_owner == 'IanFrelinger'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: |
10.0.x
8.0.x

- name: Verify global.json SDK pin matches installed SDKs
shell: bash
run: bash scripts/verify-release-sdk-pin.sh

# Directory.Build.props/.targets stamp every assembly from the root VERSION file, while the
# publish job takes package_version from this input. Without the guard a dispatch ships nupkgs
# whose PackageVersion disagrees with the assemblies inside them.
- name: Dispatch version must match root VERSION
shell: bash
env:
IN_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
# shellcheck source=scripts/lib/release-staging-guards.sh
source scripts/lib/release-staging-guards.sh
if ! assert_dispatch_version_allowed "${IN_VERSION}"; then
echo "::error::Publishing ${IN_VERSION} from ${GITHUB_REF_NAME} would ship packages whose version disagrees with their assemblies. See the ABORT lines above."
exit 1
fi

# ci/published-version is what consumer-template pins strangers to. Publishing without moving
# it leaves every copied template installing the previous release.
- name: Consumer pin (ci/published-version) must name this release
shell: bash
env:
IN_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
# shellcheck source=scripts/lib/release-staging-guards.sh
source scripts/lib/release-staging-guards.sh
if ! assert_consumer_pin_matches "${IN_VERSION}"; then
echo "::error::ci/published-version does not name this release; a stranger copying consumer-template would install the previous one. See the ABORT lines above."
exit 1
fi

nuget:
# Forks: without NUGET_PUBLISH_MODE this is artifact-only, but the guard keeps a fork from
# ever reaching the nuget.org push steps by accident (docs/CiSecrets.md).
if: github.repository_owner == 'IanFrelinger'
needs: [validate]
if: github.repository_owner == 'IanFrelinger' && needs.validate.result == 'success'
uses: ./.github/workflows/reusable-release-nuget.yml
secrets: inherit
with:
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/shell-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,13 @@ jobs:
# then required >= 10.0.12, and NU1605 failed three packaging lanes at once - in the one
# project that stands in for a stranger consuming these packages.
bash tests/scripts/sample-pin-alignment.test.sh

- name: Published package bytes are compared against something that can match
shell: bash
run: |
# The post-push check compared the raw SHA-256 of the pushed .nupkg against the bytes
# nuget.org serves. nuget.org repository-signs on publish, appending .signature.p7s, so
# those never match - the check could only fail, and it runs AFTER the push, once the
# packages are public and nuget.org allows unlisting but not deletion. It had never run:
# the v0.1.2 release died a step earlier on index lag.
bash tests/scripts/nupkg-content-digest.test.sh
8 changes: 8 additions & 0 deletions .lycheeignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ https://digital-strategy.ec.europa.eu/en/policies/regulatory-framework-ai

# Vendor blog returns 503 to CI user agents intermittently (see docs/ICPResearchMemo.md).
https://codebrewtools.com/blogs/ai-native-internal-developer-portals-2026

# Claude artifact links in docs/handoff/HANDOFF.md (added 2026-08-22 by #362). These are PRIVATE
# artifacts from the originating session: claude.ai answers an unauthenticated fetch with 403, so a
# link checker can never verify them and is only measuring claude.ai's auth behaviour. They sat
# unnoticed because this workflow's `push` trigger had not re-run over that file since 2026-09-10,
# while the `pull_request` trigger has no path filter - so the first PR to reach it failed on a
# defect that had been on master for five weeks.
https://claude.ai/code/artifact/*
65 changes: 65 additions & 0 deletions scripts/lib/nupkg_content_digest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""One definition of "the same package", used by both sides of the post-push check.

WHY THIS EXISTS. The release workflow hashes each .nupkg before pushing it, then downloads the
package nuget.org serves and compares. Those two byte streams are NEVER equal: nuget.org
repository-signs on publish, which appends a `.signature.p7s` entry to the archive. Measured on the
real feed - ashlar.sdk 0.1.2 as served contains `.signature.p7s` (12987 bytes) and hashes to
eb2111dc..., which is not the hash of the file that was pushed. A raw byte comparison therefore
cannot pass, and it runs AFTER `dotnet nuget push`, so it fails once the packages are already public
and unrecallable.

WHAT IS COMPARED INSTEAD. Every archive entry except the signature, by name and by uncompressed
content. That is the part nuget.org must not alter, and it is what a consumer actually restores.
Verified against the served package: signing appends `.signature.p7s` and touches nothing else -
`[Content_Types].xml` does not even declare the p7s extension, so the rest of the archive is
untouched.

Deliberately NOT compared: the zip container itself (entry order, compression, timestamps). Those
are re-encoded by any tool that adds an entry and carry no promise to a consumer.

Both the manifest writer and the post-push verifier import this, so the two sides cannot drift into
computing different things and agreeing anyway.
"""

from __future__ import annotations

import hashlib
import zipfile

# The repository signature nuget.org appends on publish. Excluded because its PRESENCE is the
# expected difference; a package that arrives without it is caught by signature verification, not by
# a content digest.
SIGNATURE_ENTRY = ".signature.p7s"


def frame_entry(name: str, digest: bytes) -> bytes:
"""One entry's contribution to the digest stream, length-prefixed.

The prefix makes the stream prefix-free. Without it, entry NAMES run straight into the
fixed-width digest that follows, and two different entry sets can concatenate to the same bytes
- ({"a": X}, {"bc": Y}) against ({"ab": X'}, {"c": Y'}) is such a pair whenever the digests line
up. That is not constructible against real SHA-256 content, which is why this is framed here as
a pure function: the property is asserted directly on chosen bytes rather than left as a claim
no test can reach.
"""
encoded = name.encode("utf-8")
return len(encoded).to_bytes(8, "big") + encoded + digest


def content_digest(nupkg_path: str) -> str:
"""SHA-256 over every entry except the repository signature, by name and uncompressed content."""
outer = hashlib.sha256()
with zipfile.ZipFile(nupkg_path) as archive:
names = sorted(n for n in archive.namelist() if n != SIGNATURE_ENTRY)
for name in names:
with archive.open(name) as entry:
inner = hashlib.sha256()
for chunk in iter(lambda: entry.read(1024 * 1024), b""):
inner.update(chunk)
outer.update(frame_entry(name, inner.digest()))
return outer.hexdigest()


def has_signature(nupkg_path: str) -> bool:
with zipfile.ZipFile(nupkg_path) as archive:
return SIGNATURE_ENTRY in archive.namelist()
27 changes: 24 additions & 3 deletions scripts/render-nuget-release-manifest.sh
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
#!/usr/bin/env bash
# Write nuget-publish-manifest.json + per-file .sha256.txt for each *.nupkg in DIR.
#
# Each row carries TWO digests, because they answer different questions:
#
# sha256 the bytes of the file we are about to push. Provenance for the artifact we
# produced, and the right thing to compare on a staging feed that does not re-sign.
#
# contentSha256 every archive entry except the repository signature, by name and uncompressed
# content (scripts/lib/nupkg_content_digest.py). This is what survives publication:
# nuget.org repository-signs on publish, which appends `.signature.p7s` and changes
# the file hash, so `sha256` CANNOT match what the feed serves. Comparing it there
# fails every time, after the push has already made the packages public.
set -euo pipefail
DIR="${1:?directory containing *.nupkg}"
VER="${PACKAGE_VERSION:?set PACKAGE_VERSION (semver without v prefix)}"
VER="${VER#v}"
OUT_JSON="${DIR}/nuget-publish-manifest.json"
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/lib" && pwd)"

python3 - "$DIR" "$VER" "$OUT_JSON" <<'PY'
python3 - "$DIR" "$VER" "$OUT_JSON" "$LIB_DIR" <<'PY'
import hashlib, json, os, sys

d, ver, out_path = sys.argv[1], sys.argv[2], sys.argv[3]
d, ver, out_path, lib_dir = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
sys.path.insert(0, lib_dir)
from nupkg_content_digest import content_digest

rows = []
for name in sorted(os.listdir(d)):
if not name.endswith(".nupkg"):
Expand All @@ -27,7 +42,13 @@ for name in sorted(os.listdir(d)):
digest = h.hexdigest()
with open(path + ".sha256.txt", "w", encoding="utf-8") as sf:
sf.write(f"{digest} {name}\n")
rows.append({"id": pid, "version": ver, "fileName": name, "sha256": digest})
rows.append({
"id": pid,
"version": ver,
"fileName": name,
"sha256": digest,
"contentSha256": content_digest(path),
})
with open(out_path, "w", encoding="utf-8") as f:
json.dump(rows, f, indent=2)
print(f"Wrote {out_path} ({len(rows)} packages)")
Expand Down
93 changes: 78 additions & 15 deletions scripts/verify-nuget-published-sha256-matches-manifest.sh
Original file line number Diff line number Diff line change
@@ -1,38 +1,101 @@
#!/usr/bin/env bash
# Download each package from nuget.org flat-container and assert sha256 matches nuget-publish-manifest.json from the pack step.
# Assert that what nuget.org SERVES is the package we packed.
#
# WHAT CHANGED AND WHY. This compared the raw SHA-256 of the downloaded .nupkg against the hash
# taken before `dotnet nuget push`. Those bytes are never equal: nuget.org repository-signs on
# publish, appending a `.signature.p7s` entry. Measured on the real feed - ashlar.sdk 0.1.2 as
# served carries that entry and hashes to eb2111dc..., not to the pushed file's hash. So the check
# could only ever fail, and it runs AFTER the push, when the packages are already public and
# nuget.org allows unlisting but not deletion. It had never actually executed: the v0.1.2 release
# (run 33867752688) died one step earlier on flat-container index lag, which is why a check that
# cannot pass sat here looking green.
#
# It now compares the package CONTENT - every entry except the signature, by name and uncompressed
# bytes (scripts/lib/nupkg_content_digest.py). That is the part publication must not alter, and the
# part a consumer restores. The signature's presence is asserted separately and positively, so
# excluding it from the digest does not lose the fact that it must be there.
#
# Env: ASHLAR_MANIFEST_JSON (path), ASHLAR_NUGET_VERIFY_VERSION (must match manifest entries)
set -euo pipefail
MAN="${ASHLAR_MANIFEST_JSON:?set ASHLAR_MANIFEST_JSON}"
VER="${ASHLAR_NUGET_VERIFY_VERSION:?set ASHLAR_NUGET_VERIFY_VERSION}"
VER="${VER#v}"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "${WORKDIR}"' EXIT
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/lib" && pwd)"

python3 - "$MAN" "$VER" "$WORKDIR" <<'PY'
import hashlib, json, os, subprocess, sys, urllib.request
python3 - "$MAN" "$VER" "$WORKDIR" "$LIB_DIR" <<'PY'
import json, os, sys, urllib.request

manifest_path, ver, workdir, lib_dir = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
sys.path.insert(0, lib_dir)
from nupkg_content_digest import content_digest, has_signature

manifest_path, ver, workdir = sys.argv[1], sys.argv[2], sys.argv[3]
with open(manifest_path, encoding="utf-8") as f:
rows = json.load(f)
os.makedirs(workdir, exist_ok=True)

checked = 0
failures = 0
for row in rows:
pid, v, expected = row["id"], row["version"], row["sha256"]
pid, v = row["id"], row["version"]
if v != ver:
print(f"skip {pid} version mismatch manifest {v} vs {ver}", file=sys.stderr)
continue
id_lc = pid.lower()
v_lc = v.lower()
expected = row.get("contentSha256")
if not expected:
print(
f"::error::manifest row for {pid} {v} has no contentSha256. It was written by an older "
f"render-nuget-release-manifest.sh, which recorded only the pre-push file hash - a value "
f"the served package can never match. Re-render the manifest.",
file=sys.stderr,
)
failures += 1
continue

id_lc, v_lc = pid.lower(), v.lower()
url = f"https://api.nuget.org/v3-flatcontainer/{id_lc}/{v_lc}/{id_lc}.{v_lc}.nupkg"
out = os.path.join(workdir, row["fileName"])
print(f"download {url}")
urllib.request.urlretrieve(url, out)
h = hashlib.sha256()
with open(out, "rb") as bf:
for chunk in iter(lambda: bf.read(1024 * 1024), b""):
h.update(chunk)
got = h.hexdigest()

got = content_digest(out)
if got.lower() != expected.lower():
print(f"::error::SHA256 mismatch for {pid} {v}: expected {expected} got {got}", file=sys.stderr)
sys.exit(1)
print("verify-nuget-published-sha256-matches-manifest: OK")
print(
f"::error::content mismatch for {pid} {v}: the package nuget.org serves does not "
f"contain the bytes we packed (expected {expected}, got {got}). This compares every "
f"archive entry except the repository signature, so it is NOT explained by signing.",
file=sys.stderr,
)
failures += 1
continue

if not has_signature(out):
print(
f"::error::{pid} {v} came back without a .signature.p7s entry. nuget.org repository-signs "
f"on publish, so a package served unsigned means it did not come through the normal "
f"publication path.",
file=sys.stderr,
)
failures += 1
continue

checked += 1

# POSITIVE CONTROL: a manifest that parsed to nothing, or whose every row was skipped on a version
# mismatch, would reach this point having verified nothing at all.
if checked == 0 and failures == 0:
print(
f"::error::verified 0 packages for version {ver}. The manifest has {len(rows)} row(s), so "
f"either it is empty or every row was skipped on a version mismatch. This check passed "
f"without inspecting anything.",
file=sys.stderr,
)
sys.exit(1)

if failures:
print(f"verify-nuget-published-sha256-matches-manifest: {failures} failure(s)", file=sys.stderr)
sys.exit(1)

print(f"verify-nuget-published-sha256-matches-manifest: OK ({checked} package(s) match, all signed)")
PY
Loading
Loading