name: Node.js CI on: workflow_call: permissions: contents: read pull-requests: write issues: write jobs: node-ci: name: Node.js CI - Lint, Build, Test, Type Check runs-on: blacksmith-8vcpu-ubuntu-2404-arm env: GITHUB_REPO_NAME: ${{ github.repository }} steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '24' - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 10 run_install: false - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libpam0g-dev - name: Cache TypeScript build info uses: useblacksmith/cache@v5 continue-on-error: true with: path: | web/dist/tsconfig.server.tsbuildinfo web/public/tsconfig.client.tsbuildinfo web/public/tsconfig.sw.tsbuildinfo key: ${{ runner.os }}-tsbuild-${{ hashFiles('web/src/**/*.ts', 'web/tsconfig*.json') }} restore-keys: | ${{ runner.os }}-tsbuild- - name: Install dependencies working-directory: web run: | pnpm config set network-concurrency 4 pnpm config set child-concurrency 2 pnpm install --frozen-lockfile --prefer-offline - name: Build node-pty working-directory: web run: | cd node-pty && npm install && npm run build # Run all checks - build first, then tests - name: Run all checks working-directory: web run: | # Create a temporary directory for outputs mkdir -p ci-outputs # Run format, lint, typecheck, and audit in parallel (these don't depend on build) ( echo "Starting format check..." pnpm run format:check > ci-outputs/format-output.txt 2>&1 echo $? > ci-outputs/format-exit-code.txt echo "Format check completed" ) & ( echo "Starting lint..." pnpm run lint:biome > ci-outputs/lint-output.txt 2>&1 echo $? > ci-outputs/lint-exit-code.txt echo "Lint completed" ) & ( echo "Starting type check..." pnpm run typecheck > ci-outputs/typecheck-output.txt 2>&1 echo $? > ci-outputs/typecheck-exit-code.txt echo "Type check completed" ) & ( echo "Starting security audit..." pnpm audit --audit-level=moderate > ci-outputs/audit-output.txt 2>&1 || true echo 0 > ci-outputs/audit-exit-code.txt # Don't fail on audit echo "Audit completed" ) & # Wait for parallel checks wait # Run build (must complete before tests) echo "Starting build..." export ESBUILD_MAX_WORKERS=$(nproc) pnpm run build:ci > ci-outputs/build-output.txt 2>&1 echo $? > ci-outputs/build-exit-code.txt echo "Build completed" # Run tests after build completes echo "Starting tests..." # Run client and server tests sequentially to avoid conflicts pnpm run test:client:coverage > ci-outputs/test-client-output.txt 2>&1 CLIENT_EXIT=$? pnpm run test:server:coverage > ci-outputs/test-server-output.txt 2>&1 SERVER_EXIT=$? # Return non-zero if either test failed if [ $CLIENT_EXIT -ne 0 ] || [ $SERVER_EXIT -ne 0 ]; then echo 1 > ci-outputs/test-exit-code.txt else echo 0 > ci-outputs/test-exit-code.txt fi echo "Tests completed" echo "All checks completed" # Process results - name: Process check results if: always() id: results working-directory: web run: | # Read exit codes FORMAT_EXIT=$(cat ci-outputs/format-exit-code.txt || echo 1) LINT_EXIT=$(cat ci-outputs/lint-exit-code.txt || echo 1) TYPECHECK_EXIT=$(cat ci-outputs/typecheck-exit-code.txt || echo 1) BUILD_EXIT=$(cat ci-outputs/build-exit-code.txt || echo 1) TEST_EXIT=$(cat ci-outputs/test-exit-code.txt || echo 1) # Set outputs echo "format_result=$FORMAT_EXIT" >> $GITHUB_OUTPUT echo "lint_result=$LINT_EXIT" >> $GITHUB_OUTPUT echo "typecheck_result=$TYPECHECK_EXIT" >> $GITHUB_OUTPUT echo "build_result=$BUILD_EXIT" >> $GITHUB_OUTPUT echo "test_result=$TEST_EXIT" >> $GITHUB_OUTPUT # Read outputs for reporting echo 'format_output<> $GITHUB_OUTPUT cat ci-outputs/format-output.txt 2>/dev/null || echo "No output" >> $GITHUB_OUTPUT echo 'EOF' >> $GITHUB_OUTPUT echo 'lint_output<> $GITHUB_OUTPUT cat ci-outputs/lint-output.txt 2>/dev/null || echo "No output" >> $GITHUB_OUTPUT echo 'EOF' >> $GITHUB_OUTPUT echo 'typecheck_output<> $GITHUB_OUTPUT cat ci-outputs/typecheck-output.txt 2>/dev/null || echo "No output" >> $GITHUB_OUTPUT echo 'EOF' >> $GITHUB_OUTPUT echo 'build_output<> $GITHUB_OUTPUT tail -n 50 ci-outputs/build-output.txt 2>/dev/null || echo "No output" >> $GITHUB_OUTPUT echo 'EOF' >> $GITHUB_OUTPUT echo 'test_output<> $GITHUB_OUTPUT tail -n 100 ci-outputs/test-client-output.txt 2>/dev/null || echo "No client test output" >> $GITHUB_OUTPUT echo "---" >> $GITHUB_OUTPUT tail -n 100 ci-outputs/test-server-output.txt 2>/dev/null || echo "No server test output" >> $GITHUB_OUTPUT echo 'EOF' >> $GITHUB_OUTPUT echo 'audit_output<> $GITHUB_OUTPUT cat ci-outputs/audit-output.txt 2>/dev/null || echo "No output" >> $GITHUB_OUTPUT echo 'EOF' >> $GITHUB_OUTPUT # Determine overall result if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $TYPECHECK_EXIT -ne 0 ] || [ $BUILD_EXIT -ne 0 ] || [ $TEST_EXIT -ne 0 ]; then echo "overall_result=failure" >> $GITHUB_OUTPUT else echo "overall_result=success" >> $GITHUB_OUTPUT fi # Generate coverage summary - name: Generate coverage summaries if: always() working-directory: web run: | # Process client coverage if [ -f coverage/client/coverage-summary.json ]; then node -e " const coverage = require('./coverage/client/coverage-summary.json'); const total = coverage.total; const summary = { type: 'client', lines: { pct: total.lines.pct, covered: total.lines.covered, total: total.lines.total }, statements: { pct: total.statements.pct, covered: total.statements.covered, total: total.statements.total }, functions: { pct: total.functions.pct, covered: total.functions.covered, total: total.functions.total }, branches: { pct: total.branches.pct, covered: total.branches.covered, total: total.branches.total } }; console.log(JSON.stringify(summary, null, 2)); " > coverage-client-summary.json else echo '{"error": "No client coverage data found"}' > coverage-client-summary.json fi # Process server coverage if [ -f coverage/server/coverage-summary.json ]; then node -e " const coverage = require('./coverage/server/coverage-summary.json'); const total = coverage.total; const summary = { type: 'server', lines: { pct: total.lines.pct, covered: total.lines.covered, total: total.lines.total }, statements: { pct: total.statements.pct, covered: total.statements.covered, total: total.statements.total }, functions: { pct: total.functions.pct, covered: total.functions.covered, total: total.functions.total }, branches: { pct: total.branches.pct, covered: total.branches.covered, total: total.branches.total } }; console.log(JSON.stringify(summary, null, 2)); " > coverage-server-summary.json else echo '{"error": "No server coverage data found"}' > coverage-server-summary.json fi # Report results - name: Report Format Results if: always() uses: ./.github/actions/lint-reporter with: title: 'Node.js Biome Formatting' lint-result: ${{ steps.results.outputs.format_result == '0' && 'success' || 'failure' }} lint-output: ${{ steps.results.outputs.format_output }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Report Lint Results if: always() uses: ./.github/actions/lint-reporter with: title: 'Node.js Biome Linting' lint-result: ${{ steps.results.outputs.lint_result == '0' && 'success' || 'failure' }} lint-output: ${{ steps.results.outputs.lint_output }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Report TypeCheck Results if: always() uses: ./.github/actions/lint-reporter with: title: 'Node.js TypeScript Type Checking' lint-result: ${{ steps.results.outputs.typecheck_result == '0' && 'success' || 'failure' }} lint-output: ${{ steps.results.outputs.typecheck_output }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Report Build Results if: always() uses: ./.github/actions/lint-reporter with: title: 'Node.js Build' lint-result: ${{ steps.results.outputs.build_result == '0' && 'success' || 'failure' }} lint-output: ${{ steps.results.outputs.build_output }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Report Test Results if: always() uses: ./.github/actions/lint-reporter with: title: 'Node.js Tests' lint-result: ${{ steps.results.outputs.test_result == '0' && 'success' || 'failure' }} lint-output: ${{ steps.results.outputs.test_output }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Report Audit Results if: always() uses: ./.github/actions/lint-reporter with: title: 'Node.js Security Audit' lint-result: 'success' lint-output: ${{ steps.results.outputs.audit_output }} github-token: ${{ secrets.GITHUB_TOKEN }} # Format and report coverage - name: Format coverage output id: format-coverage if: always() && github.event_name == 'pull_request' working-directory: web run: | # Format client coverage CLIENT_OUTPUT="**Client Coverage:**\n" if [ -f coverage-client-summary.json ] && [ "$(jq -r '.error // empty' coverage-client-summary.json)" = "" ]; then CLIENT_LINES=$(jq -r '.lines.pct' coverage-client-summary.json) CLIENT_FUNCTIONS=$(jq -r '.functions.pct' coverage-client-summary.json) CLIENT_BRANCHES=$(jq -r '.branches.pct' coverage-client-summary.json) CLIENT_STATEMENTS=$(jq -r '.statements.pct' coverage-client-summary.json) CLIENT_OUTPUT="${CLIENT_OUTPUT}• Lines: ${CLIENT_LINES}%\n" CLIENT_OUTPUT="${CLIENT_OUTPUT}• Functions: ${CLIENT_FUNCTIONS}%\n" CLIENT_OUTPUT="${CLIENT_OUTPUT}• Branches: ${CLIENT_BRANCHES}%\n" CLIENT_OUTPUT="${CLIENT_OUTPUT}• Statements: ${CLIENT_STATEMENTS}%\n" else CLIENT_OUTPUT="${CLIENT_OUTPUT}No client coverage data found\n" fi # Format server coverage SERVER_OUTPUT="\n**Server Coverage:**\n" if [ -f coverage-server-summary.json ] && [ "$(jq -r '.error // empty' coverage-server-summary.json)" = "" ]; then SERVER_LINES=$(jq -r '.lines.pct' coverage-server-summary.json) SERVER_FUNCTIONS=$(jq -r '.functions.pct' coverage-server-summary.json) SERVER_BRANCHES=$(jq -r '.branches.pct' coverage-server-summary.json) SERVER_STATEMENTS=$(jq -r '.statements.pct' coverage-server-summary.json) SERVER_OUTPUT="${SERVER_OUTPUT}• Lines: ${SERVER_LINES}%\n" SERVER_OUTPUT="${SERVER_OUTPUT}• Functions: ${SERVER_FUNCTIONS}%\n" SERVER_OUTPUT="${SERVER_OUTPUT}• Branches: ${SERVER_BRANCHES}%\n" SERVER_OUTPUT="${SERVER_OUTPUT}• Statements: ${SERVER_STATEMENTS}%" else SERVER_OUTPUT="${SERVER_OUTPUT}No server coverage data found" fi echo "output<> $GITHUB_OUTPUT echo -e "${CLIENT_OUTPUT}${SERVER_OUTPUT}" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - name: Report Coverage Results if: always() && github.event_name == 'pull_request' uses: ./.github/actions/lint-reporter with: title: 'Node.js Test Coverage' lint-result: 'success' lint-output: ${{ steps.format-coverage.outputs.output }} github-token: ${{ secrets.GITHUB_TOKEN }} # Upload artifacts - name: Upload coverage artifacts if: always() uses: actions/upload-artifact@v4 with: name: node-coverage path: | web/coverage-client-summary.json web/coverage-server-summary.json web/coverage/client/lcov.info web/coverage/server/lcov.info # Check overall result - name: Check overall result if: always() run: | if [ "${{ steps.results.outputs.overall_result }}" = "failure" ]; then echo "::error::One or more checks failed" exit 1 fi