diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7052638..429207f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,15 @@
All notable changes to this project are documented in this file. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and the project uses semantic versioning.
+## [Unreleased]
+
+### Added
+
+- `GET /version.json` on `play.`: `{"version": ""}` with
+ `Content-Type: application/json` and `Cache-Control: no-store`, so a deploy can be verified by the
+ version it reports. It is answered on the API host only, never on a game's host, so it cannot
+ shadow a static game's own `/version.json`.
+
## [0.6.0] - 2026-10-03
### Added
diff --git a/README.md b/README.md
index 326579c..d86573d 100644
--- a/README.md
+++ b/README.md
@@ -103,6 +103,7 @@ out for a phone first, and is sent with `Cache-Control: public, max-age=60`. Set
| `PUT` | `/api/games//versions/` | Body: a `.tar` (optionally compressed) or `.zip` holding exactly `index.html`, `game.zip`, `version.txt`. It is validated before anything is written, then stored and made current unless `?activate=false`. Responses: **201**; **409** if the version exists (versions are immutable); **401/403** on a missing or wrong token; **411** without a `Content-Length`; **413** over the size cap; **400** for a missing or extra file, a `version.txt` that disagrees with ``, or a `game.zip` without tak's four assets. |
| `POST` | `/api/games//current` | `{"version": "…"}`: repoints the game to a stored version. This is rollback. **404** for a version not on disk. |
| `GET` | `/api/games`, `/api/games/` | slug, title, repo, url, aliases, kind, isolation, current, versions, **plays** (page loads by people; crawlers and scripts are excluded by User-Agent). Needs no token, never shows hashes, and is readable cross-origin (`Access-Control-Allow-Origin: *`) so the portal can show play counts. |
+| `GET` | `/version.json` | `{"version": ""}`: the version of arcade itself that is running (`arcade.__version__`), with `Cache-Control: no-store`, so a deploy can be verified by the version it reports. Needs no token. Answered on `play.` only: on a game's host `/version.json` is the game's own path. |
The token is `Authorization: Bearer `, and each game's token deploys only that game. An
unknown slug and a wrong token both get 403. The last `ARCADE_KEEP_VERSIONS` (5) versions per game
diff --git a/src/arcade/server.py b/src/arcade/server.py
index de49483..8296bcf 100644
--- a/src/arcade/server.py
+++ b/src/arcade/server.py
@@ -5,7 +5,8 @@
play. the upload API, and / a page listing every game
(or, with ARCADE_LANDING_MODE=redirect, a 302 to
- the portal)
+ the portal), and /version.json: the running
+ arcade's own version, {"version": ""}
.play. a game, served the way tak.web.serve serves one
a game's old hostname (registry `aliases`)
@@ -394,6 +395,9 @@ def _api(self, path, read):
if read and path in ("/", "/index.html"):
self._landing()
return
+ if read and path == "/version.json":
+ self._version()
+ return
if read and path == "/api/games":
registry = arcade.registry.registry
self._json(200, {"games": [self._describe(game) for game in registry]}, public=True)
@@ -416,6 +420,14 @@ def _api(self, path, read):
return
self._json(404, {"error": "not found"})
+ def _version(self):
+ # The version of the code this process is running, so a deploy can
+ # be verified by the version it reports. Only on the API host: on a
+ # game's host /version.json belongs to the game (a static site may
+ # ship its own), so it is never answered there.
+ body = json.dumps({"version": __version__}).encode("utf-8")
+ self._send(200, body, "application/json", headers=(("Cache-Control", "no-store"),))
+
def _landing(self):
if config.landingMode == "redirect":
self._send(302, b"", headers=(("Location", config.landingUrl),))
diff --git a/tests/test_server.py b/tests/test_server.py
index 6d02788..2363b0a 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -5,6 +5,7 @@
import pytest
+from arcade import __version__ as arcade_version
from arcade.server import ISOLATION_HEADERS, Arcade, Config, makeServer
from helpers import ASSETS, OTHER_SHA, OTHER_TOKEN, TOKEN, bundleFiles, game, registryText, tarBundle
@@ -84,6 +85,28 @@ def test_health_on_any_host(arcade):
assert (response.status, data) == (200, b"ok\n")
+def test_the_api_host_reports_the_running_version(arcade):
+ for method in ("GET", "HEAD"):
+ response, data = request(arcade, method, API, "/version.json")
+ assert response.status == 200
+ assert response.getheader("Content-Type") == "application/json"
+ assert response.getheader("Cache-Control") == "no-store"
+ if method == "GET":
+ assert json.loads(data) == {"version": arcade_version}
+ else:
+ assert data == b""
+
+
+def test_version_json_on_a_game_host_belongs_to_the_game(arcade):
+ site = dict(_site("0.1"), **{"version.json": b'{"game": "rps"}'})
+ assert upload(arcade, slug="rps", version="0.1", body=tarBundle(site))[0].status == 201
+ response, data = request(arcade, "GET", "rps." + DOMAIN, "/version.json")
+ assert (response.status, data) == (200, b'{"game": "rps"}')
+ upload(arcade)
+ for host in (TIDEWATER, "tidewater.example.org", "nope." + DOMAIN, "localhost"):
+ assert request(arcade, "GET", host, "/version.json")[0].status == 404, host
+
+
def test_unknown_hosts_are_404_and_still_isolated(arcade):
for host in ("example.com", "nope." + DOMAIN, "a.b." + DOMAIN, ""):
response, _ = request(arcade, "GET", host, "/")