phostt 0.4.1

Local STT server powered by Zipformer-vi RNN-T — on-device Vietnamese speech recognition via ONNX Runtime
Documentation
name: Release

on:
  push:
    tags:
      - 'v*'
  workflow_dispatch:
    inputs:
      tag:
        description: 'Tag to release (e.g. v0.5.1)'
        required: true

permissions:
  contents: write
  # SUS-05: signed build provenance attestations require the GitHub-issued
  # id-token for Sigstore + write access to the `attestations` API.
  id-token: write
  attestations: write

jobs:
  build:
    name: ${{ matrix.name }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - name: macos-arm64
            os: macos-14
            target: aarch64-apple-darwin
            features: coreml
            asset_suffix: aarch64-apple-darwin
            cuda: false
          - name: linux-x86_64-cpu
            os: ubuntu-latest
            target: x86_64-unknown-linux-gnu
            features: ""
            asset_suffix: x86_64-unknown-linux-gnu
            cuda: false
          - name: linux-aarch64
            os: ubuntu-latest
            target: aarch64-unknown-linux-gnu
            features: ""
            asset_suffix: aarch64-unknown-linux-gnu
            cuda: false
          - name: macos-x86_64
            os: macos-13
            target: x86_64-apple-darwin
            features: coreml
            asset_suffix: x86_64-apple-darwin
            cuda: false
          # linux-x86_64-cuda is intentionally omitted until the CUDA install
          # path is stabilized (Jimver/cuda-toolkit breaks on ubuntu-latest for
          # 12.4.x package names). Tracked in specs/todo.md.
    steps:
      - uses: actions/checkout@v6

      - name: Resolve tag
        id: tag
        shell: bash
        run: |
          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
            echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
          else
            echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
          fi

      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}

      # `build.rs` invokes `prost-build` which shells out to `protoc`.
      # Both GitHub-hosted macos and ubuntu images need it installed
      # explicitly; the `arduino/setup-protoc` action handles both.
      - name: Install cross-compiler (Linux aarch64)
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        shell: bash
        run: |
          sudo apt-get update
          sudo apt-get install -y gcc-aarch64-linux-gnu
          echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"

      - name: Install protoc
        uses: arduino/setup-protoc@v3
        with:
          version: '29.x'
          repo-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Install CUDA toolkit
        if: matrix.cuda
        uses: Jimver/cuda-toolkit@v0.2.35
        with:
          cuda: '12.4.1'
          method: network
          sub-packages: '["nvcc", "cudart", "cublas", "cublas_dev"]'

      - uses: Swatinem/rust-cache@v2
        with:
          key: release-${{ matrix.asset_suffix }}

      - name: Build release
        shell: bash
        env:
          FEATURES: ${{ matrix.features }}
        run: |
          if [ -n "$FEATURES" ]; then
            cargo build --release --locked --target ${{ matrix.target }} --features "$FEATURES"
          else
            cargo build --release --locked --target ${{ matrix.target }}
          fi

      - name: Package tarball
        id: package
        shell: bash
        env:
          TAG: ${{ steps.tag.outputs.tag }}
          SUFFIX: ${{ matrix.asset_suffix }}
          TARGET: ${{ matrix.target }}
        run: |
          VERSION="${TAG#v}"
          ASSET="phostt-${VERSION}-${SUFFIX}.tar.gz"

          STAGE="$(mktemp -d)"
          cp "target/${TARGET}/release/phostt" "$STAGE/phostt"
          cp LICENSE README.md CHANGELOG.md "$STAGE/" 2>/dev/null || true

          tar -C "$STAGE" -czf "$ASSET" .
          sha256sum "$ASSET" > "${ASSET}.sha256" 2>/dev/null || shasum -a 256 "$ASSET" > "${ASSET}.sha256"

          echo "asset=$ASSET" >> "$GITHUB_OUTPUT"
          echo "sha_file=${ASSET}.sha256" >> "$GITHUB_OUTPUT"
          ls -la "$ASSET" "${ASSET}.sha256"

      - uses: actions/upload-artifact@v7
        with:
          name: phostt-${{ matrix.asset_suffix }}
          path: |
            ${{ steps.package.outputs.asset }}
            ${{ steps.package.outputs.sha_file }}
          if-no-files-found: error
          retention-days: 7

  publish:
    name: Publish GitHub release
    needs: build
    runs-on: ubuntu-latest
    env:
      # GitHub Actions forbids referencing `secrets.*` in `if:` conditions
      # directly; expose presence as a boolean env var at job scope instead.
      HAS_MINISIGN_KEY: ${{ secrets.MINISIGN_SECRET_KEY != '' && 'true' || 'false' }}
    steps:
      - uses: actions/checkout@v6

      - name: Resolve tag
        id: tag
        shell: bash
        run: |
          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
            echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
          else
            echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
          fi

      - uses: actions/download-artifact@v8
        with:
          path: dist
          merge-multiple: true

      - name: Aggregate SHA256SUMS.txt
        shell: bash
        working-directory: dist
        run: |
          : > SHA256SUMS.txt
          for f in *.tar.gz; do
            (sha256sum "$f" 2>/dev/null || shasum -a 256 "$f") >> SHA256SUMS.txt
          done
          cat SHA256SUMS.txt

      # SUS-02: CycloneDX SBOM for the published tag. Generated from the
      # workspace Cargo.lock so consumers can audit exactly which crate
      # versions went into the released binary. Output name matches the
      # release tag so GitHub's asset resolver can find it verbatim.
      - name: Install Rust toolchain for SBOM generation
        uses: dtolnay/rust-toolchain@stable

      - name: Generate CycloneDX SBOM
        shell: bash
        env:
          TAG: ${{ steps.tag.outputs.tag }}
        run: |
          set -euo pipefail
          cargo install --locked --version 0.5.5 cargo-cyclonedx
          VERSION="${TAG#v}"
          # cargo-cyclonedx writes `<crate>.cdx.json` next to Cargo.toml.
          cargo cyclonedx --format json --spec-version 1.5
          mkdir -p dist
          # Pin the artifact name to the release tag for reproducibility.
          cp phostt.cdx.json "dist/phostt-${VERSION}.cdx.json"
          ls -la "dist/phostt-${VERSION}.cdx.json"

      # SUS-05: SLSA build provenance attestation. Every artefact in
      # `dist/` gets a signed in-toto provenance statement anchored to
      # this workflow run. Downstream consumers verify with
      # `gh attestation verify <file> --repo ekhodzitsky/phostt`.
      - name: Generate SLSA build provenance
        uses: actions/attest-build-provenance@v4
        with:
          subject-path: |
            dist/*.tar.gz
            dist/SHA256SUMS.txt
            dist/*.cdx.json

      # SUS-03: minisign signatures for tarballs + SHA256SUMS.txt. The
      # signing secret is injected via the `MINISIGN_SECRET_KEY` and
      # `MINISIGN_PASSWORD` repository secrets. When the secret is not
      # configured (forks, preview runs), the step degrades to a warning
      # so we don't break the release pipeline — minisign is added to
      # the tarball asset list only if signing succeeds.
      - name: Sign tarballs + SHA256SUMS with minisign
        if: env.HAS_MINISIGN_KEY == 'true'
        id: minisign
        shell: bash
        env:
          MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
          MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
        working-directory: dist
        run: |
          set -euo pipefail
          # Install system `minisign` (apt-cacher is fast) — it accepts the
          # passphrase on stdin when stdout is not a TTY, which is exactly
          # what we need in CI. `rsign2` 0.6 does not expose a
          # non-interactive password flag, so we avoid it here.
          sudo apt-get update
          sudo apt-get install -y --no-install-recommends minisign
          printf '%s\n' "$MINISIGN_SECRET_KEY" > minisign.key
          chmod 600 minisign.key
          for f in *.tar.gz SHA256SUMS.txt *.cdx.json; do
            [ -f "$f" ] || continue
            # `-S` = sign; `-s` = secret key; `-c` = comment; `-t` = trusted
            # comment (appears in `-Vm` output). Pipe the password so
            # minisign reads it from stdin instead of prompting.
            printf '%s\n' "$MINISIGN_PASSWORD" | \
              minisign -Sm "$f" -s minisign.key \
                -c "phostt release $(basename "$f")" \
                -t "phostt $(basename "$f") ${{ steps.tag.outputs.tag }}"
          done
          shred -u minisign.key 2>/dev/null || rm -f minisign.key
          ls -la ./*.minisig
          echo "signed=true" >> "$GITHUB_OUTPUT"

      - name: Warn on missing minisign secret
        if: env.HAS_MINISIGN_KEY != 'true'
        shell: bash
        run: |
          echo "::warning ::MINISIGN_SECRET_KEY not configured — release artefacts will not carry minisign signatures."

      - name: Create / update release
        uses: softprops/action-gh-release@v3
        with:
          tag_name: ${{ steps.tag.outputs.tag }}
          name: ${{ steps.tag.outputs.tag }}
          draft: false
          prerelease: false
          fail_on_unmatched_files: true
          generate_release_notes: true
          files: |
            dist/*.tar.gz
            dist/*.sha256
            dist/SHA256SUMS.txt
            dist/*.cdx.json
            dist/*.minisig