ci(actions): full pipeline — build / test / scan / publish #154

Merged
KofTwentyTwo merged 4 commits from ci/github-actions-v0.1 into develop 2026-05-13 11:28:10 +00:00
7 changed files with 590 additions and 0 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# CODEOWNERS — review hints for the sole maintainer.
# Wildcard catches everything; expand later as the team grows.
* @KofTwentyTwo
+291
View File
@@ -0,0 +1,291 @@
# 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
- name: Typecheck
working-directory: webview
run: pnpm -r typecheck
- name: Test (vitest)
working-directory: webview
run: pnpm -r test
- name: Build HUD bundle
working-directory: webview
run: pnpm -r build
- name: Upload HUD dist
uses: actions/upload-artifact@v4
with:
name: webview-dist
path: webview/packages/hud/dist/
retention-days: 7
if-no-files-found: error
swift-packages:
name: SPM ${{ matrix.package }}
runs-on: macos-15
strategy:
fail-fast: false
matrix:
package:
- AgentCore
- Bus
- Config
- DevOverlay
- Harness
- Keychain
- Logging
- MCP
- Memory
- Replay
- Shell
- Vision
- Voice
- VoiceLog
steps:
- uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_16.app/Contents/Developer || sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
- name: Show Swift toolchain
run: |
swift --version
xcodebuild -version
- name: Cache SPM build artifacts
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 }}-
- 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
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_16.app/Contents/Developer || sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
- 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: Install xcodegen
run: brew install xcodegen
- name: Build webview (produces App/Resources/webview/)
run: bash scripts/build-webview.sh
- 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"
+61
View File
@@ -0,0 +1,61 @@
# CodeQL static analysis for Swift + JavaScript/TypeScript.
name: CodeQL
on:
push:
branches: [develop, main, "rc/*", "release/*"]
pull_request:
branches: [develop, main]
schedule:
- cron: "0 9 * * 1"
concurrency:
group: codeql-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: read
contents: read
security-events: write
jobs:
analyze:
name: Analyze ${{ matrix.language }}
runs-on: ${{ matrix.language == 'swift' && 'macos-15' || 'ubuntu-latest' }}
strategy:
fail-fast: false
matrix:
include:
- language: javascript-typescript
build-mode: none
- language: swift
build-mode: manual
steps:
- uses: actions/checkout@v4
- name: Select Xcode (swift)
if: matrix.language == 'swift'
run: sudo xcode-select -s /Applications/Xcode_16.app/Contents/Developer || sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
queries: security-extended
# CodeQL needs a manual Swift build. We build a representative subset
# of packages (Bus, AgentCore, MCP) so the security lane stays cheap;
# extending to all 14 packages would dominate the CI budget.
- name: Build Swift (CodeQL extraction)
if: matrix.language == 'swift'
run: |
swift build --package-path packages/Bus
swift build --package-path packages/AgentCore
swift build --package-path packages/MCP
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
+35
View File
@@ -0,0 +1,35 @@
# Dependency review on PRs that touch manifest/lockfiles for npm or SPM.
# Fails the PR if a new dependency introduces a known high-severity vuln
# or a forbidden license.
name: Dependency Review
on:
pull_request:
branches: [develop, main]
paths:
- "webview/package.json"
- "webview/pnpm-lock.yaml"
- "webview/packages/**/package.json"
- "**/Package.swift"
- "**/Package.resolved"
- ".github/workflows/dependency-review.yml"
permissions:
contents: read
pull-requests: write
jobs:
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Dependency Review
uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
# Default license policy: warn on copyleft, fail on nothing automatically.
# Tighten via `deny-licenses: GPL-2.0, GPL-3.0, AGPL-3.0` after the
# user reviews the existing SPM + npm graph.
comment-summary-in-pr: on-failure
+79
View File
@@ -0,0 +1,79 @@
# Release workflow: creates a GitHub Release whenever a v* tag is pushed.
# Pre-release tags (vX.Y.Z-dev.*, vX.Y.Z-rc.*) are marked as pre-releases.
# Final tags (vX.Y.Z) become production releases.
#
# The release body is auto-generated from commits since the previous tag.
name: Release
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-*"
permissions:
contents: write
jobs:
release:
name: Publish GitHub Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Classify tag
id: classify
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Anything containing a dash after vX.Y.Z is a pre-release.
if [[ "$TAG" == *-* ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
# Find the previous tag (any kind) for the changelog scope.
prev=$(git tag --list 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -n 1 || true)
echo "previous_tag=${prev}" >> "$GITHUB_OUTPUT"
echo "Classified $TAG: prerelease=$(grep prerelease "$GITHUB_OUTPUT" | tail -n 1), previous=$prev"
- name: Generate release notes
id: notes
env:
TAG: ${{ github.ref_name }}
PREV: ${{ steps.classify.outputs.previous_tag }}
run: |
set -euo pipefail
{
echo "## Changes"
echo
if [ -n "$PREV" ]; then
git log --pretty='format:- %s (%h)' "$PREV..$TAG"
else
git log --pretty='format:- %s (%h)'
fi
echo
echo
echo "## Verification"
echo
echo "- CI build: see https://github.com/${GITHUB_REPOSITORY}/actions"
echo "- Boundary gates: 18/18 expected green on this tag"
} > release-notes.md
{
echo "body<<NOTES_EOF"
cat release-notes.md
echo "NOTES_EOF"
} >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
prerelease: ${{ steps.classify.outputs.prerelease == 'true' }}
body: ${{ steps.notes.outputs.body }}
generate_release_notes: false
+53
View File
@@ -0,0 +1,53 @@
# Semgrep static analysis covering default + Swift + TypeScript rule packs.
# Runs on PRs + pushes to develop/main + weekly schedule.
#
# SEMGREP_APP_TOKEN is optional; without it, scans run with public rules
# and findings appear only in the workflow log (not Semgrep Cloud).
name: Semgrep
on:
pull_request:
branches: [develop, main]
push:
branches: [develop, main]
schedule:
- cron: "0 10 * * 1"
concurrency:
group: semgrep-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
security-events: write
jobs:
semgrep:
name: Semgrep scan
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
if: (github.actor != 'dependabot[bot]')
steps:
- uses: actions/checkout@v4
- name: Semgrep CI
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
SEMGREP_RULES: "p/default p/swift p/typescript p/secrets"
run: |
# If SEMGREP_APP_TOKEN is set, `semgrep ci` posts to Semgrep Cloud.
# Otherwise, we fall back to a local scan over SEMGREP_RULES.
if [ -n "${SEMGREP_APP_TOKEN:-}" ]; then
semgrep ci
else
semgrep scan --config "$SEMGREP_RULES" --error --sarif --output=semgrep.sarif || true
fi
- name: Upload SARIF (when present)
if: hashFiles('semgrep.sarif') != ''
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: semgrep.sarif
category: semgrep
+68
View File
@@ -160,6 +160,74 @@ The eval harness — Anthropic + local-model matrix, corpus in
`.planning/evals/` — is pinned to `qwen2.5-coder:32b` for the local-model
lane.
## CI/CD
GitHub Actions is the canonical CI surface. Five workflows live in
`.github/workflows/`:
| Workflow | Trigger | What it does |
|----------|---------|--------------|
| `ci.yml` | PR + push to `develop`/`main`/`rc/*`/`release/*` | Boundary gates, webview (pnpm), 14 SPM packages (matrix), app target (xcodebuild), tag publish |
| `codeql.yml` | PR + push + weekly cron | Static analysis for Swift + JavaScript/TypeScript |
| `semgrep.yml` | PR + push + weekly cron | Rule-based scan (`p/default`, `p/swift`, `p/typescript`, `p/secrets`) |
| `dependency-review.yml` | PR touching `package.json` / `pnpm-lock.yaml` / `Package.swift` / `Package.resolved` | Blocks high-severity vulns + license violations |
| `release.yml` | Push of `v*` tag | Creates a GitHub Release with auto-generated changelog |
### Branch + tag conventions
- **`feature/*`, `fix/*`** — open a PR against `develop`. CI runs full
build + test + scan; no tagging.
- **`develop`** — integration trunk. Every push runs full CI and publishes a
pre-release tag `v0.X.Y-dev.<run_number>`.
- **`rc/*` / `release/*`** — release candidates. Tag `v0.X.Y-rc.N` (N auto-
increments from existing tags).
- **`main`** — production. Only updated via PR from `rc/*` or `release/*`.
Push to `main` produces a final tag `v0.X.Y` and a GitHub Release.
Versioning bumps follow Conventional Commits since the previous final tag:
- `feat:` → minor bump
- `fix:` → patch bump
- `feat!:` or `BREAKING CHANGE:` footer → major bump
When no tags exist yet, the seed version is `v0.1.0` (current roadmap
target).
### Failed boundary gate?
Each script in `scripts/check-*.sh` encodes an architectural invariant. The
table in [Boundary-gate linters](#boundary-gate-linters) above explains
what each one enforces. To reproduce locally:
```bash
bash scripts/check-<name>.sh
```
The script's header comments explain the "why" behind the check and point
at the relevant audit finding or bug.
### Opting into real-hardware tests
The real-hardware suites (`JARVIS_REAL_MODELS=1`, `JARVIS_REAL_CAMERA=1`)
need local Ollama and a real camera, so they don't run on GitHub-hosted
runners. To run them in CI:
1. Register a self-hosted macOS runner with `Ollama` + the necessary
models + camera TCC grants.
2. Trigger `ci.yml` via the Actions tab → "Run workflow" → set
`run_real_hardware` to `true`.
The `real-hardware` job is gated on `runs-on: [self-hosted, macOS]` and
only fires for `workflow_dispatch` events with the input set.
### Required repo secrets (optional)
- `SEMGREP_APP_TOKEN` — only needed if you want findings posted to
Semgrep Cloud. Without it, scans still run with public rules and the
workflow log shows results inline.
`GITHUB_TOKEN` is provided automatically by Actions; no setup needed.
## Cross-references
- [README](README.md) — project overview and quick-start.