Skip to content

Commit bb2cdcb

Browse files
committed
build: attest exact release provenance
1 parent 174f890 commit bb2cdcb

6 files changed

Lines changed: 110 additions & 12 deletions

File tree

.github/workflows/release-assets.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ jobs:
2121
runs-on: ubuntu-latest
2222
timeout-minutes: 15
2323
permissions:
24+
artifact-metadata: write
25+
attestations: write
2426
contents: write
27+
id-token: write
2528
steps:
2629
- name: Verify release tag targets protected main
2730
id: verify-tag
@@ -129,6 +132,11 @@ jobs:
129132
env:
130133
RELEASE_TAG: ${{ inputs.release_tag }}
131134
run: python scripts/release_assets.py --tag "$RELEASE_TAG" --output-dir dist
135+
- name: Attest wheel and source archive provenance
136+
if: steps.verify-tag.outputs.release_state != 'published'
137+
uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
138+
with:
139+
subject-checksums: dist/SHA256SUMS
132140
- name: Generate and validate public release notes
133141
env:
134142
GH_TOKEN: ${{ github.token }}
@@ -215,6 +223,30 @@ jobs:
215223
jq '[.[][]]' > "$RUNNER_TEMP/draft-assets.json"
216224
python scripts/validate_release_assets.py \
217225
--local-dir dist --inventory "$RUNNER_TEMP/draft-assets.json"
226+
- name: Verify distribution provenance
227+
env:
228+
GH_TOKEN: ${{ github.token }}
229+
run: |
230+
signer_workflow="github.com/$GITHUB_REPOSITORY/.github/workflows/release-assets.yml"
231+
for artifact in dist/*.whl dist/*.tar.gz; do
232+
verified=false
233+
for attempt in {1..20}; do
234+
if gh attestation verify "$artifact" \
235+
--repo "$GITHUB_REPOSITORY" \
236+
--signer-workflow "$signer_workflow" \
237+
--signer-digest "$GITHUB_SHA" \
238+
--source-ref "$GITHUB_REF" \
239+
--source-digest "$GITHUB_SHA" >/dev/null 2>&1; then
240+
verified=true
241+
break
242+
fi
243+
sleep 3
244+
done
245+
if [ "$verified" != "true" ]; then
246+
echo "distribution provenance verification failed: ${artifact##*/}" >&2
247+
exit 1
248+
fi
249+
done
218250
- name: Publish complete draft release
219251
if: steps.verify-tag.outputs.release_state != 'published'
220252
env:

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ project uses Semantic Versioning.
2121
- Require an exact release-tag and package-version match before building release assets.
2222
- Publish a sorted `SHA256SUMS` file with the wheel and source archive, and refuse stale
2323
output directories or release-asset overwrites.
24+
- Record Sigstore-signed GitHub build-provenance attestations for the checksummed wheel
25+
and source archive.
2426
- Scope checksum instructions to releases produced after the new workflow takes effect.
27+
- Scope provenance instructions to releases produced after attestation takes effect.
2528

2629
### Fixed
2730

@@ -53,6 +56,8 @@ project uses Semantic Versioning.
5356
- Validate tag annotations and generated release notes as public metadata.
5457
- Require exact draft and published remote asset names, SHA-256 digests, and bytes against
5558
a protected-tag rebuild.
59+
- Verify each distribution's exact provenance identity before irreversible publication
60+
and on published-release reruns.
5661
- Remove the build tool's hidden output helper before exact release-asset validation.
5762
- Remove generated contributor credits and reject every private-workflow, local-path,
5863
attribution, personal-account, positioning, and prohibited-punctuation class before

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,20 @@ files, verify the distributions before installation:
3939
shasum -a 256 -c SHA256SUMS
4040
```
4141

42+
Starting with the first release produced after the provenance workflow change, GitHub
43+
also records signed build provenance for both distributions. Release v0.2.3 predates
44+
that workflow and has no distribution attestations. For a later release, substitute its
45+
actual version and verify a downloaded wheel against this public repository:
46+
47+
```console
48+
gh attestation verify splitseal-X.Y.Z-py3-none-any.whl \
49+
--repo tovellan/splitseal \
50+
--signer-workflow github.com/tovellan/splitseal/.github/workflows/release-assets.yml \
51+
--signer-digest TAG_COMMIT_SHA \
52+
--source-ref refs/tags/vX.Y.Z \
53+
--source-digest TAG_COMMIT_SHA
54+
```
55+
4256
For Parquet input, add the optional dependency after cloning:
4357

4458
```console

SECURITY.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@ The release workflow accepts an existing version tag, requires it to match the p
3131
version exactly, and builds into an empty directory. It produces a sorted `SHA256SUMS`
3232
alongside the wheel and source archive, then attaches every asset while the release is
3333
still a draft before publication. Checksums establish download integrity against the
34-
GitHub release; they are not a publisher signature or an independent transparency log.
34+
GitHub release. GitHub records Sigstore-signed build-provenance attestations for the
35+
checksummed wheel and source archive. Each attestation binds the artifact digest to the
36+
repository's release workflow identity. It does not replace source review, establish
37+
dataset quality, or act as an independent transparency log. Before publication and on a
38+
published-release rerun, the workflow verifies each distribution against the exact signer
39+
workflow, signer commit, protected tag ref, and source commit.
3540
Closure requires the GitHub Releases API to report `immutable: true` and GitHub's
3641
automatic release attestation to verify. The GitHub Releases API reports
3742
`immutable: false` for release v0.2.3.

docs/release-process.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,20 @@ immutability and the no-bypass `v*` update and deletion rules must remain enable
1313
6. Dispatch the `Release assets` workflow from the exact protected `vX.Y.Z` tag ref and
1414
supply that same tag as its input. Never select a branch or another tag as the workflow
1515
revision. Do not create the GitHub release or attach assets manually: the workflow
16-
exclusively builds the wheel, source archive, and `SHA256SUMS`, attaches all three,
17-
and publishes the complete draft before verifying immutability and the automatic
18-
release attestation. Before draft creation, generated notes remove contributor credits
19-
and validate the complete public-text policy without printing the removed account
20-
metadata.
16+
exclusively builds the wheel, source archive, and `SHA256SUMS`, records and verifies
17+
exact distribution provenance, attaches all three, and publishes the complete draft
18+
before verifying immutability and the automatic release attestation. Before draft
19+
creation, generated notes remove contributor credits and validate the complete
20+
public-text policy without printing the removed account metadata.
2121

2222
The workflow is safe to rerun after a partial draft upload: it resumes the existing draft,
2323
keeps byte-identical assets, replaces only incomplete uploads, and refuses conflicting
2424
or unexpected assets. After publication it rebuilds from the protected tag, requires exact
2525
remote asset names and SHA-256 digests plus byte equality, skips upload and publication,
26-
and repeats immutable-release and attestation verification. These recovery paths require
27-
the tag target to remain in protected `main` history, although it need not remain the branch
28-
tip. The workflow does not publish to PyPI, another package registry, or a container
29-
registry.
26+
and repeats exact distribution-provenance, immutable-release, and automatic-attestation
27+
verification. These recovery paths require the tag target to remain in protected `main`
28+
history, although it need not remain the branch tip. The workflow does not publish to
29+
PyPI, another package registry, or a container registry.
3030

3131
`make release-gate` performs tests, formatting checks, lint, static typing, package build,
3232
wheel installation, example execution, dependency audit, text policy checks, tracked-file

tests/test_release_workflow.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,18 @@
99
WORKFLOW_PATH = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "release-assets.yml"
1010

1111

12-
def _release_steps() -> list[Mapping[str, Any]]:
12+
def _release_job() -> Mapping[str, Any]:
1313
document = yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8"))
1414
assert isinstance(document, Mapping)
1515
jobs = document.get("jobs")
1616
assert isinstance(jobs, Mapping)
1717
build = jobs.get("build")
1818
assert isinstance(build, Mapping)
19-
steps = build.get("steps")
19+
return build
20+
21+
22+
def _release_steps() -> list[Mapping[str, Any]]:
23+
steps = _release_job().get("steps")
2024
assert isinstance(steps, list)
2125
assert all(isinstance(step, Mapping) for step in steps)
2226
return steps
@@ -144,3 +148,41 @@ def test_release_closure_is_retryable_after_publication() -> None:
144148
assert "2>/dev/null" in verify
145149
assert 'gh release verify "$RELEASE_TAG" --format json' in verify
146150
assert "sleep 15" in verify
151+
152+
153+
def test_distribution_provenance_precedes_irreversible_publication() -> None:
154+
assert _release_job().get("permissions") == {
155+
"artifact-metadata": "write",
156+
"attestations": "write",
157+
"contents": "write",
158+
"id-token": "write",
159+
}
160+
161+
attest = _step("Attest wheel and source archive provenance")
162+
assert attest.get("if") == "steps.verify-tag.outputs.release_state != 'published'"
163+
assert attest.get("uses") == ("actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d")
164+
settings = attest.get("with")
165+
assert isinstance(settings, Mapping)
166+
assert settings.get("subject-checksums") == "dist/SHA256SUMS"
167+
168+
verify_step = _step("Verify distribution provenance")
169+
assert "if" not in verify_step
170+
verify = _run("Verify distribution provenance")
171+
assert "dist/*.whl dist/*.tar.gz" in verify
172+
assert "for attempt in {1..20}" in verify
173+
assert '--repo "$GITHUB_REPOSITORY"' in verify
174+
assert '--signer-workflow "$signer_workflow"' in verify
175+
assert '--signer-digest "$GITHUB_SHA"' in verify
176+
assert '--source-ref "$GITHUB_REF"' in verify
177+
assert '--source-digest "$GITHUB_SHA"' in verify
178+
179+
assert _step_index("Build tag-matched distributions and checksums") < _step_index(
180+
"Attest wheel and source archive provenance"
181+
)
182+
assert _step_index("Attest wheel and source archive provenance") < _step_index(
183+
"Attach exact draft assets"
184+
)
185+
assert _step_index("Attach exact draft assets") < _step_index("Verify distribution provenance")
186+
assert _step_index("Verify distribution provenance") < _step_index(
187+
"Publish complete draft release"
188+
)

0 commit comments

Comments
 (0)