Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,13 @@ jobs:
name_suffix: -gemma-e2b
models: mlx-community/gemma-4-e2b-it-4bit
test_model: mlx-community/gemma-4-e2b-it-4bit
# LFM2.5 used to be absent here and was fetched by the server mid-test, which
# is how it silently picked up a republished revision whose chat template was
# one brace short of valid and took main red. Prefetching it at a pinned
# revision is what makes this job depend on our code rather than on what
# upstream published that afternoon.
- modality: vision
models: mlx-community/Qwen2-VL-2B-Instruct-4bit
models: mlx-community/Qwen2-VL-2B-Instruct-4bit LiquidAI/LFM2.5-VL-450M-MLX-4bit@10ce3604e42cd595497c47aaf67b7890e1e2a3b4
- modality: audio
models: mlx-community/gemma-4-e4b-it-4bit
- modality: graph
Expand Down
137 changes: 108 additions & 29 deletions .github/workflows/update_dependencies.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
name: Dependency Automation

# Prepares a submodule bump when mlx-swift or mlx-swift-lm cuts a release, and stops
# at a pushed branch rather than opening a pull request.
#
# Opening the PR from a workflow needs a personal access token, because GitHub does
# not start workflow runs for events raised by GITHUB_TOKEN. A bot-opened PR would
# therefore arrive with no checks at all — permanently pending, never green — and this
# repository gates releases on CI concluding successfully (see release.yml). A branch
# is the honest stopping point: opening the PR yourself takes one click, and CI then
# runs normally because the event is yours.
#
# That a human sees the bump before it merges is a feature. Bumps here have needed a
# pointer check, an umbrella build and a smoke test to be trustworthy; the automation
# does the mechanical part and leaves the judgement.

on:
repository_dispatch:
types: [dependency_bump]

permissions:
contents: write
pull-requests: write

jobs:
bump-dependencies:
Expand All @@ -18,34 +31,100 @@ jobs:
submodules: recursive
fetch-depth: 0

- name: Update swift-mlx dependencies
if: ${{ github.event.client_payload.source_repo == 'mlx-swift' }}
# client_payload is attacker-controlled in principle — anything able to dispatch
# to this repository chooses these strings — and they end up in shell and in a
# ref name. Validate them here and pass them onward through the environment
# rather than interpolating ${{ }} into a run block, where a crafted tag would
# be executed rather than compared.
- name: Validate dispatch payload
env:
PAYLOAD_SOURCE_REPO: ${{ github.event.client_payload.source_repo }}
PAYLOAD_NEW_TAG: ${{ github.event.client_payload.new_tag }}
run: |
echo "Bumping mlx-swift dependency to ${{ github.event.client_payload.new_tag }}"
# In Package.swift we depend on branch main, but if we wanted to depend on a tag:
# SwiftPM resolves "main" to the latest commit automatically, but updating the SPM resolved file ensures deterministic builds:
swift package update mlx-swift
set -euo pipefail
case "$PAYLOAD_SOURCE_REPO" in
mlx-swift|mlx-swift-lm) ;;
*)
echo "::error::unexpected source_repo '$PAYLOAD_SOURCE_REPO' — expected mlx-swift or mlx-swift-lm"
exit 1
;;
esac
if ! printf '%s' "$PAYLOAD_NEW_TAG" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$'; then
echo "::error::refusing tag '$PAYLOAD_NEW_TAG' — not a plain tag name"
exit 1
fi
{
echo "SOURCE_REPO=$PAYLOAD_SOURCE_REPO"
echo "NEW_TAG=$PAYLOAD_NEW_TAG"
} >> "$GITHUB_ENV"

- name: Update swift-mlx-lm dependencies
if: ${{ github.event.client_payload.source_repo == 'mlx-swift-lm' }}
# Both dependencies are `.package(path: "./…")` in Package.swift, backed by git
# submodules, so bumping either one is a pointer move. `swift package update`
# does nothing for a path dependency — SwiftPM takes whatever is on disk — which
# is why the mlx-swift branch of the previous version of this workflow could only
# ever have produced an empty commit.
- name: Move submodule to the released tag
run: |
echo "Bumping local mlx-swift-lm submodule to ${{ github.event.client_payload.new_tag }}"
git submodule update --remote mlx-swift-lm
# Force the submodule onto the specific new release tag
cd mlx-swift-lm
git checkout ${{ github.event.client_payload.new_tag }}
cd ..
git add mlx-swift-lm

- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.SWIFTLM_PR_TOKEN }}
commit-message: "chore(deps): bump ${{ github.event.client_payload.source_repo }} to ${{ github.event.client_payload.new_tag }}"
title: "Update ${{ github.event.client_payload.source_repo }} Dependency to ${{ github.event.client_payload.new_tag }}"
body: |
Automated dependency update triggered by release `${{ github.event.client_payload.new_tag }}` in `SharpAI/${{ github.event.client_payload.source_repo }}`.

This PR ensures SwiftLM is tracking the latest validated architectural improvements.
branch: "auto-update/${{ github.event.client_payload.source_repo }}-${{ github.event.client_payload.new_tag }}"
base: main
set -euo pipefail
git -C "$SOURCE_REPO" fetch --tags --force origin
if ! git -C "$SOURCE_REPO" rev-parse -q --verify "refs/tags/${NEW_TAG}^{commit}" >/dev/null; then
echo "::error::tag $NEW_TAG does not exist in $SOURCE_REPO"
exit 1
fi
before=$(git rev-parse "HEAD:$SOURCE_REPO")
git -C "$SOURCE_REPO" checkout --detach "refs/tags/$NEW_TAG"
after=$(git -C "$SOURCE_REPO" rev-parse HEAD)
{
echo "BEFORE_SHA=$before"
echo "AFTER_SHA=$after"
} >> "$GITHUB_ENV"
if [ "$before" = "$after" ]; then
echo "ALREADY_CURRENT=1" >> "$GITHUB_ENV"
fi

- name: Report an already-current submodule and stop
if: env.ALREADY_CURRENT == '1'
run: |
{
echo "### Nothing to bump"
echo
echo "\`$SOURCE_REPO\` is already at \`$NEW_TAG\` (\`${AFTER_SHA:0:7}\`)."
} >> "$GITHUB_STEP_SUMMARY"

- name: Push the bump branch
if: env.ALREADY_CURRENT != '1'
run: |
set -euo pipefail
branch="auto-update/${SOURCE_REPO}-${NEW_TAG}"
git checkout -B "$branch"
git add "$SOURCE_REPO"
git \
-c user.name='github-actions[bot]' \
-c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
commit -m "chore(deps): bump $SOURCE_REPO to $NEW_TAG

Moves the $SOURCE_REPO submodule from ${BEFORE_SHA:0:7} to ${AFTER_SHA:0:7},
the commit tagged $NEW_TAG.

Prepared automatically; opened by hand so that CI runs against it."
# The auto-update/* namespace belongs to this workflow, so replacing a branch
# left by an earlier run for the same tag is safe and keeps re-runs idempotent.
git push --force origin "$branch"
echo "BUMP_BRANCH=$branch" >> "$GITHUB_ENV"

- name: Summarise, with a link that opens the pull request
if: env.ALREADY_CURRENT != '1'
run: |
{
echo "### \`$SOURCE_REPO\` → \`$NEW_TAG\` is ready"
echo
echo "Branch \`$BUMP_BRANCH\` pushed, moving the submodule from"
echo "\`${BEFORE_SHA:0:7}\` to \`${AFTER_SHA:0:7}\`."
echo
echo "**[Open the pull request](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/main...${BUMP_BRANCH}?expand=1)**"
echo
echo "No PR is opened here on purpose: GitHub does not start workflow runs"
echo "for events raised by \`GITHUB_TOKEN\`, so a bot-opened PR would never"
echo "get CI. Opening it yourself gets the checks this repository gates"
echo "releases on."
} >> "$GITHUB_STEP_SUMMARY"
20 changes: 17 additions & 3 deletions scripts/ci-download-models.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,26 @@ has_partial_files() {
}

download_one() {
local repo="$1"
local spec="$1"
# `repo@revision` pins to an immutable commit. Upstream repositories are mutable:
# LiquidAI republished LFM2.5-VL-450M-MLX-4bit with a malformed chat template
# (`{- bos_token -}}`, one brace short), which turned every request into an HTTP 500
# and took main red with no change on our side. A floating tag means CI results
# depend on what a third party did that afternoon.
local repo="${spec%@*}"
local revision=""
if [ "$spec" != "$repo" ]; then
revision="${spec##*@}"
fi
local dir="$HUB_DIR/models--${repo//\//--}"

for attempt in $(seq 1 "$ATTEMPTS"); do
echo "--- $repo (attempt $attempt/$ATTEMPTS, per-request timeout ${HF_HUB_DOWNLOAD_TIMEOUT}s)"
if hf download "$repo"; then
echo "--- $repo${revision:+ @ $revision} (attempt $attempt/$ATTEMPTS, per-request timeout ${HF_HUB_DOWNLOAD_TIMEOUT}s)"
# Spelled out rather than assembling an args array: the runners are macOS, which
# ships bash 3.2, where expanding an empty array under `set -u` is an unbound
# variable error rather than nothing at all. That fails every unpinned download.
if { [ -n "$revision" ] && hf download "$repo" --revision "$revision"; } \
|| { [ -z "$revision" ] && hf download "$repo"; }; then
if has_partial_files "$dir"; then
echo "::warning::$repo downloaded but .incomplete files remain; retrying"
else
Expand Down
31 changes: 30 additions & 1 deletion tests/test-vision.sh
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,37 @@ mkdir -p /tmp/vision_test
# 28x28 black PNG (requires multiple of 28 for Qwen2-VL patch embedder)
BASE64_IMG="iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAIAAAD9b0jDAAAAGUlEQVR4nO3BMQEAAADCoPVPbQdvoAAA6DQJTAABRMAOLAAAAABJRU5ErkJggg=="

# A model id names a moving target. On 2026-08-12 LiquidAI republished
# LFM2.5-VL-450M-MLX-4bit with a chat template one brace short of valid —
# `{- bos_token -}}` where the previous revision had `{{- bos_token -}}` — so every
# request became `parser('Unexpected token type: closeExpression')`, HTTP 500, and main
# went red five minutes later with nothing changed on our side. Restoring that single
# brace locally makes the same revision answer normally, so the fault is upstream, not
# a compatibility gap worth chasing here.
#
# Pinning is what stops a third party's afternoon from deciding whether this repository
# has a green build. Re-point it deliberately, when someone means to test a newer
# revision, and treat the failure that follows as a real result.
LFM_REPO="LiquidAI/LFM2.5-VL-450M-MLX-4bit"
LFM_REVISION="10ce3604e42cd595497c47aaf67b7890e1e2a3b4"

# Resolve to the pinned snapshot on disk. Falling back to the bare repo id keeps a local
# `./tests/test-vision.sh` working without a prefetch, but says so — a run that quietly
# tested a different revision than CI did is worse than one that took a moment longer.
pinned_snapshot() {
local repo="$1" revision="$2"
local hub="${HF_HUB_CACHE:-${HF_HOME:-$HOME/.cache/huggingface}/hub}"
local dir="$hub/models--${repo//\//--}/snapshots/$revision"
if [ -d "$dir" ]; then
echo "$dir"
else
log "note: pinned revision ${revision:0:7} of $repo is not in the cache; using the floating id"
echo "$repo"
fi
}

run_case "mlx-community/Qwen2-VL-2B-Instruct-4bit" "$BASE_PORT" "yes"
run_case "LiquidAI/LFM2.5-VL-450M-MLX-4bit" "$((BASE_PORT + 1))" "yes"
run_case "$(pinned_snapshot "$LFM_REPO" "$LFM_REVISION")" "$((BASE_PORT + 1))" "yes"

rm -rf /tmp/vision_test
exit 0
Loading