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>
This commit is contained in:
2026-10-01 15:16:58 +03:00
parent c723efb186
commit 5c50742a61
3 changed files with 345 additions and 0 deletions
Executable
+193
View File
@@ -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}"
Executable
+108
View File
@@ -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
}
+44
View File
@@ -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}"