Skip to content
Closed
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
32 changes: 32 additions & 0 deletions .github/workflows/release-assets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 9 additions & 9 deletions docs/release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 44 additions & 2 deletions tests/test_release_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
)