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
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 with:
ref: ${{ env.RELEASE_TAG }}
persist-credentials: false
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b with:
target: ${{ matrix.target }}
args: --release --out dist
manylinux: auto
- name: Upload wheels
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 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 with:
ref: ${{ env.RELEASE_TAG }}
persist-credentials: false
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b with:
command: sdist
args: --out dist
- name: Upload sdist
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 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 with:
ref: ${{ env.RELEASE_TAG }}
persist-credentials: false
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 with:
python-version: "3.11"
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 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 with:
ref: ${{ env.RELEASE_TAG }}
persist-credentials: false
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 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]
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 with:
ref: ${{ env.RELEASE_TAG }}
persist-credentials: false
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 with:
python-version: "3.11"
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 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 with:
pattern: wheels-*
path: dist
merge-multiple: true
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 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 with:
subject-path: |
dist/*
sbom/*
- name: Attest SPDX SBOM
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e with:
subject-path: dist/*
sbom-path: sbom/tempus-ddb-${{ env.RELEASE_TAG }}.spdx.json
- name: Attest Rust CycloneDX SBOM
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e 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 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 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 with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: sbom/*.json
file_glob: true
tag: ${{ env.RELEASE_TAG }}
overwrite: true
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 with:
packages-dir: dist/
skip-existing: true
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 with:
ref: ${{ env.RELEASE_TAG }}
persist-credentials: false
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 with:
python-version: "3.11"
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 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 - 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