name: Release on: # Fires when release-please (or a maintainer) publishes a GitHub Release. # Using the `release` event instead of `push: tags` is necessary because # tags created by the release-please workflow use GITHUB_TOKEN, which by # design does not trigger downstream workflows — that caused v0.9.4 to # ship with no binary assets (see issue #424). release: types: [published] # Manual backfill: run against a specific existing tag (e.g. v0.9.4) to # upload assets to an already-published release. workflow_dispatch: inputs: tag: description: "Existing tag to build and attach assets to (e.g. v0.9.4)" required: true type: string permissions: contents: write id-token: write actions: read env: CARGO_TERM_COLOR: always BINARY: llmfit jobs: build: strategy: matrix: include: # Linux x86_64 (static musl) - target: x86_64-unknown-linux-musl os: ubuntu-latest use-cross: true # Linux ARM64 (static musl) - target: aarch64-unknown-linux-musl os: ubuntu-latest use-cross: true # Linux x86_64 (glibc) - target: x86_64-unknown-linux-gnu os: ubuntu-latest use-cross: false # Linux ARM64 (glibc) - target: aarch64-unknown-linux-gnu os: ubuntu-latest use-cross: true # Linux RISCV64 (glibc) - target: riscv64gc-unknown-linux-gnu os: ubuntu-latest use-cross: true # macOS Intel (cross-compiled from ARM64 runner) - target: x86_64-apple-darwin os: macos-latest use-cross: false # macOS Apple Silicon - target: aarch64-apple-darwin os: macos-latest use-cross: false # Windows x86_64 - target: x86_64-pc-windows-msvc os: windows-latest use-cross: false # Windows ARM64 - target: aarch64-pc-windows-msvc os: windows-latest use-cross: false runs-on: ${{ matrix.os }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.tag || github.ref }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - name: Build web dashboard assets run: | cd llmfit-web npm ci npm run build - name: Install cross if: matrix.use-cross run: cargo install cross --version 0.2.5 - name: Build shell: bash run: | if [ "${{ matrix.use-cross }}" = "true" ]; then cross build --release --target ${{ matrix.target }} else cargo build --release --target ${{ matrix.target }} fi - name: Package shell: bash run: | TAG="${{ inputs.tag || github.ref_name }}" ASSET_NAME="${BINARY}-${TAG}-${{ matrix.target }}" STAGING="${RUNNER_TEMP}/${ASSET_NAME}" mkdir -p "${STAGING}" if [[ "${{ matrix.target }}" == *"windows"* ]]; then EXE_EXT=".exe" ARCHIVE_EXT=".zip" COMPRESS_CMD="7z a ${ASSET_NAME}${ARCHIVE_EXT} ${ASSET_NAME}" else EXE_EXT="" ARCHIVE_EXT=".tar.gz" COMPRESS_CMD="tar czf ${ASSET_NAME}${ARCHIVE_EXT} ${ASSET_NAME}" fi cp "target/${{ matrix.target }}/release/${BINARY}${EXE_EXT}" "${STAGING}/" cp README.md LICENSE "${STAGING}/" 2>/dev/null || true cd "${RUNNER_TEMP}" $COMPRESS_CMD # Generate per-asset SHA256 checksum file (consistent format for both tools) if command -v sha256sum >/dev/null 2>&1; then sha256sum "${ASSET_NAME}${ARCHIVE_EXT}" | awk '{print $1 " " $2}' > "${ASSET_NAME}${ARCHIVE_EXT}.sha256" elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "${ASSET_NAME}${ARCHIVE_EXT}" | awk '{print $1 " " $2}' > "${ASSET_NAME}${ARCHIVE_EXT}.sha256" fi echo "ASSET=${ASSET_NAME}${ARCHIVE_EXT}" >> "$GITHUB_ENV" echo "ASSET_PATH=${RUNNER_TEMP}/${ASSET_NAME}${ARCHIVE_EXT}" >> "$GITHUB_ENV" echo "CHECKSUM_PATH=${RUNNER_TEMP}/${ASSET_NAME}${ARCHIVE_EXT}.sha256" >> "$GITHUB_ENV" - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ env.ASSET }} path: | ${{ env.ASSET_PATH }} ${{ env.CHECKSUM_PATH }} # Sign Windows executables via SignPath (Authenticode). # Uses the test-signing policy initially; switch to release-signing # after SignPath reviews the setup and provisions the production cert. sign-windows: needs: build runs-on: ubuntu-latest strategy: matrix: target: - x86_64-pc-windows-msvc - aarch64-pc-windows-msvc steps: - name: Determine asset name id: asset-name shell: bash run: | TAG="${{ inputs.tag || github.ref_name }}" ASSET="llmfit-${TAG}-${{ matrix.target }}" echo "zip=${ASSET}.zip" >> "$GITHUB_OUTPUT" echo "dir=${ASSET}" >> "$GITHUB_OUTPUT" - name: Download unsigned artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ steps.asset-name.outputs.zip }} path: unsigned - name: Extract exe from zip shell: bash run: | cd unsigned unzip "${{ steps.asset-name.outputs.zip }}" # Move the exe to a clean directory for signing mkdir -p ../to-sign cp "${{ steps.asset-name.outputs.dir }}/llmfit.exe" ../to-sign/ - name: Upload exe for SignPath id: upload-for-signing uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ steps.asset-name.outputs.zip }}-unsigned path: to-sign/llmfit.exe - name: Submit signing request to SignPath uses: signpath/github-action-submit-signing-request@v2 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }} signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }} artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }} github-artifact-id: ${{ steps.upload-for-signing.outputs.artifact-id }} wait-for-completion: true output-artifact-directory: signed - name: Repackage signed exe into zip shell: bash run: | # Replace the unsigned exe with the signed one cp signed/llmfit.exe "unsigned/${{ steps.asset-name.outputs.dir }}/llmfit.exe" cd unsigned zip -r "../${{ steps.asset-name.outputs.zip }}" "${{ steps.asset-name.outputs.dir }}" - name: Replace unsigned artifact with signed one uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ steps.asset-name.outputs.zip }} path: ${{ steps.asset-name.outputs.zip }} overwrite: true - name: Regenerate checksum for signed zip shell: bash run: | sha256sum "${{ steps.asset-name.outputs.zip }}" | awk '{print $1 " " $2}' > "${{ steps.asset-name.outputs.zip }}.sha256" - name: Upload signed checksum uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ steps.asset-name.outputs.zip }}.sha256 path: ${{ steps.asset-name.outputs.zip }}.sha256 overwrite: true release: needs: [build, sign-windows] # Run even if sign-windows is skipped (e.g. secrets not yet configured) if: always() && needs.build.result == 'success' runs-on: ubuntu-latest steps: - name: Download all artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: artifacts - name: Create GitHub Release uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 with: tag_name: ${{ inputs.tag || github.ref_name }} generate_release_notes: true files: | artifacts/**/*.tar.gz artifacts/**/*.zip artifacts/**/*.sha256 publish-python: needs: release # Run when release succeeds, even if upstream sign-windows was skipped or failed if: always() && needs.release.result == 'success' runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/llmfit/ permissions: id-token: write steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.tag || github.ref }} - name: Set up uv uses: astral-sh/setup-uv@v8.2.0 - name: Build Python wheels shell: bash env: TAG: ${{ inputs.tag || github.ref_name }} run: | # Each entry: python_platform_tag:rust_target:binary_name:archive_ext # Must stay in sync with TARGET_CONFIGS in llmfit-python/hatch_build.py. TARGETS=( "manylinux_2_17_x86_64:x86_64-unknown-linux-gnu:llmfit:tar.gz" "manylinux_2_17_aarch64:aarch64-unknown-linux-gnu:llmfit:tar.gz" "manylinux_2_39_riscv64:riscv64gc-unknown-linux-gnu:llmfit:tar.gz" "musllinux_1_2_x86_64:x86_64-unknown-linux-musl:llmfit:tar.gz" "musllinux_1_2_aarch64:aarch64-unknown-linux-musl:llmfit:tar.gz" "macosx_10_12_x86_64:x86_64-apple-darwin:llmfit:tar.gz" "macosx_11_0_arm64:aarch64-apple-darwin:llmfit:tar.gz" "win_amd64:x86_64-pc-windows-msvc:llmfit.exe:zip" "win_arm64:aarch64-pc-windows-msvc:llmfit.exe:zip" ) BASE_URL="https://github.com/AlexsJones/llmfit/releases/download/${TAG}" mkdir -p dist/ for entry in "${TARGETS[@]}"; do IFS=: read -r py_tag rust_target binary_name archive_ext <<< "$entry" ASSET="llmfit-${TAG}-${rust_target}" ARCHIVE="${ASSET}.${archive_ext}" EXPECTED=$(curl -fsSL "${BASE_URL}/${ARCHIVE}.sha256" | awk '{print $1}') ACTUAL=$(curl -fsSL "${BASE_URL}/${ARCHIVE}" | tee "${ARCHIVE}" | sha256sum | awk '{print $1}') if [ "${ACTUAL}" != "${EXPECTED}" ]; then echo "Checksum mismatch for ${ARCHIVE}: expected ${EXPECTED}, got ${ACTUAL}" exit 1 fi echo "Checksum OK for ${ARCHIVE}" if [ "${archive_ext}" = "tar.gz" ]; then tar -xzf "${ARCHIVE}" "${ASSET}/${binary_name}" else unzip "${ARCHIVE}" "${ASSET}/${binary_name}" fi dest="target/${rust_target}/release" mkdir -p "${dest}" cp "${ASSET}/${binary_name}" "${dest}/${binary_name}" chmod +x "${dest}/${binary_name}" rm -f "${ARCHIVE}" rm -rf "${ASSET}" LLMFIT_PYTHON_PLATFORM_TAG="${py_tag}" uv build --wheel --out-dir=dist/ llmfit-python/ done - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 publish-crate: needs: release if: always() && needs.release.result == 'success' runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.tag || github.ref }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable # Pass the registry token via env rather than --token: argv is # visible in the process listing on the runner, and GitHub secret # masking only covers log output. cargo reads CARGO_REGISTRY_TOKEN # automatically. - name: Publish llmfit-core to crates.io env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: cargo publish -p llmfit-core - name: Wait for crates.io index update run: sleep 30 - name: Publish llmfit to crates.io env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: cargo publish -p llmfit update-homebrew: needs: release if: always() && needs.release.result == 'success' runs-on: ubuntu-latest steps: - name: Checkout tap uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: AlexsJones/homebrew-llmfit token: ${{ secrets.HOMEBREW_TAP_TOKEN }} path: homebrew-tap - name: Download release assets and update formula env: TAG: ${{ inputs.tag || github.ref_name }} run: | VERSION="${TAG#v}" # Download the four tarballs and compute SHA256 declare -A SHAS for target in aarch64-apple-darwin x86_64-apple-darwin aarch64-unknown-linux-musl x86_64-unknown-linux-musl; do URL="https://github.com/AlexsJones/llmfit/releases/download/${TAG}/llmfit-${TAG}-${target}.tar.gz" echo "Downloading ${URL}..." SHA=$(curl -fsSL "$URL" | shasum -a 256 | awk '{print $1}') SHAS[$target]="$SHA" echo "${target}: ${SHA}" done # Generate the formula cat > homebrew-tap/Formula/llmfit.rb << RUBY class Llmfit < Formula desc "Terminal tool that right-sizes LLM models to your system hardware" homepage "https://github.com/AlexsJones/llmfit" version "${VERSION}" license "MIT" on_macos do if Hardware::CPU.arm? url "https://github.com/AlexsJones/llmfit/releases/download/v#{version}/llmfit-v#{version}-aarch64-apple-darwin.tar.gz" sha256 "${SHAS[aarch64-apple-darwin]}" else url "https://github.com/AlexsJones/llmfit/releases/download/v#{version}/llmfit-v#{version}-x86_64-apple-darwin.tar.gz" sha256 "${SHAS[x86_64-apple-darwin]}" end end on_linux do if Hardware::CPU.arm? url "https://github.com/AlexsJones/llmfit/releases/download/v#{version}/llmfit-v#{version}-aarch64-unknown-linux-musl.tar.gz" sha256 "${SHAS[aarch64-unknown-linux-musl]}" else url "https://github.com/AlexsJones/llmfit/releases/download/v#{version}/llmfit-v#{version}-x86_64-unknown-linux-musl.tar.gz" sha256 "${SHAS[x86_64-unknown-linux-musl]}" end end def install bin.install "llmfit" end test do assert_match "llmfit", shell_output("#{bin}/llmfit --help") end end RUBY # Fix heredoc indentation sed -i 's/^ //' homebrew-tap/Formula/llmfit.rb - name: Commit and push run: | cd homebrew-tap git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add Formula/llmfit.rb git commit -m "Update llmfit to ${TAG}" git push