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:
Executable
+193
@@ -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
@@ -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
|
||||||
|
}
|
||||||
Executable
+44
@@ -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}"
|
||||||
Reference in New Issue
Block a user