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
64 changes: 64 additions & 0 deletions .github/workflows/publish-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/publish-attested.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
35 changes: 35 additions & 0 deletions .github/workflows/publish-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
42 changes: 8 additions & 34 deletions .github/workflows/release-drafter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
1 change: 1 addition & 0 deletions Docs/Home.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions Docs/Package-Signing.md
Original file line number Diff line number Diff line change
@@ -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 <SHA256_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 "<PASSWORD>" \
--timestamper "<RFC3161_TIMESTAMP_URL>" \
--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.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 10 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=<sha256>'
- sh: test -n "{{.FINGERPRINT}}"
msg: 'FINGERPRINT is required. Example: task verify:package-signing PACKAGE=dist/ModularityKit.Mutator.0.1.0.nupkg FINGERPRINT=<sha256>'
cmds:
- dotnet nuget verify {{.PACKAGE}} --all --certificate-fingerprint {{.FINGERPRINT}}

verify:
desc: Run the common local verification workflow
deps:
Expand Down
2 changes: 2 additions & 0 deletions toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading