Files
2026-07-13 12:12:21 +08:00

434 lines
15 KiB
YAML

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