From a79eafd70f91fa4b242a8a8faf9d540e30bf8d9d Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Mon, 11 May 2026 14:21:28 +0300 Subject: [PATCH] feat: add prerelease RPM builds from upstream main branch Poll upstream main branch HEAD alongside release tags. When a new commit is detected, build and publish prerelease RPMs to a separate unstable repo at rpm.lair.cafe/fedora/$releasever/$basearch/unstable/. RPM versioning uses the Fedora snapshot convention (e.g. 0.8.1-0.1.20260511git1a2b3c4.fc43) so stable releases automatically supersede any installed prerelease. - RPM spec: conditional Release field via mistralrs_prerelease define - poll-upstream.yml: new check-prerelease job fetches main HEAD + Cargo.toml version - build-prerelease.yml: new workflow for commit-based builds without --locked - UI: fetch both stable/unstable manifests, show channel badges, add unstable repo setup instructions to home page Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/build-prerelease.yml | 209 ++++++++++++++++++++++++++ .gitea/workflows/poll-upstream.yml | 70 +++++++++ rpm/mistralrs.spec | 9 +- ui/src/hooks/usePackages.ts | 46 ++++-- ui/src/pages/Home.tsx | 51 +++++++ ui/src/pages/PackageDetail.tsx | 25 ++- ui/src/pages/PackageList.tsx | 34 ++++- ui/src/types/packages.ts | 4 + 8 files changed, 423 insertions(+), 25 deletions(-) create mode 100644 .gitea/workflows/build-prerelease.yml diff --git a/.gitea/workflows/build-prerelease.yml b/.gitea/workflows/build-prerelease.yml new file mode 100644 index 0000000..785ed17 --- /dev/null +++ b/.gitea/workflows/build-prerelease.yml @@ -0,0 +1,209 @@ +name: build-prerelease + +on: + workflow_dispatch: + inputs: + commit: + description: "mistral.rs upstream commit SHA" + required: true + type: string + version: + description: "Version from upstream Cargo.toml (e.g. 0.8.1)" + required: true + type: string + date: + description: "Commit date as YYYYMMDD" + required: true + type: string + short_sha: + description: "Short commit SHA (7 chars)" + required: true + type: string + +concurrency: + group: prerelease-build + cancel-in-progress: true + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - name: cuda13 + fedora_version: "43" + runner: cuda-13.0 + cuda_home: /usr/local/cuda-13.0 + cargo_features: "cuda cudnn flash-attn nccl" + compute_caps: "120" + build_jobs: 12 + nvcc_threads: 4 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + + - name: Install/update Rust toolchain + run: | + if command -v rustup &> /dev/null; then + rustup update stable + else + curl --proto '=https' --tlsv1.2 --silent --show-error --fail https://sh.rustup.rs | sh -s -- -y + fi + echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH" + + - name: Clone mistral.rs at commit + run: | + git clone https://github.com/EricLBuehler/mistral.rs.git src/ + cd src + git checkout "${{ inputs.commit }}" + + - name: Build mistralrs + run: | + export PATH="${{ matrix.cuda_home }}/bin:${PATH}" + export LD_LIBRARY_PATH="${{ matrix.cuda_home }}/targets/x86_64-linux/lib:${{ matrix.cuda_home }}/lib64:${LD_LIBRARY_PATH:-}" + export LIBRARY_PATH="${{ matrix.cuda_home }}/targets/x86_64-linux/lib:${{ matrix.cuda_home }}/lib64:${LIBRARY_PATH:-}" + cd src + cargo build --release --features "${{ matrix.cargo_features }}" + env: + CUDA_COMPUTE_CAP: ${{ matrix.compute_caps }} + CARGO_BUILD_JOBS: ${{ matrix.build_jobs }} + NVCC_THREADS: ${{ matrix.nvcc_threads }} + + - name: Collect artifacts + run: | + mkdir --parents artifacts + cp src/target/release/mistralrs "artifacts/mistralrs-${{ matrix.name }}" + echo "built: $(artifacts/mistralrs-${{ matrix.name }} --version 2>&1 | head -1)" + + - name: Upload binary artifact + uses: actions/upload-artifact@v3 + with: + name: mistralrs-${{ matrix.name }}-fc${{ matrix.fedora_version }} + path: artifacts/mistralrs-${{ matrix.name }} + retention-days: 1 + + package: + needs: build + runs-on: fedora-43 + strategy: + fail-fast: false + matrix: + include: + - name: cuda13 + fedora_version: "43" + steps: + - uses: actions/checkout@v4 + + - name: Download binary + uses: actions/download-artifact@v3 + with: + name: mistralrs-${{ matrix.name }}-fc${{ matrix.fedora_version }} + path: artifacts/ + + - name: Build RPM + run: | + rm -f ~/.rpmmacros + rpmdev-setuptree + cp artifacts/mistralrs-${{ matrix.name }} ~/rpmbuild/SOURCES/ + cp rpm/systemd/mistralrs@.service ~/rpmbuild/SOURCES/ + cp rpm/systemd/mistralrs@.conf.example ~/rpmbuild/SOURCES/ + rpmbuild -bb rpm/mistralrs.spec \ + --define "mistralrs_version ${{ inputs.version }}" \ + --define "mistralrs_flavour ${{ matrix.name }}" \ + --define "mistralrs_prerelease 0.1.${{ inputs.date }}git${{ inputs.short_sha }}" \ + --undefine dist \ + --define "dist .fc${{ matrix.fedora_version }}" + + - name: Upload RPM + uses: actions/upload-artifact@v3 + with: + name: rpm-${{ matrix.name }}-fc${{ matrix.fedora_version }} + path: ~/rpmbuild/RPMS/x86_64/*.rpm + retention-days: 7 + + publish: + needs: package + runs-on: fedora-43 + concurrency: + group: rpm-publish + cancel-in-progress: false + env: + RPM_REPO_HOST: oolon.kosherinata.internal + strategy: + fail-fast: false + matrix: + include: + - fedora_version: "43" + steps: + - uses: actions/checkout@v4 + + - name: Download RPMs for fc${{ matrix.fedora_version }} + uses: actions/download-artifact@v3 + with: + path: rpms/ + pattern: rpm-*-fc${{ matrix.fedora_version }} + + - name: Flatten RPM artifacts + run: | + find rpms/ -name '*.rpm' -exec mv --target-directory=rpms/ {} + + find rpms/ -mindepth 1 -type d -empty -delete + + - name: Check for sequoia-sq + run: | + if ! command -v sq &> /dev/null; then + echo "ERROR: sequoia-sq is not installed. Install with: sudo dnf install sequoia-sq" + exit 1 + fi + + - name: Import signing key + run: | + echo "${{ secrets.RPM_SIGNING_KEY }}" | gpg --batch --import + fpr=$(gpg --batch --with-colons --list-keys "${{ secrets.RPM_SIGNING_KEY_ID }}" | awk -F: '/^fpr:/ { print $10; exit }') + echo "${fpr}:6:" | gpg --batch --import-ownertrust + sed "s/@GPG_NAME@/${{ secrets.RPM_SIGNING_KEY_ID }}/" rpm/rpmmacros > ~/.rpmmacros + + - name: Sign RPMs + run: | + for rpm in rpms/*.rpm; do + echo "signing ${rpm}..." + rpm --addsign "${rpm}" + done + + - name: Set up SSH + run: | + install --directory --mode 700 ~/.ssh + echo "${RSYNC_SSH_KEY}" | install --mode 600 /dev/stdin ~/.ssh/id_ed25519 + env: + RSYNC_SSH_KEY: ${{ secrets.RSYNC_SSH_KEY }} + + - name: Test SSH connectivity + run: | + ssh -o StrictHostKeyChecking=accept-new "gitea_ci@${RPM_REPO_HOST}" exit + + - name: Ensure unstable repo directory exists + run: | + ssh "gitea_ci@${RPM_REPO_HOST}" \ + "mkdir --parents /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64/unstable" + + - name: Sync RPMs to unstable repo + run: | + rsync \ + --archive \ + --verbose \ + --chmod D755,F644 \ + rpms/*.rpm \ + "gitea_ci@${RPM_REPO_HOST}:/var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64/unstable/" + + - name: Update unstable repo metadata + run: | + ssh "gitea_ci@${RPM_REPO_HOST}" \ + "cd /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64/unstable && createrepo_c --update ." + + - name: Generate packages.json for unstable + run: | + scp script/generate-packages-json.py "gitea_ci@${RPM_REPO_HOST}:/tmp/" + ssh "gitea_ci@${RPM_REPO_HOST}" \ + "python3 /tmp/generate-packages-json.py \ + --repodata-dir /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64/unstable/repodata \ + --output /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64/unstable/packages.json \ + --base-url https://rpm.lair.cafe/fedora/${{ matrix.fedora_version }}/x86_64/unstable" diff --git a/.gitea/workflows/poll-upstream.yml b/.gitea/workflows/poll-upstream.yml index acd29cb..78cefdf 100644 --- a/.gitea/workflows/poll-upstream.yml +++ b/.gitea/workflows/poll-upstream.yml @@ -83,3 +83,73 @@ jobs: --header 'Content-Type: application/json' \ --url "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/workflows/build-release.yml/dispatches" \ --data "{\"ref\":\"refs/heads/main\",\"inputs\":{\"tag\":\"${{ steps.upstream.outputs.tag }}\"}}" + + check-prerelease: + runs-on: fedora-43 + steps: + - name: Get upstream main branch HEAD + id: upstream + run: | + response=$(curl --silent --show-error --fail --location \ + --header 'Accept: application/vnd.github+json' \ + --url 'https://api.github.com/repos/EricLBuehler/mistral.rs/commits/main') + sha=$(echo "${response}" | jq -r .sha) + short_sha=$(echo "${sha}" | head --bytes=7) + date=$(echo "${response}" | jq -r '.commit.committer.date[:10]' | tr -d '-') + echo "sha=${sha}" >> "$GITHUB_OUTPUT" + echo "short_sha=${short_sha}" >> "$GITHUB_OUTPUT" + echo "date=${date}" >> "$GITHUB_OUTPUT" + echo "Upstream main HEAD: ${sha} (${date})" + + - name: Get version from upstream Cargo.toml + id: version + run: | + version=$(curl --silent --show-error --fail --location \ + --header 'Accept: application/vnd.github.raw+json' \ + --url "https://api.github.com/repos/EricLBuehler/mistral.rs/contents/Cargo.toml?ref=${{ steps.upstream.outputs.sha }}" \ + | grep '^version' | head --lines=1 | sed 's/.*"\(.*\)".*/\1/') + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "Upstream Cargo.toml version: ${version}" + + - name: Check if prerelease is already published + id: published + run: | + prerelease="0.1.${UPSTREAM_DATE}git${UPSTREAM_SHORT_SHA}" + needs_build=false + for target in "43:cuda13"; do + fedora_version="${target%%:*}" + flavour="${target##*:}" + base_url="https://rpm.lair.cafe/fedora/${fedora_version}/x86_64/unstable" + rpm_name="mistralrs-${flavour}-${UPSTREAM_VERSION}-${prerelease}.fc${fedora_version}.x86_64.rpm" + + http_code=$(curl \ + --silent \ + --write-out "%{http_code}" \ + --output /dev/null \ + --head \ + --url "${base_url}/${rpm_name}") + if [ "${http_code}" = "404" ]; then + echo "missing: ${base_url}/${rpm_name}" + needs_build=true + continue + elif [ "${http_code}" != "200" ]; then + echo "unexpected HTTP ${http_code} for ${base_url}/${rpm_name}" + exit 1 + fi + echo "found: ${base_url}/${rpm_name}" + done + echo "already_built=$( [ "${needs_build}" = "true" ] && echo false || echo true )" >> "$GITHUB_OUTPUT" + env: + UPSTREAM_VERSION: ${{ steps.version.outputs.version }} + UPSTREAM_DATE: ${{ steps.upstream.outputs.date }} + UPSTREAM_SHORT_SHA: ${{ steps.upstream.outputs.short_sha }} + + - name: Trigger prerelease build workflow + if: steps.published.outputs.already_built == 'false' + run: | + curl --fail --silent --show-error --location \ + --request POST \ + --header "Authorization: token ${{ secrets.DISPATCH_TOKEN }}" \ + --header 'Content-Type: application/json' \ + --url "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/workflows/build-prerelease.yml/dispatches" \ + --data "{\"ref\":\"refs/heads/main\",\"inputs\":{\"commit\":\"${{ steps.upstream.outputs.sha }}\",\"version\":\"${{ steps.version.outputs.version }}\",\"date\":\"${{ steps.upstream.outputs.date }}\",\"short_sha\":\"${{ steps.upstream.outputs.short_sha }}\"}}" diff --git a/rpm/mistralrs.spec b/rpm/mistralrs.spec index 1897212..10489d9 100644 --- a/rpm/mistralrs.spec +++ b/rpm/mistralrs.spec @@ -6,9 +6,16 @@ %{!?mistralrs_version: %global mistralrs_version 0.7.0} %{!?mistralrs_flavour: %global mistralrs_flavour cuda13} +# For prerelease builds, pass --define "mistralrs_prerelease 0.1.YYYYMMDDgitSHORTSHA" +%if 0%{?mistralrs_prerelease:1} +%global mistralrs_release %{mistralrs_prerelease} +%else +%global mistralrs_release 1 +%endif + Name: mistralrs-%{mistralrs_flavour} Version: %{mistralrs_version} -Release: 1%{?dist} +Release: %{mistralrs_release}%{?dist} Summary: Fast, flexible LLM inference server (mistral.rs, %{mistralrs_flavour} flavour) License: MIT diff --git a/ui/src/hooks/usePackages.ts b/ui/src/hooks/usePackages.ts index 73ddba1..7146b09 100644 --- a/ui/src/hooks/usePackages.ts +++ b/ui/src/hooks/usePackages.ts @@ -1,23 +1,47 @@ import { useEffect, useState } from "react"; -import type { PackagesManifest } from "../types/packages.ts"; +import type { Channel, PackagesManifest, PackageVersion } from "../types/packages.ts"; -const MANIFEST_URL = "/fedora/43/x86_64/packages.json"; +const STABLE_URL = "/fedora/43/x86_64/packages.json"; +const UNSTABLE_URL = "/fedora/43/x86_64/unstable/packages.json"; + +function tagPackages( + manifest: PackagesManifest, + channel: Channel, +): PackageVersion[] { + return manifest.packages.map((p) => ({ + ...p, + channel, + baseUrl: manifest.baseUrl, + })); +} export function usePackages() { - const [manifest, setManifest] = useState(null); + const [packages, setPackages] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; - fetch(MANIFEST_URL) - .then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json() as Promise; - }) - .then((data) => { - if (!cancelled) setManifest(data); + const fetchManifest = async ( + url: string, + channel: Channel, + ): Promise => { + const res = await fetch(url); + if (!res.ok) { + if (res.status === 404) return []; + throw new Error(`HTTP ${res.status} fetching ${channel} manifest`); + } + const data = (await res.json()) as PackagesManifest; + return tagPackages(data, channel); + }; + + Promise.all([ + fetchManifest(STABLE_URL, "stable"), + fetchManifest(UNSTABLE_URL, "unstable"), + ]) + .then(([stable, unstable]) => { + if (!cancelled) setPackages([...stable, ...unstable]); }) .catch((err: unknown) => { if (!cancelled) @@ -32,5 +56,5 @@ export function usePackages() { }; }, []); - return { manifest, loading, error }; + return { packages, loading, error }; } diff --git a/ui/src/pages/Home.tsx b/ui/src/pages/Home.tsx index 1d9076b..417c047 100644 --- a/ui/src/pages/Home.tsx +++ b/ui/src/pages/Home.tsx @@ -10,6 +10,13 @@ enabled=1 gpgcheck=1 gpgkey=${GPG_KEY_URL}`; +const UNSTABLE_REPO_FILE = `[lair-cafe-unstable] +name=lair.cafe RPM Repository (unstable) +baseurl=https://rpm.lair.cafe/fedora/$releasever/$basearch/unstable/ +enabled=0 +gpgcheck=1 +gpgkey=${GPG_KEY_URL}`; + export function Home() { return ( <> @@ -43,6 +50,50 @@ export function Home() { + + + + + Unstable (prerelease) packages +

+ Unstable packages are built automatically from the latest + upstream main branch commit. They use the + next release version from Cargo.toml with a + prerelease suffix (e.g.{" "} + 0.8.1-0.1.20260511git1a2b3c4). When the + upstream version is officially released, the stable package + will automatically supersede any installed prerelease. +

+ +
Add the unstable repository
+

+ The unstable repo is disabled by default. Add it alongside the + stable repo: +

+ + {`sudo dnf config-manager addrepo --from-repofile=/dev/stdin <<'EOF'\n${UNSTABLE_REPO_FILE}\nEOF`} + + +
+ Install or update from unstable +
+ + {`sudo dnf --enablerepo=lair-cafe-unstable install mistralrs-cuda13`} + + +
+ Pin to stable +
+

+ If you have the unstable repo enabled and want to stay on + stable releases, exclude prerelease versions: +

+ + {`sudo dnf --disablerepo=lair-cafe-unstable update mistralrs-cuda13`} + +
+
+ ); diff --git a/ui/src/pages/PackageDetail.tsx b/ui/src/pages/PackageDetail.tsx index b7b419b..31c5e02 100644 --- a/ui/src/pages/PackageDetail.tsx +++ b/ui/src/pages/PackageDetail.tsx @@ -11,13 +11,13 @@ function formatBytes(bytes: number): string { export function PackageDetail() { const { name } = useParams<{ name: string }>(); - const { manifest, loading, error } = usePackages(); + const { packages, loading, error } = usePackages(); if (loading) return ; if (error) return Failed to load packages: {error}; - if (!manifest) return No package data available.; + if (packages.length === 0) return No package data available.; - const versions = manifest.packages + const versions = packages .filter((p) => p.name === name) .sort((a, b) => b.buildTime - a.buildTime); @@ -25,6 +25,7 @@ export function PackageDetail() { return Package not found: {name}; const latest = versions[0]; + const hasUnstable = versions.some((v) => v.channel === "unstable"); return ( <> @@ -33,6 +34,14 @@ export function PackageDetail() { {`sudo dnf install ${name}`} + {hasUnstable && ( +
+ + {`# install latest unstable version\nsudo dnf --enablerepo=lair-cafe-unstable install ${name}`} + +
+ )} +

Versions {versions.length}

@@ -41,6 +50,7 @@ export function PackageDetail() { Version + Channel Size Built Download @@ -48,14 +58,19 @@ export function PackageDetail() { {versions.map((pkg) => ( - + {pkg.version}-{pkg.release} + + + {pkg.channel} + + {formatBytes(pkg.size)} {new Date(pkg.buildTime * 1000).toLocaleDateString()} - + {pkg.rpmFilename} diff --git a/ui/src/pages/PackageList.tsx b/ui/src/pages/PackageList.tsx index cd7fe2e..5fddbe8 100644 --- a/ui/src/pages/PackageList.tsx +++ b/ui/src/pages/PackageList.tsx @@ -1,21 +1,29 @@ -import { Alert, Spinner, Table } from "react-bootstrap"; +import { Alert, Badge, Spinner, Table } from "react-bootstrap"; import { Link } from "react-router"; import { usePackages } from "../hooks/usePackages.ts"; export function PackageList() { - const { manifest, loading, error } = usePackages(); + const { packages, loading, error } = usePackages(); if (loading) return ; if (error) return Failed to load packages: {error}; - if (!manifest || manifest.packages.length === 0) + if (packages.length === 0) return No packages published yet.; - const byName = Map.groupBy(manifest.packages, (p) => p.name); + const byName = Map.groupBy(packages, (p) => p.name); const summaries = [...byName.entries()].map(([name, versions]) => { + const stable = versions.filter((v) => v.channel === "stable"); + const unstable = versions.filter((v) => v.channel === "unstable"); const latest = versions.reduce((a, b) => a.buildTime >= b.buildTime ? a : b, ); - return { name, latest, versionCount: versions.length }; + return { + name, + latest, + stableCount: stable.length, + unstableCount: unstable.length, + versionCount: versions.length, + }; }); summaries.sort((a, b) => a.name.localeCompare(b.name)); @@ -33,15 +41,25 @@ export function PackageList() { - {summaries.map(({ name, latest, versionCount }) => ( + {summaries.map(({ name, latest, stableCount, unstableCount }) => ( {name} - {latest.version}-{latest.release} + {latest.version}-{latest.release}{" "} + + {latest.channel} + + + + {stableCount > 0 && ( + {stableCount} stable + )} + {unstableCount > 0 && ( + {unstableCount} unstable + )} - {versionCount} {latest.summary} {new Date(latest.buildTime * 1000).toLocaleDateString()} diff --git a/ui/src/types/packages.ts b/ui/src/types/packages.ts index 4db7b6d..21044f5 100644 --- a/ui/src/types/packages.ts +++ b/ui/src/types/packages.ts @@ -4,6 +4,8 @@ export interface ChangelogEntry { text: string; } +export type Channel = "stable" | "unstable"; + export interface PackageVersion { name: string; version: string; @@ -14,6 +16,8 @@ export interface PackageVersion { buildTime: number; rpmFilename: string; changelog: ChangelogEntry[]; + channel: Channel; + baseUrl: string; } export interface PackagesManifest {