From 223f3459a61c6863dec5126a7a4c7c4a62ea2b4d Mon Sep 17 00:00:00 2001 From: CSchank Date: Sat, 12 Sep 2026 19:39:39 -0400 Subject: [PATCH 1/3] Commit generated PDFs, track dependencies, and build Markdown documents Three changes to how documents are built, plus a reduction in build time. Generated PDFs are written to pdfs/ and committed back to main, so they can be read directly in the repository without downloading build artifacts or installing LaTeX. build-pdf/ is seeded from that directory before compiling, which makes the repository rather than the Actions cache the source of truth: previously a cache miss combined with the rsync --delete would have removed every generated PDF. Documents are rebuilt when the files they read change. Only documents whose own file changed were rebuilt before, and because the shared includes are named .text rather than .tex, editing docs/Common.text, which fourteen documents read, rebuilt nothing and still reported success. refs/References.bib, read by eight documents, could not even start the workflow. Rather than maintain a list of dependencies by hand, pdflatex now runs with -recorder and record_deps.py reads the resulting .fls, which reports every file the document opened, including figures and transitively included files. .bib files come from the \bibdata entries in the .aux, since pdflatex reads the generated .bbl and never the bibliography source. select_docs.py reverses that graph to choose what to rebuild, and falls back to a full rebuild for any changed file it cannot account for, so a missing record makes a build slower rather than wrong. Markdown documents under docs/ are compiled with pandoc, using the pdflatex already installed for the .tex rule. README.md and Expectations*.md are excluded as instructor-owned. Where a folder holds both Foo.md and Foo.tex the Makefile lists the Markdown rule first, so Markdown wins. TeX Live and pandoc are cached rather than reinstalled each run, which takes that step from about 126 seconds to about 30. Almost none of that time was downloading, so the installed files are cached rather than the .deb archives. Restoring files skips their postinst scripts, so mktexlsr and updmap-sys are run afterwards to rebuild the filename and font map databases, and a short verification step compiles a small Markdown file before any real document is built. The actions GitHub reported as running on the deprecated Node 20 runtime are updated. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/record_deps.py | 192 +++++++++++++++++++++++++++++ .github/scripts/select_docs.py | 156 ++++++++++++++++++++++++ .github/workflows/latex-pages.yml | 193 ++++++++++++++++++++++-------- .gitignore | 8 +- Makefile | 16 ++- 5 files changed, 511 insertions(+), 54 deletions(-) create mode 100755 .github/scripts/record_deps.py create mode 100755 .github/scripts/select_docs.py diff --git a/.github/scripts/record_deps.py b/.github/scripts/record_deps.py new file mode 100755 index 00000000..2ecf1e4a --- /dev/null +++ b/.github/scripts/record_deps.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Record the real dependencies of a freshly built document. + +LaTeX knows exactly which files it read: ``pdflatex -recorder`` writes an +``.fls`` file listing every INPUT it opened, which picks up ``\\input`` files, +figures and style files without anyone having to enumerate them. Two things +the ``.fls`` does not cover, handled here as well: + +* ``.bib`` files, because pdflatex reads the generated ``.bbl``, never the + bibliography source. The ``.aux`` records those as ``\\bibdata`` entries. +* Markdown sources, because pandoc runs the PDF engine in a temporary + directory and discards the ``.fls``. For those we fall back to scanning the + Markdown for local file references. Anything missed there is still caught by + the "unknown file changed -> rebuild everything" rule in select_docs.py. + +Usage: record_deps.py [repo_root] +""" + +import json +import os +import re +import sys + +# Artifacts LaTeX generates for itself; they are outputs, not real inputs. +GENERATED_SUFFIXES = { + ".aux", ".bbl", ".blg", ".fls", ".log", ".out", ".toc", ".lof", ".lot", + ".nav", ".snm", ".vrb", ".fdb_latexmk", ".synctex.gz", ".spl", ".bcf", + ".run.xml", +} + +MD_REFERENCE_RE = re.compile( + r"""!\[[^\]]*\]\(\s*]+)>?[^)]*\)""" # ![alt](path) + r"""|\\includegraphics(?:\[[^\]]*\])?\{([^}]+)\}""" # raw LaTeX in md + r"""|^\s*!include\s+(\S+)""", # include extension + re.MULTILINE, +) + + +def repo_relative(path, repo_root): + """Normalise *path* to a repo-relative path, or None if outside the repo.""" + absolute = os.path.normpath(os.path.abspath(path)) + root = os.path.normpath(os.path.abspath(repo_root)) + if absolute == root: + return None + if not absolute.startswith(root + os.sep): + return None # system TeX tree, /usr/share/texlive, etc. + return os.path.relpath(absolute, root) + + +def deps_from_fls(fls_path, stem_pdf, repo_root): + """Parse INPUT lines out of an .fls file.""" + found = set() + pwd = os.path.dirname(os.path.abspath(fls_path)) + with open(fls_path, "r", errors="replace") as handle: + for line in handle: + line = line.rstrip("\n") + if line.startswith("PWD "): + pwd = line[4:].strip() + elif line.startswith("INPUT "): + raw = line[6:].strip() + if not raw: + continue + resolved = raw if os.path.isabs(raw) else os.path.join(pwd, raw) + rel = repo_relative(resolved, repo_root) + if rel is None: + continue + # Skip LaTeX's own scratch files, but keep figures: a .pdf is + # only excluded when it is this document's own output. + _, ext = os.path.splitext(rel) + if ext in GENERATED_SUFFIXES: + continue + if os.path.normpath(rel) == os.path.normpath(stem_pdf): + continue + found.add(rel) + return found + + +def deps_from_aux(aux_path, repo_root): + """Pull \\bibdata entries (the .bib files) out of an .aux file.""" + found = set() + if not os.path.exists(aux_path): + return found + aux_dir = os.path.dirname(os.path.abspath(aux_path)) + with open(aux_path, "r", errors="replace") as handle: + text = handle.read() + for match in re.finditer(r"\\bibdata\{([^}]*)\}", text): + for entry in match.group(1).split(","): + entry = entry.strip() + if not entry: + continue + if not entry.endswith(".bib"): + entry += ".bib" + rel = repo_relative(os.path.join(aux_dir, entry), repo_root) + if rel and os.path.exists(os.path.join(repo_root, rel)): + found.add(rel) + return found + + +def deps_from_markdown(md_path, repo_root): + """Best-effort scan of a Markdown source for local file references.""" + found = set() + md_dir = os.path.dirname(os.path.abspath(md_path)) + with open(md_path, "r", errors="replace") as handle: + text = handle.read() + for match in MD_REFERENCE_RE.finditer(text): + raw = next((g for g in match.groups() if g), None) + if not raw or "://" in raw: + continue # remote URL, not a build dependency + rel = repo_relative(os.path.join(md_dir, raw), repo_root) + if rel and os.path.exists(os.path.join(repo_root, rel)): + found.add(rel) + return found + + +def load_manifest(manifest_path): + if not os.path.exists(manifest_path): + return {} + try: + with open(manifest_path) as handle: + return json.load(handle) + except (ValueError, OSError): + return {} + + +def save_manifest(manifest, manifest_path): + with open(manifest_path, "w") as handle: + json.dump(manifest, handle, indent=2, sort_keys=True) + handle.write("\n") + + +def prune(manifest_path, repo_root): + """Drop manifest entries whose source document no longer exists.""" + manifest = load_manifest(manifest_path) + gone = [doc for doc in manifest + if not os.path.exists(os.path.join(repo_root, doc))] + for doc in gone: + del manifest[doc] + print("record_deps: pruned %s" % doc) + if gone: + save_manifest(manifest, manifest_path) + return len(gone) + + +def main(): + if len(sys.argv) >= 3 and sys.argv[1] == "--prune": + manifest_path = sys.argv[2] + repo_root = sys.argv[3] if len(sys.argv) > 3 else os.getcwd() + prune(manifest_path, repo_root) + return + + if len(sys.argv) < 3: + sys.exit("usage: record_deps.py [repo_root]\n" + " record_deps.py --prune [repo_root]") + + source = sys.argv[1] + manifest_path = sys.argv[2] + repo_root = sys.argv[3] if len(sys.argv) > 3 else os.getcwd() + + source_rel = repo_relative(source, repo_root) + if source_rel is None: + sys.exit("record_deps: %s is outside the repository" % source) + + stem, ext = os.path.splitext(source) + deps = {source_rel} + manifest = load_manifest(manifest_path) + + if ext == ".md": + deps |= deps_from_markdown(source, repo_root) + else: + fls = stem + ".fls" + if os.path.exists(fls): + deps |= deps_from_fls(fls, stem + ".pdf", repo_root) + elif source_rel in manifest: + # No .fls this time (a failed or skipped build). Keep what we + # already knew rather than degrading the entry to the source + # alone, which would silently stop rebuilding its dependents. + print("record_deps: no .fls for %s; keeping previous dependencies" + % source_rel) + return + else: + print("record_deps: no .fls for %s; recording source only" + % source_rel) + deps |= deps_from_aux(stem + ".aux", repo_root) + + manifest[source_rel] = sorted(deps) + save_manifest(manifest, manifest_path) + + print("record_deps: %s -> %d dependencies" % (source_rel, len(deps))) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/select_docs.py b/.github/scripts/select_docs.py new file mode 100755 index 00000000..aa663ecb --- /dev/null +++ b/.github/scripts/select_docs.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Decide which documents need rebuilding, using the recorded dependency graph. + +Given the files a push changed, a document is rebuilt when it is itself one of +the changed files, or when the manifest written by record_deps.py says it reads +one of them. That is what makes a change to a shared include such as +``docs/Common.text`` rebuild all fourteen documents that ``\\input`` it. + +The rule is deliberately fail-safe: a changed file that is neither a document, +nor a known dependency, nor explicitly ignorable forces a full rebuild. A +missing dependency record can therefore make the build slower, never wrong. + +Usage: select_docs.py --changed --deleted [--manifest P] [--root P] +Prints one source path per line on stdout; diagnostics go to stderr. +""" + +import argparse +import json +import os +import sys + +DOC_SUFFIXES = (".tex", ".md") + +# Instructor-owned prose and things this pipeline never builds from. Changes +# to these must not trigger the full-rebuild fallback. +IGNORED_BASENAMES = {"README.md", "README.txt", ".gitignore"} +IGNORED_PREFIXES = ("pdfs/", "docs/SRS-Meyer/", ".github/") + +# Changing how documents are built can change every document, even though no +# document source changed: the package list decides which LaTeX packages are +# available, and the Makefile decides how each document is compiled. These +# override IGNORED_PREFIXES above. +TOOLCHAIN_PATHS = ("Makefile", ".github/workflows/latex-pages.yml", + ".github/scripts/") + + +def is_document(rel_path): + """True if this path is a document this pipeline compiles.""" + base = os.path.basename(rel_path) + if not rel_path.startswith("docs/"): + return False + if not rel_path.endswith(DOC_SUFFIXES): + return False + if base in IGNORED_BASENAMES or base.startswith("Expectations"): + return False + if rel_path.startswith("docs/SRS-Meyer/"): + return False + return True + + +def is_ignorable(rel_path): + base = os.path.basename(rel_path) + if base in IGNORED_BASENAMES or base.startswith("Expectations"): + return True + return any(rel_path.startswith(p) for p in IGNORED_PREFIXES) + + +def discover_documents(root): + """All buildable documents, preferring Foo.md over Foo.tex on a collision. + + This mirrors the Makefile, which lists the Markdown rule first so Markdown + wins when a folder holds both. + """ + by_stem = {} + docs_dir = os.path.join(root, "docs") + for dirpath, dirnames, filenames in os.walk(docs_dir): + dirnames[:] = [d for d in dirnames if d != "SRS-Meyer"] + for name in filenames: + rel = os.path.relpath(os.path.join(dirpath, name), root) + if not is_document(rel): + continue + stem, ext = os.path.splitext(rel) + # .md beats .tex for the same target + if stem not in by_stem or ext == ".md": + by_stem[stem] = rel + return set(by_stem.values()) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--changed", nargs="*", default=[]) + parser.add_argument("--deleted", nargs="*", default=[]) + parser.add_argument("--manifest", default=".pdf-deps.json") + parser.add_argument("--root", default=os.getcwd()) + args = parser.parse_args() + + root = os.path.abspath(args.root) + documents = discover_documents(root) + + manifest = {} + if os.path.exists(args.manifest): + try: + with open(args.manifest) as handle: + manifest = json.load(handle) + except (ValueError, OSError) as exc: + print("select_docs: unreadable manifest (%s)" % exc, file=sys.stderr) + + # Reverse the manifest: dependency -> documents that read it. + dependents = {} + for document, deps in manifest.items(): + for dep in deps: + dependents.setdefault(dep, set()).add(document) + + build = set() + full_rebuild_reason = None + + if not manifest: + full_rebuild_reason = "no dependency manifest recorded yet" + + # A document with no manifest entry has never been built and cannot be + # reasoned about, so it always builds. + unrecorded = documents - set(manifest) + if unrecorded: + build |= unrecorded + print("select_docs: %d document(s) have no recorded dependencies" + % len(unrecorded), file=sys.stderr) + + for path in list(args.changed) + list(args.deleted): + if not path: + continue + if path in documents: + build.add(path) + continue + if path.startswith(TOOLCHAIN_PATHS): + full_rebuild_reason = "build configuration changed: %s" % path + continue + if is_ignorable(path): + print("select_docs: ignoring %s" % path, file=sys.stderr) + continue + readers = dependents.get(path) + if readers: + hits = readers & documents + build |= hits + print("select_docs: %s is read by %d document(s)" % (path, len(hits)), + file=sys.stderr) + continue + if is_document(path): + # A document that was deleted; nothing to build for it. + continue + full_rebuild_reason = "unrecognised change: %s" % path + + if full_rebuild_reason: + print("select_docs: full rebuild (%s)" % full_rebuild_reason, file=sys.stderr) + build = set(documents) + + # Never try to build something that no longer exists. + build = {p for p in build if os.path.exists(os.path.join(root, p))} + + for path in sorted(build): + print(path) + + print("select_docs: %d document(s) to build" % len(build), file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/latex-pages.yml b/.github/workflows/latex-pages.yml index 488d7cb5..12583a60 100644 --- a/.github/workflows/latex-pages.yml +++ b/.github/workflows/latex-pages.yml @@ -3,10 +3,10 @@ name: Build LaTeX and Deploy PDFs on: push: branches: main - paths: [docs/**] + paths: [docs/**, refs/**, Makefile, .github/scripts/**, .github/workflows/latex-pages.yml] pull_request: branches: main - paths: [docs/**] + paths: [docs/**, refs/**, Makefile, .github/scripts/**, .github/workflows/latex-pages.yml] workflow_dispatch: permissions: @@ -19,28 +19,56 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Restore PDF cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: build-pdf key: pdf-cache-${{ github.run_id }} restore-keys: | pdf-cache- + - name: Seed PDF cache from committed PDFs + run: | + # The committed pdfs/ directory is the source of truth, not the + # Actions cache. A cache miss (eviction, 7-day expiry, size limit) + # must never let the later rsync --delete wipe committed PDFs. + mkdir -p build-pdf + if [ -d pdfs ]; then + rsync -av --ignore-existing pdfs/ build-pdf/ + fi - name: Get changed LaTeX files id: latex-files run: | BASE=${{ github.event.before }} [ -z "$BASE" ] && BASE=HEAD^ - CHANGED_FILES=$(git diff --name-only "$BASE" "${{ github.sha }}" -- ':(glob)docs/**/*.tex' | xargs) + # Every file under docs/ and refs/ is a candidate, not just .tex and + # .md: a change to a shared \input, a figure or the bibliography has + # to rebuild the documents that read it. Which documents those are is + # decided by select_docs.py from the recorded dependency graph, so no + # filtering by extension happens here. + SCOPE=(':(glob)docs/**' ':(glob)refs/**' 'Makefile' + ':(glob).github/workflows/latex-pages.yml' + ':(glob).github/scripts/**') + + CHANGED_FILES=$(git diff --name-only --diff-filter=d "$BASE" "${{ github.sha }}" -- "${SCOPE[@]}" | xargs) + DELETED_FILES=$(git diff --name-only --diff-filter=D "$BASE" "${{ github.sha }}" -- "${SCOPE[@]}" | xargs) echo "Changed files: $CHANGED_FILES" - echo "CHANGED_FILES=$CHANGED_FILES" >> $GITHUB_ENV + echo "Deleted files: $DELETED_FILES" + echo "DELETED_FILES=$DELETED_FILES" >> $GITHUB_ENV + + BUILD_DOCS=$(python3 .github/scripts/select_docs.py \ + --changed $CHANGED_FILES \ + --deleted $DELETED_FILES \ + --manifest .pdf-deps.json --root . | xargs) + + echo "Documents to build: $BUILD_DOCS" + echo "BUILD_DOCS=$BUILD_DOCS" >> $GITHUB_ENV - CHANGED_FILENAMES=$(for file in $CHANGED_FILES; do basename "$file"; done | xargs) + CHANGED_FILENAMES=$(for file in $BUILD_DOCS; do basename "$file"; done | xargs) echo "CHANGED_FILENAMES=$CHANGED_FILENAMES" >> $GITHUB_ENV FILE_COUNT=$(echo "$CHANGED_FILENAMES" | wc -w) @@ -50,76 +78,137 @@ jobs: PLURAL_S='' fi echo "PLURAL_S=$PLURAL_S" >> $GITHUB_ENV - - name: Install TeX Live + # Installing these with apt takes ~126s, almost all of it unpacking + # rather than downloading, so caching the installed files (not the + # .debs) is what saves time. Pinned to a commit: this template is + # forked widely and the job has contents: write. + # texlive-full is avoided deliberately; it is far larger than needed. + # pandoc drives the Markdown -> PDF rule in the Makefile. + - name: Install TeX Live and pandoc + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: texlive-latex-extra texlive-science texlive-fonts-extra texlive-fonts-recommended texlive-latex-recommended lmodern pandoc + version: 1.1 + - name: Verify the TeX toolchain is usable run: | - sudo apt-get update - #sudo apt-get install -y texlive-full #if needed, but very large - sudo apt-get install -y --fix-missing texlive-latex-extra texlive-science texlive-fonts-extra #texlive-xetex texlive-luatex texlive-bibtex-extra + # A restored cache that skipped texlive's postinst would leave + # pdflatex unable to find packages. Fail here, loudly, rather than + # part way through compiling documents. + # Restoring cached package files skips their postinst scripts, and + # texlive's is what builds the ls-R filename database. Without this + # kpsewhich cannot see the restored packages, which surfaced as + # "lmodern.sty not found" on the first cache hit. + sudo mktexlsr + # ...and updmap-sys is what builds the font map files. Without it + # pdflatex finds lmodern.sty but not the fonts it maps to, failing + # with "Font rm-lmr7 at 600 not found". + sudo updmap-sys >/dev/null + + pdflatex --version | head -1 + pandoc --version | head -1 + kpsewhich article.cls + kpsewhich amsmath.sty + # pandoc's default template pulls in packages the .tex documents + # never ask for (lmodern.sty was missing the first time this ran), + # so smoke-test the Markdown path itself rather than guessing at + # the package list. + smoke=$(mktemp -d) + printf '%s\n' '# Smoke test' '' 'Math $x^2$ and a table:' '' \ + '| a | b |' '|---|---|' '| 1 | 2 |' > "$smoke/smoke.md" + pandoc "$smoke/smoke.md" -o "$smoke/smoke.pdf" \ + --pdf-engine=pdflatex --toc -V geometry:margin=1in + test -s "$smoke/smoke.pdf" && echo "pandoc -> PDF smoke test OK" + rm -rf "$smoke" - name: Compile LaTeX run: | PDF_CACHE_DIR="$GITHUB_WORKSPACE/build-pdf" mkdir -p "$PDF_CACHE_DIR" - echo "CHANGED_FILES seen by compile step: $CHANGED_FILES" + echo "Documents selected for build: $BUILD_DOCS" - # Bootstrap: full build if cache is empty - if [ -z "${CHANGED_FILES}" ] && [ -z "$(ls -A "$PDF_CACHE_DIR" 2>/dev/null)" ]; then - echo "No cached PDFs found; building all documents" - CHANGED_FILES=$(find docs -name "*.tex") - fi + for src in $BUILD_DOCS; do + pdf="${src%.*}.pdf" - for file in $CHANGED_FILES; do # Build using existing Makefile rules - make -B "${file%.tex}.pdf" - - # Copy result into build-pdf, preserving structure - rel=$(dirname "$file" | cut -d'/' -f2-) + make -B "$pdf" + + # Record what this document actually read, for the next run's + # change detection. Runs after the build so the .fls is present. + python3 .github/scripts/record_deps.py "$src" .pdf-deps.json . + + # Copy result into build-pdf, preserving structure below docs/ + rel=$(dirname "$pdf"); rel="${rel#docs}"; rel="${rel#/}" mkdir -p "$PDF_CACHE_DIR/$rel" - cp "${file%.tex}.pdf" "$PDF_CACHE_DIR/$rel/" + cp "$pdf" "$PDF_CACHE_DIR/$rel/" done + + # Remove cached PDFs that no longer have a source document. This is + # driven by the filesystem rather than by this push's diff: a run + # that fails before this point would otherwise lose the deletion for + # good, because the next push's diff no longer mentions it. + find "$PDF_CACHE_DIR" -name '*.pdf' -print | while read -r cached; do + rel="${cached#$PDF_CACHE_DIR/}" + stem="docs/${rel%.pdf}" + if [ ! -f "$stem.tex" ] && [ ! -f "$stem.md" ]; then + echo "Removing $rel: no source document builds it any more" + rm -f "$cached" + fi + done + + # Drop manifest entries for documents that no longer exist + python3 .github/scripts/record_deps.py --prune .pdf-deps.json . #continue-on-error: true # Job continues even if this step fails + - name: Sync PDFs for TA review + run: | + rm -rf pdfs + mkdir -p pdfs + rsync -av --delete build-pdf/ pdfs/ + - name: Commit PDFs to repo + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add pdfs .pdf-deps.json + if git diff --cached --quiet; then + echo "No PDF changes to commit" + else + git commit -m "Update generated PDFs for review" + # Another docs push may have landed while we were compiling. + for attempt in 1 2 3; do + if git push origin HEAD:main; then + echo "Pushed PDFs on attempt $attempt" + exit 0 + fi + echo "Push rejected; rebasing onto latest main (attempt $attempt)" + git pull --rebase origin main + done + echo "Could not push PDFs after 3 attempts" + exit 1 + fi - name: Prepare site run: | set -euo pipefail - # Always start from repo root - pwd - ls - # Recreate public directory rm -rf public - mkdir -p public - + mkdir -p public + # Copy PDFs preserving structure rsync -av build-pdf/ public/ - - # Generate PDF list (paths relative to site root) - PDF_LIST=$(find public -name "*.pdf" | sort | sed 's|^public/||' | awk -F/ ' - { - dir = $1 - file = $NF - groups[dir] = groups[dir] "
  • " file "
  • \n" - } - END { - for (dir in groups) { - printf "

    %s

    \n
      \n%s
    \n", dir, groups[dir] - } - }') - - # Ensure site template exists - test -f site/index.html - - # Build index.html safely - awk -v list="$PDF_LIST" ' - // { print list; next } - { print } - ' site/index.html > public/index.html + + # Build the index. This runs after the PDFs are committed, so the + # per-document timestamps come from that commit. + python3 .github/scripts/build_index.py \ + --public public \ + --template site/index.html \ + --output public/index.html \ + --tracked-dir pdfs # Copy static assets cp site/style.css public/ - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: public diff --git a/.gitignore b/.gitignore index e51d51d9..22d87b06 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ ## Generated if empty string is given at "Please type another file name for output:" .pdf +## Exception: committed, CI-generated PDFs kept here for TA review +!/pdfs/**/*.pdf + ## Bibliography auxiliary files (bibtex/biblatex/biber): *.bbl *.bcf @@ -276,4 +279,7 @@ TSWLatexianTemp* *.lpz # DS_Store file from Mac computers -.DS_Store \ No newline at end of file +.DS_Store +## Python bytecode from the build scripts +__pycache__/ +*.pyc diff --git a/Makefile b/Makefile index 916b2228..df39076a 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,13 @@ # Makefile for compiling .tex files # Define the pdflatex command -PDFLATEX_CMD = pdflatex -interaction=nonstopmode +# -recorder makes pdflatex write a .fls listing every file it read, which +# is what record_deps.py turns into the dependency manifest. +PDFLATEX_CMD = pdflatex -interaction=nonstopmode -recorder + +# Define the pandoc command, for teams who author a document in Markdown +# instead of LaTeX. Uses the pdflatex already installed for the .tex rule. +PANDOC_CMD = pandoc --pdf-engine=pdflatex --toc -V geometry:margin=1in # Define the compile step for pdflatex define COMPILE_TEX @@ -19,6 +25,14 @@ endef # Default target: Compile all .tex files if no specific target is given all: $(patsubst %.tex, %.pdf, $(wildcard **/*.tex)) +# Rule for compiling .md to .pdf. +# NOTE: this rule is deliberately listed BEFORE the .tex rule. When a folder +# contains both Foo.md and Foo.tex, GNU make picks the first matching pattern +# rule whose prerequisite exists, so Markdown wins the collision. +%.pdf: %.md + @echo "Compiling $< to $@ with pandoc" + cd $(dir $<) && $(PANDOC_CMD) $(notdir $<) -o $(notdir $@) + # Rule for compiling .tex to .pdf %.pdf: %.tex @echo "Compiling $< to $@" From 46523ea41a5ae3d109e6b64a50bbb0230c492c94 Mon Sep 17 00:00:00 2001 From: CSchank Date: Sat, 12 Sep 2026 19:39:39 -0400 Subject: [PATCH 2/3] Show on the index page when each PDF was last updated A reader had no way to tell whether a PDF reflected the current source. Each entry now shows when its PDF was last committed, and the page records when it was generated. The times come from git rather than file mtimes, because the PDFs are restored from a cache or checked out fresh and their mtimes are the time of the run. They are written as UTC in a