Files
blocker/scripts/ci.sh
SellPL 004479a032 ci: add ci.sh, size gate, and shared script helpers
`scripts/ci.sh` is the source of truth for what must pass before a PR is
mergeable. CLAUDE.md section 10.4 requires the Gitea workflow to run the same
checks; the workflow calls this script rather than reimplementing the steps, so
the two cannot drift apart.

Runs: cargo fmt --check, clippy -D warnings, cargo test, the section 5 size
gate, Android lint/assembleRelease, and a gitleaks scan.

Verified behaviour, rather than assumed:

- **Failures really fail.** Injected a syntax error into `core/src/lib.rs` and
  confirmed `ci.sh --keep-going` exits 1 and names all four dependent checks;
  then restored the file and confirmed a clean exit 0. `--keep-going` captures
  failures per step in a subshell instead of letting `set -e` abort the run, so
  the summary records what already passed.
- **Skips are never passes.** Android and gitleaks both skip on this machine
  (no SDK, no gitleaks), and each SKIP names what was skipped and why, with the
  summary listing them separately from the passes. A skip that reads as a green
  tick is how CI quietly stops testing anything.
- **Runs from any CWD.** `cargo` resolves a workspace from the CWD, not from
  the script's location, so `ci.sh` cds to the repo root. Verified by running it
  from `/tmp`.
- **`cargo` is on PATH.** rustup adds `~/.cargo/bin` via `~/.bashrc`, which a
  non-interactive shell does not read — so a script invoked from CI or by
  absolute path failed with "command not found" until `lib.sh` prepends it.
- **Size gate resolves absolute paths.** `CARGO_TARGET_DIR` is absolute by
  default, so joining it onto the repo root produced a doubled path; the default
  is now only joined when relative.

Build artifacts go to `$HOME/.blocker-target`, outside the repo, so `target/`
never appears in `git status` and can be deleted in one command.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 15:16:58 +03:00

193 lines
6.6 KiB
Bash
Executable File

#!/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}"