diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..a269390 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# +# Blocker CI — the source of truth for what must pass before a PR is mergeable. +# +# CLAUDE.md section 10.4 requires a Gitea workflow to run these same checks. It +# calls THIS script rather than reimplementing the steps, so the two cannot +# drift apart. That is the whole reason this file exists separately from any +# workflow definition. +# +# Usage: +# scripts/ci.sh run every check, stop at the first failure +# scripts/ci.sh --keep-going run every check, summarise all failures at the end +# scripts/ci.sh --help +# +# Note on skips: a skipped check never counts as a pass. Every skip names what +# was skipped and why, because a skip that reads as a green tick is how CI ends +# up quietly not testing anything. + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +KEEP_GOING=0 + +usage() { + cat <<'USAGE' +Usage: scripts/ci.sh [--keep-going] [--help] + +Checks, in order: + 1. cargo fmt --all --check + 2. cargo clippy --workspace --all-targets -- -D warnings + 3. cargo test --workspace --all-targets + 4. size gate: stripped release daemon <= 5 MB (section 5) + 5. Android lint + assembleRelease (skipped loudly if no SDK) + 6. gitleaks secret scan (skipped loudly if not installed) + + --keep-going run every check and report all failures together +USAGE +} + +while (( $# )); do + case "$1" in + --keep-going) KEEP_GOING=1 ;; + -h | --help) + usage + exit 0 + ;; + *) + printf 'unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +TOTAL_STEPS=6 + +FAILED=() +SKIPPED=() +STEP_NUM=0 + +run_step() { + STEP_NUM=$((STEP_NUM + 1)) + printf '\n%s[%d/%d]%s %s\n' \ + "${C_BLUE}" "${STEP_NUM}" "${TOTAL_STEPS}" "${C_RESET}" "$1" + shift + local output + # Each check runs in a subshell so a failure is capturable and the + # --keep-going summary can report it, rather than `set -e` aborting the + # whole run with no record of what had already passed. + if output="$("$@" 2>&1)"; then + [[ -n "${output}" ]] && printf '%s\n' "${output}" | sed 's/^/ /' + return 0 + fi + [[ -n "${output}" ]] && printf '%s\n' "${output}" | sed 's/^/ /' + return 1 +} + +record() { + if (( KEEP_GOING )); then + FAILED+=("$1") + return 0 + fi + printf '\n%scheck failed:%s %s\n' "${C_RED}" "${C_RESET}" "$1" + exit 1 +} + +printf '%sBlocker CI%s\n' "${C_BLUE}" "${C_RESET}" +info "repo: ${REPO_ROOT}" +info "target: ${CARGO_TARGET_DIR}" + +# Cargo resolves a workspace from the current directory, not from the script's +# location, so a human running this from ~/Desktop and a CI runner invoking it by +# absolute path both need the CWD moved. Everything below can then use plain +# `cargo` invocations. +cd -- "${REPO_ROOT}" + +# --- 1. Formatting --------------------------------------------------------- +if run_step 'cargo fmt --all --check' cargo fmt --all -- --check; then + pass 'formatting' +else + record 'cargo fmt --all --check' +fi + +# --- 2. Clippy ------------------------------------------------------------- +if run_step 'cargo clippy -D warnings' \ + cargo clippy --workspace --all-targets -- -D warnings; then + pass 'clippy' +else + record 'cargo clippy -D warnings' +fi + +# --- 3. Tests -------------------------------------------------------------- +if run_step 'cargo test --workspace' cargo test --workspace --all-targets; then + pass 'tests (including golden decision tables)' +else + record 'cargo test --workspace' +fi + +# --- 4. Size gate ---------------------------------------------------------- +# Section 5: the stripped desktop daemon must not exceed 5 MB. In M0 the daemon +# is a placeholder that prints two lines, so this is far under budget; the gate +# becomes meaningful in M2-Linux once enforcement code exists. It runs from M0 +# anyway so that the budget is enforced by tooling from the first commit rather +# than being introduced as a new failure later. +printf '\n%s[4/6]%s size gate (section 5: stripped daemon <= 5 MB)\n' \ + "${C_BLUE}" "${C_RESET}" +if run_step 'cargo build --release -p blocker-daemon' \ + cargo build --release -p blocker-daemon; then + if "${REPO_ROOT}/scripts/size-check.sh"; then + pass 'size gate' + else + record 'size gate' + fi +else + record 'cargo build --release -p blocker-daemon' +fi + +# --- 5. Android ------------------------------------------------------------ +# Skipped loudly, never silently. CLAUDE.md section 10.4 says to ask whether the +# runner has the SDK; until that is answered, detection plus an honest message is +# the truthful behaviour. A pass here would be a lie. +printf '\n%s[5/6]%s Android lint + assembleRelease\n' "${C_BLUE}" "${C_RESET}" +if SDK_ROOT="$(android_sdk_root)"; then + info "SDK: ${SDK_ROOT}" + if run_step 'android assembleRelease' \ + "${REPO_ROOT}/android/gradlew" -p "${REPO_ROOT}/android" lint assembleRelease; then + pass 'android' + else + record 'android lint/assembleRelease' + fi +else + skip 'android lint + assembleRelease' \ + 'no Android SDK found (ANDROID_HOME / ANDROID_SDK_ROOT unset, ~/Android/Sdk absent). Not verified by this run.' + SKIPPED+=('android') +fi + +# --- 6. Secret scan -------------------------------------------------------- +# Section 10.1 requires a secret scanner in CI and in ci.sh. gitleaks is not +# installed on every machine and that is not a reason to skip it quietly. +printf '\n%s[6/6]%s secret scan (gitleaks)\n' "${C_BLUE}" "${C_RESET}" +if command -v gitleaks > /dev/null 2>&1; then + # `gitleaks detect` never prints matched secret values, only file paths and + # rule names — so this output is safe to paste into a PR. + if run_step 'gitleaks detect' \ + gitleaks detect --source "${REPO_ROOT}" --redact --no-banner; then + pass 'secret scan' + else + record 'gitleaks detect' + fi +else + skip 'gitleaks secret scan' \ + 'gitleaks not installed. Install it (https://github.com/gitleaks/gitleaks) before merging. NOT a pass.' + SKIPPED+=('gitleaks') +fi + +# --- Summary --------------------------------------------------------------- +printf '\n%s%s%s\n' "${C_BLUE}" '── summary ──' "${C_RESET}" + +if (( ${#FAILED[@]} )); then + printf '%s%d check(s) failed:%s\n' "${C_RED}" "${#FAILED[@]}" "${C_RESET}" + for f in "${FAILED[@]}"; do printf ' - %s\n' "${f}"; done + exit 1 +fi + +if (( ${#SKIPPED[@]} )); then + printf '%s%d check(s) skipped — these are NOT passes:%s\n' \ + "${C_YELLOW}" "${#SKIPPED[@]}" "${C_RESET}" + for s in "${SKIPPED[@]}"; do printf ' - %s\n' "${s}"; done +fi + +printf '%sall executed checks passed%s\n' "${C_GREEN}" "${C_RESET}" \ No newline at end of file diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100755 index 0000000..fd28c84 --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# Shared helpers for the Blocker CI scripts. +# +# Sourced, never executed. + +set -euo pipefail + +# --- Colours --------------------------------------------------------------- +# Only when stdout is a terminal, so CI logs stay clean and diffable. +if [[ -t 1 ]]; then + C_RESET=$'\033[0m' + C_RED=$'\033[31m' + C_GREEN=$'\033[32m' + C_YELLOW=$'\033[33m' + C_BLUE=$'\033[34m' + C_DIM=$'\033[2m' +else + C_RESET='' C_RED='' C_GREEN='' C_YELLOW='' C_BLUE='' C_DIM='' +fi + +# --- Paths ----------------------------------------------------------------- +# Resolve the repo root from this file's location so every script works from +# any working directory. CI invokes these from the workspace root; humans +# invoke them from wherever they happen to be. +LIB_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${LIB_DIR}/.." && pwd)" + +# --- Build output ---------------------------------------------------------- +# Keep build artifacts OUT of the repo (section 10.1: build outputs are not in +# git) and in one deletable place. Overridable, but the default matters: a +# target/ inside the repo shows up in `git status` and tempts a `git add -A`. +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-${HOME}/.blocker-target}" + +# --- Toolchain on PATH ----------------------------------------------------- +# rustup installs shims into ~/.cargo/bin and adds a line to ~/.bashrc and +# ~/.profile. A non-interactive shell does not read ~/.bashrc, so a script run +# from CI, a cron job, or a different CWD can fail with "cargo: command not +# found" even on a correctly installed machine. Adding the directory here is +# idempotent and does not modify the user's shell config. +if [[ -d "${HOME}/.cargo/bin" ]]; then + case ":${PATH}:" in + *":${HOME}/.cargo/bin:"*) ;; + *) PATH="${HOME}/.cargo/bin:${PATH}" ;; + esac + export PATH +fi + +# --- Section 5 budgets ----------------------------------------------------- +MAX_DAEMON_BYTES=$((5 * 1024 * 1024)) # 5 MB stripped daemon + +# --- Reporting ------------------------------------------------------------- +_step=0 + +step() { + _step=$((_step + 1)) + printf '\n%s[%d/%d]%s %s\n' "${C_BLUE}" "${_step}" "${TOTAL_STEPS:-?}" "${C_RESET}" "$1" +} + +pass() { + printf ' %sPASS%s %s\n' "${C_GREEN}" "${C_RESET}" "$1" +} + +fail() { + printf ' %sFAIL%s %s\n' "${C_RED}" "${C_RESET}" "$1" +} + +# A skip that reads as a pass is the classic CI lie (CLAUDE.md section 10.4). +# Skips must always name what was skipped and why. +skip() { + printf ' %sSKIP%s %s\n' "${C_YELLOW}" "${C_RESET}" "$1" + printf ' %sreason:%s %s\n' "${C_DIM}" "${C_RESET}" "$2" +} + +info() { + printf ' %s%s%s\n' "${C_DIM}" "$1" "${C_RESET}" +} + +# Announce a non-fatal environment note once, before the checks run, so a +# reader knows what to expect rather than wondering why a stage vanished. +announce_skip() { + printf '%s%s%s\n' "${C_YELLOW}" "SKIP: $1 — $2" "${C_RESET}" +} + +die() { + printf '\n%sfatal:%s %s\n' "${C_RED}" "${C_RESET}" "$1" >&2 + exit 1 +} + +# --- Android SDK detection ------------------------------------------------- +# CLAUDE.md section 10.4 asks whether the runner has the SDK. Rather than assume +# either way, detect it and report honestly. +android_sdk_root() { + if [[ -n "${ANDROID_HOME:-}" && -d "${ANDROID_HOME}" ]]; then + printf '%s' "${ANDROID_HOME}" + return 0 + fi + if [[ -n "${ANDROID_SDK_ROOT:-}" && -d "${ANDROID_SDK_ROOT}" ]]; then + printf '%s' "${ANDROID_SDK_ROOT}" + return 0 + fi + # Common default install location. + if [[ -d "${HOME}/Android/Sdk" ]]; then + printf '%s' "${HOME}/Android/Sdk" + return 0 + fi + return 1 +} \ No newline at end of file diff --git a/scripts/size-check.sh b/scripts/size-check.sh new file mode 100755 index 0000000..1dbcd8f --- /dev/null +++ b/scripts/size-check.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# +# Size gate: fail if the stripped release daemon exceeds the section 5 budget. +# +# Usage: size-check.sh [binary-path] [max-bytes] +# +# Kept separate from ci.sh so the Gitea workflow can run it as its own step +# against an artifact it has already built, rather than rebuilding. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +# Default binary path. CARGO_TARGET_DIR may be absolute (the usual case — it +# points outside the repo) or relative, so it must not be blindly joined onto +# REPO_ROOT: doing that turns "/home/sp/.blocker-target/..." into +# "/home/sp/blocker//home/sp/.blocker-target/...". +DEFAULT_TARGET="${CARGO_TARGET_DIR:-${HOME}/.blocker-target}" +if [[ "${DEFAULT_TARGET}" = /* ]]; then + DEFAULT_BINARY="${DEFAULT_TARGET}/release/blocker-daemon" +else + DEFAULT_BINARY="${REPO_ROOT}/${DEFAULT_TARGET}/release/blocker-daemon" +fi + +BINARY="${1:-${DEFAULT_BINARY}}" +MAX_BYTES="${2:-${MAX_DAEMON_BYTES}}" + +# Normalise so the message shows the real path rather than a doubled prefix. +BINARY="$(cd -- "$(dirname -- "${BINARY}")" 2>/dev/null && pwd)/$(basename -- "${BINARY}")" \ + || die "binary not found: ${BINARY}" + +SIZE="$(stat -c %s "${BINARY}" 2>/dev/null || stat -f %z "${BINARY}")" + +# Human-readable, using 1024-based units to match how the budget is written. +printf 'binary : %s\n' "${BINARY}" +printf 'size : %s bytes (%d.%03d MB)\n' \ + "${SIZE}" "$((SIZE / 1048576))" "$(((SIZE % 1048576) * 1000 / 1048576))" +printf 'budget : %s bytes (%d MB)\n' "${MAX_BYTES}" "$((MAX_BYTES / 1048576))" + +if (( SIZE > MAX_BYTES )); then + printf '%sOVER BUDGET by %s bytes%s\n' "${C_RED}" "$((SIZE - MAX_BYTES))" "${C_RESET}" + exit 1 +fi + +printf '%swithin budget, %s bytes headroom%s\n' \ + "${C_GREEN}" "$((MAX_BYTES - SIZE))" "${C_RESET}" \ No newline at end of file