| 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_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:
echo "main HEAD: $(git rev-parse --short HEAD) — $(git log -1 --format='%s')"
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/MacLocalAPI/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)"
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/')
PREV_BUILDINFO_VERSION=$(grep 'static let version' Sources/MacLocalAPI/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/')
PREV_README_STABLE=$(grep -o 'Stable (v[^)]*)' README.md | head -1)
PREV_TAP_COMMIT=$(cd "$TAP_DIR" && git rev-parse HEAD)
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)"
ORIGINAL_BRANCH=$(git branch --show-current)
git stash --include-untracked 2>/dev/null || true
git checkout "$BUILD_SHA"
sed -i '' "s/static let version: String? = \".*\"/static let version: String? = \"v${VERSION}\"/" \
Sources/MacLocalAPI/BuildInfo.swift
grep 'static let version' Sources/MacLocalAPI/BuildInfo.swift
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)"
git submodule update --init --recursive
./Scripts/apply-mlx-patches.sh
./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:
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)"
WEBUI_DIR="vendor/llama.cpp/tools/server/webui"
WEBUI_PREBUILT="vendor/llama.cpp/tools/server/public/index.html.gz"
if [ -f "$WEBUI_PREBUILT" ]; then
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
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
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:
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"
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.
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"
TMPDIR=$(mktemp -d)
cp "$BIN" "$TMPDIR/"
cp "$(dirname "$BIN")/MacLocalAPI_MacLocalAPI.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.
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/MacLocalAPI/Info.plist"
exit 1
fi
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
plutil -lint Sources/MacLocalAPI/Info.plist || { echo "FATAL: Info.plist is malformed"; exit 1; }
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
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/"
BUNDLE_DIR="$(dirname "$BIN")/MacLocalAPI_MacLocalAPI.bundle"
if [ -d "$BUNDLE_DIR" ]; then
cp -r "$BUNDLE_DIR" "$STAGING/"
fi
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"
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)"
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"
bundle_dir = "MacLocalAPI_MacLocalAPI.bundle"
if Dir.exist?(bundle_dir)
(libexec/bundle_dir).mkpath
(libexec/bundle_dir).install Dir["#{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):
brew untap scouzi1966/afm 2>/dev/null
brew tap scouzi1966/afm-staging
brew install scouzi1966/afm-staging/afm
afm --version
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)"
sed -i '' "s/static let version: String? = \".*\"/static let version: String? = \"v${VERSION}\"/" \
Sources/MacLocalAPI/BuildInfo.swift
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml
sed -i '' "s/^__version__ = \".*\"/__version__ = \"${VERSION}\"/" macafm/__init__.py
Step 11: Update README.md
sed -i '' "s/Stable (v[0-9][^)]*)/Stable (v${VERSION})/" README.md
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
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="$(dirname "$BIN")/MacLocalAPI_MacLocalAPI.bundle/default.metallib"
if [ -f "$METALLIB" ]; then
cp "$METALLIB" macafm/bin/
fi
cp "Resources/webui/index.html.gz" macafm/share/webui/
uv build
rm -rf macafm/bin macafm/share
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
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:
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
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
Present the exact command with the absolute path to the wheel and a placeholder token:
ROOT="$(git rev-parse --show-toplevel)"
echo "uv publish --token <YOUR_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. Please run the uv publish command above (replace <YOUR_PYPI_TOKEN> with your token). Confirm when done, or skip PyPI."
Options:
- "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
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}"
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}"
$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/MacLocalAPI/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
grep 'version "' afm.rb
3. Revert version files on working branch:
cd "$(git rev-parse --show-toplevel)"
sed -i '' "s/static let version: String? = \".*\"/static let version: String? = \"${PREV_BUILDINFO_VERSION}\"/" \
Sources/MacLocalAPI/BuildInfo.swift
sed -i '' "s/^version = \".*\"/version = \"${PREV_PYPROJECT_VERSION}\"/" pyproject.toml
sed -i '' "s/^__version__ = \".*\"/__version__ = \"${PREV_INIT_VERSION}\"/" macafm/__init__.py
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/MacLocalAPI/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)