Merge Gate #2742
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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." |