Cross-export Windows release package on Linux
All checks were successful
Core validation / Linux core gate (push) Successful in 1m40s
Linux release build / Linux x64 artifact (push) Successful in 1m37s
Linux release build / Publish tagged Gitea release (push) Has been skipped

This commit is contained in:
gamer147
2026-08-03 21:59:52 -04:00
parent 60ac6fc64f
commit 02e9891baa
19 changed files with 690 additions and 32 deletions

View File

@@ -4,6 +4,7 @@ set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "$script_dir/.." && pwd)"
manifest="$script_dir/godot-linux-x64.json"
template_target="${1:-linux-x64}"
toolchain_root="$repo_root/build/toolchains/godot-4.7-stable-mono-linux-x64"
xdg_data_home="$toolchain_root/xdg-data"
download_dir="$repo_root/build/downloads"
@@ -30,6 +31,15 @@ archive_path="$download_dir/$archive"
editor_root="$toolchain_root/editor"
template_root="$xdg_data_home/godot/export_templates/$template_version"
case "$template_target" in
linux-x64) template_name="linux_release.x86_64" ;;
windows-x64) template_name="windows_release_x86_64.exe" ;;
*)
echo "unsupported Godot template target: $template_target" >&2
exit 2
;;
esac
mkdir -p -- "$download_dir" "$editor_root" "$template_root"
if [[ ! -f "$archive_path" ]]; then
curl --fail --location --retry 3 --output "$archive_path" "$url"
@@ -59,7 +69,7 @@ editor="${editors[0]}"
chmod +x "$editor"
python3 -X utf8 "$script_dir/install_godot_templates.py" \
--manifest "$manifest" --destination "$template_root" >&2
--manifest "$manifest" --destination "$template_root" --member "$template_name" >&2
reported="$(XDG_DATA_HOME="$xdg_data_home" "$editor" --headless --version)"
reported="${reported%%$'\n'*}"

View File

@@ -94,6 +94,7 @@ AGE_PUBLISH_PROJECT="$project_root/Himegari.csproj" \
AGE_PREPUBLISHED_OUTPUT="$managed_proxy_directory" \
AGE_PUBLISH_OUTPUT_ROOT="${TMPDIR:-/tmp}/godot-publish-dotnet" \
AGE_PUBLISH_ASSEMBLY="Himegari.dll" \
AGE_PUBLISH_RUNTIME="linux-x64" \
PATH="$dotnet_proxy_directory:$PATH" \
XDG_DATA_HOME="$xdg_data_home" "$godot_console" \
--headless --quit-after 120 --path "$project_root" \

129
tools/build-windows-x64.sh Executable file
View File

@@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd -- "$script_dir/.." && pwd)"
project_root="$repo_root/godot"
export_directory="$repo_root/build/export/windows-x64"
expected_export_directory="$repo_root/build/export/windows-x64"
package_directory="$repo_root/build/package/windows-x64"
managed_publish_directory="$repo_root/build/managed-publish/win-x64"
managed_proxy_directory="$repo_root/build/managed-publish-proxy/win-x64"
dotnet_proxy_directory="$repo_root/build/dotnet-export-proxy"
toolchain_root="$repo_root/build/toolchains/godot-4.7-stable-mono-linux-x64"
xdg_data_home="$toolchain_root/xdg-data"
compiler="${MINGW_CC:-x86_64-w64-mingw32-gcc}"
objdump="${MINGW_OBJDUMP:-x86_64-w64-mingw32-objdump}"
for command in python3 dotnet "$compiler" "$objdump" curl sha256sum awk; do
command -v "$command" >/dev/null 2>&1 || {
echo "required command was not found: $command" >&2
exit 1
}
done
python3 -X utf8 "$script_dir/opcodes_build.py" --build
godot_console="$("$script_dir/bootstrap-godot-linux-x64.sh" windows-x64)"
ffmpeg_sdk="$("$repo_root/native/age_movie_ffmpeg/bootstrap-win64.sh")"
"$repo_root/native/age_movie_ffmpeg/build-win64.sh" "$ffmpeg_sdk"
# Keep the memory-heavy self-contained publish outside the resident Godot editor, as on Linux.
expected_managed_publish_directory="$repo_root/build/managed-publish/win-x64"
if [[ "$managed_publish_directory" != "$expected_managed_publish_directory" || "$managed_publish_directory" == "/" ]]; then
echo "refusing to replace unexpected managed publish directory: $managed_publish_directory" >&2
exit 1
fi
rm -rf -- "$managed_publish_directory"
mkdir -p -- "$managed_publish_directory"
DOTNET_CLI_USE_MSBUILD_SERVER=0 \
MSBUILDDISABLENODEREUSE=1 \
DOTNET_gcServer=0 \
DOTNET_GCConserveMemory=9 \
dotnet publish "$project_root/Himegari.csproj" \
--configuration ExportRelease \
--runtime win-x64 \
--self-contained true \
--output "$managed_publish_directory" \
-p:GodotTargetPlatform=windows \
-p:UseSharedCompilation=false \
-p:BuildInParallel=false \
-p:RestoreDisableParallel=true \
-p:DebugType=None \
-p:DebugSymbols=false
expected_managed_proxy_directory="$repo_root/build/managed-publish-proxy/win-x64"
if [[ "$managed_proxy_directory" != "$expected_managed_proxy_directory" || "$managed_proxy_directory" == "/" ]]; then
echo "refusing to replace unexpected managed proxy directory: $managed_proxy_directory" >&2
exit 1
fi
rm -rf -- "$managed_proxy_directory"
mkdir -p -- "$managed_proxy_directory"
cp -- "$managed_publish_directory/Himegari.dll" "$managed_proxy_directory/Himegari.dll"
real_dotnet="$(command -v dotnet)"
expected_dotnet_proxy_directory="$repo_root/build/dotnet-export-proxy"
if [[ "$dotnet_proxy_directory" != "$expected_dotnet_proxy_directory" || "$dotnet_proxy_directory" == "/" ]]; then
echo "refusing to replace unexpected dotnet proxy directory: $dotnet_proxy_directory" >&2
exit 1
fi
rm -rf -- "$dotnet_proxy_directory"
mkdir -p -- "$dotnet_proxy_directory"
cp -- "$script_dir/dotnet_publish_proxy.py" "$dotnet_proxy_directory/dotnet"
chmod +x "$dotnet_proxy_directory/dotnet"
if [[ "$export_directory" != "$expected_export_directory" || "$export_directory" == "/" ]]; then
echo "refusing to replace unexpected export directory: $export_directory" >&2
exit 1
fi
rm -rf -- "$export_directory"
mkdir -p -- "$export_directory"
set +e
DOTNET_CLI_USE_MSBUILD_SERVER=0 \
MSBUILDDISABLENODEREUSE=1 \
UseSharedCompilation=false \
BuildInParallel=false \
RestoreDisableParallel=true \
DOTNET_gcServer=0 \
DOTNET_GCConserveMemory=9 \
AGE_REAL_DOTNET="$real_dotnet" \
AGE_PUBLISH_PROJECT="$project_root/Himegari.csproj" \
AGE_PREPUBLISHED_OUTPUT="$managed_proxy_directory" \
AGE_PUBLISH_OUTPUT_ROOT="${TMPDIR:-/tmp}/godot-publish-dotnet" \
AGE_PUBLISH_ASSEMBLY="Himegari.dll" \
AGE_PUBLISH_RUNTIME="win-x64" \
PATH="$dotnet_proxy_directory:$PATH" \
XDG_DATA_HOME="$xdg_data_home" "$godot_console" \
--headless --quit-after 120 --path "$project_root" \
--export-release "Windows x86_64" "$export_directory/Himegari.exe"
export_status=$?
set -e
if [[ $export_status -ne 0 ]]; then
if [[ $export_status -eq 137 ]]; then
echo "Godot export was killed with SIGKILL (137); inspect the runner memory cgroup diagnostics." >&2
fi
exit "$export_status"
fi
managed_export_directory="$export_directory/data_Himegari_windows_x86_64"
expected_managed_export_directory="$repo_root/build/export/windows-x64/data_Himegari_windows_x86_64"
if [[ "$managed_export_directory" != "$expected_managed_export_directory" || "$managed_export_directory" == "/" ]]; then
echo "refusing to replace unexpected managed export directory: $managed_export_directory" >&2
exit 1
fi
rm -rf -- "$managed_export_directory"
mkdir -p -- "$managed_export_directory"
cp -a -- "$managed_publish_directory/." "$managed_export_directory/"
python3 -X utf8 "$script_dir/package_windows_x64.py" verify "$export_directory" --objdump "$objdump"
mapfile -t package_outputs < <(
python3 -X utf8 "$script_dir/package_windows_x64.py" package "$export_directory" \
--objdump "$objdump" --output-directory "$package_directory"
)
if [[ ${#package_outputs[@]} -ne 2 ]]; then
echo "packager returned an unexpected result" >&2
exit 1
fi
archive_path="${package_outputs[1]}"
archive_hash="$(sha256sum "$archive_path" | awk '{ print $1 }')"
printf 'Windows x64 package: %s\nSHA-256: %s\n' "$archive_path" "$archive_hash"

View File

@@ -76,13 +76,14 @@ def stage_publish(request: PublishRequest, environ: dict[str, str]) -> Path:
source = Path(environ["AGE_PREPUBLISHED_OUTPUT"]).resolve()
output_root = Path(environ["AGE_PUBLISH_OUTPUT_ROOT"]).resolve()
assembly = environ["AGE_PUBLISH_ASSEMBLY"]
expected_runtime = environ["AGE_PUBLISH_RUNTIME"]
output = request.output.resolve()
if request.project.resolve() != expected_project:
raise ValueError(f"unexpected publish project: {request.project}")
if request.configuration != "ExportRelease":
raise ValueError(f"unexpected publish configuration: {request.configuration}")
if request.runtime != "linux-x64":
if request.runtime != expected_runtime:
raise ValueError(f"unexpected publish runtime: {request.runtime}")
if request.self_contained.lower() != "true":
raise ValueError(f"publish is not self-contained: {request.self_contained}")

View File

@@ -21,6 +21,13 @@
"sha256": "26b1b5d076b78c02f1476dab78e183218a6aa30c8e614653eac11a0dd198456a",
"size": 73591000,
"mode": "0755"
},
{
"archive_path": "templates/windows_release_x86_64.exe",
"install_name": "windows_release_x86_64.exe",
"sha256": "00c72494d58536b74b95d5044fa13c4cc54f2349d687b87f3d8801897741fac0",
"size": 109405184,
"mode": "0755"
}
]
}

View File

@@ -160,11 +160,21 @@ def file_matches(path: Path, expected_size: int, expected_hash: str) -> bool:
return digest.hexdigest() == expected_hash.lower()
def install_members(manifest_path: Path, destination: Path) -> list[Path]:
def install_members(
manifest_path: Path,
destination: Path,
selected_names: tuple[str, ...] | None = None,
) -> list[Path]:
with manifest_path.open(encoding="utf-8") as stream:
manifest = json.load(stream)
templates = manifest["templates"]
members = templates["members"]
if selected_names:
by_name = {member["install_name"]: member for member in members}
unknown = [name for name in selected_names if name not in by_name]
if unknown:
raise ValueError("template members were not found in the manifest: " + ", ".join(unknown))
members = [by_name[name] for name in selected_names]
destination.mkdir(parents=True, exist_ok=True)
installed = [destination / member["install_name"] for member in members]
@@ -211,8 +221,10 @@ def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--destination", type=Path, required=True)
parser.add_argument("--member", action="append", dest="members")
args = parser.parse_args()
for installed in install_members(args.manifest.resolve(), args.destination.resolve()):
selected = tuple(args.members) if args.members else None
for installed in install_members(args.manifest.resolve(), args.destination.resolve(), selected):
print(installed)
return 0

248
tools/package_windows_x64.py Executable file
View File

@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""Verify and package the Windows x64 Godot export without executing it."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import time
import zipfile
from pathlib import Path
from typing import Any
import verify_windows_native
HERE = Path(__file__).resolve().parent
REPO = HERE.parent
MANAGED_DIRECTORY = "data_Himegari_windows_x86_64"
PACKAGE_NAME = "OpenMaidEngine-Himegari-windows-x64"
REQUIRED_FILES = (
"Himegari.exe",
"Himegari.pck",
f"{MANAGED_DIRECTORY}/Himegari.dll",
f"{MANAGED_DIRECTORY}/Age.Engine.dll",
f"{MANAGED_DIRECTORY}/Age.Engine.Frontend.dll",
f"{MANAGED_DIRECTORY}/Age.Engine.Text.Windows.dll",
f"{MANAGED_DIRECTORY}/coreclr.dll",
f"{MANAGED_DIRECTORY}/hostfxr.dll",
f"{MANAGED_DIRECTORY}/hostpolicy.dll",
f"{MANAGED_DIRECTORY}/System.Private.CoreLib.dll",
*(f"{MANAGED_DIRECTORY}/{name}" for name in verify_windows_native.REQUIRED_FILES),
)
FORBIDDEN_FILES = (
"Himegari.x86_64",
f"{MANAGED_DIRECTORY}/libage_movie_ffmpeg.so",
f"{MANAGED_DIRECTORY}/libavformat.so.62",
)
PROJECT_FILES = ("LICENSE", "README.md", "THIRD_PARTY_NOTICES.md")
def _resolved_directory(path: Path, label: str) -> Path:
resolved = path.resolve()
if not resolved.is_dir():
raise ValueError(f"{label} directory was not found: {resolved}")
return resolved
def verify_export(export_directory: Path, objdump: str) -> tuple[Path, dict[str, Any]]:
export_directory = _resolved_directory(export_directory, "Windows export")
missing = [name for name in REQUIRED_FILES if not (export_directory / name).is_file()]
if missing:
raise ValueError("Windows export is incomplete; missing: " + ", ".join(missing))
forbidden = [name for name in FORBIDDEN_FILES if (export_directory / name).exists()]
forbidden.extend(
path.relative_to(export_directory).as_posix()
for path in export_directory.rglob("*")
if path.is_file() and (path.name.endswith(".so") or ".so." in path.name)
)
if forbidden:
raise ValueError("Windows export contains Linux-only files: " + ", ".join(sorted(set(forbidden))))
executable_machine = verify_windows_native.pe_machine(export_directory / "Himegari.exe")
if executable_machine != verify_windows_native.PE_MACHINE_AMD64:
raise ValueError(
f"Windows executable is not AMD64 PE (0x{executable_machine:04x}): Himegari.exe"
)
native_report = verify_windows_native.verify_bundle(
export_directory / MANAGED_DIRECTORY,
objdump,
exact_dlls=False,
)
native_report["bundle"] = MANAGED_DIRECTORY
return export_directory, {
"schema_version": 1,
"target": "win-x64",
"executable_machine": "AMD64",
"native": native_report,
}
def _git(*arguments: str) -> str:
result = subprocess.run(
("git", *arguments), cwd=REPO, check=True, capture_output=True, text=True
)
return result.stdout.strip()
def source_date_epoch() -> int:
configured = os.environ.get("SOURCE_DATE_EPOCH")
value = configured or _git("show", "-s", "--format=%ct", "HEAD")
try:
epoch = int(value)
except ValueError as error:
raise ValueError(f"invalid SOURCE_DATE_EPOCH: {value!r}") from error
if epoch < 0:
raise ValueError("SOURCE_DATE_EPOCH must not be negative")
return epoch
def build_metadata(epoch: int) -> dict[str, object]:
with (HERE / "godot-linux-x64.json").open(encoding="utf-8") as stream:
godot = json.load(stream)
windows_template = next(
member
for member in godot["templates"]["members"]
if member["install_name"] == "windows_release_x86_64.exe"
)
with (REPO / "native/age_movie_ffmpeg/dependency-win64.json").open(
encoding="utf-8"
) as stream:
ffmpeg = json.load(stream)
return {
"schema_version": 1,
"project": "OpenMaidEngine Himegari profile",
"target": "win-x64",
"source_commit": _git("rev-parse", "HEAD"),
"source_dirty": bool(_git("status", "--porcelain", "--untracked-files=normal")),
"source_date_epoch": epoch,
"godot": {
"version": godot["godot_version"],
"editor_archive_sha256": godot["editor"]["sha256"],
"release_template_sha256": windows_template["sha256"],
},
"ffmpeg": {
"provider": ffmpeg["provider"],
"version": ffmpeg["ffmpeg_version"],
"archive_sha256": ffmpeg["sha256"],
},
}
def _hash(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _write_text(path: Path, content: str) -> None:
with path.open("w", encoding="utf-8", newline="\n") as stream:
stream.write(content)
def _write_checksums(package_root: Path) -> None:
checksum_path = package_root / "SHA256SUMS"
files = sorted(
path for path in package_root.rglob("*")
if path.is_file() and path != checksum_path
)
lines = [f"{_hash(path)} {path.relative_to(package_root).as_posix()}" for path in files]
_write_text(checksum_path, "\n".join(lines) + "\n")
def _zip_timestamp(epoch: int) -> tuple[int, int, int, int, int, int]:
minimum = 315532800 # 1980-01-01, the earliest ZIP timestamp.
maximum = 4354819198 # 2107-12-31 23:59:58, the latest portable ZIP timestamp.
stamp = time.gmtime(max(minimum, min(epoch, maximum)))[:6]
return (*stamp[:5], stamp[5] - stamp[5] % 2)
def create_package(
export_directory: Path,
output_directory: Path,
metadata: dict[str, object],
verification: dict[str, Any],
epoch: int,
) -> tuple[Path, Path]:
export_directory = _resolved_directory(export_directory, "Windows export")
output_directory = output_directory.resolve()
expected_parent = (REPO / "build/package/windows-x64").resolve()
if output_directory != expected_parent:
raise ValueError(f"refusing to replace unexpected package directory: {output_directory}")
staging_parent = output_directory / "staging"
package_root = staging_parent / PACKAGE_NAME
archive_path = output_directory / f"{PACKAGE_NAME}.zip"
if staging_parent.exists():
shutil.rmtree(staging_parent)
staging_parent.mkdir(parents=True)
shutil.copytree(export_directory, package_root, copy_function=shutil.copy2)
for name in PROJECT_FILES:
source = REPO / name
if not source.is_file():
raise ValueError(f"package notice file was not found: {source}")
shutil.copy2(source, package_root / name)
_write_text(
package_root / "BUILD-INFO.json",
json.dumps(metadata, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
)
_write_text(
package_root / "WINDOWS-VERIFICATION.json",
json.dumps(verification, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
)
_write_checksums(package_root)
output_directory.mkdir(parents=True, exist_ok=True)
temporary_archive = archive_path.with_suffix(archive_path.suffix + ".part")
timestamp = _zip_timestamp(epoch)
with zipfile.ZipFile(
temporary_archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9
) as archive:
for path in sorted(item for item in package_root.rglob("*") if item.is_file()):
relative = path.relative_to(staging_parent).as_posix()
info = zipfile.ZipInfo(relative, timestamp)
info.create_system = 3
info.external_attr = 0o100644 << 16
info.compress_type = zipfile.ZIP_DEFLATED
with path.open("rb") as source, archive.open(info, "w", force_zip64=True) as target:
shutil.copyfileobj(source, target, length=1024 * 1024)
os.replace(temporary_archive, archive_path)
return package_root, archive_path
def main() -> int:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
verify = subparsers.add_parser("verify")
verify.add_argument("export_directory", type=Path)
verify.add_argument("--objdump", default="x86_64-w64-mingw32-objdump")
package = subparsers.add_parser("package")
package.add_argument("export_directory", type=Path)
package.add_argument("--objdump", default="x86_64-w64-mingw32-objdump")
package.add_argument(
"--output-directory", type=Path, default=REPO / "build/package/windows-x64"
)
args = parser.parse_args()
export_directory, verification = verify_export(args.export_directory, args.objdump)
if args.command == "verify":
print(export_directory)
return 0
epoch = source_date_epoch()
package_root, archive_path = create_package(
export_directory,
args.output_directory,
build_metadata(epoch),
verification,
epoch,
)
print(package_root)
print(archive_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -30,6 +30,7 @@ class DotnetPublishProxyTests(unittest.TestCase):
"AGE_PREPUBLISHED_OUTPUT": str(source),
"AGE_PUBLISH_OUTPUT_ROOT": str(output_root),
"AGE_PUBLISH_ASSEMBLY": "Himegari.dll",
"AGE_PUBLISH_RUNTIME": "linux-x64",
})
self.assertEqual(output.resolve(), staged)
self.assertEqual(b"managed", (staged / "Himegari.dll").read_bytes())
@@ -48,6 +49,7 @@ class DotnetPublishProxyTests(unittest.TestCase):
"AGE_PREPUBLISHED_OUTPUT": str(source),
"AGE_PUBLISH_OUTPUT_ROOT": str(root / "reserved"),
"AGE_PUBLISH_ASSEMBLY": "Himegari.dll",
"AGE_PUBLISH_RUNTIME": "linux-x64",
}
wrong_runtime = dotnet_publish_proxy.PublishRequest(
project, "ExportRelease", "win-x64", "true", root / "reserved/output"
@@ -60,6 +62,27 @@ class DotnetPublishProxyTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "outside"):
dotnet_publish_proxy.stage_publish(escaped, environment)
def test_stages_windows_publish_when_explicitly_selected(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
project = root / "Himegari.csproj"
project.write_text("<Project />", encoding="utf-8")
source = root / "prepublished"
source.mkdir()
(source / "Himegari.dll").write_bytes(b"managed")
output = root / "reserved/123-ExportRelease-win-x64"
request = dotnet_publish_proxy.PublishRequest(
project, "ExportRelease", "win-x64", "true", output
)
staged = dotnet_publish_proxy.stage_publish(request, {
"AGE_PUBLISH_PROJECT": str(project),
"AGE_PREPUBLISHED_OUTPUT": str(source),
"AGE_PUBLISH_OUTPUT_ROOT": str(root / "reserved"),
"AGE_PUBLISH_ASSEMBLY": "Himegari.dll",
"AGE_PUBLISH_RUNTIME": "win-x64",
})
self.assertEqual(output.resolve(), staged)
if __name__ == "__main__":
unittest.main()

View File

@@ -53,27 +53,52 @@ class RangeReaderTests(unittest.TestCase):
"templates": {
"url": "https://invalid.example/templates.tpz",
"size": len(archive_bytes),
"members": [{
"archive_path": "templates/linux_release.x86_64",
"install_name": "linux_release.x86_64",
"sha256": hashlib.sha256(release).hexdigest(),
"size": len(release),
"mode": "0755",
}],
"members": [
{
"archive_path": "templates/linux_release.x86_64",
"install_name": "linux_release.x86_64",
"sha256": hashlib.sha256(release).hexdigest(),
"size": len(release),
"mode": "0755",
},
{
"archive_path": "templates/windows_release_x86_64.exe",
"install_name": "windows_release_x86_64.exe",
"sha256": hashlib.sha256(unused).hexdigest(),
"size": len(unused),
"mode": "0755",
},
],
}
}), encoding="utf-8")
destination = root / "templates"
with patch.object(install_godot_templates, "open_http_range_reader", return_value=reader()) as opened:
installed = install_godot_templates.install_members(manifest, destination)
installed = install_godot_templates.install_members(
manifest, destination, ("linux_release.x86_64",)
)
opened.assert_called_once()
self.assertEqual(release, installed[0].read_bytes())
self.assertFalse((destination / "windows_release_x86_64.exe").exists())
with patch.object(install_godot_templates, "open_http_range_reader") as opened:
reused = install_godot_templates.install_members(manifest, destination)
reused = install_godot_templates.install_members(
manifest, destination, ("linux_release.x86_64",)
)
opened.assert_not_called()
self.assertEqual(installed, reused)
def test_rejects_unknown_selected_member(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
manifest = root / "manifest.json"
manifest.write_text(json.dumps({
"templates": {"members": [], "url": "unused", "size": 1}
}), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "not found in the manifest"):
install_godot_templates.install_members(
manifest, root / "templates", ("missing-template",)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
from __future__ import annotations
import hashlib
import struct
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
import package_windows_x64
import verify_windows_native
def write_pe(path: Path, machine: int = verify_windows_native.PE_MACHINE_AMD64) -> None:
data = bytearray(128)
data[:2] = b"MZ"
struct.pack_into("<I", data, 0x3C, 64)
data[64:68] = b"PE\0\0"
struct.pack_into("<H", data, 68, machine)
path.write_bytes(data)
def objdump_text() -> str:
lines = [f"DLL Name: {name}" for name in verify_windows_native.RUNTIME_DLLS]
lines.extend(f"[ 0] {name}" for name in verify_windows_native.REQUIRED_EXPORTS)
return "\n".join(lines)
def make_export(root: Path) -> Path:
export = root / "export"
for relative in package_windows_x64.REQUIRED_FILES:
path = export / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(f"fixture:{relative}".encode())
write_pe(export / "Himegari.exe")
managed = export / package_windows_x64.MANAGED_DIRECTORY
for name in (verify_windows_native.SHIM, *verify_windows_native.RUNTIME_DLLS):
write_pe(managed / name)
return export
class PackageWindowsX64Tests(unittest.TestCase):
def test_verify_rejects_missing_linux_and_wrong_machine_files(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
export = make_export(Path(temporary))
completed = Mock(returncode=0, stdout=objdump_text(), stderr="")
with patch.object(verify_windows_native.subprocess, "run", return_value=completed):
package_windows_x64.verify_export(export, "objdump")
(export / package_windows_x64.REQUIRED_FILES[-1]).unlink()
with self.assertRaisesRegex(ValueError, "missing"):
package_windows_x64.verify_export(export, "objdump")
(export / package_windows_x64.REQUIRED_FILES[-1]).write_bytes(b"restored")
linux = export / package_windows_x64.MANAGED_DIRECTORY / "stale.so"
linux.write_bytes(b"linux")
with self.assertRaisesRegex(ValueError, "Linux-only"):
package_windows_x64.verify_export(export, "objdump")
linux.unlink()
write_pe(export / "Himegari.exe", machine=0x014C)
with self.assertRaisesRegex(ValueError, "not AMD64"):
package_windows_x64.verify_export(export, "objdump")
def test_package_has_notices_reports_checksums_and_deterministic_zip(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
temporary_root = Path(temporary)
export = make_export(temporary_root)
output = temporary_root / "build/package/windows-x64"
metadata = {"schema_version": 1, "source_commit": "fixture"}
verification = {"schema_version": 1, "target": "win-x64"}
with patch.object(package_windows_x64, "REPO", temporary_root), patch.object(
package_windows_x64, "PROJECT_FILES", ("LICENSE", "THIRD_PARTY_NOTICES.md")
):
(temporary_root / "LICENSE").write_text("MIT\n", encoding="utf-8")
(temporary_root / "THIRD_PARTY_NOTICES.md").write_text(
"notices\n", encoding="utf-8"
)
package_windows_x64.create_package(
export, output, metadata, verification, 123456789
)
archive = output / f"{package_windows_x64.PACKAGE_NAME}.zip"
first = hashlib.sha256(archive.read_bytes()).hexdigest()
package_root, archive = package_windows_x64.create_package(
export, output, metadata, verification, 123456789
)
second = hashlib.sha256(archive.read_bytes()).hexdigest()
self.assertEqual(first, second)
self.assertTrue((package_root / "LICENSE").is_file())
self.assertTrue((package_root / "BUILD-INFO.json").is_file())
self.assertTrue((package_root / "WINDOWS-VERIFICATION.json").is_file())
checksums = (package_root / "SHA256SUMS").read_text(encoding="utf-8")
self.assertIn("Himegari.exe", checksums)
self.assertIn("THIRD_PARTY_NOTICES.md", checksums)
if __name__ == "__main__":
unittest.main()

View File

@@ -32,6 +32,7 @@ CORE_TESTS = (
"test_validate.py",
"test_install_godot_templates.py",
"test_package_linux_x64.py",
"test_package_windows_x64.py",
"test_dotnet_publish_proxy.py",
"test_publish_gitea_release.py",
"test_verify_windows_native.py",

View File

@@ -59,7 +59,12 @@ def parse_objdump(output: str) -> tuple[set[str], set[str]]:
return imports, exports
def verify_bundle(bundle: Path, objdump: str) -> dict[str, Any]:
def verify_bundle(
bundle: Path,
objdump: str,
*,
exact_dlls: bool = True,
) -> dict[str, Any]:
bundle = bundle.resolve()
if not bundle.is_dir():
raise ValueError(f"Windows native bundle directory was not found: {bundle}")
@@ -73,7 +78,7 @@ def verify_bundle(bundle: Path, objdump: str) -> dict[str, Any]:
for path in bundle.iterdir()
if path.is_file() and path.suffix.lower() == ".dll" and path.name.lower() not in expected_dlls
)
if unexpected_dlls:
if exact_dlls and unexpected_dlls:
raise ValueError("Windows native bundle has unexpected DLLs: " + ", ".join(unexpected_dlls))
machines: dict[str, str] = {}