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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<base>`: `{"version": "<arcade.__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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ out for a phone first, and is sent with `Cache-Control: public, max-age=60`. Set
| `PUT` | `/api/games/<slug>/versions/<version>` | 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 `<version>`, or a `game.zip` without tak's four assets. |
| `POST` | `/api/games/<slug>/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>` | 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": "<x.y.z>"}`: 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.<base>` only: on a game's host `/version.json` is the game's own path. |

The token is `Authorization: Bearer <token>`, 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
Expand Down
14 changes: 13 additions & 1 deletion src/arcade/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

play.<base> 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": "<x.y.z>"}
<slug>.play.<base> a game, served the way tak.web.serve serves one
<alias> a game's old hostname (registry `aliases`)

Expand Down Expand Up @@ -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)
Expand All @@ -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),))
Expand Down
23 changes: 23 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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, "/")
Expand Down
Loading