diff --git a/.gitea/workflows/linux-release-build.yml b/.gitea/workflows/linux-release-build.yml index 632fc24..d4909c4 100644 --- a/.gitea/workflows/linux-release-build.yml +++ b/.gitea/workflows/linux-release-build.yml @@ -66,15 +66,21 @@ jobs: - name: Build and smoke-test Linux package run: ./tools/build-linux-x64.sh + - name: Stage verified workflow artifact + run: | + mkdir -p build/workflow-artifact + cp build/package/OpenMaidEngine-Himegari-linux-x64.tar.gz build/workflow-artifact/ + cp build/package/staging/OpenMaidEngine-Himegari-linux-x64/BUILD-INFO.json build/workflow-artifact/ + cp build/package/staging/OpenMaidEngine-Himegari-linux-x64/SHA256SUMS build/workflow-artifact/ + cp build/package/package-smoke.log build/workflow-artifact/ + cd build/workflow-artifact + sha256sum OpenMaidEngine-Himegari-linux-x64.tar.gz > OpenMaidEngine-Himegari-linux-x64.tar.gz.sha256 + - name: Upload verified Linux package uses: christopherhx/gitea-upload-artifact@v4 with: name: OpenMaidEngine-Himegari-linux-x64-${{ gitea.sha }} - path: | - build/package/OpenMaidEngine-Himegari-linux-x64.tar.gz - build/package/staging/OpenMaidEngine-Himegari-linux-x64/BUILD-INFO.json - build/package/staging/OpenMaidEngine-Himegari-linux-x64/SHA256SUMS - build/package/package-smoke.log + path: build/workflow-artifact/* if-no-files-found: error retention-days: 30 @@ -88,3 +94,46 @@ jobs: build/validation/** if-no-files-found: ignore retention-days: 7 + + publish-release: + name: Publish tagged Gitea release + if: startsWith(gitea.ref, 'refs/tags/v') + needs: linux-release + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + # Gitea 1.25 granular scope; GitHub-oriented actionlint does not model it. + releases: write + steps: + - name: Check out release tooling + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python 3.11 + uses: actions/setup-python@v6 + with: + python-version: "3.11" + architecture: x64 + + - name: Download verified workflow artifact + uses: christopherhx/gitea-download-artifact@v4 + with: + name: OpenMaidEngine-Himegari-linux-x64-${{ gitea.sha }} + path: build/release-assets + + - name: Create or resume Gitea release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + python -X utf8 tools/publish_gitea_release.py \ + --server "${{ gitea.server_url }}" \ + --repository "${{ gitea.repository }}" \ + --tag "${{ gitea.ref_name }}" \ + --target "${{ gitea.sha }}" \ + --asset build/release-assets/OpenMaidEngine-Himegari-linux-x64.tar.gz \ + --asset build/release-assets/OpenMaidEngine-Himegari-linux-x64.tar.gz.sha256 \ + --asset build/release-assets/BUILD-INFO.json \ + --asset build/release-assets/SHA256SUMS \ + --asset build/release-assets/package-smoke.log diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index c17bd8a..d74ebb1 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -36,7 +36,7 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings) │ tracked source and generated references use canonical LF ├── .gitea/workflows/core-validation.yml read-only Ubuntu source-only core gate for the private Gitea remote ├── .gitea/workflows/linux-release-build.yml - │ pinned Linux export/package/smoke artifact job; no release publication + │ pinned Linux export/package/smoke artifact job plus tag-only promotion ├── run-godot.ps1 / run-godot.cmd tracked development launcher + Windows wrapper; │ resolves Godot/game-root from parameters, environment, │ PATH, and the conventional sibling install @@ -48,12 +48,14 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings) │ ├── test_validate.py pure resolver + validation-plan regressions │ ├── build-linux-x64.sh one-command pinned Linux export/package/smoke pipeline │ ├── dotnet_publish_proxy.py guarded low-memory Godot export-time publish staging + │ ├── publish_gitea_release.py guarded/retry-safe native Gitea release promotion │ ├── bootstrap-godot-linux-x64.sh repo-local Godot 4.7 Linux editor/template bootstrap │ ├── godot-linux-x64.json immutable Godot editor/template source + SHA-256 manifest │ ├── install_godot_templates.py selective HTTP-range installer for pinned template members │ ├── package_linux_x64.py export verifier + notice/checksum/archive packager │ ├── test_install_godot_templates.py, test_package_linux_x64.py, - │ │ test_dotnet_publish_proxy.py source-only bootstrap/package/publish regressions + │ │ test_dotnet_publish_proxy.py, + │ │ test_publish_gitea_release.py source-only bootstrap/package/publish regressions │ ├── sys4load.py loader + disassembler (opcode-decoding) │ ├── age_opcodes.py GENERATED complete Python ABI view (do not hand-edit) │ ├── opcodes_build.py generator/linter: vm-map/opcodes.toml -> the 5 artifacts below diff --git a/docs/platform-portability.md b/docs/platform-portability.md index 7a475c8..4fe20d5 100644 --- a/docs/platform-portability.md +++ b/docs/platform-portability.md @@ -66,9 +66,18 @@ servers disabled, lets the real Godot export consume a strictly validated one-as the complete external managed payload only after Godot exits. The complete revised path passes locally, including payload verification and the packaged opcode/FFmpeg smoke gate. The hosted retry at `400f431` then completed successfully on 2026-08-03, accepting the mitigation and the complete build/smoke/artifact-upload path on the -target runner. The workflow reports both host memory and cgroup limits for future diagnostics. Release -publication remains a separate tag-only promotion step; it should consume this already-verified artifact rather -than rebuilding it. +target runner. The workflow reports both host memory and cgroup limits for future diagnostics. + +Tag-only release promotion is now part of the same workflow without changing that build trust boundary. The +build job always retains read-only contents permission and uploads one flat five-file artifact: the archive, its +external SHA-256, build metadata, payload checksum ledger, and smoke log. A dependent job +runs only after a successful `v*` tag build, downloads that exact artifact without rebuilding, and receives +job-local `releases: write`. It uses the built-in Gitea job token with the native 1.25 release/attachment API; +branch and manual runs never receive release authority, and no personal secret or third-party release action is +introduced. Promotion rechecks the clean source commit, archive checksum, and accepted smoke result after +artifact download. It is retry-safe but fail-closed: a matching partial release can receive missing assets, +while mismatched release identity or a same-name/different-size asset is never edited, deleted, or overwritten. +The next acceptance gate is a deliberate first version tag and inspection of its five release attachments. ## Dependency inventory diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index 2b88241..fda30df 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -1015,6 +1015,19 @@ do not mix mechanical moves with semantic changes. target Gitea runner. The next bounded slice can promote an already-verified tag artifact to a Gitea release without rebuilding it. + **Tag-only Gitea release promotion (2026-08-03):** the third CI/CD slice adds a dependent promotion job to + that accepted workflow. The Linux build/smoke job remains read-only and uploads one flat five-file artifact: + archive, external archive checksum, build metadata, payload checksum ledger, and smoke evidence. + Only a successful `v*` tag run creates the second job and its job-local `releases: write` token; develop and + manual runs never receive release authority. The promotion downloads the artifact from the completed build + rather than rebuilding, then uses Gitea 1.25's native release and attachment API with the built-in job token. + The project-owned helper verifies the tag/clean-build commit/release identity, archive checksum, accepted + smoke result, and fixed archive/evidence set after download. Retries may + complete missing attachments on a matching partial release, but mismatches and same-name/different-size + collisions fail without edit, deletion, or overwrite. Pure creation/resume/refusal regressions and workflow + lint pass locally. The immediate acceptance gate is a deliberately chosen first `v*` tag and inspection of + the resulting release/archive/evidence; no tag is created by this slice. + **Not cleanup targets:** generated `build/` output, the two intentional solution files, historical `docs/superpowers/` plans/specifications, and fidelity-specific complexity that is directly covered by the native ABI. Reorganization is successful when ownership and reproduction become clearer, not when the raw diff --git a/docs/tools-reference.md b/docs/tools-reference.md index d3e416e..75b0a07 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -48,14 +48,27 @@ its tree-equivalent rewritten commit is `524ea74`. The hosted gate also succeede `d673652` on 2026-08-03. The artifact wrapper is `.gitea/workflows/linux-release-build.yml`. It runs independently from the fast core -gate on `develop` pushes, manual dispatch, and `v*` tag pushes. The job uses the same Python 3.11 and +gate on `develop` pushes, manual dispatch, and `v*` tag pushes. The build job uses the same Python 3.11 and `global.json` .NET setup actions, then calls `./tools/build-linux-x64.sh` unchanged. `actions/cache@v4` retains only the two hash-verified dependency archives and the independently hash-verified Linux release template; its key is the combined Godot/FFmpeg manifest hash and deliberately has no prefix fallback. Successful runs upload -the `.tar.gz`, unpacked `BUILD-INFO.json`/`SHA256SUMS`, and packaged-runtime smoke log for 30 days. Failed runs -retain any smoke/validation evidence for seven days. The workflow has read-only repository permission, no -secrets or private game corpus, and no release/package publication authority; a tag currently changes only the -build trigger, not the artifact's destination. +one flat artifact containing the `.tar.gz`, its external SHA-256 file, `BUILD-INFO.json`, `SHA256SUMS`, and packaged-runtime smoke log for +30 days. Failed runs retain any smoke/validation evidence for seven days. The build job has read-only repository +permission, no secrets or private game corpus, and no release/package publication authority. + +Only a successful `v*` tag run enables the dependent promotion job. It downloads that same workflow artifact +rather than rebuilding, checks out only the release tool, and receives job-local `releases: write` plus +`contents: read`; branch and manual runs skip the job and never receive release authority. The built-in +`secrets.GITEA_TOKEN` calls the native Gitea API, so no personal token or third-party release action is required. +`publish_gitea_release.py` validates the version-like tag, exact clean-build commit metadata, archive checksum, +accepted package-smoke result, fixed five-file asset set, and any +existing release/asset metadata. A retry resumes a matching partial release and uploads only missing files; it +refuses mismatched releases or same-name/different-size assets instead of editing, deleting, or overwriting them. + +| Tool | Purpose | Run | Reads → Writes | +|---|---|---|---| +| `publish_gitea_release.py` | Tag-only, retry-safe promotion of one verified Linux workflow artifact through Gitea's native release API. Requires `GITEA_TOKEN`; the token is passed to upload `curl` through standard input rather than its argument list. | `publish_gitea_release.py --server --repository --tag --target --asset ` (exactly the five expected `--asset` names) | verified downloaded workflow artifact + tag context → matching Gitea release and five attachments | +| `test_publish_gitea_release.py` | Pure synthetic creation, retry/resume, collision, and mismatch regressions. | `test_publish_gitea_release.py` | temporary files only | ## Optional local binary tools diff --git a/tools/publish_gitea_release.py b/tools/publish_gitea_release.py new file mode 100755 index 0000000..3761c5e --- /dev/null +++ b/tools/publish_gitea_release.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Promote one verified Linux workflow artifact to a matching Gitea release.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Callable, Protocol + + +EXPECTED_ASSETS = frozenset({ + "OpenMaidEngine-Himegari-linux-x64.tar.gz", + "OpenMaidEngine-Himegari-linux-x64.tar.gz.sha256", + "BUILD-INFO.json", + "SHA256SUMS", + "package-smoke.log", +}) +TAG_PATTERN = re.compile(r"v[0-9][0-9A-Za-z.+-]*\Z") +COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}\Z") +TOKEN_PATTERN = re.compile(r"[A-Za-z0-9._-]+\Z") + + +class ReleaseApi(Protocol): + def get_release(self, tag: str) -> dict[str, Any] | None: ... + + def create_release(self, payload: dict[str, Any]) -> dict[str, Any]: ... + + def list_assets(self, release_id: int) -> list[dict[str, Any]]: ... + + +class GiteaApi: + def __init__(self, server: str, repository: str, token: str) -> None: + parsed = urllib.parse.urlsplit(server) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError(f"invalid Gitea server URL: {server}") + if parsed.username or parsed.password or parsed.query or parsed.fragment: + raise ValueError("Gitea server URL must not contain credentials, a query, or a fragment") + parts = repository.split("/") + if len(parts) != 2 or not all(parts): + raise ValueError(f"repository must be owner/name: {repository}") + if not TOKEN_PATTERN.fullmatch(token): + raise ValueError("GITEA_TOKEN is missing or malformed") + owner, name = (urllib.parse.quote(part, safe="") for part in parts) + self.base_url = f"{server.rstrip('/')}/api/v1/repos/{owner}/{name}" + self.token = token + + def _request( + self, + method: str, + path: str, + *, + payload: dict[str, Any] | None = None, + allow_not_found: bool = False, + ) -> dict[str, Any] | list[dict[str, Any]] | None: + data = None if payload is None else json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + self.base_url + path, + data=data, + method=method, + headers={ + "Accept": "application/json", + "Authorization": f"token {self.token}", + "Content-Type": "application/json", + "User-Agent": "OpenMaidEngine-release-promotion", + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + except urllib.error.HTTPError as error: + if allow_not_found and error.code == 404: + return None + detail = error.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Gitea API {method} {path} failed ({error.code}): {detail}") from error + + def get_release(self, tag: str) -> dict[str, Any] | None: + result = self._request( + "GET", + "/releases/tags/" + urllib.parse.quote(tag, safe=""), + allow_not_found=True, + ) + assert result is None or isinstance(result, dict) + return result + + def create_release(self, payload: dict[str, Any]) -> dict[str, Any]: + result = self._request("POST", "/releases", payload=payload) + if not isinstance(result, dict): + raise RuntimeError("Gitea create-release response was not an object") + return result + + def list_assets(self, release_id: int) -> list[dict[str, Any]]: + result = self._request("GET", f"/releases/{release_id}/assets") + if not isinstance(result, list): + raise RuntimeError("Gitea release-assets response was not an array") + return result + + def upload_asset(self, release_id: int, asset: Path) -> dict[str, Any]: + url = ( + f"{self.base_url}/releases/{release_id}/assets?" + + urllib.parse.urlencode({"name": asset.name}) + ) + curl_config = ( + f'header = "Authorization: token {self.token}"\n' + 'header = "Accept: application/json"\n' + ) + process = subprocess.run( + [ + "curl", + "--config", "-", + "--fail-with-body", + "--silent", + "--show-error", + "--request", "POST", + "--form", f"attachment=@{asset}", + url, + ], + input=curl_config, + text=True, + encoding="utf-8", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if process.returncode != 0: + raise RuntimeError( + f"Gitea asset upload failed for {asset.name}: " + f"{process.stderr.strip()} {process.stdout.strip()}".strip() + ) + try: + result = json.loads(process.stdout) + except json.JSONDecodeError as error: + raise RuntimeError(f"Gitea asset upload returned invalid JSON for {asset.name}") from error + if not isinstance(result, dict): + raise RuntimeError(f"Gitea asset upload response was not an object for {asset.name}") + return result + + +def validate_inputs(tag: str, target: str, assets: list[Path]) -> None: + if not TAG_PATTERN.fullmatch(tag): + raise ValueError(f"release tag must be v-prefixed and version-like: {tag}") + if not COMMIT_PATTERN.fullmatch(target): + raise ValueError(f"release target must be a lowercase SHA-1 commit: {target}") + names = [asset.name for asset in assets] + if len(names) != len(set(names)): + raise ValueError("release asset names must be unique") + if set(names) != EXPECTED_ASSETS: + missing = sorted(EXPECTED_ASSETS - set(names)) + extra = sorted(set(names) - EXPECTED_ASSETS) + raise ValueError(f"unexpected release asset set; missing={missing}, extra={extra}") + for asset in assets: + if not asset.is_file(): + raise ValueError(f"release asset was not found: {asset}") + if asset.stat().st_size <= 0: + raise ValueError(f"release asset is empty: {asset}") + + by_name = {asset.name: asset for asset in assets} + try: + build_info = json.loads(by_name["BUILD-INFO.json"].read_text(encoding="utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise ValueError("BUILD-INFO.json is not valid UTF-8 JSON") from error + expected_build_info = { + "schema_version": 1, + "source_commit": target, + "source_dirty": False, + "target": "linux-x64", + } + build_mismatches = { + key: (build_info.get(key), value) + for key, value in expected_build_info.items() + if build_info.get(key) != value + } + if build_mismatches: + raise ValueError(f"BUILD-INFO.json does not match this promotion: {build_mismatches}") + + archive = by_name["OpenMaidEngine-Himegari-linux-x64.tar.gz"] + checksum_path = by_name["OpenMaidEngine-Himegari-linux-x64.tar.gz.sha256"] + checksum_line = checksum_path.read_text(encoding="ascii").strip() + checksum_match = re.fullmatch( + r"([0-9a-f]{64}) OpenMaidEngine-Himegari-linux-x64\.tar\.gz", + checksum_line, + ) + if checksum_match is None: + raise ValueError(f"archive checksum file has an unexpected format: {checksum_path}") + digest = hashlib.sha256() + with archive.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + if digest.hexdigest() != checksum_match.group(1): + raise ValueError("Linux archive does not match its external SHA-256 checksum") + + smoke_log = by_name["package-smoke.log"].read_text(encoding="utf-8") + if "PACKAGE SMOKE OK: opcodes=548 ffmpeg-abi=3" not in smoke_log: + raise ValueError("package-smoke.log does not contain the accepted package smoke result") + + +def _validate_release(release: dict[str, Any], tag: str, target: str, title: str) -> int: + expected = { + "tag_name": tag, + "target_commitish": target, + "name": title, + "draft": False, + "prerelease": False, + } + mismatches = { + key: (release.get(key), value) + for key, value in expected.items() + if release.get(key) != value + } + if mismatches: + raise ValueError(f"existing Gitea release does not match this promotion: {mismatches}") + release_id = release.get("id") + if not isinstance(release_id, int) or release_id <= 0: + raise ValueError("Gitea release has no valid numeric id") + return release_id + + +def promote_release( + api: ReleaseApi, + upload: Callable[[int, Path], dict[str, Any]], + tag: str, + target: str, + assets: list[Path], +) -> dict[str, Any]: + validate_inputs(tag, target, assets) + title = f"OpenMaidEngine Himegari {tag}" + release = api.get_release(tag) + if release is None: + release = api.create_release({ + "tag_name": tag, + "target_commitish": target, + "name": title, + "body": ( + "Automated Linux x64 release built from `" + target + "`.\n\n" + "The attached archive passed the packaged opcode-metadata and FFmpeg ABI smoke gate. " + "BUILD-INFO.json, SHA256SUMS, and package-smoke.log provide the external build evidence." + ), + "draft": False, + "prerelease": False, + }) + release_id = _validate_release(release, tag, target, title) + + existing_assets: dict[str, dict[str, Any]] = {} + for existing in api.list_assets(release_id): + name = existing.get("name") + if isinstance(name, str): + if name in existing_assets: + raise ValueError(f"Gitea release has duplicate asset names: {name}") + existing_assets[name] = existing + + for asset in assets: + existing = existing_assets.get(asset.name) + if existing is not None: + if existing.get("size") != asset.stat().st_size: + raise ValueError( + f"existing release asset differs in size and will not be overwritten: {asset.name}" + ) + continue + uploaded = upload(release_id, asset) + if uploaded.get("name") != asset.name or uploaded.get("size") != asset.stat().st_size: + raise RuntimeError(f"Gitea reported an unexpected uploaded asset: {asset.name}") + + final_assets = {asset.get("name"): asset for asset in api.list_assets(release_id)} + for asset in assets: + published = final_assets.get(asset.name) + if published is None or published.get("size") != asset.stat().st_size: + raise RuntimeError(f"release asset verification failed: {asset.name}") + return release + + +def main(arguments: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--server", required=True) + parser.add_argument("--repository", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--target", required=True) + parser.add_argument("--asset", action="append", required=True, type=Path) + args = parser.parse_args(arguments) + + token = os.environ.get("GITEA_TOKEN", "") + api = GiteaApi(args.server, args.repository, token) + release = promote_release(api, api.upload_asset, args.tag, args.target, args.asset) + print(f"Gitea release ready: {release.get('html_url', args.tag)}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, RuntimeError, ValueError) as error: + print(f"release promotion failed: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/tools/test_publish_gitea_release.py b/tools/test_publish_gitea_release.py new file mode 100755 index 0000000..cfdafdd --- /dev/null +++ b/tools/test_publish_gitea_release.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import json +import tempfile +import unittest +from pathlib import Path +from typing import Any + +import publish_gitea_release + + +TARGET = "0123456789abcdef0123456789abcdef01234567" +TAG = "v0.1.0" + + +class FakeApi: + def __init__(self, release: dict[str, Any] | None = None) -> None: + self.release = release + self.assets: list[dict[str, Any]] = [] + self.created_payload: dict[str, Any] | None = None + + def get_release(self, tag: str) -> dict[str, Any] | None: + self.requested_tag = tag + return self.release + + def create_release(self, payload: dict[str, Any]) -> dict[str, Any]: + self.created_payload = payload + self.release = matching_release() + return self.release + + def list_assets(self, release_id: int) -> list[dict[str, Any]]: + self.requested_release_id = release_id + return list(self.assets) + + def upload(self, release_id: int, asset: Path) -> dict[str, Any]: + uploaded = {"name": asset.name, "size": asset.stat().st_size} + self.assets.append(uploaded) + return uploaded + + +def matching_release() -> dict[str, Any]: + return { + "id": 17, + "tag_name": TAG, + "target_commitish": TARGET, + "name": f"OpenMaidEngine Himegari {TAG}", + "draft": False, + "prerelease": False, + "html_url": "https://gitea.invalid/releases/tag/v0.1.0", + } + + +def create_assets(root: Path) -> list[Path]: + archive = root / "OpenMaidEngine-Himegari-linux-x64.tar.gz" + archive.write_bytes(b"synthetic archive") + checksum = root / "OpenMaidEngine-Himegari-linux-x64.tar.gz.sha256" + checksum.write_text( + f"{hashlib.sha256(archive.read_bytes()).hexdigest()} {archive.name}\n", + encoding="ascii", + ) + build_info = root / "BUILD-INFO.json" + build_info.write_text(json.dumps({ + "schema_version": 1, + "source_commit": TARGET, + "source_dirty": False, + "target": "linux-x64", + }), encoding="utf-8") + ledger = root / "SHA256SUMS" + ledger.write_text(f"{'0' * 64} Himegari.x86_64\n", encoding="ascii") + smoke = root / "package-smoke.log" + smoke.write_text("PACKAGE SMOKE OK: opcodes=548 ffmpeg-abi=3\n", encoding="utf-8") + return [archive, checksum, build_info, ledger, smoke] + + +class PublishGiteaReleaseTests(unittest.TestCase): + def test_creates_release_and_uploads_verified_asset_set(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + assets = create_assets(Path(temporary)) + api = FakeApi() + result = publish_gitea_release.promote_release(api, api.upload, TAG, TARGET, assets) + self.assertEqual(17, result["id"]) + self.assertEqual(TAG, api.created_payload["tag_name"]) + self.assertEqual(TARGET, api.created_payload["target_commitish"]) + self.assertEqual({asset.name for asset in assets}, {asset["name"] for asset in api.assets}) + + def test_retry_keeps_matching_assets_and_uploads_only_missing(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + assets = create_assets(Path(temporary)) + api = FakeApi(matching_release()) + api.assets.append({"name": assets[0].name, "size": assets[0].stat().st_size}) + uploaded: list[str] = [] + + def upload(release_id: int, asset: Path) -> dict[str, Any]: + uploaded.append(asset.name) + return api.upload(release_id, asset) + + publish_gitea_release.promote_release(api, upload, TAG, TARGET, assets) + self.assertNotIn(assets[0].name, uploaded) + self.assertEqual(len(assets) - 1, len(uploaded)) + self.assertIsNone(api.created_payload) + + def test_refuses_mismatched_release_and_asset_collision(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + assets = create_assets(Path(temporary)) + wrong_release = matching_release() + wrong_release["target_commitish"] = "f" * 40 + api = FakeApi(wrong_release) + with self.assertRaisesRegex(ValueError, "does not match"): + publish_gitea_release.promote_release(api, api.upload, TAG, TARGET, assets) + + api = FakeApi(matching_release()) + api.assets.append({"name": assets[0].name, "size": 999}) + with self.assertRaisesRegex(ValueError, "will not be overwritten"): + publish_gitea_release.promote_release(api, api.upload, TAG, TARGET, assets) + + def test_refuses_mismatched_build_evidence(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + assets = create_assets(Path(temporary)) + by_name = {asset.name: asset for asset in assets} + by_name["OpenMaidEngine-Himegari-linux-x64.tar.gz"].write_bytes(b"changed") + with self.assertRaisesRegex(ValueError, "external SHA-256"): + publish_gitea_release.validate_inputs(TAG, TARGET, assets) + + assets = create_assets(Path(temporary)) + by_name = {asset.name: asset for asset in assets} + build_info = json.loads(by_name["BUILD-INFO.json"].read_text(encoding="utf-8")) + build_info["source_commit"] = "f" * 40 + by_name["BUILD-INFO.json"].write_text(json.dumps(build_info), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "does not match"): + publish_gitea_release.validate_inputs(TAG, TARGET, assets) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/validate.py b/tools/validate.py index 3e28a95..7b73e33 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -33,6 +33,7 @@ CORE_TESTS = ( "test_install_godot_templates.py", "test_package_linux_x64.py", "test_dotnet_publish_proxy.py", + "test_publish_gitea_release.py", "test_diff_optrace.py", "test_engine_ctx.py", "test_ghidra_handler_map.py",