Promote verified tag artifacts to Gitea releases
All checks were successful
Core validation / Linux core gate (push) Successful in 1m12s
Linux release build / Linux x64 artifact (push) Successful in 1m36s
Linux release build / Publish tagged Gitea release (push) Has been skipped

This commit is contained in:
gamer147
2026-08-03 19:28:51 -04:00
parent 51ce6cfca9
commit f0f5f12ffc
8 changed files with 537 additions and 15 deletions

View File

@@ -66,15 +66,21 @@ jobs:
- name: Build and smoke-test Linux package - name: Build and smoke-test Linux package
run: ./tools/build-linux-x64.sh 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 - name: Upload verified Linux package
uses: christopherhx/gitea-upload-artifact@v4 uses: christopherhx/gitea-upload-artifact@v4
with: with:
name: OpenMaidEngine-Himegari-linux-x64-${{ gitea.sha }} name: OpenMaidEngine-Himegari-linux-x64-${{ gitea.sha }}
path: | path: build/workflow-artifact/*
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
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
@@ -88,3 +94,46 @@ jobs:
build/validation/** build/validation/**
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 7 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

View File

@@ -36,7 +36,7 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
│ tracked source and generated references use canonical LF │ 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/core-validation.yml read-only Ubuntu source-only core gate for the private Gitea remote
├── .gitea/workflows/linux-release-build.yml ├── .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; ├── run-godot.ps1 / run-godot.cmd tracked development launcher + Windows wrapper;
│ resolves Godot/game-root from parameters, environment, │ resolves Godot/game-root from parameters, environment,
│ PATH, and the conventional sibling install │ 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 │ ├── test_validate.py pure resolver + validation-plan regressions
│ ├── build-linux-x64.sh one-command pinned Linux export/package/smoke pipeline │ ├── build-linux-x64.sh one-command pinned Linux export/package/smoke pipeline
│ ├── dotnet_publish_proxy.py guarded low-memory Godot export-time publish staging │ ├── 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 │ ├── 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 │ ├── godot-linux-x64.json immutable Godot editor/template source + SHA-256 manifest
│ ├── install_godot_templates.py selective HTTP-range installer for pinned template members │ ├── install_godot_templates.py selective HTTP-range installer for pinned template members
│ ├── package_linux_x64.py export verifier + notice/checksum/archive packager │ ├── package_linux_x64.py export verifier + notice/checksum/archive packager
│ ├── test_install_godot_templates.py, test_package_linux_x64.py, │ ├── 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) │ ├── sys4load.py loader + disassembler (opcode-decoding)
│ ├── age_opcodes.py GENERATED complete Python ABI view (do not hand-edit) │ ├── 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 │ ├── opcodes_build.py generator/linter: vm-map/opcodes.toml -> the 5 artifacts below

View File

@@ -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 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 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 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 target runner. The workflow reports both host memory and cgroup limits for future diagnostics.
publication remains a separate tag-only promotion step; it should consume this already-verified artifact rather
than rebuilding it. 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 ## Dependency inventory

View File

@@ -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 target Gitea runner. The next bounded slice can promote an already-verified tag artifact to a Gitea release
without rebuilding it. 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 **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 `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 native ABI. Reorganization is successful when ownership and reproduction become clearer, not when the raw

View File

@@ -48,14 +48,27 @@ its tree-equivalent rewritten commit is `524ea74`. The hosted gate also succeede
`d673652` on 2026-08-03. `d673652` on 2026-08-03.
The artifact wrapper is `.gitea/workflows/linux-release-build.yml`. It runs independently from the fast core 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 `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 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 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 one flat artifact containing the `.tar.gz`, its external SHA-256 file, `BUILD-INFO.json`, `SHA256SUMS`, and packaged-runtime smoke log for
retain any smoke/validation evidence for seven days. The workflow has read-only repository permission, no 30 days. Failed runs retain any smoke/validation evidence for seven days. The build job has read-only repository
secrets or private game corpus, and no release/package publication authority; a tag currently changes only the permission, no secrets or private game corpus, and no release/package publication authority.
build trigger, not the artifact's destination.
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 <url> --repository <owner/name> --tag <v...> --target <sha> --asset <path>` (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 ## Optional local binary tools

299
tools/publish_gitea_release.py Executable file
View File

@@ -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)

View File

@@ -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()

View File

@@ -33,6 +33,7 @@ CORE_TESTS = (
"test_install_godot_templates.py", "test_install_godot_templates.py",
"test_package_linux_x64.py", "test_package_linux_x64.py",
"test_dotnet_publish_proxy.py", "test_dotnet_publish_proxy.py",
"test_publish_gitea_release.py",
"test_diff_optrace.py", "test_diff_optrace.py",
"test_engine_ctx.py", "test_engine_ctx.py",
"test_ghidra_handler_map.py", "test_ghidra_handler_map.py",