diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml new file mode 100644 index 000000000..7c35ae4da --- /dev/null +++ b/.github/workflows/_build.yml @@ -0,0 +1,181 @@ +# Reusable: build binaries (standard + UI) on all platforms +name: Build + +on: + workflow_call: + inputs: + version: + description: 'Version string (e.g. v0.8.0)' + type: string + default: '' + +permissions: + contents: read + +jobs: + build-unix: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + goos: linux + goarch: amd64 + cc: gcc + cxx: g++ + - os: ubuntu-24.04-arm + goos: linux + goarch: arm64 + cc: gcc + cxx: g++ + - os: macos-14 + goos: darwin + goarch: arm64 + cc: cc + cxx: c++ + - os: macos-15-intel + goos: darwin + goarch: amd64 + cc: cc + cxx: c++ + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install deps (Ubuntu) + if: startsWith(matrix.os, 'ubuntu') + run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "22" + + - name: Build standard binary + run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} + + - name: Ad-hoc sign macOS binary + if: startsWith(matrix.os, 'macos') + run: codesign --sign - --force build/c/codebase-memory-mcp + + - name: Archive standard binary + run: | + cp LICENSE install.sh build/c/ + tar -czf codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \ + -C build/c codebase-memory-mcp LICENSE install.sh + + - name: Build UI binary + run: scripts/build.sh --with-ui ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} + + - name: Ad-hoc sign macOS UI binary + if: startsWith(matrix.os, 'macos') + run: codesign --sign - --force build/c/codebase-memory-mcp + + - name: Frontend integrity scan + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' + run: scripts/security-ui.sh + + - name: Archive UI binary + run: | + cp LICENSE install.sh build/c/ + tar -czf codebase-memory-mcp-ui-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \ + -C build/c codebase-memory-mcp LICENSE install.sh + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} + path: "*.tar.gz" + + build-windows: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 + with: + msystem: CLANG64 + path-type: inherit + install: >- + mingw-w64-clang-x86_64-clang + mingw-w64-clang-x86_64-zlib + make + zip + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "22" + + - name: Build standard binary + shell: msys2 {0} + run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=clang CXX=clang++ + + - name: Archive standard binary + shell: msys2 {0} + run: | + BIN=build/c/codebase-memory-mcp + [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" + cp "$BIN" codebase-memory-mcp.exe + zip codebase-memory-mcp-windows-amd64.zip codebase-memory-mcp.exe LICENSE install.ps1 + + - name: Build UI binary + shell: msys2 {0} + run: scripts/build.sh --with-ui ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=clang CXX=clang++ + + - name: Archive UI binary + shell: msys2 {0} + run: | + BIN=build/c/codebase-memory-mcp + [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" + cp "$BIN" codebase-memory-mcp.exe + zip codebase-memory-mcp-ui-windows-amd64.zip codebase-memory-mcp.exe LICENSE install.ps1 + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: binaries-windows-amd64 + path: "*.zip" + + build-linux-portable: + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + container: + image: alpine:3.21 + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install musl build deps + run: apk add --no-cache build-base linux-headers zlib-dev zlib-static nodejs npm + + - name: Build standard binary (static) + run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=gcc CXX=g++ STATIC=1 + + - name: Verify static linking + run: file build/c/codebase-memory-mcp | grep -q "statically linked" + + - name: Archive standard binary + run: | + cp LICENSE install.sh build/c/ + tar -czf codebase-memory-mcp-linux-${{ matrix.arch }}-portable.tar.gz \ + -C build/c codebase-memory-mcp LICENSE install.sh + + - name: Build UI binary (static) + run: scripts/build.sh --with-ui ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=gcc CXX=g++ STATIC=1 + + - name: Archive UI binary + run: | + cp LICENSE install.sh build/c/ + tar -czf codebase-memory-mcp-ui-linux-${{ matrix.arch }}-portable.tar.gz \ + -C build/c codebase-memory-mcp LICENSE install.sh + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: binaries-linux-${{ matrix.arch }}-portable + path: "*.tar.gz" diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml new file mode 100644 index 000000000..3277b5ef0 --- /dev/null +++ b/.github/workflows/_lint.yml @@ -0,0 +1,102 @@ +# Reusable: lint + security-static + codeql-gate +name: Lint & Security + +on: + workflow_call: {} + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install build deps + run: sudo apt-get update && sudo apt-get install -y zlib1g-dev cmake + + - name: Install LLVM 20 + run: | + wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc + echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-20 main" | sudo tee /etc/apt/sources.list.d/llvm-20.list + sudo apt-get update + sudo apt-get install -y clang-format-20 + + - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + id: cppcheck-cache + with: + path: /opt/cppcheck + key: cppcheck-2.20.0-ubuntu-amd64 + + - name: Build cppcheck 2.20.0 + if: steps.cppcheck-cache.outputs.cache-hit != 'true' + run: | + git clone --depth 1 --branch 2.20.0 https://github.com/danmar/cppcheck.git /tmp/cppcheck + cmake -S /tmp/cppcheck -B /tmp/cppcheck/build -DCMAKE_BUILD_TYPE=Release -DHAVE_RULES=OFF -DCMAKE_INSTALL_PREFIX=/opt/cppcheck + cmake --build /tmp/cppcheck/build -j$(nproc) + cmake --install /tmp/cppcheck/build + + - name: Add cppcheck to PATH + run: echo "/opt/cppcheck/bin" >> "$GITHUB_PATH" + + - name: Lint + run: scripts/lint.sh CLANG_FORMAT=clang-format-20 + + security-static: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: "Layer 1: Static allow-list audit" + run: scripts/security-audit.sh + - name: "Layer 6: UI security audit" + run: scripts/security-ui.sh + - name: "Layer 8: Vendored dependency integrity" + run: scripts/security-vendored.sh + + codeql-gate: + runs-on: ubuntu-latest + timeout-minutes: 50 + steps: + - name: Wait for CodeQL on current commit (max 45 min) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + CURRENT_SHA="${{ github.sha }}" + echo "Waiting for CodeQL to complete on $CURRENT_SHA..." + for attempt in $(seq 1 90); do + LATEST=$(gh api repos/${{ github.repository }}/actions/workflows/codeql.yml/runs?per_page=5 \ + --jq '.workflow_runs[] | select(.head_sha == "'"$CURRENT_SHA"'") | "\(.conclusion) \(.status)"' 2>/dev/null | head -1 || echo "") + if [ -z "$LATEST" ]; then + echo " $attempt/90: no run yet..."; sleep 30; continue + fi + CONCLUSION=$(echo "$LATEST" | cut -d' ' -f1) + STATUS=$(echo "$LATEST" | cut -d' ' -f2) + if [ "$STATUS" = "completed" ] && [ "$CONCLUSION" = "success" ]; then + echo "=== CodeQL passed ==="; exit 0 + elif [ "$STATUS" = "completed" ]; then + echo "BLOCKED: CodeQL $CONCLUSION"; exit 1 + fi + echo " $attempt/90: $STATUS..."; sleep 30 + done + echo "BLOCKED: CodeQL timeout"; exit 1 + + - name: Check for open code scanning alerts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Waiting 60s for alert API to settle..." + sleep 60 + ALERTS=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0") + sleep 15 + ALERTS2=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0") + [ "$ALERTS" -lt "$ALERTS2" ] && ALERTS=$ALERTS2 + if [ "$ALERTS" -gt 0 ]; then + echo "BLOCKED: $ALERTS open alert(s)" + gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \ + --jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true + exit 1 + fi + echo "=== CodeQL gate passed (0 alerts) ===" diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml new file mode 100644 index 000000000..ac220bda9 --- /dev/null +++ b/.github/workflows/_smoke.yml @@ -0,0 +1,193 @@ +# Reusable: smoke test every binary (standard + UI, all platforms) +name: Smoke + +on: + workflow_call: {} + +permissions: + contents: read + +jobs: + smoke-unix: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + goos: linux + goarch: amd64 + - os: ubuntu-24.04-arm + goos: linux + goarch: arm64 + - os: macos-14 + goos: darwin + goarch: arm64 + - os: macos-15-intel + goos: darwin + goarch: amd64 + variant: [standard, ui] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} + + - name: Extract binary + run: | + SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} + tar -xzf codebase-memory-mcp${SUFFIX}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz + chmod +x codebase-memory-mcp + + - name: Start artifact server + run: | + mkdir -p /tmp/smoke-server + cp codebase-memory-mcp /tmp/smoke-server/ + OS=${{ matrix.goos }}; ARCH=${{ matrix.goarch }} + SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} + tar -czf "/tmp/smoke-server/codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}.tar.gz" \ + -C /tmp/smoke-server codebase-memory-mcp + if [ -n "$SUFFIX" ]; then + cp "/tmp/smoke-server/codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}.tar.gz" \ + "/tmp/smoke-server/codebase-memory-mcp-${OS}-${ARCH}.tar.gz" + fi + cd /tmp/smoke-server + sha256sum *.tar.gz > checksums.txt 2>/dev/null || shasum -a 256 *.tar.gz > checksums.txt + python3 -m http.server 18080 -d /tmp/smoke-server & + + - name: Smoke test + run: scripts/smoke-test.sh ./codebase-memory-mcp + env: + SMOKE_DOWNLOAD_URL: http://localhost:18080 + + - name: Security audits (standard only) + if: matrix.variant == 'standard' + run: | + scripts/security-strings.sh ./codebase-memory-mcp + scripts/security-install.sh ./codebase-memory-mcp + scripts/security-network.sh ./codebase-memory-mcp + + - name: MCP robustness test (linux-amd64 standard only) + if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64' + run: | + scripts/security-fuzz.sh ./codebase-memory-mcp + scripts/security-fuzz-random.sh ./codebase-memory-mcp 60 + + - name: ClamAV scan (Linux) + if: matrix.variant == 'standard' && startsWith(matrix.os, 'ubuntu') + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq clamav > /dev/null 2>&1 + sudo sed -i 's/^Example/#Example/' /etc/clamav/freshclam.conf 2>/dev/null || true + grep -q "DatabaseMirror" /etc/clamav/freshclam.conf 2>/dev/null || \ + echo "DatabaseMirror database.clamav.net" | sudo tee -a /etc/clamav/freshclam.conf > /dev/null + sudo freshclam --quiet + clamscan --no-summary ./codebase-memory-mcp + + - name: ClamAV scan (macOS) + if: matrix.variant == 'standard' && startsWith(matrix.os, 'macos') + run: | + brew install clamav > /dev/null 2>&1 + CLAMAV_ETC=$(brew --prefix)/etc/clamav + if [ ! -f "$CLAMAV_ETC/freshclam.conf" ]; then + cp "$CLAMAV_ETC/freshclam.conf.sample" "$CLAMAV_ETC/freshclam.conf" 2>/dev/null || true + sed -i '' 's/^Example/#Example/' "$CLAMAV_ETC/freshclam.conf" 2>/dev/null || true + echo "DatabaseMirror database.clamav.net" >> "$CLAMAV_ETC/freshclam.conf" + fi + freshclam --quiet --no-warnings 2>/dev/null || freshclam --quiet 2>/dev/null || echo "WARNING: freshclam update failed" + clamscan --no-summary ./codebase-memory-mcp + + smoke-windows: + strategy: + fail-fast: false + matrix: + variant: [standard, ui] + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 + with: + msystem: CLANG64 + path-type: inherit + install: >- + mingw-w64-clang-x86_64-python3 + unzip + zip + coreutils + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: binaries-windows-amd64 + + - name: Extract binary + shell: msys2 {0} + run: | + SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} + unzip -o "codebase-memory-mcp${SUFFIX}-windows-amd64.zip" + [ -n "$SUFFIX" ] && cp "codebase-memory-mcp${SUFFIX}.exe" codebase-memory-mcp.exe || true + + - name: Start artifact server + shell: msys2 {0} + run: | + mkdir -p /tmp/smoke-server + cp codebase-memory-mcp.exe /tmp/smoke-server/ + SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} + cd /tmp/smoke-server + zip -q "codebase-memory-mcp${SUFFIX}-windows-amd64.zip" codebase-memory-mcp.exe + if [ -n "$SUFFIX" ]; then + cp "codebase-memory-mcp${SUFFIX}-windows-amd64.zip" "codebase-memory-mcp-windows-amd64.zip" + fi + sha256sum *.zip > checksums.txt + python3 -m http.server 18080 -d /tmp/smoke-server & + + - name: Smoke test + shell: msys2 {0} + run: scripts/smoke-test.sh ./codebase-memory-mcp.exe + env: + SMOKE_DOWNLOAD_URL: http://localhost:18080 + + - name: Security audits (standard only) + if: matrix.variant == 'standard' + shell: msys2 {0} + run: | + scripts/security-strings.sh ./codebase-memory-mcp.exe + scripts/security-install.sh ./codebase-memory-mcp.exe + + - name: Windows Defender scan (standard only) + if: matrix.variant == 'standard' + shell: pwsh + run: | + & "C:\Program Files\Windows Defender\MpCmdRun.exe" -SignatureUpdate 2>$null + $result = & "C:\Program Files\Windows Defender\MpCmdRun.exe" -Scan -ScanType 3 -File "$PWD\codebase-memory-mcp.exe" -DisableRemediation + Write-Host $result + if ($LASTEXITCODE -ne 0) { Write-Host "BLOCKED: Windows Defender flagged binary!"; exit 1 } + Write-Host "=== Windows Defender: clean ===" + + smoke-linux-portable: + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + variant: [standard, ui] + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: binaries-linux-${{ matrix.arch }}-portable + + - name: Extract and smoke test + run: | + SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} + tar -xzf codebase-memory-mcp${SUFFIX}-linux-${{ matrix.arch }}-portable.tar.gz + chmod +x codebase-memory-mcp + scripts/smoke-test.sh ./codebase-memory-mcp diff --git a/.github/workflows/_soak.yml b/.github/workflows/_soak.yml new file mode 100644 index 000000000..fa92790a3 --- /dev/null +++ b/.github/workflows/_soak.yml @@ -0,0 +1,186 @@ +# Reusable: soak tests (quick + ASan, all platforms) +name: Soak + +on: + workflow_call: + inputs: + duration_minutes: + description: 'Soak duration in minutes' + type: number + default: 10 + run_asan: + description: 'Run ASan soak in addition to quick soak' + type: boolean + default: false + version: + description: 'Version string for build' + type: string + default: '' + +permissions: + contents: read + +jobs: + soak-quick: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + goos: linux + goarch: amd64 + cc: gcc + cxx: g++ + - os: ubuntu-24.04-arm + goos: linux + goarch: arm64 + cc: gcc + cxx: g++ + - os: macos-14 + goos: darwin + goarch: arm64 + cc: cc + cxx: c++ + - os: macos-15-intel + goos: darwin + goarch: amd64 + cc: cc + cxx: c++ + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install deps (Linux) + if: startsWith(matrix.os, 'ubuntu') + run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 git + - name: Build + run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} + - name: Soak (${{ inputs.duration_minutes }} min) + run: scripts/soak-test.sh build/c/codebase-memory-mcp ${{ inputs.duration_minutes }} + - name: Upload metrics + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: soak-quick-${{ matrix.goos }}-${{ matrix.goarch }} + path: soak-results/ + retention-days: 14 + + soak-quick-windows: + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 + with: + msystem: CLANG64 + path-type: inherit + install: >- + mingw-w64-clang-x86_64-clang + mingw-w64-clang-x86_64-zlib + mingw-w64-clang-x86_64-python3 + make + git + coreutils + - name: Build + shell: msys2 {0} + run: scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=clang CXX=clang++ + - name: Soak (${{ inputs.duration_minutes }} min) + shell: msys2 {0} + run: | + BIN=build/c/codebase-memory-mcp + [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" + scripts/soak-test.sh "$BIN" ${{ inputs.duration_minutes }} + - name: Upload metrics + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: soak-quick-windows-amd64 + path: soak-results/ + retention-days: 14 + + soak-asan: + if: ${{ inputs.run_asan }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + goos: linux + goarch: amd64 + cc: gcc + cxx: g++ + - os: ubuntu-24.04-arm + goos: linux + goarch: arm64 + cc: gcc + cxx: g++ + - os: macos-14 + goos: darwin + goarch: arm64 + cc: cc + cxx: c++ + - os: macos-15-intel + goos: darwin + goarch: amd64 + cc: cc + cxx: c++ + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install deps (Linux) + if: startsWith(matrix.os, 'ubuntu') + run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 git + - name: Build (ASan) + run: | + SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer" + scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} EXTRA_CFLAGS="$SANITIZE" EXTRA_LDFLAGS="$SANITIZE" + - name: ASan soak (15 min) + env: + ASAN_OPTIONS: "detect_leaks=1:halt_on_error=0:log_path=soak-results/asan" + run: scripts/soak-test.sh build/c/codebase-memory-mcp 15 + - name: Upload metrics + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: soak-asan-${{ matrix.goos }}-${{ matrix.goarch }} + path: soak-results/ + retention-days: 14 + + soak-asan-windows: + if: ${{ inputs.run_asan }} + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 + with: + msystem: CLANG64 + path-type: inherit + install: >- + mingw-w64-clang-x86_64-clang + mingw-w64-clang-x86_64-zlib + mingw-w64-clang-x86_64-python3 + make + git + coreutils + - name: Build (ASan) + shell: msys2 {0} + run: | + SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer" + scripts/build.sh ${{ inputs.version && format('--version {0}', inputs.version) || '' }} CC=clang CXX=clang++ EXTRA_CFLAGS="$SANITIZE" EXTRA_LDFLAGS="$SANITIZE" + - name: ASan soak (15 min, no leak detection) + shell: msys2 {0} + env: + ASAN_OPTIONS: "detect_leaks=0:halt_on_error=0:log_path=soak-results/asan" + run: | + BIN=build/c/codebase-memory-mcp + [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" + scripts/soak-test.sh "$BIN" 15 + - name: Upload metrics + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: soak-asan-windows-amd64 + path: soak-results/ + retention-days: 14 diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml new file mode 100644 index 000000000..4b8b98942 --- /dev/null +++ b/.github/workflows/_test.yml @@ -0,0 +1,67 @@ +# Reusable: unit + integration tests on all platforms +name: Test + +on: + workflow_call: + inputs: + skip_perf: + description: 'Skip incremental perf tests (phases 2-7)' + type: boolean + default: true + +permissions: + contents: read + +jobs: + test-unix: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + cc: gcc + cxx: g++ + - os: ubuntu-24.04-arm + cc: gcc + cxx: g++ + - os: macos-14 + cc: cc + cxx: c++ + - os: macos-15-intel + cc: cc + cxx: c++ + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install deps (Ubuntu) + if: startsWith(matrix.os, 'ubuntu') + run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + + - name: Test + run: scripts/test.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} + env: + CBM_SKIP_PERF: ${{ inputs.skip_perf && '1' || '' }} + + test-windows: + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 + with: + msystem: CLANG64 + path-type: inherit + install: >- + mingw-w64-clang-x86_64-clang + mingw-w64-clang-x86_64-compiler-rt + mingw-w64-clang-x86_64-zlib + make + + - name: Test + shell: msys2 {0} + run: scripts/test.sh CC=clang CXX=clang++ + env: + CBM_SKIP_PERF: ${{ inputs.skip_perf && '1' || '' }} diff --git a/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index eb453ebc2..cd1db1043 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -1,22 +1,24 @@ +# Manual trigger: test everything before pushing a release. +# Each step can be skipped for faster iteration. name: Dry Run on: workflow_dispatch: inputs: skip_lint: - description: 'Skip lint step (faster iteration)' + description: 'Skip lint + security + CodeQL' type: boolean default: false skip_tests: - description: 'Skip test steps (faster iteration on build/smoke)' + description: 'Skip unit/integration tests' type: boolean default: false skip_builds: - description: 'Skip build+smoke steps (faster iteration on lint/tests)' + description: 'Skip build + smoke' type: boolean default: false soak_level: - description: 'Soak test level: full (quick+asan+nightly), quick (10min only), none' + description: 'Soak: full (quick+asan), quick (10min), none' type: choice options: ['full', 'quick', 'none'] default: 'quick' @@ -25,880 +27,37 @@ permissions: contents: read jobs: - # ── Step 1: Lint (clang-format + cppcheck) ─────────────────── + # ── Lint + Security ────────────────────────────────────────── lint: if: ${{ inputs.skip_lint != true }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: ./.github/workflows/_lint.yml + secrets: inherit - - name: Install build deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev cmake - - - name: Install LLVM 20 - run: | - wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc - echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-20 main" | sudo tee /etc/apt/sources.list.d/llvm-20.list - sudo apt-get update - sudo apt-get install -y clang-format-20 - - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - id: cppcheck-cache - with: - path: /opt/cppcheck - key: cppcheck-2.20.0-ubuntu-amd64 - - - name: Build cppcheck 2.20.0 - if: steps.cppcheck-cache.outputs.cache-hit != 'true' - run: | - git clone --depth 1 --branch 2.20.0 https://github.com/danmar/cppcheck.git /tmp/cppcheck - cmake -S /tmp/cppcheck -B /tmp/cppcheck/build -DCMAKE_BUILD_TYPE=Release -DHAVE_RULES=OFF -DCMAKE_INSTALL_PREFIX=/opt/cppcheck - cmake --build /tmp/cppcheck/build -j$(nproc) - cmake --install /tmp/cppcheck/build - - - name: Add cppcheck to PATH - run: echo "/opt/cppcheck/bin" >> "$GITHUB_PATH" - - - name: Lint - run: scripts/lint.sh CLANG_FORMAT=clang-format-20 - - # ── Step 1b: Security audit (source-only, runs parallel with lint+tests) ── - # No build needed — scans source files and vendored deps only. - # Binary-level security (L2/L3/L4/L7) runs in smoke jobs per-platform. - security-static: - if: ${{ inputs.skip_lint != true }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: "Layer 1: Static allow-list audit" - run: scripts/security-audit.sh - - - name: "Layer 6: UI security audit" - run: scripts/security-ui.sh - - - name: "Layer 8: Vendored dependency integrity" - run: scripts/security-vendored.sh - - # ── Step 1c: CodeQL SAST gate ──────────────────────────────── - codeql-gate: - if: ${{ inputs.skip_lint != true }} - runs-on: ubuntu-latest - steps: - - name: Wait for CodeQL on current commit (max 45 min) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - CURRENT_SHA="${{ github.sha }}" - echo "Current commit: $CURRENT_SHA" - echo "Waiting for CodeQL to complete on this commit..." - - for attempt in $(seq 1 90); do - LATEST=$(gh api repos/${{ github.repository }}/actions/workflows/codeql.yml/runs?per_page=5 \ - --jq '.workflow_runs[] | select(.head_sha == "'"$CURRENT_SHA"'") | "\(.conclusion) \(.status)"' 2>/dev/null | head -1 || echo "") - - if [ -z "$LATEST" ]; then - echo " Attempt $attempt/90: No CodeQL run found for $CURRENT_SHA yet..." - sleep 30 - continue - fi - - CONCLUSION=$(echo "$LATEST" | cut -d' ' -f1) - STATUS=$(echo "$LATEST" | cut -d' ' -f2) - - if [ "$STATUS" = "completed" ] && [ "$CONCLUSION" = "success" ]; then - echo "=== CodeQL completed successfully on current commit ===" - exit 0 - elif [ "$STATUS" = "completed" ]; then - echo "BLOCKED: CodeQL completed with conclusion: $CONCLUSION" - exit 1 - fi - - echo " Attempt $attempt/90: CodeQL status=$STATUS (waiting 30s)..." - sleep 30 - done - - echo "BLOCKED: CodeQL did not complete within 45 minutes" - exit 1 - - - name: Check for open code scanning alerts - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Wait for GitHub to finish processing alert state changes. - # There is a race between CodeQL marking the workflow as "completed" - # and the alerts API reflecting new/closed alerts from that scan. - echo "Waiting 60s for alert API to settle after CodeQL completion..." - sleep 60 - - # Poll alerts twice with a gap to confirm the count is stable - ALERTS1=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0") - echo "Open alerts (check 1): $ALERTS1" - sleep 15 - ALERTS2=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0") - echo "Open alerts (check 2): $ALERTS2" - - # Use the higher count (conservative — if either check sees alerts, block) - ALERTS=$ALERTS2 - if [ "$ALERTS1" -gt "$ALERTS2" ]; then - ALERTS=$ALERTS1 - fi - - if [ "$ALERTS" -gt 0 ]; then - echo "BLOCKED: $ALERTS open code scanning alert(s) found." - gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \ - --jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true - echo "Fix them: https://github.com/${{ github.repository }}/security/code-scanning" - exit 1 - fi - echo "=== CodeQL gate passed (0 open alerts) ===" - - # ── Step 2: Unit tests (ASan + UBSan) ─────────────────────── - # macOS: use cc (Apple Clang) — GCC on macOS doesn't ship ASan runtime - # Linux: use system gcc — full ASan/UBSan support - # Windows: MSYS2 MinGW GCC - test-unix: - if: ${{ inputs.skip_tests != true && always() && (needs.lint.result == 'success' || needs.lint.result == 'skipped') }} + # ── Tests (all platforms, perf tests skipped on CI) ────────── + test: needs: [lint] - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - arch: amd64 - cc: gcc - cxx: g++ - - os: ubuntu-24.04-arm - arch: arm64 - cc: gcc - cxx: g++ - - os: macos-14 - arch: arm64 - cc: cc - cxx: c++ - - os: macos-15-intel - arch: amd64 - cc: cc - cxx: c++ - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install deps (Ubuntu) - if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev - - - name: Test (unit + integration, no perf) - run: scripts/test.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} - env: - CBM_SKIP_PERF: 1 - - test-windows: - if: ${{ inputs.skip_tests != true && always() && (needs.lint.result == 'success' || needs.lint.result == 'skipped') }} - needs: [lint] - runs-on: windows-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 - with: - msystem: CLANG64 - path-type: inherit - install: >- - mingw-w64-clang-x86_64-clang - mingw-w64-clang-x86_64-compiler-rt - mingw-w64-clang-x86_64-zlib - make - - - name: Test - shell: msys2 {0} - run: scripts/test.sh CC=clang CXX=clang++ - - # ── Step 3: Build binaries (standard + UI, all OS) ────────── - build-unix: - if: ${{ inputs.skip_builds != true && always() && (needs.test-unix.result == 'success' || needs.test-unix.result == 'skipped') && (needs.test-windows.result == 'success' || needs.test-windows.result == 'skipped') }} - needs: [test-unix, test-windows] - strategy: - matrix: - include: - - os: ubuntu-latest - goos: linux - goarch: amd64 - cc: gcc - cxx: g++ - - os: ubuntu-24.04-arm - goos: linux - goarch: arm64 - cc: gcc - cxx: g++ - - os: macos-14 - goos: darwin - goarch: arm64 - cc: cc - cxx: c++ - - os: macos-15-intel - goos: darwin - goarch: amd64 - cc: cc - cxx: c++ - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install deps (Ubuntu) - if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev - - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: "22" - - - name: Build standard binary - run: scripts/build.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} - - - name: Ad-hoc sign macOS binary - if: startsWith(matrix.os, 'macos') - run: codesign --sign - --force build/c/codebase-memory-mcp - - - name: Archive standard binary - run: | - cp LICENSE build/c/ - tar -czf codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \ - -C build/c codebase-memory-mcp LICENSE - - - name: Build UI binary - run: scripts/build.sh --with-ui CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} - - - name: Ad-hoc sign macOS UI binary - if: startsWith(matrix.os, 'macos') - run: codesign --sign - --force build/c/codebase-memory-mcp - - - name: Frontend integrity scan (post-build dist/) - if: matrix.goos == 'linux' && matrix.goarch == 'amd64' - run: scripts/security-ui.sh - - - name: Archive UI binary - run: | - cp LICENSE build/c/ - tar -czf codebase-memory-mcp-ui-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \ - -C build/c codebase-memory-mcp LICENSE - - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} - path: "*.tar.gz" - - # ── Step 3b: Build portable Linux binaries (musl static) ──── - build-linux-portable: - if: ${{ inputs.skip_builds != true && always() && (needs.test-unix.result == 'success' || needs.test-unix.result == 'skipped') && (needs.test-windows.result == 'success' || needs.test-windows.result == 'skipped') }} - needs: [test-unix, test-windows] - strategy: - matrix: - include: - - arch: amd64 - runner: ubuntu-latest - - arch: arm64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - container: - image: alpine:3.21 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install musl build deps - run: apk add --no-cache build-base linux-headers zlib-dev zlib-static nodejs npm - - - name: Build standard binary (static) - run: scripts/build.sh CC=gcc CXX=g++ STATIC=1 - - - name: Verify static linking - run: | - file build/c/codebase-memory-mcp | grep -q "statically linked" - echo "=== Confirmed: fully static binary ===" - - - name: Archive standard binary - run: | - cp LICENSE build/c/ - tar -czf codebase-memory-mcp-linux-${{ matrix.arch }}-portable.tar.gz \ - -C build/c codebase-memory-mcp LICENSE - - - name: Build UI binary (static) - run: scripts/build.sh --with-ui CC=gcc CXX=g++ STATIC=1 - - - name: Archive UI binary - run: | - cp LICENSE build/c/ - tar -czf codebase-memory-mcp-ui-linux-${{ matrix.arch }}-portable.tar.gz \ - -C build/c codebase-memory-mcp LICENSE - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: binaries-linux-${{ matrix.arch }}-portable - path: "*.tar.gz" - - build-windows: - if: ${{ inputs.skip_builds != true && always() && (needs.test-unix.result == 'success' || needs.test-unix.result == 'skipped') && (needs.test-windows.result == 'success' || needs.test-windows.result == 'skipped') }} - needs: [test-unix, test-windows] - runs-on: windows-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 - with: - msystem: CLANG64 - path-type: inherit - install: >- - mingw-w64-clang-x86_64-clang - mingw-w64-clang-x86_64-zlib - make - zip - - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: "22" - - - name: Build standard binary - shell: msys2 {0} - run: scripts/build.sh CC=clang CXX=clang++ - - - name: Archive standard binary - shell: msys2 {0} - run: | - BIN=build/c/codebase-memory-mcp - [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" - cp "$BIN" codebase-memory-mcp.exe - zip codebase-memory-mcp-windows-amd64.zip codebase-memory-mcp.exe LICENSE - - - name: Build UI binary - shell: msys2 {0} - run: scripts/build.sh --with-ui CC=clang CXX=clang++ - - - name: Archive UI binary - shell: msys2 {0} - run: | - BIN=build/c/codebase-memory-mcp - [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" - cp "$BIN" codebase-memory-mcp-ui.exe - zip codebase-memory-mcp-ui-windows-amd64.zip codebase-memory-mcp-ui.exe LICENSE - - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: binaries-windows-amd64 - path: "*.zip" - - # ── Step 4: Smoke test every binary ───────────────────────── - smoke-unix: - if: ${{ !cancelled() && inputs.skip_builds != true }} - needs: [build-unix] - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - goos: linux - goarch: amd64 - - os: ubuntu-24.04-arm - goos: linux - goarch: arm64 - - os: macos-14 - goos: darwin - goarch: arm64 - - os: macos-15-intel - goos: darwin - goarch: amd64 - variant: [standard, ui] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} - - - name: Extract binary - run: | - SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} - tar -xzf codebase-memory-mcp${SUFFIX}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz - chmod +x codebase-memory-mcp - - - name: Start artifact server for E2E smoke tests - run: | - mkdir -p /tmp/smoke-server - cp codebase-memory-mcp /tmp/smoke-server/ - OS=${{ matrix.goos }} - ARCH=${{ matrix.goarch }} - SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} - tar -czf "/tmp/smoke-server/codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}.tar.gz" \ - -C /tmp/smoke-server codebase-memory-mcp - # Also serve under standard name so install.sh + update --standard work - if [ -n "$SUFFIX" ]; then - cp "/tmp/smoke-server/codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}.tar.gz" \ - "/tmp/smoke-server/codebase-memory-mcp-${OS}-${ARCH}.tar.gz" - fi - cd /tmp/smoke-server - sha256sum *.tar.gz > checksums.txt 2>/dev/null || shasum -a 256 *.tar.gz > checksums.txt - python3 -m http.server 18080 -d /tmp/smoke-server & - - - name: Smoke test (${{ matrix.variant }}, ${{ matrix.goos }}-${{ matrix.goarch }}) - run: scripts/smoke-test.sh ./codebase-memory-mcp - env: - SMOKE_DOWNLOAD_URL: http://localhost:18080 - - - name: Binary string audit (${{ matrix.goos }}-${{ matrix.goarch }}) - if: matrix.variant == 'standard' - run: scripts/security-strings.sh ./codebase-memory-mcp - - - name: Install output audit (${{ matrix.goos }}-${{ matrix.goarch }}) - if: matrix.variant == 'standard' - run: scripts/security-install.sh ./codebase-memory-mcp - - - name: Network egress test (${{ matrix.goos }}-${{ matrix.goarch }}) - if: matrix.variant == 'standard' - run: scripts/security-network.sh ./codebase-memory-mcp - - - name: MCP robustness test - if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64' - run: scripts/security-fuzz.sh ./codebase-memory-mcp - - - name: Fuzz testing (60s random input) - if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64' - run: scripts/security-fuzz-random.sh ./codebase-memory-mcp 60 - - - name: ClamAV scan (Linux) - if: matrix.variant == 'standard' && startsWith(matrix.os, 'ubuntu') - run: | - sudo apt-get update -qq && sudo apt-get install -y -qq clamav > /dev/null 2>&1 - sudo sed -i 's/^Example/#Example/' /etc/clamav/freshclam.conf 2>/dev/null || true - grep -q "DatabaseMirror" /etc/clamav/freshclam.conf 2>/dev/null || \ - echo "DatabaseMirror database.clamav.net" | sudo tee -a /etc/clamav/freshclam.conf > /dev/null - sudo freshclam --quiet - echo "=== ClamAV scan ===" - clamscan --no-summary ./codebase-memory-mcp - echo "=== ClamAV: clean ===" - - - name: ClamAV scan (macOS) - if: matrix.variant == 'standard' && startsWith(matrix.os, 'macos') - run: | - brew install clamav > /dev/null 2>&1 - CLAMAV_ETC=$(brew --prefix)/etc/clamav - if [ ! -f "$CLAMAV_ETC/freshclam.conf" ]; then - cp "$CLAMAV_ETC/freshclam.conf.sample" "$CLAMAV_ETC/freshclam.conf" 2>/dev/null || true - sed -i '' 's/^Example/#Example/' "$CLAMAV_ETC/freshclam.conf" 2>/dev/null || true - echo "DatabaseMirror database.clamav.net" >> "$CLAMAV_ETC/freshclam.conf" - fi - freshclam --quiet --no-warnings 2>/dev/null || freshclam --quiet 2>/dev/null || echo "WARNING: freshclam update failed, using bundled signatures" - echo "=== ClamAV scan (macOS) ===" - clamscan --no-summary ./codebase-memory-mcp - echo "=== ClamAV: clean ===" - - smoke-windows: - if: ${{ !cancelled() && inputs.skip_builds != true }} - needs: [build-windows] - strategy: - fail-fast: false - matrix: - variant: [standard, ui] - runs-on: windows-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 - with: - msystem: CLANG64 - path-type: inherit - install: >- - mingw-w64-clang-x86_64-python3 - unzip - zip - coreutils - - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: binaries-windows-amd64 - - - name: Extract binary - shell: msys2 {0} - run: | - SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} - unzip -o "codebase-memory-mcp${SUFFIX}-windows-amd64.zip" - [ -n "$SUFFIX" ] && cp "codebase-memory-mcp${SUFFIX}.exe" codebase-memory-mcp.exe || true - - - name: Start artifact server for E2E smoke tests - shell: msys2 {0} - run: | - mkdir -p /tmp/smoke-server - cp codebase-memory-mcp.exe /tmp/smoke-server/codebase-memory-mcp.exe - SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} - cd /tmp/smoke-server - zip -q "codebase-memory-mcp${SUFFIX}-windows-amd64.zip" codebase-memory-mcp.exe - # Also serve under standard name - if [ -n "$SUFFIX" ]; then - cp "codebase-memory-mcp${SUFFIX}-windows-amd64.zip" "codebase-memory-mcp-windows-amd64.zip" - fi - sha256sum *.zip > checksums.txt - python3 -m http.server 18080 -d /tmp/smoke-server & - - - name: Smoke test (${{ matrix.variant }}, windows-amd64) - shell: msys2 {0} - run: scripts/smoke-test.sh ./codebase-memory-mcp.exe - env: - SMOKE_DOWNLOAD_URL: http://localhost:18080 - - - name: Binary string audit (windows-amd64) - if: matrix.variant == 'standard' - shell: msys2 {0} - run: scripts/security-strings.sh ./codebase-memory-mcp.exe - - - name: Install output audit (windows-amd64) - if: matrix.variant == 'standard' - shell: msys2 {0} - run: scripts/security-install.sh ./codebase-memory-mcp.exe - - - name: Windows Defender scan - if: matrix.variant == 'standard' - shell: pwsh - run: | - Write-Host "=== Windows Defender scan (with ML heuristics) ===" - & "C:\Program Files\Windows Defender\MpCmdRun.exe" -SignatureUpdate 2>$null - $result = & "C:\Program Files\Windows Defender\MpCmdRun.exe" -Scan -ScanType 3 -File "$PWD\codebase-memory-mcp.exe" -DisableRemediation - Write-Host $result - if ($LASTEXITCODE -ne 0) { - Write-Host "BLOCKED: Windows Defender flagged the binary!" - exit 1 - } - Write-Host "=== Windows Defender: clean ===" - - # ── Step 4b: Smoke test portable Linux binaries ────────────── - smoke-linux-portable: - if: ${{ !cancelled() && inputs.skip_builds != true }} - needs: [build-linux-portable] - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - arch: amd64 - - os: ubuntu-24.04-arm - arch: arm64 - variant: [standard, ui] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: binaries-linux-${{ matrix.arch }}-portable - - - name: Extract binary - run: | - SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} - tar -xzf codebase-memory-mcp${SUFFIX}-linux-${{ matrix.arch }}-portable.tar.gz - chmod +x codebase-memory-mcp - - - name: Start artifact server for E2E smoke tests - run: | - mkdir -p /tmp/smoke-server - cp codebase-memory-mcp /tmp/smoke-server/ - ARCH=${{ matrix.arch }} - SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} - tar -czf "/tmp/smoke-server/codebase-memory-mcp${SUFFIX}-linux-${ARCH}-portable.tar.gz" \ - -C /tmp/smoke-server codebase-memory-mcp - # Also serve under standard name so install.sh + update --standard work - cp "/tmp/smoke-server/codebase-memory-mcp${SUFFIX}-linux-${ARCH}-portable.tar.gz" \ - "/tmp/smoke-server/codebase-memory-mcp-linux-${ARCH}.tar.gz" - if [ -n "$SUFFIX" ]; then - cp "/tmp/smoke-server/codebase-memory-mcp${SUFFIX}-linux-${ARCH}-portable.tar.gz" \ - "/tmp/smoke-server/codebase-memory-mcp${SUFFIX}-linux-${ARCH}.tar.gz" - fi - cd /tmp/smoke-server - sha256sum *.tar.gz > checksums.txt 2>/dev/null || shasum -a 256 *.tar.gz > checksums.txt - python3 -m http.server 18080 -d /tmp/smoke-server & - - - name: Smoke test (portable ${{ matrix.variant }}, linux-${{ matrix.arch }}) - run: scripts/smoke-test.sh ./codebase-memory-mcp - env: - SMOKE_DOWNLOAD_URL: http://localhost:18080 - - - name: Binary string audit (portable linux-${{ matrix.arch }}) - if: matrix.variant == 'standard' - run: scripts/security-strings.sh ./codebase-memory-mcp - - - name: Install output audit (portable linux-${{ matrix.arch }}) - if: matrix.variant == 'standard' - run: scripts/security-install.sh ./codebase-memory-mcp - - - name: Network egress test (portable linux-${{ matrix.arch }}) - if: matrix.variant == 'standard' - run: scripts/security-network.sh ./codebase-memory-mcp - - - name: ClamAV scan (portable Linux) - if: matrix.variant == 'standard' - run: | - sudo apt-get update -qq && sudo apt-get install -y -qq clamav > /dev/null 2>&1 - sudo sed -i 's/^Example/#Example/' /etc/clamav/freshclam.conf 2>/dev/null || true - grep -q "DatabaseMirror" /etc/clamav/freshclam.conf 2>/dev/null || \ - echo "DatabaseMirror database.clamav.net" | sudo tee -a /etc/clamav/freshclam.conf > /dev/null - sudo freshclam --quiet - echo "=== ClamAV scan (portable) ===" - clamscan --no-summary ./codebase-memory-mcp - echo "=== ClamAV: clean ===" - - # ── Step 6: Soak tests (after smoke, per-platform) ────────── - soak-quick: - if: ${{ !cancelled() && inputs.soak_level != 'none' && inputs.skip_builds != true }} - needs: [build-unix] - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - goos: linux - goarch: amd64 - cc: gcc - cxx: g++ - - os: ubuntu-24.04-arm - goos: linux - goarch: arm64 - cc: gcc - cxx: g++ - - os: macos-14 - goos: darwin - goarch: arm64 - cc: cc - cxx: c++ - - os: macos-15-intel - goos: darwin - goarch: amd64 - cc: cc - cxx: c++ - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - name: Install deps (Linux) - if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 git - - - name: Build (release mode) - run: scripts/build.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} - - - name: Quick soak (10 min) - run: scripts/soak-test.sh build/c/codebase-memory-mcp 10 - - - name: Upload soak metrics - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: soak-quick-${{ matrix.goos }}-${{ matrix.goarch }} - path: soak-results/ - retention-days: 14 - - soak-quick-windows: - if: ${{ !cancelled() && inputs.soak_level != 'none' && inputs.skip_builds != true }} - needs: [build-windows] - runs-on: windows-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 - with: - msystem: CLANG64 - path-type: inherit - install: >- - mingw-w64-clang-x86_64-clang - mingw-w64-clang-x86_64-zlib - mingw-w64-clang-x86_64-python3 - make - git - coreutils - - - name: Build (release mode) - shell: msys2 {0} - run: scripts/build.sh CC=clang CXX=clang++ - - - name: Quick soak (10 min) - shell: msys2 {0} - run: | - BIN=build/c/codebase-memory-mcp - [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" - scripts/soak-test.sh "$BIN" 10 - - - name: Upload soak metrics - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: soak-quick-windows-amd64 - path: soak-results/ - retention-days: 14 - - soak-asan: - if: ${{ !cancelled() && inputs.soak_level == 'full' && inputs.skip_builds != true }} - needs: [build-unix] - runs-on: ${{ matrix.os }} - timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - goos: linux - goarch: amd64 - cc: gcc - cxx: g++ - - os: ubuntu-24.04-arm - goos: linux - goarch: arm64 - cc: gcc - cxx: g++ - - os: macos-14 - goos: darwin - goarch: arm64 - cc: cc - cxx: c++ - - os: macos-15-intel - goos: darwin - goarch: amd64 - cc: cc - cxx: c++ - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - name: Install deps (Linux) - if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 git - - - name: Build (ASan + LeakSanitizer) - run: | - SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer" - scripts/build.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} EXTRA_CFLAGS="$SANITIZE" EXTRA_LDFLAGS="$SANITIZE" - - - name: ASan soak (15 min) - env: - ASAN_OPTIONS: "detect_leaks=1:halt_on_error=0:log_path=soak-results/asan" - run: scripts/soak-test.sh build/c/codebase-memory-mcp 15 - - - name: Upload ASan soak metrics - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: soak-asan-${{ matrix.goos }}-${{ matrix.goarch }} - path: soak-results/ - retention-days: 14 - - soak-asan-windows: - if: ${{ !cancelled() && inputs.soak_level == 'full' && inputs.skip_builds != true }} - needs: [build-windows] - runs-on: windows-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 - with: - msystem: CLANG64 - path-type: inherit - install: >- - mingw-w64-clang-x86_64-clang - mingw-w64-clang-x86_64-zlib - mingw-w64-clang-x86_64-python3 - make - git - coreutils - - name: Build (ASan, no LeakSan on Windows) - shell: msys2 {0} - run: | - SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer" - scripts/build.sh CC=clang CXX=clang++ EXTRA_CFLAGS="$SANITIZE" EXTRA_LDFLAGS="$SANITIZE" - - name: ASan soak (15 min, no leak detection) - shell: msys2 {0} - env: - ASAN_OPTIONS: "detect_leaks=0:halt_on_error=0:log_path=soak-results/asan" - run: | - BIN=build/c/codebase-memory-mcp - [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" - scripts/soak-test.sh "$BIN" 15 - - name: Upload ASan soak metrics - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: soak-asan-windows-amd64 - path: soak-results/ - retention-days: 14 - - # ── Step 6c: Quick soak (portable Linux, musl static) ────────── - soak-quick-linux-portable: - if: ${{ !cancelled() && inputs.soak_level != 'none' && inputs.skip_builds != true }} - needs: [build-linux-portable] - strategy: - fail-fast: false - matrix: - include: - - arch: amd64 - runner: ubuntu-latest - - arch: arm64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - container: - image: alpine:3.21 - timeout-minutes: 30 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install musl build deps - run: apk add --no-cache build-base linux-headers zlib-dev zlib-static python3 git nodejs npm - - - name: Build (release mode, static) - run: scripts/build.sh CC=gcc CXX=g++ STATIC=1 - - - name: Quick soak (10 min) - run: scripts/soak-test.sh build/c/codebase-memory-mcp 10 - - - name: Upload soak metrics - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: soak-quick-linux-${{ matrix.arch }}-portable - path: soak-results/ - retention-days: 14 - - # ── Step 6d: ASan soak (portable Linux, musl static) ────────── - soak-asan-linux-portable: - if: ${{ !cancelled() && inputs.soak_level == 'full' && inputs.skip_builds != true }} - needs: [build-linux-portable] - strategy: - fail-fast: false - matrix: - include: - - arch: amd64 - runner: ubuntu-latest - - arch: arm64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - container: - image: alpine:3.21 - timeout-minutes: 45 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install musl build deps - run: apk add --no-cache build-base linux-headers zlib-dev zlib-static python3 git nodejs npm - - - name: Build (ASan + LeakSanitizer, static) - run: | - SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer" - scripts/build.sh CC=gcc CXX=g++ STATIC=1 EXTRA_CFLAGS="$SANITIZE" EXTRA_LDFLAGS="$SANITIZE" - - - name: ASan soak (15 min) - env: - ASAN_OPTIONS: "detect_leaks=1:halt_on_error=0:log_path=soak-results/asan" - run: scripts/soak-test.sh build/c/codebase-memory-mcp 15 - - - name: Upload ASan soak metrics - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: soak-asan-linux-${{ matrix.arch }}-portable - path: soak-results/ - retention-days: 14 + if: ${{ inputs.skip_tests != true && !cancelled() && (needs.lint.result == 'success' || needs.lint.result == 'skipped') }} + uses: ./.github/workflows/_test.yml + with: + skip_perf: true + + # ── Build all platforms ────────────────────────────────────── + build: + if: ${{ inputs.skip_builds != true && !cancelled() && (needs.test.result == 'success' || needs.test.result == 'skipped') }} + needs: [test] + uses: ./.github/workflows/_build.yml + + # ── Smoke test every binary ───────────────────────────────── + smoke: + if: ${{ inputs.skip_builds != true && !cancelled() && needs.build.result == 'success' }} + needs: [build] + uses: ./.github/workflows/_smoke.yml + + # ── Soak tests (optional) ─────────────────────────────────── + soak: + if: ${{ inputs.soak_level != 'none' && !cancelled() && (needs.test.result == 'success' || needs.test.result == 'skipped') }} + needs: [test] + uses: ./.github/workflows/_soak.yml + with: + duration_minutes: 10 + run_asan: ${{ inputs.soak_level == 'full' }} diff --git a/.github/workflows/nightly-soak.yml b/.github/workflows/nightly-soak.yml index 1618406b6..e3a83109b 100644 --- a/.github/workflows/nightly-soak.yml +++ b/.github/workflows/nightly-soak.yml @@ -1,3 +1,4 @@ +# Weekly soak test: 4h sustained load + ASan leak detection name: Nightly Soak on: @@ -9,194 +10,13 @@ on: description: 'Soak duration in minutes (default: 240 = 4h)' type: number default: 240 - platforms: - description: 'Platforms to test' - type: choice - options: ['all', 'linux-only', 'macos-only'] - default: 'all' permissions: contents: read jobs: - soak-nightly: - runs-on: ${{ matrix.os }} - timeout-minutes: 330 # 5.5h safety cap (4h soak + build + analysis) - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - goos: linux - goarch: amd64 - cc: gcc - cxx: g++ - filter: all linux-only - - os: ubuntu-24.04-arm - goos: linux - goarch: arm64 - cc: gcc - cxx: g++ - filter: all linux-only - - os: macos-14 - goos: darwin - goarch: arm64 - cc: cc - cxx: c++ - filter: all macos-only - steps: - - name: Check platform filter - if: ${{ !contains(matrix.filter, inputs.platforms || 'all') }} - run: | - echo "Skipping ${{ matrix.goos }}-${{ matrix.goarch }} (filter: ${{ inputs.platforms }})" - exit 0 - - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - name: Install deps (Linux) - if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 git - - - name: Build (release mode) - run: scripts/build.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} - - - name: Nightly soak (${{ inputs.duration_minutes || 240 }} min) - run: scripts/soak-test.sh build/c/codebase-memory-mcp ${{ inputs.duration_minutes || 240 }} - - - name: Upload soak metrics - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: nightly-soak-${{ matrix.goos }}-${{ matrix.goarch }} - path: soak-results/ - retention-days: 30 - - soak-nightly-asan: - runs-on: ${{ matrix.os }} - timeout-minutes: 120 # ASan runs shorter (60 min) due to 2-3x overhead - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - goos: linux - goarch: amd64 - cc: gcc - cxx: g++ - filter: all linux-only - - os: ubuntu-24.04-arm - goos: linux - goarch: arm64 - cc: gcc - cxx: g++ - filter: all linux-only - steps: - - name: Check platform filter - if: ${{ !contains(matrix.filter, inputs.platforms || 'all') }} - run: exit 0 - - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - name: Install deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 git - - - name: Build (ASan + LeakSanitizer) - run: | - SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer" - scripts/build.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} EXTRA_CFLAGS="$SANITIZE" EXTRA_LDFLAGS="$SANITIZE" - - - name: ASan soak (60 min) - env: - ASAN_OPTIONS: "detect_leaks=1:halt_on_error=0:log_path=soak-results/asan" - run: scripts/soak-test.sh build/c/codebase-memory-mcp 60 - - - name: Upload ASan soak metrics - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: nightly-asan-${{ matrix.goos }}-${{ matrix.goarch }} - path: soak-results/ - retention-days: 30 - - # ── Nightly soak: portable Linux (musl static) ────────────── - soak-nightly-portable: - runs-on: ${{ matrix.runner }} - container: - image: alpine:3.21 - timeout-minutes: 330 - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-latest - arch: amd64 - filter: all linux-only - - runner: ubuntu-24.04-arm - arch: arm64 - filter: all linux-only - steps: - - name: Check platform filter - if: ${{ !contains(matrix.filter, inputs.platforms || 'all') }} - run: exit 0 - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install musl build deps - run: apk add --no-cache build-base linux-headers zlib-dev zlib-static python3 git nodejs npm - - - name: Build (release mode, static) - run: scripts/build.sh CC=gcc CXX=g++ STATIC=1 - - - name: Nightly soak (${{ inputs.duration_minutes || 240 }} min, portable) - run: scripts/soak-test.sh build/c/codebase-memory-mcp ${{ inputs.duration_minutes || 240 }} - - - name: Upload soak metrics - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: nightly-soak-linux-${{ matrix.arch }}-portable - path: soak-results/ - retention-days: 30 - - soak-nightly-asan-portable: - runs-on: ${{ matrix.runner }} - container: - image: alpine:3.21 - timeout-minutes: 120 - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-latest - arch: amd64 - filter: all linux-only - - runner: ubuntu-24.04-arm - arch: arm64 - filter: all linux-only - steps: - - name: Check platform filter - if: ${{ !contains(matrix.filter, inputs.platforms || 'all') }} - run: exit 0 - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install musl build deps - run: apk add --no-cache build-base linux-headers zlib-dev zlib-static python3 git nodejs npm - - - name: Build (ASan + LeakSanitizer, static) - run: | - SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer" - scripts/build.sh CC=gcc CXX=g++ STATIC=1 EXTRA_CFLAGS="$SANITIZE" EXTRA_LDFLAGS="$SANITIZE" - - - name: ASan soak (60 min, portable) - env: - ASAN_OPTIONS: "detect_leaks=1:halt_on_error=0:log_path=soak-results/asan" - run: scripts/soak-test.sh build/c/codebase-memory-mcp 60 - - - name: Upload ASan soak metrics - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: nightly-asan-linux-${{ matrix.arch }}-portable - path: soak-results/ - retention-days: 30 + soak: + uses: ./.github/workflows/_soak.yml + with: + duration_minutes: ${{ inputs.duration_minutes || 240 }} + run_asan: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30ae203ca..f8b072350 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,4 @@ +# Release pipeline: lint → test → build → smoke → soak → draft → verify → publish name: Release on: @@ -17,7 +18,7 @@ on: type: boolean default: false soak_level: - description: 'Soak test level: full (quick+asan), quick (10min only), none (skip)' + description: 'Soak: full (quick+asan), quick (10min), none' type: choice options: ['full', 'quick', 'none'] default: 'quick' @@ -28,769 +29,43 @@ permissions: attestations: write jobs: - # ── Step 1: Lint (clang-format + cppcheck) ─────────────────── + # ── 1. Lint + Security + CodeQL ──────────────────────────────── lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: ./.github/workflows/_lint.yml + secrets: inherit - - name: Install build deps - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev cmake - - - name: Install LLVM 20 - run: | - wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc - echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-20 main" | sudo tee /etc/apt/sources.list.d/llvm-20.list - sudo apt-get update - sudo apt-get install -y clang-format-20 - - - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - id: cppcheck-cache - with: - path: /opt/cppcheck - key: cppcheck-2.20.0-ubuntu-amd64 - - - name: Build cppcheck 2.20.0 - if: steps.cppcheck-cache.outputs.cache-hit != 'true' - run: | - git clone --depth 1 --branch 2.20.0 https://github.com/danmar/cppcheck.git /tmp/cppcheck - cmake -S /tmp/cppcheck -B /tmp/cppcheck/build -DCMAKE_BUILD_TYPE=Release -DHAVE_RULES=OFF -DCMAKE_INSTALL_PREFIX=/opt/cppcheck - cmake --build /tmp/cppcheck/build -j$(nproc) - cmake --install /tmp/cppcheck/build - - - name: Add cppcheck to PATH - run: echo "/opt/cppcheck/bin" >> "$GITHUB_PATH" - - - name: Lint - run: scripts/lint.sh CLANG_FORMAT=clang-format-20 - - # ── Step 1b: Security audit (source-only, runs parallel with lint+tests) ── - # No build needed — scans source files and vendored deps only. - # Binary-level security (L2/L3/L4/L7) runs in smoke jobs per-platform. - security-static: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: "Layer 1: Static allow-list audit" - run: scripts/security-audit.sh - - - name: "Layer 6: UI security audit" - run: scripts/security-ui.sh - - - name: "Layer 8: Vendored dependency integrity" - run: scripts/security-vendored.sh - - # ── Step 1c: CodeQL SAST gate ──────────────────────────────── - # Verifies CodeQL has run on the current commit AND has 0 open alerts. - # Prevents false green from stale/missing scans. - codeql-gate: - runs-on: ubuntu-latest - steps: - - name: Wait for CodeQL on current commit (max 45 min) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - CURRENT_SHA="${{ github.sha }}" - echo "Current commit: $CURRENT_SHA" - echo "Waiting for CodeQL to complete on this commit..." - - for attempt in $(seq 1 90); do - LATEST=$(gh api repos/${{ github.repository }}/actions/workflows/codeql.yml/runs?per_page=5 \ - --jq '.workflow_runs[] | select(.head_sha == "'"$CURRENT_SHA"'") | "\(.conclusion) \(.status)"' 2>/dev/null | head -1 || echo "") - - if [ -z "$LATEST" ]; then - echo " Attempt $attempt/90: No CodeQL run found for $CURRENT_SHA yet..." - sleep 30 - continue - fi - - CONCLUSION=$(echo "$LATEST" | cut -d' ' -f1) - STATUS=$(echo "$LATEST" | cut -d' ' -f2) - - if [ "$STATUS" = "completed" ] && [ "$CONCLUSION" = "success" ]; then - echo "=== CodeQL completed successfully on current commit ===" - exit 0 - elif [ "$STATUS" = "completed" ]; then - echo "BLOCKED: CodeQL completed with conclusion: $CONCLUSION" - exit 1 - fi - - echo " Attempt $attempt/90: CodeQL status=$STATUS (waiting 30s)..." - sleep 30 - done - - echo "BLOCKED: CodeQL did not complete within 45 minutes" - exit 1 - - - name: Check for open code scanning alerts - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Wait for GitHub to finish processing alert state changes. - # There is a race between CodeQL marking the workflow as "completed" - # and the alerts API reflecting new/closed alerts from that scan. - echo "Waiting 60s for alert API to settle after CodeQL completion..." - sleep 60 - - # Poll alerts twice with a gap to confirm the count is stable - ALERTS1=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0") - echo "Open alerts (check 1): $ALERTS1" - sleep 15 - ALERTS2=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0") - echo "Open alerts (check 2): $ALERTS2" - - # Use the higher count (conservative — if either check sees alerts, block) - ALERTS=$ALERTS2 - if [ "$ALERTS1" -gt "$ALERTS2" ]; then - ALERTS=$ALERTS1 - fi - - if [ "$ALERTS" -gt 0 ]; then - echo "BLOCKED: $ALERTS open code scanning alert(s) found." - gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \ - --jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true - echo "Fix them: https://github.com/${{ github.repository }}/security/code-scanning" - exit 1 - fi - echo "=== CodeQL gate passed (0 open alerts on current commit) ===" - - # ── Step 2: Unit tests (ASan + UBSan) ─────────────────────── - # macOS: use cc (Apple Clang) — GCC on macOS doesn't ship ASan runtime - # Linux: use system gcc — full ASan/UBSan support - # Windows: MSYS2 MinGW GCC - test-unix: + # ── 2. Tests (all platforms, full suite for release) ─────────── + test: needs: [lint] - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - arch: amd64 - cc: gcc - cxx: g++ - - os: ubuntu-24.04-arm - arch: arm64 - cc: gcc - cxx: g++ - - os: macos-14 - arch: arm64 - cc: cc - cxx: c++ - - os: macos-15-intel - arch: amd64 - cc: cc - cxx: c++ - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: ./.github/workflows/_test.yml + with: + skip_perf: false - - name: Install deps (Ubuntu) - if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + # ── 3. Build all platforms ───────────────────────────────────── + build: + needs: [test] + uses: ./.github/workflows/_build.yml + with: + version: ${{ inputs.version }} - - name: Test - run: scripts/test.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} + # ── 4. Smoke test every binary ───────────────────────────────── + smoke: + needs: [build] + uses: ./.github/workflows/_smoke.yml - test-windows: - needs: [lint] - runs-on: windows-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 - with: - msystem: CLANG64 - path-type: inherit - install: >- - mingw-w64-clang-x86_64-clang - mingw-w64-clang-x86_64-compiler-rt - mingw-w64-clang-x86_64-zlib - make - - - name: Test - shell: msys2 {0} - run: scripts/test.sh CC=clang CXX=clang++ - - # ── Step 3: Build binaries (standard + UI, all OS) ────────── - build-unix: - needs: [test-unix, test-windows] - strategy: - matrix: - include: - - os: ubuntu-latest - goos: linux - goarch: amd64 - cc: gcc - cxx: g++ - - os: ubuntu-24.04-arm - goos: linux - goarch: arm64 - cc: gcc - cxx: g++ - - os: macos-14 - goos: darwin - goarch: arm64 - cc: cc - cxx: c++ - - os: macos-15-intel - goos: darwin - goarch: amd64 - cc: cc - cxx: c++ - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install deps (Ubuntu) - if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev - - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: "22" - - - name: Build standard binary - run: scripts/build.sh --version ${{ inputs.version }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} - - - name: Ad-hoc sign macOS binary - if: startsWith(matrix.os, 'macos') - run: codesign --sign - --force build/c/codebase-memory-mcp - - - name: Archive standard binary - run: | - cp LICENSE install.sh build/c/ - tar -czf codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \ - -C build/c codebase-memory-mcp LICENSE install.sh - - - name: Build UI binary - run: scripts/build.sh --with-ui --version ${{ inputs.version }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} - - - name: Ad-hoc sign macOS UI binary - if: startsWith(matrix.os, 'macos') - run: codesign --sign - --force build/c/codebase-memory-mcp - - - name: Frontend integrity scan (post-build dist/) - if: matrix.goos == 'linux' && matrix.goarch == 'amd64' - run: scripts/security-ui.sh - - - name: Archive UI binary - run: | - cp LICENSE install.sh build/c/ - tar -czf codebase-memory-mcp-ui-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \ - -C build/c codebase-memory-mcp LICENSE install.sh - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} - path: "*.tar.gz" - - build-windows: - needs: [test-unix, test-windows] - runs-on: windows-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 - with: - msystem: CLANG64 - path-type: inherit - install: >- - mingw-w64-clang-x86_64-clang - mingw-w64-clang-x86_64-zlib - make - zip - - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: "22" - - - name: Build standard binary - shell: msys2 {0} - run: scripts/build.sh --version ${{ inputs.version }} CC=clang CXX=clang++ - - - name: Archive standard binary - shell: msys2 {0} - run: | - BIN=build/c/codebase-memory-mcp - [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" - cp "$BIN" codebase-memory-mcp.exe - zip codebase-memory-mcp-windows-amd64.zip codebase-memory-mcp.exe LICENSE install.ps1 - - - name: Build UI binary - shell: msys2 {0} - run: scripts/build.sh --with-ui --version ${{ inputs.version }} CC=clang CXX=clang++ - - - name: Archive UI binary - shell: msys2 {0} - run: | - BIN=build/c/codebase-memory-mcp - [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" - cp "$BIN" codebase-memory-mcp.exe - zip codebase-memory-mcp-ui-windows-amd64.zip codebase-memory-mcp.exe LICENSE install.ps1 - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: binaries-windows-amd64 - path: "*.zip" - - # ── Step 3b: Build portable Linux binaries (musl static) ──── - build-linux-portable: - needs: [test-unix, test-windows] - strategy: - matrix: - include: - - arch: amd64 - runner: ubuntu-latest - - arch: arm64 - runner: ubuntu-24.04-arm - runs-on: ${{ matrix.runner }} - container: - image: alpine:3.21 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Install musl build deps - run: apk add --no-cache build-base linux-headers zlib-dev zlib-static nodejs npm - - - name: Build standard binary (static) - run: scripts/build.sh --version ${{ inputs.version }} CC=gcc CXX=g++ STATIC=1 - - - name: Verify static linking - run: | - file build/c/codebase-memory-mcp | grep -q "statically linked" - echo "=== Confirmed: fully static binary ===" - - - name: Archive standard binary - run: | - cp LICENSE install.sh build/c/ - tar -czf codebase-memory-mcp-linux-${{ matrix.arch }}-portable.tar.gz \ - -C build/c codebase-memory-mcp LICENSE install.sh - - - name: Build UI binary (static) - run: scripts/build.sh --with-ui --version ${{ inputs.version }} CC=gcc CXX=g++ STATIC=1 - - - name: Archive UI binary - run: | - cp LICENSE install.sh build/c/ - tar -czf codebase-memory-mcp-ui-linux-${{ matrix.arch }}-portable.tar.gz \ - -C build/c codebase-memory-mcp LICENSE install.sh - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: binaries-linux-${{ matrix.arch }}-portable - path: "*.tar.gz" - - build-windows: - needs: [test-unix, test-windows] - runs-on: windows-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 - with: - msystem: CLANG64 - path-type: inherit - install: >- - mingw-w64-clang-x86_64-clang - mingw-w64-clang-x86_64-zlib - make - zip - - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: "22" - - - name: Build standard binary - shell: msys2 {0} - run: scripts/build.sh --version ${{ inputs.version }} CC=clang CXX=clang++ - - - name: Archive standard binary - shell: msys2 {0} - run: | - BIN=build/c/codebase-memory-mcp - [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" - cp "$BIN" codebase-memory-mcp.exe - zip codebase-memory-mcp-windows-amd64.zip codebase-memory-mcp.exe LICENSE install.ps1 - - - name: Build UI binary - shell: msys2 {0} - run: scripts/build.sh --with-ui --version ${{ inputs.version }} CC=clang CXX=clang++ - - - name: Archive UI binary - shell: msys2 {0} - run: | - BIN=build/c/codebase-memory-mcp - [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" - cp "$BIN" codebase-memory-mcp.exe - zip codebase-memory-mcp-ui-windows-amd64.zip codebase-memory-mcp.exe LICENSE install.ps1 - - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: binaries-windows-amd64 - path: "*.zip" - - # ── Step 4: Smoke test every binary ───────────────────────── - smoke-unix: - needs: [build-unix] - strategy: - matrix: - include: - - os: ubuntu-latest - goos: linux - goarch: amd64 - - os: ubuntu-24.04-arm - goos: linux - goarch: arm64 - - os: macos-14 - goos: darwin - goarch: arm64 - - os: macos-15-intel - goos: darwin - goarch: amd64 - variant: [standard, ui] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} - - - name: Extract binary - run: | - SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} - tar -xzf codebase-memory-mcp${SUFFIX}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz - chmod +x codebase-memory-mcp - - - name: Start artifact server for E2E smoke tests - run: | - mkdir -p /tmp/smoke-server - cp codebase-memory-mcp /tmp/smoke-server/ - OS=${{ matrix.goos }} - ARCH=${{ matrix.goarch }} - SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} - tar -czf "/tmp/smoke-server/codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}.tar.gz" \ - -C /tmp/smoke-server codebase-memory-mcp - # Also serve under standard name so install.sh + update --standard work - if [ -n "$SUFFIX" ]; then - cp "/tmp/smoke-server/codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}.tar.gz" \ - "/tmp/smoke-server/codebase-memory-mcp-${OS}-${ARCH}.tar.gz" - fi - cd /tmp/smoke-server - sha256sum *.tar.gz > checksums.txt 2>/dev/null || shasum -a 256 *.tar.gz > checksums.txt - python3 -m http.server 18080 -d /tmp/smoke-server & - - - name: Smoke test (${{ matrix.variant }}, ${{ matrix.goos }}-${{ matrix.goarch }}) - run: scripts/smoke-test.sh ./codebase-memory-mcp - env: - SMOKE_DOWNLOAD_URL: http://localhost:18080 - - - name: Binary string audit (${{ matrix.goos }}-${{ matrix.goarch }}) - if: matrix.variant == 'standard' - run: scripts/security-strings.sh ./codebase-memory-mcp - - - name: Install output audit (${{ matrix.goos }}-${{ matrix.goarch }}) - if: matrix.variant == 'standard' - run: scripts/security-install.sh ./codebase-memory-mcp - - - name: Network egress test (${{ matrix.goos }}-${{ matrix.goarch }}) - if: matrix.variant == 'standard' - run: scripts/security-network.sh ./codebase-memory-mcp - - - name: MCP robustness test - if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64' - run: scripts/security-fuzz.sh ./codebase-memory-mcp - - - name: Fuzz testing (60s random input) - if: matrix.variant == 'standard' && matrix.goos == 'linux' && matrix.goarch == 'amd64' - run: scripts/security-fuzz-random.sh ./codebase-memory-mcp 60 - - # Native platform antivirus scan - - name: ClamAV scan (Linux) - if: matrix.variant == 'standard' && startsWith(matrix.os, 'ubuntu') - run: | - sudo apt-get update -qq && sudo apt-get install -y -qq clamav > /dev/null 2>&1 - # Ensure freshclam config has DatabaseMirror set - sudo sed -i 's/^Example/#Example/' /etc/clamav/freshclam.conf 2>/dev/null || true - grep -q "DatabaseMirror" /etc/clamav/freshclam.conf 2>/dev/null || \ - echo "DatabaseMirror database.clamav.net" | sudo tee -a /etc/clamav/freshclam.conf > /dev/null - sudo freshclam --quiet - echo "=== ClamAV scan ===" - clamscan --no-summary ./codebase-memory-mcp - echo "=== ClamAV: clean ===" - - - name: ClamAV scan (macOS) - if: matrix.variant == 'standard' && startsWith(matrix.os, 'macos') - run: | - brew install clamav > /dev/null 2>&1 - CLAMAV_ETC=$(brew --prefix)/etc/clamav - if [ ! -f "$CLAMAV_ETC/freshclam.conf" ]; then - cp "$CLAMAV_ETC/freshclam.conf.sample" "$CLAMAV_ETC/freshclam.conf" 2>/dev/null || true - sed -i '' 's/^Example/#Example/' "$CLAMAV_ETC/freshclam.conf" 2>/dev/null || true - echo "DatabaseMirror database.clamav.net" >> "$CLAMAV_ETC/freshclam.conf" - fi - # Download signatures (--no-warnings suppresses X509 store errors on macOS) - freshclam --quiet --no-warnings 2>/dev/null || freshclam --quiet 2>/dev/null || echo "WARNING: freshclam update failed, using bundled signatures" - echo "=== ClamAV scan (macOS) ===" - clamscan --no-summary ./codebase-memory-mcp - echo "=== ClamAV: clean ===" - - smoke-windows: - needs: [build-windows] - strategy: - matrix: - variant: [standard, ui] - runs-on: windows-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 - with: - msystem: CLANG64 - path-type: inherit - install: >- - mingw-w64-clang-x86_64-python3 - unzip - zip - coreutils - - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: binaries-windows-amd64 - - - name: Extract binary - shell: msys2 {0} - run: | - SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} - unzip -o "codebase-memory-mcp${SUFFIX}-windows-amd64.zip" - [ -n "$SUFFIX" ] && cp "codebase-memory-mcp${SUFFIX}.exe" codebase-memory-mcp.exe || true - - - name: Start artifact server for E2E smoke tests - shell: msys2 {0} - run: | - mkdir -p /tmp/smoke-server - cp codebase-memory-mcp.exe /tmp/smoke-server/codebase-memory-mcp.exe - SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} - cd /tmp/smoke-server - zip -q "codebase-memory-mcp${SUFFIX}-windows-amd64.zip" codebase-memory-mcp.exe - # Also serve under standard name - if [ -n "$SUFFIX" ]; then - cp "codebase-memory-mcp${SUFFIX}-windows-amd64.zip" "codebase-memory-mcp-windows-amd64.zip" - fi - sha256sum *.zip > checksums.txt - python3 -m http.server 18080 -d /tmp/smoke-server & - - - name: Smoke test (${{ matrix.variant }}, windows-amd64) - shell: msys2 {0} - run: scripts/smoke-test.sh ./codebase-memory-mcp.exe - env: - SMOKE_DOWNLOAD_URL: http://localhost:18080 - - - name: Binary string audit (windows-amd64) - if: matrix.variant == 'standard' - shell: msys2 {0} - run: scripts/security-strings.sh ./codebase-memory-mcp.exe - - - name: Install output audit (windows-amd64) - if: matrix.variant == 'standard' - shell: msys2 {0} - run: scripts/security-install.sh ./codebase-memory-mcp.exe - - # Windows Defender scan (includes ML heuristics — catches what VirusTotal misses) - - name: Windows Defender scan - if: matrix.variant == 'standard' - shell: pwsh - run: | - Write-Host "=== Windows Defender scan (with ML heuristics) ===" - # Update definitions first - & "C:\Program Files\Windows Defender\MpCmdRun.exe" -SignatureUpdate 2>$null - # Full scan of the binary - $result = & "C:\Program Files\Windows Defender\MpCmdRun.exe" -Scan -ScanType 3 -File "$PWD\codebase-memory-mcp.exe" -DisableRemediation - Write-Host $result - if ($LASTEXITCODE -ne 0) { - Write-Host "BLOCKED: Windows Defender flagged the binary!" - Write-Host "Exit code: $LASTEXITCODE" - exit 1 - } - Write-Host "=== Windows Defender: clean ===" - - # ── Step 5a: Quick soak (parallel with smoke, per-platform) ──── - soak-quick: + # ── 5. Soak tests ───────────────────────────────────────────── + soak: if: ${{ inputs.soak_level != 'none' }} - needs: [build-unix] - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - goos: linux - goarch: amd64 - cc: gcc - cxx: g++ - - os: ubuntu-24.04-arm - goos: linux - goarch: arm64 - cc: gcc - cxx: g++ - - os: macos-14 - goos: darwin - goarch: arm64 - cc: cc - cxx: c++ - - os: macos-15-intel - goos: darwin - goarch: amd64 - cc: cc - cxx: c++ - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 - - name: Install deps (Linux) - if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 git - - name: Build (release mode) - run: scripts/build.sh --version ${{ inputs.version }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} - - name: Quick soak (10 min) - run: scripts/soak-test.sh build/c/codebase-memory-mcp 10 - - name: Upload soak metrics - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: soak-quick-${{ matrix.goos }}-${{ matrix.goarch }} - path: soak-results/ - retention-days: 14 + needs: [test] + uses: ./.github/workflows/_soak.yml + with: + duration_minutes: 10 + run_asan: ${{ inputs.soak_level == 'full' }} + version: ${{ inputs.version }} - # ── Step 5a-win: Quick soak (Windows, separate job) ───────────── - soak-quick-windows: - if: ${{ inputs.soak_level != 'none' }} - needs: [build-windows] - runs-on: windows-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 - - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 - with: - msystem: CLANG64 - path-type: inherit - install: >- - mingw-w64-clang-x86_64-clang - mingw-w64-clang-x86_64-zlib - mingw-w64-clang-x86_64-python3 - make - git - coreutils - - name: Build (release mode) - shell: msys2 {0} - run: scripts/build.sh --version ${{ inputs.version }} CC=clang CXX=clang++ - - name: Quick soak (10 min) - shell: msys2 {0} - run: | - BIN=build/c/codebase-memory-mcp - [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" - scripts/soak-test.sh "$BIN" 10 - - name: Upload soak metrics - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: soak-quick-windows-amd64 - path: soak-results/ - retention-days: 14 - - # ── Step 5b: ASan soak (Linux + macOS, parallel with smoke) ──── - soak-asan: - if: ${{ inputs.soak_level == 'full' }} - needs: [build-unix] - runs-on: ${{ matrix.os }} - timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - goos: linux - goarch: amd64 - cc: gcc - cxx: g++ - - os: ubuntu-24.04-arm - goos: linux - goarch: arm64 - cc: gcc - cxx: g++ - - os: macos-14 - goos: darwin - goarch: arm64 - cc: cc - cxx: c++ - - os: macos-15-intel - goos: darwin - goarch: amd64 - cc: cc - cxx: c++ - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 - - name: Install deps (Linux) - if: startsWith(matrix.os, 'ubuntu') - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 git - - name: Build (ASan + LeakSanitizer) - run: | - SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer" - scripts/build.sh --version ${{ inputs.version }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} EXTRA_CFLAGS="$SANITIZE" EXTRA_LDFLAGS="$SANITIZE" - - name: ASan soak (15 min) - env: - ASAN_OPTIONS: "detect_leaks=1:halt_on_error=0:log_path=soak-results/asan" - run: scripts/soak-test.sh build/c/codebase-memory-mcp 15 - - name: Upload ASan soak metrics - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: soak-asan-${{ matrix.goos }}-${{ matrix.goarch }} - path: soak-results/ - retention-days: 14 - - soak-asan-windows: - if: ${{ inputs.soak_level == 'full' }} - needs: [build-windows] - runs-on: windows-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 - - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 - with: - msystem: CLANG64 - path-type: inherit - install: >- - mingw-w64-clang-x86_64-clang - mingw-w64-clang-x86_64-zlib - mingw-w64-clang-x86_64-python3 - make - git - coreutils - - name: Build (ASan, no LeakSan on Windows) - shell: msys2 {0} - run: | - SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer" - scripts/build.sh --version ${{ inputs.version }} CC=clang CXX=clang++ EXTRA_CFLAGS="$SANITIZE" EXTRA_LDFLAGS="$SANITIZE" - - name: ASan soak (15 min, no leak detection) - shell: msys2 {0} - env: - ASAN_OPTIONS: "detect_leaks=0:halt_on_error=0:log_path=soak-results/asan" - run: | - BIN=build/c/codebase-memory-mcp - [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" - scripts/soak-test.sh "$BIN" 15 - - name: Upload ASan soak metrics - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: soak-asan-windows-amd64 - path: soak-results/ - retention-days: 14 - - # ── Step 6: Create DRAFT release (not public yet) ───────────── + # ── 6. Create DRAFT release ─────────────────────────────────── release-draft: - needs: [smoke-unix, smoke-windows, security-static, codeql-gate, soak-quick, soak-quick-windows, soak-asan, soak-asan-windows] + needs: [smoke, soak, lint] if: ${{ !cancelled() && !failure() }} runs-on: ubuntu-latest permissions: @@ -810,27 +85,15 @@ jobs: - name: Generate checksums run: sha256sum *.tar.gz *.zip > checksums.txt - # ── Artifact attestations (SLSA provenance) ────────────── - - name: Attest build provenance (tar.gz) + - name: Attest build provenance uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: - subject-path: '*.tar.gz' + subject-path: '*.tar.gz,*.zip,checksums.txt' - - name: Attest build provenance (zip) - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 - with: - subject-path: '*.zip' - - - name: Attest build provenance (checksums) - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 - with: - subject-path: 'checksums.txt' - - # ── SBOM generation (SPDX format) ────────────────────────── - name: Generate SBOM run: | python3 -c " - import json, uuid + import json sbom = { 'spdxVersion': 'SPDX-2.3', 'dataLicense': 'CC0-1.0', @@ -860,17 +123,15 @@ jobs: subject-path: '*.tar.gz' sbom-path: 'sbom.json' - # ── Sigstore cosign signing ────────────────────────────── - name: Install cosign uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 - - name: Sign release artifacts with cosign + - name: Sign artifacts run: | for f in *.tar.gz *.zip checksums.txt; do cosign sign-blob --yes --bundle "${f}.bundle" "$f" done - # ── Create DRAFT release (not visible to users yet) ────── - name: Delete existing release if: ${{ inputs.replace }} env: @@ -898,10 +159,7 @@ jobs: body: ${{ inputs.release_notes || '' }} generate_release_notes: ${{ inputs.release_notes == '' }} - # ── Step 6: Verify draft release ───────────────────────────── - # Scans binaries with VirusTotal, runs OpenSSF Scorecard. - # If verification passes, appends results and publishes. - # If it fails, the draft stays unpublished. + # ── 7. Verify + Publish ──────────────────────────────────────── verify: needs: [release-draft] runs-on: ubuntu-latest @@ -912,10 +170,6 @@ jobs: with: persist-credentials: false - # ── VirusTotal scan ────────────────────────────────────── - # Extract raw binaries from archives before scanning. - # VirusTotal may not unpack archives >3MB, so we scan the - # actual executables that users will run. - name: Download and extract release binaries env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -923,205 +177,35 @@ jobs: run: | mkdir -p assets binaries gh release download "$VERSION" --dir assets --repo "$GITHUB_REPOSITORY" --pattern '*.tar.gz' --pattern '*.zip' - ls -la assets/ - - # Extract binaries from archives for scanning for f in assets/*.tar.gz; do NAME=$(basename "$f" .tar.gz) tar -xzf "$f" -C binaries/ 2>/dev/null || true - # Rename to include platform for identification - if [ -f binaries/codebase-memory-mcp ]; then - mv binaries/codebase-memory-mcp "binaries/${NAME}" - fi + [ -f binaries/codebase-memory-mcp ] && mv binaries/codebase-memory-mcp "binaries/${NAME}" done for f in assets/*.zip; do NAME=$(basename "$f" .zip) unzip -o "$f" -d binaries/ 2>/dev/null || true - if [ -f binaries/codebase-memory-mcp.exe ]; then - mv binaries/codebase-memory-mcp.exe "binaries/${NAME}.exe" - fi + [ -f binaries/codebase-memory-mcp.exe ] && mv binaries/codebase-memory-mcp.exe "binaries/${NAME}.exe" done - # Also include install scripts (users curl | sh these) - cp install.sh binaries/install.sh 2>/dev/null || true - cp install.ps1 binaries/install.ps1 2>/dev/null || true - - echo "=== Files for scanning ===" + cp install.sh binaries/ 2>/dev/null || true + cp install.ps1 binaries/ 2>/dev/null || true ls -la binaries/ - - name: Scan extracted binaries with VirusTotal + - name: VirusTotal scan uses: crazy-max/ghaction-virustotal@936d8c5c00afe97d3d9a1af26d017cfdf26800a2 # v5.0.0 id: virustotal with: vt_api_key: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }} - files: | - binaries/* + files: binaries/* - # ── Wait for ALL VirusTotal engines to complete, then check ── - # The action outputs comma-separated "file=URL" pairs. - # URLs are /gui/file-analysis//detection — we extract the - # base64 analysis ID and poll /api/v3/analyses/ until completed. - - name: Check VirusTotal scan results (wait for 100% completion) + - name: Wait for VirusTotal results env: VT_API_KEY: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }} VT_ANALYSIS: ${{ steps.virustotal.outputs.analysis }} - run: | - echo "=== Waiting for VirusTotal scans to fully complete ===" - MIN_ENGINES=60 - rm -f /tmp/vt_gate_fail + run: scripts/ci/check-virustotal.sh - echo "$VT_ANALYSIS" | tr ',' '\n' | while IFS= read -r entry; do - [ -z "$entry" ] && continue - FILE=$(echo "$entry" | cut -d'=' -f1) - URL=$(echo "$entry" | cut -d'=' -f2-) - BASENAME=$(basename "$FILE") - - # Extract base64 analysis ID from URL: /gui/file-analysis//detection - ANALYSIS_ID=$(echo "$URL" | sed -n 's|.*/file-analysis/\([^/]*\)/.*|\1|p') - if [ -z "$ANALYSIS_ID" ]; then - echo "WARNING: Could not extract analysis ID from $URL" - # Try SHA256 fallback (older action versions use /gui/file/) - ANALYSIS_ID=$(echo "$URL" | grep -oE '[a-f0-9]{64}') - if [ -z "$ANALYSIS_ID" ]; then - echo "BLOCKED: Cannot parse VirusTotal URL: $URL" - echo "FAIL" >> /tmp/vt_gate_fail - continue - fi - fi - - # Poll /api/v3/analyses/ until status=completed (max 120 min) - SCAN_COMPLETE=false - for attempt in $(seq 1 720); do - RESULT=$(curl -sf --max-time 10 \ - -H "x-apikey: $VT_API_KEY" \ - "https://www.virustotal.com/api/v3/analyses/$ANALYSIS_ID" 2>/dev/null || echo "") - - if [ -z "$RESULT" ]; then - echo " $BASENAME: waiting (attempt $attempt)..." - sleep 10 - continue - fi - - STATS=$(echo "$RESULT" | python3 -c " - import json, sys - d = json.loads(sys.stdin.read()) - attrs = d.get('data', {}).get('attributes', {}) - status = attrs.get('status', 'queued') - stats = attrs.get('stats', {}) - malicious = stats.get('malicious', 0) - suspicious = stats.get('suspicious', 0) - undetected = stats.get('undetected', 0) - harmless = stats.get('harmless', 0) - total = sum(stats.values()) - completed = malicious + suspicious + undetected + harmless - print(f'{status},{malicious},{suspicious},{completed},{total}') - " 2>/dev/null || echo "queued,0,0,0,0") - - STATUS=$(echo "$STATS" | cut -d',' -f1) - MALICIOUS=$(echo "$STATS" | cut -d',' -f2) - SUSPICIOUS=$(echo "$STATS" | cut -d',' -f3) - COMPLETED=$(echo "$STATS" | cut -d',' -f4) - TOTAL=$(echo "$STATS" | cut -d',' -f5) - - if [ "$STATUS" = "completed" ]; then - echo "$BASENAME: $MALICIOUS malicious, $SUSPICIOUS suspicious ($COMPLETED completed, $TOTAL total engines)" - - if [ "$MALICIOUS" -gt 0 ] || [ "$SUSPICIOUS" -gt 0 ]; then - echo "BLOCKED: $BASENAME flagged! See $URL" - echo "FAIL" >> /tmp/vt_gate_fail - fi - SCAN_COMPLETE=true - break - fi - - echo " $BASENAME: $STATUS (attempt $attempt)..." - sleep 10 - done - - if [ "$SCAN_COMPLETE" != "true" ]; then - # Script files (sh, ps1) are low-priority in VT queue — warn but don't block - echo "BLOCKED: $BASENAME scan did not complete within 120 minutes!" - echo "FAIL" >> /tmp/vt_gate_fail - fi - done - - if [ -f /tmp/vt_gate_fail ]; then - FAIL_COUNT=$(wc -l < /tmp/vt_gate_fail | tr -d ' ') - echo "" - echo "=== VIRUSTOTAL GATE FAILED ===" - echo "$FAIL_COUNT binary(ies) flagged or scan incomplete." - echo "Draft release will NOT be published. Investigate before retrying." - exit 1 - fi - - echo "=== All binaries clean (all engines completed) ===" - - # ── OpenSSF Scorecard gate ────────────────────────────────── - # Fetch public score and block release if repo health degrades below threshold. - - name: OpenSSF Scorecard gate (minimum 4.0) - run: | - SCORE=$(curl -sf "https://api.scorecard.dev/projects/github.com/DeusData/codebase-memory-mcp" 2>/dev/null \ - | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('score',0))" 2>/dev/null \ - || echo "0") - echo "OpenSSF Scorecard: $SCORE/10" - if python3 -c "exit(0 if float('$SCORE') >= 4.0 else 1)" 2>/dev/null; then - echo "=== Scorecard gate passed (>= 4.0) ===" - else - echo "BLOCKED: Scorecard $SCORE/10 is below minimum 4.0" - echo "Check https://scorecard.dev/viewer/?uri=github.com/DeusData/codebase-memory-mcp" - exit 1 - fi - - # ── Append results + publish ───────────────────────────── - - name: Append security verification and publish release + - name: Publish release env: - VT_ANALYSIS: ${{ steps.virustotal.outputs.analysis }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ inputs.version }} - run: | - echo "=== Building security verification report ===" - - REPORT=$'---\n\n### Security Verification\n\n' - REPORT+=$'All release binaries have been independently verified:\n\n' - - # VirusTotal results (comma-separated "file=URL" pairs) - REPORT+=$'**VirusTotal** — scanned by 70+ antivirus engines:\n\n' - REPORT+=$'| Binary | Scan |\n|--------|------|\n' - echo "$VT_ANALYSIS" | tr ',' '\n' | while IFS= read -r entry; do - [ -z "$entry" ] && continue - FILE=$(echo "$entry" | cut -d'=' -f1) - URL=$(echo "$entry" | cut -d'=' -f2-) - BASENAME=$(basename "$FILE") - echo "| $BASENAME | [View Report]($URL) |" - done >> /tmp/vt_table - if [ -f /tmp/vt_table ]; then - REPORT+=$(cat /tmp/vt_table)$'\n' - rm -f /tmp/vt_table - fi - - # Build provenance - REPORT+=$'**Build Provenance (SLSA)** — cryptographic proof each binary was built by GitHub Actions from this repo:\n' - REPORT+=$'```\ngh attestation verify --repo DeusData/codebase-memory-mcp\n```\n\n' - - # Cosign - REPORT+=$'**Sigstore cosign** — keyless signature verification:\n' - REPORT+=$'```\ncosign verify-blob --bundle .bundle \n```\n\n' - - # Native AV scans - REPORT+=$'**Native antivirus scans** — all binaries passed these scans before this release was created (any detection would have blocked the release):\n' - REPORT+=$'- Windows: Windows Defender with ML heuristics (the same engine end users run)\n' - REPORT+=$'- Linux: ClamAV with daily signature updates\n' - REPORT+=$'- macOS: ClamAV with daily signature updates\n\n' - - # SBOM - REPORT+=$'**SBOM** — Software Bill of Materials (`sbom.json`) lists all vendored dependencies.\n\n' - - REPORT+=$'See [SECURITY.md](https://github.com/DeusData/codebase-memory-mcp/blob/main/SECURITY.md) for full details.\n' - - # Append to release notes - EXISTING=$(gh release view "$VERSION" --json body --jq '.body' --repo "$GITHUB_REPOSITORY") - printf '%s\n\n%s\n' "$EXISTING" "$REPORT" | gh release edit "$VERSION" --notes-file - --repo "$GITHUB_REPOSITORY" - - # ── Publish: promote draft to public release ───────── - gh release edit "$VERSION" --draft=false --repo "$GITHUB_REPOSITORY" - - echo "=== Release verified and published ===" + run: gh release edit "$VERSION" --draft=false --repo "$GITHUB_REPOSITORY" diff --git a/scripts/ci/check-virustotal.sh b/scripts/ci/check-virustotal.sh new file mode 100755 index 000000000..cbb2f81a4 --- /dev/null +++ b/scripts/ci/check-virustotal.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Wait for VirusTotal scans to complete and check results. +# Expects: VT_API_KEY, VT_ANALYSIS (comma-separated "file=URL" pairs) +set -euo pipefail + +MIN_ENGINES=60 +rm -f /tmp/vt_gate_fail + +echo "=== Waiting for VirusTotal scans to fully complete ===" + +echo "$VT_ANALYSIS" | tr ',' '\n' | while IFS= read -r entry; do + [ -z "$entry" ] && continue + FILE=$(echo "$entry" | cut -d'=' -f1) + URL=$(echo "$entry" | cut -d'=' -f2-) + BASENAME=$(basename "$FILE") + + # Extract base64 analysis ID from URL + ANALYSIS_ID=$(echo "$URL" | sed -n 's|.*/file-analysis/\([^/]*\)/.*|\1|p') + if [ -z "$ANALYSIS_ID" ]; then + ANALYSIS_ID=$(echo "$URL" | grep -oE '[a-f0-9]{64}') + if [ -z "$ANALYSIS_ID" ]; then + echo "BLOCKED: Cannot parse VirusTotal URL: $URL" + echo "FAIL" >> /tmp/vt_gate_fail + continue + fi + fi + + # Poll until completed (max 120 min) + SCAN_COMPLETE=false + for attempt in $(seq 1 720); do + RESULT=$(curl -sf --max-time 10 \ + -H "x-apikey: $VT_API_KEY" \ + "https://www.virustotal.com/api/v3/analyses/$ANALYSIS_ID" 2>/dev/null || echo "") + + if [ -z "$RESULT" ]; then + echo " $BASENAME: waiting (attempt $attempt)..." + sleep 10 + continue + fi + + STATS=$(echo "$RESULT" | python3 -c " +import json, sys +d = json.loads(sys.stdin.read()) +attrs = d.get('data', {}).get('attributes', {}) +status = attrs.get('status', 'queued') +stats = attrs.get('stats', {}) +malicious = stats.get('malicious', 0) +suspicious = stats.get('suspicious', 0) +undetected = stats.get('undetected', 0) +harmless = stats.get('harmless', 0) +total = sum(stats.values()) +completed = malicious + suspicious + undetected + harmless +print(f'{status},{malicious},{suspicious},{completed},{total}') +" 2>/dev/null || echo "queued,0,0,0,0") + + STATUS=$(echo "$STATS" | cut -d',' -f1) + MALICIOUS=$(echo "$STATS" | cut -d',' -f2) + SUSPICIOUS=$(echo "$STATS" | cut -d',' -f3) + COMPLETED=$(echo "$STATS" | cut -d',' -f4) + TOTAL=$(echo "$STATS" | cut -d',' -f5) + + if [ "$STATUS" = "completed" ] && [ "$COMPLETED" -ge "$MIN_ENGINES" ]; then + SCAN_COMPLETE=true + if [ "$MALICIOUS" -gt 0 ] || [ "$SUSPICIOUS" -gt 0 ]; then + echo "BLOCKED: $BASENAME flagged ($MALICIOUS malicious, $SUSPICIOUS suspicious / $COMPLETED engines)" + echo " $URL" + echo "FAIL" >> /tmp/vt_gate_fail + else + echo "OK: $BASENAME clean ($COMPLETED engines, 0 detections)" + fi + break + fi + + echo " $BASENAME: $COMPLETED/$TOTAL engines ($STATUS, attempt $attempt)..." + sleep 10 + done + + if [ "$SCAN_COMPLETE" != "true" ]; then + echo "WARNING: $BASENAME scan did not complete in time" + echo "FAIL" >> /tmp/vt_gate_fail + fi +done + +if [ -f /tmp/vt_gate_fail ]; then + echo "BLOCKED: One or more VirusTotal checks failed" + exit 1 +fi +echo "=== All VirusTotal scans passed ==="