From c4fa25f39dc1221a4f3ab57d6bd85bac4376c2ae Mon Sep 17 00:00:00 2001 From: Scott Converse Date: Mon, 4 May 2026 17:02:34 -0600 Subject: [PATCH] feat: wire sigstore release workflow --- .github/workflows/release-preflight.yml | 31 +++++- .github/workflows/release.yml | 106 +++++++++++++++---- CHANGELOG.md | 3 + civiccore/__init__.py | 2 + civiccore/release_provenance.py | 43 ++++++++ docs/ops/sigstore-release-workflow-draft.yml | 4 +- scripts/build-release-attestation.py | 38 +++++++ tests/test_release_provenance.py | 34 ++++++ 8 files changed, 233 insertions(+), 28 deletions(-) create mode 100644 scripts/build-release-attestation.py diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml index ffeedad..8b49208 100644 --- a/.github/workflows/release-preflight.yml +++ b/.github/workflows/release-preflight.yml @@ -15,6 +15,18 @@ on: description: Expected target tree SHA from the verified release build required: false type: string + attestation: + description: Optional path to a prepared release-attestation.json in the checked-out tree + required: false + type: string + bundle: + description: Optional path to a prepared release-attestation.json.bundle in the checked-out tree + required: false + type: string + artifacts_dir: + description: Optional path to release artifacts named by the attestation + required: false + type: string fixtures_only: description: Run only the adversarial fixture suite as a hypothetical corrected-release dry-run required: false @@ -46,6 +58,9 @@ jobs: run: bash scripts/verify-release.sh - name: Verify adversarial release provenance fixtures run: python scripts/verify-release-provenance.py --fixtures-dir tests/fixtures/release_provenance + - name: Install cosign + if: ${{ inputs.fixtures_only != 'true' }} + uses: sigstore/cosign-installer@v4.1.1 - name: Verify prospective release tag provenance if: ${{ inputs.fixtures_only != 'true' }} env: @@ -53,8 +68,14 @@ jobs: RELEASE_TAG: ${{ inputs.tag }} EXPECTED_TARGET: ${{ inputs.expected_target }} EXPECTED_TREE: ${{ inputs.expected_tree }} - run: > - python scripts/verify-release-provenance.py "${RELEASE_TAG}" - --repo "${{ github.repository }}" - --expected-target "${EXPECTED_TARGET}" - --expected-tree "${EXPECTED_TREE}" + ATTESTATION: ${{ inputs.attestation }} + BUNDLE: ${{ inputs.bundle }} + ARTIFACTS_DIR: ${{ inputs.artifacts_dir }} + run: | + cmd=(python scripts/verify-release-provenance.py "${RELEASE_TAG}" --repo "${{ github.repository }}") + if [ -n "${ATTESTATION}" ]; then cmd+=(--attestation "${ATTESTATION}"); fi + if [ -n "${BUNDLE}" ]; then cmd+=(--bundle "${BUNDLE}"); fi + if [ -n "${ARTIFACTS_DIR}" ]; then cmd+=(--artifacts-dir "${ARTIFACTS_DIR}"); fi + if [ -n "${EXPECTED_TARGET}" ]; then cmd+=(--expected-target "${EXPECTED_TARGET}"); fi + if [ -n "${EXPECTED_TREE}" ]; then cmd+=(--expected-tree "${EXPECTED_TREE}"); fi + "${cmd[@]}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f37912d..6945490 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,10 +7,11 @@ on: permissions: contents: write + id-token: write jobs: - verify: - name: Verify release candidate + preflight: + name: Preflight release fixtures runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -28,28 +29,17 @@ jobs: python -m pip install --upgrade pip python -m pip install -e .[dev] - - name: Run release verification - run: bash scripts/verify-release.sh - - name: Verify adversarial release provenance fixtures run: python scripts/verify-release-provenance.py --fixtures-dir tests/fixtures/release_provenance - - name: Verify release tag provenance before publication - env: - GH_TOKEN: ${{ github.token }} - run: | - expected_tree="$(git show -s --format=%T "${{ github.sha }}")" - python scripts/verify-release-provenance.py "${{ github.ref_name }}" \ - --repo "${{ github.repository }}" \ - --expected-target "${{ github.sha }}" \ - --expected-tree "$expected_tree" - build: - name: Build wheel, sdist, and checksums + name: Build, attest, and verify release candidate runs-on: ubuntu-latest - needs: verify + needs: preflight steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Set up Python 3.13 uses: actions/setup-python@v6 @@ -57,17 +47,79 @@ jobs: python-version: "3.13" cache: pip + - name: Install dev dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e .[dev] + + - name: Run release verification + run: bash scripts/verify-release.sh + - name: Build release artifacts run: | - python -m pip install --upgrade pip build + rm -rf dist build python -m build (cd dist && sha256sum * > SHA256SUMS.txt) + - name: Resolve release ref + id: release_ref + env: + GH_TOKEN: ${{ github.token }} + run: | + ref_json="$(gh api "repos/${{ github.repository }}/git/ref/tags/${{ github.ref_name }}")" + tag_ref_type="$(echo "$ref_json" | python -c "import json,sys; print(json.load(sys.stdin)['object']['type'])")" + tag_ref_sha="$(echo "$ref_json" | python -c "import json,sys; print(json.load(sys.stdin)['object']['sha'])")" + if [ "$tag_ref_type" = "tag" ]; then + tag_json="$(gh api "repos/${{ github.repository }}/git/tags/${tag_ref_sha}")" + target_commit="$(echo "$tag_json" | python -c "import json,sys; print(json.load(sys.stdin)['object']['sha'])")" + else + target_commit="$tag_ref_sha" + fi + target_tree="$(git show -s --format=%T "$target_commit")" + echo "tag_ref_type=$tag_ref_type" >> "$GITHUB_OUTPUT" + echo "tag_ref_sha=$tag_ref_sha" >> "$GITHUB_OUTPUT" + echo "target_commit=$target_commit" >> "$GITHUB_OUTPUT" + echo "target_tree=$target_tree" >> "$GITHUB_OUTPUT" + + - name: Build release attestation + run: | + python scripts/build-release-attestation.py \ + --repo "${{ github.repository }}" \ + --tag "${{ github.ref_name }}" \ + --tag-ref-type "${{ steps.release_ref.outputs.tag_ref_type }}" \ + --tag-ref-sha "${{ steps.release_ref.outputs.tag_ref_sha }}" \ + --target-commit "${{ steps.release_ref.outputs.target_commit }}" \ + --target-tree "${{ steps.release_ref.outputs.target_tree }}" \ + --workflow-run-id "${{ github.run_id }}" \ + --artifacts-dir dist \ + --output release-attestation.json + + - name: Install cosign + uses: sigstore/cosign-installer@v4.1.1 + + - name: Sign release attestation + run: cosign sign-blob release-attestation.json --bundle release-attestation.json.bundle --yes + + - name: Verify release attestation before publication + env: + GH_TOKEN: ${{ github.token }} + run: | + python scripts/verify-release-provenance.py "${{ github.ref_name }}" \ + --repo "${{ github.repository }}" \ + --attestation release-attestation.json \ + --bundle release-attestation.json.bundle \ + --artifacts-dir dist \ + --expected-target "${{ steps.release_ref.outputs.target_commit }}" \ + --expected-tree "${{ steps.release_ref.outputs.target_tree }}" + - name: Upload build artifacts uses: actions/upload-artifact@v7 with: name: civiccore-dist - path: dist/* + path: | + dist/* + release-attestation.json + release-attestation.json.bundle release: name: Publish GitHub release @@ -87,4 +139,18 @@ jobs: gh release create "${{ github.ref_name }}" dist/* \ --repo "$GH_REPO" \ --title "civiccore ${{ github.ref_name }}" \ - --generate-notes + --notes "CivicCore ${{ github.ref_name }} release. + +Release provenance: +- The Git tag is a release pointer; the trust artifact is release-attestation.json plus release-attestation.json.bundle. +- Pre-flight provenance fixtures and live attestation verification ran before publication in workflow run ${{ github.run_id }}. +- Verify with: + +\`\`\`bash +cosign verify-blob release-attestation.json \\ + --bundle release-attestation.json.bundle \\ + --certificate-identity \"https://github.com/${{ github.repository }}/.github/workflows/release.yml@refs/tags/${{ github.ref_name }}\" \\ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + +sha256sum -c SHA256SUMS.txt +\`\`\`" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b753cb..bb8082c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ MINOR; bug fixes ship as PATCH. and the bootstrap trust problem. - Draft historical provenance disclosure records the boundary between GitHub-native historical releases and future Sigstore-attested releases. +- Release workflow now installs cosign, generates and signs + `release-attestation.json`, verifies the attestation before publication, and + uploads the attestation plus bundle alongside wheel, sdist, and checksums. ## [0.22.0] - 2026-05-03 diff --git a/civiccore/__init__.py b/civiccore/__init__.py index c703eec..b7421d5 100644 --- a/civiccore/__init__.py +++ b/civiccore/__init__.py @@ -148,6 +148,7 @@ FixtureProvenanceClient, GitHubProvenanceClient, ProvenanceError, + build_release_attestation, canonical_json_bytes, expected_workflow_identity, load_attestation, @@ -270,6 +271,7 @@ "ProvenanceError", "ATTESTATION_SCHEMA_VERSION", "canonical_json_bytes", + "build_release_attestation", "expected_workflow_identity", "load_attestation", "run_fixtures", diff --git a/civiccore/release_provenance.py b/civiccore/release_provenance.py index 944a7bd..ebbaee4 100644 --- a/civiccore/release_provenance.py +++ b/civiccore/release_provenance.py @@ -473,6 +473,49 @@ def load_attestation(path: Path) -> dict[str, Any]: return json.load(handle) +def build_release_attestation( + *, + repo: str, + tag_name: str, + tag_ref_type: str, + tag_ref_sha: str, + target_commit: str, + target_tree: str, + workflow_run_id: str, + artifacts_dir: Path, + evidence_bundles: list[dict[str, str]] | None = None, + workflow_path: str = ".github/workflows/release.yml", +) -> dict[str, Any]: + """Build a version 1 release attestation from local release artifacts.""" + + artifacts = [] + for path in sorted(artifacts_dir.iterdir()): + if not path.is_file() or path.name.endswith(".bundle"): + continue + artifacts.append({"name": path.name, "sha256": _sha256_file(path)}) + if not artifacts: + raise ProvenanceError(f"No release artifacts found in {artifacts_dir}.") + return { + "schema_version": ATTESTATION_SCHEMA_VERSION, + "subject": { + "repo": repo, + "tag": tag_name, + "tag_ref_type": tag_ref_type, + "tag_ref_sha": tag_ref_sha, + "target_commit": target_commit, + "target_tree": target_tree, + }, + "build": { + "workflow_identity": expected_workflow_identity(repo, tag_name, "release.yml"), + "workflow_path": workflow_path, + "workflow_run_id": workflow_run_id, + "oidc_issuer": GITHUB_ACTIONS_ISSUER, + }, + "artifacts": artifacts, + "evidence_bundles": evidence_bundles or [], + } + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("tag_name", nargs="?", help="Release tag to verify, for example v0.22.0.") diff --git a/docs/ops/sigstore-release-workflow-draft.yml b/docs/ops/sigstore-release-workflow-draft.yml index bb3f1ba..a4c5422 100644 --- a/docs/ops/sigstore-release-workflow-draft.yml +++ b/docs/ops/sigstore-release-workflow-draft.yml @@ -38,8 +38,7 @@ jobs: run: | python -m pip install --upgrade pip build python -m pip install -e .[dev] - # Authorized implementation will pin the cosign installer/action version. - echo "Install cosign here before first authorized release-class use." + echo "Install cosign with sigstore/cosign-installer@v4.1.1." - name: Build artifacts run: | @@ -58,4 +57,3 @@ jobs: run: | python scripts/verify-release-provenance.py --fixtures-dir tests/fixtures/release_provenance echo "python scripts/verify-release-provenance.py ${{ inputs.tag }} --attestation release-attestation.json --bundle release-attestation.json.bundle --artifacts-dir dist" - diff --git a/scripts/build-release-attestation.py b/scripts/build-release-attestation.py new file mode 100644 index 0000000..c5127a0 --- /dev/null +++ b/scripts/build-release-attestation.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from civiccore.release_provenance import build_release_attestation, canonical_json_bytes + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--tag-ref-type", required=True, choices=["commit", "tag"]) + parser.add_argument("--tag-ref-sha", required=True) + parser.add_argument("--target-commit", required=True) + parser.add_argument("--target-tree", required=True) + parser.add_argument("--workflow-run-id", required=True) + parser.add_argument("--artifacts-dir", type=Path, required=True) + parser.add_argument("--output", type=Path, default=Path("release-attestation.json")) + args = parser.parse_args() + + attestation = build_release_attestation( + repo=args.repo, + tag_name=args.tag, + tag_ref_type=args.tag_ref_type, + tag_ref_sha=args.tag_ref_sha, + target_commit=args.target_commit, + target_tree=args.target_tree, + workflow_run_id=args.workflow_run_id, + artifacts_dir=args.artifacts_dir, + ) + args.output.write_bytes(canonical_json_bytes(attestation)) + print(f"Wrote {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_provenance.py b/tests/test_release_provenance.py index 8991c82..d0c5040 100644 --- a/tests/test_release_provenance.py +++ b/tests/test_release_provenance.py @@ -4,6 +4,8 @@ import sys from pathlib import Path +from civiccore.release_provenance import build_release_attestation, canonical_json_bytes + REPO_ROOT = Path(__file__).resolve().parents[1] PROVENANCE_SCRIPT = REPO_ROOT / "scripts" / "verify-release-provenance.py" @@ -34,3 +36,35 @@ def test_adversarial_release_provenance_fixtures_are_enforced() -> None: assert "FIXTURE PASS: attestation target mismatch is rejected (fail)" in result.stdout assert "FIXTURE PASS: workflow rename identity drift is rejected (fail)" in result.stdout assert "FIXTURE PASS: trust-root rotation fails closed (fail)" in result.stdout + + +def test_release_attestation_builder_uses_exact_workflow_identity(tmp_path: Path) -> None: + artifact = tmp_path / "civiccore-0.22.1-py3-none-any.whl" + artifact.write_text("wheel bytes", encoding="utf-8") + + attestation = build_release_attestation( + repo="CivicSuite/civiccore", + tag_name="v0.22.1", + tag_ref_type="commit", + tag_ref_sha="a" * 40, + target_commit="a" * 40, + target_tree="b" * 40, + workflow_run_id="25346024240", + artifacts_dir=tmp_path, + ) + + assert attestation["schema_version"] == 1 + assert ( + attestation["build"]["workflow_identity"] + == "https://github.com/CivicSuite/civiccore/.github/workflows/release.yml@refs/tags/v0.22.1" + ) + assert attestation["build"]["oidc_issuer"] == "https://token.actions.githubusercontent.com" + assert attestation["artifacts"] == [ + { + "name": "civiccore-0.22.1-py3-none-any.whl", + "sha256": "67c0d8f7de19e30c2d5891030a0b37cbfcdd240852b53055c0b28290ad52290b", + } + ] + assert canonical_json_bytes(attestation).decode("utf-8").startswith( + '{"artifacts":[{"name":"civiccore-0.22.1-py3-none-any.whl"' + )