tempus-ddb 0.5.4

Fail-closed policy gate for AI agent actions: signed policies, single-use permits, receipts
Documentation
name: Release

on:
  push:
    tags:
      - "v*"
  workflow_dispatch:
    inputs:
      tag_name:
        description: "Existing release tag to build (for example v0.5.3)"
        required: true
        type: string
      publish:
        description: "Publish to GitHub Releases, PyPI and crates.io (false = build and test only)"
        type: boolean
        required: false
        default: false

permissions:
  contents: read

env:
  RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }}

concurrency:
  group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }}
  cancel-in-progress: false

# A tag push is the publish path. This workflow creates the GitHub Release, so it
# does not also listen for `release: published` (that event would start a second
# run). workflow_dispatch builds and tests the named tag; it publishes only when
# `publish` is true. Either publish path waits on the protected `release`
# environment. Required reviewers are a repository setting, not a key in this file.
#
# Every checkout pins `ref` to the release tag so a manual re-run builds exactly
# the tagged source instead of the branch the workflow was dispatched from.
# Action SHAs must resolve to real upstream tags; verify with
# `gh api repos/<owner>/<action>/commits/<sha>` before changing them.
jobs:
  build:
    name: Build wheels on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    timeout-minutes: 45
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            target: x86_64-unknown-linux-gnu
          - os: windows-latest
            target: x86_64-pc-windows-msvc
          - os: macos-latest
            target: x86_64-apple-darwin
          - os: macos-latest
            target: aarch64-apple-darwin
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ env.RELEASE_TAG }}
          persist-credentials: false
      - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
        with:
          target: ${{ matrix.target }}
          args: --release --out dist
          manylinux: auto
      - name: Upload wheels
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: wheels-${{ matrix.os }}-${{ matrix.target }}
          path: dist

  sdist:
    name: Build sdist
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ env.RELEASE_TAG }}
          persist-credentials: false
      - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
        with:
          command: sdist
          args: --out dist
      - name: Upload sdist
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: sdist
          path: dist

  test-wheels:
    name: Test wheels on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    needs: [build]
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ env.RELEASE_TAG }}
          persist-credentials: false
      - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
        with:
          python-version: "3.11"
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          pattern: wheels-${{ matrix.os }}-*
          path: dist
          merge-multiple: true
      - name: Install wheel and verify import and tests
        shell: bash
        run: |
          # dist/ can hold several wheels for one OS (macOS gets x86_64 and
          # arm64): let pip pick the one this runner supports, then resolve its
          # runtime dependencies from PyPI while keeping the local wheel.
          pip install --no-index --find-links dist --no-deps tempus-ddb
          pip install --find-links dist tempus-ddb
          pip check
          # pyproject's pytest `pythonpath` and tests/test_cli.py both put python/
          # on the import path; drop the uncompiled source package so every test
          # (and subprocess) can only import the installed wheel.
          rm -rf python/tempus_ddb
          python -c "import tempus_ddb; print('tempus_ddb version:', tempus_ddb.__version__, tempus_ddb.__file__)"
          pip install pytest pytest-asyncio
          pytest tests/ -p no:cacheprovider

  cargo-test:
    name: Run Cargo Tests
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ env.RELEASE_TAG }}
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master 2026-10-16
        with:
          toolchain: stable
      - name: Run Cargo Test
        run: cargo test --all-targets --verbose

  release:
    name: Release
    runs-on: ubuntu-latest
    timeout-minutes: 45
    needs: [build, sdist, test-wheels, cargo-test]
    # Tag pushes publish. Manual runs stay build-and-test unless `publish` is set.
    if: github.event_name == 'push' || inputs.publish
    environment:
      name: release
      url: https://github.com/${{ github.repository }}/releases/tag/${{ env.RELEASE_TAG }}
    permissions:
      contents: write
      id-token: write
      attestations: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ env.RELEASE_TAG }}
          persist-credentials: false
      - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
        with:
          python-version: "3.11"
      - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master 2026-10-16
        with:
          toolchain: stable
      - name: Check tag matches package versions
        shell: bash
        run: python scripts/check_release_tag.py "${RELEASE_TAG}"
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          pattern: wheels-*
          path: dist
          merge-multiple: true
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: sdist
          path: dist
      - name: Record source date
        id: source-date
        shell: bash
        run: echo "epoch=$(git log -1 --format=%ct)" >> "$GITHUB_OUTPUT"
      - name: Generate SPDX SBOM
        env:
          SOURCE_DATE_EPOCH: ${{ steps.source-date.outputs.epoch }}
        shell: bash
        run: |
          set -euo pipefail
          mkdir -p sbom
          python scripts/generate_sbom.py \
            --version "${RELEASE_TAG}" \
            --output "sbom/tempus-ddb-${RELEASE_TAG}.spdx.json"
      - name: Generate Rust CycloneDX SBOM
        shell: bash
        run: |
          set -euo pipefail
          # Do not export SOURCE_DATE_EPOCH here. cargo-cyclonedx 0.5.9 omits
          # serialNumber when that variable is set, and actions/attest-sbom
          # rejects a CycloneDX document that has no serialNumber.
          unset SOURCE_DATE_EPOCH || true
          cargo install cargo-cyclonedx --version 0.5.9 --locked
          cargo cyclonedx \
            --format json \
            --spec-version 1.5 \
            --all \
            --override-filename "tempus-ddb-${RELEASE_TAG}.rust.cdx"
          src="tempus-ddb-${RELEASE_TAG}.rust.cdx.json"
          if [ ! -f "${src}" ]; then
            echo "cargo-cyclonedx did not write ${src}" >&2
            find . -name '*.cdx.json' -print >&2
            exit 1
          fi
          mkdir -p sbom
          mv "${src}" "sbom/${src}"
      - name: Generate Python CycloneDX SBOM
        shell: bash
        run: |
          set -euo pipefail
          unset SOURCE_DATE_EPOCH || true
          mapfile -t wheels < <(find dist -type f -name 'tempus_ddb-*manylinux*x86_64*.whl' | sort)
          if [ "${#wheels[@]}" -ne 1 ]; then
            echo "expected exactly one linux x86_64 wheel, found ${#wheels[@]}" >&2
            find dist -type f -print >&2
            exit 1
          fi
          pkg_venv="${RUNNER_TEMP}/python-sbom-pkg"
          tool_venv="${RUNNER_TEMP}/python-sbom-tool"
          python -m venv "${pkg_venv}"
          python -m venv "${tool_venv}"
          "${pkg_venv}/bin/python" -m pip install --upgrade pip
          "${pkg_venv}/bin/python" -m pip install "${wheels[0]}"
          # Inventory the published distribution. pip, setuptools and wheel are
          # installer tools, not runtime dependencies of the wheel.
          for pkg in setuptools wheel pip; do
            if "${pkg_venv}/bin/python" -m pip show "${pkg}" >/dev/null 2>&1; then
              "${pkg_venv}/bin/python" -m pip uninstall -y "${pkg}"
            fi
          done
          "${tool_venv}/bin/python" -m pip install --upgrade pip
          "${tool_venv}/bin/python" -m pip install "cyclonedx-bom==7.5.0"
          mkdir -p sbom
          "${tool_venv}/bin/python" -m cyclonedx_py environment "${pkg_venv}/bin/python" \
            --pyproject pyproject.toml \
            --mc-type library \
            --sv 1.6 \
            --of JSON \
            --output-file "sbom/tempus-ddb-${RELEASE_TAG}.python.cdx.json"
      - name: Validate SBOM documents
        shell: bash
        run: |
          set -euo pipefail
          python scripts/validate_release_sboms.py
          find dist sbom -type f -print0 | sort -z | xargs -0 sha256sum
      - name: Attest release provenance
        uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
        with:
          subject-path: |
            dist/*
            sbom/*
      - name: Attest SPDX SBOM
        uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
        with:
          subject-path: dist/*
          sbom-path: sbom/tempus-ddb-${{ env.RELEASE_TAG }}.spdx.json
      - name: Attest Rust CycloneDX SBOM
        uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
        with:
          subject-path: dist/*
          sbom-path: sbom/tempus-ddb-${{ env.RELEASE_TAG }}.rust.cdx.json
      - name: Attest Python CycloneDX SBOM
        uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
        with:
          subject-path: dist/*
          sbom-path: sbom/tempus-ddb-${{ env.RELEASE_TAG }}.python.cdx.json
      - name: Upload distributions to GitHub Release
        uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # 2.11.5
        with:
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          file: dist/*
          file_glob: true
          tag: ${{ env.RELEASE_TAG }}
          overwrite: true
          release_name: ${{ env.RELEASE_TAG }}
          prerelease: ${{ contains(env.RELEASE_TAG, '-') }}
          make_latest: ${{ !contains(env.RELEASE_TAG, '-') }}
          body: |
            Wheels, sdist, SPDX SBOM and CycloneDX SBOMs (Rust and Python) for ${{ env.RELEASE_TAG }}.
            Verify the files with `gh attestation verify`. Procedure: docs/RELEASING.md.
      - name: Upload SBOMs to GitHub Release
        uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # 2.11.5
        with:
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          file: sbom/*.json
          file_glob: true
          tag: ${{ env.RELEASE_TAG }}
          overwrite: true

      # PyPI Trusted Publishing (OIDC). The token's `environment` claim is
      # `release`, so the PyPI publisher must name this workflow and that
      # environment. No API token is read.
      - name: Publish to PyPI
        uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
        with:
          packages-dir: dist/
          skip-existing: true

  # Separate job so a crates.io failure still leaves the GitHub Release and the
  # PyPI publication visible as a successful job. It does not run unless that
  # job succeeded, and it asks for its own approval on `release`.
  publish-crates:
    name: Publish to crates.io
    runs-on: ubuntu-latest
    timeout-minutes: 30
    needs: [release]
    if: github.event_name == 'push' || inputs.publish
    environment:
      name: release
      url: https://crates.io/crates/tempus-ddb
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ env.RELEASE_TAG }}
          persist-credentials: false
      - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
        with:
          python-version: "3.11"
      - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master 2026-10-16
        with:
          toolchain: stable
      - name: Check tag matches package versions
        shell: bash
        run: python scripts/check_release_tag.py "${RELEASE_TAG}"
      - name: Authenticate to crates.io
        id: crates-auth
        uses: rust-lang/crates-io-auth-action@3268eb413133e2960fa4ae0e963ca76380c54cd9 # v1.0.6
      - name: Publish to crates.io
        env:
          CARGO_REGISTRY_TOKEN: ${{ steps.crates-auth.outputs.token }}
        shell: bash
        run: |
          set -euo pipefail
          if [ -z "${CARGO_REGISTRY_TOKEN}" ]; then
            echo "crates.io trusted publishing did not return a token" >&2
            exit 1
          fi
          cargo publish --locked