diff --git a/.github/scripts/package.py b/.github/scripts/package.py new file mode 100644 index 00000000..6a2545f4 --- /dev/null +++ b/.github/scripts/package.py @@ -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") diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..53860a77 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..2b95fc95 --- /dev/null +++ b/.github/workflows/release.yml @@ -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[@]}" diff --git a/backend/conftest.py b/backend/conftest.py new file mode 100644 index 00000000..5e501cba --- /dev/null +++ b/backend/conftest.py @@ -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 diff --git a/backend/src/api/test_map_access.py b/backend/src/api/test_map_access.py index 18da2fef..264dab8a 100644 --- a/backend/src/api/test_map_access.py +++ b/backend/src/api/test_map_access.py @@ -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"]) diff --git a/backend/src/characters/test_realm_wipe.py b/backend/src/characters/test_realm_wipe.py index a4f275e6..a9b49fe3 100644 --- a/backend/src/characters/test_realm_wipe.py +++ b/backend/src/characters/test_realm_wipe.py @@ -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. diff --git a/backend/src/skins/test_realm_codes.py b/backend/src/skins/test_realm_codes.py index 9521f0c4..4356ea34 100644 --- a/backend/src/skins/test_realm_codes.py +++ b/backend/src/skins/test_realm_codes.py @@ -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"}, diff --git a/backend/src/skins/test_realm_scope.py b/backend/src/skins/test_realm_scope.py index ad36b2cd..5f2c5c19 100644 --- a/backend/src/skins/test_realm_scope.py +++ b/backend/src/skins/test_realm_scope.py @@ -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"}, @@ -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"}, @@ -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"}, @@ -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"}, diff --git a/backend/tests/test_text_validation.py b/backend/tests/test_text_validation.py index c0448752..d6182ff9 100644 --- a/backend/tests/test_text_validation.py +++ b/backend/tests/test_text_validation.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys +import tempfile import unittest from pathlib import Path from unittest.mock import patch @@ -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 diff --git a/frontend/app/components/chronicle/ChronicleStaffConsole.test.tsx b/frontend/app/components/chronicle/ChronicleStaffConsole.test.tsx index d8e4405c..0fc29e62 100644 --- a/frontend/app/components/chronicle/ChronicleStaffConsole.test.tsx +++ b/frontend/app/components/chronicle/ChronicleStaffConsole.test.tsx @@ -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, diff --git a/frontend/app/lib/titleProvinces.test.ts b/frontend/app/lib/titleProvinces.test.ts index 9783f528..76ca3b28 100644 --- a/frontend/app/lib/titleProvinces.test.ts +++ b/frontend/app/lib/titleProvinces.test.ts @@ -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>("county.json"); const duchy = loadJson>("duchy.json"); const kingdom = loadJson>("kingdom.json"); @@ -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);