WUT_Computer_Science/run.sh
Krzysztof kuhy Rudnicki e512bde26f Gate the invariants, fix ECOTE's unrunnable tests, contain build output
Two invariants had been established by hand and left ungated, which is the
"a remedy that only works if someone remembers" pattern:

  - scripts/check_ignore_consistency.sh fails the commit if any TRACKED file
    is also gitignored. .binary-allowlist used to *instruct* the reader to
    re-run that check; now it is enforced. Verified both ways -- passes on the
    tree, and fails when an ignored file is force-added.

DOCS-runnability.md now really has two columns. Each environment writes only
its own gates/runnability-<env>.tsv and the doc renders from both, so a CI run
cannot clobber the local results. The ci tsv is gitignored: committing a copy
generated here would label this machine's capabilities as the runner's.
Rendering it surfaced a classic bug -- awk's NR==FNR idiom silently swallows
the SECOND file when the first is empty, so the table came out with a header
and no rows. It compares FILENAME instead.

runnability.yml deliberately does NOT diff against a committed baseline. A
status is a property of the machine, so `runnable` locally and `blocked` on a
runner is correct rather than drift, and diffing would make the workflow red
by construction. It fails on `error` instead -- a runner that crashes or
reports an unknown kind.

Then five more courses were actually run, because EADS alone had produced four
real bugs and one sample proves nothing:

  - ECOAR and EPFU build (nasm + 32-bit gcc, and gcc respectively).
  - ECOTE's 17 test modules could not run AT ALL. Its package is named `code/`,
    which shadows the standard library: pytest's debugging plugin imports pdb,
    pdb imports code, and it got this directory instead, so the suite died
    with an INTERNALERROR before collecting a single test. Renamed to
    translator/ with all 17 imports updated -- 26 tests now pass.
  - A LaTeX report builds. That one exposed a hazard rather than a bug:
    latexmk wrote its PDF beside the source, and in a course where the built
    name matches the submitted one it would have silently overwritten a
    tracked deliverable. Both the LaTeX and C++ runners now build into a
    gitignored build/, and rebuilding ECOGR leaves `git status` empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172dj34FDpao6wsix8ZuCzE
2026-08-28 18:43:13 +02:00

171 lines
6.3 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# run.sh -- drive this coursework archive.
#
# ./run.sh check run the full local gate (pre-commit, all files)
# ./run.sh install alias for ./install.sh
# ./run.sh status probe every course dir, rewrite DOCS-runnability.md
# ./run.sh run <dir> actually execute one course project
# ./run.sh sync-vendored show the mirrors that gates/vendored.txt feeds
#
# `status` PROBES: it asks each directory whether its toolchain is present and
# never executes the project, so it takes seconds and is safe in CI. Use
# `run <dir>` to actually run something. Those are very different commands and
# conflating them is how a "status check" turns into 44 builds.
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly RUNNABILITY_DOC="${SCRIPT_DIR}/DOCS-runnability.md"
# Exit codes a per-directory run.sh uses to report itself.
readonly EXIT_BLOCKED=78
readonly EXIT_NO_CODE=79
log() { printf '==> %s\n' "$*"; }
err() { printf 'error: %s\n' "$*" >&2; }
usage() {
grep -E '^# \./run\.sh' "${BASH_SOURCE[0]}" | sed 's/^# //'
exit "${1:-0}"
}
course_dirs() {
find "${SCRIPT_DIR}/Programming" "${SCRIPT_DIR}/NotProgramming" \
-mindepth 1 -maxdepth 1 -type d | sort
}
cmd_check() {
log "running the full gate"
( cd "$SCRIPT_DIR" && pre-commit run --all-files )
}
cmd_install() { exec "${SCRIPT_DIR}/install.sh" "$@"; }
# A runner opts in either by handling --probe itself, or by delegating to the
# shared library (which does). Anything else predates this harness and is left
# strictly alone.
_supports_probe() {
grep -qF -- '--probe' "$1" || grep -qF 'course_lib.sh' "$1"
}
# Probe one directory. Prints "<status>\t<detail>"; never executes a project.
#
# The guard below is load-bearing. Some course dirs already ship their own
# run.sh from years ago -- Programming/PSD/run.sh installs packages with sudo
# and starts Kafka. Calling one of those with an unknown --probe flag does not
# probe anything, it RUNS the project. So a runner is only invoked if it
# actually declares --probe; anything else is reported unprobed and left alone.
# `timeout` is defence in depth for a script that declares the flag and then
# blocks on input anyway.
probe_one() {
local dir="$1" runner="$1/run.sh"
if [[ -x "$runner" ]] && _supports_probe "$runner"; then
local out status
out="$(timeout 20s "$runner" --probe 2> /dev/null)" && status=0 || status=$?
case "$status" in
0) printf 'runnable\t%s\n' "${out:-ok}" ;;
"$EXIT_BLOCKED") printf 'blocked\t%s\n' "${out:-unknown}" ;;
"$EXIT_NO_CODE") printf 'no-code\t%s\n' "${out:-unknown}" ;;
*) printf 'error\t%s\n' "probe exited $status" ;;
esac
return 0
fi
if [[ -x "$runner" ]]; then
printf 'unprobed\tpre-existing run.sh, no --probe support\n'
else
printf 'unprobed\tno run.sh yet\n'
fi
}
cmd_status() {
local env_label="${CI:+ci}"
env_label="${env_label:-local}"
local store="${SCRIPT_DIR}/gates/runnability-${env_label}.tsv"
log "probing course directories (${env_label})"
local dir rel line
: > "$store"
while IFS= read -r dir; do
rel="${dir#"${SCRIPT_DIR}"/}"
line="$(probe_one "$dir")"
printf '%s\t%s\n' "$rel" "$line" >> "$store"
done < <(course_dirs)
# Each environment writes only its OWN file, then the doc is rendered from
# both. Writing a single shared column would mean a CI run silently
# overwrote the local results with its own -- and the whole point of
# recording per environment is that "runnable here, blocked on a runner"
# is the normal case rather than drift.
write_runnability_doc
log "wrote ${RUNNABILITY_DOC##*/}"
awk -F'\t' '{ n[$2]++ } END { for (k in n) printf " %-10s %d\n", k, n[k] }' "$store"
}
write_runnability_doc() {
local local_tsv="${SCRIPT_DIR}/gates/runnability-local.tsv"
local ci_tsv="${SCRIPT_DIR}/gates/runnability-ci.tsv"
[[ -f "$local_tsv" ]] || : > "$local_tsv"
[[ -f "$ci_tsv" ]] || : > "$ci_tsv"
{
cat <<'MD'
# Runnability
Generated by `./run.sh status`. Do not edit by hand.
Status is recorded PER ENVIRONMENT, from `gates/runnability-local.tsv` and
`gates/runnability-ci.tsv`. A directory that is `runnable` locally and
`blocked` in CI is the normal case, not drift: the runner has no MATLAB, no
Unity and no .NET SDK. Each environment rewrites only its own file, so a CI
run cannot clobber the local column.
`-` means that environment has not probed yet.
MD
printf '| Course | local | ci | Detail (local) |\n|---|---|---|---|\n'
# Compare FILENAME rather than using the NR==FNR idiom: with an EMPTY
# first file awk never reads it, so NR==FNR stays true for every row
# of the second file and the whole table comes out blank.
awk -F'\t' -v cifile="$ci_tsv" '
FILENAME == cifile { ci[$1] = $2; next }
{ printf "| `%s` | %s | %s | %s |\n", $1, $2, ($1 in ci ? ci[$1] : "-"), $3 }
' "$ci_tsv" "$local_tsv"
} > "$RUNNABILITY_DOC"
}
cmd_run() {
local target="${1:-}"
[[ -n "$target" ]] || { err "run needs a directory"; usage 1; }
local runner="${SCRIPT_DIR}/${target}/run.sh"
[[ -x "$runner" ]] || { err "no executable run.sh in $target"; return 1; }
log "running $target"
"$runner"
}
cmd_sync_vendored() {
log "gates/vendored.txt declares:"
grep -vE '^\s*#|^\s*$' "${SCRIPT_DIR}/gates/vendored.txt" | sed 's/^/ /'
printf '\nMirrored into:\n'
printf ' ~/utils/file_length/_tables.py VENDORED_SUBPATHS["WUT_Computer_Science"]\n'
printf ' pyproject.toml [tool.ruff] exclude (Milestone 4)\n'
printf ' pyproject.toml [tool.coverage.run] omit (Milestone 4)\n'
}
main() {
case "${1:-}" in
check) shift; cmd_check "$@" ;;
install) shift; cmd_install "$@" ;;
status) shift; cmd_status "$@" ;;
run) shift; cmd_run "$@" ;;
sync-vendored) shift; cmd_sync_vendored "$@" ;;
-h | --help | "") usage ;;
*) err "unknown command: $1"; usage 1 ;;
esac
}
main "$@"