diff --git a/.github/workflows/md-naming.yml b/.github/workflows/md-naming.yml new file mode 100644 index 00000000..ce96d06a --- /dev/null +++ b/.github/workflows/md-naming.yml @@ -0,0 +1,30 @@ +name: md-naming + +# Deliberately a SEPARATE workflow from pre-commit.yml: one workflow per gate, +# so a naming failure is legible on its own rather than buried in a suite. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + md-naming: + name: markdown naming convention + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + # The gate and its exemption list live in the shared utils repo so every + # repo enforces the same rule. Checking it out beats vendoring a copy, + # which is how a gate and a survey start disagreeing. + - name: Check out the shared gate + uses: actions/checkout@v4 + with: + repository: kuhyx/utils + path: .utils + + - name: Enforce the naming convention + run: bash .utils/scripts/check_md_naming.sh --all diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 00000000..5bb2345a --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,48 @@ +name: pre-commit + +# The local hooks can be skipped -- a fresh clone that never ran ./install.sh +# has no hooks at all -- so the gate needs a second home that nobody can +# forget to install. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + pre-commit: + runs-on: ubuntu-latest + env: + # scripts/check_md_naming.sh is a thin shim that execs the shared gate + # out of the utils repo. A runner has no ~/utils, so without this the + # hook fails with "shared gate not found". + UTILS_ROOT: ${{ github.workspace }}/.utils + steps: + # fetch-depth 1: the history here is ~494 MB and no gate reads it. + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Check out the shared gates + uses: actions/checkout@v4 + with: + repository: kuhyx/utils + path: .utils + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install shellcheck + run: sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck + + - uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} + + - name: Install pre-commit + run: python -m pip install --quiet pre-commit + + - name: Run the hooks + run: pre-commit run --all-files --show-diff-on-failure diff --git a/DOCS-runnability.md b/DOCS-runnability.md new file mode 100644 index 00000000..365edbfb --- /dev/null +++ b/DOCS-runnability.md @@ -0,0 +1,54 @@ +# Runnability + +Generated by `./run.sh status`. Do not edit by hand. + +Status is recorded PER ENVIRONMENT. 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. CI compares only its own column. + +| Course | local | Detail | +|---|---|---| +| `NotProgramming/DPZ` | unprobed | no run.sh yet | +| `NotProgramming/ECOGR` | unprobed | no run.sh yet | +| `NotProgramming/EDABA` | unprobed | no run.sh yet | +| `NotProgramming/EDABA-LAB` | unprobed | no run.sh yet | +| `NotProgramming/EDISE` | unprobed | no run.sh yet | +| `NotProgramming/EDISP` | unprobed | no run.sh yet | +| `NotProgramming/ELAC` | unprobed | no run.sh yet | +| `NotProgramming/EMISY` | unprobed | no run.sh yet | +| `NotProgramming/ENUME` | unprobed | no run.sh yet | +| `NotProgramming/EPHY2` | unprobed | no run.sh yet | +| `NotProgramming/EPOGAI` | unprobed | no run.sh yet | +| `NotProgramming/ERPM` | unprobed | no run.sh yet | +| `NotProgramming/ESDP1` | unprobed | no run.sh yet | +| `NotProgramming/ESOEN` | unprobed | no run.sh yet | +| `NotProgramming/MOM` | unprobed | no run.sh yet | +| `NotProgramming/SDM` | unprobed | no run.sh yet | +| `NotProgramming/SDM2` | unprobed | no run.sh yet | +| `NotProgramming/SPD` | unprobed | no run.sh yet | +| `NotProgramming/STUP` | unprobed | no run.sh yet | +| `Programming/AIS` | unprobed | no run.sh yet | +| `Programming/BD2` | unprobed | no run.sh yet | +| `Programming/BSS` | unprobed | no run.sh yet | +| `Programming/EADS` | unprobed | no run.sh yet | +| `Programming/EARIN` | unprobed | no run.sh yet | +| `Programming/ECOAR` | unprobed | no run.sh yet | +| `Programming/ECOTE` | unprobed | no run.sh yet | +| `Programming/ECRYPT` | unprobed | no run.sh yet | +| `Programming/ECRYPT_PROJECT` | unprobed | no run.sh yet | +| `Programming/EGUI` | unprobed | no run.sh yet | +| `Programming/EOOP` | unprobed | no run.sh yet | +| `Programming/EOPSY` | unprobed | no run.sh yet | +| `Programming/EPFU` | unprobed | no run.sh yet | +| `Programming/E-PSYOPS` | unprobed | no run.sh yet | +| `Programming/ERSMS-project` | unprobed | no run.sh yet | +| `Programming/NLP` | unprobed | no run.sh yet | +| `Programming/PBAD` | unprobed | no run.sh yet | +| `Programming/PORR` | unprobed | no run.sh yet | +| `Programming/PSD` | unprobed | pre-existing run.sh, no --probe support | +| `Programming/psd_project` | unprobed | no run.sh yet | +| `Programming/TRAK` | unprobed | no run.sh yet | +| `Programming/twm_4` | unprobed | no run.sh yet | +| `Programming/USD` | unprobed | no run.sh yet | +| `Programming/WDWR` | unprobed | no run.sh yet | +| `Programming/ZPOB` | unprobed | no run.sh yet | diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..acbe6489 --- /dev/null +++ b/install.sh @@ -0,0 +1,114 @@ +#!/bin/bash + +# ============================================================================ +# Set this repository up for development. +# +# Usage: +# ./install.sh install tools and wire the git hooks +# ./install.sh --check verify only; exit 1 if anything is missing +# +# Idempotent: a second run reports what is already present and changes nothing. +# +# It ENDS by verifying, and fails loudly if the gate is not actually runnable. +# A warning here would just recreate the bug it exists to prevent: the hooks +# were absent from this repo entirely, so every "pre-commit passed" claim +# before now was vacuous. +# ============================================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly UTILS_ROOT="${UTILS_ROOT:-$HOME/utils}" + +CHECK_ONLY=0 + +log() { printf '==> %s\n' "$*"; } +err() { printf 'error: %s\n' "$*" >&2; } + +usage() { + grep -E '^# \./install\.sh' "${BASH_SOURCE[0]}" | sed 's/^# //' + exit 0 +} + +# The shared gates are not vendored here on purpose -- they live in one repo so +# that every repo enforces the same rule. Without them the hooks cannot run. +check_shared_gates() { + if [[ ! -d "$UTILS_ROOT" ]]; then + err "shared gates not found at $UTILS_ROOT" + err " git clone https://github.com/kuhyx/utils \"$UTILS_ROOT\"" + err " (or set UTILS_ROOT to where it lives)" + return 1 + fi + log "shared gates: $UTILS_ROOT" +} + +install_tools() { + local missing=() + command -v shellcheck > /dev/null 2>&1 || missing+=(shellcheck) + command -v pre-commit > /dev/null 2>&1 || missing+=(pre-commit) + + if [[ ${#missing[@]} -eq 0 ]]; then + log "tools already present: shellcheck, pre-commit" + return 0 + fi + + if [[ $CHECK_ONLY -eq 1 ]]; then + err "missing tools: ${missing[*]}" + return 1 + fi + + log "installing: ${missing[*]}" + if command -v pacman > /dev/null 2>&1; then + sudo pacman -S --needed --noconfirm "${missing[@]}" + elif command -v apt-get > /dev/null 2>&1; then + sudo apt-get update -qq && sudo apt-get install -y -qq "${missing[@]}" + else + err "no supported package manager; install manually: ${missing[*]}" + return 1 + fi +} + +install_hooks() { + if [[ $CHECK_ONLY -eq 1 ]]; then + [[ -f "${SCRIPT_DIR}/.git/hooks/pre-commit" ]] || { + err "git hooks are not installed" + return 1 + } + log "git hooks installed" + return 0 + fi + + log "wiring git hooks (pre-commit and pre-push)" + ( cd "$SCRIPT_DIR" \ + && pre-commit install > /dev/null \ + && pre-commit install --hook-type pre-push > /dev/null ) +} + +# The last word. If the gate cannot actually run, say so and exit non-zero. +verify() { + log "verifying the gate runs" + if ! ( cd "$SCRIPT_DIR" && pre-commit run --all-files > /dev/null 2>&1 ); then + err "pre-commit run --all-files failed -- the repo is not clean" + err "run it directly to see why: pre-commit run --all-files" + return 1 + fi + log "OK: all hooks pass" +} + +main() { + check_shared_gates + install_tools + install_hooks + verify +} + +while [[ $# -gt 0 ]]; do + case $1 in + --check) CHECK_ONLY=1; shift ;; + -h | --help) usage ;; + *) err "unknown option: $1"; exit 1 ;; + esac +done + +main "$@" diff --git a/run.sh b/run.sh new file mode 100755 index 00000000..b24d0cdd --- /dev/null +++ b/run.sh @@ -0,0 +1,146 @@ +#!/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 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 ` 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" "$@"; } + +# Probe one directory. Prints "\t"; 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" ]] && grep -qF -- '--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}" + log "probing course directories (${env_label})" + + local tmp + tmp="$(mktemp)" + trap 'rm -f "${tmp:-}"' RETURN + + local dir rel line + while IFS= read -r dir; do + rel="${dir#"${SCRIPT_DIR}"/}" + line="$(probe_one "$dir")" + printf '%s\t%s\n' "$rel" "$line" >> "$tmp" + done < <(course_dirs) + + write_runnability_doc "$tmp" "$env_label" + log "wrote ${RUNNABILITY_DOC##*/}" + awk -F'\t' '{ n[$2]++ } END { for (k in n) printf " %-10s %d\n", k, n[k] }' "$tmp" +} + +write_runnability_doc() { + local data="$1" env_label="$2" + { + cat <<'MD' +# Runnability + +Generated by `./run.sh status`. Do not edit by hand. + +Status is recorded PER ENVIRONMENT. 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. CI compares only its own column. + +MD + printf '| Course | %s | Detail |\n|---|---|---|\n' "$env_label" + awk -F'\t' '{ printf "| `%s` | %s | %s |\n", $1, $2, $3 }' "$data" + } > "$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 "$@"