forge: step 10 — add CI safety checks

This commit is contained in:
cj-vana
2026-05-14 21:52:07 -06:00
parent f7e901bdc0
commit 4b828d74ca
5 changed files with 281 additions and 2 deletions

View File

@@ -32,8 +32,14 @@ jobs:
- name: Install PlatformIO - name: Install PlatformIO
run: python3 -m pip install --upgrade platformio run: python3 -m pip install --upgrade platformio
- name: Run host and safety checks
run: bash scripts/verify.sh --no-build
- name: Build representative firmware - name: Build representative firmware
run: bash scripts/build-representative.sh --baseline run: |
MESHCORE_SKIP_APPLY_PATCHES=1 \
SIZE_REPORT=out/size/report.txt \
bash scripts/build-representative.sh --baseline
- name: Upload size reports - name: Upload size reports
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -10,6 +10,10 @@ Usage: scripts/build-representative.sh [--baseline|--compare <baseline-json>]
Applies the MeshCore patch queue, builds the representative companion firmware Applies the MeshCore patch queue, builds the representative companion firmware
environments, copies firmware artifacts to out/firmware, and writes a size environments, copies firmware artifacts to out/firmware, and writes a size
summary to out/size/summary.json. summary to out/size/summary.json.
To intentionally refresh a baseline, run --baseline and review out/size/summary.json
before copying it to the tracked baseline path. Optional environment variables:
SIZE_THRESHOLDS=<json>, SIZE_REPORT=<path>, SIZE_ENFORCE_THRESHOLDS=1.
EOF EOF
} }
@@ -87,7 +91,11 @@ if [ ! -f "${MESHCORE_DIR}/build.sh" ]; then
exit 1 exit 1
fi fi
if [ "${MESHCORE_SKIP_APPLY_PATCHES:-0}" = "1" ]; then
echo "Skipping MeshCore patch application; using current vendor/MeshCore tree."
else
"${MESHCORE_FW_ROOT}/scripts/apply-patches.sh" "${MESHCORE_FW_ROOT}/scripts/apply-patches.sh"
fi
OUT_DIR="${MESHCORE_FW_ROOT}/out" OUT_DIR="${MESHCORE_FW_ROOT}/out"
LOG_DIR="${OUT_DIR}/size" LOG_DIR="${OUT_DIR}/size"
@@ -121,6 +129,15 @@ parser_args=(
for env in "${REPRESENTATIVE_ENVS[@]}"; do for env in "${REPRESENTATIVE_ENVS[@]}"; do
parser_args+=(--env "$env") parser_args+=(--env "$env")
done done
if [ -n "${SIZE_THRESHOLDS:-}" ]; then
parser_args+=(--thresholds "$SIZE_THRESHOLDS")
fi
if [ -n "${SIZE_REPORT:-}" ]; then
parser_args+=(--report "$SIZE_REPORT")
fi
if [ "${SIZE_ENFORCE_THRESHOLDS:-0}" = "1" ]; then
parser_args+=(--enforce-thresholds)
fi
case "$mode" in case "$mode" in
baseline) baseline)

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/meshcore-env.sh"
bot_sources=(
"${MESHCORE_DIR}/examples/companion_radio/BotTypes.h"
"${MESHCORE_DIR}/examples/companion_radio/FirmwareBot.h"
"${MESHCORE_DIR}/examples/companion_radio/FirmwareBot.cpp"
"${MESHCORE_DIR}/examples/companion_radio/BotCommands.h"
"${MESHCORE_DIR}/examples/companion_radio/BotCommands.cpp"
"${MESHCORE_DIR}/examples/companion_radio/BotPolicy.h"
"${MESHCORE_DIR}/examples/companion_radio/BotPolicy.cpp"
"${MESHCORE_DIR}/examples/companion_radio/BotPrefs.h"
"${MESHCORE_DIR}/examples/companion_radio/BotPrefs.cpp"
"${MESHCORE_DIR}/examples/companion_radio/EmergencyForwarder.h"
"${MESHCORE_DIR}/examples/companion_radio/EmergencyForwarder.cpp"
"${MESHCORE_DIR}/examples/companion_radio/KnownBotRegistry.h"
"${MESHCORE_DIR}/examples/companion_radio/KnownBotRegistry.cpp"
"${MESHCORE_DIR}/examples/companion_radio/ResponseCoordinator.h"
"${MESHCORE_DIR}/examples/companion_radio/ResponseCoordinator.cpp"
)
missing=0
for file in "${bot_sources[@]}"; do
if [ ! -f "$file" ]; then
echo "Missing bot source: ${file#${MESHCORE_FW_ROOT}/}" >&2
missing=1
fi
done
if [ "$missing" -ne 0 ]; then
exit 1
fi
forbidden='\bString\b|std::|\bnew[[:space:]]|\bdelete[[:space:]]|\b(malloc|calloc|realloc|free)[[:space:]]*\(|ArduinoJson|JsonDocument|HTTPClient|WiFiClient|WebServer|AsyncWebServer|socket[[:space:]]*\('
if grep -n -E "$forbidden" "${bot_sources[@]}"; then
echo "Forbidden dynamic allocation, container, JSON, or network API found in firmware bot sources." >&2
exit 1
fi
forbidden_timing='\b(txdelay|rxdelay)\b|direct[._-]?tx[._-]?delay|airtime_factor|rx_delay_base'
if grep -n -E -i "$forbidden_timing" "${bot_sources[@]}"; then
echo "Forbidden lower-layer timing dependency found in firmware bot sources." >&2
exit 1
fi
require_pattern() {
local pattern="$1"
shift
local label="$1"
shift
if ! grep -q -E "$pattern" "$@"; then
echo "Missing safety marker: $label" >&2
exit 1
fi
}
require_pattern 'BOT_POLICY_IGNORE' 'normal Public bot traffic is ignored silently' \
"${MESHCORE_DIR}/examples/companion_radio/BotPolicy.cpp" \
"${MESHCORE_DIR}/examples/companion_radio/BotTypes.h"
require_pattern 'BOT_POLICY_EMERGENCY_FORWARD' 'emergency traffic forwarding policy exists' \
"${MESHCORE_DIR}/examples/companion_radio/BotPolicy.cpp" \
"${MESHCORE_DIR}/examples/companion_radio/BotPolicy.h"
require_pattern 'recordBotObservation\(|sendQueuedEmergencyForwards\(|tickBot\(' 'emergency path is wired in MyMesh' \
"${MESHCORE_DIR}/examples/companion_radio/MyMesh.cpp"
require_pattern '_prefs\.path_hash_mode[[:space:]]*=[[:space:]]*1' 'bot firmware defaults to two-byte path hashes' \
"${MESHCORE_DIR}/examples/companion_radio/MyMesh.cpp"
require_pattern 'CMESH_BOT_ENABLED=1' 'production bot build flag is enabled' \
"${MESHCORE_DIR}/platformio.ini"
require_pattern 'ENABLE_PRIVATE_KEY_IMPORT=0' 'private key import disabled in production bot flags' \
"${MESHCORE_DIR}/platformio.ini"
require_pattern 'ENABLE_PRIVATE_KEY_EXPORT=0' 'private key export disabled in production bot flags' \
"${MESHCORE_DIR}/platformio.ini"
require_pattern '\$\{cmesh_bot_production\.build_flags\}' 'representative envs include production bot flags' \
"${MESHCORE_DIR}/variants/heltec_v3/platformio.ini" \
"${MESHCORE_DIR}/variants/rak4631/platformio.ini"
count="$(grep -h -E '\$\{cmesh_bot_production\.build_flags\}' \
"${MESHCORE_DIR}/variants/heltec_v3/platformio.ini" \
"${MESHCORE_DIR}/variants/rak4631/platformio.ini" | wc -l | tr -d ' ')"
if [ "$count" -lt 4 ]; then
echo "Expected production bot flags in four representative envs, found $count." >&2
exit 1
fi
echo "Firmware bot safety checks passed."

View File

@@ -98,6 +98,102 @@ def add_delta(entry, baseline_by_env):
entry["delta"] = delta entry["delta"] = delta
def metric_limit(config, metric, level):
value = config.get(metric, {})
if isinstance(value, dict):
return value.get(f"{level}_delta", value.get(level))
return None
def env_threshold_config(thresholds, env):
config = dict(thresholds.get("defaults", {}))
config.update(thresholds.get("environments", {}).get(env, {}))
return config
def evaluate_thresholds(summary, thresholds):
checks = []
status = "ok"
for entry in summary["environments"]:
delta = entry.get("delta")
if not delta:
checks.append({"env": entry["env"], "status": "not_evaluated", "reason": "no baseline delta"})
continue
config = env_threshold_config(thresholds, entry["env"])
for metric in ("flash_used", "ram_used"):
value = delta.get(metric)
if value is None:
checks.append({"env": entry["env"], "metric": metric, "status": "not_evaluated", "reason": "missing delta"})
continue
warn_limit = metric_limit(config, metric, "warn")
fail_limit = metric_limit(config, metric, "fail")
check_status = "ok"
if fail_limit is not None and value > fail_limit:
check_status = "fail"
status = "fail"
elif warn_limit is not None and value > warn_limit:
check_status = "warn"
if status == "ok":
status = "warn"
checks.append({
"env": entry["env"],
"metric": metric,
"delta": value,
"warn_delta": warn_limit,
"fail_delta": fail_limit,
"status": check_status,
})
artifact_config = config.get("artifact_bytes_by_kind", {})
for kind, value in delta.get("artifact_bytes_by_kind", {}).items():
limits = artifact_config.get(kind, {})
warn_limit = limits.get("warn_delta", limits.get("warn"))
fail_limit = limits.get("fail_delta", limits.get("fail"))
check_status = "ok"
if fail_limit is not None and value > fail_limit:
check_status = "fail"
status = "fail"
elif warn_limit is not None and value > warn_limit:
check_status = "warn"
if status == "ok":
status = "warn"
checks.append({
"env": entry["env"],
"metric": f"artifact_bytes_by_kind.{kind}",
"delta": value,
"warn_delta": warn_limit,
"fail_delta": fail_limit,
"status": check_status,
})
return {"status": status, "checks": checks}
def report_lines(summary):
lines = [f"Size summary ({summary['mode']})"]
for entry in summary["environments"]:
lines.append(f"- {entry['env']}:")
lines.append(f" RAM: {entry['ram_used']} / {entry['ram_total']} bytes")
lines.append(f" Flash: {entry['flash_used']} / {entry['flash_total']} bytes")
if entry.get("delta"):
delta = entry["delta"]
lines.append(f" Delta: RAM {delta.get('ram_used')} bytes, flash {delta.get('flash_used')} bytes")
for name, size in sorted(entry.get("artifact_bytes", {}).items()):
lines.append(f" Artifact: {name} = {size} bytes")
thresholds = summary.get("thresholds")
if thresholds:
lines.append(f"Threshold status: {thresholds['status']}")
for check in thresholds["checks"]:
if check["status"] == "ok":
continue
reason = check.get("reason", "")
metric = check.get("metric", "all")
delta = check.get("delta", "n/a")
lines.append(f"- {check['env']} {metric}: {check['status']} delta={delta} {reason}".rstrip())
return lines
def build_summary(args): def build_summary(args):
log_dir = args.logs log_dir = args.logs
artifact_dir = args.artifacts artifact_dir = args.artifacts
@@ -136,16 +232,29 @@ def main():
parser.add_argument("--env", action="append", help="Environment name to include; may be passed more than once") parser.add_argument("--env", action="append", help="Environment name to include; may be passed more than once")
parser.add_argument("--baseline", action="store_true", help="Mark output as a baseline summary") parser.add_argument("--baseline", action="store_true", help="Mark output as a baseline summary")
parser.add_argument("--compare", type=Path, help="Baseline JSON to compare against") parser.add_argument("--compare", type=Path, help="Baseline JSON to compare against")
parser.add_argument("--thresholds", type=Path, help="JSON file with warn/fail size delta thresholds")
parser.add_argument("--enforce-thresholds", action="store_true", help="Exit nonzero when threshold status is fail")
parser.add_argument("--report", type=Path, help="Write a human-readable size report to this path")
parser.add_argument("--output", type=Path, help="Write JSON summary to this path") parser.add_argument("--output", type=Path, help="Write JSON summary to this path")
args = parser.parse_args() args = parser.parse_args()
summary = build_summary(args) summary = build_summary(args)
if args.thresholds:
thresholds = json.loads(args.thresholds.read_text())
summary["thresholds"] = evaluate_thresholds(summary, thresholds)
text = json.dumps(summary, indent=2, sort_keys=True) text = json.dumps(summary, indent=2, sort_keys=True)
if args.output: if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True) args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(text + "\n") args.output.write_text(text + "\n")
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text("\n".join(report_lines(summary)) + "\n")
print(text) print(text)
if args.enforce_thresholds and summary.get("thresholds", {}).get("status") == "fail":
raise SystemExit(1)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

61
scripts/verify.sh Normal file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/meshcore-env.sh"
usage() {
cat <<'EOF'
Usage: scripts/verify.sh [--no-build|--build]
Runs local pre-review checks. By default, this applies the MeshCore patch queue
when needed, runs host tests, runs firmware bot safety checks, and compiles
Python helper scripts. Use --build to also run representative firmware builds.
EOF
}
run_build=0
while [ "$#" -gt 0 ]; do
case "$1" in
--help|-h)
usage
exit 0
;;
--no-build)
run_build=0
shift
;;
--build)
run_build=1
shift
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [ ! -d "${MESHCORE_DIR}/.git" ] && [ ! -f "${MESHCORE_DIR}/.git" ]; then
echo "MeshCore submodule is missing at ${MESHCORE_DIR}" >&2
echo "Run: git submodule update --init --recursive" >&2
exit 1
fi
if ! git -C "${MESHCORE_DIR}" diff --quiet --cached || ! git -C "${MESHCORE_DIR}" diff --quiet || [ -n "$(git -C "${MESHCORE_DIR}" ls-files --others --exclude-standard)" ]; then
echo "MeshCore submodule is dirty; verify existing patched tree without applying patches."
else
"${MESHCORE_FW_ROOT}/scripts/apply-patches.sh"
fi
python3 "${MESHCORE_FW_ROOT}/tests/firmware_bot/run_tests.py"
bash "${MESHCORE_FW_ROOT}/scripts/check-bot-safety.sh"
python3 -m py_compile \
"${MESHCORE_FW_ROOT}/scripts/parse-size-report.py" \
"${MESHCORE_FW_ROOT}/tests/firmware_bot/run_tests.py"
if [ "$run_build" -eq 1 ]; then
MESHCORE_SKIP_APPLY_PATCHES=1 bash "${MESHCORE_FW_ROOT}/scripts/build-representative.sh" --baseline
fi
echo "Verification complete."