name: Release
on:
push:
tags: ["v[0-9]*.[0-9]*.[0-9]*"]
workflow_dispatch:
inputs:
version:
description: "Version to rehearse (defaults to Cargo.toml)"
required: false
type: string
dry_run:
description: "Build and validate without publishing"
required: true
default: true
type: boolean
permissions:
contents: read
jobs:
version:
name: Verify release version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Match tag to package version
id: version
env:
REQUESTED_VERSION: ${{ inputs.version }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)"
version="${REQUESTED_VERSION#v}"
version="${version:-$manifest_version}"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]
test "$version" = "$manifest_version"
if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then
test "v${version}" = "$GITHUB_REF_NAME"
elif [[ "$DRY_RUN" != "true" ]]; then
test "$GITHUB_REF_TYPE" = "tag"
test "v${version}" = "$GITHUB_REF_NAME"
fi
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
verify:
uses: ./.github/workflows/ci.yml
binaries:
name: Binary (${{ matrix.target }})
needs: [version, verify]
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
binary: teams
- os: macos-15-intel
target: x86_64-apple-darwin
binary: teams
- os: macos-latest
target: aarch64-apple-darwin
binary: teams
- os: windows-latest
target: x86_64-pc-windows-msvc
binary: teams.exe
runs-on: ${{ matrix.os }}
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
with:
toolchain: 1.96.0
components: rustfmt, clippy
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 with:
key: ${{ matrix.target }}
- run: cargo build --locked --release --target ${{ matrix.target }}
- name: Package binary
shell: bash
env:
RELEASE_VERSION: ${{ needs.version.outputs.version }}
run: |
mkdir -p dist/package
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" dist/package/
cp README.md LICENSE CHANGELOG.md dist/package/
tar -C dist/package -czf "dist/teams-cli-v${RELEASE_VERSION}-${{ matrix.target }}.tar.gz" .
rm -rf dist/package
- name: Smoke test packaged binary
shell: bash
env:
RELEASE_VERSION: ${{ needs.version.outputs.version }}
run: |
smoke="$(mktemp -d)"
trap 'rm -rf "$smoke"' EXIT
tar -xzf "dist/teams-cli-v${RELEASE_VERSION}-${{ matrix.target }}.tar.gz" -C "$smoke"
python scripts/smoke-release.py "$smoke/${{ matrix.binary }}" "$RELEASE_VERSION"
- name: Attest build provenance
if: ${{ inputs.dry_run != true }}
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 with:
subject-path: dist/*.tar.gz
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: binary-${{ matrix.target }}
path: dist/*.tar.gz
if-no-files-found: error
python-wheels:
name: Python wheel (${{ matrix.target }})
needs: [version, verify]
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64
manylinux: "2_28"
- os: ubuntu-latest
target: aarch64
manylinux: "2_28"
- os: macos-latest
target: universal2-apple-darwin
manylinux: auto
- os: windows-latest
target: x64
manylinux: auto
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b with:
maturin-version: v1.14.1
command: build
target: ${{ matrix.target }}
manylinux: ${{ matrix.manylinux }}
args: --release --locked --compatibility pypi --out dist
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 if: ${{ inputs.dry_run == true }}
- name: Validate wheel metadata
if: ${{ inputs.dry_run == true }}
run: uvx --from twine==6.2.0 twine check --strict dist/*.whl
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: wheel-${{ matrix.target }}
path: dist/*.whl
if-no-files-found: error
source-distributions:
name: Source distributions
needs: [version, verify]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
with:
toolchain: 1.96.0
components: rustfmt, clippy
- run: cargo package --locked
- name: Validate crates.io package
if: ${{ inputs.dry_run == true }}
run: cargo publish --locked --dry-run
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b with:
maturin-version: v1.14.1
command: sdist
args: --out dist
- run: cp target/package/*.crate dist/
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 if: ${{ inputs.dry_run == true }}
- name: Validate source distribution metadata
if: ${{ inputs.dry_run == true }}
run: uvx --from twine==6.2.0 twine check --strict dist/*.tar.gz
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: source-distributions
path: dist/*
if-no-files-found: error
github-release:
name: GitHub release
needs: [binaries, python-wheels, source-distributions]
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
pattern: binary-*
path: dist
merge-multiple: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: source-distributions
path: dist
- name: Generate CycloneDX SBOM
run: |
cargo install cargo-cyclonedx --version 0.5.9 --locked
cargo cyclonedx --format json --override-filename teams-cli.cdx
cp teams-cli.cdx.json dist/
- name: Generate checksums
working-directory: dist
run: sha256sum ./*.tar.gz ./*.crate ./*.json > SHA256SUMS
- name: Attest artifacts and SBOM
if: ${{ inputs.dry_run != true }}
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 with:
subject-path: dist/*
sbom-path: dist/teams-cli.cdx.json
- name: Create GitHub release
if: ${{ inputs.dry_run != true }}
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
args=()
if [[ "$GITHUB_REF_NAME" == *-* ]]; then
args+=(--prerelease --latest=false)
fi
gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --generate-notes "${args[@]}"
crates-io:
name: Publish crates.io
needs: github-release
if: ${{ inputs.dry_run != true }}
runs-on: ubuntu-latest
environment: crates-io
permissions:
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
with:
toolchain: 1.96.0
components: rustfmt, clippy
- name: Check whether this version is already published
id: crate
shell: bash
run: |
version="${GITHUB_REF_NAME#v}"
response="$(curl --fail --silent --show-error --location \
--user-agent "teams-cli-release-workflow" \
"https://crates.io/api/v1/crates/teams-cli")"
if jq --exit-status --arg version "$version" \
'.versions[] | select(.num == $version)' <<< "$response" > /dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Get temporary crates.io token
if: steps.crate.outputs.exists != 'true'
id: crates-auth
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 - if: steps.crate.outputs.exists != 'true'
run: cargo publish --locked
env:
CARGO_REGISTRY_TOKEN: ${{ steps.crates-auth.outputs.token }}
- if: steps.crate.outputs.exists == 'true'
run: echo "teams-cli ${GITHUB_REF_NAME#v} is already available on crates.io."
pypi:
name: Publish PyPI
needs: github-release
if: ${{ inputs.dry_run != true }}
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/teams-cli
permissions:
id-token: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
pattern: wheel-*
path: dist
merge-multiple: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: source-distributions
path: source
- name: Collect Python distributions
run: find source -name '*.tar.gz' -exec cp {} dist/ \;
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 with:
packages-dir: dist
post-publish-smoke:
name: Verify public packages
needs: [crates-io, pypi]
if: ${{ inputs.dry_run != true }}
runs-on: ubuntu-latest
steps:
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
with:
toolchain: 1.96.0
components: rustfmt, clippy
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 with:
python-version: "3.x"
- name: Install and run the crates.io package
shell: bash
run: |
version="${GITHUB_REF_NAME#v}"
root="${RUNNER_TEMP}/cargo-smoke"
for _ in 1 2 3 4 5 6; do
if cargo install --root "$root" teams-cli --version "$version" --locked; then
"$root/bin/teams" --version | grep -F "teams $version"
exit 0
fi
sleep 10
done
exit 1
- name: Install and run the PyPI package
shell: bash
run: |
version="${GITHUB_REF_NAME#v}"
environment="${RUNNER_TEMP}/pypi-smoke"
python -m venv "$environment"
for _ in 1 2 3 4 5 6; do
if "$environment/bin/pip" install --no-cache-dir "teams-cli==$version"; then
"$environment/bin/teams" --version | grep -F "teams $version"
exit 0
fi
sleep 10
done
exit 1