diff --git a/.gitignore b/.gitignore index 8abe4181..4e5a0147 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,10 @@ a11y-report.json repomix-output.xml codebase-analysis.md +# Build/version info written by docker-entrypoint.sh at container start (#1366), +# read by the /version/ endpoint. Per-deploy, never committed. +build-info.json + # coverage.py artifacts (#1278 item 4) — the data file and HTML/XML reports are # written into the repo root (the container's CWD is the bind-mounted /code). .coverage diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 59b0b948..74c8b003 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -47,6 +47,19 @@ echo "" # setfacl -m u:48:rwx /code # chown -R apache /code +# Capture build/version info for the /version/ endpoint (#1366). +# The servers deploy via git, so .git is present and `git rev-parse` works; we +# capture the short SHA + a timestamp ONCE here (not per request) into a small +# build-info.json that website/views/version.py reads. Falls back to "unknown" +# if git isn't available (the view also tolerates a missing file). +echo "****************** STEP -1/5: docker-entrypoint.sh ************************" +echo "-1. Writing build-info.json (git sha + build timestamp) for /version/" +echo "******************************************" +GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILT_AT=$(date --iso-8601=seconds 2>/dev/null || echo "unknown") +printf '{"git_sha": "%s", "built_at": "%s"}\n' "$GIT_SHA" "$BUILT_AT" > build-info.json +cat build-info.json + # Collect static files echo "****************** STEP 0/5: docker-entrypoint.sh ************************" echo "0. Printing environment variables visible to Django" diff --git a/makeabilitylab/settings.py b/makeabilitylab/settings.py index b4ae1ed4..d61a4757 100644 --- a/makeabilitylab/settings.py +++ b/makeabilitylab/settings.py @@ -86,8 +86,8 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # Makeability Lab Global Variables, including Makeability Lab version -ML_WEBSITE_VERSION = "2.16.3" # Keep this updated with each release and also change the short description below -ML_WEBSITE_VERSION_DESCRIPTION = "Fix the project listing page (/projects/) overflowing horizontally on mobile, which cut thumbnails and the filter UI off on the right. Below the 992px sidebar breakpoint the card grid's flex parent reported an indefinite width, blowing a grid track wider than the viewport; .row-flex is now mobile-first (block <992px, flex >=992px) and the track is capped with minmax(min(100%, 250px), 1fr). Also adds anti-CLS image sizing, touch-friendly hover, two-line titles, and lazy-loaded thumbnails (#1367)." +ML_WEBSITE_VERSION = "2.17.0" # Keep this updated with each release and also change the short description below +ML_WEBSITE_VERSION_DESCRIPTION = "Add an unauthenticated machine-readable build/version endpoint at /version/ (and /version.json) returning JSON: version, description, environment, git_sha, and built_at. The git short SHA and build timestamp are captured once at container start by docker-entrypoint.sh into a gitignored build-info.json (falling back to 'unknown'), so you can confirm what code a server is actually running without scraping the HTML comment in base.html. Sets Cache-Control: no-store so no proxy serves a stale version (#1366)." DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed MAX_BANNERS = 7 # Maximum number of banners on a page diff --git a/website/tests/test_version_endpoint.py b/website/tests/test_version_endpoint.py new file mode 100644 index 00000000..2fa39080 --- /dev/null +++ b/website/tests/test_version_endpoint.py @@ -0,0 +1,86 @@ +"""Tests for the machine-readable version / build-info endpoint (#1366). + +``/version/`` (and ``/version.json``) returns JSON describing the running build +so we can confirm what code a server is actually deploying without scraping the +HTML comment in ``base.html``. These pin: + - routing of both URLs to the same view, + - the JSON shape and that version/description/environment come from settings, + - ``Cache-Control: no-store`` (so a proxy can't serve a stale version), + - that git_sha/built_at are read from the entrypoint-written build-info file, + and fall back to ``"unknown"`` when the file is absent (e.g. local dev). +""" + +import json +import os + +from django.test import SimpleTestCase, override_settings +from django.urls import resolve, reverse + +import importlib + +# Import the submodule (not the re-exported `version` function, which shadows the +# `website.views.version` name in the package namespace) so we can read/patch its +# module-level BUILD_INFO_PATH. +version_module = importlib.import_module("website.views.version") + + +class VersionRoutingTests(SimpleTestCase): + def test_version_url_resolves(self): + match = resolve("/version/") + self.assertEqual(match.url_name, "version") + self.assertIs(match.func, version_module.version) + + def test_version_json_url_resolves_to_same_view(self): + match = resolve("/version.json") + self.assertIs(match.func, version_module.version) + + def test_reverse(self): + self.assertEqual(reverse("website:version"), "/version/") + + +@override_settings( + ML_WEBSITE_VERSION="9.9.9", + ML_WEBSITE_VERSION_DESCRIPTION="Test description.", + DJANGO_ENV="TEST", +) +class VersionResponseTests(SimpleTestCase): + def test_payload_from_settings_and_no_store_header(self): + response = self.client.get("/version/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/json") + self.assertEqual(response["Cache-Control"], "no-store") + data = json.loads(response.content) + self.assertEqual(data["version"], "9.9.9") + self.assertEqual(data["description"], "Test description.") + self.assertEqual(data["environment"], "TEST") + # Always present, even without a build-info file. + self.assertIn("git_sha", data) + self.assertIn("built_at", data) + + def test_build_info_missing_falls_back_to_unknown(self): + with override_settings(): + # Point at a path that doesn't exist. + original = version_module.BUILD_INFO_PATH + version_module.BUILD_INFO_PATH = "/nonexistent/build-info.json" + try: + data = json.loads(self.client.get("/version/").content) + finally: + version_module.BUILD_INFO_PATH = original + self.assertEqual(data["git_sha"], "unknown") + self.assertEqual(data["built_at"], "unknown") + + def test_build_info_read_from_file(self): + path = os.path.join( + os.path.dirname(__file__), "__version_build_info_probe.json" + ) + with open(path, "w") as f: + json.dump({"git_sha": "abc1234", "built_at": "2026-06-21T18:30:00-07:00"}, f) + original = version_module.BUILD_INFO_PATH + version_module.BUILD_INFO_PATH = path + try: + data = json.loads(self.client.get("/version/").content) + finally: + version_module.BUILD_INFO_PATH = original + os.remove(path) + self.assertEqual(data["git_sha"], "abc1234") + self.assertEqual(data["built_at"], "2026-06-21T18:30:00-07:00") diff --git a/website/urls.py b/website/urls.py index 3bb03353..2913f51a 100644 --- a/website/urls.py +++ b/website/urls.py @@ -60,6 +60,13 @@ # and spaces, and routes it to the `project` view. re_path(r'^project/(?P[a-zA-Z\- ]+)/$', views.project, name='project'), + # Machine-readable build/version info as JSON (#1366). Lets us confirm what + # code a server is actually running (version + git_sha + built_at) without + # scraping the HTML comment in base.html. Both /version/ and /version.json + # hit the same view; the view sets Cache-Control: no-store. + path('version/', views.version, name='version'), + path('version.json', views.version, name='version_json'), + # Matches the URL "news/" and routes it to the `news_listing` view. re_path(r'^news/$', views.news_listing, name='news_listing'), diff --git a/website/views/__init__.py b/website/views/__init__.py index 49e8dc6a..e5f7777f 100644 --- a/website/views/__init__.py +++ b/website/views/__init__.py @@ -11,4 +11,5 @@ from .serve_pdf import * from .awards import awards from .custom_404 import custom_404, custom_500, preview_404, preview_500 +from .version import version diff --git a/website/views/version.py b/website/views/version.py new file mode 100644 index 00000000..895e3aaa --- /dev/null +++ b/website/views/version.py @@ -0,0 +1,89 @@ +""" +Machine-readable version / build-info endpoint (#1366). + +Exposes the running code version as JSON at ``/version/`` (and ``/version.json``) +so the deployed build can be checked without fetching a full page and scraping +the ```` comment in ``base.html``. + +``version`` / ``description`` / ``environment`` come straight from +``settings.py``. ``git_sha`` and ``built_at`` are captured *once at container +start* by ``docker-entrypoint.sh`` (which writes them into the build-info file at +:data:`BUILD_INFO_PATH`) rather than per request -- that avoids running ``git`` +on every hit or needing git in the runtime image. Both fall back to +``"unknown"`` when the file is missing (e.g. local dev without the entrypoint). + +These two fields are what actually answer *"is prod stale?"*: a bumped tag that +never deployed shows an old ``git_sha`` / ``built_at`` at a glance. No new info +is disclosed -- ``version`` / ``description`` are already public via the HTML +comment. + +Example:: + + GET /version/ + { + "version": "2.16.3", + "description": "Fix the project listing page ...", + "environment": "PROD", + "git_sha": "02909b0", + "built_at": "2026-06-21T18:30:00-07:00" + } +""" + +import json +import logging +import os + +from django.conf import settings +from django.http import JsonResponse + +# Module logger (configured in settings.LOGGING). +_logger = logging.getLogger(__name__) + +# Small JSON file written by docker-entrypoint.sh at container start. Not +# committed (gitignored); the view tolerates its absence. +BUILD_INFO_PATH = os.path.join(settings.BASE_DIR, "build-info.json") + + +def _read_build_info(): + """ + Return ``{"git_sha": ..., "built_at": ...}`` read from the build-info file, + falling back to ``"unknown"`` for any missing/unreadable value. Never raises + -- a broken or absent file just yields the fallbacks. + """ + fallback = {"git_sha": "unknown", "built_at": "unknown"} + try: + with open(BUILD_INFO_PATH) as f: + data = json.load(f) + except FileNotFoundError: + return fallback + except (OSError, ValueError) as e: + _logger.warning("Could not read build-info file %s: %s", BUILD_INFO_PATH, e) + return fallback + return { + "git_sha": data.get("git_sha") or "unknown", + "built_at": data.get("built_at") or "unknown", + } + + +def version(request, format=None): + """ + GET /version/ (and /version.json) -> JSON build/version info. + + Unauthenticated and free of sensitive data. Sets ``Cache-Control: no-store`` + so Apache or any intermediary can't serve a stale version string -- the whole + point is to read the *current* deployed build. + + ``format`` is accepted (and ignored) so the DRF ``format_suffix_patterns`` + wrapper applied in ``website/urls.py`` doesn't choke on the suffixed route. + """ + build_info = _read_build_info() + payload = { + "version": settings.ML_WEBSITE_VERSION, + "description": settings.ML_WEBSITE_VERSION_DESCRIPTION, + "environment": settings.DJANGO_ENV or "unknown", + "git_sha": build_info["git_sha"], + "built_at": build_info["built_at"], + } + response = JsonResponse(payload) + response["Cache-Control"] = "no-store" + return response