Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 71 additions & 3 deletions .github/workflows/dry-run.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
# Manual trigger: test everything before pushing a release.
# Each step can be skipped for faster iteration.
#
# Pipeline: lint → test → build → smoke/soak (sequential chain)
# Security: security-static + codeql-gate (independent island)
# Pipeline: lint → test → build → smoke/soak → virustotal (sequential chain)
# Security: security-static + codeql-gate (independent island)
#
# Security does NOT block lint/test/build/smoke/soak. All jobs must pass
# for the overall workflow to be green.
#
# VirusTotal runs LAST and scans the same artifacts the release scans, so a
# detection surfaces here instead of mid-release. It is the one dry-run job
# whose verdict does not depend on our code: the same unchanged bytes can come
# back clean one day and flagged the next, which is precisely why finding out
# during a dry run is worth the wait.
#
# Two independent skips, because they answer different questions:
# skip_builds no artifacts at all — "do lint and tests pass?"
# skip_virustotal build + smoke + soak, no scan — "do the archives pass CI?"
# The second exists because the scan costs API quota and polls for up to two
# hours, which is pure noise while iterating on a build or smoke failure.
name: Dry Run

on:
Expand All @@ -20,7 +32,11 @@ on:
type: boolean
default: false
skip_builds:
description: 'Skip build + smoke'
description: 'Skip build + smoke (also skips VirusTotal — nothing to scan)'
type: boolean
default: false
skip_virustotal:
description: 'Skip VirusTotal scan (still builds + smokes the archives)'
type: boolean
default: false
soak_level:
Expand Down Expand Up @@ -84,3 +100,55 @@ jobs:
with:
duration_minutes: 10
run_asan: ${{ inputs.soak_level == 'full' }}

# ── VirusTotal (final step) ────────────────────────────────────
# Mirrors the release workflow's scan so a dry run answers the question the
# release will ask, before we tag anything. The only difference is where the
# archives come from: the release pulls them off a published release, a dry
# run has none, so they come from the build job's artifacts — the SAME files,
# produced by the same canonical package-release.sh.
#
# Runs after smoke and soak deliberately. It is the slowest job (polling can
# take up to two hours) and the one most likely to be waiting on someone
# else's queue, so it must not delay the feedback that is actually about our
# code.
virustotal:
if: ${{ inputs.skip_builds != true && inputs.skip_virustotal != true && !cancelled() && needs.build.result == 'success' && needs.smoke.result != 'failure' && needs.soak.result != 'failure' }}
needs: [build, smoke, soak]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Download build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: binaries-*
merge-multiple: true
path: assets

# Same extraction the release verify job performs, so the bytes handed to
# VirusTotal here are the bytes a user would download and run.
- name: Extract archive artifacts
run: scripts/ci/extract-release-archives.sh assets binaries

- 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/*

# Same ZERO-tolerance gate as the release. A detection here is a real
# finding even when it is a false positive: it is what the release gate
# will do, so the dry run should say so now. Resolve it upstream (vendor
# false-positive submission for the exact hashes, then re-run) — never by
# loosening this gate.
- name: Wait for VirusTotal results
env:
VT_API_KEY: ${{ secrets.VIRUS_TOTAL_SCANNER_API_KEY }}
VT_ANALYSIS: ${{ steps.virustotal.outputs.analysis }}
run: scripts/ci/check-virustotal.sh
65 changes: 65 additions & 0 deletions scripts/ci/extract-release-archives.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Extract the release archives into a flat directory of scannable files.
#
# Turns the per-platform artifacts (codebase-memory-mcp-<goos>-<goarch>.tar.gz /
# .zip, produced by the canonical package-release.sh) into one directory holding
# the actual executables, each renamed to its platform so a scan report names
# something a human can act on. The install scripts are copied in beside them:
# they ship in the release too, and a compromised installer matters as much as a
# compromised binary.
#
# Usage: extract-release-archives.sh <archive-dir> <output-dir>
#
# Lives in scripts/ rather than inline in the workflow because the venue-parity
# contract requires it: a venue may provision, plumb artifacts, or call a
# canonical leg script, and this is leg logic. It also means the dry run and the
# release can extract identically instead of drifting apart in two YAML copies.
set -euo pipefail

ARCHIVE_DIR="${1:?extract-release-archives: missing <archive-dir>}"
OUT_DIR="${2:?extract-release-archives: missing <output-dir>}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"

mkdir -p "$OUT_DIR"
shopt -s nullglob

# Counts BINARIES recovered from archives, deliberately not "files in OUT_DIR".
# The install scripts below are copied in unconditionally, so a whole-directory
# count is never zero and would have reported success on an empty input — a
# clean VirusTotal run over nothing, which is the precise false green this
# script exists to prevent. Caught by running it against an empty directory.
extracted=0

for archive in "$ARCHIVE_DIR"/*.tar.gz; do
name="$(basename "$archive" .tar.gz)"
tar -xzf "$archive" -C "$OUT_DIR" 2>/dev/null || true
if [ -f "$OUT_DIR/codebase-memory-mcp" ]; then
mv "$OUT_DIR/codebase-memory-mcp" "$OUT_DIR/${name}"
extracted=$((extracted + 1))
fi
done

for archive in "$ARCHIVE_DIR"/*.zip; do
name="$(basename "$archive" .zip)"
unzip -o "$archive" -d "$OUT_DIR" >/dev/null 2>&1 || true
if [ -f "$OUT_DIR/codebase-memory-mcp.exe" ]; then
mv "$OUT_DIR/codebase-memory-mcp.exe" "$OUT_DIR/${name}.exe"
extracted=$((extracted + 1))
fi
done

if [ "$extracted" -eq 0 ]; then
echo "FAIL: no binaries extracted from '$ARCHIVE_DIR' — a scan here would have"
echo " reported clean without examining a single byte"
ls -la "$ARCHIVE_DIR" 2>/dev/null || true
exit 1
fi

# Shipped alongside the binaries, so scanned alongside them: a compromised
# installer matters as much as a compromised executable.
cp "$ROOT/install.sh" "$OUT_DIR/" 2>/dev/null || true
cp "$ROOT/install.ps1" "$OUT_DIR/" 2>/dev/null || true

ls -la "$OUT_DIR"
total="$(find "$OUT_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ')"
echo "extracted $extracted binarie(s); $total file(s) queued for scanning"
Loading