Release #76
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: "Release version (e.g. v0.8.0)" | |
| required: true | |
| type: string | |
| release_notes: | |
| description: "Release notes (optional — auto-generated from commits if empty)" | |
| required: false | |
| type: string | |
| replace: | |
| description: "Replace existing release if it exists" | |
| required: false | |
| type: boolean | |
| default: false | |
| permissions: | |
| contents: write | |
| id-token: write | |
| attestations: write | |
| jobs: | |
| # ── Step 1: Lint (clang-format + cppcheck) ─────────────────── | |
| lint: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - 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: | |
| 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 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: | |
| 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@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 | |
| run: scripts/test.sh CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} | |
| test-windows: | |
| 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: | |
| 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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | |
| with: | |
| node-version: "22" | |
| - name: Build standard binary | |
| run: scripts/build.sh --version ${{ inputs.version }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} | |
| - name: Archive standard binary | |
| run: | | |
| tar -czf codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \ | |
| -C build/c codebase-memory-mcp | |
| - name: Build UI binary | |
| run: scripts/build.sh --with-ui --version ${{ inputs.version }} CC=${{ matrix.cc }} CXX=${{ matrix.cxx }} | |
| - name: Frontend integrity scan (post-build dist/) | |
| if: matrix.goos == 'linux' && matrix.goarch == 'amd64' | |
| run: scripts/security-ui.sh | |
| - name: Archive UI binary | |
| run: | | |
| tar -czf codebase-memory-mcp-ui-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz \ | |
| -C build/c codebase-memory-mcp | |
| - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| 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@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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | |
| 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 | |
| - 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-ui.exe | |
| zip codebase-memory-mcp-ui-windows-amd64.zip codebase-memory-mcp-ui.exe | |
| - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| 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@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: Smoke test (${{ matrix.variant }}, ${{ matrix.goos }}-${{ matrix.goarch }}) | |
| run: scripts/smoke-test.sh ./codebase-memory-mcp | |
| - 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 | |
| # 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2 | |
| with: | |
| msystem: CLANG64 | |
| path-type: inherit | |
| install: >- | |
| mingw-w64-clang-x86_64-python3 | |
| unzip | |
| - 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: Smoke test (${{ matrix.variant }}, windows-amd64) | |
| shell: msys2 {0} | |
| run: scripts/smoke-test.sh ./codebase-memory-mcp.exe | |
| - 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 5: Create DRAFT release (not public yet) ───────────── | |
| release-draft: | |
| needs: [smoke-unix, smoke-windows, security-static] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| id-token: write | |
| attestations: write | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 | |
| with: | |
| merge-multiple: true | |
| - name: List artifacts | |
| run: ls -la *.tar.gz *.zip | |
| - name: Generate checksums | |
| run: sha256sum *.tar.gz *.zip > checksums.txt | |
| # ── Artifact attestations (SLSA provenance) ────────────── | |
| - name: Attest build provenance (tar.gz) | |
| uses: actions/attest-build-provenance@96b4a1ef7235a096b17240c259729fdd70c83d45 # v2 | |
| with: | |
| subject-path: '*.tar.gz' | |
| - name: Attest build provenance (zip) | |
| uses: actions/attest-build-provenance@96b4a1ef7235a096b17240c259729fdd70c83d45 # v2 | |
| with: | |
| subject-path: '*.zip' | |
| - name: Attest build provenance (checksums) | |
| uses: actions/attest-build-provenance@96b4a1ef7235a096b17240c259729fdd70c83d45 # v2 | |
| with: | |
| subject-path: 'checksums.txt' | |
| # ── SBOM generation (SPDX format) ────────────────────────── | |
| - name: Generate SBOM | |
| run: | | |
| python3 -c " | |
| import json, uuid | |
| sbom = { | |
| 'spdxVersion': 'SPDX-2.3', | |
| 'dataLicense': 'CC0-1.0', | |
| 'SPDXID': 'SPDXRef-DOCUMENT', | |
| 'name': 'codebase-memory-mcp-${{ inputs.version }}', | |
| 'documentNamespace': 'https://github.com/DeusData/codebase-memory-mcp/releases/${{ inputs.version }}', | |
| 'creationInfo': { | |
| 'created': '$(date -u +%Y-%m-%dT%H:%M:%SZ)', | |
| 'creators': ['Tool: codebase-memory-mcp-release-pipeline'] | |
| }, | |
| 'packages': [ | |
| {'SPDXID': 'SPDXRef-Package-sqlite3', 'name': 'sqlite3', 'versionInfo': '3.49.1', 'downloadLocation': 'https://sqlite.org', 'filesAnalyzed': False}, | |
| {'SPDXID': 'SPDXRef-Package-yyjson', 'name': 'yyjson', 'versionInfo': '0.10.0', 'downloadLocation': 'https://github.com/ibireme/yyjson', 'filesAnalyzed': False}, | |
| {'SPDXID': 'SPDXRef-Package-mongoose', 'name': 'mongoose', 'versionInfo': '7.16', 'downloadLocation': 'https://github.com/cesanta/mongoose', 'filesAnalyzed': False}, | |
| {'SPDXID': 'SPDXRef-Package-mimalloc', 'name': 'mimalloc', 'versionInfo': '2.1.7', 'downloadLocation': 'https://github.com/microsoft/mimalloc', 'filesAnalyzed': False}, | |
| {'SPDXID': 'SPDXRef-Package-xxhash', 'name': 'xxhash', 'versionInfo': '0.8.2', 'downloadLocation': 'https://github.com/Cyan4973/xxHash', 'filesAnalyzed': False}, | |
| {'SPDXID': 'SPDXRef-Package-tre', 'name': 'tre', 'versionInfo': '0.8.0', 'downloadLocation': 'https://github.com/laurikari/tre', 'filesAnalyzed': False}, | |
| {'SPDXID': 'SPDXRef-Package-tree-sitter', 'name': 'tree-sitter', 'versionInfo': '0.24.4', 'downloadLocation': 'https://github.com/tree-sitter/tree-sitter', 'filesAnalyzed': False} | |
| ] | |
| } | |
| json.dump(sbom, open('sbom.json', 'w'), indent=2) | |
| " | |
| - name: Attest SBOM | |
| uses: actions/attest-sbom@10926c72720ffc3f7b666661c8e55b1344e2a365 # v2 | |
| with: | |
| subject-path: '*.tar.gz' | |
| sbom-path: 'sbom.json' | |
| # ── Sigstore cosign signing ────────────────────────────── | |
| - name: Install cosign | |
| uses: sigstore/cosign-installer@f713795cb21599bc4e5c4b58cbad1da852d7eeb9 # v3 | |
| - name: Sign release artifacts with cosign | |
| 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: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| VERSION: ${{ inputs.version }} | |
| run: gh release delete "$VERSION" --yes --cleanup-tag || true | |
| - name: Create tag | |
| env: | |
| VERSION: ${{ inputs.version }} | |
| run: | | |
| git tag -f "$VERSION" | |
| git push origin "$VERSION" --force | |
| - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 | |
| with: | |
| tag_name: ${{ inputs.version }} | |
| draft: true | |
| files: | | |
| *.tar.gz | |
| *.zip | |
| checksums.txt | |
| sbom.json | |
| *.bundle | |
| 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. | |
| verify: | |
| needs: [release-draft] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| security-events: write | |
| id-token: write | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| 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 }} | |
| VERSION: ${{ inputs.version }} | |
| 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 | |
| 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 | |
| done | |
| echo "=== Extracted binaries for scanning ===" | |
| ls -la binaries/ | |
| - name: Scan extracted binaries with VirusTotal | |
| uses: crazy-max/ghaction-virustotal@d34968c958ae283fe976efed637081b9f9dcf74f # v4 | |
| id: virustotal | |
| with: | |
| vt_api_key: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }} | |
| files: | | |
| binaries/* | |
| # ── Wait for ALL VirusTotal engines to complete, then check ── | |
| # The action outputs comma-separated "file=URL" pairs. | |
| # URLs are /gui/file-analysis/<base64_id>/detection — we extract the | |
| # base64 analysis ID and poll /api/v3/analyses/<id> until completed. | |
| - name: Check VirusTotal scan results (wait for 100% completion) | |
| 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 | |
| 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/<ID>/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/<sha256>) | |
| 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/<id> until status=completed (max 20 min) | |
| SCAN_COMPLETE=false | |
| for attempt in $(seq 1 120); 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 | |
| echo "BLOCKED: $BASENAME scan did not complete within 20 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 ──────────────────────────────────── | |
| - name: Run OpenSSF Scorecard | |
| uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 | |
| id: scorecard | |
| with: | |
| results_file: scorecard.sarif | |
| results_format: sarif | |
| publish_results: true | |
| - name: Upload Scorecard SARIF | |
| uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4 | |
| with: | |
| sarif_file: scorecard.sarif | |
| - name: Extract Scorecard score | |
| id: score | |
| run: | | |
| SCORE=$(python3 -c " | |
| import json, sys | |
| with open('scorecard.sarif') as f: | |
| d = json.load(f) | |
| props = d.get('runs', [{}])[0].get('tool', {}).get('driver', {}).get('properties', {}) | |
| print(props.get('score', 'N/A')) | |
| " 2>/dev/null || echo "N/A") | |
| echo "score=$SCORE" >> "$GITHUB_OUTPUT" | |
| echo "OpenSSF Scorecard: $SCORE/10" | |
| # ── Append results + publish ───────────────────────────── | |
| - name: Append security verification and publish release | |
| env: | |
| VT_ANALYSIS: ${{ steps.virustotal.outputs.analysis }} | |
| SCORECARD_SCORE: ${{ steps.score.outputs.score }} | |
| 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 | |
| # OpenSSF Scorecard | |
| REPORT+=$'\n**OpenSSF Scorecard** — repository security health: **'"$SCORECARD_SCORE"$'/10**\n' | |
| REPORT+=$'[View detailed scorecard](https://scorecard.dev/viewer/?uri=github.com/DeusData/codebase-memory-mcp)\n\n' | |
| # Build provenance | |
| REPORT+=$'**Build Provenance (SLSA)** — cryptographic proof each binary was built by GitHub Actions from this repo:\n' | |
| REPORT+=$'```\ngh attestation verify <downloaded-file> --repo DeusData/codebase-memory-mcp\n```\n\n' | |
| # Cosign | |
| REPORT+=$'**Sigstore cosign** — keyless signature verification:\n' | |
| REPORT+=$'```\ncosign verify-blob --bundle <file>.bundle <file>\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 ===" |