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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,24 @@ on:
permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
quality:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
Expand All @@ -36,9 +43,12 @@ jobs:
package:
name: Package and example
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,23 @@ on:
permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
analyze:
name: Analyze Python
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
security-events: write
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/gitleaks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,21 @@ on:
permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
scan:
name: Full-history Gitleaks scan
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out complete history
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Download checksum-pinned Gitleaks
env:
GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
Expand Down
282 changes: 273 additions & 9 deletions .github/workflows/release-assets.yml
Original file line number Diff line number Diff line change
@@ -1,33 +1,297 @@
name: Release assets

on:
release:
types: [published]
workflow_dispatch:
inputs:
release_tag:
description: Existing annotated or signed v-prefixed release tag
required: true
type: string

permissions:
contents: read

concurrency:
group: release-assets-${{ inputs.release_tag }}
cancel-in-progress: false

jobs:
build:
name: Build and attach Python distributions
name: Build, publish, and verify release
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
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "release tag must be an exact v-prefixed semantic version" >&2
exit 1
fi
object_type="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq '.object.type')"
object_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq '.object.sha')"
if [ "$object_type" != "tag" ]; then
echo "release tag must be an annotated or signed tag object" >&2
exit 1
fi
for attempt in 1 2 3 4 5; do
if [ "$object_type" = "commit" ]; then
break
fi
if [ "$object_type" != "tag" ]; then
echo "release tag does not resolve to a commit" >&2
exit 1
fi
tag_object="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$object_sha")"
tagger_name="$(jq -r '.tagger.name // ""' <<<"$tag_object")"
tagger_email="$(jq -r '.tagger.email // ""' <<<"$tag_object")"
if [ "$tagger_name" != "Tovellan Maintainers" ] || [ "$tagger_email" != "noreply@github.com" ]; then
echo "release tag must use the generic maintainer identity" >&2
exit 1
fi
tag_message="$(jq -r '.message // ""' <<<"$tag_object")"
case "$tag_message" in
"SplitSeal $RELEASE_TAG"|"SplitSeal $RELEASE_TAG"$'\n-----BEGIN PGP SIGNATURE-----'*) ;;
*) echo "release tag must use the exact public annotation message" >&2; exit 1 ;;
esac
object_type="$(jq -r '.object.type' <<<"$tag_object")"
object_sha="$(jq -r '.object.sha' <<<"$tag_object")"
done
if [ "$object_type" != "commit" ]; then
echo "release tag indirection is too deep" >&2
exit 1
fi
if [ "$GITHUB_REF" != "refs/tags/$RELEASE_TAG" ] || [ "$GITHUB_SHA" != "$object_sha" ]; then
echo "workflow must be dispatched from the exact protected release tag" >&2
exit 1
fi
release_record="$(
gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/releases?per_page=100" |
jq -c --arg tag "$RELEASE_TAG" \
'[.[][] | select(.tag_name == $tag)] | if length > 1 then error("duplicate release tag") elif length == 1 then .[0] else null end'
)"
if [ "$release_record" = "null" ]; then
release_state="absent"
release_id=""
elif [ "$(jq -r '.draft' <<<"$release_record")" = "true" ]; then
release_state="draft"
release_id="$(jq -r '.id' <<<"$release_record")"
else
release_state="published"
release_id="$(jq -r '.id' <<<"$release_record")"
fi
main_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')"
if [ "$release_state" = "absent" ]; then
if [ "$object_sha" != "$main_sha" ]; then
echo "new release tag must target the current protected main commit" >&2
exit 1
fi
else
comparison_status="$(
gh api "repos/$GITHUB_REPOSITORY/compare/$object_sha...$main_sha" --jq '.status'
)"
if [ "$comparison_status" != "identical" ] && [ "$comparison_status" != "ahead" ]; then
echo "existing release tag must remain in protected main history" >&2
exit 1
fi
fi
printf 'target_sha=%s\n' "$object_sha" >> "$GITHUB_OUTPUT"
printf 'release_state=%s\n' "$release_state" >> "$GITHUB_OUTPUT"
printf 'release_id=%s\n' "$release_id" >> "$GITHUB_OUTPUT"
- name: Check out release tag
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.release.tag_name }}
persist-credentials: false
ref: ${{ steps.verify-tag.outputs.target_sha }}
- name: Install Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Verify tag matches package version
env:
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
package_version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
if [ "$RELEASE_TAG" != "v$package_version" ]; then
echo "release tag does not match package version" >&2
exit 1
fi
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
- name: Build distributions
run: uv build
- name: Attach distributions to the GitHub release
with:
version: "0.12.5"
- name: Build tag-matched distributions and checksums
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 }}
RELEASE_TAG: ${{ inputs.release_tag }}
RELEASE_ID: ${{ steps.verify-tag.outputs.release_id }}
RELEASE_STATE: ${{ steps.verify-tag.outputs.release_state }}
TARGET_SHA: ${{ steps.verify-tag.outputs.target_sha }}
run: |
if [ "$RELEASE_STATE" = "published" ]; then
gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
> "$RUNNER_TEMP/release-notes.json"
else
gh api --method POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" \
-f tag_name="$RELEASE_TAG" -f target_commitish="$TARGET_SHA" \
> "$RUNNER_TEMP/generated-release-notes.json"
python scripts/validate_release_metadata.py \
--tag "$RELEASE_TAG" \
--input "$RUNNER_TEMP/generated-release-notes.json" \
--output "$RUNNER_TEMP/release-notes.json" \
--sanitize-generated
fi
if [ "$RELEASE_STATE" = "published" ]; then
python scripts/validate_release_metadata.py \
--tag "$RELEASE_TAG" --input "$RUNNER_TEMP/release-notes.json"
fi
- name: Create or resume draft release
id: release
if: steps.verify-tag.outputs.release_state != 'published'
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
RELEASE_ID: ${{ steps.verify-tag.outputs.release_id }}
RELEASE_STATE: ${{ steps.verify-tag.outputs.release_state }}
run: |
if [ "$RELEASE_STATE" = "absent" ]; then
release_notes="$(jq -r '.body' "$RUNNER_TEMP/release-notes.json")"
release_record="$(
gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \
-f tag_name="$RELEASE_TAG" -f name="SplitSeal $RELEASE_TAG" \
-f body="$release_notes" -F draft=true
)"
RELEASE_ID="$(jq -r '.id' <<<"$release_record")"
else
gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
> "$RUNNER_TEMP/existing-draft.json"
python scripts/validate_release_metadata.py \
--tag "$RELEASE_TAG" --input "$RUNNER_TEMP/existing-draft.json"
fi
printf 'release_id=%s\n' "$RELEASE_ID" >> "$GITHUB_OUTPUT"
- name: Attach exact draft assets
if: steps.verify-tag.outputs.release_state != 'published'
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
RELEASE_ID: ${{ steps.release.outputs.release_id }}
run: |
for artifact in dist/*; do
asset_name="${artifact##*/}"
asset_record="$(
gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?per_page=100" |
jq -c --arg name "$asset_name" \
'[.[][] | select(.name == $name)] | if length > 1 then error("duplicate release asset") elif length == 1 then .[0] else null end'
)"
if [ "$asset_record" = "null" ]; then
gh release upload "$RELEASE_TAG" "$artifact"
continue
fi
asset_id="$(jq -r '.id' <<<"$asset_record")"
if [ "$(jq -r '.state' <<<"$asset_record")" != "uploaded" ]; then
gh api --method DELETE "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id"
gh release upload "$RELEASE_TAG" "$artifact"
continue
fi
downloaded="$RUNNER_TEMP/existing-$asset_name"
gh api -H 'Accept: application/octet-stream' \
"repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" > "$downloaded"
if ! cmp -s "$artifact" "$downloaded"; then
echo "existing draft asset does not match the verified build: $asset_name" >&2
exit 1
fi
done
gh api --paginate --slurp \
"repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?per_page=100" |
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:
GH_TOKEN: ${{ github.token }}
RELEASE_ID: ${{ steps.release.outputs.release_id }}
run: |
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
-F draft=false >/dev/null
- name: Verify exact published assets
env:
GH_TOKEN: ${{ github.token }}
RELEASE_ID: ${{ steps.release.outputs.release_id || steps.verify-tag.outputs.release_id }}
run: |
remote_assets="$(
gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?per_page=100" |
jq -c '[.[][]]'
)"
printf '%s\n' "$remote_assets" > "$RUNNER_TEMP/published-assets.json"
python scripts/validate_release_assets.py \
--local-dir dist --inventory "$RUNNER_TEMP/published-assets.json"
for artifact in dist/*; do
asset_name="${artifact##*/}"
asset_id="$(jq -r --arg name "$asset_name" '.[] | select(.name == $name) | .id' <<<"$remote_assets")"
downloaded="$RUNNER_TEMP/published-$asset_name"
gh api -H 'Accept: application/octet-stream' \
"repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" > "$downloaded"
if ! cmp -s "$artifact" "$downloaded"; then
echo "published release asset does not match the verified build: $asset_name" >&2
exit 1
fi
done
- name: Verify immutable release and automatic attestation
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: gh release upload "$RELEASE_TAG" dist/* --clobber
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
for attempt in {1..40}; do
state=""
if state="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" --jq '.immutable' 2>/dev/null)" && \
[ "$state" = "true" ] && gh release verify "$RELEASE_TAG" --format json >/dev/null; then
printf 'GitHub release immutable: true\n'
printf 'GitHub automatic release attestation: verified\n'
exit 0
fi
sleep 15
done
echo "GitHub automatic release attestation verification failed" >&2
exit 1
Loading