Compare commits

...

85 Commits

Author SHA1 Message Date
bf39c5b9ab chore: ignore claude settings
All checks were successful
poll-upstream / check (push) Successful in 1s
poll-upstream / check-prerelease (push) Successful in 1s
2026-05-16 08:33:26 +03:00
1af2d440db fix(nginx): drop duplicate application/x-rpm mime declaration
All checks were successful
deploy-ui / build-and-deploy (push) Successful in 52s
The mapping is already provided by /etc/nginx/mime.types on Fedora's
nginx package, so redeclaring it produced a duplicate-extension warning
on every `nginx -t`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:31:41 +03:00
e9abeccdea fix(ui): host .repo files and use URL form in install instructions
The previous instructions used `--from-repofile=/dev/stdin` with a
heredoc, which fails because dnf copies the source file and /dev/stdin
is not a regular file. Host the .repo files on the deployed site and
update the docs to use `--from-repofile=https://...` instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:31:34 +03:00
e7d7e6961f fix(ci): use rpm runner for package and publish jobs
All checks were successful
poll-upstream / check (push) Successful in 1s
poll-upstream / check-prerelease (push) Successful in 2s
The cuda-13.0 container used for build jobs does not have RPM build
tools. Switch package and publish jobs to the rpm runner which includes
rpmdev-setuptree, rpmbuild, and sequoia-sq.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 05:50:11 +03:00
34c9cbb89d fix(ci): restore cancel-in-progress on prerelease builds
All checks were successful
poll-upstream / check (push) Successful in 1s
poll-upstream / check-prerelease (push) Successful in 1s
Hourly polling is sufficient to prevent re-dispatch during builds.
Keep cancel-in-progress: true so stalled builds can be cleared.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 19:04:45 +03:00
e0cb337538 fix(ci): reduce poll frequency to hourly and stop cancelling prerelease builds
The 15-minute poll interval was re-dispatching builds before they could
finish. The prerelease workflow had cancel-in-progress: true which
cancelled the running build each time. Change poll to hourly and set
cancel-in-progress: false on prerelease builds so they run to completion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 19:03:07 +03:00
3e3090802a fix(ci): separate concurrency groups so polling is not blocked by builds
All checks were successful
poll-upstream / check (push) Successful in 3s
poll-upstream / check-prerelease (push) Successful in 2s
poll-upstream and build-release shared the poll-and-build concurrency
group, which caused cron-triggered poll runs to be queued or dropped
while a build was in progress. Give each workflow its own group and
set cancel-in-progress: true for the poller since stale polls are
redundant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 14:53:36 +03:00
c1ffe7e62e fix(ci): use upstream default branch master for prerelease polling
The mistral.rs repo uses master as its default branch, not main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 14:49:35 +03:00
0d6f48fcc0 docs: update readme and CLAUDE.md for per-GPU flavours and prerelease
Replace cuda13 references with ampere/ada/blackwell flavours, add
unstable repo client setup instructions, remove obsolete nvm runner
prerequisites and flavours.yml references.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 14:48:39 +03:00
ace6037a2d feat: replace cuda13 flavour with per-GPU-generation packages
All checks were successful
deploy-ui / build-and-deploy (push) Successful in 44s
Build separate packages for each GPU generation instead of a single
cuda13 package:
- mistralrs-ampere (sm_86, RTX 3060)
- mistralrs-ada (sm_89, RTX 4090)
- mistralrs-blackwell (sm_120, RTX 5090)

All use the same CUDA 13.0 toolkit and features (cuda, cudnn, flash-attn,
nccl), varying only the compute capability target.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 14:45:28 +03:00
661cf574f2 fix(ci): remove nvm dependency from deploy-ui workflow
Some checks failed
deploy-ui / build-and-deploy (push) Successful in 1m0s
poll-upstream / check (push) Successful in 1s
poll-upstream / check-prerelease (push) Failing after 1s
The fedora-43 base image now includes nodejs and npm, so the nvm runner
label and Node.js setup step are no longer needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 14:23:02 +03:00
a79eafd70f feat: add prerelease RPM builds from upstream main branch
Some checks failed
deploy-ui / build-and-deploy (push) Has been cancelled
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) <noreply@anthropic.com>
2026-05-11 14:21:28 +03:00
fff56a626c Revert "fix(ci): clone upstream locally instead of using repo-url for changelog"
All checks were successful
poll-upstream / check (push) Successful in 1s
This reverts commit 23283c375f.
2026-04-28 08:18:15 +03:00
23283c375f fix(ci): clone upstream locally instead of using repo-url for changelog
The rpm-changelog action's repo-url input uses mktemp which fails on
runners with restricted /tmp permissions. Clone the upstream repo
directly in the workflow and use source-dir instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 08:15:57 +03:00
9a316bad2f fix: remove all remaining mistralrs-server references
All checks were successful
deploy-ui / build-and-deploy (push) Successful in 20s
poll-upstream / check (push) Successful in 1s
Drop Obsoletes/Provides for the old package name and update readme
and UI to use mistralrs-cuda13 consistently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:58:37 +03:00
ef7e3a3183 refactor: rename package from mistralrs-server to mistralrs
All checks were successful
deploy-ui / build-and-deploy (push) Successful in 20s
Rename the RPM package from mistralrs-server-<flavour> to
mistralrs-<flavour> and the installed binary from mistralrs-server
to mistralrs, matching the upstream CLI binary name.

Adds Obsoletes/Provides for the old package name so dnf will cleanly
replace it on upgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:53:32 +03:00
3e4191a7d9 feat: switch from deprecated mistralrs-server to mistralrs CLI
Build the mistralrs binary (CLI) instead of the deprecated
mistralrs-server. The RPM still installs as /usr/bin/mistralrs-server
for backwards compatibility. The systemd unit now invokes
`mistralrs-server serve` to use the CLI's serve subcommand.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:50:52 +03:00
b6977eda02 feat(rpm): create mistralrs system user and group on install
All checks were successful
poll-upstream / check (push) Successful in 1s
Add %pre scriptlet to ensure the mistralrs user and group exist
before the package files are installed. The systemd unit runs the
service as this user.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:21:18 +03:00
b8e568b8bf refactor(rpm): simplify to single /usr/bin/mistralrs-server binary
All checks were successful
poll-upstream / check (push) Successful in 1s
Drop the flavour-suffixed binary name, flavour-specific systemd unit
names, and update-alternatives machinery. Install a plain
mistralrs-server binary, a single mistralrs@.service template, and
a default.conf.example. Flavour coexistence was over-engineered for
the target audience.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:14:21 +03:00
755f2175e5 refactor(rpm): install binary to /usr/bin instead of /opt
Install the binary as /usr/bin/mistralrs-server-<flavour> using the
standard %{_bindir} macro. The update-alternatives system manages
the /usr/bin/mistralrs-server symlink as before. Removes the
non-standard /opt/mistralrs/<flavour>/bin/ directory structure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:09:57 +03:00
d9cddb4824 fix: remove explicit author override from changelog action
All checks were successful
poll-upstream / check (push) Successful in 1s
Let the action use its default author rather than misattributing
upstream commits to the package maintainer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:47:18 +03:00
291596cb71 fix: set changelog author and remove hardcoded entry from spec
All checks were successful
poll-upstream / check (push) Successful in 2s
Set the rpm-changelog action author to the actual maintainer instead
of the default "Gitea Actions". Remove the static changelog entry
from the spec since the action generates entries automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:43:36 +03:00
61cdc53e39 fix(rpm): filter auto-detected CUDA library dependencies
The binary's ELF headers cause rpm to auto-require exact soname
versions of CUDA libraries from the build host, forcing downgrades
on systems with newer compatible versions. Filter these out with
__requires_exclude so consumers can use any compatible CUDA install.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:38:04 +03:00
6647ed299d fix: use tee to write repo file instead of dnf config-manager
All checks were successful
deploy-ui / build-and-deploy (push) Successful in 21s
dnf config-manager cannot read from /dev/stdin as a repo file source.
Write the repo file directly with tee instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:33:46 +03:00
10263e4a2b fix: support zstd-compressed repodata in packages.json generator
createrepo_c on Fedora 43 uses zstd compression by default. Detect
the file extension and use zstdcat for .zst files, gzip for .gz.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:30:52 +03:00
de96e7c687 fix: include standard mime.types in nginx config
All checks were successful
poll-upstream / check (push) Successful in 1s
The custom types block replaced all default MIME types, causing
index.html to be served as application/octet-stream. Include the
system mime.types and only add the custom .rpm type on top.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:26:20 +03:00
0ec89de36f fix(ci): remove nginx config deploy from deploy-ui workflow
All checks were successful
deploy-ui / build-and-deploy (push) Successful in 21s
Nginx config is managed by script/setup/nginx.sh, not CI. The
gitea_ci user doesn't have permissions to write to /etc/nginx.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:24:10 +03:00
9f57342810 fix(ci): use --recursive instead of --archive for UI deploy
Some checks failed
deploy-ui / build-and-deploy (push) Failing after 22s
--archive includes -pogDt which tries to set permissions, ownership,
and timestamps on the root-owned web root directory. Only --recursive
and --links are needed for deploying static files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:20:56 +03:00
6dde36080e fix(ci): trigger deploy-ui on changes to its own workflow file
Some checks failed
deploy-ui / build-and-deploy (push) Failing after 21s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:19:22 +03:00
bb76402e5a fix(ci): add --omit-dir-times to UI rsync deploy
The gitea_ci user cannot set timestamps on /var/www/rpm/ which is
owned by root. Directory timestamps are irrelevant for static files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:16:46 +03:00
38a875d06b feat: add nvm setup script for CI runners
All checks were successful
poll-upstream / check (push) Successful in 1s
Installs nvm, Node.js LTS, and creates a stable symlink at
~/.nvm/default_bin for the systemd PATH so actions/checkout@v4
can find node without sourcing .bashrc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:11:38 +03:00
3603c31e21 fix(ci): use explicit NVM_DIR to load nvm in non-interactive shell
Gitea Actions runs steps in a non-interactive shell that does not
source .bashrc. Use the explicit NVM_DIR path to load nvm.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:03:13 +03:00
7f9e857695 feat: add React UI for rpm.lair.cafe
Some checks failed
poll-upstream / check (push) Successful in 1s
deploy-ui / build-and-deploy (push) Failing after 19s
- Vite + React + SWC + TypeScript SPA with react-router and
  react-bootstrap
- Dark/light/system theme with Bootstrap 5.3 data-bs-theme
- Home page with repo setup instructions and copyable code blocks
- Package list and detail pages driven by packages.json
- Python script to generate packages.json from repodata XML
- Nginx config updated for SPA fallback, asset caching, removed
  autoindex
- New deploy-ui workflow triggered on ui/ or nginx config changes,
  requires runners with nvm label
- packages.json generation added to publish job after createrepo_c
- Runner setup docs for nvm and sequoia-sq added to readme

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 12:55:38 +03:00
a6cebc76ba docs: use sudo for repo server commands in rebuild instructions
All checks were successful
poll-upstream / check (push) Successful in 1s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 10:46:11 +03:00
85b78d0c0c docs: add forced rebuild instructions to readme
All checks were successful
poll-upstream / check (push) Successful in 1s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 10:44:50 +03:00
6d3dca17fa feat(ci): generate rpm changelog from upstream mistral.rs commits
Use actions/rpm-changelog@v1 with repo-url to collect commits from
the upstream mistral.rs repo between release tags and prepend a
changelog entry to the spec file before building the RPM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 10:39:14 +03:00
6946682df1 fix(ci): export LIBRARY_PATH for CUDA linker search paths
All checks were successful
poll-upstream / check (push) Successful in 1s
The linker needs LIBRARY_PATH to find -lcudnn at link time.
LD_LIBRARY_PATH only affects runtime library loading.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 10:16:58 +03:00
ff8e5437ef fix(ci): verify repo index consistency in poll-upstream check
All checks were successful
poll-upstream / check (push) Successful in 1s
The RPM file existing on the server is not sufficient — the repo
metadata must also reference it. After checking the file exists,
verify repomd.xml is present and dnf repoquery can find the package
in the index. This catches the case where sync succeeded but
createrepo_c failed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 10:06:58 +03:00
65a265c095 refactor(ci): inline build steps, delete build-binary.sh
Replace build-binary.sh with discrete workflow steps: Build
mistralrs-server, Collect artifacts. Remove commented-out NCCL
check block.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 10:00:22 +03:00
54cffcfe81 fix(ci): move RPM_REPO_HOST to job-level env, remove step duplication
All checks were successful
poll-upstream / check (push) Successful in 0s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:25:26 +03:00
cacdbebbf7 refactor(ci): use RPM_REPO_HOST env var, add SSH connectivity test
- Set RPM_REPO_HOST=oolon.kosherinata.internal as a plain env var
  instead of treating the hostname as a secret via RSYNC_TARGET
- Add explicit SSH connectivity test step using StrictHostKeyChecking=accept-new
- Remove ssh-keyscan in favour of accept-new which provides meaningful errors
- Remove RSYNC_TARGET secret dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:23:54 +03:00
ba5eec78f1 refactor(ci): inline publish steps, delete publish-repo.sh
All checks were successful
poll-upstream / check (push) Successful in 1s
Replace the monolithic publish-repo.sh with discrete workflow steps:
Sign RPMs, Set up SSH, Sync RPMs to repo, Update repo metadata.

Each step now has its own name in the CI UI, making failures
immediately identifiable. Removed 2>/dev/null from ssh-keyscan
which was silently hiding DNS resolution failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:07:54 +03:00
e3c403e98b fix(ci): add progress markers to publish script for debugging
All checks were successful
poll-upstream / check (push) Successful in 1s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 08:49:53 +03:00
087c5d5524 fix(ci): add rpmmacros dump and explicit exit code capture for signing
All checks were successful
poll-upstream / check (push) Successful in 0s
Disable set -e around rpm --addsign to prevent silent exits and
capture the actual exit code and error output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 08:27:07 +03:00
7df736292c fix(ci): use rpm 6 openpgp signing with sequoia-sq
All checks were successful
poll-upstream / check (push) Successful in 1s
RPM 6 on Fedora 43 uses sequoia (sq) for signing instead of gpg.
Replace %_gpg_name with %_openpgp_sign_id and drop the gpg-agent
loopback config. Add a pre-flight check for sequoia-sq.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 07:47:52 +03:00
affc38213e fix(ci): remove gpg sign command override, let rpm-sequoia sign natively
All checks were successful
poll-upstream / check (push) Successful in 1s
Fedora 43 uses rpm-sequoia which does not expand %{__plaintext_filename}
or %{__signature_filename} from %__gpg_sign_cmd. Remove the override
and let rpm-sequoia read the gpg key directly. The key trust and
gpg-agent loopback config are already in place.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 07:31:34 +03:00
9f0116bb2b fix(ci): override rpm sign command to use gpg backend
All checks were successful
poll-upstream / check (push) Successful in 1s
Fedora 43 defaults to rpm-sequoia for signing which ignores the
imported gpg key. Set %__gpg_sign_cmd explicitly to force gpg-based
signing with loopback pinentry. Remove diagnostics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 19:58:07 +03:00
3291f77fcd fix(ci): add gpg and rpmsign diagnostics to isolate signing failure
All checks were successful
poll-upstream / check (push) Successful in 1s
Test gpg signing directly, dump macro expansion, and use rpmsign
with --verbose to get more detail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 19:34:44 +03:00
7575ec6a3c fix(ci): set ultimate trust on imported signing key
All checks were successful
poll-upstream / check (push) Successful in 1s
GPG refuses to sign with a key that has unknown trust. Set the
imported key to ultimate trust after import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 18:32:22 +03:00
ecf38bb53f fix(ci): add diagnostics to signing step
All checks were successful
poll-upstream / check (push) Successful in 1s
Dump rpmmacros, gpg keys, and file permissions before signing to
debug the silent failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 18:14:44 +03:00
93d442d270 fix(ci): capture rpm --addsign output to file for error reporting
All checks were successful
poll-upstream / check (push) Successful in 1s
Direct stdout/stderr capture may miss gpg subprocess output. Write
to a temp file and cat it on failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 17:56:50 +03:00
33aa40ee85 fix(ci): surface rpm --addsign error output in CI logs
All checks were successful
poll-upstream / check (push) Successful in 1s
Capture stderr from rpm --addsign so the actual gpg error is visible
when signing fails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 17:06:40 +03:00
6ffbde4c30 fix(ci): strip trailing slash from RPM_DIR to avoid double slashes
All checks were successful
poll-upstream / check (push) Successful in 1s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 16:55:30 +03:00
e902729ba4 fix(ci): remove rpm --checksig that requires root
All checks were successful
poll-upstream / check (push) Successful in 1s
The rpm keyring import needs root access which CI doesn't have.
Client-side verification on install is sufficient.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 16:11:19 +03:00
bab7d9850c fix(ci): use fedora-43 runner label for all non-build jobs
All checks were successful
poll-upstream / check (push) Successful in 1s
Ensures package, publish, and poll-upstream jobs are picked up by
Fedora 43 runners specifically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 15:32:40 +03:00
70ae2108ee fix(ci): import public key for checksig and force dist tag override
All checks were successful
poll-upstream / check (push) Successful in 1s
Import the GPG public key into rpm's keyring so rpm --checksig can
verify signatures. Also use --undefine dist before --define to ensure
the CLI value overrides the system macro on the build host.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 15:27:40 +03:00
38e36e4547 fix(ci): use gpg-agent loopback instead of custom sign command
All checks were successful
poll-upstream / check (push) Successful in 1s
The custom %__gpg_sign_cmd macro with %{__plaintext_filename} is not
supported on modern rpm. Instead, configure gpg-agent for loopback
pinentry and let rpm use its default sign command.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 15:14:37 +03:00
1919e14032 fix(ci): move rpmmacros to template file
All checks were successful
poll-upstream / check (push) Successful in 1s
The heredoc with column-0 lines inside a YAML block scalar may
confuse Gitea's workflow parser. Move rpmmacros content to
rpm/rpmmacros as a template with sed substitution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 14:54:48 +03:00
94d890b82e fix(ci): remove job-level concurrency group from publish
Gitea may not support matrix expressions in job-level concurrency
groups. The workflow-level concurrency group already prevents
parallel runs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 14:53:12 +03:00
ee693f638c fix(ci): revert runner array syntax unsupported by Gitea
Some checks failed
poll-upstream / check (push) Failing after 1s
Gitea 1.25 does not support array values in matrix includes for
runs-on, causing the dispatch API to return 500. Revert to a single
runner label.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 14:41:00 +03:00
f4e1008684 feat(ci): parameterize fedora version across pipeline
Add fedora_version to build, package, and publish matrices so the
pipeline can target multiple Fedora releases in parallel. Force the
dist tag via --define to ensure RPMs are stamped correctly regardless
of build host. Update poll-upstream to check all fedora/flavour
combinations before triggering a build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 14:36:09 +03:00
0cb6a4f524 fix(ci): use heredoc for rpmmacros to avoid shell escaping issues
The echo-based approach was mangling rpm macro tokens like
%{__plaintext_filename}. Switch to a heredoc so the content is
written verbatim.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 14:25:57 +03:00
4160334bf1 fix(ci): remove duplicate gpg in sign command macro
All checks were successful
poll-upstream / check (push) Successful in 1s
%{__gpg} already expands to /usr/bin/gpg, so the extra "gpg" was
passed as a positional argument causing all flags to be ignored.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 13:48:30 +03:00
0147e0fe32 fix(ci): configure gpg for non-interactive RPM signing
All checks were successful
poll-upstream / check (push) Successful in 1s
Add %__gpg_sign_cmd macro to ~/.rpmmacros with --batch, --no-tty, and
--pinentry-mode loopback so rpm --addsign works without a TTY in CI.

Also add signing progress output and post-sign verification to
publish-repo.sh for easier debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 13:22:33 +03:00
82a04c88dc fix(ci): flatten RPM artifacts before signing
All checks were successful
poll-upstream / check (push) Successful in 1s
Gitea's download-artifact does not support merge-multiple, so RPMs
end up in subdirectories. Add a step to move them into the expected
flat directory before publish-repo.sh runs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 17:05:55 +03:00
75806fd1d9 chore: see if we can squeeze a few more cycles out of the builders 2026-04-24 17:02:52 +03:00
cdf6cdf1e6 docs: add repo readme with setup and usage instructions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 15:05:44 +03:00
937e72bff6 fix(ci): remove stale .rpmmacros before rpmbuild in package job
All checks were successful
poll-upstream / check (push) Successful in 1s
The shared runner retains ~/.rpmmacros from previous publish jobs,
causing a spurious "Macro %_gpg_name has empty body" error during
rpmbuild in the package job.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 14:59:42 +03:00
8ceabed354 feat: add GPG key setup script and generalize nginx GPG key serving
All checks were successful
poll-upstream / check (push) Successful in 2s
Add script/setup/gpg.sh to generate a dedicated lair keyring with a
certify-only master key and a 1-year signing subkey, cross-signed by
both personal keys. The public key is synced to oolon as <short-id>.gpg.

Update nginx config to serve any .gpg file instead of a hardcoded
RPM-GPG-KEY-mistralrs path, supporting multiple keys as the repo grows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 14:43:07 +03:00
e6c2b4e402 fix(ci): prevent poll-upstream from cancelling in-progress builds
All checks were successful
poll-upstream / check (push) Successful in 1s
Poll was firing every minute, dispatching new builds that cancelled
the running one. Restore 15-minute cron interval and add shared
concurrency group across both workflows so new polls queue instead
of re-dispatching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 13:15:34 +03:00
f2ed86bb4d feat(ci): add CARGO_BUILD_JOBS and NVCC_THREADS to build matrix
All checks were successful
poll-upstream / check (push) Successful in 2s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 12:56:25 +03:00
146af6a976 fix (ci): will have to debug this later
All checks were successful
poll-upstream / check (push) Successful in 1s
2026-04-24 12:48:24 +03:00
40cd1a4e76 feat(ci): add NCCL dependency check with install instructions
All checks were successful
poll-upstream / check (push) Successful in 1s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 12:38:07 +03:00
8f0bf3663e fix(ci): add rustup install/update step to build job
All checks were successful
poll-upstream / check (push) Successful in 1s
The gitea runner user on beast doesn't have Rust installed.
Reuses existing installation on subsequent runs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 11:44:42 +03:00
f4e68d41ae refactor(ci): replace dynamic matrix with static includes
All checks were successful
poll-upstream / check (push) Successful in 1s
Gitea Actions doesn't support fromJSON in matrix strategies
(expressions are evaluated before dependent jobs run). Move
flavour definitions into the workflow as static matrix includes
and remove flavours.yml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 11:41:01 +03:00
2785395850 fix(ci): use python yq syntax for flavours parsing
All checks were successful
poll-upstream / check (push) Successful in 1s
The runners have python yq (jq wrapper), not mikefarah/yq (Go).
Replace -o=json -I=0 with --compact-output which is the jq equivalent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 11:28:12 +03:00
adb1683a54 chore(ci): remove debug logging from poll-upstream dispatch
All checks were successful
poll-upstream / check (push) Successful in 1s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 11:22:07 +03:00
ead42ae7b4 fix(ci): use full ref format and Content-Type header for dispatch
All checks were successful
poll-upstream / check (push) Successful in 1s
Gitea API requires refs/heads/main (not just main) and
Content-Type: application/json for the dispatch endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 11:20:06 +03:00
09ca33e0b6 debug(ci): log dispatch URL and response body for 422 diagnosis
All checks were successful
poll-upstream / check (push) Successful in 1s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 11:12:16 +03:00
8cffedd2a9 fix(ci): use PAT for workflow dispatch in poll-upstream
Some checks failed
poll-upstream / check (push) Failing after 1s
The automatic GITEA_TOKEN cannot trigger other workflows. Use a
dedicated DISPATCH_TOKEN secret (personal access token with
repository read/write scope) instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 11:10:29 +03:00
95dec3d652 fix(ci): grant actions write permission to poll-upstream workflow
Some checks failed
poll-upstream / check (push) Failing after 1s
The automatic GITEA_TOKEN lacks actions:write by default, causing
a 422 when dispatching the build-release workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 10:54:09 +03:00
c691687d81 fix(ci): remove --fail from curl in poll-upstream version check
Some checks failed
poll-upstream / check (push) Failing after 1s
--fail causes curl to exit 22 on HTTP errors before --write-out can
capture the response code, breaking the 200/404 branching logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 10:49:24 +03:00
429bcbccb6 fix: evaluate http response code for package existence
Some checks failed
poll-upstream / check (push) Failing after 1s
2026-04-24 10:45:27 +03:00
a598048fd9 fix: nginx deployment syntax errors
Some checks failed
poll-upstream / check (push) Failing after 1s
2026-04-24 10:34:59 +03:00
4025171c75 fix: package name 2026-04-24 09:53:34 +03:00
38 changed files with 3543 additions and 203 deletions

View File

@@ -0,0 +1,229 @@
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: ampere
fedora_version: "43"
runner: cuda-13.0
cuda_home: /usr/local/cuda-13.0
cargo_features: "cuda cudnn flash-attn nccl"
compute_caps: "86"
build_jobs: 12
nvcc_threads: 4
- name: ada
fedora_version: "43"
runner: cuda-13.0
cuda_home: /usr/local/cuda-13.0
cargo_features: "cuda cudnn flash-attn nccl"
compute_caps: "89"
build_jobs: 12
nvcc_threads: 4
- name: blackwell
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: rpm
strategy:
fail-fast: false
matrix:
include:
- name: ampere
fedora_version: "43"
- name: ada
fedora_version: "43"
- name: blackwell
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: rpm
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"

View File

@@ -8,119 +8,214 @@ on:
required: true
type: string
jobs:
plan:
runs-on: fedora
outputs:
flavours: ${{ steps.plan.outputs.flavours }}
version: ${{ steps.plan.outputs.version }}
steps:
- uses: actions/checkout@v4
- id: plan
run: |
version="${TAG#v}"
echo "version=${version}" >> "$GITHUB_OUTPUT"
# Emit flavours as a JSON array for matrix consumption
flavours=$(yq -o=json -I=0 '.flavours' flavours.yml)
echo "flavours=${flavours}" >> "$GITHUB_OUTPUT"
env:
TAG: ${{ inputs.tag }}
concurrency:
group: build-release
cancel-in-progress: false
jobs:
build:
needs: plan
runs-on: cuda-13.0
strategy:
fail-fast: false
matrix:
flavour: ${{ fromJSON(needs.plan.outputs.flavours) }}
include:
- name: ampere
fedora_version: "43"
runner: cuda-13.0
cuda_home: /usr/local/cuda-13.0
cargo_features: "cuda cudnn flash-attn nccl"
compute_caps: "86"
build_jobs: 12
nvcc_threads: 4
- name: ada
fedora_version: "43"
runner: cuda-13.0
cuda_home: /usr/local/cuda-13.0
cargo_features: "cuda cudnn flash-attn nccl"
compute_caps: "89"
build_jobs: 12
nvcc_threads: 4
- name: blackwell
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 tag
run: |
git clone --depth 1 --branch "${{ inputs.tag }}" \
https://github.com/EricLBuehler/mistral.rs.git src/
- name: Build
run: ./script/build-binary.sh
- 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 --locked --features "${{ matrix.cargo_features }}"
env:
FLAVOUR_NAME: ${{ matrix.flavour.name }}
CUDA_HOME: ${{ matrix.flavour.cuda_home }}
CARGO_FEATURES: ${{ matrix.flavour.cargo_features }}
CUDA_COMPUTE_CAP: ${{ matrix.flavour.compute_caps }}
SRC_DIR: src
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-server-${{ matrix.flavour.name }}
path: artifacts/mistralrs-server-${{ matrix.flavour.name }}
name: mistralrs-${{ matrix.name }}-fc${{ matrix.fedora_version }}
path: artifacts/mistralrs-${{ matrix.name }}
retention-days: 1
package:
needs: [plan, build]
runs-on: fedora
needs: build
runs-on: rpm
strategy:
fail-fast: false
matrix:
flavour: ${{ fromJSON(needs.plan.outputs.flavours) }}
include:
- name: ampere
fedora_version: "43"
- name: ada
fedora_version: "43"
- name: blackwell
fedora_version: "43"
steps:
- uses: actions/checkout@v4
#- name: Install build tools
# run: sudo dnf install -y rpm-build rpmdevtools systemd-rpm-macros
- name: Download binary
uses: actions/download-artifact@v3
with:
name: mistralrs-server-${{ matrix.flavour.name }}
name: mistralrs-${{ matrix.name }}-fc${{ matrix.fedora_version }}
path: artifacts/
- name: Determine version
id: version
run: |
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
env:
TAG: ${{ inputs.tag }}
- name: Generate changelog
uses: https://git.lair.cafe/actions/rpm-changelog@v1
with:
spec: rpm/mistralrs.spec
version: ${{ steps.version.outputs.version }}
repo-url: https://github.com/EricLBuehler/mistral.rs.git
- name: Build RPM
run: |
rm -f ~/.rpmmacros
rpmdev-setuptree
cp artifacts/mistralrs-server-${{ matrix.flavour.name }} ~/rpmbuild/SOURCES/
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 ${{ needs.plan.outputs.version }}" \
--define "mistralrs_flavour ${{ matrix.flavour.name }}"
--define "mistralrs_version ${{ steps.version.outputs.version }}" \
--define "mistralrs_flavour ${{ matrix.name }}" \
--undefine dist \
--define "dist .fc${{ matrix.fedora_version }}"
- name: Upload RPM
uses: actions/upload-artifact@v3
with:
name: rpm-${{ matrix.flavour.name }}
name: rpm-${{ matrix.name }}-fc${{ matrix.fedora_version }}
path: ~/rpmbuild/RPMS/x86_64/*.rpm
retention-days: 7
publish:
needs: [plan, package]
runs-on: fedora
# concurrency ensures only one publish runs at a time — repo metadata
# corruption is a nightmare if two createrepo_c processes race.
concurrency:
group: rpm-publish
cancel-in-progress: false
needs: package
runs-on: rpm
env:
RPM_REPO_HOST: oolon.kosherinata.internal
strategy:
fail-fast: false
matrix:
include:
- fedora_version: "43"
steps:
- uses: actions/checkout@v4
#- name: Install tools
# run: sudo dnf install -y createrepo_c rpm-sign rsync
- name: Download all RPMs
- name: Download RPMs for fc${{ matrix.fedora_version }}
uses: actions/download-artifact@v3
with:
path: rpms/
pattern: rpm-*
merge-multiple: true
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
echo "%_gpg_name ${{ secrets.RPM_SIGNING_KEY_ID }}" > ~/.rpmmacros
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 and publish
run: ./script/publish-repo.sh rpms/
- 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_TARGET: ${{ secrets.RSYNC_TARGET }}
RSYNC_SSH_KEY: ${{ secrets.RSYNC_SSH_KEY }}
- name: Test SSH connectivity
run: |
ssh -o StrictHostKeyChecking=accept-new "gitea_ci@${RPM_REPO_HOST}" exit
- name: Sync RPMs to repo
run: |
rsync \
--archive \
--verbose \
--chmod D755,F644 \
rpms/*.rpm \
"gitea_ci@${RPM_REPO_HOST}:/var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64/"
- name: Update repo metadata
run: |
ssh "gitea_ci@${RPM_REPO_HOST}" \
"cd /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64 && createrepo_c --update ."
- name: Generate packages.json
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/repodata \
--output /var/www/rpm/fedora/${{ matrix.fedora_version }}/x86_64/packages.json \
--base-url https://rpm.lair.cafe/fedora/${{ matrix.fedora_version }}/x86_64"

View File

@@ -0,0 +1,53 @@
name: deploy-ui
on:
push:
branches: [main]
paths:
- "ui/**"
- ".gitea/workflows/deploy-ui.yml"
workflow_dispatch: {}
jobs:
build-and-deploy:
runs-on: fedora-43
env:
RPM_REPO_HOST: oolon.kosherinata.internal
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
cd ui
npm ci
- name: Build UI
run: |
cd ui
npm run build
- 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: Deploy UI to web root
run: |
rsync \
--recursive \
--links \
--verbose \
--delete \
--chmod D755,F644 \
--include="index.html" \
--include="*.repo" \
--include="assets/***" \
--exclude="*" \
ui/dist/ \
"gitea_ci@${RPM_REPO_HOST}:/var/www/rpm/"

View File

@@ -2,12 +2,16 @@ name: poll-upstream
on:
schedule:
- cron: "*/15 * * * *"
- cron: "0 * * * *"
workflow_dispatch: {}
concurrency:
group: poll-upstream
cancel-in-progress: true
jobs:
check:
runs-on: fedora
runs-on: fedora-43
steps:
- name: Get upstream latest tag
id: upstream
@@ -19,25 +23,133 @@ jobs:
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "Upstream latest: ${tag}"
- name: Get published version from our repo
- name: Check if all packages are published
id: published
run: |
# Query our own dnf repo. If the version is there, we've already built it.
# Strip leading 'v' because RPM versions don't use it.
version="${UPSTREAM_TAG#v}"
if curl -sSfI "https://rpm.lair.cafe/fedora/43/x86_64/mistralrs-server-cuda13-fa-${version}-1.fc43.x86_64.rpm" | grep -q '^HTTP.*200'; then
echo "already_built=true" >> "$GITHUB_OUTPUT"
else
echo "already_built=false" >> "$GITHUB_OUTPUT"
fi
needs_build=false
for target in "43:ampere" "43:ada" "43:blackwell"; do
fedora_version="${target%%:*}"
flavour="${target##*:}"
base_url="https://rpm.lair.cafe/fedora/${fedora_version}/x86_64"
rpm_name="mistralrs-${flavour}-${version}-1.fc${fedora_version}.x86_64.rpm"
# check that the rpm file exists
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}"
# check that the repo index references this package
if ! curl --silent --fail "${base_url}/repodata/repomd.xml" \
| grep --quiet 'primary'; then
echo "missing or invalid repomd.xml at ${base_url}/repodata/"
needs_build=true
continue
fi
if ! dnf repoquery \
--repofrompath=check,"${base_url}" \
--repo=check \
--quiet \
"mistralrs-${flavour}-${version}" 2>&1 \
| grep --quiet "mistralrs-${flavour}"; then
echo "repo index missing: mistralrs-${flavour}-${version} not in ${base_url}/repodata/"
needs_build=true
continue
fi
echo "indexed: mistralrs-${flavour}-${version} in ${base_url}/repodata/"
done
echo "already_built=$( [ "${needs_build}" = "true" ] && echo false || echo true )" >> "$GITHUB_OUTPUT"
env:
UPSTREAM_TAG: ${{ steps.upstream.outputs.tag }}
- name: Trigger build workflow
if: steps.published.outputs.already_built == 'false'
run: |
curl -sSfL -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H 'Accept: application/json' \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/workflows/build-release.yml/dispatches" \
-d "{\"ref\":\"main\",\"inputs\":{\"tag\":\"${{ steps.upstream.outputs.tag }}\"}}"
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-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/master')
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:ampere" "43:ada" "43:blackwell"; 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 }}\"}}"

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.claude/

View File

@@ -10,46 +10,46 @@ This repo packages [mistral.rs](https://github.com/EricLBuehler/mistral.rs) (a R
### Pipeline flow
1. **poll-upstream** (`.gitea/workflows/poll-upstream.yml`) — cron every 15 min, checks GitHub for latest mistral.rs release tag. If the corresponding RPM doesn't exist on `rpm.lair.cafe`, triggers `build-release`.
1. **poll-upstream** (`.gitea/workflows/poll-upstream.yml`) — cron every 15 min, checks GitHub for latest mistral.rs release tag. If the corresponding RPMs don't exist on `rpm.lair.cafe`, triggers `build-release`. Also checks upstream `main` branch HEAD and triggers `build-prerelease` for the unstable repo.
2. **build-release** (`.gitea/workflows/build-release.yml`) — three-stage pipeline:
- **plan** — reads `flavours.yml`, emits a JSON matrix of flavours + stripped version.
- **build** — runs on a `cuda-13.0` runner. Clones upstream at tag, calls `script/build-binary.sh` to `cargo build --release --locked` with flavour-specific CUDA features.
- **build** — runs on a `cuda-13.0` runner. Clones upstream at tag, runs `cargo build --release --locked` with flavour-specific CUDA features.
- **package** — runs `rpmbuild -bb rpm/mistralrs.spec` with `--define` for version and flavour.
- **publish** — GPG-signs RPMs, rsyncs to `rpm.lair.cafe`, runs `createrepo_c --update`. Uses concurrency group `rpm-publish` to prevent metadata races.
3. **build-prerelease** (`.gitea/workflows/build-prerelease.yml`) — same structure as build-release but clones at a specific commit from `main`, omits `--locked`, uses prerelease release suffix, and publishes to the unstable repo at `rpm.lair.cafe/fedora/$releasever/$basearch/unstable/`.
### Flavours
Defined in `flavours.yml`. Each flavour specifies a name, `cuda_home`, `cargo_features`, and `compute_caps`. The RPM spec uses `update-alternatives` so multiple flavours can coexist, with priority: base=10, fa=20, nccl=30.
Defined in the workflow matrix. Each flavour targets a specific GPU generation using the same CUDA 13.0 toolkit and features (cuda, cudnn, flash-attn, nccl), varying only the compute capability.
| Flavour | Compute cap | GPU generation |
|------------|-------------|---------------------------|
| ampere | sm_86 | RTX 3060, A2000A6000 |
| ada | sm_89 | RTX 40604090, L40 |
| blackwell | sm_120 | RTX 5090, B100, B200 |
### Key files
- `flavours.yml` — flavour matrix definition (drives CI matrix)
- `rpm/mistralrs.spec` — RPM spec (binary-only package, no rebuild)
- `rpm/systemd/mistralrs@.service` — templated systemd unit (`@BINARY@` and `@FLAVOUR@` are sed-replaced during rpmbuild)
- `rpm/systemd/mistralrs@.conf.example` — example env file for instances
- `script/build-binary.sh` — compiles mistralrs-server with cargo (requires `FLAVOUR_NAME`, `CUDA_HOME`, `CARGO_FEATURES`, `CUDA_COMPUTE_CAP`, `SRC_DIR` env vars)
- `script/publish-repo.sh` — signs RPMs and rsyncs to the repo server
- `script/setup/` — one-time infra setup scripts (DNS, TLS cert, nginx) for `rpm.lair.cafe` on host `oolon`
- `script/setup/` — one-time infra setup scripts (DNS, TLS cert, nginx, GPG) for `rpm.lair.cafe` on host `oolon`
## Commands
Build a binary locally (requires CUDA toolkit):
```bash
FLAVOUR_NAME=cuda13 CUDA_HOME=/usr/local/cuda-13.0 CARGO_FEATURES="cuda cudnn flash-attn nccl" CUDA_COMPUTE_CAP=120 SRC_DIR=./src ./script/build-binary.sh
```
Build an RPM from a pre-built binary:
```bash
rpmdev-setuptree
cp artifacts/mistralrs-server-cuda13 ~/rpmbuild/SOURCES/
cp artifacts/mistralrs-ada ~/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 0.7.0" --define "mistralrs_flavour cuda13"
rpmbuild -bb rpm/mistralrs.spec --define "mistralrs_version 0.8.0" --define "mistralrs_flavour ada"
```
## Infrastructure
- CI runs on Gitea Actions (self-hosted), not GitHub Actions
- RPM repo hosted at `rpm.lair.cafe` on host `oolon.kosherinata.internal`
- Stable repo: `rpm.lair.cafe/fedora/$releasever/$basearch/`
- Unstable repo: `rpm.lair.cafe/fedora/$releasever/$basearch/unstable/`
- TLS via Let's Encrypt with Cloudflare DNS challenge
- Publish uses rsync over SSH as `gitea_ci` user

View File

@@ -10,16 +10,14 @@ server {
root /var/www/rpm;
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
types {
application/x-rpm rpm;
application/xml xml;
}
include /etc/nginx/mime.types;
default_type application/octet-stream;
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location ~ \.rpm$ {
expires 30d;
add_header Cache-Control "public, immutable";
@@ -30,7 +28,16 @@ server {
add_header Cache-Control "no-cache, must-revalidate";
}
location = /RPM-GPG-KEY-mistralrs {
location ~ packages\.json$ {
expires 5m;
add_header Cache-Control "public, must-revalidate";
}
location ~ \.gpg$ {
default_type text/plain;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,5 +0,0 @@
flavours:
- name: cuda13
cuda_home: /usr/local/cuda-13.0
cargo_features: "cuda cudnn flash-attn nccl"
compute_caps: "120"

173
readme.md Normal file
View File

@@ -0,0 +1,173 @@
# mistralrs-package
RPM packaging pipeline for [mistral.rs](https://github.com/EricLBuehler/mistral.rs) on Fedora 43 / x86_64 with CUDA support.
This repo does not contain the mistral.rs source. It clones upstream at a given release tag, cross-compiles with CUDA, and produces signed RPMs published to a dnf repo at `rpm.lair.cafe`.
## How it works
Two Gitea Actions workflows drive the pipeline:
1. **poll-upstream** runs every 15 minutes, checks GitHub for the latest mistral.rs release tag, and triggers a build if the corresponding RPM doesn't already exist on `rpm.lair.cafe`. It also checks the upstream `main` branch HEAD and triggers prerelease builds for the unstable repo.
2. **build-release** runs in three stages:
- **build** — clones upstream at the tag and compiles `mistralrs` with flavour-specific CUDA features on a `cuda-13.0` runner.
- **package** — builds an RPM from the compiled binary using `rpmbuild`.
- **publish** — GPG-signs the RPMs, rsyncs them to `rpm.lair.cafe`, and updates the repo metadata with `createrepo_c`.
3. **build-prerelease** — same structure as build-release but clones at a specific commit from `main`, uses versioning from `Cargo.toml` with a prerelease release suffix (e.g. `0.8.1-0.1.20260511git1a2b3c4`), and publishes to the unstable repo.
### Flavours
Build flavours are defined in the workflow matrix. Each flavour targets a specific GPU generation with the same CUDA 13.0 toolkit and features (cuda, cudnn, flash-attn, nccl).
Currently defined:
| Flavour | Compute cap | GPU generation |
|------------|-------------|---------------------------|
| ampere | sm_86 | RTX 3060, A2000A6000 |
| ada | sm_89 | RTX 40604090, L40 |
| blackwell | sm_120 | RTX 5090, B100, B200 |
### Systemd integration
Each RPM installs a templated systemd unit (`mistralrs@.service`). Instances are configured via environment files in `/etc/mistralrs/`:
```bash
# copy the example config
sudo cp /etc/mistralrs/default.conf.example /etc/mistralrs/mymodel.conf
# edit MISTRALRS_ARGS, HF_TOKEN, etc.
sudo systemctl start mistralrs@mymodel
```
## Infrastructure setup
The RPM repo is hosted on `oolon` (oolon.kosherinata.internal) behind nginx with TLS via Let's Encrypt. The setup scripts in `script/setup/` are run once from a dev workstation with SSH access to oolon.
### 1. DNS
```bash
./script/setup/dns.sh
```
Creates a CNAME record for `rpm.lair.cafe` via the Cloudflare API. Requires a Cloudflare API token in `~/.cloudflare/lair.cafe`.
### 2. TLS certificate
```bash
./script/setup/cert.sh
```
Obtains a Let's Encrypt certificate for `rpm.lair.cafe` using the Cloudflare DNS challenge. Run on oolon.
### 3. Nginx and repo directory
```bash
./script/setup/nginx.sh
```
Syncs the nginx config to oolon, creates the `gitea_ci` system user with SSH access for CI publishing, sets up the RPM repo directory at `/var/www/rpm/fedora/43/x86_64`, and reloads nginx. Requires the `gitea_ci` SSH public key at `~/.ssh/id_gitea_ci.pub`.
### 4. GPG signing key
```bash
./script/setup/gpg.sh
```
Manages the RPM signing key in a dedicated keyring at `~/.gnupg/lair`:
- Creates a certify-only ed25519 master key (no expiry) for `rpm@lair.cafe` if one doesn't exist.
- Adds a signing subkey with 1-year expiry.
- Cross-signs the key with your personal keys from the default keyring.
- Exports the public key and syncs it to `oolon:/var/www/rpm/<short-id>.gpg`.
After running the script, add two secrets to the Gitea repo:
| Secret | Value |
|---------------------|-----------------------------------------------------------------------------------------|
| `RPM_SIGNING_KEY` | Output of `gpg --homedir ~/.gnupg/lair --armor --export-secret-subkeys <subkey-fpr>!` |
| `RPM_SIGNING_KEY_ID`| `rpm@lair.cafe` |
The trailing `!` in the export command restricts the export to that specific subkey. Only the signing subkey is shared with CI; the master key stays on the workstation.
#### Rotating the signing subkey
```bash
gpg --homedir ~/.gnupg/lair --quick-add-key <master-fpr> ed25519 sign 1y
```
Then update the `RPM_SIGNING_KEY` secret in Gitea with the new subkey. The public key served to users doesn't change since it's anchored to the master key.
### 5. Runner prerequisites
#### sequoia-sq (for RPM signing)
Runners that run the publish job need `sequoia-sq` installed:
```bash
sudo dnf install sequoia-sq
```
## Client setup
### Stable packages
```bash
sudo rpm --import https://rpm.lair.cafe/<short-id>.gpg
sudo tee /etc/yum.repos.d/lair-cafe.repo > /dev/null <<'EOF'
[lair-cafe]
name=lair.cafe RPM Repository
baseurl=https://rpm.lair.cafe/fedora/$releasever/$basearch/
enabled=1
gpgcheck=1
gpgkey=https://rpm.lair.cafe/<short-id>.gpg
EOF
# install the package for your GPU generation
sudo dnf install mistralrs-ampere # RTX 3000 series
sudo dnf install mistralrs-ada # RTX 4000 series
sudo dnf install mistralrs-blackwell # RTX 5000 series
```
### Unstable (prerelease) packages
Unstable packages are built from the latest upstream `main` commit and published to a separate repo. The RPM release field uses the Fedora snapshot convention (e.g. `0.8.1-0.1.20260511git1a2b3c4.fc43`) so stable releases automatically supersede any installed prerelease.
```bash
sudo tee /etc/yum.repos.d/lair-cafe-unstable.repo > /dev/null <<'EOF'
[lair-cafe-unstable]
name=lair.cafe RPM Repository (unstable)
baseurl=https://rpm.lair.cafe/fedora/$releasever/$basearch/unstable/
enabled=0
gpgcheck=1
gpgkey=https://rpm.lair.cafe/<short-id>.gpg
EOF
# install from unstable on demand
sudo dnf --enablerepo=lair-cafe-unstable install mistralrs-ada
```
## Forcing a rebuild
To force a rebuild of an already-published RPM (e.g. after a packaging change), remove the RPM from the repo server and update the index:
```bash
ssh oolon "
sudo rm /var/www/rpm/fedora/43/x86_64/mistralrs-ada-<version>-1.fc43.x86_64.rpm \
&& cd /var/www/rpm/fedora/43/x86_64 \
&& sudo createrepo_c --update .;
"
```
The next poll-upstream cycle (every 15 minutes) will detect the missing package and trigger a full rebuild. You can also trigger poll-upstream manually from the Gitea Actions UI to avoid waiting.
Do not delete the RPM without running `createrepo_c --update` afterwards — this leaves the repo index referencing a missing file, which causes errors for dnf clients.
## CI secrets
The build-release and build-prerelease workflows require the following secrets:
| Secret | Purpose |
|------------------|----------------------------------------------|
| `DISPATCH_TOKEN` | Gitea API token for triggering builds |
| `RPM_SIGNING_KEY`| ASCII-armored GPG signing subkey |
| `RPM_SIGNING_KEY_ID` | GPG key UID (`rpm@lair.cafe`) |
| `RSYNC_SSH_KEY` | SSH private key for the `gitea_ci` user |

View File

@@ -4,46 +4,45 @@
# Passed in via --define at rpmbuild time
%{!?mistralrs_version: %global mistralrs_version 0.7.0}
%{!?mistralrs_flavour: %global mistralrs_flavour cuda13}
%{!?mistralrs_flavour: %global mistralrs_flavour blackwell}
Name: mistralrs-server-%{mistralrs_flavour}
# 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
URL: https://github.com/EricLBuehler/mistral.rs
# Pre-built binary (produced in the build job, not rebuilt here)
Source0: mistralrs-server-%{mistralrs_flavour}
Source0: mistralrs-%{mistralrs_flavour}
Source1: mistralrs@.service
Source2: mistralrs@.conf.example
ExclusiveArch: x86_64
# Runtime requirements. We link against the CUDA runtime; consumers must have
# a matching CUDA installation or the rpmfusion nvidia driver's cuda-libs.
# We don't hard-require it at the RPM level because consumers may have CUDA
# from multiple sources (nvidia direct, rpmfusion, etc.) — failing to load
# libcuda.so at runtime gives a clearer error than RPM dep resolution would.
# Suppress auto-detected CUDA library dependencies. The binary links against
# the CUDA runtime, cuDNN, NCCL, etc. but we don't want rpm to pin exact
# soname versions — consumers may have CUDA from multiple sources (nvidia
# direct, rpmfusion, etc.) and different compatible versions. A runtime
# dlopen failure gives a clearer error than rpm dep resolution would.
%global __requires_exclude ^lib(cuda|cudart|cudnn|cublas|cublasLt|curand|nvrtc|nccl)
Requires: systemd
# Flavours are mutually exclusive with other flavours of themselves at the
# same install path, but you can install cuda13, cuda13-fa, cuda13-fa-nccl
# side by side — they all get separate /opt paths.
Provides: mistralrs-server = %{version}-%{release}
%description
mistral.rs is a blazingly fast LLM inference engine written in Rust.
This package provides the %{mistralrs_flavour} flavour, built with features:
cuda, cudnn, and optionally flash-attn and nccl depending on flavour name.
Binary installs to /opt/mistralrs/%{mistralrs_flavour}/bin/ and can coexist
with other flavours. Use `update-alternatives --config mistralrs-server` to
select the default /usr/bin/mistralrs-server symlink target.
%prep
# Nothing to unpack; Source0 is the binary itself
cp %{SOURCE0} .
cp %{SOURCE1} .
cp %{SOURCE2} .
@@ -52,49 +51,35 @@ cp %{SOURCE2} .
# Already built
%install
install -D -m 0755 mistralrs-server-%{mistralrs_flavour} \
%{buildroot}/opt/mistralrs/%{mistralrs_flavour}/bin/mistralrs-server
install -D -m 0755 mistralrs-%{mistralrs_flavour} \
%{buildroot}%{_bindir}/mistralrs
install -D -m 0644 mistralrs@.service \
%{buildroot}%{_unitdir}/mistralrs-%{mistralrs_flavour}@.service
%{buildroot}%{_unitdir}/mistralrs@.service
install -D -m 0644 mistralrs@.conf.example \
%{buildroot}%{_sysconfdir}/mistralrs/%{mistralrs_flavour}.conf.example
%{buildroot}%{_sysconfdir}/mistralrs/default.conf.example
# Patch the unit to point at this flavour's binary
sed -i "s|@BINARY@|/opt/mistralrs/%{mistralrs_flavour}/bin/mistralrs-server|g" \
%{buildroot}%{_unitdir}/mistralrs-%{mistralrs_flavour}@.service
# Patch the unit to point at the binary
sed -i "s|@BINARY@|%{_bindir}/mistralrs|g" \
%{buildroot}%{_unitdir}/mistralrs@.service
sed -i "s|@FLAVOUR@|%{mistralrs_flavour}|g" \
%{buildroot}%{_unitdir}/mistralrs-%{mistralrs_flavour}@.service
%{buildroot}%{_unitdir}/mistralrs@.service
%pre
getent group mistralrs >/dev/null || groupadd -r mistralrs
getent passwd mistralrs >/dev/null || useradd -r -g mistralrs -d /var/lib/mistralrs -s /sbin/nologin mistralrs
%post
# Register this flavour as an alternative for /usr/bin/mistralrs-server.
# Priority = 10 for cuda13, 20 for cuda13-fa, 30 for cuda13-fa-nccl so that
# "more featureful" wins by default. Consumers can override with
# `update-alternatives --config mistralrs-server`.
priority=10
case "%{mistralrs_flavour}" in
*nccl*) priority=30 ;;
*fa*) priority=20 ;;
esac
update-alternatives --install /usr/bin/mistralrs-server mistralrs-server \
/opt/mistralrs/%{mistralrs_flavour}/bin/mistralrs-server "${priority}"
%systemd_post mistralrs-%{mistralrs_flavour}@.service
%systemd_post mistralrs@.service
%preun
%systemd_preun mistralrs-%{mistralrs_flavour}@.service
%systemd_preun mistralrs@.service
%postun
if [ $1 -eq 0 ]; then
update-alternatives --remove mistralrs-server \
/opt/mistralrs/%{mistralrs_flavour}/bin/mistralrs-server
fi
%systemd_postun_with_restart mistralrs-%{mistralrs_flavour}@.service
%systemd_postun_with_restart mistralrs@.service
%files
/opt/mistralrs/%{mistralrs_flavour}/
%{_unitdir}/mistralrs-%{mistralrs_flavour}@.service
%{_sysconfdir}/mistralrs/%{mistralrs_flavour}.conf.example
%{_bindir}/mistralrs
%{_unitdir}/mistralrs@.service
%{_sysconfdir}/mistralrs/default.conf.example
%changelog
* Thu Apr 23 2026 Robin Thijssen <grenade@lair.cafe> - %{mistralrs_version}-1
- Automated build for %{mistralrs_flavour} flavour

1
rpm/rpmmacros Normal file
View File

@@ -0,0 +1 @@
%_openpgp_sign_id @GPG_NAME@

View File

@@ -9,7 +9,7 @@ User=mistralrs
Group=mistralrs
SupplementaryGroups=video render
EnvironmentFile=/etc/mistralrs/%i.conf
ExecStart=@BINARY@ $MISTRALRS_ARGS
ExecStart=@BINARY@ serve $MISTRALRS_ARGS
Restart=on-failure
RestartSec=10s

View File

@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
: "${FLAVOUR_NAME:?}"
: "${CUDA_HOME:?}"
: "${CARGO_FEATURES:?}"
: "${CUDA_COMPUTE_CAP:?}"
: "${SRC_DIR:?}"
export PATH="${CUDA_HOME}/bin:${PATH}"
export LD_LIBRARY_PATH="${CUDA_HOME}/targets/x86_64-linux/lib:${CUDA_HOME}/lib64:${LD_LIBRARY_PATH:-}"
cd "${SRC_DIR}"
# --locked ensures Cargo.lock is respected; fails loud if it's out of sync
# rather than silently resolving to different versions.
cargo build --release --locked --features "${CARGO_FEATURES}"
mkdir -p ../artifacts
cp target/release/mistralrs-server "../artifacts/mistralrs-server-${FLAVOUR_NAME}"
# Also grab the other binaries if you want them
cp target/release/mistralrs "../artifacts/mistralrs-${FLAVOUR_NAME}" 2>/dev/null || true
echo "Built $(../artifacts/mistralrs-server-${FLAVOUR_NAME} --version 2>&1 | head -1)"

154
script/generate-packages-json.py Executable file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Parse RPM repodata and emit a packages.json manifest for the UI."""
import argparse
import gzip
import json
import os
import subprocess
import sys
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
RPM_NS = "http://linux.duke.edu/metadata/common"
OTHER_NS = "http://linux.duke.edu/metadata/other"
REPO_NS = "http://linux.duke.edu/metadata/repo"
def find_repodata_file(repodata_dir, data_type):
"""Read repomd.xml and return the path to a specific data type's file."""
repomd_path = os.path.join(repodata_dir, "repomd.xml")
tree = ET.parse(repomd_path)
root = tree.getroot()
for data in root.findall(f"{{{REPO_NS}}}data"):
if data.get("type") == data_type:
location = data.find(f"{{{REPO_NS}}}location")
if location is not None:
href = location.get("href", "")
return os.path.join(os.path.dirname(repodata_dir), href)
return None
def open_compressed(path):
"""Open a gzip or zstd compressed file for reading."""
if path.endswith(".zst"):
result = subprocess.run(
["zstdcat", path], capture_output=True, check=True
)
import io
return io.BytesIO(result.stdout)
else:
return gzip.open(path, "rb")
def parse_primary(repodata_dir):
"""Parse primary.xml.{gz,zst} and return package metadata."""
path = find_repodata_file(repodata_dir, "primary")
if not path:
print("error: primary metadata not found in repomd.xml", file=sys.stderr)
sys.exit(1)
packages = {}
with open_compressed(path) as f:
tree = ET.parse(f)
for pkg in tree.getroot().findall(f"{{{RPM_NS}}}package"):
if pkg.get("type") != "rpm":
continue
name = pkg.findtext(f"{{{RPM_NS}}}name", "")
version_el = pkg.find(f"{{{RPM_NS}}}version")
ver = version_el.get("ver", "") if version_el is not None else ""
rel = version_el.get("rel", "") if version_el is not None else ""
arch = pkg.findtext(f"{{{RPM_NS}}}arch", "")
size_el = pkg.find(f"{{{RPM_NS}}}size")
size = int(size_el.get("package", "0")) if size_el is not None else 0
time_el = pkg.find(f"{{{RPM_NS}}}time")
build_time = int(time_el.get("build", "0")) if time_el is not None else 0
location_el = pkg.find(f"{{{RPM_NS}}}location")
filename = os.path.basename(location_el.get("href", "")) if location_el is not None else ""
key = f"{name}-{ver}-{rel}"
packages[key] = {
"name": name,
"version": ver,
"release": rel,
"arch": arch,
"summary": pkg.findtext(f"{{{RPM_NS}}}summary", ""),
"size": size,
"buildTime": build_time,
"rpmFilename": filename,
"changelog": [],
}
return packages
def parse_other(repodata_dir, packages):
"""Parse other.xml.gz and attach changelog entries to packages."""
path = find_repodata_file(repodata_dir, "other")
if not path:
return
with open_compressed(path) as f:
tree = ET.parse(f)
for pkg in tree.getroot().findall(f"{{{OTHER_NS}}}package"):
name = pkg.get("name", "")
version_el = pkg.find(f"{{{OTHER_NS}}}version")
ver = version_el.get("ver", "") if version_el is not None else ""
rel = version_el.get("rel", "") if version_el is not None else ""
key = f"{name}-{ver}-{rel}"
if key not in packages:
continue
for entry in pkg.findall(f"{{{OTHER_NS}}}changelog"):
packages[key]["changelog"].append({
"author": entry.get("author", ""),
"date": int(entry.get("date", "0")),
"text": (entry.text or "").strip(),
})
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--repodata-dir",
required=True,
help="path to the repodata/ directory",
)
parser.add_argument(
"--output",
required=True,
help="path to write packages.json",
)
parser.add_argument(
"--base-url",
required=True,
help="public base URL for the repo (e.g. https://rpm.lair.cafe/fedora/43/x86_64)",
)
args = parser.parse_args()
packages = parse_primary(args.repodata_dir)
parse_other(args.repodata_dir, packages)
manifest = {
"generated": datetime.now(timezone.utc).isoformat(),
"baseUrl": args.base_url,
"packages": list(packages.values()),
}
with open(args.output, "w") as f:
json.dump(manifest, f, indent=2)
print(f"wrote {len(packages)} packages to {args.output}")
if __name__ == "__main__":
main()

View File

@@ -1,24 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
RPM_DIR="${1:?usage: $0 <rpm-directory>}"
REMOTE_DIR="/var/www/rpm/fedora/43/x86_64"
# sign each rpm with the imported gpg key
for rpm in "${RPM_DIR}"/*.rpm; do
rpm --addsign "${rpm}"
done
install --directory --mode 700 ~/.ssh
echo "${RSYNC_SSH_KEY}" | install --mode 600 /dev/stdin ~/.ssh/id_ed25519
ssh-keyscan -H oolon.kosherinata.internal > ~/.ssh/known_hosts 2>/dev/null
rsync \
--archive \
--verbose \
--chmod D755,F644 \
"${RPM_DIR}/"*.rpm \
"${RSYNC_TARGET}:${REMOTE_DIR}/"
ssh "${RSYNC_TARGET}" "cd ${REMOTE_DIR} && createrepo_c --update ."
echo "Published $(ls ${RPM_DIR}/*.rpm | wc -l) RPMs"

99
script/setup/gpg.sh Executable file
View File

@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail
keyring_dir="${HOME}/.gnupg/lair"
key_uid="rpm@lair.cafe"
remote_host=oolon
remote_key_dir="/var/www/rpm"
signing_keys=(
"1C09AC24C113C7F080DD4AA5B3C5A958508A43F2"
"CF3E5AA5DAFD4A7FB69053E393977688ACF3510F"
)
# ensure the lair keyring directory exists
install --directory --mode 700 "${keyring_dir}"
# check for an existing valid key in the lair keyring
existing_fpr=$(gpg --homedir "${keyring_dir}" --batch --with-colons --list-keys "${key_uid}" 2>/dev/null \
| awk -F: '/^fpr:/ { print $10; exit }') || true
if [ -n "${existing_fpr}" ]; then
echo "found existing key: ${existing_fpr}"
else
echo "no key found for ${key_uid} in ${keyring_dir}, generating..."
# create a certify-only master key
gpg --homedir "${keyring_dir}" --batch --gen-key <<KEYEOF
%no-protection
Key-Type: eddsa
Key-Curve: ed25519
Key-Usage: cert
Name-Real: lair.cafe RPM signing
Name-Email: ${key_uid}
Expire-Date: 0
%commit
KEYEOF
existing_fpr=$(gpg --homedir "${keyring_dir}" --batch --with-colons --list-keys "${key_uid}" \
| awk -F: '/^fpr:/ { print $10; exit }')
echo "generated master key: ${existing_fpr}"
# add a dedicated signing subkey with 1-year expiry
gpg --homedir "${keyring_dir}" --batch --passphrase '' --quick-add-key \
"${existing_fpr}" ed25519 sign 1y
echo "added signing subkey to ${existing_fpr}"
# sign the lair key with each personal key from the default keyring
gpg --homedir "${keyring_dir}" --batch --armor --export "${existing_fpr}" | gpg --import
for signer in "${signing_keys[@]}"; do
gpg --batch --yes --local-user "${signer}" --sign-key "${existing_fpr}"
echo "signed lair key with ${signer}"
done
gpg --armor --export "${existing_fpr}" | gpg --homedir "${keyring_dir}" --import
echo "imported signatures back into lair keyring"
fi
short_id="${existing_fpr: -8}"
short_id_lower=$(echo "${short_id}" | tr '[:upper:]' '[:lower:]')
public_key_file="${short_id_lower}.gpg"
echo ""
echo "key fingerprint: ${existing_fpr}"
echo "short id: ${short_id}"
echo "public key file: ${public_key_file}"
# export the public key in ascii-armored format
gpg --homedir "${keyring_dir}" --batch --armor --export "${existing_fpr}" > "/tmp/${public_key_file}"
echo "exported public key to /tmp/${public_key_file}"
# sync public key to the remote rpm repo root (will not overwrite due to unique filename)
if rsync \
--archive \
--verbose \
--ignore-existing \
--rsync-path 'sudo rsync' \
--chown root:root \
--chmod F644 \
"/tmp/${public_key_file}" \
"${remote_host}:${remote_key_dir}/${public_key_file}"; then
echo "sync'd public key to ${remote_host}:${remote_key_dir}/${public_key_file}"
else
echo "failed to sync public key to ${remote_host}:${remote_key_dir}/${public_key_file}"
exit 1
fi
rm "/tmp/${public_key_file}"
echo ""
signing_subkey_fpr=$(gpg --homedir "${keyring_dir}" --batch --with-colons --list-keys "${key_uid}" \
| awk -F: '/^fpr:/ { fpr=$10 } /^sub:/ { getfpr=1; next } getfpr && /^fpr:/ { print $10; exit }')
echo "next steps:"
echo " 1. add the following secrets to the gitea repo:"
echo " RPM_SIGNING_KEY = output of: gpg --homedir ${keyring_dir} --armor --export-secret-subkeys ${signing_subkey_fpr}!"
echo " RPM_SIGNING_KEY_ID = ${key_uid}"
echo " 2. users can import the key with:"
echo " sudo rpm --import https://rpm.lair.cafe/${public_key_file}"
echo ""
echo " the master key (certify-only, no expiry) stays on this workstation in ${keyring_dir}."
echo " the signing subkey (1-year expiry) is what CI uses. rotate it with:"
echo " gpg --homedir ${keyring_dir} --quick-add-key ${existing_fpr} ed25519 sign 1y"

View File

@@ -15,6 +15,8 @@ if rsync \
--archive \
--compress \
--verbose \
--rsync-path 'sudo rsync' \
--chown root:root \
${nginx_conf_local_path} \
${nginx_host}:${nginx_conf_remote_path}; then
echo "sync'd ${nginx_conf_local_path} to ${nginx_host}:${nginx_conf_remote_path}"
@@ -24,7 +26,7 @@ else
fi
if ssh ${nginx_host} "id gitea_ci &> /dev/null || sudo useradd --system --create-home --home-dir /var/lib/gitea_ci gitea_ci"; then
echo "gitea_ci user created or observed on ${nginx_host}"
if ssh ${nginx_host} "sudo --user gitea_ci install --directory --mode 0700 /var/lib/gitea_ci/.ssh && echo '${gitea_ssh_key}' | sudo --user gitea_ci install --mode 0600 /dev/stdin /var/lib/gitea_ci/.ssh/authorized_keys"; then
if ssh ${nginx_host} "sudo install --directory --owner gitea_ci --group gitea_ci --mode 0700 /var/lib/gitea_ci/.ssh && echo '${gitea_ssh_key}' | sudo sh -c 'install --owner gitea_ci --group gitea_ci --mode 0600 /dev/stdin /var/lib/gitea_ci/.ssh/authorized_keys'"; then
echo "gitea_ci ssh key installed on ${nginx_host}"
else
echo "failed to install gitea_ci ssh key on ${nginx_host}"
@@ -40,7 +42,7 @@ else
echo "failed to create rpm repo directory on ${nginx_host}"
exit 1
fi
if ssh ${nginx_host} "sudo ln -sf ${nginx_conf_remote_path} ${nginx_conf_remote_path/available/enabled} && sudo nginx -t ${nginx_conf_remote_path} && sudo systemctl reload nginx"; then
if ssh ${nginx_host} "sudo ln -sf ${nginx_conf_remote_path} ${nginx_conf_remote_path/available/enabled} && sudo nginx -t && sudo systemctl reload nginx"; then
echo "nginx config reload on ${nginx_host} successful"
else
echo "nginx config reload on ${nginx_host} failed"

54
script/setup/nvm.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Set up nvm and a stable default Node.js for a gitea_runner user on a CI runner.
#
# Usage: ./script/setup/nvm.sh <runner-host>
#
# This script:
# 1. Installs nvm for the gitea_runner user (if not already present)
# 2. Installs the current Node.js LTS version
# 3. Creates a stable symlink at ~/.nvm/default_bin pointing to the
# default node binary directory
# 4. Prints the systemd Environment line needed for the runner unit
#
# After running, add the following to the runner's systemd unit
# (/etc/systemd/system/gitea-action-runner.service):
#
# Environment=PATH=/var/lib/gitea_runner/.nvm/default_bin:/usr/local/sbin:/usr/local/bin:/usr/bin
#
# Then: sudo systemctl daemon-reload && sudo systemctl restart gitea-action-runner
set -euo pipefail
runner_host="${1:?usage: $0 <runner-host>}"
runner_user="gitea_runner"
echo "installing nvm on ${runner_host} for ${runner_user}..."
ssh "${runner_host}" "sudo -u ${runner_user} bash -c '
export NVM_DIR=\"\${HOME}/.nvm\"
if [ -s \"\${NVM_DIR}/nvm.sh\" ]; then
echo \"nvm already installed at \${NVM_DIR}\"
else
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
fi
'"
echo "installing Node.js LTS and creating stable symlink..."
ssh "${runner_host}" "sudo -u ${runner_user} bash -c '
export NVM_DIR=\"\${HOME}/.nvm\"
. \"\${NVM_DIR}/nvm.sh\"
nvm install --lts
nvm alias default lts/*
ln -sfn \"\$(dirname \"\$(nvm which default)\")\" \"\${NVM_DIR}/default_bin\"
echo \"node: \$(node --version)\"
echo \"symlink: \$(readlink -f \"\${NVM_DIR}/default_bin\")\"
'"
echo ""
echo "add the following to /etc/systemd/system/gitea-action-runner.service on ${runner_host}:"
echo ""
echo " Environment=PATH=/var/lib/${runner_user}/.nvm/default_bin:/usr/local/sbin:/usr/local/bin:/usr/bin"
echo ""
echo "then run:"
echo " ssh ${runner_host} 'sudo systemctl daemon-reload && sudo systemctl restart gitea-action-runner'"
echo ""
echo "also add 'nvm' to the runner's labels in /etc/act_runner/config.yml"

24
ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

12
ui/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>rpm.lair.cafe</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1733
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
ui/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.3.0",
"typescript": "~6.0.2",
"vite": "^8.0.10"
},
"dependencies": {
"bootstrap": "^5.3.8",
"react": "^19.2.5",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.5",
"react-router": "^7.14.2"
}
}

View File

@@ -0,0 +1,6 @@
[lair-cafe-unstable]
name=lair.cafe RPM Repository (unstable)
baseurl=https://rpm.lair.cafe/fedora/$releasever/$basearch/unstable/
enabled=0
gpgcheck=1
gpgkey=https://rpm.lair.cafe/8b2023ce.gpg

6
ui/public/lair-cafe.repo Normal file
View File

@@ -0,0 +1,6 @@
[lair-cafe]
name=lair.cafe RPM Repository
baseurl=https://rpm.lair.cafe/fedora/$releasever/$basearch/
enabled=1
gpgcheck=1
gpgkey=https://rpm.lair.cafe/8b2023ce.gpg

25
ui/src/App.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { createBrowserRouter, RouterProvider } from "react-router";
import { ThemeProvider } from "./theme/ThemeContext.tsx";
import { Layout } from "./components/Layout.tsx";
import { Home } from "./pages/Home.tsx";
import { PackageList } from "./pages/PackageList.tsx";
import { PackageDetail } from "./pages/PackageDetail.tsx";
const router = createBrowserRouter([
{
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{ path: "packages", element: <PackageList /> },
{ path: "packages/:name", element: <PackageDetail /> },
],
},
]);
export default function App() {
return (
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
);
}

View File

@@ -0,0 +1,35 @@
import { useState } from "react";
import { Button } from "react-bootstrap";
interface CodeBlockProps {
children: string;
language?: string;
}
export function CodeBlock({ children, language }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(children.trim());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="position-relative">
<pre className="bg-body-tertiary rounded p-3 overflow-auto">
<code className={language ? `language-${language}` : undefined}>
{children.trim()}
</code>
</pre>
<Button
variant="outline-secondary"
size="sm"
className="position-absolute top-0 end-0 m-2"
onClick={copy}
>
{copied ? "Copied" : "Copy"}
</Button>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { Container } from "react-bootstrap";
import { Outlet } from "react-router";
import { NavHeader } from "./NavHeader.tsx";
export function Layout() {
return (
<>
<NavHeader />
<Container as="main" className="pb-5">
<Outlet />
</Container>
</>
);
}

View File

@@ -0,0 +1,37 @@
import { Container, Nav, Navbar } from "react-bootstrap";
import { Link, useLocation } from "react-router";
import { ThemeToggle } from "../theme/ThemeToggle.tsx";
export function NavHeader() {
const location = useLocation();
return (
<Navbar expand="sm" className="border-bottom mb-4">
<Container>
<Navbar.Brand as={Link} to="/">
rpm.lair.cafe
</Navbar.Brand>
<Navbar.Toggle />
<Navbar.Collapse>
<Nav className="me-auto">
<Nav.Link
as={Link}
to="/"
active={location.pathname === "/"}
>
Home
</Nav.Link>
<Nav.Link
as={Link}
to="/packages"
active={location.pathname.startsWith("/packages")}
>
Packages
</Nav.Link>
</Nav>
<ThemeToggle />
</Navbar.Collapse>
</Container>
</Navbar>
);
}

View File

@@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
import type { Channel, PackagesManifest, PackageVersion } from "../types/packages.ts";
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 [packages, setPackages] = useState<PackageVersion[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const fetchManifest = async (
url: string,
channel: Channel,
): Promise<PackageVersion[]> => {
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)
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { packages, loading, error };
}

10
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "bootstrap/dist/css/bootstrap.min.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

91
ui/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,91 @@
import { Card, Col, Row } from "react-bootstrap";
import { CodeBlock } from "../components/CodeBlock.tsx";
const GPG_KEY_URL = "https://rpm.lair.cafe/8b2023ce.gpg";
const REPO_URL = "https://rpm.lair.cafe/lair-cafe.repo";
const UNSTABLE_REPO_URL = "https://rpm.lair.cafe/lair-cafe-unstable.repo";
export function Home() {
return (
<>
<h1 className="mb-3">rpm.lair.cafe</h1>
<p className="lead mb-4">
Self-hosted RPM repository for Fedora, currently hosting
CUDA-accelerated builds of{" "}
<a href="https://github.com/EricLBuehler/mistral.rs">mistral.rs</a>.
</p>
<Row className="g-4">
<Col lg={12}>
<Card>
<Card.Body>
<Card.Title>Quick start</Card.Title>
<h6 className="mt-4">1. Import the signing key</h6>
<CodeBlock language="bash">
{`sudo rpm --import ${GPG_KEY_URL}`}
</CodeBlock>
<h6 className="mt-4">2. Add the repository</h6>
<CodeBlock language="bash">
{`sudo dnf config-manager addrepo --from-repofile=${REPO_URL}`}
</CodeBlock>
<h6 className="mt-4">3. Install a package</h6>
<p className="text-body-secondary">
Choose the package matching your GPU generation:
</p>
<CodeBlock language="bash">
{`# RTX 3000 series (Ampere)\nsudo dnf install mistralrs-ampere\n\n# RTX 4000 series (Ada Lovelace)\nsudo dnf install mistralrs-ada\n\n# RTX 5000 series (Blackwell)\nsudo dnf install mistralrs-blackwell`}
</CodeBlock>
</Card.Body>
</Card>
</Col>
<Col lg={12}>
<Card>
<Card.Body>
<Card.Title>Unstable (prerelease) packages</Card.Title>
<p>
Unstable packages are built automatically from the latest
upstream <code>main</code> branch commit. They use the
next release version from <code>Cargo.toml</code> with a
prerelease suffix (e.g.{" "}
<code>0.8.1-0.1.20260511git1a2b3c4</code>). When the
upstream version is officially released, the stable package
will automatically supersede any installed prerelease.
</p>
<h6 className="mt-4">Add the unstable repository</h6>
<p className="text-body-secondary">
The unstable repo is disabled by default. Add it alongside the
stable repo:
</p>
<CodeBlock language="bash">
{`sudo dnf config-manager addrepo --from-repofile=${UNSTABLE_REPO_URL}`}
</CodeBlock>
<h6 className="mt-4">
Install or update from unstable
</h6>
<CodeBlock language="bash">
{`sudo dnf --enablerepo=lair-cafe-unstable install mistralrs-ada`}
</CodeBlock>
<h6 className="mt-4">
Pin to stable
</h6>
<p className="text-body-secondary">
If you have the unstable repo enabled and want to stay on
stable releases, exclude prerelease versions:
</p>
<CodeBlock language="bash">
{`sudo dnf --disablerepo=lair-cafe-unstable update mistralrs-ada`}
</CodeBlock>
</Card.Body>
</Card>
</Col>
</Row>
</>
);
}

View File

@@ -0,0 +1,115 @@
import { Accordion, Alert, Badge, Spinner, Table } from "react-bootstrap";
import { useParams } from "react-router";
import { usePackages } from "../hooks/usePackages.ts";
import { CodeBlock } from "../components/CodeBlock.tsx";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function PackageDetail() {
const { name } = useParams<{ name: string }>();
const { packages, loading, error } = usePackages();
if (loading) return <Spinner animation="border" />;
if (error) return <Alert variant="danger">Failed to load packages: {error}</Alert>;
if (packages.length === 0) return <Alert variant="info">No package data available.</Alert>;
const versions = packages
.filter((p) => p.name === name)
.sort((a, b) => b.buildTime - a.buildTime);
if (versions.length === 0)
return <Alert variant="warning">Package not found: {name}</Alert>;
const latest = versions[0];
const hasUnstable = versions.some((v) => v.channel === "unstable");
return (
<>
<h1 className="mb-1">{name}</h1>
<p className="text-body-secondary mb-4">{latest.summary}</p>
<CodeBlock language="bash">{`sudo dnf install ${name}`}</CodeBlock>
{hasUnstable && (
<div className="mt-3">
<CodeBlock language="bash">
{`# install latest unstable version\nsudo dnf --enablerepo=lair-cafe-unstable install ${name}`}
</CodeBlock>
</div>
)}
<h2 className="mt-4 mb-3">
Versions <Badge bg="secondary">{versions.length}</Badge>
</h2>
<Table striped hover responsive>
<thead>
<tr>
<th>Version</th>
<th>Channel</th>
<th>Size</th>
<th>Built</th>
<th>Download</th>
</tr>
</thead>
<tbody>
{versions.map((pkg) => (
<tr key={`${pkg.version}-${pkg.release}-${pkg.channel}`}>
<td>
{pkg.version}-{pkg.release}
</td>
<td>
<Badge bg={pkg.channel === "stable" ? "success" : "warning"}>
{pkg.channel}
</Badge>
</td>
<td>{formatBytes(pkg.size)}</td>
<td>{new Date(pkg.buildTime * 1000).toLocaleDateString()}</td>
<td>
<a href={`${pkg.baseUrl}/${pkg.rpmFilename}`}>
{pkg.rpmFilename}
</a>
</td>
</tr>
))}
</tbody>
</Table>
{versions.some((v) => v.changelog.length > 0) && (
<>
<h2 className="mt-4 mb-3">Changelog</h2>
<Accordion>
{versions
.filter((v) => v.changelog.length > 0)
.map((pkg) => (
<Accordion.Item
key={`${pkg.version}-${pkg.release}`}
eventKey={`${pkg.version}-${pkg.release}`}
>
<Accordion.Header>
{pkg.version}-{pkg.release} &mdash;{" "}
{new Date(pkg.buildTime * 1000).toLocaleDateString()}
</Accordion.Header>
<Accordion.Body>
{pkg.changelog.map((entry, i) => (
<div key={i} className="mb-3">
<small className="text-body-secondary">
{new Date(entry.date * 1000).toLocaleDateString()}{" "}
&mdash; {entry.author}
</small>
<pre className="mb-0 mt-1">{entry.text}</pre>
</div>
))}
</Accordion.Body>
</Accordion.Item>
))}
</Accordion>
</>
)}
</>
);
}

View File

@@ -0,0 +1,71 @@
import { Alert, Badge, Spinner, Table } from "react-bootstrap";
import { Link } from "react-router";
import { usePackages } from "../hooks/usePackages.ts";
export function PackageList() {
const { packages, loading, error } = usePackages();
if (loading) return <Spinner animation="border" />;
if (error) return <Alert variant="danger">Failed to load packages: {error}</Alert>;
if (packages.length === 0)
return <Alert variant="info">No packages published yet.</Alert>;
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,
stableCount: stable.length,
unstableCount: unstable.length,
versionCount: versions.length,
};
});
summaries.sort((a, b) => a.name.localeCompare(b.name));
return (
<>
<h1 className="mb-3">Packages</h1>
<Table striped hover responsive>
<thead>
<tr>
<th>Package</th>
<th>Latest version</th>
<th>Versions</th>
<th>Summary</th>
<th>Built</th>
</tr>
</thead>
<tbody>
{summaries.map(({ name, latest, stableCount, unstableCount }) => (
<tr key={name}>
<td>
<Link to={`/packages/${name}`}>{name}</Link>
</td>
<td>
{latest.version}-{latest.release}{" "}
<Badge bg={latest.channel === "stable" ? "success" : "warning"} className="ms-1">
{latest.channel}
</Badge>
</td>
<td>
{stableCount > 0 && (
<Badge bg="success" className="me-1">{stableCount} stable</Badge>
)}
{unstableCount > 0 && (
<Badge bg="warning">{unstableCount} unstable</Badge>
)}
</td>
<td>{latest.summary}</td>
<td>{new Date(latest.buildTime * 1000).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</Table>
</>
);
}

View File

@@ -0,0 +1,74 @@
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
type ThemeChoice = "system" | "light" | "dark";
type ResolvedTheme = "light" | "dark";
interface ThemeContextValue {
choice: ThemeChoice;
resolved: ResolvedTheme;
setChoice: (choice: ThemeChoice) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function resolveSystemTheme(): ResolvedTheme {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function resolve(choice: ThemeChoice): ResolvedTheme {
return choice === "system" ? resolveSystemTheme() : choice;
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [choice, setChoiceState] = useState<ThemeChoice>(() => {
const stored = localStorage.getItem("theme");
return stored === "light" || stored === "dark" ? stored : "system";
});
const [resolved, setResolved] = useState<ResolvedTheme>(() =>
resolve(choice),
);
function setChoice(next: ThemeChoice) {
setChoiceState(next);
if (next === "system") {
localStorage.removeItem("theme");
} else {
localStorage.setItem("theme", next);
}
}
useEffect(() => {
setResolved(resolve(choice));
if (choice !== "system") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => setResolved(resolveSystemTheme());
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [choice]);
useEffect(() => {
document.documentElement.setAttribute("data-bs-theme", resolved);
}, [resolved]);
return (
<ThemeContext.Provider value={{ choice, resolved, setChoice }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}

View File

@@ -0,0 +1,32 @@
import { Dropdown } from "react-bootstrap";
import { useTheme } from "./ThemeContext.tsx";
const options = [
{ key: "light" as const, label: "Light", icon: "☀️" },
{ key: "dark" as const, label: "Dark", icon: "🌙" },
{ key: "system" as const, label: "System", icon: "💻" },
];
export function ThemeToggle() {
const { choice, setChoice } = useTheme();
const current = options.find((o) => o.key === choice) ?? options[2];
return (
<Dropdown align="end">
<Dropdown.Toggle variant="outline-secondary" size="sm">
{current.icon}
</Dropdown.Toggle>
<Dropdown.Menu>
{options.map((o) => (
<Dropdown.Item
key={o.key}
active={o.key === choice}
onClick={() => setChoice(o.key)}
>
{o.icon} {o.label}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
}

27
ui/src/types/packages.ts Normal file
View File

@@ -0,0 +1,27 @@
export interface ChangelogEntry {
author: string;
date: number;
text: string;
}
export type Channel = "stable" | "unstable";
export interface PackageVersion {
name: string;
version: string;
release: string;
arch: string;
summary: string;
size: number;
buildTime: number;
rpmFilename: string;
changelog: ChangelogEntry[];
channel: Channel;
baseUrl: string;
}
export interface PackagesManifest {
generated: string;
baseUrl: string;
packages: PackageVersion[];
}

25
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es2023",
"module": "esnext",
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"jsx": "react-jsx",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
ui/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
export default defineConfig({
plugins: [react()],
base: "/",
});