| name | afm-build-promote-nightly |
| description | Use when promoting afm to a stable release — builds from main HEAD or a nightly commit, verifies patches, updates Homebrew stable tap (afm.rb), builds a PyPI wheel, updates README and version files, and verifies both brew install and pip install work. Repo admin only. |
Promote AFM to Stable Release
Build a stable release from either the current main branch HEAD or a specific nightly commit. Updates BuildInfo.swift with the stable version, rebuilds with verified patches, then publishes to GitHub, Homebrew (afm.rb), and PyPI.
Repo admin only (scouzi1966).
Usage
/afm-build-promote-nightly — build stable release (choose source interactively)
/afm-build-promote-nightly 0.9.7 — build stable with explicit version
Instructions
Step 1: Verify Admin Access
gh auth status
GH_USER=$(gh api user -q .login)
echo "GitHub user: $GH_USER"
gh api repos/scouzi1966/maclocal-api -q '.permissions.push'
gh api repos/scouzi1966/homebrew-afm -q '.permissions.push'
# Tap repo — auto-clone if missing
TAP_DIR="${TAP_DIR:-$(cd "$(git rev-parse --show-toplevel)/.." && pwd)/homebrew-afm}"
if [ ! -d "$TAP_DIR/.git" ]; then
echo "Tap repo missing — cloning..."
gh repo clone scouzi1966/homebrew-afm "$TAP_DIR"
fi
test -f "$TAP_DIR/afm.rb" && echo "Tap OK" || echo "FAILED"
If GH_USER is not scouzi1966 or push access is false for either repo, STOP immediately:
This skill publishes stable releases for scouzi1966/maclocal-api.
Only the repository owner (scouzi1966) can run it. You are authenticated as: <username>
Step 2: Choose Build Source
List recent nightly releases for reference:
# Show current main HEAD
echo "main HEAD: $(git rev-parse --short HEAD) — $(git log -1 --format='%s')"
# List recent nightlies
gh release list --repo scouzi1966/maclocal-api --limit 10 --json tagName,name,publishedAt,isPrerelease \
-q '.[] | select(.isPrerelease) | "\(.tagName)\t\(.name)\t\(.publishedAt)"'
Use AskUserQuestion:
Question: "Build stable release from which source?"
Options:
- "main HEAD" — build from the current tip of main (most common)
- "A nightly release" — build from a specific nightly tag's commit
- "A specific commit" — enter a commit SHA
If "main HEAD": Set BUILD_SHA=$(git rev-parse HEAD)
If "A nightly release": Show the nightly list and ask user to pick one, then:
NIGHTLY_TAG="<selected tag>"
BUILD_SHA=$(git rev-list -n 1 "$NIGHTLY_TAG" 2>/dev/null || \
gh release view "$NIGHTLY_TAG" --repo scouzi1966/maclocal-api --json targetCommitish -q '.targetCommitish')
If "A specific commit": User provides the SHA, set BUILD_SHA=<user input>
echo "Build source commit: $BUILD_SHA"
echo "Commit message: $(git log -1 --format='%s' $BUILD_SHA)"
Step 3: Determine Stable Version
If version was provided as argument, use it. Otherwise derive from BuildInfo.swift:
BASE_VERSION=$(grep 'static let version' Sources/AFMKit/BuildInfo.swift \
| sed 's/.*"\(.*\)".*/\1/' | sed 's/^v//')
Use AskUserQuestion:
Question: "Stable version? Derived base version is X.Y.Z."
Options:
- "
X.Y.Z (derived)" — use as-is
- "Custom version" — enter a different version
Save as VERSION. Set TAG="v${VERSION}".
Check stable tag doesn't already exist:
if gh release view "v${VERSION}" --repo scouzi1966/maclocal-api >/dev/null 2>&1; then
echo "ERROR: Release v${VERSION} already exists!"
fi
If it exists, STOP and ask the user to choose a different version.
Step 3b: Capture Rollback State
Before any mutations, snapshot the current state so the entire promotion can be reverted (except PyPI, which is immutable).
cd "$(git rev-parse --show-toplevel)"
# Previous stable version (for tap rollback)
PREV_STABLE_VERSION=$(grep 'version "' "$TAP_DIR/afm.rb" | head -1 | sed 's/.*"\(.*\)".*/\1/')
PREV_STABLE_URL=$(grep 'url "' "$TAP_DIR/afm.rb" | head -1 | sed 's/.*"\(.*\)".*/\1/')
PREV_STABLE_SHA256=$(grep 'sha256 "' "$TAP_DIR/afm.rb" | head -1 | sed 's/.*"\(.*\)".*/\1/')
# Previous version file values
PREV_BUILDINFO_VERSION=$(grep 'static let version' Sources/AFMKit/BuildInfo.swift | sed 's/.*"\(.*\)".*/\1/')
PREV_PYPROJECT_VERSION=$(grep '^version = ' pyproject.toml | sed 's/.*"\(.*\)".*/\1/')
PREV_INIT_VERSION=$(grep '^__version__' macafm/__init__.py | sed 's/.*"\(.*\)".*/\1/')
# Previous README version
PREV_README_STABLE=$(grep -o 'Stable (v[^)]*)' README.md | head -1)
# Tap commit before our changes (production)
PREV_TAP_COMMIT=$(cd "$TAP_DIR" && git rev-parse HEAD)
# Staging tap commit before our changes (used in Step 8b)
STAGING_DIR="${STAGING_TAP_DIR:-$(cd "$(git rev-parse --show-toplevel)/.." && pwd)/homebrew-afm-staging}"
if [ -d "$STAGING_DIR/.git" ]; then
PREV_STAGING_COMMIT=$(cd "$STAGING_DIR" && git rev-parse HEAD)
else
PREV_STAGING_COMMIT="(repo not yet cloned — will be created in Step 8b)"
fi
echo "=== Rollback snapshot ==="
echo "Tap version: $PREV_STABLE_VERSION"
echo "Tap commit: $PREV_TAP_COMMIT"
echo "Staging commit: $PREV_STAGING_COMMIT"
echo "BuildInfo: $PREV_BUILDINFO_VERSION"
echo "pyproject: $PREV_PYPROJECT_VERSION"
echo "__init__: $PREV_INIT_VERSION"
echo "README: $PREV_README_STABLE"
Report the rollback snapshot to the user so they have it on record.
Step 4: Checkout Nightly Commit and Update Version
CRITICAL: The stable build must use the chosen commit (BUILD_SHA), with only BuildInfo.swift changed to carry the stable version.
cd "$(git rev-parse --show-toplevel)"
# Save current branch to return later
ORIGINAL_BRANCH=$(git branch --show-current)
# Stash any uncommitted changes
git stash --include-untracked 2>/dev/null || true
# Checkout the chosen commit
git checkout "$BUILD_SHA"
# Update ONLY BuildInfo.swift with stable version
sed -i '' "s/static let version: String? = \".*\"/static let version: String? = \"v${VERSION}\"/" \
Sources/AFMKit/BuildInfo.swift
# Verify the change
grep 'static let version' Sources/AFMKit/BuildInfo.swift
# Must show: static let version: String? = "vX.Y.Z"
Step 5: Verify Patches and Rebuild
CRITICAL FAIL-SAFE: Before building, verify that ALL vendor patches are correctly applied. The build MUST use our patched files, not upstream originals.
cd "$(git rev-parse --show-toplevel)"
# Step 5a: Initialize submodules at the exact state for this commit
git submodule update --init --recursive
# Step 5b: Apply patches
./Scripts/apply-mlx-patches.sh
# Step 5c: FAIL-SAFE — Verify every patch file matches its vendor target
./Scripts/apply-mlx-patches.sh --check
PATCH_CHECK=$?
if [ $PATCH_CHECK -ne 0 ]; then
echo "FATAL: Patch verification FAILED. Aborting build."
echo "One or more vendor files do not match Scripts/patches/."
echo "This means the build would use unpatched vendor code."
fi
If --check exits non-zero, STOP IMMEDIATELY. Do NOT proceed with the build. Report the failing patches to the user and investigate.
Additional per-file verification — confirm critical patched files byte-for-byte:
# Verify the most critical patches individually
PATCHES_DIR="$(git rev-parse --show-toplevel)/Scripts/patches"
VENDOR_DIR="$(git rev-parse --show-toplevel)/vendor/mlx-swift-lm"
CRITICAL_PATCHES=(
"Evaluate.swift:Libraries/MLXLMCommon/Evaluate.swift"
"LLMModelFactory.swift:Libraries/MLXLLM/LLMModelFactory.swift"
"ToolCallFormat.swift:Libraries/MLXLMCommon/Tool/ToolCallFormat.swift"
"Load.swift:Libraries/MLXLMCommon/Load.swift"
"KVCache.swift:Libraries/MLXLMCommon/KVCache.swift"
)
ALL_OK=true
for entry in "${CRITICAL_PATCHES[@]}"; do
PATCH_NAME="${entry%%:*}"
TARGET_REL="${entry##*:}"
if ! diff -q "$PATCHES_DIR/$PATCH_NAME" "$VENDOR_DIR/$TARGET_REL" >/dev/null 2>&1; then
echo "MISMATCH: $PATCH_NAME != $TARGET_REL"
ALL_OK=false
else
echo "OK: $PATCH_NAME"
fi
done
if [ "$ALL_OK" = false ]; then
echo "FATAL: Critical patch mismatch detected. Aborting."
fi
If any critical patch fails diff, STOP. Do not build.
Step 5d: Build WebUI assets (MANDATORY):
The WebUI (-w flag) is a core feature. Every release MUST include Resources/webui/index.html.gz. This asset comes from the llama.cpp submodule and must be built or copied before packaging.
cd "$(git rev-parse --show-toplevel)"
# Build webui from llama.cpp submodule
WEBUI_DIR="vendor/llama.cpp/tools/server/webui"
WEBUI_PREBUILT="vendor/llama.cpp/tools/server/public/index.html.gz"
if [ -f "$WEBUI_PREBUILT" ]; then
# Pre-built webui exists in submodule — copy it
mkdir -p Resources/webui
cp "$WEBUI_PREBUILT" Resources/webui/index.html.gz
echo "WebUI: copied pre-built from llama.cpp submodule"
elif [ -d "$WEBUI_DIR" ] && command -v node >/dev/null 2>&1; then
# Build from source
cd "$WEBUI_DIR" && npm install && npm run build && cd "$(git rev-parse --show-toplevel)"
mkdir -p Resources/webui
cp vendor/llama.cpp/tools/server/public/index.html.gz Resources/webui/index.html.gz
echo "WebUI: built from source"
else
echo "FATAL: Cannot build or find WebUI assets."
echo "Neither $WEBUI_PREBUILT nor $WEBUI_DIR with Node.js available."
fi
# MANDATORY CHECK — release MUST have webui
if [ ! -f "Resources/webui/index.html.gz" ]; then
echo "FATAL: Resources/webui/index.html.gz is MISSING. Cannot release without WebUI."
else
echo "WebUI OK: $(ls -lh Resources/webui/index.html.gz | awk '{print $5}')"
fi
If Resources/webui/index.html.gz does not exist, STOP IMMEDIATELY. Do NOT proceed with the build. The WebUI is a core feature and every release must include it.
Step 5e: Build for release:
# Clean and build release
swift package clean
swift build -c release
Step 5f: Post-build version verification:
BIN=".build/arm64-apple-macosx/release/afm"
[ -x "$BIN" ] || BIN=".build/release/afm"
# The binary MUST report the stable version
REPORTED_VERSION=$($BIN --version 2>&1)
echo "Binary reports: $REPORTED_VERSION"
if ! echo "$REPORTED_VERSION" | grep -qF "v${VERSION}"; then
echo "FATAL: Binary reports '$REPORTED_VERSION' but expected 'v${VERSION}'"
echo "BuildInfo.swift was not picked up by the build."
fi
If the binary version doesn't match v${VERSION}, STOP. The build did not incorporate the version change.
Step 5g: Relocated binary check (MANDATORY — blocks pip/Homebrew install crash):
SPM auto-generates resource_bundle_accessor.swift with a hardcoded absolute build path. If any code calls Bundle.module, the binary will fatalError when relocated (pip install, Homebrew). This has shipped broken releases before. This check is non-negotiable.
# 1. Source code audit — Bundle.module must NEVER appear in non-comment source
HITS=$(grep -r 'Bundle\.module' Sources/ --include='*.swift' | grep -v '^\s*//' | grep -v '// ' | wc -l | tr -d ' ')
if [ "$HITS" -gt 0 ]; then
echo "FATAL: Found $HITS Bundle.module call(s) — will crash on pip/Homebrew install"
grep -rn 'Bundle\.module' Sources/ --include='*.swift' | grep -v '//'
echo "STOP. Remove all Bundle.module calls before proceeding."
exit 1
fi
echo "PASS: No Bundle.module calls in source"
# 2. Runtime simulation — copy binary + loose metallib to temp dir (NO SPM bundle)
TMPDIR=$(mktemp -d)
cp "$BIN" "$TMPDIR/"
cp "$(dirname "$BIN")/AFMKit_AFMKitMLX.bundle/default.metallib" "$TMPDIR/"
MACAFM_MLX_MODEL_CACHE=/Volumes/edata/models/vesta-test-cache \
"$TMPDIR/afm" mlx -m mlx-community/Qwen3.5-35B-A3B-4bit -s "hello" --max-tokens 5 2>&1 | head -3
EXIT_CODE=${PIPESTATUS[0]}
rm -rf "$TMPDIR"
if [ "$EXIT_CODE" -ne 0 ]; then
echo "FATAL: Relocated binary crashed (exit $EXIT_CODE)"
echo "This means pip install and Homebrew install will crash on first run."
echo "STOP. Fix MLXMetalLibrary.swift before proceeding."
exit 1
fi
echo "PASS: Relocated binary runs without crash"
If either check fails, STOP IMMEDIATELY. Do NOT package, publish, or release. Every pip and Homebrew user will get a crash on first run.
Step 5h: Info.plist embedding check (MANDATORY — blocks Speech Recognition SIGABRT):
macOS 26 SIGABRTs any process that requests privacy-sensitive APIs (Speech Recognition, microphone, camera, etc.) without the matching *UsageDescription key in its embedded Info.plist. Required for afm speech, POST /v1/audio/transcriptions, and chat input_audio content parts. Any future privacy-API integration needs its *UsageDescription key added here too.
# 1. Verify __TEXT,__info_plist section is present in the binary
if ! otool -l "$BIN" | grep -q '__info_plist'; then
echo "FATAL: Missing __TEXT,__info_plist section in binary"
echo "Check Package.swift linker flags (-Xlinker -sectcreate ...) and Sources/AFMCLI/Info.plist"
exit 1
fi
# 2. Verify NSSpeechRecognitionUsageDescription key is embedded
if ! strings "$BIN" | grep -q 'NSSpeechRecognitionUsageDescription'; then
echo "FATAL: NSSpeechRecognitionUsageDescription missing from embedded plist"
echo "afm speech / /v1/audio/transcriptions will SIGABRT on macOS 26"
exit 1
fi
# 3. Verify Info.plist source file is well-formed
plutil -lint Sources/AFMCLI/Info.plist || { echo "FATAL: Info.plist is malformed"; exit 1; }
# 4. Verify Apple frameworks that require the embedded plist are actually linked.
# PR #104 (Vision OCR) and PR #107 (Speech transcription) depend on these — if they
# drop out of Package.swift the plist is pointless because the APIs can't be called.
REQUIRED_FRAMEWORKS=(Speech Vision PDFKit ImageIO)
FW_MISSING=()
for FW in "${REQUIRED_FRAMEWORKS[@]}"; do
if ! otool -L "$BIN" | grep -q "/${FW}.framework/"; then
FW_MISSING+=("$FW")
fi
done
if [ ${#FW_MISSING[@]} -gt 0 ]; then
echo "FATAL: Binary missing required framework links: ${FW_MISSING[*]}"
echo "Check Package.swift .linkedFramework(...) entries — PR #104/#107 features will not work."
exit 1
fi
# 5. Verify CFBundleIdentifier (TCC identity) is embedded. Changing this bundle id
# forces every user to re-grant Speech/mic/camera permission — treat as a contract.
if ! strings "$BIN" | grep -q 'com.scouzi1966.afm'; then
echo "FATAL: Embedded plist missing CFBundleIdentifier=com.scouzi1966.afm (TCC identity broken)"
exit 1
fi
echo "PASS: Info.plist embedded with NSSpeechRecognitionUsageDescription"
echo "PASS: Required frameworks linked: ${REQUIRED_FRAMEWORKS[*]}"
echo "PASS: CFBundleIdentifier present in embedded plist"
If this fails, STOP. Shipping a stable release with broken Speech Recognition is worse than shipping a nightly — stable users have higher expectations.
Note on CFBundleIdentifier in Info.plist: The CFBundleIdentifier (com.scouzi1966.afm) establishes the TCC identity. Changing it later forces every user to re-grant Speech Recognition / microphone / camera permission. Treat it as a stable contract across releases.
Step 6: Package Stable Tarball
cd "$(git rev-parse --show-toplevel)"
BIN=".build/arm64-apple-macosx/release/afm"
[ -x "$BIN" ] || BIN=".build/release/afm"
STAGING=".build/release-package-stable"
rm -rf "$STAGING"
mkdir -p "$STAGING"
cp "$BIN" "$STAGING/"
# Provider resource bundles
for BUNDLE_NAME in AFMKit_AFMKitMLX.bundle AFMKit_AFMKitDwarfStar.bundle; do
BUNDLE_DIR="$(dirname "$BIN")/$BUNDLE_NAME"
test -d "$BUNDLE_DIR"
cp -r "$BUNDLE_DIR" "$STAGING/"
done
# WebUI (MANDATORY — verified in Step 5d)
mkdir -p "$STAGING/Resources/webui"
cp "Resources/webui/index.html.gz" "$STAGING/Resources/webui/"
cp README.md "$STAGING/" 2>/dev/null || true
cp LICENSE "$STAGING/" 2>/dev/null || true
STABLE_TARBALL="afm-v${VERSION}-arm64.tar.gz"
tar -czf "$STABLE_TARBALL" -C "$STAGING" .
SHA256=$(shasum -a 256 "$STABLE_TARBALL" | cut -d' ' -f1)
echo "Tarball: $STABLE_TARBALL ($(du -h "$STABLE_TARBALL" | cut -f1 | xargs))"
echo "SHA256: $SHA256"
# MANDATORY: Verify tarball contains webui
if ! tar tzf "$STABLE_TARBALL" | grep -q "index.html.gz"; then
echo "FATAL: Tarball does NOT contain WebUI! Aborting."
fi
Step 7: Return to Original Branch
cd "$(git rev-parse --show-toplevel)"
# Return to original branch
git checkout "$ORIGINAL_BRANCH"
git stash pop 2>/dev/null || true
Now apply version changes to the working branch (Steps 8-9 operate on the current branch, not the detached HEAD).
Step 8: Create Stable GitHub Release
cd "$(git rev-parse --show-toplevel)"
PREV_TAG=$(gh release list --repo scouzi1966/maclocal-api --limit 20 --json tagName -q '.[].tagName' \
| grep '^v[0-9]' | head -1) || true
if [ -n "$PREV_TAG" ] && git cat-file -e "${PREV_TAG}^{commit}" 2>/dev/null; then
CHANGELOG=$(git log --pretty=format:"- %s (\`%h\`)" "${PREV_TAG}..${BUILD_SHA}" -- . ':!vendor' 2>/dev/null)
else
CHANGELOG=$(git log --pretty=format:"- %s (\`%h\`)" -20 -- . ':!vendor' 2>/dev/null)
fi
gh release create "v${VERSION}" \
--title "afm ${VERSION}" \
--notes "$(cat <<EOF
## afm ${VERSION}
Apple Foundation Models + MLX local models — OpenAI-compatible API, WebUI, all Swift.
### Changes since ${PREV_TAG:-first release}
${CHANGELOG}
### Install / Upgrade via Homebrew
**Fresh install:**
\`\`\`
brew tap scouzi1966/afm
brew install scouzi1966/afm/afm
\`\`\`
**Upgrade:**
\`\`\`
brew upgrade afm
\`\`\`
### Install via PyPI
\`\`\`
pip install macafm==${VERSION}
\`\`\`
EOF
)" \
--target "$BUILD_SHA" \
--repo scouzi1966/maclocal-api \
"$STABLE_TARBALL"
Note: --target "$BUILD_SHA" ensures the release points to the exact commit used for the build.
IMPORTANT: Do NOT delete, edit, or modify any existing nightly releases. Nightly releases and their nightly-* tags must remain intact on GitHub. This skill creates a NEW stable release — it does not replace any existing release.
Step 8b: Publish to Staging Tap and Validate on a Clean Machine (MANDATORY)
DO NOT update the production tap (scouzi1966/afm) until the staging validation completes. The production tap is what every brew install scouzi1966/afm/afm user downloads. Shipping a broken build to it forces a revert — and any user who ran brew update in the broken window already has the bad bottle. Validate on the staging tap first so the real install path is tested against the real published artifact.
The staging tap repo is scouzi1966/homebrew-afm-staging. If it does not exist, create it (public, visible only via explicit tap):
if ! gh repo view scouzi1966/homebrew-afm-staging >/dev/null 2>&1; then
gh repo create scouzi1966/homebrew-afm-staging --public \
--description "Staging Homebrew tap for afm — candidate builds under test, not for production"
fi
STAGING_DIR="${STAGING_TAP_DIR:-$(cd "$(git rev-parse --show-toplevel)/.." && pwd)/homebrew-afm-staging}"
if [ ! -d "$STAGING_DIR/.git" ]; then
gh repo clone scouzi1966/homebrew-afm-staging "$STAGING_DIR"
fi
Write the candidate formula into the staging tap. Use the same URL / version / sha256 as the GitHub release created in Step 8 so the download path matches what the production tap will later serve.
DOWNLOAD_URL="https://github.com/scouzi1966/maclocal-api/releases/download/v${VERSION}/afm-v${VERSION}-arm64.tar.gz"
cd "$STAGING_DIR"
git pull --ff-only 2>/dev/null || true
cat > afm.rb <<RUBY
class Afm < Formula
desc "Apple Foundation Models + MLX local models — OpenAI-compatible API, WebUI, all Swift (STAGING)"
homepage "https://github.com/scouzi1966/maclocal-api"
url "${DOWNLOAD_URL}"
version "${VERSION}"
sha256 "${SHA256}"
depends_on arch: :arm64
depends_on :macos
def install
bin.install "afm"
["AFMKit_AFMKitMLX.bundle", "AFMKit_AFMKitDwarfStar.bundle"].each do |bundle_dir|
(libexec/bundle_dir).mkpath
(libexec/bundle_dir).install Dir["#{bundle_dir}/*"]
bin.install_symlink libexec/bundle_dir
end
if Dir.exist?("Resources/webui")
(share/"afm/webui").mkpath
(share/"afm/webui").install Dir["Resources/webui/*"]
end
end
def caveats
<<~EOS
⚠️ STAGING BUILD — not for production use.
This is v${VERSION} served from a staging tap for validation before
promotion to the main scouzi1966/afm tap. Switch to the main tap
for stable use:
brew untap scouzi1966/afm-staging
brew install scouzi1966/afm/afm
EOS
end
test do
assert_match "v${VERSION}", shell_output("#{bin}/afm --version")
end
end
RUBY
git add afm.rb
git commit -m "Stage candidate v${VERSION}"
git push 2>&1 | tail -3
Now provide the user with a machine-portable install-and-verify recipe for a clean host. It MUST be a host that is not the one where the build was produced — the binary has been code-signed ad-hoc and has no special access on the build machine; only a fresh install via the staging tap exercises the same download and TCC path real end users will see.
Use AskUserQuestion:
Question: "Staged v${VERSION} is live at scouzi1966/afm-staging. Please install on a clean machine (ideally not the build host) and run the regression test matrix. Confirm results below."
Install instructions to give the user (absolute brew install form, not the cached tap form):
# On the clean machine — no prior afm install of any kind
brew untap scouzi1966/afm 2>/dev/null # optional: avoid any tap collision during test
brew tap scouzi1966/afm-staging
brew install scouzi1966/afm-staging/afm
afm --version # must show vVERSION
Regression matrix the user should run — at minimum:
afm --version reports v${VERSION}.
- Plain text-only Foundation chat (tests the core code path; missing from earlier runs catches this class of regression):
afm -p 9999 &
sleep 3
curl -s -X POST http://127.0.0.1:9999/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"foundation","stream":false,"messages":[{"role":"user","content":"Say hi"}]}'
kill %1
- Any per-release PR-specific smoke tests (e.g. Vision OCR, Speech, MLX generation) relevant to what this promotion ships.
Options:
- "Staging validation PASSED — promote to production tap" — proceed to Step 9.
- "Staging validation FAILED" — STOP. The production tap must not be touched. Investigate, re-build, re-publish the GitHub release (or cut a new version), re-stage, re-test. Return here only after a clean pass.
- "Skip staging validation (not recommended)" — continue to Step 9 at your own risk. Only choose this for emergency hotfixes where the staging machine is unavailable and you accept the rollback cost.
If the user selects option 2, do NOT proceed to Step 9. The production tap remains on the previous stable. Offer the rollback procedure (below) if the GitHub release needs to be withdrawn.
Step 9: Update Homebrew Stable Tap (afm.rb)
TAP_DIR="${TAP_DIR:-$(cd "$(git rev-parse --show-toplevel)/.." && pwd)/homebrew-afm}"
cd "$TAP_DIR"
git pull --ff-only
DOWNLOAD_URL="https://github.com/scouzi1966/maclocal-api/releases/download/v${VERSION}/afm-v${VERSION}-arm64.tar.gz"
sed -i '' "s|url \".*\"|url \"${DOWNLOAD_URL}\"|" afm.rb
sed -i '' "s/version \".*\"/version \"${VERSION}\"/" afm.rb
sed -i '' "s/sha256 \".*\"/sha256 \"${SHA256}\"/" afm.rb
sed -i '' "s/assert_match \"v[0-9][^\"]*\"/assert_match \"v${VERSION}\"/" afm.rb
sed -i '' "s/MLX Local Models (v[0-9][^)]*)/MLX Local Models (v${VERSION}+)/" afm.rb
git add afm.rb
git commit -m "afm ${VERSION}"
git push
Step 10: Update Version Files on Working Branch
cd "$(git rev-parse --show-toplevel)"
# BuildInfo.swift
sed -i '' "s/static let version: String? = \".*\"/static let version: String? = \"v${VERSION}\"/" \
Sources/AFMKit/BuildInfo.swift
# pyproject.toml
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml
# macafm/__init__.py
sed -i '' "s/^__version__ = \".*\"/__version__ = \"${VERSION}\"/" macafm/__init__.py
Step 11: Update README.md
# Install table version
sed -i '' "s/Stable (v[0-9][^)]*)/Stable (v${VERSION})/" README.md
# Release notes link
sed -i '' "s|\[v[0-9][^]]*\](https://github.com/scouzi1966/maclocal-api/releases/tag/v[^)]*)|[v${VERSION}](https://github.com/scouzi1966/maclocal-api/releases/tag/v${VERSION})|" README.md
# "What's new in afm-next" section — reset to show no new features since this stable release.
# All previous nightly features are now IN the stable release, so the list must be cleared.
# Future nightlies will add items back as new features land.
sed -i '' "s/everything in v[0-9][^ ]* plus/everything in v${VERSION} plus/" README.md
IMPORTANT: After updating the version reference, replace the bullet list under "What's new in afm-next" with:
> - No new features yet — nightly is currently in sync with the stable release
Remove all previous bullet points — they are now part of the stable release and no longer "new in afm-next". Future nightly builds will add new items to this section as features land after the stable cut.
Step 12: Build Python Wheel
Stage the freshly built binary (from the stable build in Step 5) into the Python package:
cd "$(git rev-parse --show-toplevel)"
BIN=".build/arm64-apple-macosx/release/afm"
[ -x "$BIN" ] || BIN=".build/release/afm"
mkdir -p macafm/bin macafm/share/webui
cp "$BIN" macafm/bin/
# Metallib
METALLIB="$(dirname "$BIN")/AFMKit_AFMKitMLX.bundle/default.metallib"
if [ -f "$METALLIB" ]; then
cp "$METALLIB" macafm/bin/
fi
# WebUI (MANDATORY — verified in Step 5d)
cp "Resources/webui/index.html.gz" macafm/share/webui/
# Build wheel
uv build
# Clean staged assets (never commit binaries)
rm -rf macafm/bin macafm/share
# Verify wheel size (must be >1MB — contains binary)
WHEEL=$(ls -1 dist/macafm-${VERSION}*.whl 2>/dev/null | head -1)
WHEEL_SIZE=$(stat -f%z "$WHEEL" 2>/dev/null || stat -c%s "$WHEEL" 2>/dev/null)
echo "Wheel: $WHEEL (${WHEEL_SIZE} bytes)"
if [ "$WHEEL_SIZE" -lt 1000000 ]; then
echo "ERROR: Wheel is only ${WHEEL_SIZE} bytes — assets were not staged correctly!"
fi
# MANDATORY: Verify wheel contains webui
if ! unzip -l "$WHEEL" | grep -q "index.html.gz"; then
echo "FATAL: Wheel does NOT contain WebUI!"
fi
If wheel is under 1 MB or missing WebUI, STOP. Do not provide the publish command.
Step 12b: User Clean-Slate Installation Testing (MANDATORY)
Before publishing to PyPI, the user MUST verify both installation methods work from a completely clean slate. Each method must be tested independently with full uninstall in between to avoid conflicts.
First, uninstall ALL existing afm installations:
# Remove all traces
brew uninstall afm 2>/dev/null || true
brew uninstall afm-next 2>/dev/null || true
pip uninstall macafm -y 2>/dev/null || true
uv pip uninstall macafm 2>/dev/null || true
# Verify clean slate
echo "=== Verify clean slate ==="
brew list --formula 2>&1 | grep -E "^afm" || echo "Homebrew: clean"
pip show macafm 2>&1 | grep -i version || echo "pip: clean"
which afm 2>&1 || echo "PATH: clean"
Use AskUserQuestion:
Question: "Clean-slate testing required. Please test Homebrew install first, then uninstall before testing pip. Ready to start?"
Provide the user with these commands:
Test 1 — Homebrew (test, then fully uninstall before Test 2):
brew update
brew tap scouzi1966/afm
brew install scouzi1966/afm/afm
afm --version # must show vVERSION
afm mlx -w -m <model> # must launch WebUI in browser
brew uninstall afm # MUST uninstall before Test 2
Test 2 — pip from local wheel (after brew is fully removed):
pip install FULL_PATH_TO_WHEEL
afm --version # must show vVERSION
afm mlx -w -m <model> # must launch WebUI in browser
pip uninstall macafm # clean up after testing
IMPORTANT: Always provide the full absolute path to the wheel file. The user may run the command from any directory.
Options:
- "Both passed" — continue to PyPI publish
- "Homebrew failed" — investigate and fix before continuing
- "pip failed" — investigate and fix before continuing
If either test fails, STOP. Investigate the failure, fix the issue, and re-run the failed test before proceeding. Do NOT publish to PyPI until both methods pass.
Step 13: Provide PyPI Publish Command
The maintainer has a TestPyPI account (separate login from production PyPI, 2FA + API token), so the wheel can be validated on TestPyPI before the production push. For a release that feels risky (new packaging, big version jump), offer the TestPyPI dry-run first; otherwise production is fine. The token is supplied at publish time (--token / UV_PUBLISH_TOKEN / ~/.pypirc) — never assume it's stored in the repo.
Present the exact command with the absolute path to the wheel and a placeholder token (production):
ROOT="$(git rev-parse --show-toplevel)"
echo "uv publish --token <YOUR_PYPI_TOKEN> ${ROOT}/dist/macafm-${VERSION}*"
TestPyPI dry-run variant (validate first, then verify with pip install --index-url https://test.pypi.org/simple/ macafm==${VERSION}):
echo "uv publish --publish-url https://test.pypi.org/legacy/ --token <YOUR_TEST_PYPI_TOKEN> ${ROOT}/dist/macafm-${VERSION}*"
IMPORTANT: Always provide the full absolute path to the dist files — the user may run the command from a different directory.
Use AskUserQuestion:
Question: "The wheel is ready. Validate on TestPyPI first, publish straight to production PyPI, or skip? (Replace the token placeholder with your own.)"
Options:
- "TestPyPI first" — run the TestPyPI dry-run, verify, then production
- "Done — published to PyPI"
- "Skip PyPI"
Step 14: Verify Deployment
Homebrew verification:
brew update
brew tap scouzi1966/afm
brew install scouzi1966/afm/afm || brew upgrade afm
# If afm-next is linked, unlink it first: brew unlink afm-next && brew link afm
BREW_VERSION=$(afm --version 2>&1)
echo "Homebrew afm version: $BREW_VERSION"
echo "$BREW_VERSION" | grep -qF "v${VERSION}" && echo "VERSION: PASS" || echo "VERSION: FAIL: expected v${VERSION}"
# Smoke test — run a simple prompt to verify the binary actually works
afm -s "why is the sky blue" 2>&1 | head -20
echo "BREW SMOKE TEST: PASS (if output above contains a response)"
PyPI verification (only if user published in Step 13):
VENV_DIR=$(mktemp -d)
python3 -m venv "$VENV_DIR"
"$VENV_DIR/bin/pip" install macafm==${VERSION}
PIP_VERSION=$("$VENV_DIR/bin/afm" --version 2>&1)
echo "PyPI afm version: $PIP_VERSION"
echo "$PIP_VERSION" | grep -qF "v${VERSION}" && echo "VERSION: PASS" || echo "VERSION: FAIL: expected v${VERSION}"
# Smoke test — run a simple prompt to verify the pip-installed binary works
$VENV_DIR/bin/afm -s "why is the sky blue" 2>&1 | head -20
echo "PIP SMOKE TEST: PASS (if output above contains a response)"
rm -rf "$VENV_DIR"
Report PASS/FAIL for each (version check + smoke test). If either fails, investigate and report the issue before proceeding.
Step 15: Commit and Report
cd "$(git rev-parse --show-toplevel)"
git add Sources/AFMKit/BuildInfo.swift pyproject.toml macafm/__init__.py README.md
git commit -m "Release v${VERSION}: promote nightly to stable"
Use AskUserQuestion before pushing:
Question: "Version files committed. Push to origin/main?"
Options:
- "Push" —
git push origin main
- "Don't push"
Cleanup:
rm -rf ".build/release-package-stable"
rm -f "afm-v${VERSION}-arm64.tar.gz"
Final report:
- Stable version:
VERSION
- Built from commit:
BUILD_SHA
- GitHub release URL
- Homebrew:
brew install scouzi1966/afm/afm / brew upgrade afm
- PyPI:
pip install macafm==VERSION (if published)
- Wheel file: path and size
- Verification results (Homebrew ✓/✗, PyPI ✓/✗)
Error Handling
- Patch verification fails (Step 5): STOP. Do not build. Show which patches failed
diff -q. Likely cause: submodule is at wrong commit or patches were not applied. Re-run git submodule update --init --recursive then ./Scripts/apply-mlx-patches.sh.
- Binary version mismatch (Step 5e): STOP.
BuildInfo.swift change was not compiled. Run swift package clean and rebuild.
- Stable tag already exists: User must choose a different version or delete existing release first.
- Tap push fails: Check
$TAP_DIR is on right branch with no uncommitted changes.
- Wheel too small (<1 MB): Assets not staged — verify binary, metallib, and webui exist in staging dirs.
uv build fails: Check uv is installed; check pyproject.toml syntax.
- Homebrew install fails after publish: Run
brew update; check formula with brew audit afm.
- PyPI install fails: Check version was actually published; try
pip install macafm==VERSION --no-cache-dir.
- Detached HEAD issues: Step 7 returns to original branch. If
git checkout fails, check for uncommitted changes with git status.
Rollback Procedure
If something goes wrong after publishing, use the rollback snapshot from Step 3b to revert. PyPI is immutable — a published version cannot be deleted or replaced. All other channels can be fully reverted.
Use AskUserQuestion before executing rollback:
Question: "Are you sure you want to rollback the v${VERSION} stable release? This will delete the GitHub release, revert the Homebrew tap, and undo version file changes. PyPI cannot be reverted."
Options:
- "Rollback" — proceed
- "Cancel" — abort rollback
1. Delete GitHub release and tag:
gh release delete "v${VERSION}" --repo scouzi1966/maclocal-api --yes
git tag -d "v${VERSION}" 2>/dev/null || true
git push origin --delete "v${VERSION}" 2>/dev/null || true
2. Revert Homebrew tap:
cd "$TAP_DIR"
git revert HEAD --no-edit
git push
# Verify it matches the previous state
grep 'version "' afm.rb
# Should show: PREV_STABLE_VERSION
3. Revert version files on working branch:
cd "$(git rev-parse --show-toplevel)"
# Restore previous values
sed -i '' "s/static let version: String? = \".*\"/static let version: String? = \"${PREV_BUILDINFO_VERSION}\"/" \
Sources/AFMKit/BuildInfo.swift
sed -i '' "s/^version = \".*\"/version = \"${PREV_PYPROJECT_VERSION}\"/" pyproject.toml
sed -i '' "s/^__version__ = \".*\"/__version__ = \"${PREV_INIT_VERSION}\"/" macafm/__init__.py
# Restore README
sed -i '' "s/Stable (v[0-9][^)]*)/Stable (v${PREV_PYPROJECT_VERSION})/" README.md
sed -i '' "s|\[v${VERSION}\](https://github.com/scouzi1966/maclocal-api/releases/tag/v${VERSION})|[v${PREV_PYPROJECT_VERSION}](https://github.com/scouzi1966/maclocal-api/releases/tag/v${PREV_PYPROJECT_VERSION})|" README.md
git add Sources/AFMKit/BuildInfo.swift pyproject.toml macafm/__init__.py README.md
git commit -m "Rollback: revert v${VERSION} promotion"
Use AskUserQuestion before pushing the rollback commit.
4. PyPI (immutable — cannot rollback):
If the wheel was published to PyPI, it cannot be deleted. Options:
- Publish a new patch version (e.g.,
X.Y.Z+1) with the fix
- Yank the version (marks it as not recommended but doesn't delete it):
pip install twine && twine yank macafm ${VERSION} --repository pypi
After rollback, report:
- GitHub release: deleted ✓
- Git tag
v${VERSION}: deleted ✓
- Homebrew tap: reverted to
PREV_STABLE_VERSION ✓
- Version files: reverted ✓
- PyPI: immutable (not reverted)