Skip to content

About

Generate AI release notes using Apple Intelligence (on-device AFM) on a self-hosted macOS runner. No cloud API, no tokens.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

298 Commits

Folders and files

Repository files navigation

afm-release-notes-action

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.


How It Works

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.


Inputs

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

Outputs

Output Description
release_title AI-generated release title
release_body AI-generated release notes body (Markdown)
prev_tag The previous tag used for comparison

Usage

Minimal

- uses: runbot-hq/afm-release-notes-action@v1
  id: notes
  env:
    GITHUB_TOKEN: ${{ github.token }}
  with:
    tag: ${{ github.ref_name }}

With outputs

Injection safety: release_title and release_body are LLM-generated. Pass them via env: rather than interpolating ${{ }} directly into shell, and write the body to a temp file for --notes-file to 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"

Using prev_tag

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"

Full caller workflow

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 }}"

Runner Requirements

  • macOS 26.4 or later — required for SystemLanguageModel.tokenCount(for:), the API that afm-cli --count-tokens calls 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) and dist/index.js are committed; nothing is downloaded or installed at runtime

actions/checkout must use fetch-depth: 0 — a shallow clone will fail tag resolution.


Known Constraints

  • Requires macOS 26.4+ — the token preflight (afm-cli --count-tokens) depends on SystemLanguageModel.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_extra capped at 300 chars — prevents context bloat.
  • release_body capped at 120,000 chars — GitHub Releases supports ~125k chars; the cap leaves headroom.
  • GITHUB_TOKEN must be passed via env: — the action reads it from the environment, not as a declared input.
  • @v1 floating tag — moves on every patch. Breaking changes bump to v2.

Rebuilding Artifacts

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.js may appear out of sync with src/index.ts immediately after a source commit. This is expected — build-artifacts.yml commits the rebuilt dist/ back to the branch automatically on every src/** push. check-artifacts.yml then verifies the committed dist/ 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 commit dist/ 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/ not dist/index.js — ncc emits dist/sourcemap-register.js as a required sibling. Staging only dist/index.js will cause a MODULE_NOT_FOUND crash 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+ Migration Path

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.


Roadmap

  • #26 — Extract afm-cli Swift binary to its own repo for reuse across actions
  • #28 — macOS 27 fm CLI integration
  • #24 — Replace firstIndex(of:) arg parser with two-pass parser in afm-cli
  • #9 — prompt_override input to replace base prompt entirely

About

Generate AI release notes using Apple Intelligence (on-device AFM) on a self-hosted macOS runner. No cloud API, no tokens.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages