diff --git a/tools/build-manual b/tools/build-manual index d86fe23..80a6052 100755 --- a/tools/build-manual +++ b/tools/build-manual @@ -37,9 +37,10 @@ import argparse import base64 import html import mimetypes +import os import re import sys -from datetime import date +from datetime import date, datetime from pathlib import Path ROOT = Path(__file__).resolve().parent.parent @@ -704,7 +705,13 @@ def main(): if problems: return 1 - revision = f"Revision {date.today():%d %B %Y}" + # The day the source was committed, when the build says so, rather than + # the day it was built: a release built from a commit a day later must + # still ship the manual that commit holds, or the build changes the + # tree it was built from. SOURCE_DATE_EPOCH is the usual name for it. + epoch = os.environ.get("SOURCE_DATE_EPOCH") + day = datetime.fromtimestamp(int(epoch)).date() if epoch else date.today() + revision = f"Revision {day:%d %B %Y}" if arguments.check: for name in ("classroom-manual.html", "classroom-manual.pdf"): diff --git a/tools/build-release.sh b/tools/build-release.sh index 94e342e..88a20a2 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -19,57 +19,12 @@ release_require_version "$VERSION" || exit 1 STAGE="$ROOT/packaging/root" OUTPUT="$ROOT/dist/ekklesia-classroom_${VERSION}_amd64.deb" -# Syntax check everything the package ships, by shebang, so this cannot -# drift out of date as classroom commands are added. -echo "Validating sources" - -for script in "$ROOT/packaging/debian/postinst" "$ROOT"/scripts/* "$ROOT"/tests/*; do - [[ -f "$script" ]] || continue - - case "$(head -n 1 "$script")" in - *python3*) - python3 -m py_compile "$script" - ;; - *bash*|*bin/sh*) - bash -n "$script" - ;; - esac -done - -for module in "$ROOT"/lib/*.py; do - [[ -f "$module" ]] || continue - - python3 -m py_compile "$module" -done - -for policy in "$ROOT"/config/chrome/policies/*/*.json; do - [[ -f "$policy" ]] || continue - - python3 -c 'import json, sys; json.load(open(sys.argv[1]))' "$policy" -done - -find "$ROOT/scripts" "$ROOT/lib" "$ROOT/tests" \ - -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true - -# THE MANUAL IS PART OF THE RELEASE, NOT A JOB FOR AFTERWARDS. -# -# Built from docs/manual before the tests run, so that the test which -# checks the digital and printed versions against each other is checking -# what this package is about to ship rather than whatever was built last. -# A page that would make the printed manual wrong fails the build here. -echo "Building the teacher's manual" -"$ROOT/tools/build-manual" - -for test in "$ROOT"/tests/test-*.sh; do - [[ -x "$test" ]] || continue - - echo "Running $(basename "$test")" - "$test" > /dev/null || { - echo "Tests failed. Run it directly to see why:" - echo " $test" - exit 1 - } -done +# Sources, the teacher's manual and every test, before anything is built. +# The same list CI runs on every change; see tools/check.sh. +"$ROOT/tools/check.sh" || { + echo "Checks failed; nothing was built." + exit 1 +} echo "Building Ekklesia Classroom ${VERSION}" diff --git a/tools/check.sh b/tools/check.sh new file mode 100755 index 0000000..c922352 --- /dev/null +++ b/tools/check.sh @@ -0,0 +1,141 @@ +#!/bin/bash +# EVERY CHECK THE REPOSITORY HAS THAT NEEDS NO CONTAINER AND NO SCREEN. +# +# tools/check.sh sources, the manual, every test +# tools/check.sh --base REF and the change since REF is clean +# CHECK_LOG_DIR=DIR tools/check.sh each test's output in DIR, and a +# summary.md for a CI run to show +# +# The one list. tools/build-release.sh runs it before it builds anything, +# and CI runs it on every push and pull request, so a test added to tests/ +# is a test both of them run, with nothing else to edit. +# +# Every test runs even after one has failed, so a report names all of +# them; the exit status is 1 if any did. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +base="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --base) base="$2"; shift 2 ;; + *) echo "Usage: $0 [--base REF]" >&2; exit 2 ;; + esac +done + +logs="${CHECK_LOG_DIR:-$(mktemp -d)}" +mkdir -p "$logs" +summary="$logs/summary.md" +failed=0 + +{ + echo "| Check | Result | Seconds |" + echo "| --- | --- | --- |" +} > "$summary" + +# check NAME COMMAND...: run it, keep its output, say how it went. +check() { + local name="$1" started status log + shift + started=$SECONDS + log="$logs/$name.log" + + "$@" > "$log" 2>&1 + status=$? + + if [[ $status -eq 0 ]]; then + printf ' ok %s\n' "$name" + echo "| $name | pass | $(( SECONDS - started )) |" >> "$summary" + else + printf ' FAIL %s\n' "$name" + tail -n 25 "$log" | sed 's/^/ /' + echo "| $name | **FAIL** | $(( SECONDS - started )) |" >> "$summary" + failed=1 + fi +} + +# Syntax check everything the package ships, by shebang, so this cannot +# drift out of date as classroom commands are added. +validate_sources() { + local script module policy + + for script in "$ROOT/packaging/debian/postinst" "$ROOT"/scripts/* "$ROOT"/tests/* \ + "$ROOT"/tools/* "$ROOT"/tools/lab/* "$ROOT"/update_*.sh; do + [[ -f "$script" ]] || continue + + case "$(head -n 1 "$script")" in + *python3*) + python3 -m py_compile "$script" || return 1 + ;; + *bash*|*bin/sh*) + bash -n "$script" || return 1 + ;; + *) + # Sourced, so no #! line: tools/release-lib.sh. + if [[ "$script" == *.sh ]]; then + bash -n "$script" || return 1 + fi + ;; + esac + done + + for module in "$ROOT"/lib/*.py; do + [[ -f "$module" ]] || continue + python3 -m py_compile "$module" || return 1 + done + + for policy in "$ROOT"/config/chrome/policies/*/*.json "$ROOT"/config/wallpapers/*/metadata.json; do + [[ -f "$policy" ]] || continue + python3 -c 'import json, sys; json.load(open(sys.argv[1]))' "$policy" || return 1 + done + + find "$ROOT/scripts" "$ROOT/lib" "$ROOT/tests" "$ROOT/tools" \ + -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true +} + +# The change itself: no whitespace damage, and nothing built or published +# committed by mistake. Only with --base, because it is a question about a +# change, not about a tree. +hygiene() { + git diff --check "$base" HEAD || return 1 + + if git ls-files | grep -E '\.deb$|^(dist|repo|packaging/root)/'; then + echo "built or published files are committed (above)" >&2 + return 1 + fi + + # A committed release version is one a release could be built from. + source "$ROOT/tools/release-lib.sh" + release_require_version "$(cat "$ROOT/VERSION")" +} + +echo "Checking $(git rev-parse --short HEAD 2>/dev/null || echo 'the tree')" + +check sources validate_sources + +[[ -z "$base" ]] || check hygiene hygiene + +# THE MANUAL IS PART OF THE RELEASE, NOT A JOB FOR AFTERWARDS. +# +# Built from docs/manual before the tests run, so that the test which +# checks the digital and printed versions against each other is checking +# what this package is about to ship rather than whatever was built last. +# A page that would make the printed manual wrong fails here. +check manual "$ROOT/tools/build-manual" + +for test in "$ROOT"/tests/test-*.sh; do + [[ -x "$test" ]] || continue + check "$(basename "$test" .sh)" "$test" +done + +echo +if [[ $failed -eq 0 ]]; then + echo "Every check passed. Logs: $logs" +else + echo "Checks FAILED. Each one's full output is in $logs" +fi + +exit "$failed"