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
32 changes: 32 additions & 0 deletions .github/scripts/package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Package verified repository contents and build provenance."""
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import tarfile

name = 'ProvinceSystem'
version = os.environ.get("BUILD_VERSION") or datetime.now(timezone.utc).strftime("DEV-%Y%m%d-%H%M")
if not re.fullmatch(r"(?:DEV-\d{8}-\d{4}|\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)", version) or "SNAPSHOT" in version.upper():
raise SystemExit("Invalid archive version")
dist = Path("dist")
dist.mkdir(exist_ok=False)
archive = dist / f"{name}-{version}.tar.gz"
subprocess.run(["git", "archive", "--format=tar.gz", f"--prefix={name}-{version}/", "-o", str(archive), "HEAD"], check=True)
def runtime_only(member):
if "/.next/cache" in member.name:
return None
return member

with tarfile.open(dist / f"ProvinceSystem-frontend-{version}.tar.gz", "w:gz") as output:
for filename in [".next", "public", "package.json", "package-lock.json", "next.config.ts"]:
output.add(Path("frontend") / filename, arcname=f"{name}-{version}/frontend/{filename}", filter=runtime_only)
checksums = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(dist.glob("*.tar.gz"))}
(dist / "SHA256SUMS").write_text("".join(f"{sha} {filename}\n" for filename, sha in checksums.items()))
metadata = {"repository": os.environ.get("GITHUB_REPOSITORY", name), "commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), "version": version, "run_id": os.environ.get("GITHUB_RUN_ID"), "sha256": checksums}
(dist / "build.json").write_text(json.dumps(metadata, indent=2) + "\n")
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
output.write(f"name={name}-{version}\n")
75 changes: 75 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: Build

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_call:
inputs:
version:
required: true
type: string
outputs:
artifact-name:
value: ${{ jobs.build.outputs.artifact-name }}

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-24.04
timeout-minutes: 30
outputs:
artifact-name: ${{ steps.package.outputs.name }}-${{ github.run_id }}-${{ github.run_attempt }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- uses: actions/setup-python@v7
with:
python-version: '3.12'
cache: pip
cache-dependency-path: backend/requirements*.txt
- name: Install frontend dependencies
working-directory: frontend
run: npm ci --no-audit --no-fund
- name: Install backend dependencies
run: python -m pip install -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Test frontend
working-directory: frontend
run: npm test -- --reporter=default --reporter=junit --outputFile.junit=../test-results/frontend.xml
- name: Test backend
if: ${{ !cancelled() }}
working-directory: backend
run: python -m pytest -q --junitxml=../test-results/backend.xml
- name: Upload test reports
if: ${{ !cancelled() && hashFiles('test-results/*.xml') != '' }}
uses: actions/upload-artifact@v7
with:
name: test-reports-${{ github.run_id }}-${{ github.run_attempt }}
path: test-results/*.xml
if-no-files-found: error
- name: Build frontend
working-directory: frontend
env:
NEXT_TELEMETRY_DISABLED: '1'
PS_PRODUCTION: '1'
NEXT_PUBLIC_API_URL: http://127.0.0.1:8000
run: npm run build
- name: Package application
id: package
env:
BUILD_VERSION: ${{ inputs.version }}
run: python .github/scripts/package.py
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.package.outputs.name }}-${{ github.run_id }}-${{ github.run_attempt }}
path: dist/
if-no-files-found: error
63 changes: 63 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Release

on:
push:
tags: ['v*']

permissions:
contents: read

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
validate:
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- id: version
env:
TAG: ${{ github.ref_name }}
run: |
python3 - <<'PYTHON'
import os, re, json
from pathlib import Path
tag = os.environ['TAG']
if not re.fullmatch(r'v\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?', tag) or 'SNAPSHOT' in tag.upper():
raise SystemExit('Use a numeric v tag, optionally with a prerelease suffix')
if json.loads(Path('frontend/package.json').read_text())['version'] != tag[1:]:
raise SystemExit('Tag must match the frontend package version')
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
output.write(f'version={tag[1:]}\n')
PYTHON
build:
needs: validate
uses: ./.github/workflows/build.yml
with:
version: ${{ needs.validate.outputs.version }}
publish:
needs: build
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/download-artifact@v8
with:
name: ${{ needs.build.outputs.artifact-name }}
path: release
- name: Create draft release
working-directory: release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
sha256sum --check SHA256SUMS
flags=()
if [[ "$TAG" == *-* ]]; then flags+=(--prerelease); fi
gh release create "$TAG" *.tar.gz SHA256SUMS build.json --verify-tag --draft --generate-notes "${flags[@]}"
15 changes: 15 additions & 0 deletions backend/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Give test fixtures one database module across both supported import paths."""
from pathlib import Path
import sys

backend = Path(__file__).resolve().parent
sys.path.insert(0, str(backend))
sys.path.insert(0, str(backend / "src"))

import src.skins as skins
from src.skins import db

# Fixtures replace db paths with temporary directories. All imported connect
# functions must retain that same module's globals throughout test collection.
sys.modules["skins"] = skins
sys.modules["skins.db"] = db
3 changes: 2 additions & 1 deletion backend/src/api/test_map_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,9 @@ def test_ui_dev_session_ignored_when_flag_off(self) -> None:
with mock.patch(
"api.map_access.is_character_ui_dev",
return_value=False,
):
), mock.patch("api.map_access.get_session", return_value=None) as get_session:
ids = [item.id for item in list_accessible_maps(auth)]
get_session.assert_called_once_with(UI_DEV_SESSION_TOKEN)
self.assertEqual(ids, ["main"])


Expand Down
2 changes: 2 additions & 0 deletions backend/src/characters/test_realm_wipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ def setUp(self) -> None:
db_mod.SKINS_DIR = root / "skins"
db_mod.WARDROBE_DIR = root / "wardrobe"
db_mod.migrate()
# Other fixtures reload codes; refresh the route's exception imports.
importlib.reload(importlib.import_module("src.api.characters_routes"))

# wardrobe.py binds DATA_DIR / WARDROBE_DIR at import, so png deletes need
# the same override.
Expand Down
4 changes: 4 additions & 0 deletions backend/src/skins/test_realm_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ def test_create_character_stamps_realm(self) -> None:
from characters import creates as creates_mod

with mock.patch.object(
creates_mod,
"require_synced_creation_catalog",
return_value={"web_creator_access": {"by_realm": {}}},
), mock.patch.object(
creates_mod,
"_validate_and_normalize",
return_value={"client_request_id": None, "name": "Test"},
Expand Down
16 changes: 16 additions & 0 deletions backend/src/skins/test_realm_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ def test_list_pending_filters_by_realm(self) -> None:
from characters import creates as creates_mod

with mock.patch.object(
creates_mod,
"require_synced_creation_catalog",
return_value={"web_creator_access": {"by_realm": {}}},
), mock.patch.object(
creates_mod,
"_validate_and_normalize",
return_value={"client_request_id": None, "name": "DevChar"},
Expand All @@ -62,6 +66,10 @@ def test_list_pending_filters_by_realm(self) -> None:
realm_id="dev",
)
with mock.patch.object(
creates_mod,
"require_synced_creation_catalog",
return_value={"web_creator_access": {"by_realm": {}}},
), mock.patch.object(
creates_mod,
"_validate_and_normalize",
return_value={"client_request_id": None, "name": "MainChar"},
Expand Down Expand Up @@ -127,6 +135,10 @@ def test_list_for_player_scopes_pending_and_roster(self) -> None:
realm_id="dev",
)
with mock.patch.object(
creates_mod,
"require_synced_creation_catalog",
return_value={"web_creator_access": {"by_realm": {}}},
), mock.patch.object(
creates_mod,
"_validate_and_normalize",
return_value={"client_request_id": None, "name": "DevPending"},
Expand All @@ -137,6 +149,10 @@ def test_list_for_player_scopes_pending_and_roster(self) -> None:
realm_id="dev",
)
with mock.patch.object(
creates_mod,
"require_synced_creation_catalog",
return_value={"web_creator_access": {"by_realm": {}}},
), mock.patch.object(
creates_mod,
"_validate_and_normalize",
return_value={"client_request_id": None, "name": "MainPending"},
Expand Down
19 changes: 19 additions & 0 deletions backend/tests/test_text_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
Expand Down Expand Up @@ -122,6 +123,24 @@ def _minimal_catalog() -> dict:


class CreateValidationWireTests(unittest.TestCase):
def setUp(self) -> None:
from src.skins import db

temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
root = Path(temporary.name)
paths = patch.multiple(
db,
DATA_DIR=root,
DB_PATH=root / "province.db",
SKINS_DIR=root / "skins",
DRINKS_DIR=root / "drinks",
WARDROBE_DIR=root / "wardrobe",
)
paths.start()
self.addCleanup(paths.stop)
db.migrate()

def test_rejects_script_name(self) -> None:
from src.characters.creates import CreateError, _validate_and_normalize

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@ describe("ChronicleStaffConsole", () => {
// A hand-rolled response rather than `new Response(...)`: jsdom does not
// define the fetch response classes, and `fetchMapApi` only reads `ok`,
// `status` and `json()`.
vi.fn(async () => ({
vi.fn(async (url: string) => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({
json: async () => String(url).includes("/maps/accessible") ? ({
maps: [{ id: "main", display_name: "Main", public: true, archived: false }],
}) : ({
backups: [
{
id: 7,
Expand Down
8 changes: 7 additions & 1 deletion frontend/app/lib/titleProvinces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ describe("KINGDOM_1 spot-check (main defines)", () => {
) as T;
}

it("matches union of COUNTY_1 through COUNTY_9 provinces", () => {
it("matches union of COUNTY_1 through COUNTY_15 provinces", () => {
const county = loadJson<Record<string, TitleEntity>>("county.json");
const duchy = loadJson<Record<string, TitleEntity>>("duchy.json");
const kingdom = loadJson<Record<string, TitleEntity>>("kingdom.json");
Expand All @@ -111,6 +111,12 @@ describe("KINGDOM_1 spot-check (main defines)", () => {
"COUNTY_7",
"COUNTY_8",
"COUNTY_9",
"COUNTY_10",
"COUNTY_11",
"COUNTY_12",
"COUNTY_13",
"COUNTY_14",
"COUNTY_15",
].flatMap((id) => county[id]?.provinces ?? []);

const result = resolveTitleProvinces("KINGDOM_1", "kingdom", layers);
Expand Down