Skip to content

Release

Release #14

Workflow file for this run

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
soak_level:
description: 'Soak test level: full (quick+asan), quick (10min only), none (skip)'
type: choice
options: ['full', 'quick', 'none']
default: 'quick'
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 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:
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@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@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@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@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: 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@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
# 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:
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@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 --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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: soak-quick-${{ matrix.goos }}-${{ matrix.goarch }}
path: soak-results/
retention-days: 14
# ── 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@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 --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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
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@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 --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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
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@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 --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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: soak-asan-windows-amd64
path: soak-results/
retention-days: 14
# ── Step 6: Create DRAFT release (not public yet) ─────────────
release-draft:
needs: [smoke-unix, smoke-windows, security-static, codeql-gate, soak-quick, soak-quick-windows, soak-asan, soak-asan-windows]
if: ${{ !cancelled() && !failure() }}
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@e8998f949152b193b063cb0ec769d69d929409be # v2
with:
subject-path: '*.tar.gz'
- name: Attest build provenance (zip)
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
with:
subject-path: '*.zip'
- name: Attest build provenance (checksums)
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # 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/maplenk/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@398d4b0eeef1380460a10c8013a76f728fb906ac # 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]
if: ${{ !cancelled() && needs.release-draft.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: 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
# 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 ==="
ls -la binaries/
- name: Scan extracted binaries with VirusTotal
uses: crazy-max/ghaction-virustotal@936d8c5c00afe97d3d9a1af26d017cfdf26800a2 # v5.0.0
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 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/maplenk/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/maplenk/codebase-memory-mcp"
exit 1
fi
# ── Append results + publish ─────────────────────────────
- name: Append security verification and 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 <downloaded-file> --repo maplenk/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/maplenk/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 ==="