Skip to content

Commit ac33afc

Browse files
authored
feat: sign GitHub Release files with Sigstore (#478)
PyPI attestations cover the PyPI copy only, so files downloaded from a GitHub Release had nothing to verify against and Scorecard's Signed-Releases check scored 0 on every generated repo. create-release now signs each distribution and the SBOM keylessly and uploads the .sigstore.json bundles next to them.
1 parent ea36d8e commit ac33afc

3 files changed

Lines changed: 51 additions & 3 deletions

File tree

‎template/.github/{% if include_actions %}workflows{% endif %}/publish-release.yml.jinja‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ jobs:
123123
runs-on: ubuntu-latest
124124
permissions:
125125
contents: write
126+
# Keyless Sigstore signing below exchanges this job's OIDC token for a
127+
# short-lived certificate naming this workflow. Granted here rather than in
128+
# `build`, which runs the project's own build hooks.
129+
id-token: write
126130
steps:
127131
# Check out main (not the tag): main carries the CHANGELOG entry the merged
128132
# changelog PR just added, which the tag does not yet have.
@@ -174,6 +178,21 @@ jobs:
174178
name: {% raw %}${{ env.sbom-artifact-name }}{% endraw %}
175179
path: sbom/
176180
181+
- name: Install uv
182+
uses: astral-sh/setup-uv@v10.3.0
183+
with:
184+
# renovate: datasource=github-releases depName=astral-sh/uv
185+
version: "0.13.1"
186+
187+
# One `<file>.sigstore.json` bundle per release file, attached to the release
188+
# next to it. PyPI already carries attestations for the distributions, but a
189+
# download from the GitHub Release had nothing to verify against, which is what
190+
# Scorecard's Signed-Releases check reports. Verify a download with
191+
# `sigstore verify github <file> --bundle <file>.sigstore.json --repository <owner>/<repo>`.
192+
- name: Sign the release artifacts with Sigstore
193+
# renovate: datasource=pypi depName=sigstore
194+
run: uvx sigstore==4.5.0 sign dist/* sbom/sbom.cdx.json
195+
177196
# Created as a draft: a draft release is visible to maintainers only, so nothing
178197
# is public until the `pypi` environment gate has been approved and the upload
179198
# has succeeded. `finalize-release` undrafts it. If the gate is rejected or the
@@ -198,14 +217,14 @@ jobs:
198217
# decision, taken only after a successful upload.
199218
if gh release view "$VERSION" >/dev/null 2>&1; then
200219
echo "Release $VERSION already exists; refreshing its notes and artifacts"
201-
gh release upload "$VERSION" dist/* sbom/sbom.cdx.json --clobber
220+
gh release upload "$VERSION" dist/* sbom/* --clobber
202221
gh release edit "$VERSION" --notes "$RELEASE_NOTES"
203222
else
204223
gh release create "$VERSION" \
205224
--draft \
206225
--title "$VERSION" \
207226
--notes "$RELEASE_NOTES" \
208-
dist/* sbom/sbom.cdx.json
227+
dist/* sbom/*
209228
fi
210229
211230
pypi-publish:

‎template/docs/pages/explanation/security.md.jinja‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ audited release workflow.
1414
**Signed artifacts (PEP 740 attestations).** Every published distribution carries a
1515
cryptographic attestation, produced with Sigstore, binding the artifact to the exact
1616
workflow run that built it. You can verify that what you install is what this project
17-
actually released, not a tampered copy.
17+
actually released, not a tampered copy. The same holds for files downloaded from a GitHub
18+
Release: each one sits next to a `.sigstore.json` bundle, checked with
19+
`sigstore verify github <file> --bundle <file>.sigstore.json --repository {{ github_username }}/{{ project_slug }}`.
1820

1921
**A software bill of materials (SBOM).** Each release ships a CycloneDX SBOM enumerating
2022
every dependency it resolves. When a vulnerability is disclosed in a transitive

‎tests/test_security_hardening.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,33 @@ def test_publish_flow_is_self_contained_with_sbom(copie):
217217
assert "sbom" in publish.lower()
218218

219219

220+
def test_release_files_are_signed_with_sigstore(copie):
221+
"""Every GitHub Release file gets a Sigstore bundle attached next to it.
222+
223+
PyPI attestations cover the PyPI copy only, so a download from the GitHub Release
224+
had nothing to verify against and Scorecard's Signed-Releases check scored 0 on
225+
every generated repo. Signing runs in `create-release`, the job that uploads, which
226+
is the one that needs `id-token: write`; `build` runs the project's own build hooks
227+
and must not hold it.
228+
"""
229+
import yaml
230+
231+
result = copie.copy(extra_answers={"include_actions": True})
232+
publish = _read(result.project_dir, ".github/workflows/publish-release.yml")
233+
jobs = yaml.safe_load(publish)["jobs"]
234+
235+
assert jobs["create-release"]["permissions"].get("id-token") == "write"
236+
assert "id-token" not in (jobs["build"].get("permissions") or {})
237+
runs = [step.get("run", "") for step in jobs["create-release"]["steps"]]
238+
assert any("sigstore" in run and " sign dist/* sbom/sbom.cdx.json" in run for run in runs), (
239+
"the release files are no longer signed before upload"
240+
)
241+
# The bundles land next to the files they sign, so both upload paths must take the
242+
# whole directories rather than naming the unsigned files one by one.
243+
assert publish.count("dist/* sbom/*") == 2
244+
assert "sbom/sbom.cdx.json --clobber" not in publish
245+
246+
220247
def test_changelog_uses_app_token_not_pat(copie):
221248
"""The changelog PR is opened with a short-lived GitHub App token, not a long-lived PAT."""
222249
result = copie.copy(extra_answers={"include_actions": True})

0 commit comments

Comments
 (0)