Promote verified tag artifacts to Gitea releases
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
299
tools/publish_gitea_release.py
Executable 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)
|
||||||
136
tools/test_publish_gitea_release.py
Executable file
136
tools/test_publish_gitea_release.py
Executable 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()
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user