Skip to content

Commit a5d6618

Browse files
authored
ci: add NuGet package signing and verification (#69)
2 parents 30736d5 + b39f2de commit a5d6618

9 files changed

Lines changed: 225 additions & 34 deletions

File tree

.github/workflows/publish-artifacts.yml

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ on:
3434
required: false
3535
type: boolean
3636
default: true
37+
secrets:
38+
NUGET_SIGN_CERTIFICATE_BASE64:
39+
required: true
40+
NUGET_SIGN_CERTIFICATE_PASSWORD:
41+
required: true
42+
NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT:
43+
required: true
44+
NUGET_SIGN_TIMESTAMP_URL:
45+
required: true
3746

3847
permissions:
3948
contents: read
@@ -128,6 +137,61 @@ jobs:
128137
-o nupkg
129138
-p:PackageVersion=${{ steps.version.outputs.redis_version }}
130139
140+
- name: Import signing certificate
141+
env:
142+
NUGET_SIGN_CERTIFICATE_BASE64: ${{ secrets.NUGET_SIGN_CERTIFICATE_BASE64 }}
143+
run: |
144+
if [ -z "$NUGET_SIGN_CERTIFICATE_BASE64" ]; then
145+
echo "NUGET_SIGN_CERTIFICATE_BASE64 secret is required for package signing." >&2
146+
exit 1
147+
fi
148+
149+
printf '%s' "$NUGET_SIGN_CERTIFICATE_BASE64" | base64 --decode > signing-cert.pfx
150+
151+
- name: Sign packages
152+
env:
153+
NUGET_SIGN_CERTIFICATE_PASSWORD: ${{ secrets.NUGET_SIGN_CERTIFICATE_PASSWORD }}
154+
NUGET_SIGN_TIMESTAMP_URL: ${{ secrets.NUGET_SIGN_TIMESTAMP_URL }}
155+
run: |
156+
if [ -z "$NUGET_SIGN_CERTIFICATE_PASSWORD" ]; then
157+
echo "NUGET_SIGN_CERTIFICATE_PASSWORD secret is required for package signing." >&2
158+
exit 1
159+
fi
160+
161+
if [ -z "$NUGET_SIGN_TIMESTAMP_URL" ]; then
162+
echo "NUGET_SIGN_TIMESTAMP_URL secret is required for package signing." >&2
163+
exit 1
164+
fi
165+
166+
for package in nupkg/*.nupkg; do
167+
dotnet nuget sign "$package" \
168+
--certificate-path signing-cert.pfx \
169+
--certificate-password "$NUGET_SIGN_CERTIFICATE_PASSWORD" \
170+
--timestamper "$NUGET_SIGN_TIMESTAMP_URL" \
171+
--timestamp-hash-algorithm SHA256 \
172+
--hash-algorithm SHA256 \
173+
--overwrite
174+
done
175+
176+
- name: Verify signed packages
177+
env:
178+
NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT: ${{ secrets.NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT }}
179+
run: |
180+
if [ -z "$NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT" ]; then
181+
echo "NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT secret is required for package verification." >&2
182+
exit 1
183+
fi
184+
185+
for package in nupkg/*.nupkg; do
186+
dotnet nuget verify "$package" \
187+
--all \
188+
--certificate-fingerprint "$NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT"
189+
done
190+
191+
- name: Remove signing certificate
192+
if: ${{ always() }}
193+
run: rm -f signing-cert.pfx
194+
131195
- name: Upload packages
132196
uses: actions/upload-artifact@v4
133197
with:

.github/workflows/publish-attested.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ on:
77
description: Release version without the leading "v"
88
required: false
99
type: string
10+
workflow_call:
11+
inputs:
12+
version:
13+
description: Release version without the leading "v"
14+
required: false
15+
type: string
16+
secrets:
17+
NUGET_SIGN_CERTIFICATE_BASE64:
18+
required: true
19+
NUGET_SIGN_CERTIFICATE_PASSWORD:
20+
required: true
21+
NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT:
22+
required: true
23+
NUGET_SIGN_TIMESTAMP_URL:
24+
required: true
1025

1126
permissions:
1227
contents: write
@@ -19,6 +34,11 @@ jobs:
1934
uses: ./.github/workflows/publish-artifacts.yml
2035
with:
2136
package_version: ${{ inputs.version }}
37+
secrets:
38+
NUGET_SIGN_CERTIFICATE_BASE64: ${{ secrets.NUGET_SIGN_CERTIFICATE_BASE64 }}
39+
NUGET_SIGN_CERTIFICATE_PASSWORD: ${{ secrets.NUGET_SIGN_CERTIFICATE_PASSWORD }}
40+
NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT: ${{ secrets.NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT }}
41+
NUGET_SIGN_TIMESTAMP_URL: ${{ secrets.NUGET_SIGN_TIMESTAMP_URL }}
2242

2343
release:
2444
name: Upload artifacts to draft release

.github/workflows/publish-packages.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ jobs:
101101
publish_core: ${{ inputs.publish_core }}
102102
publish_governance: ${{ inputs.publish_governance }}
103103
publish_redis: ${{ inputs.publish_redis }}
104+
secrets:
105+
NUGET_SIGN_CERTIFICATE_BASE64: ${{ secrets.NUGET_SIGN_CERTIFICATE_BASE64 }}
106+
NUGET_SIGN_CERTIFICATE_PASSWORD: ${{ secrets.NUGET_SIGN_CERTIFICATE_PASSWORD }}
107+
NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT: ${{ secrets.NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT }}
108+
NUGET_SIGN_TIMESTAMP_URL: ${{ secrets.NUGET_SIGN_TIMESTAMP_URL }}
104109

105110
publish-nuget:
106111
name: Publish to NuGet.org
@@ -124,6 +129,21 @@ jobs:
124129
path: dist
125130
merge-multiple: true
126131

132+
- name: Verify signed packages
133+
env:
134+
NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT: ${{ secrets.NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT }}
135+
run: |
136+
if [ -z "$NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT" ]; then
137+
echo "NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT secret is required for package verification." >&2
138+
exit 1
139+
fi
140+
141+
for package in dist/*.nupkg; do
142+
dotnet nuget verify "$package" \
143+
--all \
144+
--certificate-fingerprint "$NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT"
145+
done
146+
127147
- name: NuGet login
128148
id: login
129149
uses: NuGet/login@v1
@@ -163,6 +183,21 @@ jobs:
163183
path: dist
164184
merge-multiple: true
165185

186+
- name: Verify signed packages
187+
env:
188+
NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT: ${{ secrets.NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT }}
189+
run: |
190+
if [ -z "$NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT" ]; then
191+
echo "NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT secret is required for package verification." >&2
192+
exit 1
193+
fi
194+
195+
for package in dist/*.nupkg; do
196+
dotnet nuget verify "$package" \
197+
--all \
198+
--certificate-fingerprint "$NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT"
199+
done
200+
166201
- name: Push packages to GitHub Packages
167202
env:
168203
GITHUB_TOKEN: ${{ github.token }}

.github/workflows/release-drafter.yml

Lines changed: 8 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ jobs:
2222
runs-on: ubuntu-latest
2323
outputs:
2424
tag_name: ${{ steps.release_drafter.outputs.tag_name }}
25-
html_url: ${{ steps.release_drafter.outputs.html_url }}
2625

2726
steps:
2827
- name: Update release draft
@@ -34,38 +33,13 @@ jobs:
3433
env:
3534
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3635

37-
publish:
36+
publish-attested:
3837
needs: update-release-draft
39-
uses: ./.github/workflows/publish-artifacts.yml
38+
uses: ./.github/workflows/publish-attested.yml
4039
with:
41-
package_version: ${{ needs.update-release-draft.outputs.tag_name }}
42-
43-
upload-release-assets:
44-
name: Upload artifacts to release draft
45-
runs-on: ubuntu-latest
46-
needs:
47-
- update-release-draft
48-
- publish
49-
50-
steps:
51-
- name: Checkout
52-
uses: actions/checkout@v5
53-
54-
- name: Download published artifacts
55-
uses: actions/download-artifact@v6
56-
with:
57-
pattern: ModularityKit-packages
58-
path: dist
59-
merge-multiple: true
60-
61-
- name: Upload assets to Release Drafter draft
62-
env:
63-
GITHUB_TOKEN: ${{ github.token }}
64-
REPOSITORY: ${{ github.repository }}
65-
RELEASE_TAG: ${{ needs.update-release-draft.outputs.tag_name }}
66-
DIST_DIR: dist
67-
ENSURE_DRAFT: "true"
68-
FAIL_MESSAGE: "Release Drafter did not return a tag name."
69-
ASSET_PATTERNS: |
70-
*
71-
run: python3 -m scripts.releases.upload_release_assets
40+
version: ${{ needs.update-release-draft.outputs.tag_name }}
41+
secrets:
42+
NUGET_SIGN_CERTIFICATE_BASE64: ${{ secrets.NUGET_SIGN_CERTIFICATE_BASE64 }}
43+
NUGET_SIGN_CERTIFICATE_PASSWORD: ${{ secrets.NUGET_SIGN_CERTIFICATE_PASSWORD }}
44+
NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT: ${{ secrets.NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT }}
45+
NUGET_SIGN_TIMESTAMP_URL: ${{ secrets.NUGET_SIGN_TIMESTAMP_URL }}

Docs/Home.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ approval flow, and Redis-backed storage.
1111
- [Architecture](Architecture.md)
1212
- [Core concepts](Core-Concepts.md)
1313
- [Execution model](ExecutionModel.md)
14+
- [Package signing](Package-Signing.md)
1415
- [ADR index](Decision/listadr.md)
1516

1617
## Packages

Docs/Package-Signing.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Package Signing
2+
3+
`ModularityKit.Mutator` release packages are signed as part of the standard package publish path.
4+
5+
The repository signs generated `.nupkg` artifacts before they are uploaded or pushed to package feeds.
6+
Unsigned release packages are treated as a validation failure.
7+
8+
## Signing approach
9+
10+
The repository uses `dotnet nuget sign` with:
11+
12+
- a code-signing certificate provided to GitHub Actions as a base64-encoded PFX
13+
- a certificate password stored in GitHub Actions secrets
14+
- an RFC 3161 timestamp server
15+
- `dotnet nuget verify --all` with the expected SHA-256 signer certificate fingerprint
16+
17+
This keeps signing explicit, auditable, and integrated with the existing `pack` and publish workflows.
18+
19+
## Release workflow
20+
21+
The standard package release path is:
22+
23+
1. pack packages in `.github/workflows/publish-artifacts.yml`
24+
2. sign every `.nupkg`
25+
3. verify every signed `.nupkg`
26+
4. upload signed artifacts
27+
5. download and verify signed artifacts again before pushing to NuGet.org or GitHub Packages
28+
29+
The signing step changes package contents only by adding signature metadata.
30+
31+
## Required GitHub secrets
32+
33+
Configure these repository secrets before using the package publish workflows:
34+
35+
- `NUGET_SIGN_CERTIFICATE_BASE64`
36+
- `NUGET_SIGN_CERTIFICATE_PASSWORD`
37+
- `NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT`
38+
- `NUGET_SIGN_TIMESTAMP_URL`
39+
- `NUGET_USERNAME`
40+
41+
Notes:
42+
43+
- `NUGET_SIGN_CERTIFICATE_BASE64` should be the PFX file encoded as base64 without line wrapping changes.
44+
- `NUGET_SIGN_CERTIFICATE_SHA256_FINGERPRINT` must be the SHA-256 fingerprint of the signer certificate used for verification.
45+
- `NUGET_SIGN_TIMESTAMP_URL` should point to the timestamp service provided by the certificate issuer or your preferred RFC 3161 service.
46+
- `NUGET_USERNAME` is still required for the existing NuGet Trusted Publishing login step.
47+
48+
## Local developer expectations
49+
50+
Local development does not require access to signing material.
51+
52+
Contributors can still:
53+
54+
- build the solution
55+
- pack projects locally
56+
- run tests and smoke tests
57+
58+
Signing is enforced in the repository release workflows, not for ordinary local development.
59+
60+
If you have access to the signing certificate locally, you can validate package signatures with:
61+
62+
```bash
63+
dotnet nuget verify path/to/package.nupkg --all --certificate-fingerprint <SHA256_FINGERPRINT>
64+
```
65+
66+
To sign a package locally with the same CLI shape used in CI:
67+
68+
```bash
69+
dotnet nuget sign path/to/package.nupkg \
70+
--certificate-path path/to/certificate.pfx \
71+
--certificate-password "<PASSWORD>" \
72+
--timestamper "<RFC3161_TIMESTAMP_URL>" \
73+
--timestamp-hash-algorithm SHA256 \
74+
--hash-algorithm SHA256
75+
```
76+
77+
## NuGet.org expectation
78+
79+
For NuGet.org publishing, the signing certificate must also be registered with the owning NuGet.org account or organization before publishing signed packages.

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,9 @@ three published packages.
6868
The published site is deployed from `main` to GitHub Pages:
6969

7070
https://modularitykit.github.io/ModularityKit.Mutator/
71+
72+
## Package signing
73+
74+
Release packages are signed and verified in the standard package publish workflow. See
75+
[`Docs/Package-Signing.md`](Docs/Package-Signing.md) for the signing approach, required secrets, and
76+
local verification expectations.

Taskfile.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@ tasks:
9292
- dotnet build {{.SOLUTION}} -c {{.CONFIGURATION}}
9393
- docfx docfx.json
9494

95+
verify:package-signing:
96+
desc: Verify signed NuGet packages with a signer fingerprint
97+
preconditions:
98+
- sh: test -n "{{.PACKAGE}}"
99+
msg: 'PACKAGE is required. Example: task verify:package-signing PACKAGE=dist/ModularityKit.Mutator.0.1.0.nupkg FINGERPRINT=<sha256>'
100+
- sh: test -n "{{.FINGERPRINT}}"
101+
msg: 'FINGERPRINT is required. Example: task verify:package-signing PACKAGE=dist/ModularityKit.Mutator.0.1.0.nupkg FINGERPRINT=<sha256>'
102+
cmds:
103+
- dotnet nuget verify {{.PACKAGE}} --all --certificate-fingerprint {{.FINGERPRINT}}
104+
95105
verify:
96106
desc: Run the common local verification workflow
97107
deps:

toc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
href: Docs/Core-Concepts.md
1717
- name: Execution Model
1818
href: Docs/ExecutionModel.md
19+
- name: Package Signing
20+
href: Docs/Package-Signing.md
1921
- name: Roadmap
2022
href: Docs/Roadmap.md
2123
- name: Decision

0 commit comments

Comments
 (0)