ci: stop node_modules cache restore-keys poisoning #9
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
| # MDK private monorepo (mdk-prv): backend/core/*, backend/workers/**, ui (all npm). | |
| # Aligned with MiningOS CI Checks: per-package lockfile check, node cache actions, parallel jobs, PR audits. | |
| name: CI Checks | |
| on: | |
| push: | |
| branches: [main, develop, staging, "release/**"] | |
| pull_request: | |
| branches: [main, develop, staging, "release/**"] | |
| types: [opened, reopened, synchronize] | |
| workflow_dispatch: | |
| inputs: | |
| target: | |
| description: "Which pipeline to run" | |
| required: true | |
| default: auto | |
| type: choice | |
| options: | |
| - auto | |
| - ui | |
| - core | |
| - workers | |
| - all | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| NODE_VERSION: "lts/*" | |
| UI_DIR: ui | |
| jobs: | |
| detect-target: | |
| name: Detect changed areas | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| run_ui: ${{ steps.flags.outputs.run_ui }} | |
| run_core: ${{ steps.flags.outputs.run_core }} | |
| run_workers: ${{ steps.flags.outputs.run_workers }} | |
| reason: ${{ steps.flags.outputs.reason }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Detect changed paths | |
| id: changed | |
| shell: bash | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} | |
| HEAD_SHA: ${{ github.sha }} | |
| run: | | |
| ui=false; core=false; workers=false | |
| if [ "$EVENT_NAME" != "workflow_dispatch" ]; then | |
| if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then | |
| git fetch origin main --depth=1 2>/dev/null || true | |
| BASE_SHA=$(git merge-base HEAD origin/main 2>/dev/null || echo "") | |
| fi | |
| [ -n "$BASE_SHA" ] && CHANGED=$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}") || CHANGED="" | |
| echo "$CHANGED" | grep -q "^ui/" && ui=true || true | |
| echo "$CHANGED" | grep -q "^backend/core/" && core=true || true | |
| echo "$CHANGED" | grep -q "^backend/workers/" && workers=true || true | |
| fi | |
| echo "ui=$ui" >> "$GITHUB_OUTPUT" | |
| echo "core=$core" >> "$GITHUB_OUTPUT" | |
| echo "workers=$workers" >> "$GITHUB_OUTPUT" | |
| - name: Resolve which pipelines should run | |
| id: flags | |
| shell: bash | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| INPUT_TARGET: ${{ github.event_name == 'workflow_dispatch' && inputs.target || 'auto' }} | |
| UI_CHANGED: ${{ steps.changed.outputs.ui }} | |
| CORE_CHANGED: ${{ steps.changed.outputs.core }} | |
| WORKERS_CHANGED: ${{ steps.changed.outputs.workers }} | |
| run: | | |
| run_ui=false | |
| run_core=false | |
| run_workers=false | |
| reason="" | |
| has_ui_lock=false | |
| [ -f "${{ env.UI_DIR }}/package-lock.json" ] && has_ui_lock=true | |
| if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$INPUT_TARGET" != "auto" ]; then | |
| case "$INPUT_TARGET" in | |
| ui) run_ui=true; run_core=false; run_workers=false; reason="manual=ui" ;; | |
| core) run_ui=false; run_core=true; run_workers=false; reason="manual=core" ;; | |
| workers) run_ui=false; run_core=false; run_workers=true; reason="manual=workers" ;; | |
| all) run_ui=true; run_core=true; run_workers=true; reason="manual=all" ;; | |
| *) run_ui=true; run_core=true; run_workers=true; reason="manual=unknown-fallback-all" ;; | |
| esac | |
| else | |
| if [ "$UI_CHANGED" = "true" ] && [ "$has_ui_lock" = "true" ]; then run_ui=true; fi | |
| if [ "$CORE_CHANGED" = "true" ]; then run_core=true; fi | |
| if [ "$WORKERS_CHANGED" = "true" ]; then run_workers=true; fi | |
| if [ "$run_ui" = "false" ] && [ "$run_core" = "false" ] && [ "$run_workers" = "false" ]; then | |
| run_ui=$([ "$has_ui_lock" = "true" ] && echo true || echo false) | |
| run_core=true | |
| run_workers=true | |
| reason="auto=no-targeted-change->run-core-workers" | |
| else | |
| reason="auto=changed-paths" | |
| fi | |
| fi | |
| echo "run_ui=$run_ui" >> "$GITHUB_OUTPUT" | |
| echo "run_core=$run_core" >> "$GITHUB_OUTPUT" | |
| echo "run_workers=$run_workers" >> "$GITHUB_OUTPUT" | |
| echo "reason=$reason" >> "$GITHUB_OUTPUT" | |
| echo "Selected: ui=$run_ui core=$run_core workers=$run_workers ($reason)" | |
| list-workers: | |
| name: List worker packages | |
| runs-on: ubuntu-latest | |
| needs: [detect-target] | |
| if: needs.detect-target.outputs.run_workers == 'true' | |
| outputs: | |
| dirs: ${{ steps.list.outputs.dirs }} | |
| buildable_dirs: ${{ steps.list.outputs.buildable_dirs }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - name: Discover worker package directories | |
| id: list | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mapfile -t dirs < <( | |
| find backend/workers -name package.json -not -path '*/node_modules/*' \ | |
| | xargs -I{} dirname {} | sort -u | |
| ) | |
| if [ "${#dirs[@]}" -eq 0 ]; then | |
| echo "::error::No package.json under backend/workers/" | |
| exit 1 | |
| fi | |
| json=$(printf '%s\n' "${dirs[@]}" | jq -R . | jq -s -c .) | |
| echo "dirs=${json}" >> "$GITHUB_OUTPUT" | |
| echo "Worker packages: ${json}" | |
| buildable=() | |
| for dir in "${dirs[@]}"; do | |
| node -e "const s=require('./${dir}/package.json').scripts||{}; process.exit(s.build?0:1)" 2>/dev/null \ | |
| && buildable+=("$dir") || true | |
| done | |
| if [ "${#buildable[@]}" -gt 0 ]; then | |
| buildable_json=$(printf '%s\n' "${buildable[@]}" | jq -R . | jq -s -c .) | |
| else | |
| buildable_json='[]' | |
| fi | |
| echo "buildable_dirs=${buildable_json}" >> "$GITHUB_OUTPUT" | |
| echo "Buildable worker packages: ${buildable_json}" | |
| list-core: | |
| name: List core packages | |
| runs-on: ubuntu-latest | |
| needs: [detect-target] | |
| if: needs.detect-target.outputs.run_core == 'true' | |
| outputs: | |
| dirs: ${{ steps.list.outputs.dirs }} | |
| buildable_dirs: ${{ steps.list.outputs.buildable_dirs }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - name: Discover core package directories | |
| id: list | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mapfile -t dirs < <( | |
| find backend/core -name package.json -not -path '*/node_modules/*' \ | |
| | xargs -I{} dirname {} | sort -u | |
| ) | |
| if [ "${#dirs[@]}" -eq 0 ]; then | |
| echo "::error::No package.json under backend/core/" | |
| exit 1 | |
| fi | |
| json=$(printf '%s\n' "${dirs[@]}" | jq -R . | jq -s -c .) | |
| echo "dirs=${json}" >> "$GITHUB_OUTPUT" | |
| echo "Core packages: ${json}" | |
| buildable=() | |
| for dir in "${dirs[@]}"; do | |
| node -e "const s=require('./${dir}/package.json').scripts||{}; process.exit(s.build?0:1)" 2>/dev/null \ | |
| && buildable+=("$dir") || true | |
| done | |
| if [ "${#buildable[@]}" -gt 0 ]; then | |
| buildable_json=$(printf '%s\n' "${buildable[@]}" | jq -R . | jq -s -c .) | |
| else | |
| buildable_json='[]' | |
| fi | |
| echo "buildable_dirs=${buildable_json}" >> "$GITHUB_OUTPUT" | |
| echo "Buildable core packages: ${buildable_json}" | |
| setup-ui: | |
| name: 📦 Setup UI (ui) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [detect-target] | |
| if: needs.detect-target.outputs.run_ui == 'true' | |
| outputs: | |
| cache-key: ${{ steps.setup-deps.outputs.cache-key }} | |
| lockfile-outcome: ${{ steps.lockfile.outcome }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - name: Setup Node.js | |
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Lockfile consistency check | |
| id: lockfile | |
| shell: bash | |
| working-directory: ${{ env.UI_DIR }} | |
| run: | | |
| set -euo pipefail | |
| if [ ! -f package-lock.json ]; then | |
| echo "No package-lock.json; skipping lockfile check" | |
| exit 0 | |
| fi | |
| npm install --package-lock-only --ignore-scripts --no-audit --no-fund | |
| git -C "${GITHUB_WORKSPACE}" diff --exit-code "${{ env.UI_DIR }}/package-lock.json" || ( | |
| echo "::error::package-lock.json is out of sync with package.json." | |
| echo "::error::Regenerate with: npm install --package-lock-only --ignore-scripts --no-audit --no-fund" | |
| exit 1 | |
| ) | |
| - name: Install dependencies and cache node_modules | |
| id: setup-deps | |
| uses: ./.github/actions/node-setup-cache | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ env.UI_DIR }} | |
| lint-ui: | |
| name: ✨ Lint UI | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: [detect-target, setup-ui] | |
| if: needs.detect-target.outputs.run_ui == 'true' | |
| defaults: | |
| run: | |
| working-directory: ${{ env.UI_DIR }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-ui.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ env.UI_DIR }} | |
| - run: npm run lint --if-present | |
| typecheck-ui: | |
| name: 🔎 Typecheck UI | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [detect-target, setup-ui] | |
| if: needs.detect-target.outputs.run_ui == 'true' | |
| defaults: | |
| run: | |
| working-directory: ${{ env.UI_DIR }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-ui.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ env.UI_DIR }} | |
| - run: npm run typecheck --if-present | |
| # 🧪 Test UI — sharded matrix. | |
| # react-devkit is the bottleneck (~13 min, 296 test files), so it is split | |
| # across 4 parallel runners via `vitest --shard`. Each shard writes a blob | |
| # report; the coverage-ui job merges them and enforces thresholds. The smaller | |
| # packages (ui-core, react-adapter, cli) run once on shard 1 via turbo. | |
| test-ui: | |
| name: 🧪 Test UI (shard ${{ matrix.shard }}/4) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [detect-target, setup-ui] | |
| if: needs.detect-target.outputs.run_ui == 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| shard: [1, 2, 3, 4] | |
| env: | |
| NODE_OPTIONS: --max-old-space-size=4096 | |
| defaults: | |
| run: | |
| working-directory: ${{ env.UI_DIR }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-ui.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ env.UI_DIR }} | |
| - name: Cache Turbo | |
| uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 | |
| with: | |
| path: ${{ env.UI_DIR }}/.turbo | |
| key: turbo-ui-test-${{ runner.os }}-${{ hashFiles('ui/package-lock.json') }}-${{ github.sha }}-shard-${{ matrix.shard }} | |
| restore-keys: | | |
| turbo-ui-${{ runner.os }}-${{ hashFiles('ui/package-lock.json') }}- | |
| turbo-ui-${{ runner.os }}- | |
| - name: Cache Vitest transforms | |
| uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 | |
| with: | |
| # All tested packages set `cacheDir: '.cache'` in vitest.config.js. | |
| path: | | |
| ui/packages/react-devkit/.cache | |
| ui/packages/ui-core/.cache | |
| ui/packages/react-adapter/.cache | |
| ui/packages/cli/.cache | |
| key: vitest-ui-${{ runner.os }}-${{ hashFiles('ui/packages/**/*.ts', 'ui/packages/**/*.tsx', 'ui/packages/**/vitest.config.js') }}-shard-${{ matrix.shard }} | |
| restore-keys: | | |
| vitest-ui-${{ runner.os }}- | |
| - name: Restore react-devkit build from Turbo cache | |
| run: | | |
| # node_modules is restored with --ignore-scripts, so workspace dist/ | |
| # outputs are absent; vitest needs them to resolve sibling packages. | |
| npx turbo run build --filter=@tetherto/mdk-react-devkit | |
| - name: Test smaller packages (shard 1 only) | |
| if: matrix.shard == 1 | |
| # Only react-devkit is sharded; the rest run here once. Packages without | |
| # a test:coverage task (fonts, catalog app) are skipped by turbo. | |
| run: | | |
| npx turbo run test:coverage --filter='!@tetherto/mdk-react-devkit' | |
| - name: Test react-devkit shard ${{ matrix.shard }}/4 | |
| working-directory: ${{ env.UI_DIR }}/packages/react-devkit | |
| # Thresholds are disabled per-shard (each shard covers only a subset of | |
| # files); they are enforced on the merged report in the coverage-ui job. | |
| run: | | |
| npx vitest run \ | |
| --shard=${{ matrix.shard }}/4 \ | |
| --coverage \ | |
| --reporter=blob \ | |
| --coverage.thresholds.lines=0 \ | |
| --coverage.thresholds.functions=0 \ | |
| --coverage.thresholds.branches=0 \ | |
| --coverage.thresholds.statements=0 | |
| - name: Upload blob report | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ui-blob-report-${{ matrix.shard }} | |
| path: ${{ env.UI_DIR }}/packages/react-devkit/.vitest-reports/ | |
| retention-days: 1 | |
| if-no-files-found: error | |
| include-hidden-files: true | |
| coverage-ui: | |
| name: 📊 Coverage Merge UI | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: [detect-target, setup-ui, test-ui] | |
| if: needs.detect-target.outputs.run_ui == 'true' | |
| defaults: | |
| run: | |
| working-directory: ${{ env.UI_DIR }}/packages/react-devkit | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-ui.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ env.UI_DIR }} | |
| - name: Download all blob reports | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: ${{ env.UI_DIR }}/packages/react-devkit/.vitest-reports | |
| pattern: ui-blob-report-* | |
| merge-multiple: true | |
| - name: Merge coverage and enforce thresholds | |
| run: | | |
| ls -la .vitest-reports/ | |
| npx vitest run --mergeReports --coverage --coverage.reporter=text-summary --reporter=default | |
| build-ui: | |
| name: 🏗️ Build UI | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| needs: [detect-target, setup-ui] | |
| if: needs.detect-target.outputs.run_ui == 'true' | |
| defaults: | |
| run: | |
| working-directory: ${{ env.UI_DIR }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-ui.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ env.UI_DIR }} | |
| - run: npm run build --if-present | |
| setup-core: | |
| name: 📦 Setup Core (${{ matrix.dir }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [detect-target, list-core] | |
| if: needs.detect-target.outputs.run_core == 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| dir: ${{ fromJson(needs.list-core.outputs.dirs) }} | |
| outputs: | |
| cache-key: ${{ steps.setup-deps.outputs.cache-key }} | |
| lockfile-outcome: ${{ steps.lockfile.outcome }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - name: Lockfile consistency check | |
| id: lockfile | |
| shell: bash | |
| working-directory: ${{ matrix.dir }} | |
| run: | | |
| set -euo pipefail | |
| if [ ! -f package-lock.json ]; then | |
| echo "No package-lock.json; skipping lockfile check" | |
| exit 0 | |
| fi | |
| npm install --package-lock-only --ignore-scripts --no-audit --no-fund | |
| git -C "${GITHUB_WORKSPACE}" diff --exit-code "${{ matrix.dir }}/package-lock.json" || ( | |
| echo "::error::package-lock.json is out of sync with package.json in ${{ matrix.dir }}." | |
| echo "::error::Regenerate with: npm install --package-lock-only --ignore-scripts --no-audit --no-fund" | |
| exit 1 | |
| ) | |
| - name: Install dependencies and cache node_modules | |
| id: setup-deps | |
| uses: ./.github/actions/node-setup-cache | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ matrix.dir }} | |
| lint-core: | |
| name: ✨ Lint Core (${{ matrix.dir }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: [detect-target, list-core, setup-core] | |
| if: needs.detect-target.outputs.run_core == 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| dir: ${{ fromJson(needs.list-core.outputs.dirs) }} | |
| defaults: | |
| run: | |
| working-directory: ${{ matrix.dir }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-core.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ matrix.dir }} | |
| - run: npm run lint --if-present | |
| typecheck-core: | |
| name: 🔎 Typecheck Core (${{ matrix.dir }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [detect-target, list-core, setup-core] | |
| if: needs.detect-target.outputs.run_core == 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| dir: ${{ fromJson(needs.list-core.outputs.dirs) }} | |
| defaults: | |
| run: | |
| working-directory: ${{ matrix.dir }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-core.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ matrix.dir }} | |
| - run: npm run typecheck --if-present | |
| test-core: | |
| name: 🧪 Test Core (${{ matrix.dir }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| needs: [detect-target, list-core, setup-core] | |
| if: needs.detect-target.outputs.run_core == 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| dir: ${{ fromJson(needs.list-core.outputs.dirs) }} | |
| defaults: | |
| run: | |
| working-directory: ${{ matrix.dir }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-core.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ matrix.dir }} | |
| - name: Install cross-package dependencies | |
| shell: bash | |
| working-directory: ${{ github.workspace }} | |
| run: | | |
| # Core packages use relative requires into sibling core packages | |
| # (e.g. mdk -> ork) and into backend/workers/** (base, lib, | |
| # http.node.wrk, containers). Each test runner only restores the | |
| # matrix package's node_modules, so the reachable sibling packages | |
| # must be installed here too. | |
| find backend/core backend/workers -name package.json \ | |
| -not -path '*/node_modules/*' -not -path '*/examples/*' \ | |
| | xargs -I{} dirname {} \ | |
| | grep -v "^${{ matrix.dir }}$" \ | |
| | while read -r dir; do | |
| echo "Installing deps: $dir" | |
| if [ -f "$dir/package-lock.json" ]; then | |
| (cd "$dir" && npm ci --ignore-scripts --no-audit --no-fund) | |
| else | |
| (cd "$dir" && npm install --ignore-scripts --no-audit --no-fund) | |
| fi | |
| done | |
| - run: npm run test:coverage --if-present || npm test --if-present | |
| build-core: | |
| name: 🏗️ Build Core (${{ matrix.dir }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| needs: [detect-target, list-core, setup-core] | |
| if: needs.detect-target.outputs.run_core == 'true' && needs.list-core.outputs.buildable_dirs != '[]' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| dir: ${{ fromJson(needs.list-core.outputs.buildable_dirs) }} | |
| defaults: | |
| run: | |
| working-directory: ${{ matrix.dir }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-core.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ matrix.dir }} | |
| - run: npm run build | |
| setup-workers: | |
| name: 📦 Setup Worker (${{ matrix.dir }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [detect-target, list-workers] | |
| if: needs.detect-target.outputs.run_workers == 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| dir: ${{ fromJson(needs.list-workers.outputs.dirs) }} | |
| outputs: | |
| cache-key: ${{ steps.setup-deps.outputs.cache-key }} | |
| lockfile-outcome: ${{ steps.lockfile.outcome }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - name: Lockfile consistency check | |
| id: lockfile | |
| shell: bash | |
| working-directory: ${{ matrix.dir }} | |
| run: | | |
| set -euo pipefail | |
| if [ ! -f package-lock.json ]; then | |
| echo "No package-lock.json; skipping lockfile check" | |
| exit 0 | |
| fi | |
| npm install --package-lock-only --ignore-scripts --no-audit --no-fund | |
| git -C "${GITHUB_WORKSPACE}" diff --exit-code "${{ matrix.dir }}/package-lock.json" || ( | |
| echo "::error::package-lock.json is out of sync with package.json in ${{ matrix.dir }}." | |
| echo "::error::Regenerate with: npm install --package-lock-only --ignore-scripts --no-audit --no-fund" | |
| exit 1 | |
| ) | |
| - name: Install dependencies and cache node_modules | |
| id: setup-deps | |
| uses: ./.github/actions/node-setup-cache | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ matrix.dir }} | |
| lint-workers: | |
| name: ✨ Lint Worker (${{ matrix.dir }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: [detect-target, list-workers, setup-workers] | |
| if: needs.detect-target.outputs.run_workers == 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| dir: ${{ fromJson(needs.list-workers.outputs.dirs) }} | |
| defaults: | |
| run: | |
| working-directory: ${{ matrix.dir }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-workers.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ matrix.dir }} | |
| - run: npm run lint --if-present | |
| typecheck-workers: | |
| name: 🔎 Typecheck Worker (${{ matrix.dir }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [detect-target, list-workers, setup-workers] | |
| if: needs.detect-target.outputs.run_workers == 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| dir: ${{ fromJson(needs.list-workers.outputs.dirs) }} | |
| defaults: | |
| run: | |
| working-directory: ${{ matrix.dir }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-workers.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ matrix.dir }} | |
| - run: npm run typecheck --if-present | |
| test-workers: | |
| name: 🧪 Test Worker (${{ matrix.dir }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| needs: [detect-target, list-workers, setup-workers] | |
| if: needs.detect-target.outputs.run_workers == 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| dir: ${{ fromJson(needs.list-workers.outputs.dirs) }} | |
| defaults: | |
| run: | |
| working-directory: ${{ matrix.dir }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-workers.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ matrix.dir }} | |
| - name: Install shared base package dependencies | |
| shell: bash | |
| working-directory: ${{ github.workspace }} | |
| run: | | |
| # Worker packages use relative requires into sibling base packages. | |
| # Each test runner only restores the matrix package's node_modules, so | |
| # shared packages must be installed here too. | |
| find backend/workers -name package.json -not -path '*/node_modules/*' \ | |
| | xargs -I{} dirname {} \ | |
| | grep -E '(/base$|/base/)' \ | |
| | grep -v "^${{ matrix.dir }}$" \ | |
| | while read -r dir; do | |
| echo "Installing deps: $dir" | |
| (cd "$dir" && npm ci --ignore-scripts --no-audit --no-fund) | |
| done | |
| for dir in backend/core/mock-control-service; do | |
| [ -f "$dir/package.json" ] && \ | |
| (cd "$dir" && npm ci --ignore-scripts --no-audit --no-fund) || true | |
| done | |
| - run: npm run test:coverage --if-present || npm test --if-present | |
| build-workers: | |
| name: 🏗️ Build Worker (${{ matrix.dir }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| needs: [detect-target, list-workers, setup-workers] | |
| if: needs.detect-target.outputs.run_workers == 'true' && needs.list-workers.outputs.buildable_dirs != '[]' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| dir: ${{ fromJson(needs.list-workers.outputs.buildable_dirs) }} | |
| defaults: | |
| run: | |
| working-directory: ${{ matrix.dir }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-workers.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ matrix.dir }} | |
| - run: npm run build | |
| security_checks: | |
| name: 🔐 Security Checks | |
| if: github.event_name == 'pull_request' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| ref: ${{ github.event.pull_request.head.sha }} | |
| persist-credentials: false | |
| - name: Dependency Review | |
| uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 | |
| with: | |
| fail-on-severity: moderate | |
| vulnerability-check: true | |
| license-check: true | |
| security_audit_ui: | |
| name: 🔐 Security Audit UI | |
| if: github.event_name == 'pull_request' && needs.detect-target.outputs.run_ui == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [detect-target, setup-ui] | |
| permissions: | |
| contents: read | |
| defaults: | |
| run: | |
| working-directory: ${{ env.UI_DIR }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| ref: ${{ github.event.pull_request.head.sha }} | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-ui.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ env.UI_DIR }} | |
| - name: Security audit (npm) | |
| run: | | |
| set +e | |
| npm audit signatures 2>&1 | |
| SIGNATURES_EXIT=$? | |
| npm audit --audit-level=moderate 2>&1 | |
| MODERATE_EXIT=$? | |
| npm exec --yes -- audit-ci@7 --config "${GITHUB_WORKSPACE}/.github/scripts/audit-ci.jsonc" 2>&1 | |
| HIGH_EXIT=$? | |
| set -e | |
| [ $SIGNATURES_EXIT -eq 0 ] && SIGNATURES_STATUS="PASS" || SIGNATURES_STATUS="WARN" | |
| [ $MODERATE_EXIT -eq 0 ] && MODERATE_STATUS="PASS" || MODERATE_STATUS="WARN" | |
| [ $HIGH_EXIT -eq 0 ] && HIGH_STATUS="PASS" || HIGH_STATUS="WARN" | |
| # Only emit a summary block when the audit found something; a clean | |
| # PR should not produce a wall of PASS tables. | |
| if [ $MODERATE_EXIT -ne 0 ] || [ $HIGH_EXIT -ne 0 ]; then | |
| AUD_JSON="${RUNNER_TEMP:-/tmp}/npm-audit-ui.json" | |
| npm audit --json > "${AUD_JSON}" 2>/dev/null || true | |
| { | |
| echo "## Security Audit — UI (\`${{ env.UI_DIR }}\`)" | |
| echo "" | |
| echo "| Signatures | Moderate | High/Critical (audit-ci) |" | |
| echo "|---|---|---|" | |
| echo "| ${SIGNATURES_STATUS} | ${MODERATE_STATUS} | ${HIGH_STATUS} |" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| bash "${GITHUB_WORKSPACE}/.github/scripts/github-summary-npm-audit-high-critical.sh" "${AUD_JSON}" >> "$GITHUB_STEP_SUMMARY" || true | |
| fi | |
| [ $HIGH_EXIT -eq 0 ] | |
| security_audit_core: | |
| name: 🔐 Security Audit Core (${{ matrix.dir }}) | |
| if: github.event_name == 'pull_request' && needs.detect-target.outputs.run_core == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [detect-target, list-core, setup-core] | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| dir: ${{ fromJson(needs.list-core.outputs.dirs) }} | |
| defaults: | |
| run: | |
| working-directory: ${{ matrix.dir }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| ref: ${{ github.event.pull_request.head.sha }} | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-core.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ matrix.dir }} | |
| - name: Security audit (npm) | |
| run: | | |
| set +e | |
| npm audit signatures 2>&1 | |
| SIGNATURES_EXIT=$? | |
| npm audit --audit-level=moderate 2>&1 | |
| MODERATE_EXIT=$? | |
| npm exec --yes -- audit-ci@7 --config "${GITHUB_WORKSPACE}/.github/scripts/audit-ci.jsonc" 2>&1 | |
| HIGH_EXIT=$? | |
| set -e | |
| [ $SIGNATURES_EXIT -eq 0 ] && SIGNATURES_STATUS="PASS" || SIGNATURES_STATUS="WARN" | |
| [ $MODERATE_EXIT -eq 0 ] && MODERATE_STATUS="PASS" || MODERATE_STATUS="WARN" | |
| [ $HIGH_EXIT -eq 0 ] && HIGH_STATUS="PASS" || HIGH_STATUS="WARN" | |
| # Only emit a per-package summary block when the audit found | |
| # something; a clean PR should not produce a wall of PASS tables. | |
| if [ $MODERATE_EXIT -ne 0 ] || [ $HIGH_EXIT -ne 0 ]; then | |
| AUD_JSON="${RUNNER_TEMP:-/tmp}/npm-audit-${{ matrix.dir }}.json" | |
| npm audit --json > "${AUD_JSON}" 2>/dev/null || true | |
| { | |
| echo "## Security Audit — Core (\`${{ matrix.dir }}\`)" | |
| echo "" | |
| echo "| Signatures | Moderate | High/Critical (audit-ci) |" | |
| echo "|---|---|---|" | |
| echo "| ${SIGNATURES_STATUS} | ${MODERATE_STATUS} | ${HIGH_STATUS} |" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| bash "${GITHUB_WORKSPACE}/.github/scripts/github-summary-npm-audit-high-critical.sh" "${AUD_JSON}" >> "$GITHUB_STEP_SUMMARY" || true | |
| fi | |
| [ $HIGH_EXIT -eq 0 ] | |
| security_audit_workers: | |
| name: 🔐 Security Audit Worker (${{ matrix.dir }}) | |
| if: github.event_name == 'pull_request' && needs.detect-target.outputs.run_workers == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| needs: [detect-target, list-workers, setup-workers] | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| dir: ${{ fromJson(needs.list-workers.outputs.dirs) }} | |
| defaults: | |
| run: | |
| working-directory: ${{ matrix.dir }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| ref: ${{ github.event.pull_request.head.sha }} | |
| persist-credentials: false | |
| - uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup-workers.outputs.cache-key }} | |
| node-version: ${{ env.NODE_VERSION }} | |
| working-directory: ${{ matrix.dir }} | |
| - name: Security audit (npm) | |
| run: | | |
| set +e | |
| npm audit signatures 2>&1 | |
| SIGNATURES_EXIT=$? | |
| npm audit --audit-level=moderate 2>&1 | |
| MODERATE_EXIT=$? | |
| npm exec --yes -- audit-ci@7 --config "${GITHUB_WORKSPACE}/.github/scripts/audit-ci.jsonc" 2>&1 | |
| HIGH_EXIT=$? | |
| set -e | |
| [ $SIGNATURES_EXIT -eq 0 ] && SIGNATURES_STATUS="PASS" || SIGNATURES_STATUS="WARN" | |
| [ $MODERATE_EXIT -eq 0 ] && MODERATE_STATUS="PASS" || MODERATE_STATUS="WARN" | |
| [ $HIGH_EXIT -eq 0 ] && HIGH_STATUS="PASS" || HIGH_STATUS="WARN" | |
| # Only emit a per-package summary block when the audit found | |
| # something; a clean PR should not produce a wall of PASS tables. | |
| if [ $MODERATE_EXIT -ne 0 ] || [ $HIGH_EXIT -ne 0 ]; then | |
| AUD_JSON="${RUNNER_TEMP:-/tmp}/npm-audit-${{ matrix.dir }}.json" | |
| npm audit --json > "${AUD_JSON}" 2>/dev/null || true | |
| { | |
| echo "## Security Audit — Workers (\`${{ matrix.dir }}\`)" | |
| echo "" | |
| echo "| Signatures | Moderate | High/Critical (audit-ci) |" | |
| echo "|---|---|---|" | |
| echo "| ${SIGNATURES_STATUS} | ${MODERATE_STATUS} | ${HIGH_STATUS} |" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| bash "${GITHUB_WORKSPACE}/.github/scripts/github-summary-npm-audit-high-critical.sh" "${AUD_JSON}" >> "$GITHUB_STEP_SUMMARY" || true | |
| fi | |
| [ $HIGH_EXIT -eq 0 ] | |
| summary: | |
| name: 📋 CI Summary | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| if: always() && !cancelled() | |
| needs: | |
| - detect-target | |
| - security_checks | |
| - security_audit_ui | |
| - security_audit_core | |
| - security_audit_workers | |
| - setup-ui | |
| - lint-ui | |
| - typecheck-ui | |
| - test-ui | |
| - coverage-ui | |
| - build-ui | |
| - list-core | |
| - setup-core | |
| - lint-core | |
| - typecheck-core | |
| - test-core | |
| - build-core | |
| - list-workers | |
| - setup-workers | |
| - lint-workers | |
| - typecheck-workers | |
| - test-workers | |
| - build-workers | |
| steps: | |
| - name: Write job summary | |
| shell: bash | |
| env: | |
| REASON: ${{ needs.detect-target.outputs.reason }} | |
| RUN_UI: ${{ needs.detect-target.outputs.run_ui }} | |
| RUN_CORE: ${{ needs.detect-target.outputs.run_core }} | |
| RUN_WORKERS: ${{ needs.detect-target.outputs.run_workers }} | |
| SEC: ${{ needs.security_checks.result || 'skipped' }} | |
| SEC_UI: ${{ needs.security_audit_ui.result || 'skipped' }} | |
| SEC_CORE: ${{ needs.security_audit_core.result || 'skipped' }} | |
| SEC_WRK: ${{ needs.security_audit_workers.result || 'skipped' }} | |
| UI_SETUP: ${{ needs.setup-ui.result || 'skipped' }} | |
| CORE_SETUP: ${{ needs.setup-core.result || 'skipped' }} | |
| WRK_SETUP: ${{ needs.setup-workers.result || 'skipped' }} | |
| run: | | |
| ic() { | |
| case "$1" in | |
| success) echo "✅" ;; | |
| failure) echo "❌" ;; | |
| cancelled) echo "🚫" ;; | |
| skipped) echo "⏭️" ;; | |
| *) echo "❔" ;; | |
| esac | |
| } | |
| { | |
| echo "## CI Summary (mdk-prv monorepo)" | |
| echo "" | |
| echo "**Selection:** ${REASON} (ui=${RUN_UI} core=${RUN_CORE} workers=${RUN_WORKERS})" | |
| echo "" | |
| echo "| Area | Setup | Security |" | |
| echo "|---|---|---|" | |
| echo "| 🔐 Dependency review | — | $(ic "${SEC}") \`${SEC}\` |" | |
| echo "| 🎨 UI | $(ic "${UI_SETUP}") \`${UI_SETUP}\` | $(ic "${SEC_UI}") \`${SEC_UI}\` |" | |
| echo "| 🧱 Core | $(ic "${CORE_SETUP}") \`${CORE_SETUP}\` | $(ic "${SEC_CORE}") \`${SEC_CORE}\` |" | |
| echo "| ⚙️ Workers | $(ic "${WRK_SETUP}") \`${WRK_SETUP}\` | $(ic "${SEC_WRK}") \`${SEC_WRK}\` |" | |
| echo "" | |
| echo "### What ran" | |
| echo "- Per-package **lockfile consistency** in setup jobs (npm \`package-lock.json\`)" | |
| echo "- **One install per package** via cache actions; downstream jobs restore \`node_modules\`" | |
| echo "- **PR:** dependency review + per-package npm audits (audit-ci for high/critical)" | |
| } >> "$GITHUB_STEP_SUMMARY" |