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
12 changes: 9 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
name: CI Tests

on:
# Runs tests whenever someone opens or updates a Pull Request targeting 'main'
# Runs tests whenever someone opens or updates a Pull Request targeting 'main' or
# 'dev' — the integration branch that publishes development images.
pull_request:
branches:
- main

# Runs tests when code is merged or pushed directly to 'main'
- dev

# Runs tests when code is merged or pushed directly to 'main'.
#
# Not 'dev': a push there triggers Dev Build to Docker Hub, which runs both suites
# itself before publishing. Adding it here would run the same tests twice on every
# dev commit for no extra signal.
push:
branches:
- main
Expand Down
114 changes: 114 additions & 0 deletions .github/workflows/dev-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
name: Dev Build to Docker Hub

# Publishes a development build. Deliberately separate from Release, and deliberately
# never touching `latest` — a stable deployment must not be able to reach these images.
#
# Two tags go out for each build, and both are needed:
# dev the moving pointer that IMAGE_TAG=dev pulls
# X.Y.Z-dev.N an immutable build, and the only form the update check can see,
# since `dev` is not a version and is filtered out of the tag listing
#
# The version comes from frontend/package.json, so dev builds must run *ahead* of the
# last release: with package.json at 1.9.0 the builds are 1.9.0-dev.N, which supersede
# 1.8.1 and are in turn superseded by 1.9.0 when it ships.

on:
workflow_dispatch:
push:
branches: [dev]

jobs:
test:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'

- name: Backend tests
working-directory: ./backend
run: npm ci && npm test

- name: Frontend tests
working-directory: ./frontend
run: npm ci && npm test

publish:
name: Build & Push
needs: test
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Compose the dev version
id: version
run: |
BASE=$(jq -r '.version' frontend/package.json)
# The run number gives each build a distinct, increasing identity, so a dev
# deployment is offered the next one. A single moving X.Y.Z-dev tag would be
# the same version every time and would never be seen as an update.
echo "VERSION=${BASE}-dev.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
echo "BASE=${BASE}" >> "$GITHUB_OUTPUT"

- name: Refuse to publish a dev build older than the latest release
env:
BASE: ${{ steps.version.outputs.BASE }}
REPO: ${{ secrets.DOCKERHUB_USERNAME }}/citynet-frontend
run: |
set -euo pipefail
LATEST=$(curl -fsSL "https://hub.docker.com/v2/repositories/${REPO}/tags?page_size=100" \
| jq -r '[.results[].name | select(test("^[0-9]+[.][0-9]+[.][0-9]+$"))]
| sort_by(split(".") | map(tonumber)) | last // "0.0.0"')
echo "package.json: ${BASE} latest release: ${LATEST}"
# X.Y.Z-dev sorts *below* X.Y.Z, so a dev build of an already-released version
# is older than what is already out and would never be offered to anyone.
# Catching that here is far cheaper than working out later why a dev
# deployment is being told it is up to date.
NEWEST=$(printf '%s\n%s\n' "${BASE}" "${LATEST}" | sort -V | tail -1)
if [ "${BASE}" = "${LATEST}" ] || [ "${NEWEST}" != "${BASE}" ]; then
echo "::error::Dev builds must be ahead of the last release. Bump package.json and frontend/package.json past ${LATEST} first."
exit 1
fi

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build & push backend
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.backend
push: true
build-args: |
APP_VERSION=${{ steps.version.outputs.VERSION }}
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/citynet-backend:dev
${{ secrets.DOCKERHUB_USERNAME }}/citynet-backend:${{ steps.version.outputs.VERSION }}

- name: Build & push frontend
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.frontend
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/citynet-frontend:dev
${{ secrets.DOCKERHUB_USERNAME }}/citynet-frontend:${{ steps.version.outputs.VERSION }}

- name: Summary
run: |
{
echo "Published \`${{ steps.version.outputs.VERSION }}\` and \`:dev\`."
echo ""
echo "A deployment with \`IMAGE_TAG=dev\` will be offered this build."
} >> "$GITHUB_STEP_SUMMARY"
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,38 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

---

## [1.8.1] - 2026-08-02

### Added

- **An optional development channel, selected by one setting.** `IMAGE_TAG=latest` by default, which is stable; `IMAGE_TAG=dev` follows development builds. That is the same variable `docker-compose.yml` interpolates to decide which images are pulled, so what the update check offers and what it installs cannot disagree — the check reads the deployment's own tag rather than a separate declaration of intent.

Development builds are tagged `X.Y.Z-dev`, with a counter accepted but not required, so `1.9.0-dev` and `1.9.0-dev.7` both work and introducing counters later is a build-workflow change rather than a code one. A dev build of a newer release is offered to someone on an older release, the release itself supersedes its own dev builds when it lands, and a release user is never dragged back onto a dev build of the same version. A pinned version tag counts as stable, since pinning is not a channel.

A `Dev Build to Docker Hub` workflow publishes them, on manual dispatch or a push to a `dev` branch. It runs the test suites first, never touches `latest`, and pushes two tags per build: `dev`, which is what `IMAGE_TAG=dev` pulls, and `X.Y.Z-dev.N`, which is the only form the update check can see — `dev` is not a version and is filtered out of the tag listing. It refuses to run when `package.json` still holds an already-released version, since `X.Y.Z-dev` sorts *below* `X.Y.Z` and such a build could never be offered to anyone.

`IMAGE_TAG` must also reach the `.env` beside `docker-compose.yml`, which the setup steps already cover by copying `backend/.env` to the project root: compose interpolates from the project file rather than from `env_file`.

### Fixed

- **One dev tag on the registry would have silenced update notices for everyone.** The tag filter was `/^\d+\.\d+\.\d+/`, unanchored, so `1.9.0-dev` passed it and then parsed to `NaN` — which made the sort comparator return `NaN`, leaving the ordering undefined and letting a prerelease surface as the newest tag, whereupon the version check correctly refused it and reported no update at all. Version tags are matched strictly now, and sorted by a comparator that understands them.
- **The in-app updater could sit on `WAITING FOR SERVER` indefinitely.** Every step failed silently — both child processes discarded their output, the route answered "Update started" before checking anything could work, and a non-zero exit from `docker compose pull` simply returned — so a stack that *could not* update was indistinguishable from one still working, and the client polled every three seconds forever with no deadline.

The likeliest cause on a long-running instance is the compose file's own self-mount at `/tmp/docker-compose.yml`, which the update reads. A container started before that line existed does not have it, the pull fails, and everything above turns that into an endless wait — so the instances least able to update in place are exactly the ones that have been running longest.

`POST /api/update` now checks the mount, the Docker socket and the compose project labels *before* answering, and returns `409` naming what is missing and what to do about it. Both steps append to `backend/data/update.log`, which lives on the data volume and so survives the container being replaced. `GET /api/update/status` reports phase and error, and the modal shows them, reassures at 45 seconds that a pull legitimately takes minutes, and gives up after six with the host command to fall back to.
- **A container too old to update itself now says so immediately.** Such a container answers `POST /api/update` with "Update started" and then does nothing, so the client used to wait out the full deadline to learn what could be known at once. It is asked for `GET /api/update/status` first — a route that only exists in the self-checking build — and if that is missing the modal says the container predates it and shows the command to run on the host. The response shape is checked rather than just the status code, since a setup serving `index.html` for unknown paths answers `200` with a page. Nothing is POSTed to a server that cannot act on it.
- **The nav-panel update button had none of the above.** There were two implementations of the update flow — the modal and the panel — and only the modal's was hardened. The panel ignored the server's refusal entirely, waited on the version rather than the restart, and polled every three seconds with no deadline, so the original symptom survived in the path the upgrade guide tells people to use. Both now drive one shared client, which is the only reason a second copy could go unfixed.
- **The "read more" link on the update panel pointed at a heading that does not exist.** `README.md#updating` has no such anchor, so someone whose update just failed landed at the top of a 570-line README. Both links now go to `UPGRADE.md`, which is the actual guide.
- **A successful update could hang too.** The client waited for the reported version to change, but a build without `APP_VERSION` reports `dev` before and after. `/api/version` now carries a boot id and the client waits for the restart itself.
- **The update check offered downgrades.** `hasUpdate` was `latest !== current`, so a published tag trailing the running one counted as an update — a 1.8.0 instance was offered 1.7.4. It is a numeric version comparison now, and anything unparseable (`dev`, `latest`) is never offered.

### Technical

- The update logic moved out of the admin route into `backend/updater.js`. None of it was reachable from a test where it was, which is a large part of why three separate faults sat in it unnoticed.

---

## [1.8.0] - 2026-08-01

### Added
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,10 +323,11 @@ CITY_NET/
├── backend/
│ ├── server.js # Express entrypoint — mounts routes, starts Socket.IO
│ ├── db.js # SQLite schema and migrations
│ ├── updater.js # In-app self-update — release channels selected by IMAGE_TAG alone, the same variable compose pulls with (X.Y.Z-dev tags with an optional counter, ordered so a release supersedes its own dev builds); preflight (compose file mounted, docker socket, compose project labels) so a stack that cannot update says why instead of hanging; upgrade-only semver check; update log on the data volume; boot id so a restart is detectable without a version change
│ ├── middleware/
│ │ └── auth.js # JWT verify middleware (admin + elevated users)
│ ├── routes/
│ │ ├── admin.js # Admin-only REST endpoints; undo covers locations, roads, signs; POST /water marks generated water so a regenerate can clear its own river without touching a lake the GM drew
│ │ ├── admin.js # Admin-only REST endpoints; undo covers locations, roads, signs; POST /update preflights and returns 409 naming what is missing, GET /update/status reports phase, error and log tail, POST /check-update offers only genuine upgrades from the deployment's own channel; POST /water marks generated water so a regenerate can clear its own river without touching a lake the GM drew
│ │ ├── locations.js # Location CRUD; JOIN→CUSTOM classification upserts roots + child parts to custom_structure_library; serves GET /custom-library (CUSTOM-only); GET / includes sheet_data for NPC initiative rolls; POST /purge-region clears one region's generated content in a single transaction, keeping GM-named structures, tokens, battle-map content and hand-drawn water
│ │ ├── battle_maps.js # Battle map image upload/management
│ │ ├── maps.js # Saved map snapshots (locations, districts, roads, overpasses, water bodies); preserves only rhombus tokens on load/clear; records active_map_name in global_settings so exports can name their files
Expand Down Expand Up @@ -362,7 +363,9 @@ CITY_NET/
│ └── __tests__/
│ ├── helpers/
│ │ └── testDb.js # In-memory SQLite factory for isolated test DBs
│ ├── admin.test.js # Admin endpoints (auth, settings, undo access)
│ ├── admin.test.js # Admin endpoints (auth, settings, undo access); update routes — 409 with a reason rather than a false success, unauthenticated status, boot id on /version; check-update against a stubbed registry — upgrades only, dev tags per channel, and a prerelease not hiding a stable release
│ ├── updater.test.js # Version ordering including X.Y.Z-dev, tag filtering per channel, preflight refusals, and an update that records its failures instead of returning silently
│ ├── docker_config.test.js # Deployment invariants — DB_PATH baked in, data excluded from the image, image tags parameterised by IMAGE_TAG, compose file mounted for the updater, channel shipped pointing at stable
│ ├── battle_maps.test.js # Battle map upload/list/delete
│ ├── locations.test.js # Location CRUD and classification
│ ├── locations.global.test.js # Custom structure global persistence tests
Expand Down Expand Up @@ -543,6 +546,7 @@ CITY_NET/
│ │ │ └── shadowrun_6e.ts # Shadowrun 6E — attributes, d6 pool skills, Edge pips (SPEND button, admin replenish), weapons (DV/AR), Stun track, gated AWAKENED/EMERGED tabs; dynamic spell list (DRAIN/CAST) and adept power list (PP cost auto-summed)
│ │ ├── streamerMode.ts # IS_SPECTATOR constant — detects ?streamer=true URL param
│ │ └── utils/
│ │ ├── updateClient.ts # One implementation of the in-app update flow, shared by the update modal and the nav panel — stale-container probe, server refusal passed through verbatim, restart detected by boot id, bounded wait. Two copies is how one of them stayed unhardened
│ │ ├── locationHelpers.ts # Location geometry utilities; exports ZONE_TYPE_NAMES and isUserDefinedName
│ │ ├── rhombusHelpers.ts # Player token position math
│ │ ├── threeHelpers.tsx # Three.js scene utilities
Expand All @@ -556,6 +560,7 @@ CITY_NET/
│ │ ├── roadHelpers.test.ts # consolidateRoads, chainRoadPolylines, buildRoadRibbonGeometry
│ │ ├── mapExportBounds.test.ts # Bounds coverage; GPU clamping on both axes, aspect preserved when scaling down
│ │ ├── mapExportWatermark.test.ts # Watermark anchor and stacking, scaling floor, filename slugging, download link cleanup
│ │ ├── updateClient.test.ts # Stale-container detection including an index.html fallback answering 200, refusals passed through, nothing POSTed to a server that cannot act
│ │ └── overpassHelpers.test.ts # Elevation, geometry, and path-sampling tests
│ └── public/
│ ├── signs/ # Preset neon SVG sign images (motel, bar, cyber-clinic, etc.)
Expand All @@ -564,7 +569,8 @@ CITY_NET/
├── docs/ # Reference docs (deployment plans, feature notes)
├── Dockerfile.backend
├── Dockerfile.frontend
├── docker-compose.yml
├── .github/workflows/ # CI Tests on PRs and main; Release to Docker Hub on green main; Dev Build to Docker Hub on dispatch or a push to dev
├── docker-compose.yml # Image tags read ${IMAGE_TAG:-latest}, so the release channel is a setting rather than an edit
├── nginx.conf
└── .env.example
```
Expand Down
13 changes: 13 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ How to update CITY_NET to the latest version.

Log in as admin, open the nav panel, and click **CLICK TO UPDATE (docker only)**. The app will pull the latest image, restart all containers, and reload automatically.

If it cannot update, it now says why rather than waiting — the commonest reason being a container started before `docker-compose.yml` mounted itself into the backend, which is to say a long-running one. Recreating the stack once with the manual steps below fixes that permanently. Details of any failure are appended to `backend/data/update.log`.

**The in-app update pulls images, not files.** `docker-compose.yml`, `nginx.conf` and the rest come from the repository, so a release that changes one of them needs a `git pull` as well. Everything keeps working without it; only the new capability is missing.

### Manual Docker update

```bash
Expand Down Expand Up @@ -43,6 +47,15 @@ pm2 restart citynet-backend

## Environment variable changes by version

### [1.8.1]
- **`IMAGE_TAG`** — Optional, defaults to `latest`. Selects the release channel: `latest` for stable releases, `dev` for development builds, or a pinned version such as `1.8.1`.

**Existing installs need to change nothing.** An absent `IMAGE_TAG` resolves to `latest`, which is the behaviour you already have.

If you do set it, put it in the `.env` beside `docker-compose.yml` as well as `backend/.env` — compose interpolates it from the project file rather than from `env_file`, and the setup steps already cover this by copying one to the other. It also requires the `docker-compose.yml` from 1.8.1 or later, since that is where the tag became a variable; see the note above about the in-app update not updating repository files.

Development builds are unreleased and may break. They are only ever offered when `IMAGE_TAG=dev`.

### [1.2.3]
No new required vars. `WATCHTOWER_API_TOKEN` is no longer required — you can remove it from your `.env` if present.

Expand Down
15 changes: 15 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,18 @@ DUCKDNS_TOKEN=your-duckdns-token

# Timezone for DuckDNS container (e.g. America/New_York)
TZ=America/Chicago

# ── Release channel (optional) ───────────────────────────────────────────────
# Which images this deployment runs. Stable unless you change it.
#
# IMAGE_TAG=latest stable releases (default)
# IMAGE_TAG=dev development builds — unreleased, and they may break
#
# One setting, because it is one decision: docker compose pulls this tag, and the
# update check offers versions from the same channel. Development builds are tagged
# X.Y.Z-dev and are only ever offered when this is set to dev.
#
# It must also be present in the .env beside docker-compose.yml — compose interpolates
# ${IMAGE_TAG} from that file rather than from env_file — which the setup steps already
# cover by copying backend/.env to the project root.
IMAGE_TAG=latest
Loading
Loading