|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Copyright (c) 2026 bitkaio LLC. All rights reserved. |
| 3 | +# Licensed under the Apache License, Version 2.0. See LICENSE for details. |
| 4 | +# |
| 5 | +# Run the same checks GitHub Actions CI runs, locally, in Docker. |
| 6 | +# Mirrors .github/workflows/ci.yml so you can get a green-or-red signal |
| 7 | +# before pushing. |
| 8 | +# |
| 9 | +# Usage: |
| 10 | +# scripts/ci-local.sh # run all checks |
| 11 | +# scripts/ci-local.sh lint test # run only selected checks |
| 12 | +# SKIP_CONTAINER_SCAN=1 scripts/ci-local.sh # skip the slow container build+trivy |
| 13 | +# |
| 14 | +# Checks (names match the CI job names as closely as possible): |
| 15 | +# lint golangci-lint (Docker) |
| 16 | +# test go test -race ./... |
| 17 | +# vulncheck govulncheck ./... |
| 18 | +# semgrep Semgrep with the same config set as CI (Docker) |
| 19 | +# hadolint Hadolint on both Dockerfiles (Docker) |
| 20 | +# zizmor Zizmor on .github/workflows (Docker) |
| 21 | +# dockerfile Build deploy/Dockerfile (no push) |
| 22 | +# trivy Trivy HIGH/CRITICAL scan of the built image (Docker) |
| 23 | +# markdown markdownlint-cli2 (Docker) |
| 24 | +# |
| 25 | +# Exit code is non-zero if any selected check fails. |
| 26 | + |
| 27 | +set -euo pipefail |
| 28 | + |
| 29 | +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| 30 | +cd "$REPO_ROOT" |
| 31 | + |
| 32 | +# --- Pinned tool versions (keep in sync with .github/workflows/ci.yml) ------- |
| 33 | +SEMGREP_IMAGE="semgrep/semgrep@sha256:d8159ff400a103b21d231a9646452025769552e631df786f508448d2e4eacf86" |
| 34 | +HADOLINT_IMAGE="ghcr.io/hadolint/hadolint:v2.14.0-debian" |
| 35 | +GOLANGCI_IMAGE="golangci/golangci-lint:latest-alpine" |
| 36 | +ZIZMOR_VERSION="1.17.0" |
| 37 | +TRIVY_IMAGE="aquasec/trivy:0.70.0" |
| 38 | +MARKDOWNLINT_IMAGE="davidanson/markdownlint-cli2:v0.18.1" |
| 39 | +LOCAL_IMAGE_TAG="palena:ci-local" |
| 40 | + |
| 41 | +# --- Pretty output ------------------------------------------------------------ |
| 42 | +C_GREEN=$'\033[32m'; C_RED=$'\033[31m'; C_YELLOW=$'\033[33m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m' |
| 43 | + |
| 44 | +FAILED=() |
| 45 | +PASSED=() |
| 46 | + |
| 47 | +step() { printf '\n%s==> %s%s\n' "$C_BOLD" "$1" "$C_RESET"; } |
| 48 | +ok() { printf '%s[PASS]%s %s\n' "$C_GREEN" "$C_RESET" "$1"; PASSED+=("$1"); } |
| 49 | +fail() { printf '%s[FAIL]%s %s\n' "$C_RED" "$C_RESET" "$1"; FAILED+=("$1"); } |
| 50 | +skip() { printf '%s[SKIP]%s %s\n' "$C_YELLOW" "$C_RESET" "$1"; } |
| 51 | + |
| 52 | +need_docker() { |
| 53 | + if ! command -v docker >/dev/null 2>&1; then |
| 54 | + echo "docker is required but not installed" >&2 |
| 55 | + exit 2 |
| 56 | + fi |
| 57 | +} |
| 58 | + |
| 59 | +# --- Individual checks -------------------------------------------------------- |
| 60 | + |
| 61 | +run_lint() { |
| 62 | + step "golangci-lint" |
| 63 | + need_docker |
| 64 | + if docker run --rm -v "$REPO_ROOT:/app" -w /app "$GOLANGCI_IMAGE" \ |
| 65 | + golangci-lint run --timeout=5m; then |
| 66 | + ok "lint" |
| 67 | + else |
| 68 | + fail "lint" |
| 69 | + fi |
| 70 | +} |
| 71 | + |
| 72 | +run_test() { |
| 73 | + step "go test -race ./..." |
| 74 | + if ! command -v go >/dev/null 2>&1; then |
| 75 | + skip "test (go not installed locally)" |
| 76 | + return |
| 77 | + fi |
| 78 | + if go test -race -coverprofile=coverage.out -covermode=atomic ./...; then |
| 79 | + ok "test" |
| 80 | + else |
| 81 | + fail "test" |
| 82 | + fi |
| 83 | +} |
| 84 | + |
| 85 | +run_vulncheck() { |
| 86 | + step "govulncheck" |
| 87 | + if ! command -v go >/dev/null 2>&1; then |
| 88 | + skip "vulncheck (go not installed locally)" |
| 89 | + return |
| 90 | + fi |
| 91 | + if ! command -v govulncheck >/dev/null 2>&1; then |
| 92 | + go install golang.org/x/vuln/cmd/govulncheck@latest |
| 93 | + fi |
| 94 | + if govulncheck ./...; then |
| 95 | + ok "vulncheck" |
| 96 | + else |
| 97 | + fail "vulncheck" |
| 98 | + fi |
| 99 | +} |
| 100 | + |
| 101 | +run_semgrep() { |
| 102 | + step "semgrep (same config + severity as CI)" |
| 103 | + need_docker |
| 104 | + if docker run --rm -v "$REPO_ROOT:/src" -w /src "$SEMGREP_IMAGE" \ |
| 105 | + semgrep scan \ |
| 106 | + --config p/golang \ |
| 107 | + --config p/security-audit \ |
| 108 | + --config p/owasp-top-ten \ |
| 109 | + --config p/dockerfile \ |
| 110 | + --severity ERROR \ |
| 111 | + --error \ |
| 112 | + --metrics=off; then |
| 113 | + ok "semgrep" |
| 114 | + else |
| 115 | + fail "semgrep" |
| 116 | + fi |
| 117 | +} |
| 118 | + |
| 119 | +run_hadolint() { |
| 120 | + step "hadolint (deploy/Dockerfile + deploy/Dockerfile.flashrank)" |
| 121 | + need_docker |
| 122 | + local rc=0 |
| 123 | + for df in deploy/Dockerfile deploy/Dockerfile.flashrank; do |
| 124 | + printf -- '--- %s ---\n' "$df" |
| 125 | + if ! docker run --rm -i "$HADOLINT_IMAGE" hadolint - < "$df"; then |
| 126 | + rc=1 |
| 127 | + fi |
| 128 | + done |
| 129 | + if [ "$rc" -eq 0 ]; then |
| 130 | + ok "hadolint" |
| 131 | + else |
| 132 | + fail "hadolint" |
| 133 | + fi |
| 134 | +} |
| 135 | + |
| 136 | +run_zizmor() { |
| 137 | + step "zizmor (fail on HIGH)" |
| 138 | + need_docker |
| 139 | + # zizmor ships an official pip package; run via python:slim to avoid |
| 140 | + # polluting the host Python env. |
| 141 | + if docker run --rm -v "$REPO_ROOT:/src" -w /src python:3.12-slim \ |
| 142 | + sh -c "pip install --quiet --disable-pip-version-check 'zizmor==$ZIZMOR_VERSION' \ |
| 143 | + && zizmor --min-severity high --format plain .github/workflows/"; then |
| 144 | + ok "zizmor" |
| 145 | + else |
| 146 | + fail "zizmor" |
| 147 | + fi |
| 148 | +} |
| 149 | + |
| 150 | +run_dockerfile() { |
| 151 | + step "docker build deploy/Dockerfile" |
| 152 | + need_docker |
| 153 | + if docker build -f deploy/Dockerfile -t "$LOCAL_IMAGE_TAG" .; then |
| 154 | + ok "dockerfile" |
| 155 | + else |
| 156 | + fail "dockerfile" |
| 157 | + fi |
| 158 | +} |
| 159 | + |
| 160 | +run_trivy() { |
| 161 | + step "trivy image scan (HIGH/CRITICAL, --ignore-unfixed)" |
| 162 | + need_docker |
| 163 | + if ! docker image inspect "$LOCAL_IMAGE_TAG" >/dev/null 2>&1; then |
| 164 | + echo "$LOCAL_IMAGE_TAG not built; running 'dockerfile' first" |
| 165 | + run_dockerfile |
| 166 | + fi |
| 167 | + if docker run --rm \ |
| 168 | + -v /var/run/docker.sock:/var/run/docker.sock \ |
| 169 | + "$TRIVY_IMAGE" image \ |
| 170 | + --exit-code 1 \ |
| 171 | + --severity HIGH,CRITICAL \ |
| 172 | + --ignore-unfixed \ |
| 173 | + "$LOCAL_IMAGE_TAG"; then |
| 174 | + ok "trivy" |
| 175 | + else |
| 176 | + fail "trivy" |
| 177 | + fi |
| 178 | +} |
| 179 | + |
| 180 | +run_markdown() { |
| 181 | + step "markdownlint-cli2" |
| 182 | + need_docker |
| 183 | + if docker run --rm -v "$REPO_ROOT:/workdir" "$MARKDOWNLINT_IMAGE" \ |
| 184 | + "**/*.md" "#node_modules" "#tmp" "#vendor"; then |
| 185 | + ok "markdown" |
| 186 | + else |
| 187 | + fail "markdown" |
| 188 | + fi |
| 189 | +} |
| 190 | + |
| 191 | +# --- Dispatch ----------------------------------------------------------------- |
| 192 | + |
| 193 | +ALL_CHECKS=(lint test vulncheck semgrep hadolint zizmor dockerfile trivy markdown) |
| 194 | + |
| 195 | +if [ "$#" -gt 0 ]; then |
| 196 | + SELECTED=("$@") |
| 197 | +else |
| 198 | + SELECTED=("${ALL_CHECKS[@]}") |
| 199 | +fi |
| 200 | + |
| 201 | +for check in "${SELECTED[@]}"; do |
| 202 | + case "$check" in |
| 203 | + lint) run_lint ;; |
| 204 | + test) run_test ;; |
| 205 | + vulncheck) run_vulncheck ;; |
| 206 | + semgrep) run_semgrep ;; |
| 207 | + hadolint) run_hadolint ;; |
| 208 | + zizmor) run_zizmor ;; |
| 209 | + dockerfile) run_dockerfile ;; |
| 210 | + trivy) |
| 211 | + if [ "${SKIP_CONTAINER_SCAN:-0}" = "1" ]; then |
| 212 | + skip "trivy (SKIP_CONTAINER_SCAN=1)" |
| 213 | + else |
| 214 | + run_trivy |
| 215 | + fi |
| 216 | + ;; |
| 217 | + markdown) run_markdown ;; |
| 218 | + *) echo "unknown check: $check" >&2; exit 2 ;; |
| 219 | + esac |
| 220 | +done |
| 221 | + |
| 222 | +printf '\n%s=== Summary ===%s\n' "$C_BOLD" "$C_RESET" |
| 223 | +for c in ${PASSED[@]+"${PASSED[@]}"}; do printf '%s[PASS]%s %s\n' "$C_GREEN" "$C_RESET" "$c"; done |
| 224 | +for c in ${FAILED[@]+"${FAILED[@]}"}; do printf '%s[FAIL]%s %s\n' "$C_RED" "$C_RESET" "$c"; done |
| 225 | + |
| 226 | +if [ "${#FAILED[@]}" -gt 0 ]; then |
| 227 | + printf '\n%s%d check(s) failed.%s\n' "$C_RED" "${#FAILED[@]}" "$C_RESET" |
| 228 | + exit 1 |
| 229 | +fi |
| 230 | +printf '\n%sAll checks passed.%s\n' "$C_GREEN" "$C_RESET" |
0 commit comments