diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 71ac1fd..9445e04 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -21,7 +21,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 permissions: + artifact-metadata: write + attestations: write contents: write + id-token: write steps: - name: Verify release tag targets protected main id: verify-tag @@ -129,6 +132,11 @@ jobs: env: RELEASE_TAG: ${{ inputs.release_tag }} run: python scripts/release_assets.py --tag "$RELEASE_TAG" --output-dir dist + - name: Attest wheel and source archive provenance + if: steps.verify-tag.outputs.release_state != 'published' + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 + with: + subject-checksums: dist/SHA256SUMS - name: Generate and validate public release notes env: GH_TOKEN: ${{ github.token }} @@ -215,6 +223,30 @@ jobs: jq '[.[][]]' > "$RUNNER_TEMP/draft-assets.json" python scripts/validate_release_assets.py \ --local-dir dist --inventory "$RUNNER_TEMP/draft-assets.json" + - name: Verify distribution provenance + env: + GH_TOKEN: ${{ github.token }} + run: | + signer_workflow="github.com/$GITHUB_REPOSITORY/.github/workflows/release-assets.yml" + for artifact in dist/*.whl dist/*.tar.gz; do + verified=false + for attempt in {1..20}; do + if gh attestation verify "$artifact" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$signer_workflow" \ + --signer-digest "$GITHUB_SHA" \ + --source-ref "$GITHUB_REF" \ + --source-digest "$GITHUB_SHA" >/dev/null 2>&1; then + verified=true + break + fi + sleep 3 + done + if [ "$verified" != "true" ]; then + echo "distribution provenance verification failed: ${artifact##*/}" >&2 + exit 1 + fi + done - name: Publish complete draft release if: steps.verify-tag.outputs.release_state != 'published' env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac8cd3..8a9d7c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,10 @@ project uses Semantic Versioning. - Require an exact release-tag and package-version match before building release assets. - Publish a sorted `SHA256SUMS` file with the wheel and source archive, and refuse stale output directories or release-asset overwrites. +- Record Sigstore-signed GitHub build-provenance attestations for the checksummed wheel + and source archive. - Scope checksum instructions to releases produced after the new workflow takes effect. +- Scope provenance instructions to releases produced after attestation takes effect. ### Fixed @@ -30,6 +33,8 @@ project uses Semantic Versioning. - Validate tag annotations and generated release notes as public metadata. - Require exact draft and published remote asset names, SHA-256 digests, and bytes against a protected-tag rebuild. +- Verify each distribution's exact provenance identity before irreversible publication + and on published-release reruns. - Remove the build tool's hidden output helper before exact release-asset validation. - Remove generated contributor credits and reject every private-workflow, local-path, attribution, personal-account, positioning, and prohibited-punctuation class before diff --git a/README.md b/README.md index 538d452..d9af05e 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,20 @@ files, verify the distributions before installation: shasum -a 256 -c SHA256SUMS ``` +Starting with the first release produced after the provenance workflow change, GitHub +also records signed build provenance for both distributions. Release v0.2.3 predates +that workflow and has no distribution attestations. For a later release, substitute its +actual version and verify a downloaded wheel against this public repository: + +```console +gh attestation verify splitseal-X.Y.Z-py3-none-any.whl \ + --repo tovellan/splitseal \ + --signer-workflow github.com/tovellan/splitseal/.github/workflows/release-assets.yml \ + --signer-digest TAG_COMMIT_SHA \ + --source-ref refs/tags/vX.Y.Z \ + --source-digest TAG_COMMIT_SHA +``` + For Parquet input, add the optional dependency after cloning: ```console diff --git a/SECURITY.md b/SECURITY.md index 287b3ab..1d0b0a7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,7 +25,12 @@ The release workflow accepts an existing version tag, requires it to match the p version exactly, and builds into an empty directory. It produces a sorted `SHA256SUMS` alongside the wheel and source archive, then attaches every asset while the release is still a draft before publication. Checksums establish download integrity against the -GitHub release; they are not a publisher signature or an independent transparency log. +GitHub release. GitHub records Sigstore-signed build-provenance attestations for the +checksummed wheel and source archive. Each attestation binds the artifact digest to the +repository's release workflow identity. It does not replace source review, establish +dataset quality, or act as an independent transparency log. Before publication and on a +published-release rerun, the workflow verifies each distribution against the exact signer +workflow, signer commit, protected tag ref, and source commit. Closure requires the GitHub Releases API to report `immutable: true` and GitHub's automatic release attestation to verify. The GitHub Releases API reports `immutable: false` for release v0.2.3. diff --git a/docs/release-process.md b/docs/release-process.md index 7a9d0fa..0c4bab4 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -13,20 +13,20 @@ immutability and the no-bypass `v*` update and deletion rules must remain enable 6. Dispatch the `Release assets` workflow from the exact protected `vX.Y.Z` tag ref and supply that same tag as its input. Never select a branch or another tag as the workflow revision. Do not create the GitHub release or attach assets manually: the workflow - exclusively builds the wheel, source archive, and `SHA256SUMS`, attaches all three, - and publishes the complete draft before verifying immutability and the automatic - release attestation. Before draft creation, generated notes remove contributor credits - and validate the complete public-text policy without printing the removed account - metadata. + exclusively builds the wheel, source archive, and `SHA256SUMS`, records and verifies + exact distribution provenance, attaches all three, and publishes the complete draft + before verifying immutability and the automatic release attestation. Before draft + creation, generated notes remove contributor credits and validate the complete + public-text policy without printing the removed account metadata. The workflow is safe to rerun after a partial draft upload: it resumes the existing draft, keeps byte-identical assets, replaces only incomplete uploads, and refuses conflicting or unexpected assets. After publication it rebuilds from the protected tag, requires exact remote asset names and SHA-256 digests plus byte equality, skips upload and publication, -and repeats immutable-release and attestation verification. These recovery paths require -the tag target to remain in protected `main` history, although it need not remain the branch -tip. The workflow does not publish to PyPI, another package registry, or a container -registry. +and repeats exact distribution-provenance, immutable-release, and automatic-attestation +verification. These recovery paths require the tag target to remain in protected `main` +history, although it need not remain the branch tip. The workflow does not publish to +PyPI, another package registry, or a container registry. `make release-gate` performs tests, formatting checks, lint, static typing, package build, wheel installation, example execution, dependency audit, text policy checks, tracked-file diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index 1c82ae1..c2999c7 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -9,14 +9,18 @@ WORKFLOW_PATH = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "release-assets.yml" -def _release_steps() -> list[Mapping[str, Any]]: +def _release_job() -> Mapping[str, Any]: document = yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8")) assert isinstance(document, Mapping) jobs = document.get("jobs") assert isinstance(jobs, Mapping) build = jobs.get("build") assert isinstance(build, Mapping) - steps = build.get("steps") + return build + + +def _release_steps() -> list[Mapping[str, Any]]: + steps = _release_job().get("steps") assert isinstance(steps, list) assert all(isinstance(step, Mapping) for step in steps) return steps @@ -144,3 +148,41 @@ def test_release_closure_is_retryable_after_publication() -> None: assert "2>/dev/null" in verify assert 'gh release verify "$RELEASE_TAG" --format json' in verify assert "sleep 15" in verify + + +def test_distribution_provenance_precedes_irreversible_publication() -> None: + assert _release_job().get("permissions") == { + "artifact-metadata": "write", + "attestations": "write", + "contents": "write", + "id-token": "write", + } + + attest = _step("Attest wheel and source archive provenance") + assert attest.get("if") == "steps.verify-tag.outputs.release_state != 'published'" + assert attest.get("uses") == ("actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d") + settings = attest.get("with") + assert isinstance(settings, Mapping) + assert settings.get("subject-checksums") == "dist/SHA256SUMS" + + verify_step = _step("Verify distribution provenance") + assert "if" not in verify_step + verify = _run("Verify distribution provenance") + assert "dist/*.whl dist/*.tar.gz" in verify + assert "for attempt in {1..20}" in verify + assert '--repo "$GITHUB_REPOSITORY"' in verify + assert '--signer-workflow "$signer_workflow"' in verify + assert '--signer-digest "$GITHUB_SHA"' in verify + assert '--source-ref "$GITHUB_REF"' in verify + assert '--source-digest "$GITHUB_SHA"' in verify + + assert _step_index("Build tag-matched distributions and checksums") < _step_index( + "Attest wheel and source archive provenance" + ) + assert _step_index("Attest wheel and source archive provenance") < _step_index( + "Attach exact draft assets" + ) + assert _step_index("Attach exact draft assets") < _step_index("Verify distribution provenance") + assert _step_index("Verify distribution provenance") < _step_index( + "Publish complete draft release" + )