Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions makeabilitylab/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
86 changes: 86 additions & 0 deletions website/tests/test_version_endpoint.py
Original file line number Diff line number Diff line change
@@ -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")
7 changes: 7 additions & 0 deletions website/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@
# and spaces, and routes it to the `project` view.
re_path(r'^project/(?P<project_name>[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'),

Expand Down
1 change: 1 addition & 0 deletions website/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

89 changes: 89 additions & 0 deletions website/views/version.py
Original file line number Diff line number Diff line change
@@ -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 ``<!-- Makeability Lab website version ... -->`` 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
Loading