Skip to content

Merge Gate

Merge Gate #2742

Workflow file for this run

name: Merge Gate
on:
pull_request:
branches:
- main
# Run the same gate when GitHub's merge queue forms a merge group, so the
# "Merge Gate" required check reports a status for queued PRs. Without this,
# a merge-queue-required check would never run and PRs would be stuck.
# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue#triggering-merge-group-checks-with-github-actions
merge_group:
jobs:
detect-changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
lint: ${{ steps.check.outputs.lint }}
test: ${{ steps.check.outputs.test }}
pi_skills: ${{ steps.check.outputs.pi_skills }}
seed_skills: ${{ steps.check.outputs.seed_skills }}
script_types: ${{ steps.check.outputs.script_types }}
openapi: ${{ steps.check.outputs.openapi }}
vendored_openapi: ${{ steps.check.outputs.vendored_openapi }}
docker: ${{ steps.check.outputs.docker }}
ui: ${{ steps.check.outputs.ui }}
templates_workflows: ${{ steps.check.outputs.templates_workflows }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Determine base for diff
id: base
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "merge_group" ]; then
# merge_group events have no github.base_ref; diff against the
# merge group's base SHA (already reachable via fetch-depth: 0).
echo "ref=${{ github.event.merge_group.base_sha }}" >> "$GITHUB_OUTPUT"
else
git fetch origin "${{ github.base_ref }}"
echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT"
fi
- name: Compute per-domain change flags
id: check
run: |
set -euo pipefail
CHANGED=$(git diff --name-only ${{ steps.base.outputs.ref }}...HEAD)
echo "Changed files:"
echo "$CHANGED"
echo "---"
# Always re-run everything if the merge-gate workflow itself changed.
GATE_CHANGED=$(echo "$CHANGED" | grep -E '^\.github/workflows/merge-gate\.yml$' || true)
match() {
local pattern="$1"
if [ -n "$GATE_CHANGED" ]; then
echo "true"; return
fi
if echo "$CHANGED" | grep -E "$pattern" > /dev/null; then
echo "true"
else
echo "false"
fi
}
# lint + tsc:check: biome runs on src/, tsc covers src/ (scripts excluded in tsconfig).
# bunfig.toml (linker/test config), turbo.json, member manifests (root frozen
# install), and the dep-graph inputs (.dependency-cruiser.cjs, plugin/) are all
# load-bearing for this job since the workspace migration.
LINT=$(match '^(src/|apps/evals/|package\.json$|bun\.lock|bunfig\.toml$|turbo\.json$|konsistent\.json$|tsconfig\.json$|biome\.json$|\.dependency-cruiser\.cjs$|plugin/opencode-plugins/|apps/(ui|templates-ui)/package\.json$)')
# tests: src/ + scripts that tests import + lockfile/tsconfig + bunfig ([test]
# preload + pathIgnorePatterns) + member manifests (root frozen install).
TEST=$(match '^(src/|scripts/|package\.json$|bun\.lock|bunfig\.toml$|tsconfig\.json$|apps/(ui|templates-ui|evals)/package\.json$)')
# pi-skills freshness: generator inputs + outputs.
PI_SKILLS=$(match '^(plugin/commands/|plugin/pi-skills/|plugin/build-pi-skills\.ts$)')
# seeded-skill invariants + manifest freshness + the skill seeder tests.
# NOTE: neither LINT nor TEST matches templates/ or plugin/skills/, so
# without this flag a PR touching only those would run nothing but the
# Docker build and could merge a stale bundled-files.generated.json — or a
# baked skill colliding with a seeded one, which is the exact bug
# check-skill-sources.ts exists to prevent.
#
# This must list every INPUT the checks read, not just the seeded content:
# templates/skills/ seeded skills + bundled files
# plugin/skills/ baked skills (duplicate-delivery-path rule)
# integrations-catalog.ts templatePath entries (missing-remote-skill rule)
# Dockerfile.worker npx --skill entries (duplicate-delivery-path rule)
# the four scripts the checkers, generators, and vendor sync
# src/be/seed-skills/ seeder + generated manifest
# skill-fs-writer.ts the FS reconcile the tests cover
SEED_SKILLS=$(match '^(templates/skills/|templates/ai-toolbox\.manifest\.json$|plugin/skills/|plugin/pi-skills/|plugin/commands/|Dockerfile\.worker$|src/be/seed-skills/|src/utils/skill-fs-writer\.ts$|scripts/(build-seed-skill-files|build-skill-md|check-skill-sources|sync-ai-toolbox-skills)\.ts$|apps/ui/src/lib/integrations-catalog\.ts$)')
# script SDK types freshness: source constants + generator + outputs +
# allowlist/registry (generation validates SDK_ALLOWLIST against server.ts).
SCRIPT_TYPES=$(match '^(src/be/scripts/typecheck\.ts$|src/scripts-runtime/types/|src/scripts-runtime/sdk-allowlist\.ts$|scripts/bundle-script-types\.ts$|src/server\.ts$)')
# openapi freshness: routes, types, generator, version, generated artifacts.
OPENAPI=$(match '^(src/http/|src/types\.ts$|scripts/generate-openapi\.ts$|openapi\.json$|docs-site/content/docs/api-reference/|package\.json$)')
VENDORED_OPENAPI=$(match '^(vendored-openapi/|src/be/vendored-openapi\.ts$|src/be/script-connections\.ts$|src/http/script-connections\.ts$|src/tools/script-connections/|scripts/(check|refresh)-vendored-openapi\.ts$|scripts/vendored-openapi-utils\.ts$|package\.json$)')
# docker build test: mirrors docker-and-deploy.yml paths. All Dockerfiles now
# COPY bunfig.toml + the member manifests, .dockerignore shapes the context,
# and apps/evals/ feeds the evals-service image.
DOCKER=$(match '^(src/|vendored-openapi/|Dockerfile$|Dockerfile\.worker$|apps/evals/|\.dockerignore$|docker-entrypoint\.sh$|api-entrypoint\.sh$|package\.json$|bun\.lock|bunfig\.toml$|tsconfig\.json$|scripts/|templates/|plugin/|deploy/|apps/(ui|templates-ui)/package\.json$)')
# ui app lint + typecheck. ui's dependency tree is resolved by the ROOT
# bun.lock + root overrides since the workspace migration.
UI=$(match '^(apps/ui/|bun\.lock|package\.json$|bunfig\.toml$)')
# workflow template validation: content.md changes in templates/workflows/.
TEMPLATES_WORKFLOWS=$(match '^(templates/workflows/|scripts/validate-template-workflows\.ts$|src/workflows/definition\.ts$|src/types\.ts$)')
for k in LINT TEST PI_SKILLS SEED_SKILLS SCRIPT_TYPES OPENAPI VENDORED_OPENAPI DOCKER UI TEMPLATES_WORKFLOWS; do
v=$(eval echo \$$k)
key=$(echo "$k" | tr '[:upper:]' '[:lower:]')
echo "${key}=${v}" >> $GITHUB_OUTPUT
echo "${key}=${v}"
done
lint-and-typecheck:
name: Lint and Type Check
needs: detect-changes
if: needs.detect-changes.outputs.lint == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run Biome linter
id: lint
run: |
start=$(date +%s%3N)
set +e
bun run lint
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
- name: Run TypeScript type check
id: tsc
run: |
start=$(date +%s%3N)
set +e
bun run tsc:check
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
- name: Run structural consistency lint
id: structural-lint
continue-on-error: true
env:
KONSISTENT_NO_UPDATE_CHECK: "true"
run: |
start=$(date +%s%3N)
set +e
bun run lint:structure
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
# evals is now a Bun workspace member, so the root `bun install --frozen-lockfile`
# above already installs its deps into the shared hoisted node_modules. No separate
# `cd apps/evals && bun install` is needed.
- name: Type check evals
id: evals-tsc
run: |
start=$(date +%s%3N)
set +e
bun run tsc:check
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
working-directory: apps/evals
- name: Test evals
id: evals-test
# evals is its own bun package; these are pure unit tests (scoring,
# registry, normalize, scenario structure) — no E2B/network, ~0.5s.
run: |
start=$(date +%s%3N)
set +e
bun test
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
working-directory: apps/evals
- name: Build evals UI
id: evals-ui-build
run: |
start=$(date +%s%3N)
set +e
bun run ui:build
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
working-directory: apps/evals
- name: Check worker/API DB boundary
id: db-boundary
run: |
start=$(date +%s%3N)
set +e
bash scripts/check-db-boundary.sh
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
- name: Check API_KEY env-access boundary
id: api-key-boundary
run: |
start=$(date +%s%3N)
set +e
bash scripts/check-api-key-boundary.sh
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
- name: Check RBAC authorization boundary
id: rbac-boundary
run: |
start=$(date +%s%3N)
set +e
bash scripts/check-rbac-boundary.sh
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
- name: Check dependency graph (worker/API boundary)
id: dependency-graph
run: |
start=$(date +%s%3N)
set +e
bun run check:dep-graph
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
- name: Check DB audit columns
id: audit-columns
run: |
start=$(date +%s%3N)
set +e
bash scripts/check-audit-columns.sh
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
- name: Check SDK tool registration
id: sdk-tool-registration
run: |
start=$(date +%s%3N)
set +e
bun scripts/check-sdk-tool-registration.ts
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
- name: Check RBAC coverage (tools, verbs, routes)
id: rbac-coverage
run: |
start=$(date +%s%3N)
set +e
bun scripts/check-rbac-coverage.ts
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
- name: Check OpenAPI response-schema coverage
run: bun scripts/check-openapi-response-coverage.ts
# Same-run calls merge server-side. See agent-fs docs/ci-timings.md.
# Skipped on merge_group events and forks (no secret); never blocks the gate.
- name: Report check timings to ci-timings
if: always() && github.event_name == 'pull_request'
continue-on-error: true
env:
TOKEN: ${{ secrets.SWARM_CI_TIMINGS_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
PR_NUMBER: ${{ github.event.pull_request.number || 'null' }}
LINT_DURATION: ${{ steps.lint.outputs.duration_ms || '0' }}
LINT_STATUS: ${{ steps.lint.outputs.status || 'skipped' }}
TSC_DURATION: ${{ steps.tsc.outputs.duration_ms || '0' }}
TSC_STATUS: ${{ steps.tsc.outputs.status || 'skipped' }}
EVALS_TSC_DURATION: ${{ steps.evals-tsc.outputs.duration_ms || '0' }}
EVALS_TSC_STATUS: ${{ steps.evals-tsc.outputs.status || 'skipped' }}
EVALS_TEST_DURATION: ${{ steps.evals-test.outputs.duration_ms || '0' }}
EVALS_TEST_STATUS: ${{ steps.evals-test.outputs.status || 'skipped' }}
EVALS_UI_BUILD_DURATION: ${{ steps.evals-ui-build.outputs.duration_ms || '0' }}
EVALS_UI_BUILD_STATUS: ${{ steps.evals-ui-build.outputs.status || 'skipped' }}
STRUCTURAL_LINT_DURATION: ${{ steps.structural-lint.outputs.duration_ms || '0' }}
STRUCTURAL_LINT_STATUS: ${{ steps.structural-lint.outputs.status || 'skipped' }}
DB_BOUNDARY_DURATION: ${{ steps.db-boundary.outputs.duration_ms || '0' }}
DB_BOUNDARY_STATUS: ${{ steps.db-boundary.outputs.status || 'skipped' }}
API_KEY_BOUNDARY_DURATION: ${{ steps.api-key-boundary.outputs.duration_ms || '0' }}
API_KEY_BOUNDARY_STATUS: ${{ steps.api-key-boundary.outputs.status || 'skipped' }}
RBAC_BOUNDARY_DURATION: ${{ steps.rbac-boundary.outputs.duration_ms || '0' }}
RBAC_BOUNDARY_STATUS: ${{ steps.rbac-boundary.outputs.status || 'skipped' }}
DEPENDENCY_GRAPH_DURATION: ${{ steps.dependency-graph.outputs.duration_ms || '0' }}
DEPENDENCY_GRAPH_STATUS: ${{ steps.dependency-graph.outputs.status || 'skipped' }}
AUDIT_COLUMNS_DURATION: ${{ steps.audit-columns.outputs.duration_ms || '0' }}
AUDIT_COLUMNS_STATUS: ${{ steps.audit-columns.outputs.status || 'skipped' }}
SDK_TOOL_REGISTRATION_DURATION: ${{ steps.sdk-tool-registration.outputs.duration_ms || '0' }}
SDK_TOOL_REGISTRATION_STATUS: ${{ steps.sdk-tool-registration.outputs.status || 'skipped' }}
RBAC_COVERAGE_DURATION: ${{ steps.rbac-coverage.outputs.duration_ms || '0' }}
RBAC_COVERAGE_STATUS: ${{ steps.rbac-coverage.outputs.status || 'skipped' }}
run: |
set -euo pipefail
if [ -z "$TOKEN" ]; then echo "SWARM_CI_TIMINGS_TOKEN not available; skipping"; exit 0; fi
BOUNDARIES_DURATION=$((STRUCTURAL_LINT_DURATION + DB_BOUNDARY_DURATION + API_KEY_BOUNDARY_DURATION + RBAC_BOUNDARY_DURATION + DEPENDENCY_GRAPH_DURATION + AUDIT_COLUMNS_DURATION + SDK_TOOL_REGISTRATION_DURATION + RBAC_COVERAGE_DURATION))
BOUNDARIES_STATUS=success
for status in "$STRUCTURAL_LINT_STATUS" "$DB_BOUNDARY_STATUS" "$API_KEY_BOUNDARY_STATUS" "$RBAC_BOUNDARY_STATUS" "$DEPENDENCY_GRAPH_STATUS" "$AUDIT_COLUMNS_STATUS" "$SDK_TOOL_REGISTRATION_STATUS" "$RBAC_COVERAGE_STATUS"; do
if [ "$status" = failure ]; then BOUNDARIES_STATUS=failure; break; fi
if [ "$status" = skipped ]; then BOUNDARIES_STATUS=skipped; fi
done
BODY=$(jq -n \
--arg repo "$GITHUB_REPOSITORY" \
--arg branch "${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" \
--arg sha "$HEAD_SHA" \
--arg base "$GITHUB_BASE_REF" \
--argjson pr "$PR_NUMBER" \
--arg runId "$GITHUB_RUN_ID" \
--arg attempt "$GITHUB_RUN_ATTEMPT" \
--arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
--arg wf "$GITHUB_WORKFLOW" \
--arg event "$GITHUB_EVENT_NAME" \
--arg actor "$GITHUB_ACTOR" \
--argjson lintDur "$LINT_DURATION" --arg lintStatus "$LINT_STATUS" \
--argjson tscDur "$TSC_DURATION" --arg tscStatus "$TSC_STATUS" \
--argjson evalsTscDur "$EVALS_TSC_DURATION" --arg evalsTscStatus "$EVALS_TSC_STATUS" \
--argjson evalsTestDur "$EVALS_TEST_DURATION" --arg evalsTestStatus "$EVALS_TEST_STATUS" \
--argjson evalsUiBuildDur "$EVALS_UI_BUILD_DURATION" --arg evalsUiBuildStatus "$EVALS_UI_BUILD_STATUS" \
--argjson boundariesDur "$BOUNDARIES_DURATION" --arg boundariesStatus "$BOUNDARIES_STATUS" \
'{repo:$repo, branch:$branch, sha:$sha, baseBranch:$base, prNumber:$pr,
runId:$runId, runAttempt:$attempt, runUrl:$url, workflow:$wf,
event:$event, actor:$actor,
actions:[
{name:"lint", group:"checks", durationMs:$lintDur, status:$lintStatus},
{name:"tsc:check", group:"checks", durationMs:$tscDur, status:$tscStatus},
{name:"evals:tsc", group:"checks", durationMs:$evalsTscDur, status:$evalsTscStatus},
{name:"evals:test", group:"checks", durationMs:$evalsTestDur, status:$evalsTestStatus},
{name:"evals:ui-build", group:"checks", durationMs:$evalsUiBuildDur, status:$evalsUiBuildStatus},
{name:"checks:boundaries", group:"checks", durationMs:$boundariesDur, status:$boundariesStatus}
]}')
curl -sSf --max-time 60 -X POST https://api.desplega.agent-swarm.dev/api/x/script/MJlLzJEoRxWG \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d "$BODY"
test:
name: Run Tests (${{ matrix.shard }}/4)
needs: detect-changes
if: needs.detect-changes.outputs.test == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run tests
id: tests
# apps/evals/ is excluded from the root run via bunfig.toml pathIgnorePatterns
# (it's a separate bun package with its own deps + its own CI test step in
# the lint-and-typecheck job). Each shard gets its own runner because these
# tests start servers, subprocesses, and SQLite workers.
run: |
start=$(date +%s%3N)
set +e
bun run test:root -- --shard=${{ matrix.shard }}/4
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
# Each shard reports independently. See agent-fs docs/ci-timings.md.
# Skipped on merge_group events and forks (no secret); never blocks the gate.
- name: Report test timing to ci-timings
if: always() && github.event_name == 'pull_request'
continue-on-error: true
env:
TOKEN: ${{ secrets.SWARM_CI_TIMINGS_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
PR_NUMBER: ${{ github.event.pull_request.number || 'null' }}
run: |
set -euo pipefail
if [ -z "$TOKEN" ]; then echo "SWARM_CI_TIMINGS_TOKEN not available; skipping"; exit 0; fi
BODY=$(jq -n \
--arg repo "$GITHUB_REPOSITORY" \
--arg branch "${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" \
--arg sha "$HEAD_SHA" \
--arg base "$GITHUB_BASE_REF" \
--argjson pr "$PR_NUMBER" \
--arg runId "$GITHUB_RUN_ID" \
--arg attempt "$GITHUB_RUN_ATTEMPT" \
--arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
--arg wf "$GITHUB_WORKFLOW" \
--arg event "$GITHUB_EVENT_NAME" \
--arg actor "$GITHUB_ACTOR" \
--argjson dur "${{ steps.tests.outputs.duration_ms || '0' }}" \
--arg status "${{ steps.tests.outputs.status || 'skipped' }}" \
--argjson idx "${{ matrix.shard }}" \
'{repo:$repo, branch:$branch, sha:$sha, baseBranch:$base, prNumber:$pr,
runId:$runId, runAttempt:$attempt, runUrl:$url, workflow:$wf,
event:$event, actor:$actor,
actions:[{name:"test:root", group:"tests", durationMs:$dur, status:$status,
shard:{index:$idx, total:4}, job:("test (\($idx)/4)")}]}')
curl -sSf --max-time 60 -X POST https://api.desplega.agent-swarm.dev/api/x/script/MJlLzJEoRxWG \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d "$BODY"
pi-skills-freshness:
name: Pi-Skills Freshness Check
needs: detect-changes
if: needs.detect-changes.outputs.pi_skills == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Rebuild pi-skills and check for drift
run: |
bun run build:pi-skills
if [ -n "$(git diff --name-only plugin/pi-skills/)" ]; then
echo "::error::pi-skills are out of date! Run 'bun run build:pi-skills' and commit the changes."
git diff --stat plugin/pi-skills/
exit 1
fi
echo "pi-skills are up to date."
seed-skills:
name: Seeded Skills Check
needs: detect-changes
if: needs.detect-changes.outputs.seed_skills == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Check skill source-of-truth invariants
run: bun run check:skill-sources
- name: Check vendored ai-toolbox skills
run: bun run check:ai-toolbox-skills
- name: Check generated SKILL.md freshness
run: bun run check:skill-md
- name: Check bundled-file manifest freshness
run: bun run check:seed-skill-files
- name: Run skill seeder tests
# LINT/TEST do not match templates/, so these would not otherwise run for
# a templates/skills-only change.
run: bun run test:root -- src/tests/seed-skills-bundled-files.test.ts src/tests/system-default-skills.test.ts src/tests/skill-fs-writer.test.ts src/tests/skill-sync.test.ts src/tests/sync-ai-toolbox-skills.test.ts src/tests/skill-parser.test.ts
script-types-freshness:
name: Script SDK Types Freshness Check
needs: detect-changes
if: needs.detect-changes.outputs.script_types == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Rebuild script SDK types and check for drift
run: bun run check:script-types
openapi-freshness:
name: OpenAPI Spec Freshness Check
needs: detect-changes
if: needs.detect-changes.outputs.openapi == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Rebuild OpenAPI spec and check for drift
run: |
bun run docs:openapi
if [ -n "$(git diff --name-only openapi.json)" ]; then
echo "::error::openapi.json is out of date! Run 'bun run docs:openapi' and commit the changes."
git diff --stat openapi.json
exit 1
fi
echo "OpenAPI spec is up to date."
- name: Check generated API docs for drift
run: |
if [ -n "$(git diff --name-only docs-site/content/docs/api-reference/ docs-site/public/openapi.d.ts)" ]; then
echo "::error::API reference docs are out of date! Run 'bun run docs:openapi' and commit the changes."
git diff --stat docs-site/content/docs/api-reference/ docs-site/public/openapi.d.ts
exit 1
fi
echo "API reference docs are up to date."
- name: Check for raw matchRoute usage
run: |
UNREGISTERED=$(grep -rn "matchRoute(" src/http/ --include="*.ts" | grep -v "route-def.ts" | grep -v "utils.ts" || true)
if [ -n "$UNREGISTERED" ]; then
echo "::error::Found raw matchRoute() calls. Convert to route() definitions:"
echo "$UNREGISTERED"
exit 1
fi
echo "No raw matchRoute() calls found."
vendored-openapi:
name: Vendored OpenAPI Check
needs: detect-changes
if: needs.detect-changes.outputs.vendored_openapi == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Check vendored OpenAPI determinism
run: bun run check:vendored-openapi
docker-build:
name: Docker Build Test
needs: detect-changes
if: needs.detect-changes.outputs.docker == 'true'
runs-on: ubuntu-latest
strategy:
matrix:
# apps/evals/Dockerfile is the standalone evals-service image (Dokploy); it installs
# from the ROOT workspace lockfile, so workspace changes can break it silently
# unless it is built here too.
# The worker builds only the worker-slim target here (fast PR gate — it still
# exercises the shared worker-base layers and the leaf block); the full target
# is built and published on merge by docker-and-deploy.yml.
# `metric` legs load the built image into the local daemon and report its
# uncompressed size to the ci-metrics swarm script (sticky PR diff comment).
# See agent-fs docs/ci-metrics.md for the script contract.
include:
- dockerfile: Dockerfile
target: ""
metric: image.api
timing: docker.api
- dockerfile: Dockerfile.worker
target: worker-slim
metric: image.worker-slim
timing: docker.worker-slim
- dockerfile: apps/evals/Dockerfile
target: ""
metric: ""
timing: docker.evals
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Start Docker build timer
id: docker-timer
run: echo "started_at=$(date +%s%3N)" >> "$GITHUB_OUTPUT"
- name: Build Docker image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./${{ matrix.dockerfile }}
target: ${{ matrix.target }}
push: false
load: ${{ matrix.metric != '' }}
tags: test-image:latest
# Each matrix leg reports independently. See agent-fs docs/ci-timings.md.
# Skipped on merge_group events and forks (no secret); never blocks the gate.
- name: Report Docker build timing to ci-timings
if: always() && github.event_name == 'pull_request'
continue-on-error: true
env:
TOKEN: ${{ secrets.SWARM_CI_TIMINGS_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
PR_NUMBER: ${{ github.event.pull_request.number || 'null' }}
STARTED_AT: ${{ steps.docker-timer.outputs.started_at || '0' }}
BUILD_STATUS: ${{ job.status }}
run: |
set -euo pipefail
if [ -z "$TOKEN" ]; then echo "SWARM_CI_TIMINGS_TOKEN not available; skipping"; exit 0; fi
if [ "$STARTED_AT" -eq 0 ]; then DURATION=0; BUILD_STATUS=skipped; else DURATION=$(( $(date +%s%3N) - STARTED_AT )); fi
BODY=$(jq -n \
--arg repo "$GITHUB_REPOSITORY" \
--arg branch "${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" \
--arg sha "$HEAD_SHA" \
--arg base "$GITHUB_BASE_REF" \
--argjson pr "$PR_NUMBER" \
--arg runId "$GITHUB_RUN_ID" \
--arg attempt "$GITHUB_RUN_ATTEMPT" \
--arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
--arg wf "$GITHUB_WORKFLOW" \
--arg event "$GITHUB_EVENT_NAME" \
--arg actor "$GITHUB_ACTOR" \
--arg name "${{ matrix.timing }}" \
--argjson dur "$DURATION" \
--arg status "$BUILD_STATUS" \
'{repo:$repo, branch:$branch, sha:$sha, baseBranch:$base, prNumber:$pr,
runId:$runId, runAttempt:$attempt, runUrl:$url, workflow:$wf,
event:$event, actor:$actor,
actions:[{name:$name, group:"build", durationMs:$dur, status:$status}]}')
curl -sSf --max-time 60 -X POST https://api.desplega.agent-swarm.dev/api/x/script/MJlLzJEoRxWG \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d "$BODY"
# Same-sha calls merge server-side, so each matrix leg posts its own metric
# and they all land in ONE sticky "Docker image sizes" PR comment.
# Skipped on merge_group events and forks (no secret); never blocks the gate.
- name: Report image size to ci-metrics
if: matrix.metric != '' && github.event_name == 'pull_request'
continue-on-error: true
env:
TOKEN: ${{ secrets.SWARM_CI_METRICS_TOKEN }}
METRIC: ${{ matrix.metric }}
PR: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
if [ -z "$TOKEN" ]; then echo "SWARM_CI_METRICS_TOKEN not available; skipping"; exit 0; fi
SIZE=$(docker image inspect test-image:latest --format '{{.Size}}')
BODY=$(jq -n \
--arg sha "$HEAD_SHA" \
--arg branch "$GITHUB_HEAD_REF" \
--arg runUrl "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
--argjson pr "$PR" \
--arg name "$METRIC" \
--argjson value "$SIZE" \
'{repo: "desplega-ai/agent-swarm", sha: $sha, branch: $branch, prNumber: $pr,
runUrl: $runUrl, commentKey: "docker", title: "Docker image sizes (uncompressed, amd64)",
metrics: [{name: $name, value: $value, unit: "bytes", group: "docker"}]}')
curl -sSf --max-time 60 -X POST https://api.desplega.agent-swarm.dev/api/x/script/JWCeFHIQUAQQ \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "$BODY" | jq -r '.result.markdown // empty' >> "$GITHUB_STEP_SUMMARY"
ui-lint:
name: UI Lint and Type Check
needs: detect-changes
if: needs.detect-changes.outputs.ui == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
# ui is now a Bun workspace member; the root install hoists its deps. Run the
# ui-scoped scripts from the apps/ui/ directory against that shared node_modules.
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Check design tokens (no raw color literals)
id: ui-tokens
run: |
start=$(date +%s%3N)
set +e
bun run check:tokens
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
working-directory: apps/ui
- name: Run Biome linter
id: ui-lint
run: |
start=$(date +%s%3N)
set +e
bun run lint
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
working-directory: apps/ui
- name: Run TypeScript type check
id: ui-tsc
run: |
start=$(date +%s%3N)
set +e
bunx tsc -b
code=$?
set -e
echo "duration_ms=$(( $(date +%s%3N) - start ))" >> "$GITHUB_OUTPUT"
echo "status=$([ $code -eq 0 ] && echo success || echo failure)" >> "$GITHUB_OUTPUT"
exit $code
working-directory: apps/ui
# See agent-fs docs/ci-timings.md. Skipped on merge_group events and forks
# (no secret); reporting is best-effort and never blocks the gate.
- name: Report UI check timings to ci-timings
if: always() && github.event_name == 'pull_request'
continue-on-error: true
env:
TOKEN: ${{ secrets.SWARM_CI_TIMINGS_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
PR_NUMBER: ${{ github.event.pull_request.number || 'null' }}
run: |
set -euo pipefail
if [ -z "$TOKEN" ]; then echo "SWARM_CI_TIMINGS_TOKEN not available; skipping"; exit 0; fi
BODY=$(jq -n \
--arg repo "$GITHUB_REPOSITORY" \
--arg branch "${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" \
--arg sha "$HEAD_SHA" \
--arg base "$GITHUB_BASE_REF" \
--argjson pr "$PR_NUMBER" \
--arg runId "$GITHUB_RUN_ID" \
--arg attempt "$GITHUB_RUN_ATTEMPT" \
--arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
--arg wf "$GITHUB_WORKFLOW" \
--arg event "$GITHUB_EVENT_NAME" \
--arg actor "$GITHUB_ACTOR" \
--argjson lintDur "${{ steps.ui-lint.outputs.duration_ms || '0' }}" \
--arg lintStatus "${{ steps.ui-lint.outputs.status || 'skipped' }}" \
--argjson tscDur "${{ steps.ui-tsc.outputs.duration_ms || '0' }}" \
--arg tscStatus "${{ steps.ui-tsc.outputs.status || 'skipped' }}" \
--argjson tokensDur "${{ steps.ui-tokens.outputs.duration_ms || '0' }}" \
--arg tokensStatus "${{ steps.ui-tokens.outputs.status || 'skipped' }}" \
'{repo:$repo, branch:$branch, sha:$sha, baseBranch:$base, prNumber:$pr,
runId:$runId, runAttempt:$attempt, runUrl:$url, workflow:$wf,
event:$event, actor:$actor,
actions:[
{name:"ui:lint", group:"checks", durationMs:$lintDur, status:$lintStatus},
{name:"ui:tsc", group:"checks", durationMs:$tscDur, status:$tscStatus},
{name:"ui:tokens", group:"checks", durationMs:$tokensDur, status:$tokensStatus}
]}')
curl -sSf --max-time 60 -X POST https://api.desplega.agent-swarm.dev/api/x/script/MJlLzJEoRxWG \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d "$BODY"
template-workflow-validation:
name: Validate Workflow Templates
needs: detect-changes
if: needs.detect-changes.outputs.templates_workflows == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Validate workflow templates
run: bun run scripts/validate-template-workflows.ts
gate:
name: Merge Gate
needs: [detect-changes, lint-and-typecheck, test, pi-skills-freshness, seed-skills, script-types-freshness, openapi-freshness, vendored-openapi, docker-build, ui-lint, template-workflow-validation]
if: always()
runs-on: ubuntu-latest
steps:
- name: Check results
run: |
results='${{ toJSON(needs.*.result) }}'
echo "Job results: $results"
if echo "$results" | jq -e 'map(select(. != "success" and . != "skipped")) | length > 0' > /dev/null 2>&1; then
echo "::error::Some required jobs failed!"
exit 1
fi
echo "All jobs passed or were skipped."