From b39f2de6b139adbd140da868acd99841d943a598 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Thu, 2 Jul 2026 22:50:08 +0200 Subject: [PATCH] ci: add NuGet package signing and verification --- .github/workflows/publish-artifacts.yml | 64 ++++++++++++++++++++ .github/workflows/publish-attested.yml | 20 +++++++ .github/workflows/publish-packages.yml | 35 +++++++++++ .github/workflows/release-drafter.yml | 42 +++---------- Docs/Home.md | 1 + Docs/Package-Signing.md | 79 +++++++++++++++++++++++++ README.md | 6 ++ Taskfile.yml | 10 ++++ toc.yml | 2 + 9 files changed, 225 insertions(+), 34 deletions(-) create mode 100644 Docs/Package-Signing.md diff --git a/.github/workflows/publish-artifacts.yml b/.github/workflows/publish-artifacts.yml index 921039e..725b5e0 100644 --- a/.github/workflows/publish-artifacts.yml +++ b/.github/workflows/publish-artifacts.yml @@ -34,6 +34,15 @@ on: required: false type: boolean default: true + secrets: + NUGET_SIGN_CERTIFICATE_BASE64: + required: true + NUGET_SIGN_CERTIFICATE_PASSWORD: + required: true + NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT: + required: true + NUGET_SIGN_TIMESTAMP_URL: + required: true permissions: contents: read @@ -128,6 +137,61 @@ jobs: -o nupkg -p:PackageVersion=${{ steps.version.outputs.redis_version }} + - name: Import signing certificate + env: + NUGET_SIGN_CERTIFICATE_BASE64: ${{ secrets.NUGET_SIGN_CERTIFICATE_BASE64 }} + run: | + if [ -z "$NUGET_SIGN_CERTIFICATE_BASE64" ]; then + echo "NUGET_SIGN_CERTIFICATE_BASE64 secret is required for package signing." >&2 + exit 1 + fi + + printf '%s' "$NUGET_SIGN_CERTIFICATE_BASE64" | base64 --decode > signing-cert.pfx + + - name: Sign packages + env: + NUGET_SIGN_CERTIFICATE_PASSWORD: ${{ secrets.NUGET_SIGN_CERTIFICATE_PASSWORD }} + NUGET_SIGN_TIMESTAMP_URL: ${{ secrets.NUGET_SIGN_TIMESTAMP_URL }} + run: | + if [ -z "$NUGET_SIGN_CERTIFICATE_PASSWORD" ]; then + echo "NUGET_SIGN_CERTIFICATE_PASSWORD secret is required for package signing." >&2 + exit 1 + fi + + if [ -z "$NUGET_SIGN_TIMESTAMP_URL" ]; then + echo "NUGET_SIGN_TIMESTAMP_URL secret is required for package signing." >&2 + exit 1 + fi + + for package in nupkg/*.nupkg; do + dotnet nuget sign "$package" \ + --certificate-path signing-cert.pfx \ + --certificate-password "$NUGET_SIGN_CERTIFICATE_PASSWORD" \ + --timestamper "$NUGET_SIGN_TIMESTAMP_URL" \ + --timestamp-hash-algorithm SHA256 \ + --hash-algorithm SHA256 \ + --overwrite + done + + - name: Verify signed packages + env: + NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT: ${{ secrets.NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT }} + run: | + if [ -z "$NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT" ]; then + echo "NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT secret is required for package verification." >&2 + exit 1 + fi + + for package in nupkg/*.nupkg; do + dotnet nuget verify "$package" \ + --all \ + --certificate-fingerprint "$NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT" + done + + - name: Remove signing certificate + if: ${{ always() }} + run: rm -f signing-cert.pfx + - name: Upload packages uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/publish-attested.yml b/.github/workflows/publish-attested.yml index b3b6870..e050fc9 100644 --- a/.github/workflows/publish-attested.yml +++ b/.github/workflows/publish-attested.yml @@ -7,6 +7,21 @@ on: description: Release version without the leading "v" required: false type: string + workflow_call: + inputs: + version: + description: Release version without the leading "v" + required: false + type: string + secrets: + NUGET_SIGN_CERTIFICATE_BASE64: + required: true + NUGET_SIGN_CERTIFICATE_PASSWORD: + required: true + NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT: + required: true + NUGET_SIGN_TIMESTAMP_URL: + required: true permissions: contents: write @@ -19,6 +34,11 @@ jobs: uses: ./.github/workflows/publish-artifacts.yml with: package_version: ${{ inputs.version }} + secrets: + NUGET_SIGN_CERTIFICATE_BASE64: ${{ secrets.NUGET_SIGN_CERTIFICATE_BASE64 }} + NUGET_SIGN_CERTIFICATE_PASSWORD: ${{ secrets.NUGET_SIGN_CERTIFICATE_PASSWORD }} + NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT: ${{ secrets.NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT }} + NUGET_SIGN_TIMESTAMP_URL: ${{ secrets.NUGET_SIGN_TIMESTAMP_URL }} release: name: Upload artifacts to draft release diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 5917338..eadc238 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -101,6 +101,11 @@ jobs: publish_core: ${{ inputs.publish_core }} publish_governance: ${{ inputs.publish_governance }} publish_redis: ${{ inputs.publish_redis }} + secrets: + NUGET_SIGN_CERTIFICATE_BASE64: ${{ secrets.NUGET_SIGN_CERTIFICATE_BASE64 }} + NUGET_SIGN_CERTIFICATE_PASSWORD: ${{ secrets.NUGET_SIGN_CERTIFICATE_PASSWORD }} + NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT: ${{ secrets.NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT }} + NUGET_SIGN_TIMESTAMP_URL: ${{ secrets.NUGET_SIGN_TIMESTAMP_URL }} publish-nuget: name: Publish to NuGet.org @@ -124,6 +129,21 @@ jobs: path: dist merge-multiple: true + - name: Verify signed packages + env: + NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT: ${{ secrets.NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT }} + run: | + if [ -z "$NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT" ]; then + echo "NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT secret is required for package verification." >&2 + exit 1 + fi + + for package in dist/*.nupkg; do + dotnet nuget verify "$package" \ + --all \ + --certificate-fingerprint "$NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT" + done + - name: NuGet login id: login uses: NuGet/login@v1 @@ -163,6 +183,21 @@ jobs: path: dist merge-multiple: true + - name: Verify signed packages + env: + NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT: ${{ secrets.NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT }} + run: | + if [ -z "$NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT" ]; then + echo "NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT secret is required for package verification." >&2 + exit 1 + fi + + for package in dist/*.nupkg; do + dotnet nuget verify "$package" \ + --all \ + --certificate-fingerprint "$NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT" + done + - name: Push packages to GitHub Packages env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 7a2a5c7..c105fba 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -22,7 +22,6 @@ jobs: runs-on: ubuntu-latest outputs: tag_name: ${{ steps.release_drafter.outputs.tag_name }} - html_url: ${{ steps.release_drafter.outputs.html_url }} steps: - name: Update release draft @@ -34,38 +33,13 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - publish: + publish-attested: needs: update-release-draft - uses: ./.github/workflows/publish-artifacts.yml + uses: ./.github/workflows/publish-attested.yml with: - package_version: ${{ needs.update-release-draft.outputs.tag_name }} - - upload-release-assets: - name: Upload artifacts to release draft - runs-on: ubuntu-latest - needs: - - update-release-draft - - publish - - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Download published artifacts - uses: actions/download-artifact@v6 - with: - pattern: ModularityKit-packages - path: dist - merge-multiple: true - - - name: Upload assets to Release Drafter draft - env: - GITHUB_TOKEN: ${{ github.token }} - REPOSITORY: ${{ github.repository }} - RELEASE_TAG: ${{ needs.update-release-draft.outputs.tag_name }} - DIST_DIR: dist - ENSURE_DRAFT: "true" - FAIL_MESSAGE: "Release Drafter did not return a tag name." - ASSET_PATTERNS: | - * - run: python3 -m scripts.releases.upload_release_assets + version: ${{ needs.update-release-draft.outputs.tag_name }} + secrets: + NUGET_SIGN_CERTIFICATE_BASE64: ${{ secrets.NUGET_SIGN_CERTIFICATE_BASE64 }} + NUGET_SIGN_CERTIFICATE_PASSWORD: ${{ secrets.NUGET_SIGN_CERTIFICATE_PASSWORD }} + NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT: ${{ secrets.NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT }} + NUGET_SIGN_TIMESTAMP_URL: ${{ secrets.NUGET_SIGN_TIMESTAMP_URL }} diff --git a/Docs/Home.md b/Docs/Home.md index c9e7504..c859556 100644 --- a/Docs/Home.md +++ b/Docs/Home.md @@ -11,6 +11,7 @@ approval flow, and Redis-backed storage. - [Architecture](Architecture.md) - [Core concepts](Core-Concepts.md) - [Execution model](ExecutionModel.md) +- [Package signing](Package-Signing.md) - [ADR index](Decision/listadr.md) ## Packages diff --git a/Docs/Package-Signing.md b/Docs/Package-Signing.md new file mode 100644 index 0000000..cade2f0 --- /dev/null +++ b/Docs/Package-Signing.md @@ -0,0 +1,79 @@ +# Package Signing + +`ModularityKit.Mutator` release packages are signed as part of the standard package publish path. + +The repository signs generated `.nupkg` artifacts before they are uploaded or pushed to package feeds. +Unsigned release packages are treated as a validation failure. + +## Signing approach + +The repository uses `dotnet nuget sign` with: + +- a code-signing certificate provided to GitHub Actions as a base64-encoded PFX +- a certificate password stored in GitHub Actions secrets +- an RFC 3161 timestamp server +- `dotnet nuget verify --all` with the expected SHA-256 signer certificate fingerprint + +This keeps signing explicit, auditable, and integrated with the existing `pack` and publish workflows. + +## Release workflow + +The standard package release path is: + +1. pack packages in `.github/workflows/publish-artifacts.yml` +2. sign every `.nupkg` +3. verify every signed `.nupkg` +4. upload signed artifacts +5. download and verify signed artifacts again before pushing to NuGet.org or GitHub Packages + +The signing step changes package contents only by adding signature metadata. + +## Required GitHub secrets + +Configure these repository secrets before using the package publish workflows: + +- `NUGET_SIGN_CERTIFICATE_BASE64` +- `NUGET_SIGN_CERTIFICATE_PASSWORD` +- `NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT` +- `NUGET_SIGN_TIMESTAMP_URL` +- `NUGET_USERNAME` + +Notes: + +- `NUGET_SIGN_CERTIFICATE_BASE64` should be the PFX file encoded as base64 without line wrapping changes. +- `NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT` must be the SHA-256 fingerprint of the signer certificate used for verification. +- `NUGET_SIGN_TIMESTAMP_URL` should point to the timestamp service provided by the certificate issuer or your preferred RFC 3161 service. +- `NUGET_USERNAME` is still required for the existing NuGet Trusted Publishing login step. + +## Local developer expectations + +Local development does not require access to signing material. + +Contributors can still: + +- build the solution +- pack projects locally +- run tests and smoke tests + +Signing is enforced in the repository release workflows, not for ordinary local development. + +If you have access to the signing certificate locally, you can validate package signatures with: + +```bash +dotnet nuget verify path/to/package.nupkg --all --certificate-fingerprint +``` + +To sign a package locally with the same CLI shape used in CI: + +```bash +dotnet nuget sign path/to/package.nupkg \ + --certificate-path path/to/certificate.pfx \ + --certificate-password "" \ + --timestamper "" \ + --timestamp-hash-algorithm SHA256 \ + --hash-algorithm SHA256 +``` + +## NuGet.org expectation + +For NuGet.org publishing, the signing certificate must also be registered with the owning NuGet.org account or organization before publishing signed packages. diff --git a/README.md b/README.md index cfb1d52..ef4b75f 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,9 @@ three published packages. The published site is deployed from `main` to GitHub Pages: https://modularitykit.github.io/ModularityKit.Mutator/ + +## Package signing + +Release packages are signed and verified in the standard package publish workflow. See +[`Docs/Package-Signing.md`](Docs/Package-Signing.md) for the signing approach, required secrets, and +local verification expectations. diff --git a/Taskfile.yml b/Taskfile.yml index 30c62b5..e8660ed 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -92,6 +92,16 @@ tasks: - dotnet build {{.SOLUTION}} -c {{.CONFIGURATION}} - docfx docfx.json + verify:package-signing: + desc: Verify signed NuGet packages with a signer fingerprint + preconditions: + - sh: test -n "{{.PACKAGE}}" + msg: 'PACKAGE is required. Example: task verify:package-signing PACKAGE=dist/ModularityKit.Mutator.0.1.0.nupkg FINGERPRINT=' + - sh: test -n "{{.FINGERPRINT}}" + msg: 'FINGERPRINT is required. Example: task verify:package-signing PACKAGE=dist/ModularityKit.Mutator.0.1.0.nupkg FINGERPRINT=' + cmds: + - dotnet nuget verify {{.PACKAGE}} --all --certificate-fingerprint {{.FINGERPRINT}} + verify: desc: Run the common local verification workflow deps: diff --git a/toc.yml b/toc.yml index 24c8aa3..1d24679 100644 --- a/toc.yml +++ b/toc.yml @@ -16,6 +16,8 @@ href: Docs/Core-Concepts.md - name: Execution Model href: Docs/ExecutionModel.md + - name: Package Signing + href: Docs/Package-Signing.md - name: Roadmap href: Docs/Roadmap.md - name: Decision