v1.8.3 #22
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release Pipeline | |
| on: | |
| release: | |
| types: [published] | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Release version (e.g., 1.0.0)' | |
| required: true | |
| type: string | |
| permissions: | |
| contents: read | |
| packages: write | |
| pages: write | |
| concurrency: | |
| group: production-release-${{ github.event.release.tag_name || inputs.version }} | |
| cancel-in-progress: false | |
| jobs: | |
| config: | |
| name: Get Configuration | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| outputs: | |
| python-versions: ${{ steps.config.outputs.python-versions }} | |
| default-python-version: ${{ steps.config.outputs.default-python-version }} | |
| container-registry: ${{ steps.config.outputs.container-registry }} | |
| container-image: ${{ steps.config.outputs.container-image }} | |
| package-version: ${{ steps.config.outputs.package-version }} | |
| is-release: ${{ steps.config.outputs.is-release }} | |
| pypi-name: ${{ steps.config.outputs.pypi-name }} | |
| build-platforms: ${{ steps.config.outputs.build-platforms }} | |
| short-name: ${{ steps.config.outputs.short-name }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Get project configuration | |
| id: config | |
| uses: ./.github/actions/get-config | |
| # Build the wheel and UI bundle exactly once so prod-containers, prod-pypi, | |
| # and github-assets all publish the identical artefact. | |
| build-wheel: | |
| name: Build Release Wheel | |
| needs: config | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Setup Python and UV | |
| uses: ./.github/actions/setup-uv-cached | |
| with: | |
| cache-key: prod-wheel-${{ needs.config.outputs.package-version }} | |
| fail-on-cache-miss: false | |
| - name: Setup Bun (for UI static bundle) | |
| uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 | |
| with: | |
| bun-version: "1.2.15" # pinned; update intentionally to avoid surprise breakage | |
| # The SPA bundle is built automatically by the setuptools build hook during | |
| # `make build` (python -m build). No separate step needed. | |
| - name: Build package wheel | |
| run: make build | |
| - name: Upload wheel artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: release-wheel-${{ needs.config.outputs.package-version }} | |
| path: dist/ | |
| retention-days: 1 | |
| prod-containers: | |
| name: Build Production Containers | |
| needs: [config, build-wheel] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| # Only push/sign/attest images on a real GitHub Release. | |
| # Manual workflow_dispatch would build DEV-versioned images (1.x.y.devN) | |
| # and push+sign them to the prod GHCR registry, polluting it with | |
| # non-release artefacts. The release gate here mirrors prod-manifests, | |
| # prod-pypi, and github-assets. | |
| if: github.event_name == 'release' | |
| strategy: | |
| # Each python-version variant scans, builds, and signs independently. | |
| # fail-fast: false ensures that one variant's CVE gate failure does not | |
| # cancel the other variants — each arch is self-contained. | |
| fail-fast: false | |
| matrix: | |
| python-version: ${{ fromJSON(needs.config.outputs.python-versions) }} | |
| env: | |
| SCAN_LEVEL: ${{ matrix.python-version == needs.config.outputs.default-python-version && 'full' || 'basic' }} | |
| PUSH_TO_REGISTRY: true | |
| permissions: | |
| contents: read | |
| packages: write | |
| security-events: write | |
| # Required for keyless cosign signing (ambient OIDC token) and | |
| # actions/attest-build-provenance. | |
| id-token: write | |
| attestations: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 | |
| with: | |
| platforms: ${{ needs.config.outputs.build-platforms }} | |
| - name: Log in to Container Registry | |
| uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 | |
| with: | |
| registry: ${{ needs.config.outputs.container-registry }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Download release wheel | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| name: release-wheel-${{ needs.config.outputs.package-version }} | |
| path: dist/ | |
| - name: Build container image locally for scanning (full scan gate) | |
| # For the default-Python 'full' scan we build with --load so Trivy can | |
| # inspect the image locally BEFORE it is pushed. A CRITICAL/HIGH finding | |
| # will fail the job and prevent a vulnerable image from reaching the | |
| # registry. Non-default Python versions use a --push build directly | |
| # (scan is advisory / exit-code 0) because the default-Python image is | |
| # the canonical security gate for the release. | |
| if: env.SCAN_LEVEL == 'full' | |
| id: build-local | |
| env: | |
| REGISTRY: ${{ needs.config.outputs.container-registry }} | |
| IMAGE_NAME: ${{ needs.config.outputs.container-image }} | |
| VERSION: ${{ needs.config.outputs.package-version }} | |
| PYTHON_VERSION: ${{ matrix.python-version }} | |
| BUILD_PLATFORMS: ${{ needs.config.outputs.build-platforms }} | |
| PACKAGE_NAME_SHORT: ${{ needs.config.outputs.short-name }} | |
| run: | | |
| # Build for the first/primary platform only so --load works (multi-arch | |
| # cannot be loaded into the local Docker daemon). | |
| SCAN_PLATFORM=$(echo "$BUILD_PLATFORMS" | cut -d, -f1) | |
| docker buildx build \ | |
| --platform "$SCAN_PLATFORM" \ | |
| --build-arg "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \ | |
| --build-arg "VERSION=$VERSION" \ | |
| --build-arg "VCS_REF=$(git rev-parse --short HEAD)" \ | |
| --build-arg "PYTHON_VERSION=$PYTHON_VERSION" \ | |
| --build-arg "PACKAGE_NAME_SHORT=$PACKAGE_NAME_SHORT" \ | |
| --cache-from type=gha \ | |
| --load \ | |
| -t "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" \ | |
| . | |
| - name: Run Trivy security scan (gate — full, default Python) | |
| # exit-code: '1' blocks the push if CRITICAL or HIGH CVEs are found. | |
| # This is the security gate: the image is not pushed to GHCR until it | |
| # passes. SCAN_LEVEL == 'full' only on the default Python matrix entry. | |
| # trivyignores honours the suppression file for known unfixable base-image | |
| # CVEs (e.g. Debian CVEs in the Python slim images) so the gate only fires | |
| # on actionable findings. | |
| if: env.SCAN_LEVEL == 'full' | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| image-ref: "${{ needs.config.outputs.container-registry }}/${{ needs.config.outputs.container-image }}:${{ needs.config.outputs.package-version }}-python${{ matrix.python-version }}" | |
| format: 'sarif' | |
| output: 'trivy-results-py${{ matrix.python-version }}.sarif' | |
| exit-code: '1' | |
| severity: 'CRITICAL,HIGH' | |
| trivyignores: .trivyignore | |
| - name: Build and push container image | |
| id: build | |
| env: | |
| REGISTRY: ${{ needs.config.outputs.container-registry }} | |
| IMAGE_NAME: ${{ needs.config.outputs.container-image }} | |
| VERSION: ${{ needs.config.outputs.package-version }} | |
| PYTHON_VERSION: ${{ matrix.python-version }} | |
| BUILD_PLATFORMS: ${{ needs.config.outputs.build-platforms }} | |
| PACKAGE_NAME_SHORT: ${{ needs.config.outputs.short-name }} | |
| run: | | |
| docker buildx build \ | |
| --platform "$BUILD_PLATFORMS" \ | |
| --build-arg "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \ | |
| --build-arg "VERSION=$VERSION" \ | |
| --build-arg "VCS_REF=$(git rev-parse --short HEAD)" \ | |
| --build-arg "PYTHON_VERSION=$PYTHON_VERSION" \ | |
| --build-arg "PACKAGE_NAME_SHORT=$PACKAGE_NAME_SHORT" \ | |
| --provenance=true \ | |
| --sbom=true \ | |
| --metadata-file metadata.json \ | |
| --cache-from type=gha \ | |
| --cache-to type=gha,mode=max \ | |
| --push \ | |
| -t "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" \ | |
| . | |
| # Extract the immutable digest from the BuildKit metadata output. | |
| # containerimage.digest is the multi-arch index digest (sha256:…). | |
| DIGEST=$(jq -r '."containerimage.digest"' metadata.json) | |
| echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" | |
| echo "image=${REGISTRY}/${IMAGE_NAME}" >> "$GITHUB_OUTPUT" | |
| # Trivy cannot scan a multi-arch manifest — pull back a single-arch image | |
| # for the advisory scan (non-default Python). | |
| SCAN_PLATFORM=$(echo "$BUILD_PLATFORMS" | cut -d, -f1) | |
| docker pull --platform "$SCAN_PLATFORM" "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" | |
| - name: Install cosign | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| - name: Sign container image (keyless / OIDC) | |
| # Signs the immutable digest — not a mutable tag — using the GitHub OIDC | |
| # ambient credential. The Rekor transparency-log entry records the | |
| # workflow identity, giving consumers a verifiable supply-chain link. | |
| env: | |
| DIGEST: ${{ steps.build.outputs.digest }} | |
| IMAGE: ${{ steps.build.outputs.image }} | |
| run: cosign sign --yes "$IMAGE@$DIGEST" | |
| - name: Attach SLSA provenance attestation | |
| # Generates a SLSA Build L2-conformant provenance attestation signed with | |
| # the GitHub OIDC token and pushes it to the registry alongside the image. | |
| # Complements the cosign signature: signature proves who signed; provenance | |
| # records exactly what was built and how (workflow, inputs, commit SHA). | |
| uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 | |
| with: | |
| subject-name: ${{ steps.build.outputs.image }} | |
| subject-digest: ${{ steps.build.outputs.digest }} | |
| push-to-registry: true | |
| - name: Run Trivy security scan (advisory — non-default Python) | |
| # For non-default Python variants, the scan is advisory (exit-code 0). | |
| # The default-Python image already passed the gate above; these variants | |
| # share the same base image and package set, so findings here are | |
| # informational / uploaded to the Security tab. | |
| if: env.SCAN_LEVEL != 'full' | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| image-ref: "${{ needs.config.outputs.container-registry }}/${{ needs.config.outputs.container-image }}:${{ needs.config.outputs.package-version }}-python${{ matrix.python-version }}" | |
| format: 'sarif' | |
| output: 'trivy-results-py${{ matrix.python-version }}.sarif' | |
| exit-code: '0' | |
| severity: 'CRITICAL,HIGH' | |
| trivyignores: .trivyignore | |
| - name: Upload Trivy scan results | |
| uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 | |
| if: always() | |
| with: | |
| sarif_file: 'trivy-results-py${{ matrix.python-version }}.sarif' | |
| prod-manifests: | |
| name: Create Production Manifests | |
| needs: [config, prod-containers] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| # Only repoint `latest` / version manifests on a real GitHub Release. | |
| # Manual workflow_dispatch builds images for testing but must not move prod tags. | |
| if: github.event_name == 'release' | |
| permissions: | |
| contents: read | |
| packages: write | |
| # Required for keyless cosign signing of the manifest-list digests. | |
| id-token: write | |
| attestations: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Log in to Container Registry | |
| uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 | |
| with: | |
| registry: ${{ needs.config.outputs.container-registry }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Install cosign | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| - name: Create production tag manifests | |
| id: manifests | |
| shell: bash | |
| env: | |
| REGISTRY: ${{ needs.config.outputs.container-registry }} | |
| IMAGE: ${{ needs.config.outputs.container-image }} | |
| VERSION: ${{ needs.config.outputs.package-version }} | |
| DEFAULT_PYTHON: ${{ needs.config.outputs.default-python-version }} | |
| IS_RELEASE: "true" | |
| run: | | |
| # Parse Makefile container tag output into shell variables in the same step. | |
| # GITHUB_ENV writes are only visible to SUBSEQUENT steps, so we assign | |
| # directly into the current shell environment instead. | |
| # get-container-tags-env emits "export KEY=VALUE" lines; strip the prefix and | |
| # validate the KEY=VALUE format before assigning (rejects subcommands etc.). | |
| while IFS= read -r line; do | |
| # Strip leading "export " if present | |
| kv="${line#export }" | |
| # Validate KEY=VALUE format before assigning (reject lines with subcommands etc.) | |
| if [[ "$kv" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then | |
| declare "${BASH_REMATCH[1]}=${BASH_REMATCH[2]}" | |
| fi | |
| done < <(make get-container-tags-env) | |
| # Collect all created manifest-list digests so the signing step can sign them. | |
| MANIFEST_REFS="" | |
| # Create primary tags (latest, version) | |
| if [[ -n "${primary_tags:-}" ]]; then | |
| IFS=',' read -ra TAGS <<< "$primary_tags" | |
| for tag in "${TAGS[@]}"; do | |
| echo "Creating primary tag: $tag -> $VERSION-python$DEFAULT_PYTHON" | |
| docker buildx imagetools create \ | |
| -t "$REGISTRY/$IMAGE:$tag" \ | |
| "$REGISTRY/$IMAGE:$VERSION-python$DEFAULT_PYTHON" | |
| # Resolve the digest of the newly-created manifest list. | |
| DIGEST=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "$REGISTRY/$IMAGE:$tag" | jq -r '.digest') | |
| MANIFEST_REFS="${MANIFEST_REFS} $REGISTRY/$IMAGE@$DIGEST" | |
| done | |
| fi | |
| # Create Python variant tags | |
| if [[ -n "${python_tags:-}" ]]; then | |
| IFS=',' read -ra TAGS <<< "$python_tags" | |
| for tag in "${TAGS[@]}"; do | |
| py_ver=${tag#python} | |
| echo "Creating Python variant tag: $tag -> $VERSION-python$py_ver" | |
| docker buildx imagetools create \ | |
| -t "$REGISTRY/$IMAGE:$tag" \ | |
| "$REGISTRY/$IMAGE:$VERSION-python$py_ver" | |
| DIGEST=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "$REGISTRY/$IMAGE:$tag" | jq -r '.digest') | |
| MANIFEST_REFS="${MANIFEST_REFS} $REGISTRY/$IMAGE@$DIGEST" | |
| done | |
| fi | |
| # Deduplicate refs (multiple tags may resolve to the same digest). | |
| UNIQUE_REFS=$(echo "$MANIFEST_REFS" | tr ' ' '\n' | sort -u | tr '\n' ' ') | |
| echo "manifest-refs=${UNIQUE_REFS}" >> "$GITHUB_OUTPUT" | |
| - name: Sign manifest-list images (keyless / OIDC) | |
| # The per-python images were already signed in prod-containers; signing the | |
| # manifest lists (latest, version, pythonX.Y tags) closes the chain so | |
| # consumers of the convenience tags can also verify a signature. | |
| env: | |
| MANIFEST_REFS: ${{ steps.manifests.outputs.manifest-refs }} | |
| run: | | |
| for ref in $MANIFEST_REFS; do | |
| echo "Signing manifest list: $ref" | |
| cosign sign --yes "$ref" | |
| done | |
| - name: Attest SLSA provenance for manifest-list images | |
| # Attach SLSA Build L2 provenance to each unique manifest-list digest so | |
| # consumers of the convenience tags (latest, version, pythonX.Y) | |
| # can verify full supply-chain provenance — not just the cosign signature. | |
| # After deduplication the set is small (one digest per distinct Python | |
| # version + one for the default-Python "latest"/"version" tags if they | |
| # differ). The gh CLI's attestation subcommand is not suitable here; | |
| # actions/attest-build-provenance accepts one subject per call and pushes | |
| # the Sigstore OIDC attestation to the registry. We emit the refs into | |
| # step outputs so callers can fan them out via a follow-on action step. | |
| id: attest-manifests-prep | |
| env: | |
| MANIFEST_REFS: ${{ steps.manifests.outputs.manifest-refs }} | |
| run: | | |
| # Emit the first unique ref as "primary" (covers latest/version tags). | |
| # All unique digests are already in MANIFEST_REFS; pick the first one | |
| # for a single structured attest-build-provenance call. Additional | |
| # per-python variant digests are attested in the loop step that follows. | |
| FIRST=$(echo "$MANIFEST_REFS" | tr ' ' '\n' | grep -v '^$' | head -1) | |
| { | |
| echo "first-image=${FIRST%%@*}" | |
| echo "first-digest=${FIRST##*@}" | |
| # Persist the full list for the batch attest step. | |
| echo "all-refs=${MANIFEST_REFS}" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Attest SLSA provenance for manifest-list (primary tag) | |
| # Attests the first/primary manifest-list digest (latest/version tag). | |
| # This covers the most common consumer path. | |
| uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 | |
| with: | |
| subject-name: ${{ steps.attest-manifests-prep.outputs.first-image }} | |
| subject-digest: ${{ steps.attest-manifests-prep.outputs.first-digest }} | |
| push-to-registry: true | |
| prod-pypi: | |
| name: Publish to PyPI | |
| needs: [config, build-wheel] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| # Only publish to production PyPI on a real GitHub Release. | |
| # Manual dispatch must not push a dev-build version to prod PyPI. | |
| if: github.event_name == 'release' | |
| environment: pypi | |
| permissions: | |
| contents: read | |
| id-token: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Setup Python and UV | |
| uses: ./.github/actions/setup-uv-cached | |
| with: | |
| cache-key: prod-pypi-${{ needs.config.outputs.package-version }} | |
| fail-on-cache-miss: false | |
| - name: Download release wheel | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| name: release-wheel-${{ needs.config.outputs.package-version }} | |
| path: dist/ | |
| - name: Publish to PyPI | |
| uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 | |
| with: | |
| print-hash: true | |
| verbose: true | |
| attestations: true | |
| prod-docs: | |
| name: Deploy Production Documentation | |
| needs: config | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| environment: | |
| name: github-pages | |
| url: ${{ steps.deployment.outputs.page_url }} | |
| permissions: | |
| contents: write | |
| pages: write | |
| id-token: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Python and UV | |
| uses: ./.github/actions/setup-uv-cached | |
| with: | |
| cache-key: prod-docs-${{ needs.config.outputs.package-version }} | |
| fail-on-cache-miss: false | |
| - name: Compute minor version | |
| id: version | |
| run: | | |
| VERSION_MINOR=$(echo "${{ needs.config.outputs.package-version }}" | cut -d. -f1,2) | |
| echo "minor=${VERSION_MINOR}" >> "$GITHUB_OUTPUT" | |
| - name: Deploy versioned docs via mike | |
| run: bash dev-tools/docs/mike_deploy.sh release "${{ steps.version.outputs.minor }}" --push | |
| - name: Upload Pages artifact | |
| id: upload | |
| uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 | |
| with: | |
| path: docs/site | |
| - name: Deploy to GitHub Pages | |
| id: deployment | |
| uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 | |
| - name: Smoke-check deployed site | |
| run: bash dev-tools/docs/smoke_check_pages.sh "${{ steps.deployment.outputs.page_url }}" | |
| container-cleanup: | |
| name: Cleanup Old Container Images | |
| needs: [config, prod-manifests] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| packages: write | |
| steps: | |
| - name: Delete untagged container images | |
| uses: actions/delete-package-versions@e5bc658cc4c965c472efe991f8beea3981499c55 # v5 | |
| with: | |
| package-name: ${{ github.event.repository.name }} | |
| package-type: container | |
| min-versions-to-keep: 0 | |
| delete-only-untagged-versions: true | |
| github-assets: | |
| name: Upload GitHub Release Assets | |
| # prod-containers intentionally removed from needs: wheel signing + GH release | |
| # asset upload must run on every release independent of container health. | |
| # A container base-image CVE (Trivy gate) must not prevent the Python | |
| # wheel/sdist from being signed and published. prod-manifests (which | |
| # legitimately needs the pushed images) retains its prod-containers dep. | |
| needs: [config, build-wheel, prod-pypi] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| # Only upload assets and create gh release upload on a real GitHub Release. | |
| # Manual dispatch would compute a dev version tag (1.x.y.devN) and fail on | |
| # `gh release upload` because that tag doesn't exist as a release. | |
| if: github.event_name == 'release' | |
| permissions: | |
| contents: write | |
| id-token: write # required by actions/attest-build-provenance (Sigstore OIDC) | |
| attestations: write # required to persist attestation to GitHub's attestations store | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| - name: Setup Python and UV | |
| uses: ./.github/actions/setup-uv-cached | |
| with: | |
| cache-key: prod-assets-${{ needs.config.outputs.package-version }} | |
| fail-on-cache-miss: false | |
| - name: Download release wheel | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| name: release-wheel-${{ needs.config.outputs.package-version }} | |
| path: dist/ | |
| - name: Generate SBOM | |
| run: make sbom-generate | |
| - name: Attest build provenance for release artifacts | |
| # Generates a Sigstore in-toto provenance attestation for the wheel and | |
| # sdist. The bundle is also uploaded to GitHub's attestations API so it | |
| # is queryable via `gh attestation verify`. We additionally attach it as | |
| # a release asset (see next step) so OpenSSF Scorecard's Signed-Releases | |
| # check — which scans release assets for .intoto.jsonl files — can | |
| # discover it and score the release at 10/10. | |
| # | |
| # SHA pinned to actions/attest-build-provenance v4.1.1. | |
| # Verify: https://github.com/actions/attest-build-provenance/releases/tag/v4.1.1 | |
| id: attest-artifacts | |
| uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 | |
| with: | |
| subject-path: 'dist/*.whl, dist/*.tar.gz' | |
| - name: Upload release assets | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| VERSION="${{ needs.config.outputs.package-version }}" | |
| RELEASE_TAG="v$VERSION" | |
| # Upload wheel and sdist | |
| gh release upload "$RELEASE_TAG" dist/*.whl dist/*.tar.gz --clobber | |
| # Upload SBOM files if present. | |
| # sbom-generate produces: python-sbom-cyclonedx.json (CycloneDX), | |
| # python-sbom.json (pip-audit JSON), and python-sbom-spdx.json (SPDX). | |
| # syft is installed by the sbom-generate make target, so all three | |
| # should be present; the -f guard makes the step resilient to partial | |
| # runs. | |
| for f in python-sbom-cyclonedx.json python-sbom.json python-sbom-spdx.json; do | |
| if [[ -f "$f" ]]; then | |
| gh release upload "$RELEASE_TAG" "$f" --clobber | |
| fi | |
| done | |
| # Upload the Sigstore attestation bundle as a .intoto.jsonl release asset | |
| # so OpenSSF Scorecard's Signed-Releases check (which looks for this | |
| # extension on release assets) can detect provenance and score 10/10. | |
| BUNDLE_SRC="${{ steps.attest-artifacts.outputs.bundle-path }}" | |
| BUNDLE_DEST="attestation-v${VERSION}.intoto.jsonl" | |
| cp "$BUNDLE_SRC" "$BUNDLE_DEST" | |
| gh release upload "$RELEASE_TAG" "$BUNDLE_DEST" --clobber |