Skip to content
Merged
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
31 changes: 26 additions & 5 deletions .github/workflows/release-preflight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -46,15 +58,24 @@ 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:
GH_TOKEN: ${{ github.token }}
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[@]}"
106 changes: 86 additions & 20 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,46 +29,97 @@ 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
with:
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
Expand All @@ -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
\`\`\`"
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions civiccore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@
FixtureProvenanceClient,
GitHubProvenanceClient,
ProvenanceError,
build_release_attestation,
canonical_json_bytes,
expected_workflow_identity,
load_attestation,
Expand Down Expand Up @@ -270,6 +271,7 @@
"ProvenanceError",
"ATTESTATION_SCHEMA_VERSION",
"canonical_json_bytes",
"build_release_attestation",
"expected_workflow_identity",
"load_attestation",
"run_fixtures",
Expand Down
43 changes: 43 additions & 0 deletions civiccore/release_provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
4 changes: 1 addition & 3 deletions docs/ops/sigstore-release-workflow-draft.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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"

38 changes: 38 additions & 0 deletions scripts/build-release-attestation.py
Original file line number Diff line number Diff line change
@@ -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())
34 changes: 34 additions & 0 deletions tests/test_release_provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"'
)