COLITUR := colitur PREFIX ?= $(HOME)/.local BINDIR := $(PREFIX)/bin MANDIR := $(PREFIX)/share/man/man1 # Section 5 is file formats: the overlay format is a thing a user AUTHORS, # not a command they run, so it belongs beside fstab(5) rather than in man1. MAN5DIR := $(PREFIX)/share/man/man5 # The binary locates its calendar data relative to its own path # (/share/colitur/ef), so `dune install` -- not a hand-rolled copy -- # is what places the four .sexp files where a running colitur will look for # them. See bin/main.ml's own [data_dir] for the full resolution order. # Task 13 (schema/dune, templates/dune) extends the same `dune install` # mechanism to /share/colitur/schema/ (bin/main.ml's own # [schema_path], read by `colitur publish`) and /share/colitur/ # templates/ (a convenience install -- `--template` takes a plain file path, # nothing in colitur probes this location the way [data_dir]/[schema_path] do). # # dune needs the project-local opam switch on PATH. Every recipe that invokes # dune goes through this, so `make` works from a plain shell with no # `eval $(opam env)` first -- which is the whole point of having a Makefile # over documenting the dune commands. DUNE := opam exec -- .PHONY: release-tarball verify-tarball help build test check check-schema check-templates check-citations install uninstall reinstall clean fmt man doc release help: ## show this help @grep -hE '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed -E 's/:.*## /\t/' | sort build: ## build the binary $(DUNE) dune build test: ## fast suite (~5s): properties sample 200 years $(DUNE) dune test check: ## full gate (~2min): every year 1583-9999, not a sample COLITUR_EXHAUSTIVE_SWEEP=1 $(DUNE) dune test --force $(MAKE) check-install # C1 fix-wave regression guard (final-review.md, 2026-08-25-colitur-of-phases-3-5): # a `dune install`ed colitur exited 2 on `--rite of` because data/dune never # named the OF runtime files -- and nothing in the suite caught it, because # test/cli.t's cram sandbox binds %{bin:colitur} inside the BUILD TREE, where # [data_dir]/[of_data_dir]'s build-tree fallback candidate resolves even when # the installed one is missing entirely. This target is the guard: it installs # to a scratch prefix -- the exact `dune install --prefix X` the review used to # reproduce C1, not a hand-rolled copy -- and runs BOTH rites against the # installed binary only, asserting exit 0 and the expected day count. Wired # into `check` (not opt-in like check-schema/check-templates/check-citations # above, which skip when an external tool is missing) because installing needs # nothing beyond what building already needs. check-install: build ## regression guard: run an INSTALLED colitur (both rites), not just the build tree @set -e; \ scratch=$$(mktemp -d); \ trap 'rm -rf "$$scratch"' EXIT; \ $(DUNE) dune install --prefix "$$scratch" >/dev/null; \ test -f "$$scratch/share/colitur/of/calendar-2002.sexp" || \ { echo "check-install: share/colitur/of/calendar-2002.sexp was not installed" >&2; exit 1; }; \ ef_lines=$$("$$scratch/bin/colitur" day 2026 | wc -l); \ of_lines=$$("$$scratch/bin/colitur" day --rite of 2026 | wc -l); \ test "$$ef_lines" -eq 365 || { echo "check-install: installed 'day 2026' (EF) printed $$ef_lines lines, want 365" >&2; exit 1; }; \ test "$$of_lines" -eq 365 || { echo "check-install: installed 'day --rite of 2026' printed $$of_lines lines, want 365" >&2; exit 1; }; \ "$$scratch/bin/colitur" readings --rite of 2026 >/dev/null; \ echo "check-install: installed EF ($$ef_lines lines) and OF ($$of_lines lines) both run cleanly from a scratch prefix" check-schema: build ## validate emitted XML against schema/colitur-v1.xsd (needs xmllint; skipped if absent) @if command -v xmllint >/dev/null 2>&1; then \ opam exec -- dune exec colitur -- emit --format xml --from 2027 --to 2027 > /tmp/colitur-check.xml && \ xmllint --noout --schema schema/colitur-v1.xsd /tmp/colitur-check.xml && \ echo "xml: valid against schema/colitur-v1.xsd"; \ else \ echo "SKIPPED: xmllint not installed -- XML is emitted but NOT schema-validated"; \ fi # Typesets every shipped template through the real tool that would typeset it # for a reader, not merely through this program's own renderer. The golden # tests already prove `colitur table` PRODUCES the expected LaTeX/groff/HTML/ # Typst text; only this proves that text actually TYPESETS -- a template can # render byte-for-byte as pinned and still be malformed LaTeX, groff or # Typst. Each tool is genuinely optional (none is in colitur's own frozen # deps), so absence is printed LOUDLY as SKIPPED rather than silently # treated as a pass -- a silent skip reads as a pass, which is the whole # failure mode this guards against. # # ordo.ms's own -P-pa5 (added alongside grid.ms's existing -P-pa4l, the # same mechanism, not a new one) and -Kutf8 (both flavours) fix two # defects this target's own "did it TYPESET" check could never see on its # own: groff's default PDF device paper is not A5 (so the booklet was # roughly twice the intended page area -- half a real density problem # was hiding in the page, not the type) and groff's default input # encoding is not UTF-8 (so an accented Latin name like Pe\(~nafort # rendered as visibly wrong characters) -- neither one is a groff # WARNING, so this target's own "any stderr is a failure" check was # silently green through both. Rendering to an actual PDF and reading it # is what caught them; a byte-identical-to-golden pass would not have. check-templates: build ## typeset every shipped template (needs pdflatex/groff/typst; skipped per-tool if absent) @ok=1; \ if command -v pdflatex >/dev/null 2>&1; then \ for t in ordo grid; do \ opam exec -- dune exec colitur -- table --year 2027 --template templates/ef/$$t.tex > /tmp/$$t.tex && \ (cd /tmp && pdflatex -halt-on-error -interaction=nonstopmode $$t.tex >/dev/null && \ pdflatex -halt-on-error -interaction=nonstopmode $$t.tex >/dev/null) && \ echo "pdflatex: $$t.tex OK (two passes, so a TOC's own \\pageref settles)" || \ { echo "pdflatex: $$t.tex FAILED"; ok=0; }; \ done; \ else echo "SKIPPED: pdflatex not installed -- LaTeX templates render but are NOT typeset"; fi; \ if command -v groff >/dev/null 2>&1; then \ for t in ordo grid; do \ gflags=""; \ [ "$$t" = grid ] && gflags="-P-pa4l"; \ [ "$$t" = ordo ] && gflags="-P-pa5"; \ if opam exec -- dune exec colitur -- table --year 2027 --template templates/ef/$$t.ms \ > /tmp/$$t.ms 2>/tmp/$$t-render.err; then \ groff -ms -t -Tpdf -Kutf8 $$gflags /tmp/$$t.ms > /tmp/$$t-ms.pdf 2>/tmp/$$t-ms.warnings; \ if [ -s /tmp/$$t-ms.warnings ]; then \ echo "groff: $$t.ms FAILED (groff exits 0 on a tbl/troff warning -- this target"; \ echo " treats ANY stderr output as a failure, since a warning here has meant"; \ echo " silently lost table columns before):"; \ cat /tmp/$$t-ms.warnings; ok=0; \ else \ echo "groff: $$t.ms OK"; \ fi; \ else \ echo "groff: $$t.ms FAILED (colitur table render):"; cat /tmp/$$t-render.err; ok=0; \ fi; \ done; \ else echo "SKIPPED: groff not installed -- groff templates render but are NOT typeset"; fi; \ if command -v typst >/dev/null 2>&1; then \ for t in ordo grid; do \ if opam exec -- dune exec colitur -- table --year 2027 --template templates/ef/$$t.typ \ > /tmp/$$t.typ 2>/tmp/$$t-render.err; then \ if typst compile /tmp/$$t.typ /tmp/$$t-typst.pdf 2>/tmp/$$t-typst.err; then \ echo "typst: $$t.typ OK (one pass -- typst resolves its own"; \ echo " table-of-contents page numbers inside a single compile, unlike pdflatex above)"; \ else \ echo "typst: $$t.typ FAILED:"; cat /tmp/$$t-typst.err; ok=0; \ fi; \ else \ echo "typst: $$t.typ FAILED (colitur table render):"; cat /tmp/$$t-render.err; ok=0; \ fi; \ done; \ else echo "SKIPPED: typst not installed -- Typst templates render but are NOT typeset"; fi; \ test $$ok -eq 1 # lang/la.ini's own discipline: every celebration/season/rank name cites the # docs/research/LT.txt line it was transcribed from, so a claim can be # checked, not just trusted. tools/check_citations.py re-derives that check # mechanically: for every "LT.txt:" citation outside a PATTERN-marked # block, it confirms the EXACT cited line (or an explicit "n-m" range, for # a heading that genuinely wraps -- no automatic +-2-line tolerance any # more, see the script's own docstring for why) contains the Latin text the # citation claims, scoped to that citation's own entry only, never to # prose quoted elsewhere in the surrounding comment. A citation whose pool # of candidate words is too thin to discriminate a right line from a wrong # nearby one (a heuristic, not a proof) is reported CANNOT VERIFY, not # silently passed, and fails the target exactly like a genuine mismatch -- # a human adjudicates it. The script has its own self-test # (tools/test_check_citations.py, wired into `dune test`/`make test` as # well as this target) after an earlier version of this exact check was # found to be self-poisoning: see either docstring for the full account. # docs/ is gitignored, so a fresh clone has no docs/research/LT.txt at # all: the same "SKIPPED, loudly, exit 0" discipline check-schema/ # check-templates already use above -- a silent skip reads as a pass, # which this project has hit the cost of before. check-citations: ## verify lang/la.ini's LT.txt: citations (needs docs/research/LT.txt, gitignored; SKIPPED if absent) @if command -v python3 >/dev/null 2>&1; then \ python3 tools/check_citations.py; \ else \ echo "SKIPPED: python3 not installed -- citations NOT verified this run"; \ fi install: build ## install binary, calendar data, language tables, templates, schema and man pages into PREFIX (default ~/.local) $(DUNE) dune install --prefix $(PREFIX) @mkdir -p $(MANDIR) install -m 644 man/colitur.1 $(MANDIR)/colitur.1 @mkdir -p $(MAN5DIR) install -m 644 man/colitur-overlay.5 $(MAN5DIR)/colitur-overlay.5 install -m 644 man/colitur-templates.5 $(MAN5DIR)/colitur-templates.5 install -m 644 man/colitur-config.5 $(MAN5DIR)/colitur-config.5 @echo "installed $(BINDIR)/$(COLITUR), data in $(PREFIX)/share/colitur/{ef,lang,templates,schema}, man pages in $(MANDIR) and $(MAN5DIR)" @command -v $(COLITUR) >/dev/null 2>&1 || \ echo "note: $(BINDIR) is not on PATH -- add it, or run $(BINDIR)/$(COLITUR) directly" # --- release ------------------------------------------------------------- # cgit offers per-tag snapshots for EVERY tag or for none, and ten tags meant # twenty download links, nineteen of them stale. colitur.git therefore has # cgit.snapshots=0 and this target publishes one current tarball at a stable # URL instead. Run it from a tagged commit. RELEASE_HOST ?= vps-mail-i2p RELEASE_DIR ?= /usr/local/www/cgit RELEASE_NAME ?= colitur-latest.tar.gz # Your ssh config forwards i2p ports; those fail when a session already holds # them and the noise hides real errors. This target needs none of them. SSHOPT ?= -o ClearAllForwardings=yes release-tarball: ## upload the current tag's tarball as the single published download @v=$$(git describe --tags --abbrev=0 2>/dev/null) || \ { echo "make release-tarball: no tag to publish" >&2; exit 1; }; \ test -z "$$(git status --porcelain)" || \ { echo "make release-tarball: working tree is dirty" >&2; exit 1; }; \ git describe --tags --exact-match >/dev/null 2>&1 || \ echo "note: HEAD is ahead of $$v; publishing the tag, not HEAD" >&2; \ echo "packaging $$v"; \ git archive --format=tar.gz --prefix=colitur-$${v#v}/ -o /tmp/$(RELEASE_NAME) "$$v" && \ scp -q $(SSHOPT) /tmp/$(RELEASE_NAME) $(RELEASE_HOST):/tmp/$(RELEASE_NAME) && \ ssh $(SSHOPT) $(RELEASE_HOST) \ "doas install -m 644 /tmp/$(RELEASE_NAME) $(RELEASE_DIR)/$(RELEASE_NAME) && rm -f /tmp/$(RELEASE_NAME)" && \ rm -f /tmp/$(RELEASE_NAME) && \ $(MAKE) -s verify-tarball && \ echo "published $$v to https://git.labunix.xyz/$(RELEASE_NAME)" # Chained with && throughout, and the upload is VERIFIED over HTTP before # anything claims success: the first version of this target used ';' and # cheerfully printed "published" after the upload had failed and the file # was not there at all. verify-tarball: ## check the published tarball is actually downloadable @curl -fsI https://git.labunix.xyz/$(RELEASE_NAME) >/dev/null || \ { echo "verify-tarball: $(RELEASE_NAME) is not downloadable" >&2; exit 1; } @curl -fsS https://git.labunix.xyz/$(RELEASE_NAME) | tar tz >/dev/null || \ { echo "verify-tarball: published file is not a valid tarball" >&2; exit 1; } @echo "verify-tarball: ok" uninstall: ## remove everything install put into PREFIX -$(DUNE) dune uninstall --prefix $(PREFIX) rm -f $(MANDIR)/colitur.1 $(MAN5DIR)/colitur-overlay.5 $(MAN5DIR)/colitur-templates.5 $(MAN5DIR)/colitur-config.5 @echo "removed $(COLITUR) from $(PREFIX)" reinstall: uninstall install ## uninstall then install (the installed copy is a snapshot, not a link) man: ## preview the man pages man -l man/colitur.1 man -l man/colitur-overlay.5 man -l man/colitur-templates.5 man -l man/colitur-config.5 doc: ## lint the man pages (groff warnings; silence means clean) groff -man -Tutf8 -ww -z man/colitur.1 groff -man -Tutf8 -ww -z man/colitur-overlay.5 groff -man -Tutf8 -ww -z man/colitur-templates.5 groff -man -Tutf8 -ww -z man/colitur-config.5 fmt: ## format the OCaml sources $(DUNE) dune build @fmt --auto-promote clean: ## remove build artifacts $(DUNE) dune clean # Mirrors lectio's own release target, including its refusals: no release from # a dirty tree, no release without a CHANGELOG line, and no release whose # version bump silently failed to apply. The version lives in TWO places # (dune-project, which feeds colitur.opam, and bin/main.ml's own constant, # which is what --version prints), so both are rewritten and both are # re-checked -- a release that bumped one and not the other would ship a # binary disagreeing with its own package metadata. release: ## cut a release: make release VERSION=0.1.0 (add its CHANGELOG line first) @test -n "$(VERSION)" || { echo "set VERSION=X.Y.Z" >&2; exit 2; } @echo "$(VERSION)" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$$' || { echo "VERSION must be X.Y.Z" >&2; exit 2; } @test -z "$$(git status --porcelain)" || { echo "working tree not clean; commit or stash first" >&2; exit 2; } @grep -q '## \[$(VERSION)\]' CHANGELOG.md || { echo "add a one-line CHANGELOG.md entry under '## [$(VERSION)]' first" >&2; exit 2; } @sed -i 's/^(version .*)$$/(version $(VERSION))/' dune-project @sed -i 's/^let version = ".*"/let version = "$(VERSION)"/' bin/main.ml @grep -q '^(version $(VERSION))$$' dune-project || { echo "dune-project version bump failed" >&2; exit 2; } @grep -q '^let version = "$(VERSION)"' bin/main.ml || { echo "bin/main.ml version bump failed" >&2; exit 2; } $(MAKE) check @test "$$($(DUNE) dune exec --no-build colitur -- --version)" = "$(VERSION)" || \ { echo "the built binary does not report $(VERSION)" >&2; exit 2; } @git add dune-project colitur.opam bin/main.ml CHANGELOG.md @# Only commit if the bump actually changed something. Re-running release @# after a failed gate -- or cutting a version whose files are already @# correct, which is exactly how the first tag went -- leaves nothing @# staged, and `git commit` would abort the whole target on "nothing to @# commit" despite everything being in order. @git diff --cached --quiet || git commit -m "release: v$(VERSION)" @git rev-parse -q --verify "refs/tags/v$(VERSION)" >/dev/null && \ { echo "tag v$(VERSION) already exists; delete it first to re-cut" >&2; exit 2; } || true git tag -a "v$(VERSION)" -m "colitur $(VERSION)" @echo "tagged v$(VERSION). publish with: git push origin main v$(VERSION) && make release-tarball"