name: CI
on:
push:
branches: [main, develop]
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/*.md'
- 'LICENSE'
pull_request:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/*.md'
- 'LICENSE'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_ENV: test
NODE_LTS: '22.x'
permissions:
contents: read
pull-requests: write
issues: write
actions: read
jobs:
# ============================================================
# Stage 1: Fast validation (single Node LTS)
# Deterministic checks that don't depend on Node version
# ============================================================
lint:
name: Lint & Validate
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_LTS }}
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install dependencies
run: npm ci --prefer-offline --no-audit
- name: Validate contracts
run: npm run validate:contracts
- name: Validate metadata
run: npm run validate:metadata
- name: Validate versions
run: npm run validate:versions
- name: Typecheck
run: npm run typecheck
- name: Lint (ratchet)
run: npm run lint:ratchet
# ============================================================
# Stage 2: Build (single Node LTS, upload artifact)
# Build once, share with test matrix
# ============================================================
build:
name: Build
needs: lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_LTS }}
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install dependencies
run: npm ci --prefer-offline --no-audit
- name: Build bundled distribution
run: npm run build
- name: Startup smoke test
run: npm run start:test
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: dist
path: server/dist/
retention-days: 1
# ============================================================
# Stage 3: Test matrix (LTS versions only)
# Download artifact, run tests on multiple Node versions
# ============================================================
test:
name: Test (Node ${{ matrix.node }})
needs: build
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['18.x', '20.x', '22.x']
defaults:
run:
working-directory: server
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install dependencies
run: npm ci --prefer-offline --no-audit
- name: Download build artifact
uses: actions/download-artifact@v7
with:
name: dist
path: server/dist/
- name: Unit tests
run: npm run test:ci
timeout-minutes: 15
- name: E2E tests
run: npm run test:e2e
# ============================================================
# Stage 4: Architecture validation (MCP paths only)
# Runs only when MCP/runtime code changes
# ============================================================
architecture:
name: Architecture Validation
needs: lint
runs-on: ubuntu-latest
if: |
contains(github.event.head_commit.modified, 'server/src/mcp-tools/') ||
contains(github.event.head_commit.modified, 'server/src/transport/') ||
contains(github.event.head_commit.modified, 'server/src/runtime/') ||
github.event_name == 'pull_request'
defaults:
run:
working-directory: server
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_LTS }}
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install dependencies
run: npm ci --prefer-offline --no-audit
- name: Validate architecture rules
run: npm run validate:arch
# ============================================================
# PR Summary: Comment with validation results
# ============================================================
pr-summary:
name: PR Summary
needs: [lint, build, test, architecture]
if: always() && github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Analyze changed files
id: changes
run: |
if git diff --name-only origin/${{ github.base_ref }}...HEAD > changed_files.txt 2>/dev/null; then
echo "files=$(cat changed_files.txt | tr '\n' ' ')" >> $GITHUB_OUTPUT
else
git diff --name-only HEAD~1 HEAD > changed_files.txt 2>/dev/null || echo "No changes" > changed_files.txt
echo "files=$(cat changed_files.txt | tr '\n' ' ')" >> $GITHUB_OUTPUT
fi
- name: Comment PR with results
uses: actions/github-script@v8
env:
LINT_RESULT: ${{ needs.lint.result }}
BUILD_RESULT: ${{ needs.build.result }}
TEST_RESULT: ${{ needs.test.result }}
ARCH_RESULT: ${{ needs.architecture.result }}
with:
script: |
const fs = require('fs');
let changedFiles = 'Unable to determine';
try {
changedFiles = fs.readFileSync('changed_files.txt', 'utf8').trim() || 'No changes detected';
} catch (e) {
console.log(`Could not read changed_files.txt: ${e.message}`);
}
const lintResult = process.env.LINT_RESULT || 'unknown';
const buildResult = process.env.BUILD_RESULT || 'unknown';
const testResult = process.env.TEST_RESULT || 'unknown';
const archResult = process.env.ARCH_RESULT || 'unknown';
const runUrl = `${context.payload.repository.html_url}/actions/runs/${context.runId}`;
const statusEmoji = (result) => result === 'success' ? '✅' : result === 'skipped' ? '⏭️' : '❌';
const allPassed = lintResult === 'success' && buildResult === 'success' && testResult === 'success' && (archResult === 'success' || archResult === 'skipped');
const message = [
allPassed ? '## ✅ CI Passed' : '## ❌ CI Failed',
'',
'| Stage | Status |',
'|-------|--------|',
`| Lint & Validate | ${statusEmoji(lintResult)} ${lintResult} |`,
`| Build | ${statusEmoji(buildResult)} ${buildResult} |`,
`| Test Matrix (Node 18, 20, 22) | ${statusEmoji(testResult)} ${testResult} |`,
`| Architecture | ${statusEmoji(archResult)} ${archResult} |`,
'',
'<details>',
'<summary>Files changed</summary>',
'',
'```',
changedFiles,
'```',
'</details>',
'',
`[View workflow run](${runUrl})`
].join('\n');
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
const botComment = comments.data.find(c => c.user.type === 'Bot' && (c.body.includes('CI Passed') || c.body.includes('CI Failed')));
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: message
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message
});
}