Files
james.maes 774e60a020 ci(actions): create GitHub Release directly in publish-tag job (#187)
The default GITHUB_TOKEN cannot trigger downstream workflows (GitHub
prevents the chain to avoid infinite loops). publish-tag's `git push
origin "$TAG"` therefore never fires release.yml's `push: tags:`
listener — observed on v0.1.0-dev.50, where the tag landed but the
Release object was missing until manually created.

Fold the Release creation into the publish-tag job using `gh release
create` with GH_TOKEN=secrets.GITHUB_TOKEN. Direct API calls work fine
with the default token; only the push→workflow chain is blocked.

- Idempotent: skips if release already exists.
- Notes: auto-generated from `git log $prev..$TAG` plus a Verification
  block linking back to the CI run.
- Prerelease flag respects steps.version.outputs.prerelease (true for
  dev/rc tags, false for vX.Y.Z final tags on main).

release.yml stays untouched as a fallback for manually-pushed tags
(humans pushing tags directly CAN trigger workflows).

Closes #186
2026-05-13 18:28:07 -05:00

482 lines
21 KiB
YAML

# Main CI: build + test + boundary gates for every PR and push to long-lived branches.
#
# Branch strategy (defaults — override by editing the branches: lists):
# - feature/*, fix/* PR-only checks, no tagging
# - develop full CI + pre-release tag v0.X.Y-dev.<run_number>
# - rc/*, release/* full CI + tag v0.X.Y-rc.N (N auto-increments)
# - main full CI + tag v0.X.Y + GitHub Release (release.yml)
#
# Versioning: read commits since previous tag, bump per Conventional Commits
# (feat -> minor, fix -> patch, feat! / BREAKING CHANGE -> major). Defaults to
# v0.1.0 when no tags exist.
#
# Untrusted input handling: this workflow does NOT consume PR titles, bodies,
# issue text, or commit-author fields directly in run: blocks. The only
# external inputs are github.ref (controlled) and the constrained
# workflow_dispatch choice. github.run_number is GitHub-provided.
name: CI
on:
pull_request:
branches: [develop, main, "rc/*", "release/*"]
push:
branches: [develop, main, "rc/*", "release/*"]
workflow_dispatch:
inputs:
run_real_hardware:
description: "Run JARVIS_REAL_MODELS / JARVIS_REAL_CAMERA suites (self-hosted only)"
required: false
default: "false"
type: choice
options:
- "false"
- "true"
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
pull-requests: read
checks: write
jobs:
boundary-gates:
name: Boundary gates
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run all scripts/check-*.sh (excluding app-builds)
run: |
fail=0
for s in scripts/check-*.sh; do
name="$(basename "$s")"
if [ "$name" = "check-app-builds.sh" ]; then
echo "::group::SKIP $name (needs xcodebuild)"
echo "::endgroup::"
continue
fi
echo "::group::$name"
if bash "$s"; then
echo "PASS: $name"
else
echo "::error::FAIL: $name"
fail=1
fi
echo "::endgroup::"
done
exit "$fail"
webview:
name: Webview (pnpm test + build)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.30.2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
cache-dependency-path: webview/pnpm-lock.yaml
- name: pnpm install
working-directory: webview
run: pnpm install --frozen-lockfile
# Build before typecheck: @jarvis/bus emits its dist/index.d.ts here,
# which packages/hud's TS imports require. scripts/build-webview.sh
# also rsyncs the production bundle to App/Resources/webview/ so the
# downstream SPM jobs (via the artifact below) and app-build (xcodebuild)
# both find a working HUD bundle.
- name: Build HUD bundle (produces @jarvis/bus dist + App/Resources/webview/)
run: bash scripts/build-webview.sh
# Typecheck is blocking. The historical ~30 errors reported in CI run
# 25797533005 were almost entirely downstream of `TS2307: Cannot find
# module '@jarvis/bus'` and disappear once `build-webview.sh` runs above
# (which produces `packages/bus/dist/index.d.ts`). The one real strict-
# undefined error in SegmentedRing.tsx was fixed in PR #165.
- name: Typecheck
working-directory: webview
run: pnpm -r typecheck
- name: Test (vitest)
working-directory: webview
run: pnpm -r test
# Artifact is the production-stamped App/Resources/webview/ tree.
# Downstream SPM + app-build jobs depend on this artifact name.
- name: Upload App/Resources/webview artifact
uses: actions/upload-artifact@v4
with:
name: webview-resources
path: App/Resources/webview/
retention-days: 7
if-no-files-found: error
swift-packages:
name: SPM ${{ matrix.package }}
# Self-hosted on Grogu (labels: self-hosted, macOS, ARM64, jarvis) per #180.
# The github-hosted macos-15 pool was the rate-limiting step for v0.1:
# 13 matrix jobs + 1 app-build job all needed macOS slots, GitHub
# scheduled them 1-3 at a time, and total wall-clock was ~1-2h per run
# versus actual work of <10m. Self-hosted eliminates the queue.
runs-on: [self-hosted, macOS, jarvis]
# Security gate: self-hosted runners + public repo would let a fork PR
# execute arbitrary code on this machine. Push events only ever come from
# maintainers (branch protection blocks unauthorized writes). PRs are
# only allowed onto the self-hosted runner when they originate from a
# branch on this same repo (i.e., not a fork). Also gates manual dispatch.
if: |
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
needs: [webview]
strategy:
fail-fast: false
matrix:
# VoiceLog removed: no `packages/VoiceLog` directory exists in the
# repo. The matrix entry was a phantom that failed with `chdir error:
# No such file or directory`. Voice-log work tracked under v1.1.
package:
- AgentCore
- Bus
- Config
- DevOverlay
- Harness
- Keychain
- Logging
- MCP
- Memory
- Replay
- Shell
- Vision
- Voice
steps:
# Grogu is a shared workstation, not an ephemeral VM. Interactive
# Xcode test sessions, debugging, or aborted `swift test` runs can
# leave orphan xctest/swift-test/swift-driver processes around for
# hours, holding Mach services + sockets + ports that a new
# WKWebView mount or test-target launch then blocks on (observed:
# AgentCore hung 34min on develop CI 25824607025 with 6 zombie
# xctests aged 22-24h from prior interactive use).
#
# Age filter (>= 1 hour): Grogu now has SIBLING runners
# (Grogu-jarvis, -2, -3) so concurrent CI jobs can be in flight
# simultaneously. CI jobs complete in <10 min, so a 1-hour cutoff
# safely reaps interactive-use orphans without killing concurrent
# CI work on sibling runners. `ps -o etime` formats: `MM:SS`
# (<1h, skip), `HH:MM:SS` (>=1h, kill), `DD-HH:MM:SS` (days, kill).
# github-hosted VMs start clean, so this step is gated to self-hosted.
- name: Reap orphan xctest/swift-test processes older than 1h (self-hosted only)
if: runner.environment == 'self-hosted'
run: |
age_filter='/(xctest|swift-test|swift-driver)$/ && !/awk/ && (match($2, /-/) || match($2, /[0-9]+:[0-9]+:[0-9]+/)) {print $1}'
/bin/ps -A -o pid,etime,comm \
| awk "$age_filter" \
| xargs -r -I{} kill -TERM {} 2>/dev/null || true
sleep 2
/bin/ps -A -o pid,etime,comm \
| awk "$age_filter" \
| xargs -r -I{} kill -9 {} 2>/dev/null || true
echo "Surviving xctest/swift-test processes (any age — includes concurrent CI work on sibling runners):"
/bin/ps -A -o pid,etime,comm | grep -E '(xctest|swift-test|swift-driver)$' || echo " (none)"
- uses: actions/checkout@v4
# Project targets macOS 26 Tahoe + Swift 6.2/6.3. Xcode 16 (Swift 6.0)
# cannot parse `mlx-audio-swift` v0.1.2's `swift-tools-version:6.2`,
# lacks the `withThrowingTaskGroup` overload used by MCP `swift-sdk`
# 0.12.0, and rejects `@MainActor` XCTestCase `super.setUp()` patterns
# that Swift 6.1+ accepts. The first two paths cover github-hosted
# macos-15 (which ships Xcode 26.3); the bare `Xcode.app` fallback
# covers self-hosted runners with whatever local Xcode 26.x is the
# default (Grogu currently has 26.5). Using DEVELOPER_DIR (per-process)
# rather than `sudo xcode-select -s` (system-wide, needs passwordless
# sudo on self-hosted) — both paths respect DEVELOPER_DIR identically.
- name: Select Xcode
run: |
for path in \
/Applications/Xcode_26.3.app/Contents/Developer \
/Applications/Xcode_26.app/Contents/Developer \
/Applications/Xcode.app/Contents/Developer; do
if [ -d "$path" ]; then
echo "DEVELOPER_DIR=$path" >> "$GITHUB_ENV"
echo "Using Xcode at: $path"
exit 0
fi
done
echo "No Xcode 26.x found in any expected path" >&2
exit 1
- name: Show Swift toolchain
run: |
swift --version
xcodebuild -version
# Download the production webview bundle so Bus + downstream packages
# (AgentCore / Harness / MCP / Voice) can mount a real WKWebView with
# a working JS bridge. Without this, BusTests fail with `Error: bus
# not mounted` because index.html + the JS bundle are missing from
# App/Resources/webview/.
- name: Download webview resources
uses: actions/download-artifact@v4
with:
name: webview-resources
path: App/Resources/webview/
# actions/cache@v4 restores the package's `.build/` and SwiftPM's
# global cache. On github-hosted this is a real perf win; on self-hosted
# it's both unnecessary (the _work/ dir persists between jobs naturally)
# and actively harmful (caches saved on github-hosted have absolute
# paths like /Users/runner/work/Jarvis/.../onnxruntime.xcframework
# baked into .build/artifacts, which don't resolve on Grogu and break
# Voice + Harness with "XCFramework Info.plist not found"). Restrict
# the cache action to github-hosted only.
- name: Cache SPM build artifacts (github-hosted only)
if: runner.environment == 'github-hosted'
uses: actions/cache@v4
with:
path: |
packages/${{ matrix.package }}/.build
~/Library/Caches/org.swift.swiftpm
key: spm-${{ matrix.package }}-${{ runner.os }}-${{ hashFiles(format('packages/{0}/Package.resolved', matrix.package), format('packages/{0}/Package.swift', matrix.package)) }}
restore-keys: |
spm-${{ matrix.package }}-${{ runner.os }}-
# On self-hosted (Grogu) the runner is a LaunchAgent with no UI session,
# so SecItemAdd against the locked login keychain returns
# errSecInteractionNotAllowed (-25308). Use a pre-created dedicated
# keychain `jarvis-runner.keychain-db` (one-time setup on Grogu) and
# prepend it to the user search list for this run so test SecItemAdd
# calls without an explicit keychain target write there. github-hosted
# bakes its own unlocked keychain into the runner image and skips this.
- name: Unlock runner keychain (self-hosted only)
if: runner.environment == 'self-hosted'
env:
KEYCHAIN_PASSWORD: ${{ secrets.JARVIS_RUNNER_KEYCHAIN_PASSWORD }}
run: |
KEYCHAIN_PATH="$HOME/Library/Keychains/jarvis-runner.keychain-db"
if [ ! -f "$KEYCHAIN_PATH" ]; then
echo "FATAL: runner keychain missing at $KEYCHAIN_PATH" >&2
echo " Create with: security create-keychain -p \"\$KEYCHAIN_PASSWORD\" \"$KEYCHAIN_PATH\"" >&2
exit 1
fi
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# NOTE: do NOT re-run `set-keychain-settings` here — the Security
# framework requires user interaction for that operation even with
# the keychain unlocked, and the LaunchAgent has no UI. The
# settings (no-timeout, no-lock-on-sleep) were applied once at
# keychain-creation time and persist; we just need to unlock.
security list-keychains -d user -s "$KEYCHAIN_PATH" "$HOME/Library/Keychains/login.keychain-db"
# On the legacy file-based macOS keychain, SecItemAdd writes to the
# user's DEFAULT keychain — the search list only determines read
# order. Changing list-keychains alone leaves SecItemAdd targeting
# login.keychain-db (locked, returns -25308). Also redirect the
# default. SIDE EFFECT documented in CLAUDE.md: this is persistent
# for james.maes's user prefs.
security default-keychain -d user -s "$KEYCHAIN_PATH"
- name: swift test
run: swift test --package-path packages/${{ matrix.package }}
app-build:
name: App target (xcodebuild)
runs-on: macos-15
needs: [boundary-gates, webview]
steps:
- uses: actions/checkout@v4
# Same Xcode 26.x pin as swift-packages — Xcode 16 cannot resolve
# mlx-audio-swift's swift-tools-version:6.2 manifest.
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_26.3.app/Contents/Developer || sudo xcode-select -s /Applications/Xcode_26.app/Contents/Developer
- name: Install xcodegen
run: brew install xcodegen
# pnpm + node need to be on PATH because Xcode.app's project has a
# build-phase script (Script-4B80D79B2A0254274258618C.sh) that calls
# scripts/build-webview.sh during the build itself. Even though we
# download the prebuilt artifact below, the build phase still runs
# and re-stamps the bundle — and would error with
# "[build-webview] FATAL: pnpm not found on PATH" without these.
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.30.2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
cache-dependency-path: webview/pnpm-lock.yaml
# Reuse the webview build from the upstream job rather than rebuilding.
# The Xcode build phase will re-stamp these on top.
- name: Download webview resources
uses: actions/download-artifact@v4
with:
name: webview-resources
path: App/Resources/webview/
- name: check-app-builds.sh
run: bash scripts/check-app-builds.sh
- name: verify-codesign-settings.sh
run: bash scripts/verify-codesign-settings.sh
- name: Upload Jarvis.app artifact
uses: actions/upload-artifact@v4
with:
name: Jarvis-app-debug
path: build/Build/Products/Debug/Jarvis.app
retention-days: 7
if-no-files-found: warn
real-hardware:
name: Real-hardware tests (opt-in)
runs-on: [self-hosted, macOS]
if: github.event_name == 'workflow_dispatch' && github.event.inputs.run_real_hardware == 'true'
needs: [swift-packages]
steps:
- uses: actions/checkout@v4
- name: JARVIS_REAL_MODELS Memory regression corpus
env:
JARVIS_REAL_MODELS: "1"
run: swift test --package-path packages/Memory --filter MemoryRegressionCorpusTests
- name: JARVIS_REAL_CAMERA capture suite
env:
JARVIS_REAL_CAMERA: "1"
run: swift test --package-path packages/Vision --filter CameraCaptureRealHardwareTests
- name: JARVIS_REAL_MODELS Orpheus TTFA
env:
JARVIS_REAL_MODELS: "1"
run: swift test --package-path packages/Voice --filter OrpheusTTFATests
publish-tag:
name: Publish tag
runs-on: ubuntu-latest
needs: [boundary-gates, webview, swift-packages, app-build]
if: |
github.event_name == 'push' && (
github.ref == 'refs/heads/develop' ||
github.ref == 'refs/heads/main' ||
startsWith(github.ref, 'refs/heads/rc/') ||
startsWith(github.ref, 'refs/heads/release/')
)
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute next version
id: version
env:
GH_REF: ${{ github.ref }}
RUN_NUMBER: ${{ github.run_number }}
run: |
set -euo pipefail
git fetch --tags --force --quiet
last_tag=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' \
| grep -Ev -- '-(dev|rc|alpha|beta)' \
| sort -V | tail -n 1 || true)
if [ -z "$last_tag" ]; then
base="v0.1.0"
from="$(git rev-list --max-parents=0 HEAD | tail -n 1)"
echo "No prior tag; defaulting base=$base, scanning all history."
else
base="$last_tag"
from="$last_tag"
echo "Last final tag: $last_tag"
fi
version="${base#v}"
IFS='.' read -r major minor patch <<< "$version"
bump="patch"
if git log "$from"..HEAD --pretty=%B 2>/dev/null | grep -qE '(^|\n)BREAKING CHANGE:|^[a-zA-Z]+(\([^)]+\))?!:'; then
bump="major"
elif git log "$from"..HEAD --pretty=%B 2>/dev/null | grep -qE '^feat(\([^)]+\))?:'; then
bump="minor"
elif git log "$from"..HEAD --pretty=%B 2>/dev/null | grep -qE '^fix(\([^)]+\))?:'; then
bump="patch"
fi
if [ -n "$last_tag" ]; then
case "$bump" in
major) major=$((major+1)); minor=0; patch=0 ;;
minor) minor=$((minor+1)); patch=0 ;;
patch) patch=$((patch+1)) ;;
esac
fi
next="${major}.${minor}.${patch}"
case "$GH_REF" in
refs/heads/develop)
tag="v${next}-dev.${RUN_NUMBER}"
prerelease=true
;;
refs/heads/rc/*|refs/heads/release/*)
existing_rc=$(git tag --list "v${next}-rc.*" | sed -E "s/.*-rc\.([0-9]+)/\1/" | sort -n | tail -n 1)
n=$(( ${existing_rc:-0} + 1 ))
tag="v${next}-rc.${n}"
prerelease=true
;;
refs/heads/main)
tag="v${next}"
prerelease=false
;;
*)
echo "Unexpected ref: $GH_REF" >&2
exit 1
;;
esac
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT"
echo "Next tag: $tag (prerelease=$prerelease)"
- name: Create + push tag
env:
TAG: ${{ steps.version.outputs.tag }}
GH_REF: ${{ github.ref }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists; skipping."
exit 0
fi
git tag -a "$TAG" -m "Automated tag from CI for $GH_REF"
git push origin "$TAG"
# GitHub Actions GITHUB_TOKEN cannot trigger downstream workflows. The
# separate `release.yml` workflow listens for `push: tags:` events but
# never sees a CI-pushed tag — observed on v0.1.0-dev.50 (tag created,
# Release object missing, had to be manually created). Create the
# Release object directly here in the same job so the Releases page
# reflects CI-pushed tags without a manual step. `release.yml` stays
# as a fallback for manually-pushed tags (which CAN trigger workflows).
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
PRERELEASE: ${{ steps.version.outputs.prerelease }}
RUN_URL: ${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }}
run: |
set -euo pipefail
if ! git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then
echo "Tag $TAG not present locally; nothing to release." >&2
exit 0
fi
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists — skipping create (idempotent)."
exit 0
fi
prev=$(git tag --list 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -n 1 || true)
notes_file=$(mktemp)
{
echo "## Changes"
echo
if [ -n "$prev" ]; then
git log --pretty='format:- %s (%h)' "${prev}..${TAG}"
else
git log --pretty='format:- %s (%h)' -20
fi
echo
echo
echo "## Verification"
echo
echo "- CI build: $RUN_URL"
echo "- Boundary gates, webview, app-build, 13/13 SPM packages: all green on this commit"
} > "$notes_file"
flag=()
if [ "$PRERELEASE" = "true" ]; then
flag+=("--prerelease")
fi
gh release create "$TAG" \
--title "$TAG" \
--notes-file "$notes_file" \
"${flag[@]}"