Generate AI release notes using Apple Intelligence (on-device AFM) on a self-hosted macOS runner. No cloud API, no tokens, no third-party dependencies — runs entirely on-device via a native Swift binary.
Pure notes-generator. Given a tag, returns AI-generated release_title, release_body, and prev_tag. What you do with the output is entirely up to the caller.
action.yml node24 action — main: dist/index.js
src/index.ts TypeScript business logic (bundled to dist/index.js via ncc)
afm-cli-bin Prebuilt Swift binary — committed to repo, sourced from runbot-hq/afm-cli
src/index.ts handles all logic: tag resolution, GitHub API diff fetch, prompt assembly, retry, and output parsing. afm-cli-bin is domain-ignorant — it takes --prompt <text> and returns plain text. Both are committed as build artifacts; no build step or network call runs on the runner.
afm-cli Swift source lives in runbot-hq/afm-cli. When the binary is updated, download afm-cli-bin from the main branch of that repo and commit it here.
| Input | Required | Default | Description |
|---|---|---|---|
tag |
✅ | — | Tag to generate release notes for (e.g. v1.2.3) |
prev_tag |
❌ | auto | Previous tag to compare against (auto-resolved from git tags if omitted) |
prompt_extra |
❌ | — | Optional extra instruction appended to the prompt (max 300 chars) |
debug |
❌ | false |
Set to true to enable verbose debug logging |
| Output | Description |
|---|---|
release_title |
AI-generated release title |
release_body |
AI-generated release notes body (Markdown) |
prev_tag |
The previous tag used for comparison |
- uses: runbot-hq/afm-release-notes-action@v1
id: notes
env:
GITHUB_TOKEN: ${{ github.token }}
with:
tag: ${{ github.ref_name }}Injection safety:
release_titleandrelease_bodyare LLM-generated. Pass them viaenv:rather than interpolating${{ }}directly into shell, and write the body to a temp file for--notes-fileto avoid word-splitting on quotes, backticks, or$().
- uses: runbot-hq/afm-release-notes-action@v1
id: notes
env:
GITHUB_TOKEN: ${{ github.token }}
with:
tag: ${{ github.ref_name }}
- name: Create release
env:
GH_TOKEN: ${{ github.token }}
AFM_TITLE: ${{ steps.notes.outputs.release_title }}
AFM_BODY: ${{ steps.notes.outputs.release_body }}
run: |
NOTES_FILE=$(mktemp)
trap "rm -f '$NOTES_FILE'" EXIT
printf '%s' "$AFM_BODY" > "$NOTES_FILE"
gh release create "${{ github.ref_name }}" \
--title "${AFM_TITLE:-${{ github.ref_name }}}" \
--notes-file "$NOTES_FILE"prev_tag is useful when you want GitHub's comparison URL to span exactly the same range the action used:
- name: Create release
env:
GH_TOKEN: ${{ github.token }}
AFM_TITLE: ${{ steps.notes.outputs.release_title }}
AFM_BODY: ${{ steps.notes.outputs.release_body }}
PREV_TAG: ${{ steps.notes.outputs.prev_tag }}
run: |
NOTES_FILE=$(mktemp)
trap "rm -f '$NOTES_FILE'" EXIT
printf '%s' "$AFM_BODY" > "$NOTES_FILE"
gh release create "${{ github.ref_name }}" \
--title "${AFM_TITLE:-${{ github.ref_name }}}" \
--notes-file "$NOTES_FILE" \
--notes-start-tag "$PREV_TAG"name: Release Notes
on:
release:
types: [published]
permissions:
contents: read # required for GitHub API diff fetch
jobs:
generate-notes:
runs-on: [self-hosted, macOS, apple-intelligence]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required — shallow clone breaks tag resolution
- uses: runbot-hq/afm-release-notes-action@v1
id: notes
env:
GITHUB_TOKEN: ${{ github.token }}
with:
tag: ${{ github.ref_name }}
- name: Log outputs
run: |
echo "Title: ${{ steps.notes.outputs.release_title }}"
echo "Prev tag: ${{ steps.notes.outputs.prev_tag }}"- macOS 26.4 or later — required for
SystemLanguageModel.tokenCount(for:), the API thatafm-cli --count-tokenscalls to measure exact prompt token counts before inference. The action verifies this at startup and fails immediately with a clear error if the runner is on an older macOS 26.x release. macOS 26.0–26.3 are not supported. - Apple Silicon Mac, Apple Intelligence enabled in System Settings (per-user — check MDM restrictions)
- Runner labeled
[self-hosted, macOS, apple-intelligence] - No runtime dependencies —
afm-cli-bin(sourced from runbot-hq/afm-cli) anddist/index.jsare committed; nothing is downloaded or installed at runtime
actions/checkoutmust usefetch-depth: 0— a shallow clone will fail tag resolution.
- Requires macOS 26.4+ — the token preflight (
afm-cli --count-tokens) depends onSystemLanguageModel.tokenCount(for:), available from macOS 26.4. The action throws at startup on older runners with a diagnostic that includes the runner OS. - Apple Intelligence is per-user — may be blocked by MDM. The action fails fast with a clear error message including the runner name.
- AFM retried once — 2 × 60s with a 15s pause between attempts; handles cold-start model loading. Fatal errors (MDM block, AI unavailable, permission denied) skip the retry immediately.
- Strict-prompt retry — if the model returns malformed JSON, the action retries once with a stricter prompt before failing.
prompt_extracapped at 300 chars — prevents context bloat.release_bodycapped at 120,000 chars — GitHub Releases supports ~125k chars; the cap leaves headroom.GITHUB_TOKENmust be passed viaenv:— the action reads it from the environment, not as a declared input.@v1floating tag — moves on every patch. Breaking changes bump tov2.
dist/index.js is a committed build artifact, automatically rebuilt by .github/workflows/build-artifacts.yml on every push to any branch that touches src/**, package.json, or tsconfig.json. The workflow commits the rebuilt dist/ back to the same branch automatically — usually within a minute of a push.
For PR reviewers:
dist/index.jsmay appear out of sync withsrc/index.tsimmediately after a source commit. This is expected —build-artifacts.ymlcommits the rebuiltdist/back to the branch automatically on everysrc/**push.check-artifacts.ymlthen verifies the committeddist/matches a clean rebuild before the PR can merge, so the dist is always correct at merge time. You do not need to rebuild or commitdist/manually.
To rebuild manually (e.g. for local testing):
# TypeScript bundle
npm install && npm run build
git add dist/
git commit -m "chore: rebuild artifacts"
git add dist/notdist/index.js— ncc emitsdist/sourcemap-register.jsas a required sibling. Staging onlydist/index.jswill cause aMODULE_NOT_FOUNDcrash at runtime.
afm-cli-bin is maintained in runbot-hq/afm-cli. To update it, fetch the binary from main of that repo and commit it here:
gh release download --repo runbot-hq/afm-cli --tag v1 --pattern 'afm-cli-bin'
chmod 755 afm-cli-bin
git add afm-cli-bin
git commit -m "chore: update afm-cli-bin from runbot-hq/afm-cli main"macOS 27 ships a built-in fm CLI that calls Foundation Models directly from the terminal with no build step required:
fm respond "Your prompt here"When your self-hosted runner upgrades to macOS 27, this action can be simplified to delegate to fm instead of afm-cli-bin — eliminating the committed Swift binary entirely. The TypeScript orchestration layer (dist/index.js) remains unchanged; only the final inference call changes.
Until then: afm-cli-bin is the correct path on macOS 26. fm is not available on macOS 26.
Tracked in #28.