Compare commits
5 Commits
60ac6fc64f
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93d8236371 | ||
|
|
d657c63e57 | ||
|
|
4799b48b9c | ||
|
|
9d3ab30bbb | ||
|
|
02e9891baa |
@@ -1,4 +1,4 @@
|
|||||||
name: Linux release build
|
name: Release builds
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -12,7 +12,7 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: linux-release-${{ gitea.ref }}
|
group: release-builds-${{ gitea.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -95,12 +95,101 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
windows-release:
|
||||||
|
name: Windows x64 artifact
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
env:
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||||
|
DOTNET_NOLOGO: "true"
|
||||||
|
NUGET_XMLDOC_MODE: skip
|
||||||
|
PYTHONUTF8: "1"
|
||||||
|
MINGW_CC: x86_64-w64-mingw32-gcc-posix
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
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: Set up .NET SDK
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
global-json-file: global.json
|
||||||
|
|
||||||
|
- name: Install MinGW cross-toolchain
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
||||||
|
gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64
|
||||||
|
|
||||||
|
- name: Restore immutable Windows inputs
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
build/downloads/Godot_*_mono_linux_x86_64.zip
|
||||||
|
build/downloads/ffmpeg-*-win64-lgpl-shared-*.zip
|
||||||
|
build/toolchains/godot-*/xdg-data/godot/export_templates/*/windows_release_x86_64.exe
|
||||||
|
key: windows-release-inputs-${{ hashFiles('tools/godot-linux-x64.json', 'native/age_movie_ffmpeg/dependency-win64.json') }}
|
||||||
|
|
||||||
|
- name: Report toolchain
|
||||||
|
run: |
|
||||||
|
python --version
|
||||||
|
dotnet --version
|
||||||
|
"$MINGW_CC" --version
|
||||||
|
x86_64-w64-mingw32-objdump --version
|
||||||
|
free -h
|
||||||
|
if [ -r /sys/fs/cgroup/memory.max ]; then
|
||||||
|
echo "cgroup memory.max=$(cat /sys/fs/cgroup/memory.max)"
|
||||||
|
echo "cgroup memory.current=$(cat /sys/fs/cgroup/memory.current)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Cross-build and verify Windows package
|
||||||
|
run: ./tools/build-windows-x64.sh
|
||||||
|
|
||||||
|
- name: Stage verified workflow artifact
|
||||||
|
run: |
|
||||||
|
mkdir -p build/workflow-artifact-windows
|
||||||
|
cp build/package/windows-x64/OpenMaidEngine-Himegari-windows-x64.zip build/workflow-artifact-windows/
|
||||||
|
cp build/package/windows-x64/staging/OpenMaidEngine-Himegari-windows-x64/BUILD-INFO.json build/workflow-artifact-windows/
|
||||||
|
cp build/package/windows-x64/staging/OpenMaidEngine-Himegari-windows-x64/SHA256SUMS build/workflow-artifact-windows/
|
||||||
|
cp build/package/windows-x64/staging/OpenMaidEngine-Himegari-windows-x64/WINDOWS-VERIFICATION.json build/workflow-artifact-windows/
|
||||||
|
cd build/workflow-artifact-windows
|
||||||
|
sha256sum OpenMaidEngine-Himegari-windows-x64.zip > OpenMaidEngine-Himegari-windows-x64.zip.sha256
|
||||||
|
|
||||||
|
- name: Upload verified Windows package
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: OpenMaidEngine-Himegari-windows-x64-${{ gitea.sha }}
|
||||||
|
path: build/workflow-artifact-windows/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload failure evidence
|
||||||
|
if: failure()
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-release-failure-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||||
|
path: |
|
||||||
|
build/native/win-x64/verification.json
|
||||||
|
build/package/windows-x64/staging/OpenMaidEngine-Himegari-windows-x64/WINDOWS-VERIFICATION.json
|
||||||
|
build/validation/**
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
publish-release:
|
publish-release:
|
||||||
name: Publish tagged Gitea release
|
name: Publish tagged Gitea release
|
||||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||||
needs: linux-release
|
needs:
|
||||||
|
- linux-release
|
||||||
|
- windows-release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 20
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
# Gitea 1.25 granular scope; GitHub-oriented actionlint does not model it.
|
# Gitea 1.25 granular scope; GitHub-oriented actionlint does not model it.
|
||||||
@@ -117,11 +206,17 @@ jobs:
|
|||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
architecture: x64
|
architecture: x64
|
||||||
|
|
||||||
- name: Download verified workflow artifact
|
- name: Download verified Linux artifact
|
||||||
uses: christopherhx/gitea-download-artifact@v4
|
uses: christopherhx/gitea-download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: OpenMaidEngine-Himegari-linux-x64-${{ gitea.sha }}
|
name: OpenMaidEngine-Himegari-linux-x64-${{ gitea.sha }}
|
||||||
path: build/release-assets
|
path: build/release-assets/linux
|
||||||
|
|
||||||
|
- name: Download verified Windows artifact
|
||||||
|
uses: christopherhx/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: OpenMaidEngine-Himegari-windows-x64-${{ gitea.sha }}
|
||||||
|
path: build/release-assets/windows
|
||||||
|
|
||||||
- name: Create or resume Gitea release
|
- name: Create or resume Gitea release
|
||||||
env:
|
env:
|
||||||
@@ -132,8 +227,6 @@ jobs:
|
|||||||
--repository "${{ gitea.repository }}" \
|
--repository "${{ gitea.repository }}" \
|
||||||
--tag "${{ gitea.ref_name }}" \
|
--tag "${{ gitea.ref_name }}" \
|
||||||
--target "${{ gitea.sha }}" \
|
--target "${{ gitea.sha }}" \
|
||||||
--asset build/release-assets/OpenMaidEngine-Himegari-linux-x64.tar.gz \
|
--linux-artifact-directory build/release-assets/linux \
|
||||||
--asset build/release-assets/OpenMaidEngine-Himegari-linux-x64.tar.gz.sha256 \
|
--windows-artifact-directory build/release-assets/windows \
|
||||||
--asset build/release-assets/BUILD-INFO.json \
|
--output-directory build/release-assets/prepared
|
||||||
--asset build/release-assets/SHA256SUMS \
|
|
||||||
--asset build/release-assets/package-smoke.log
|
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ py -3.11 -X utf8 tools\validate.py --level core
|
|||||||
.\run-godot.ps1 -GodotConsole <godot-console.exe> -GameRoot <Himegari-install>
|
.\run-godot.ps1 -GodotConsole <godot-console.exe> -GameRoot <Himegari-install>
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux release exports are asset-free build products: from a Linux host, `./tools/build-linux-x64.sh`
|
Linux and Windows release exports are asset-free build products. From a Linux host,
|
||||||
bootstraps the pinned Godot/FFmpeg inputs, exports the runtime, runs its packaged smoke gate, and writes the
|
`./tools/build-linux-x64.sh` builds, packages, and smoke-tests the Linux runtime, while
|
||||||
archive under `build/package/`. See the tools reference for exact inputs and output contents.
|
`./tools/build-windows-x64.sh` cross-builds and structurally verifies the Windows runtime without Wine.
|
||||||
|
Both write archives under `build/package/`; see the tools reference for exact inputs and output contents.
|
||||||
|
|
||||||
`workspace` and `full` validation additionally require the extracted script corpus and disposable derived
|
`workspace` and `full` validation additionally require the extracted script corpus and disposable derived
|
||||||
inputs described by the tools reference. `runtime` and `full` require Godot plus a valid game root. A
|
inputs described by the tools reference. `runtime` and `full` require Godot plus a valid game root. A
|
||||||
|
|||||||
@@ -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 plus tag-only promotion
|
│ independent Linux/Windows x64 artifact jobs plus Linux-only tag 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
|
||||||
@@ -47,16 +47,20 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
|
|||||||
│ ├── validate.py layered core/workspace/runtime/full validation driver
|
│ ├── validate.py layered core/workspace/runtime/full validation driver
|
||||||
│ ├── 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
|
||||||
|
│ ├── build-windows-x64.sh Linux-hosted Windows cross-export/package 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
|
│ ├── publish_gitea_release.py guarded/retry-safe native Gitea release promotion
|
||||||
│ ├── verify_windows_native.py static AMD64 PE/native-bundle contract verifier
|
│ ├── verify_windows_native.py static AMD64 PE/native-bundle contract verifier
|
||||||
│ ├── 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,
|
||||||
|
│ │ package_windows_x64.py target-specific export verifiers and normalized packagers
|
||||||
│ ├── test_install_godot_templates.py, test_package_linux_x64.py,
|
│ ├── test_install_godot_templates.py, test_package_linux_x64.py,
|
||||||
|
│ │ test_package_windows_x64.py,
|
||||||
│ │ test_dotnet_publish_proxy.py,
|
│ │ test_dotnet_publish_proxy.py,
|
||||||
│ │ test_publish_gitea_release.py,
|
│ │ test_publish_gitea_release.py,
|
||||||
|
│ │ test_release_workflow.py,
|
||||||
│ │ test_verify_windows_native.py source-only bootstrap/package/publish/native regressions
|
│ │ test_verify_windows_native.py source-only bootstrap/package/publish/native 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)
|
||||||
@@ -109,9 +113,10 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
|
|||||||
├── build/ DERIVED (our-work-side) — generated by tools/; disposable
|
├── build/ DERIVED (our-work-side) — generated by tools/; disposable
|
||||||
│ ├── downloads/, toolchains/ immutable dependency caches and repo-local Godot toolchain
|
│ ├── downloads/, toolchains/ immutable dependency caches and repo-local Godot toolchain
|
||||||
│ ├── export/linux-x64/ verified loose Godot Linux export
|
│ ├── export/linux-x64/ verified loose Godot Linux export
|
||||||
|
│ ├── export/windows-x64/ structurally verified loose Godot Windows export
|
||||||
│ ├── native/linux-x64/ validated Linux FFmpeg shim/shared-library bundle
|
│ ├── native/linux-x64/ validated Linux FFmpeg shim/shared-library bundle
|
||||||
│ ├── native/win-x64/ validated Windows FFmpeg shim/DLL bundle
|
│ ├── native/win-x64/ validated Windows FFmpeg shim/DLL bundle
|
||||||
│ ├── package/ staged release tree, smoke log, and `.tar.gz` artifact
|
│ ├── package/ Linux staged tree/smoke/`.tar.gz` plus Windows x64 staged tree/ZIP
|
||||||
│ ├── disasm/ <NAME>.asm — human-readable disassembly, one per script
|
│ ├── disasm/ <NAME>.asm — human-readable disassembly, one per script
|
||||||
│ ├── text/ extracted text:
|
│ ├── text/ extracted text:
|
||||||
│ │ ├── <NAME>.strings.txt all inline strings in a script
|
│ │ ├── <NAME>.strings.txt all inline strings in a script
|
||||||
@@ -160,9 +165,10 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
|
|||||||
```
|
```
|
||||||
|
|
||||||
The Godot deliverable includes `Himegari.sln` because Godot's .NET exporter requires a solution,
|
The Godot deliverable includes `Himegari.sln` because Godot's .NET exporter requires a solution,
|
||||||
`export_presets.cfg` for the Linux x86_64 target, and `build/.gdignore` so disposable capture frames below
|
`export_presets.cfg` for Linux and Windows x86_64, and `build/.gdignore` so disposable capture frames below
|
||||||
the project do not enter its import or export scan. `tools/export-linux-x64.ps1` produces and validates the
|
the project do not enter its import or export scan. `tools/build-linux-x64.sh` and
|
||||||
complete disposable artifact under `build/export/linux-x64/`.
|
`tools/build-windows-x64.sh` produce their complete disposable artifacts under `build/export/`; the older
|
||||||
|
Windows-hosted `tools/export-linux-x64.ps1` remains a Linux-export development path.
|
||||||
|
|
||||||
`godot/Main.cs` retains the front-end's startup and runtime coordination. Behavior-neutral partial-class
|
`godot/Main.cs` retains the front-end's startup and runtime coordination. Behavior-neutral partial-class
|
||||||
companions keep cohesive surfaces independently navigable without changing the Godot node type or invocation
|
companions keep cohesive surfaces independently navigable without changing the Godot node type or invocation
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ lightweight `v0.1.0` tag at `5fe3cd6` on 2026-08-03: the tag build and promotion
|
|||||||
the Gitea release carries the archive, archive checksum, build metadata, payload ledger, and smoke log. The Linux
|
the Gitea release carries the archive, archive checksum, build metadata, payload ledger, and smoke log. The Linux
|
||||||
CI/CD path is therefore accepted end to end.
|
CI/CD path is therefore accepted end to end.
|
||||||
|
|
||||||
## Planned Windows x64 CI/CD artifact
|
## Windows x64 CI/CD artifact
|
||||||
|
|
||||||
The Windows artifact will be cross-built on the existing Linux runner; neither a Windows runner nor Wine is part
|
The Windows artifact will be cross-built on the existing Linux runner; neither a Windows runner nor Wine is part
|
||||||
of its build contract. The .NET SDK publishes `win-x64`, the Linux Godot editor exports through the pinned Windows
|
of its build contract. The .NET SDK publishes `win-x64`, the Linux Godot editor exports through the pinned Windows
|
||||||
@@ -103,29 +103,61 @@ MinGW builds produced the identical shim SHA-256
|
|||||||
`9852680ca32bc37200d0b63ad89acb5453ad913bdcf4441a15c8ac9356d4da8c`. This is a compiler-independent contract
|
`9852680ca32bc37200d0b63ad89acb5453ad913bdcf4441a15c8ac9356d4da8c`. This is a compiler-independent contract
|
||||||
comparison, not an expectation that MSVC and MinGW produce byte-identical binaries.
|
comparison, not an expectation that MSVC and MinGW produce byte-identical binaries.
|
||||||
|
|
||||||
The Windows build command will mirror the accepted Linux entry point while keeping target checks separate. It
|
The cross-export/package contract was completed and locally accepted on 2026-08-03. The shared Godot manifest
|
||||||
will regenerate opcode metadata, selectively install the Windows template from the existing pinned Godot TPZ,
|
now pins both release-template members while each build range-fetches only its selected target; Windows adds the
|
||||||
bootstrap/hash-check the pinned Windows FFmpeg SDK, cross-build and inspect `age_movie_ffmpeg.dll`, perform an
|
109,405,184-byte `windows_release_x86_64.exe` member with independently recorded SHA-256. The guarded publish
|
||||||
exact self-contained `ExportRelease/win-x64` publish, and run the real Godot Windows export. The low-memory publish
|
proxy requires its driver-selected RID, so the same low-memory sequence produces exact self-contained
|
||||||
proxy will become target-parameterized rather than duplicating its fail-closed project/configuration/RID/output
|
`ExportRelease/linux-x64` and `ExportRelease/win-x64` payloads without weakening the project/configuration/RID/
|
||||||
checks. The accepted Linux path must remain byte- and behavior-compatible throughout this extension.
|
output-root checks. `tools/build-windows-x64.sh` composes the pinned editor/template, MinGW native bundle, .NET
|
||||||
|
publish, real Godot export, static verifier, and normalized ZIP. Its verifier requires the executable/PCK,
|
||||||
|
self-contained runtime, three project assemblies plus `Age.Engine.Text.Windows.dll`, native FFmpeg bundle and
|
||||||
|
license; it rejects Linux shared objects and checks the Godot EXE plus all six native DLLs as AMD64 PE. The ZIP
|
||||||
|
contains project notices, clean-source/dependency metadata, a complete payload checksum ledger, and the static
|
||||||
|
verification report under one stable root. A WSL cross-build completed in 91.3 seconds and two package passes
|
||||||
|
were byte-identical; the resulting packaged EXE also reported
|
||||||
|
`PACKAGE SMOKE OK: opcodes=548 ffmpeg-abi=3` when optionally run on the Windows host. CI remains structural and
|
||||||
|
will not execute that EXE.
|
||||||
|
|
||||||
CI acceptance is structural because the produced executable will not run on the Linux host. The verifier must
|
The Windows build command mirrors the accepted Linux entry point while keeping target checks separate. It
|
||||||
require the Windows executable/PCK/self-contained managed directory, `Age.Engine.Text.Windows.dll`, the project
|
regenerates opcode metadata, selectively installs the Windows template from the existing pinned Godot TPZ,
|
||||||
shim, all five pinned FFmpeg DLLs, and the FFmpeg license; reject Linux shared objects and incomplete/cross-target
|
bootstrap/hash-checks the pinned Windows FFmpeg SDK, cross-builds and inspects `age_movie_ffmpeg.dll`, performs an
|
||||||
payloads; and inspect every project-owned PE image as AMD64. The portable ZIP will carry the project license,
|
exact self-contained `ExportRelease/win-x64` publish, and runs the real Godot Windows export. The accepted Linux
|
||||||
|
path retains its target selection and dynamic package-smoke behavior.
|
||||||
|
|
||||||
|
CI acceptance is structural because the produced executable does not run on the Linux host. The verifier
|
||||||
|
requires the Windows executable/PCK/self-contained managed directory, `Age.Engine.Text.Windows.dll`, the project
|
||||||
|
shim, all five pinned FFmpeg DLLs, and the FFmpeg license; rejects Linux shared objects and incomplete/cross-target
|
||||||
|
payloads; and inspects the executable plus native DLLs as AMD64. The portable ZIP carries the project license,
|
||||||
third-party notices, clean source/dependency metadata, and a complete payload checksum ledger. A manual packaged
|
third-party notices, clean source/dependency metadata, and a complete payload checksum ledger. A manual packaged
|
||||||
`--package-smoke` command remains available for real-Windows acceptance, but its result is not fabricated or
|
`--package-smoke` command remains available for real-Windows acceptance, but its result is not fabricated or
|
||||||
reported as a hosted CI gate.
|
reported as a hosted CI gate.
|
||||||
|
|
||||||
Develop, manual, and tag events will run independent Linux and Windows build jobs, each with read-only repository
|
The hosted Windows artifact job was added on 2026-08-03 beside the accepted Linux job. Develop, manual, and tag
|
||||||
permission and its own manifest-keyed cache/workflow artifact. Tag promotion will require both jobs, download
|
events now run independent `ubuntu-latest` builds, each with inherited read-only repository permission and its
|
||||||
both already-built archives, verify their commit/build evidence, and publish two distributable release assets:
|
own manifest-keyed cache/workflow artifact. Windows installs Ubuntu MinGW-w64 GCC/binutils, reports the compiler
|
||||||
|
and PE inspector, calls only `tools/build-windows-x64.sh`, and retains the ZIP plus its external hash, build
|
||||||
|
metadata, payload ledger, and static verification report for 30 days. It has no secrets, Wine, EXE execution, or
|
||||||
|
release authority. Source-only workflow regressions pin those rules and preserve the current Linux-only
|
||||||
|
`publish-release` dependency. The first hosted dual-job develop run at `9d3ab30` succeeded on 2026-08-03: both
|
||||||
|
platform artifact jobs completed and the tag-only promotion job was skipped. The Windows hosted artifact path is
|
||||||
|
therefore accepted; dual-platform promotion is the remaining CI/CD slice.
|
||||||
|
|
||||||
|
Dual-platform promotion was implemented on 2026-08-03. The tag-only job now requires both successful build jobs
|
||||||
|
and downloads their retained artifacts into separate directories without rebuilding. The release helper rechecks
|
||||||
|
both external archive hashes, exact clean tag-commit metadata and target RID, matching build metadata/payload
|
||||||
|
ledgers inside each archive, Linux's accepted dynamic package smoke, and Windows's complete static AMD64/ABI/
|
||||||
|
import report. It creates or resumes only a matching release, rejects unexpected or colliding assets, and exposes
|
||||||
|
exactly two distributable archives:
|
||||||
`OpenMaidEngine-Himegari-linux-x64.tar.gz` and `OpenMaidEngine-Himegari-windows-x64.zip`. A third small
|
`OpenMaidEngine-Himegari-linux-x64.tar.gz` and `OpenMaidEngine-Himegari-windows-x64.zip`. A third small
|
||||||
`RELEASE-SHA256SUMS` attachment will authenticate both archives while keeping the release below Gitea's observed
|
`RELEASE-SHA256SUMS` attachment will authenticate both archives while keeping the release below Gitea's observed
|
||||||
five-attachment boundary; detailed build metadata, payload ledgers, and logs remain inside each archive and in
|
five-attachment boundary; detailed build metadata, payload ledgers, and logs remain inside each archive and in
|
||||||
the retained workflow artifacts. The release notes will state that Linux received the dynamic package smoke and
|
the retained workflow artifacts. The release notes will state that Linux received the dynamic package smoke and
|
||||||
Windows received structural cross-target verification only.
|
Windows received structural cross-target verification only. Source-only paired creation/resume/refusal tests
|
||||||
|
pass. The hosted develop run at `d657c63` completed both platform builds and correctly skipped tag promotion on
|
||||||
|
2026-08-03, accepting the paired non-tag path. Its core job initially failed before checkout on a transient
|
||||||
|
runner DNS lookup for GitHub, then passed unchanged on retry; this was infrastructure availability rather than a
|
||||||
|
validation failure. The user must still select a future tag for the end-to-end release proof; `v0.1.0` remains
|
||||||
|
unchanged.
|
||||||
|
|
||||||
Code signing, an installer, Windows-on-Linux execution, and retroactively modifying the accepted Linux-only
|
Code signing, an installer, Windows-on-Linux execution, and retroactively modifying the accepted Linux-only
|
||||||
`v0.1.0` release are outside this effort. A future signing slice can use `osslsigncode` and protected credentials
|
`v0.1.0` release are outside this effort. A future signing slice can use `osslsigncode` and protected credentials
|
||||||
|
|||||||
@@ -1063,6 +1063,40 @@ do not mix mechanical moves with semantic changes.
|
|||||||
next step: Windows Godot template/export, target-parameterized publish proxy, structural package contract, and
|
next step: Windows Godot template/export, target-parameterized publish proxy, structural package contract, and
|
||||||
normalized ZIP.
|
normalized ZIP.
|
||||||
|
|
||||||
|
**Slice 2 completed 2026-08-03:** the shared Godot manifest now pins and selectively fetches either release
|
||||||
|
template, the guarded low-memory publish proxy requires the calling driver's exact Linux or Windows RID, and
|
||||||
|
the Windows preset includes the conditional exact-GDI assembly. `tools/build-windows-x64.sh` performs the
|
||||||
|
complete metadata/native/publish/Godot/export/verify/package sequence without Wine. Its packager rejects
|
||||||
|
incomplete or Linux-contaminated payloads, verifies the Godot EXE and six native DLLs as AMD64 PE, embeds
|
||||||
|
notices/build provenance/payload checksums/static verification, and emits one normalized ZIP. Synthetic
|
||||||
|
template/proxy/package tests pass; the 91.3-second WSL cross-export passed, repeated packaging was byte-for-byte
|
||||||
|
stable, and an optional Windows-host run of the packaged EXE reported the 548-opcode/FFmpeg-ABI-3 smoke marker.
|
||||||
|
Slice 3 is now active: add the read-only hosted Windows artifact job while retaining the accepted Linux job.
|
||||||
|
|
||||||
|
**Slice 3 completed and hosted-accepted 2026-08-03:** the existing artifact workflow
|
||||||
|
now has independent `ubuntu-latest` Linux and Windows jobs under inherited read-only contents permission.
|
||||||
|
Windows provisions MinGW-w64 GCC/binutils, restores only its manifest-keyed editor/FFmpeg/template inputs,
|
||||||
|
calls the accepted `tools/build-windows-x64.sh`, and uploads the ZIP, external archive hash, build metadata,
|
||||||
|
payload ledger, and static verification report for 30 days. It has no secrets, Wine, EXE execution, or release
|
||||||
|
authority. A core-gated source-only workflow regression pins that boundary and deliberately proves that
|
||||||
|
`publish-release` still needs only `linux-release` during this slice. Acceptance requires the first hosted
|
||||||
|
develop run to leave promotion skipped and complete both platform jobs; slice 4 then replaces the Linux-only
|
||||||
|
promotion contract with dual-archive verification and publication. The develop run at `9d3ab30` then
|
||||||
|
completed both platform artifact jobs and skipped tag-only promotion, accepting the runner/toolchain/cache/
|
||||||
|
build/upload path and the non-tag authority boundary. Slice 4 is now active.
|
||||||
|
|
||||||
|
**Slice 4 hosted path accepted 2026-08-03:** tag promotion now requires both
|
||||||
|
successful platform jobs and downloads their already-built artifacts into separate directories. The helper
|
||||||
|
binds both archives to the same clean tag commit/RID, verifies their external hashes, compares external build
|
||||||
|
metadata and payload ledgers with the copies inside each archive, requires Linux's dynamic smoke and Windows's
|
||||||
|
AMD64/ABI/import report, and generates combined release checksums. Creation and retry expose exactly three
|
||||||
|
public assets—Linux archive, Windows archive, and `RELEASE-SHA256SUMS`—while unexpected assets, release
|
||||||
|
mismatches, and same-name/different-size collisions fail without mutation. Synthetic paired-artifact,
|
||||||
|
creation/resume/refusal, and workflow-dependency tests pass. The hosted develop run at `d657c63` completed
|
||||||
|
both platform builds and skipped promotion as required; core validation also passed unchanged after retrying
|
||||||
|
a transient pre-checkout runner DNS failure. The remaining gate is a future tag deliberately selected by the
|
||||||
|
user.
|
||||||
|
|
||||||
**Completion gate:** one deliberately selected future tag produces a single Gitea release containing the
|
**Completion gate:** one deliberately selected future tag produces a single Gitea release containing the
|
||||||
Linux `.tar.gz`, Windows `.zip`, and combined archive checksums; both archives bind to the tag commit, Linux
|
Linux `.tar.gz`, Windows `.zip`, and combined archive checksums; both archives bind to the tag commit, Linux
|
||||||
retains its dynamic packaged smoke, Windows passes all structural PE/payload gates, and neither platform job
|
retains its dynamic packaged smoke, Windows passes all structural PE/payload gates, and neither platform job
|
||||||
|
|||||||
@@ -48,27 +48,37 @@ 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 build job uses the same Python 3.11 and
|
gate on `develop` pushes, manual dispatch, and `v*` tag pushes. Independent Linux and Windows jobs use the same
|
||||||
`global.json` .NET setup actions, then calls `./tools/build-linux-x64.sh` unchanged. `actions/cache@v4` retains
|
Python 3.11 and `global.json` .NET setup actions and call their locally accepted build commands unchanged. The
|
||||||
only the two hash-verified dependency archives and the independently hash-verified Linux release template; its
|
Windows job adds Ubuntu's MinGW-w64 GCC/binutils and never installs Wine. Each job has its own manifest-keyed
|
||||||
key is the combined Godot/FFmpeg manifest hash and deliberately has no prefix fallback. Successful runs upload
|
`actions/cache@v4` entry with no prefix fallback: shared Linux Godot editor plus only that target's hash-verified
|
||||||
one flat artifact containing the `.tar.gz`, its external SHA-256 file, `BUILD-INFO.json`, `SHA256SUMS`, and packaged-runtime smoke log for
|
FFmpeg archive and selectively installed release template. Linux uploads its `.tar.gz`, external SHA-256,
|
||||||
30 days. Failed runs retain any smoke/validation evidence for seven days. The build job has read-only repository
|
`BUILD-INFO.json`, `SHA256SUMS`, and dynamic package-smoke log. Windows uploads its ZIP, external SHA-256,
|
||||||
permission, no secrets or private game corpus, and no release/package publication authority.
|
`BUILD-INFO.json`, `SHA256SUMS`, and `WINDOWS-VERIFICATION.json`; it does not execute the EXE. Both artifacts are
|
||||||
|
retained for 30 days and failures retain bounded evidence for seven days. Both build jobs inherit read-only
|
||||||
|
repository permission and have no secrets, private game corpus, or release/package publication authority. The
|
||||||
|
first hosted dual-job develop run at `9d3ab30` succeeded on 2026-08-03: both platform artifacts completed and
|
||||||
|
the tag-only promotion job was skipped. This accepts the Windows artifact job and its non-tag authority boundary.
|
||||||
|
|
||||||
Only a successful `v*` tag run enables the dependent promotion job. It downloads that same workflow artifact
|
Only a successful `v*` tag run after both platform jobs enables the dependent promotion job. It downloads the
|
||||||
rather than rebuilding, checks out only the release tool, and receives job-local `releases: write` plus
|
two verified workflow artifacts into separate directories rather than rebuilding, checks out only the release
|
||||||
`contents: read`; branch and manual runs skip the job and never receive release authority. The built-in
|
tool, and receives job-local `releases: write` plus `contents: read`; branch and manual runs skip the job and
|
||||||
`secrets.GITEA_TOKEN` calls the native Gitea API, so no personal token or third-party release action is required.
|
never receive release authority. The built-in `secrets.GITEA_TOKEN` calls the native Gitea API, so no personal
|
||||||
`publish_gitea_release.py` validates the version-like tag, exact clean-build commit metadata, archive checksum,
|
token or third-party release action is required. `publish_gitea_release.py` validates the version-like tag, both
|
||||||
accepted package-smoke result, fixed five-file asset set, and any
|
exact clean-build commit/target records, both external archive hashes, matching metadata/ledgers inside each
|
||||||
existing release/asset metadata. A retry resumes a matching partial release and uploads only missing files; it
|
archive, the accepted Linux package smoke, and the Windows AMD64/ABI/import verification. It then generates one
|
||||||
refuses mismatched releases or same-name/different-size assets instead of editing, deleting, or overwriting them.
|
`RELEASE-SHA256SUMS` and publishes exactly three assets: the Linux `.tar.gz`, Windows `.zip`, and combined
|
||||||
|
checksums. Detailed evidence remains inside each archive and in retained workflow artifacts. A retry resumes a
|
||||||
|
matching partial release and uploads only missing files; it refuses mismatched releases, unexpected assets, or
|
||||||
|
same-name/different-size collisions instead of editing, deleting, or overwriting them. The hosted develop run at
|
||||||
|
`d657c63` completed both artifact jobs and skipped tag promotion as required on 2026-08-03. No new version tag
|
||||||
|
was created as part of that proof.
|
||||||
|
|
||||||
| Tool | Purpose | Run | Reads → Writes |
|
| 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 |
|
| `publish_gitea_release.py` | Tag-only, retry-safe promotion of paired verified Linux/Windows workflow artifacts through Gitea's native release API. Revalidates both archives/evidence sets, writes combined release checksums, and exposes exactly three public assets. 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> --linux-artifact-directory <dir> --windows-artifact-directory <dir> --output-directory <dir>` | two verified downloaded workflow artifacts + tag context → matching Gitea release with Linux archive, Windows archive, and `RELEASE-SHA256SUMS` |
|
||||||
| `test_publish_gitea_release.py` | Pure synthetic creation, retry/resume, collision, and mismatch regressions. | `test_publish_gitea_release.py` | temporary files only |
|
| `test_publish_gitea_release.py` | Pure synthetic paired-artifact/archive validation, three-asset creation, retry/resume, collision, unexpected-asset, and mismatch regressions. | `test_publish_gitea_release.py` | temporary files only |
|
||||||
|
| `test_release_workflow.py` | Source-only policy regression for the independent read-only Linux/Windows jobs, target-specific MinGW/cache/artifact boundary, no-Wine/no-Windows-smoke rule, and promotion's dependency/download of both successful artifacts. | `test_release_workflow.py` | `.gitea/workflows/linux-release-build.yml` → assertions only |
|
||||||
|
|
||||||
## Optional local binary tools
|
## Optional local binary tools
|
||||||
|
|
||||||
@@ -429,6 +439,41 @@ The bootstrap prints the pinned editor path. The verifier prints the accepted ex
|
|||||||
prints the staged root and archive path. Those Python components and the guarded publish proxy have source-only
|
prints the staged root and archive path. Those Python components and the guarded publish proxy have source-only
|
||||||
synthetic regressions in the core validation level.
|
synthetic regressions in the core validation level.
|
||||||
|
|
||||||
|
**Windows x64 cross-export** uses the same pinned Linux editor and low-memory publish boundary, with MinGW and
|
||||||
|
the selectively installed Windows template:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tools/build-windows-x64.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The command requires Python 3.11, the `global.json` .NET SDK, MinGW-w64 GCC/binutils, `curl`, and `sha256sum`.
|
||||||
|
It regenerates runtime metadata, builds and statically verifies the pinned Windows FFmpeg bundle, publishes the
|
||||||
|
exact self-contained `ExportRelease/win-x64` managed runtime, and performs the real Godot `Windows x86_64`
|
||||||
|
release export. No produced Windows program is executed. `package_windows_x64.py` requires the Godot AMD64 EXE,
|
||||||
|
PCK, self-contained runtime, project assemblies including `Age.Engine.Text.Windows.dll`, and FFmpeg DLL/license
|
||||||
|
set; it rejects Linux shared objects and embeds `BUILD-INFO.json`, `WINDOWS-VERIFICATION.json`, notices, and a
|
||||||
|
complete `SHA256SUMS`. The normalized output is
|
||||||
|
`build/package/windows-x64/OpenMaidEngine-Himegari-windows-x64.zip`.
|
||||||
|
|
||||||
|
Focused commands are:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tools/bootstrap-godot-linux-x64.sh windows-x64
|
||||||
|
python3 -X utf8 tools/package_windows_x64.py verify build/export/windows-x64
|
||||||
|
python3 -X utf8 tools/package_windows_x64.py package build/export/windows-x64
|
||||||
|
```
|
||||||
|
|
||||||
|
The bootstrap defaults to `linux-x64`; passing `windows-x64` changes only the selectively installed template.
|
||||||
|
The package verifier needs `x86_64-w64-mingw32-objdump` unless `--objdump` names an equivalent tool. On a real
|
||||||
|
Windows host, an extracted package can optionally run the same asset-independent dynamic gate as Linux:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\Himegari.exe --headless -- --package-smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
That command proves the embedded 548-opcode table and local FFmpeg ABI 3 load. It is a manual acceptance aid,
|
||||||
|
not part of Linux-hosted Windows CI.
|
||||||
|
|
||||||
The older Windows-hosted cross-export remains available for local development:
|
The older Windows-hosted cross-export remains available for local development:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ public partial class Main
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!OS.HasFeature("linux"))
|
if (!OS.HasFeature("linux") && !OS.HasFeature("windows"))
|
||||||
throw new PlatformNotSupportedException("the packaged-runtime smoke gate requires Linux");
|
throw new PlatformNotSupportedException(
|
||||||
|
"the packaged-runtime smoke gate supports Linux and Windows x64");
|
||||||
var table = HimegariRuntimeMetadata.LoadOpcodeTable();
|
var table = HimegariRuntimeMetadata.LoadOpcodeTable();
|
||||||
if (table.Count != 548)
|
if (table.Count != 548)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
|
|||||||
@@ -30,3 +30,37 @@ ssh_remote_deploy/enabled=false
|
|||||||
dotnet/include_scripts_content=false
|
dotnet/include_scripts_content=false
|
||||||
dotnet/include_debug_symbols=false
|
dotnet/include_debug_symbols=false
|
||||||
dotnet/embed_build_outputs=false
|
dotnet/embed_build_outputs=false
|
||||||
|
|
||||||
|
[preset.1]
|
||||||
|
|
||||||
|
name="Windows x86_64"
|
||||||
|
platform="Windows Desktop"
|
||||||
|
runnable=false
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="all_resources"
|
||||||
|
include_filter=""
|
||||||
|
exclude_filter=""
|
||||||
|
export_path="../build/export/windows-x64/Himegari.exe"
|
||||||
|
patches=PackedStringArray()
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
seed=0
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_export_mode=2
|
||||||
|
|
||||||
|
[preset.1.options]
|
||||||
|
|
||||||
|
custom_template/debug=""
|
||||||
|
custom_template/release=""
|
||||||
|
debug/export_console_wrapper=1
|
||||||
|
binary_format/embed_pck=false
|
||||||
|
texture_format/s3tc_bptc=true
|
||||||
|
texture_format/etc2_astc=false
|
||||||
|
binary_format/architecture="x86_64"
|
||||||
|
codesign/enable=false
|
||||||
|
ssh_remote_deploy/enabled=false
|
||||||
|
dotnet/include_scripts_content=false
|
||||||
|
dotnet/include_debug_symbols=false
|
||||||
|
dotnet/embed_build_outputs=false
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
|||||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
repo_root="$(cd -- "$script_dir/.." && pwd)"
|
repo_root="$(cd -- "$script_dir/.." && pwd)"
|
||||||
manifest="$script_dir/godot-linux-x64.json"
|
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"
|
toolchain_root="$repo_root/build/toolchains/godot-4.7-stable-mono-linux-x64"
|
||||||
xdg_data_home="$toolchain_root/xdg-data"
|
xdg_data_home="$toolchain_root/xdg-data"
|
||||||
download_dir="$repo_root/build/downloads"
|
download_dir="$repo_root/build/downloads"
|
||||||
@@ -30,6 +31,15 @@ archive_path="$download_dir/$archive"
|
|||||||
editor_root="$toolchain_root/editor"
|
editor_root="$toolchain_root/editor"
|
||||||
template_root="$xdg_data_home/godot/export_templates/$template_version"
|
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"
|
mkdir -p -- "$download_dir" "$editor_root" "$template_root"
|
||||||
if [[ ! -f "$archive_path" ]]; then
|
if [[ ! -f "$archive_path" ]]; then
|
||||||
curl --fail --location --retry 3 --output "$archive_path" "$url"
|
curl --fail --location --retry 3 --output "$archive_path" "$url"
|
||||||
@@ -59,7 +69,7 @@ editor="${editors[0]}"
|
|||||||
chmod +x "$editor"
|
chmod +x "$editor"
|
||||||
|
|
||||||
python3 -X utf8 "$script_dir/install_godot_templates.py" \
|
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="$(XDG_DATA_HOME="$xdg_data_home" "$editor" --headless --version)"
|
||||||
reported="${reported%%$'\n'*}"
|
reported="${reported%%$'\n'*}"
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ AGE_PUBLISH_PROJECT="$project_root/Himegari.csproj" \
|
|||||||
AGE_PREPUBLISHED_OUTPUT="$managed_proxy_directory" \
|
AGE_PREPUBLISHED_OUTPUT="$managed_proxy_directory" \
|
||||||
AGE_PUBLISH_OUTPUT_ROOT="${TMPDIR:-/tmp}/godot-publish-dotnet" \
|
AGE_PUBLISH_OUTPUT_ROOT="${TMPDIR:-/tmp}/godot-publish-dotnet" \
|
||||||
AGE_PUBLISH_ASSEMBLY="Himegari.dll" \
|
AGE_PUBLISH_ASSEMBLY="Himegari.dll" \
|
||||||
|
AGE_PUBLISH_RUNTIME="linux-x64" \
|
||||||
PATH="$dotnet_proxy_directory:$PATH" \
|
PATH="$dotnet_proxy_directory:$PATH" \
|
||||||
XDG_DATA_HOME="$xdg_data_home" "$godot_console" \
|
XDG_DATA_HOME="$xdg_data_home" "$godot_console" \
|
||||||
--headless --quit-after 120 --path "$project_root" \
|
--headless --quit-after 120 --path "$project_root" \
|
||||||
|
|||||||
129
tools/build-windows-x64.sh
Executable file
129
tools/build-windows-x64.sh
Executable 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"
|
||||||
@@ -76,13 +76,14 @@ def stage_publish(request: PublishRequest, environ: dict[str, str]) -> Path:
|
|||||||
source = Path(environ["AGE_PREPUBLISHED_OUTPUT"]).resolve()
|
source = Path(environ["AGE_PREPUBLISHED_OUTPUT"]).resolve()
|
||||||
output_root = Path(environ["AGE_PUBLISH_OUTPUT_ROOT"]).resolve()
|
output_root = Path(environ["AGE_PUBLISH_OUTPUT_ROOT"]).resolve()
|
||||||
assembly = environ["AGE_PUBLISH_ASSEMBLY"]
|
assembly = environ["AGE_PUBLISH_ASSEMBLY"]
|
||||||
|
expected_runtime = environ["AGE_PUBLISH_RUNTIME"]
|
||||||
output = request.output.resolve()
|
output = request.output.resolve()
|
||||||
|
|
||||||
if request.project.resolve() != expected_project:
|
if request.project.resolve() != expected_project:
|
||||||
raise ValueError(f"unexpected publish project: {request.project}")
|
raise ValueError(f"unexpected publish project: {request.project}")
|
||||||
if request.configuration != "ExportRelease":
|
if request.configuration != "ExportRelease":
|
||||||
raise ValueError(f"unexpected publish configuration: {request.configuration}")
|
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}")
|
raise ValueError(f"unexpected publish runtime: {request.runtime}")
|
||||||
if request.self_contained.lower() != "true":
|
if request.self_contained.lower() != "true":
|
||||||
raise ValueError(f"publish is not self-contained: {request.self_contained}")
|
raise ValueError(f"publish is not self-contained: {request.self_contained}")
|
||||||
|
|||||||
@@ -21,6 +21,13 @@
|
|||||||
"sha256": "26b1b5d076b78c02f1476dab78e183218a6aa30c8e614653eac11a0dd198456a",
|
"sha256": "26b1b5d076b78c02f1476dab78e183218a6aa30c8e614653eac11a0dd198456a",
|
||||||
"size": 73591000,
|
"size": 73591000,
|
||||||
"mode": "0755"
|
"mode": "0755"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"archive_path": "templates/windows_release_x86_64.exe",
|
||||||
|
"install_name": "windows_release_x86_64.exe",
|
||||||
|
"sha256": "00c72494d58536b74b95d5044fa13c4cc54f2349d687b87f3d8801897741fac0",
|
||||||
|
"size": 109405184,
|
||||||
|
"mode": "0755"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,11 +160,21 @@ def file_matches(path: Path, expected_size: int, expected_hash: str) -> bool:
|
|||||||
return digest.hexdigest() == expected_hash.lower()
|
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:
|
with manifest_path.open(encoding="utf-8") as stream:
|
||||||
manifest = json.load(stream)
|
manifest = json.load(stream)
|
||||||
templates = manifest["templates"]
|
templates = manifest["templates"]
|
||||||
members = templates["members"]
|
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)
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
installed = [destination / member["install_name"] for member in members]
|
installed = [destination / member["install_name"] for member in members]
|
||||||
@@ -211,8 +221,10 @@ def main() -> int:
|
|||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--manifest", type=Path, required=True)
|
parser.add_argument("--manifest", type=Path, required=True)
|
||||||
parser.add_argument("--destination", type=Path, required=True)
|
parser.add_argument("--destination", type=Path, required=True)
|
||||||
|
parser.add_argument("--member", action="append", dest="members")
|
||||||
args = parser.parse_args()
|
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)
|
print(installed)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
248
tools/package_windows_x64.py
Executable file
248
tools/package_windows_x64.py
Executable 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())
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Promote one verified Linux workflow artifact to a matching Gitea release."""
|
"""Validate paired platform artifacts and promote three assets to Gitea."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -9,23 +9,42 @@ import os
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tarfile
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Protocol
|
from typing import Any, Callable, Protocol
|
||||||
|
|
||||||
|
import verify_windows_native
|
||||||
|
|
||||||
EXPECTED_ASSETS = frozenset({
|
|
||||||
"OpenMaidEngine-Himegari-linux-x64.tar.gz",
|
LINUX_ARCHIVE = "OpenMaidEngine-Himegari-linux-x64.tar.gz"
|
||||||
"OpenMaidEngine-Himegari-linux-x64.tar.gz.sha256",
|
WINDOWS_ARCHIVE = "OpenMaidEngine-Himegari-windows-x64.zip"
|
||||||
|
RELEASE_CHECKSUMS = "RELEASE-SHA256SUMS"
|
||||||
|
LINUX_ARTIFACT_FILES = frozenset({
|
||||||
|
LINUX_ARCHIVE,
|
||||||
|
f"{LINUX_ARCHIVE}.sha256",
|
||||||
"BUILD-INFO.json",
|
"BUILD-INFO.json",
|
||||||
"SHA256SUMS",
|
"SHA256SUMS",
|
||||||
"package-smoke.log",
|
"package-smoke.log",
|
||||||
})
|
})
|
||||||
|
WINDOWS_ARTIFACT_FILES = frozenset({
|
||||||
|
WINDOWS_ARCHIVE,
|
||||||
|
f"{WINDOWS_ARCHIVE}.sha256",
|
||||||
|
"BUILD-INFO.json",
|
||||||
|
"SHA256SUMS",
|
||||||
|
"WINDOWS-VERIFICATION.json",
|
||||||
|
})
|
||||||
|
EXPECTED_RELEASE_ASSETS = frozenset({LINUX_ARCHIVE, WINDOWS_ARCHIVE, RELEASE_CHECKSUMS})
|
||||||
|
LINUX_PACKAGE_ROOT = "OpenMaidEngine-Himegari-linux-x64"
|
||||||
|
WINDOWS_PACKAGE_ROOT = "OpenMaidEngine-Himegari-windows-x64"
|
||||||
|
SMOKE_MARKER = "PACKAGE SMOKE OK: opcodes=548 ffmpeg-abi=3"
|
||||||
TAG_PATTERN = re.compile(r"v[0-9][0-9A-Za-z.+-]*\Z")
|
TAG_PATTERN = re.compile(r"v[0-9][0-9A-Za-z.+-]*\Z")
|
||||||
COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}\Z")
|
COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}\Z")
|
||||||
TOKEN_PATTERN = re.compile(r"[A-Za-z0-9._-]+\Z")
|
TOKEN_PATTERN = re.compile(r"[A-Za-z0-9._-]+\Z")
|
||||||
|
MAX_EVIDENCE_SIZE = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
class ReleaseApi(Protocol):
|
class ReleaseApi(Protocol):
|
||||||
@@ -113,14 +132,8 @@ class GiteaApi:
|
|||||||
)
|
)
|
||||||
process = subprocess.run(
|
process = subprocess.run(
|
||||||
[
|
[
|
||||||
"curl",
|
"curl", "--config", "-", "--fail-with-body", "--silent", "--show-error",
|
||||||
"--config", "-",
|
"--request", "POST", "--form", f"attachment=@{asset}", url,
|
||||||
"--fail-with-body",
|
|
||||||
"--silent",
|
|
||||||
"--show-error",
|
|
||||||
"--request", "POST",
|
|
||||||
"--form", f"attachment=@{asset}",
|
|
||||||
url,
|
|
||||||
],
|
],
|
||||||
input=curl_config,
|
input=curl_config,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -143,62 +156,223 @@ class GiteaApi:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def validate_inputs(tag: str, target: str, assets: list[Path]) -> None:
|
def _validate_tag_target(tag: str, target: str) -> None:
|
||||||
if not TAG_PATTERN.fullmatch(tag):
|
if not TAG_PATTERN.fullmatch(tag):
|
||||||
raise ValueError(f"release tag must be v-prefixed and version-like: {tag}")
|
raise ValueError(f"release tag must be v-prefixed and version-like: {tag}")
|
||||||
if not COMMIT_PATTERN.fullmatch(target):
|
if not COMMIT_PATTERN.fullmatch(target):
|
||||||
raise ValueError(f"release target must be a lowercase SHA-1 commit: {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}
|
|
||||||
|
def _artifact_files(directory: Path, expected: frozenset[str], label: str) -> dict[str, Path]:
|
||||||
|
directory = directory.resolve()
|
||||||
|
if not directory.is_dir():
|
||||||
|
raise ValueError(f"{label} artifact directory was not found: {directory}")
|
||||||
|
entries = list(directory.iterdir())
|
||||||
|
invalid = [entry.name for entry in entries if not entry.is_file() or entry.is_symlink()]
|
||||||
|
if invalid:
|
||||||
|
raise ValueError(f"{label} artifact has invalid entries: {', '.join(sorted(invalid))}")
|
||||||
|
by_name = {entry.name: entry for entry in entries}
|
||||||
|
if set(by_name) != expected:
|
||||||
|
missing = sorted(expected - set(by_name))
|
||||||
|
extra = sorted(set(by_name) - expected)
|
||||||
|
raise ValueError(f"unexpected {label} artifact set; missing={missing}, extra={extra}")
|
||||||
|
empty = sorted(name for name, path in by_name.items() if path.stat().st_size <= 0)
|
||||||
|
if empty:
|
||||||
|
raise ValueError(f"{label} artifact has empty files: {', '.join(empty)}")
|
||||||
|
return by_name
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_external_checksum(archive: Path, checksum: Path, label: str) -> str:
|
||||||
try:
|
try:
|
||||||
build_info = json.loads(by_name["BUILD-INFO.json"].read_text(encoding="utf-8"))
|
line = checksum.read_text(encoding="ascii").strip()
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError) as error:
|
except UnicodeDecodeError as error:
|
||||||
raise ValueError("BUILD-INFO.json is not valid UTF-8 JSON") from error
|
raise ValueError(f"{label} archive checksum is not ASCII") from error
|
||||||
expected_build_info = {
|
match = re.fullmatch(rf"([0-9a-f]{{64}}) {re.escape(archive.name)}", line)
|
||||||
|
if match is None:
|
||||||
|
raise ValueError(f"{label} archive checksum file has an unexpected format: {checksum}")
|
||||||
|
actual = _hash(archive)
|
||||||
|
if actual != match.group(1):
|
||||||
|
raise ValueError(f"{label} archive does not match its external SHA-256 checksum")
|
||||||
|
return actual
|
||||||
|
|
||||||
|
|
||||||
|
def _json_bytes(data: bytes, label: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(data.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||||
|
raise ValueError(f"{label} is not valid UTF-8 JSON") from error
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError(f"{label} JSON root is not an object")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_build_info(data: bytes, target: str, rid: str, label: str) -> None:
|
||||||
|
build_info = _json_bytes(data, label)
|
||||||
|
expected = {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"source_commit": target,
|
"source_commit": target,
|
||||||
"source_dirty": False,
|
"source_dirty": False,
|
||||||
"target": "linux-x64",
|
"target": rid,
|
||||||
}
|
}
|
||||||
build_mismatches = {
|
mismatches = {
|
||||||
key: (build_info.get(key), value)
|
key: (build_info.get(key), value)
|
||||||
for key, value in expected_build_info.items()
|
for key, value in expected.items()
|
||||||
if build_info.get(key) != value
|
if build_info.get(key) != value
|
||||||
}
|
}
|
||||||
if build_mismatches:
|
if mismatches:
|
||||||
raise ValueError(f"BUILD-INFO.json does not match this promotion: {build_mismatches}")
|
raise ValueError(f"{label} does not match this promotion: {mismatches}")
|
||||||
|
|
||||||
archive = by_name["OpenMaidEngine-Himegari-linux-x64.tar.gz"]
|
|
||||||
checksum_path = by_name["OpenMaidEngine-Himegari-linux-x64.tar.gz.sha256"]
|
def _tar_member(archive_path: Path, name: str) -> bytes:
|
||||||
checksum_line = checksum_path.read_text(encoding="ascii").strip()
|
with tarfile.open(archive_path, mode="r:gz") as archive:
|
||||||
checksum_match = re.fullmatch(
|
try:
|
||||||
r"([0-9a-f]{64}) OpenMaidEngine-Himegari-linux-x64\.tar\.gz",
|
member = archive.getmember(name)
|
||||||
checksum_line,
|
except KeyError as error:
|
||||||
|
raise ValueError(f"Linux archive evidence was not found: {name}") from error
|
||||||
|
if not member.isfile() or member.size <= 0 or member.size > MAX_EVIDENCE_SIZE:
|
||||||
|
raise ValueError(f"Linux archive evidence has an invalid size or type: {name}")
|
||||||
|
stream = archive.extractfile(member)
|
||||||
|
if stream is None:
|
||||||
|
raise ValueError(f"Linux archive evidence could not be read: {name}")
|
||||||
|
return stream.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_member(archive_path: Path, name: str) -> bytes:
|
||||||
|
with zipfile.ZipFile(archive_path) as archive:
|
||||||
|
try:
|
||||||
|
info = archive.getinfo(name)
|
||||||
|
except KeyError as error:
|
||||||
|
raise ValueError(f"Windows archive evidence was not found: {name}") from error
|
||||||
|
if info.is_dir() or info.file_size <= 0 or info.file_size > MAX_EVIDENCE_SIZE:
|
||||||
|
raise ValueError(f"Windows archive evidence has an invalid size or type: {name}")
|
||||||
|
with archive.open(info) as stream:
|
||||||
|
return stream.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_windows_verification(data: bytes) -> None:
|
||||||
|
report = _json_bytes(data, "WINDOWS-VERIFICATION.json")
|
||||||
|
native = report.get("native")
|
||||||
|
if not isinstance(native, dict):
|
||||||
|
raise ValueError("WINDOWS-VERIFICATION.json has no native report")
|
||||||
|
machines = native.get("machines")
|
||||||
|
expected_machines = {
|
||||||
|
name: "AMD64"
|
||||||
|
for name in (verify_windows_native.SHIM, *verify_windows_native.RUNTIME_DLLS)
|
||||||
|
}
|
||||||
|
expected = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"target": "win-x64",
|
||||||
|
"executable_machine": "AMD64",
|
||||||
|
}
|
||||||
|
mismatches = {
|
||||||
|
key: (report.get(key), value)
|
||||||
|
for key, value in expected.items()
|
||||||
|
if report.get(key) != value
|
||||||
|
}
|
||||||
|
if native.get("schema_version") != 1 or native.get("target") != "win-x64":
|
||||||
|
mismatches["native"] = ((native.get("schema_version"), native.get("target")), (1, "win-x64"))
|
||||||
|
if native.get("bundle") != "data_Himegari_windows_x86_64":
|
||||||
|
mismatches["bundle"] = (native.get("bundle"), "data_Himegari_windows_x86_64")
|
||||||
|
if machines != expected_machines:
|
||||||
|
mismatches["machines"] = (machines, expected_machines)
|
||||||
|
exports_value = native.get("shim_exports")
|
||||||
|
exports = (
|
||||||
|
{name for name in exports_value if isinstance(name, str)}
|
||||||
|
if isinstance(exports_value, list)
|
||||||
|
else set()
|
||||||
)
|
)
|
||||||
if checksum_match is None:
|
if exports != verify_windows_native.REQUIRED_EXPORTS:
|
||||||
raise ValueError(f"archive checksum file has an unexpected format: {checksum_path}")
|
mismatches["shim_exports"] = (exports_value, "required AGE ABI exports")
|
||||||
digest = hashlib.sha256()
|
imports_value = native.get("shim_imports")
|
||||||
with archive.open("rb") as stream:
|
imports = (
|
||||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
{name.lower() for name in imports_value if isinstance(name, str)}
|
||||||
digest.update(chunk)
|
if isinstance(imports_value, list)
|
||||||
if digest.hexdigest() != checksum_match.group(1):
|
else set()
|
||||||
raise ValueError("Linux archive does not match its external SHA-256 checksum")
|
)
|
||||||
|
required_imports = {name.lower() for name in verify_windows_native.RUNTIME_DLLS}
|
||||||
|
if not required_imports.issubset(imports):
|
||||||
|
mismatches["shim_imports"] = (sorted(imports), sorted(required_imports))
|
||||||
|
forbidden_imports = imports & verify_windows_native.FORBIDDEN_RUNTIME_IMPORTS
|
||||||
|
if forbidden_imports:
|
||||||
|
mismatches["forbidden_imports"] = (sorted(forbidden_imports), [])
|
||||||
|
if mismatches:
|
||||||
|
raise ValueError(f"WINDOWS-VERIFICATION.json is not accepted: {mismatches}")
|
||||||
|
|
||||||
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:
|
def prepare_release_assets(
|
||||||
raise ValueError("package-smoke.log does not contain the accepted package smoke result")
|
tag: str,
|
||||||
|
target: str,
|
||||||
|
linux_directory: Path,
|
||||||
|
windows_directory: Path,
|
||||||
|
output_directory: Path,
|
||||||
|
) -> list[Path]:
|
||||||
|
_validate_tag_target(tag, target)
|
||||||
|
linux = _artifact_files(linux_directory, LINUX_ARTIFACT_FILES, "Linux")
|
||||||
|
windows = _artifact_files(windows_directory, WINDOWS_ARTIFACT_FILES, "Windows")
|
||||||
|
linux_hash = _validate_external_checksum(
|
||||||
|
linux[LINUX_ARCHIVE], linux[f"{LINUX_ARCHIVE}.sha256"], "Linux"
|
||||||
|
)
|
||||||
|
windows_hash = _validate_external_checksum(
|
||||||
|
windows[WINDOWS_ARCHIVE], windows[f"{WINDOWS_ARCHIVE}.sha256"], "Windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
linux_build_info = linux["BUILD-INFO.json"].read_bytes()
|
||||||
|
windows_build_info = windows["BUILD-INFO.json"].read_bytes()
|
||||||
|
linux_ledger = linux["SHA256SUMS"].read_bytes()
|
||||||
|
windows_ledger = windows["SHA256SUMS"].read_bytes()
|
||||||
|
windows_verification = windows["WINDOWS-VERIFICATION.json"].read_bytes()
|
||||||
|
_validate_build_info(linux_build_info, target, "linux-x64", "Linux BUILD-INFO.json")
|
||||||
|
_validate_build_info(windows_build_info, target, "win-x64", "Windows BUILD-INFO.json")
|
||||||
|
if SMOKE_MARKER not in linux["package-smoke.log"].read_text(encoding="utf-8"):
|
||||||
|
raise ValueError("package-smoke.log does not contain the accepted Linux package smoke result")
|
||||||
|
_validate_windows_verification(windows_verification)
|
||||||
|
|
||||||
|
internal_linux_build = _tar_member(
|
||||||
|
linux[LINUX_ARCHIVE], f"{LINUX_PACKAGE_ROOT}/BUILD-INFO.json"
|
||||||
|
)
|
||||||
|
internal_linux_ledger = _tar_member(
|
||||||
|
linux[LINUX_ARCHIVE], f"{LINUX_PACKAGE_ROOT}/SHA256SUMS"
|
||||||
|
)
|
||||||
|
internal_windows_build = _zip_member(
|
||||||
|
windows[WINDOWS_ARCHIVE], f"{WINDOWS_PACKAGE_ROOT}/BUILD-INFO.json"
|
||||||
|
)
|
||||||
|
internal_windows_ledger = _zip_member(
|
||||||
|
windows[WINDOWS_ARCHIVE], f"{WINDOWS_PACKAGE_ROOT}/SHA256SUMS"
|
||||||
|
)
|
||||||
|
internal_windows_verification = _zip_member(
|
||||||
|
windows[WINDOWS_ARCHIVE], f"{WINDOWS_PACKAGE_ROOT}/WINDOWS-VERIFICATION.json"
|
||||||
|
)
|
||||||
|
comparisons = (
|
||||||
|
(linux_build_info, internal_linux_build, "Linux BUILD-INFO.json"),
|
||||||
|
(linux_ledger, internal_linux_ledger, "Linux SHA256SUMS"),
|
||||||
|
(windows_build_info, internal_windows_build, "Windows BUILD-INFO.json"),
|
||||||
|
(windows_ledger, internal_windows_ledger, "Windows SHA256SUMS"),
|
||||||
|
(windows_verification, internal_windows_verification, "WINDOWS-VERIFICATION.json"),
|
||||||
|
)
|
||||||
|
for external, internal, label in comparisons:
|
||||||
|
if external != internal:
|
||||||
|
raise ValueError(f"external and packaged {label} do not match")
|
||||||
|
|
||||||
|
if b" Himegari.x86_64\n" not in linux_ledger:
|
||||||
|
raise ValueError("Linux SHA256SUMS does not contain the packaged executable")
|
||||||
|
if b" Himegari.exe\n" not in windows_ledger:
|
||||||
|
raise ValueError("Windows SHA256SUMS does not contain the packaged executable")
|
||||||
|
|
||||||
|
output_directory = output_directory.resolve()
|
||||||
|
output_directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
checksum_path = output_directory / RELEASE_CHECKSUMS
|
||||||
|
content = f"{linux_hash} {LINUX_ARCHIVE}\n{windows_hash} {WINDOWS_ARCHIVE}\n"
|
||||||
|
with checksum_path.open("w", encoding="ascii", newline="\n") as stream:
|
||||||
|
stream.write(content)
|
||||||
|
return [linux[LINUX_ARCHIVE], windows[WINDOWS_ARCHIVE], checksum_path]
|
||||||
|
|
||||||
|
|
||||||
def _validate_release(release: dict[str, Any], tag: str, target: str, title: str) -> int:
|
def _validate_release(release: dict[str, Any], tag: str, target: str, title: str) -> int:
|
||||||
@@ -227,9 +401,15 @@ def promote_release(
|
|||||||
upload: Callable[[int, Path], dict[str, Any]],
|
upload: Callable[[int, Path], dict[str, Any]],
|
||||||
tag: str,
|
tag: str,
|
||||||
target: str,
|
target: str,
|
||||||
assets: list[Path],
|
linux_directory: Path,
|
||||||
|
windows_directory: Path,
|
||||||
|
output_directory: Path,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
validate_inputs(tag, target, assets)
|
assets = prepare_release_assets(
|
||||||
|
tag, target, linux_directory, windows_directory, output_directory
|
||||||
|
)
|
||||||
|
if {asset.name for asset in assets} != EXPECTED_RELEASE_ASSETS:
|
||||||
|
raise RuntimeError("prepared release asset set is invalid")
|
||||||
title = f"OpenMaidEngine Himegari {tag}"
|
title = f"OpenMaidEngine Himegari {tag}"
|
||||||
release = api.get_release(tag)
|
release = api.get_release(tag)
|
||||||
if release is None:
|
if release is None:
|
||||||
@@ -238,9 +418,10 @@ def promote_release(
|
|||||||
"target_commitish": target,
|
"target_commitish": target,
|
||||||
"name": title,
|
"name": title,
|
||||||
"body": (
|
"body": (
|
||||||
"Automated Linux x64 release built from `" + target + "`.\n\n"
|
"Automated Linux and Windows x64 release built from `" + target + "`.\n\n"
|
||||||
"The attached archive passed the packaged opcode-metadata and FFmpeg ABI smoke gate. "
|
"The Linux archive passed the dynamic opcode/FFmpeg package smoke. The Windows archive "
|
||||||
"BUILD-INFO.json, SHA256SUMS, and package-smoke.log provide the external build evidence."
|
"passed structural AMD64 PE, payload, ABI export, and FFmpeg import verification without "
|
||||||
|
"executing the EXE. Each archive contains its detailed build metadata and payload ledger."
|
||||||
),
|
),
|
||||||
"draft": False,
|
"draft": False,
|
||||||
"prerelease": False,
|
"prerelease": False,
|
||||||
@@ -254,6 +435,9 @@ def promote_release(
|
|||||||
if name in existing_assets:
|
if name in existing_assets:
|
||||||
raise ValueError(f"Gitea release has duplicate asset names: {name}")
|
raise ValueError(f"Gitea release has duplicate asset names: {name}")
|
||||||
existing_assets[name] = existing
|
existing_assets[name] = existing
|
||||||
|
unexpected = sorted(set(existing_assets) - EXPECTED_RELEASE_ASSETS)
|
||||||
|
if unexpected:
|
||||||
|
raise ValueError("Gitea release has unexpected assets: " + ", ".join(unexpected))
|
||||||
|
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
existing = existing_assets.get(asset.name)
|
existing = existing_assets.get(asset.name)
|
||||||
@@ -268,9 +452,10 @@ def promote_release(
|
|||||||
raise RuntimeError(f"Gitea reported an unexpected uploaded asset: {asset.name}")
|
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)}
|
final_assets = {asset.get("name"): asset for asset in api.list_assets(release_id)}
|
||||||
|
if set(final_assets) != EXPECTED_RELEASE_ASSETS:
|
||||||
|
raise RuntimeError(f"release asset verification failed: {sorted(final_assets)}")
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
published = final_assets.get(asset.name)
|
if final_assets[asset.name].get("size") != asset.stat().st_size:
|
||||||
if published is None or published.get("size") != asset.stat().st_size:
|
|
||||||
raise RuntimeError(f"release asset verification failed: {asset.name}")
|
raise RuntimeError(f"release asset verification failed: {asset.name}")
|
||||||
return release
|
return release
|
||||||
|
|
||||||
@@ -281,12 +466,22 @@ def main(arguments: list[str] | None = None) -> int:
|
|||||||
parser.add_argument("--repository", required=True)
|
parser.add_argument("--repository", required=True)
|
||||||
parser.add_argument("--tag", required=True)
|
parser.add_argument("--tag", required=True)
|
||||||
parser.add_argument("--target", required=True)
|
parser.add_argument("--target", required=True)
|
||||||
parser.add_argument("--asset", action="append", required=True, type=Path)
|
parser.add_argument("--linux-artifact-directory", required=True, type=Path)
|
||||||
|
parser.add_argument("--windows-artifact-directory", required=True, type=Path)
|
||||||
|
parser.add_argument("--output-directory", required=True, type=Path)
|
||||||
args = parser.parse_args(arguments)
|
args = parser.parse_args(arguments)
|
||||||
|
|
||||||
token = os.environ.get("GITEA_TOKEN", "")
|
token = os.environ.get("GITEA_TOKEN", "")
|
||||||
api = GiteaApi(args.server, args.repository, token)
|
api = GiteaApi(args.server, args.repository, token)
|
||||||
release = promote_release(api, api.upload_asset, args.tag, args.target, args.asset)
|
release = promote_release(
|
||||||
|
api,
|
||||||
|
api.upload_asset,
|
||||||
|
args.tag,
|
||||||
|
args.target,
|
||||||
|
args.linux_artifact_directory,
|
||||||
|
args.windows_artifact_directory,
|
||||||
|
args.output_directory,
|
||||||
|
)
|
||||||
print(f"Gitea release ready: {release.get('html_url', args.tag)}")
|
print(f"Gitea release ready: {release.get('html_url', args.tag)}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -294,6 +489,6 @@ def main(arguments: list[str] | None = None) -> int:
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
except (OSError, RuntimeError, ValueError) as error:
|
except (OSError, RuntimeError, ValueError, tarfile.TarError, zipfile.BadZipFile) as error:
|
||||||
print(f"release promotion failed: {error}", file=sys.stderr)
|
print(f"release promotion failed: {error}", file=sys.stderr)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class DotnetPublishProxyTests(unittest.TestCase):
|
|||||||
"AGE_PREPUBLISHED_OUTPUT": str(source),
|
"AGE_PREPUBLISHED_OUTPUT": str(source),
|
||||||
"AGE_PUBLISH_OUTPUT_ROOT": str(output_root),
|
"AGE_PUBLISH_OUTPUT_ROOT": str(output_root),
|
||||||
"AGE_PUBLISH_ASSEMBLY": "Himegari.dll",
|
"AGE_PUBLISH_ASSEMBLY": "Himegari.dll",
|
||||||
|
"AGE_PUBLISH_RUNTIME": "linux-x64",
|
||||||
})
|
})
|
||||||
self.assertEqual(output.resolve(), staged)
|
self.assertEqual(output.resolve(), staged)
|
||||||
self.assertEqual(b"managed", (staged / "Himegari.dll").read_bytes())
|
self.assertEqual(b"managed", (staged / "Himegari.dll").read_bytes())
|
||||||
@@ -48,6 +49,7 @@ class DotnetPublishProxyTests(unittest.TestCase):
|
|||||||
"AGE_PREPUBLISHED_OUTPUT": str(source),
|
"AGE_PREPUBLISHED_OUTPUT": str(source),
|
||||||
"AGE_PUBLISH_OUTPUT_ROOT": str(root / "reserved"),
|
"AGE_PUBLISH_OUTPUT_ROOT": str(root / "reserved"),
|
||||||
"AGE_PUBLISH_ASSEMBLY": "Himegari.dll",
|
"AGE_PUBLISH_ASSEMBLY": "Himegari.dll",
|
||||||
|
"AGE_PUBLISH_RUNTIME": "linux-x64",
|
||||||
}
|
}
|
||||||
wrong_runtime = dotnet_publish_proxy.PublishRequest(
|
wrong_runtime = dotnet_publish_proxy.PublishRequest(
|
||||||
project, "ExportRelease", "win-x64", "true", root / "reserved/output"
|
project, "ExportRelease", "win-x64", "true", root / "reserved/output"
|
||||||
@@ -60,6 +62,27 @@ class DotnetPublishProxyTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, "outside"):
|
with self.assertRaisesRegex(ValueError, "outside"):
|
||||||
dotnet_publish_proxy.stage_publish(escaped, environment)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -53,27 +53,52 @@ class RangeReaderTests(unittest.TestCase):
|
|||||||
"templates": {
|
"templates": {
|
||||||
"url": "https://invalid.example/templates.tpz",
|
"url": "https://invalid.example/templates.tpz",
|
||||||
"size": len(archive_bytes),
|
"size": len(archive_bytes),
|
||||||
"members": [{
|
"members": [
|
||||||
|
{
|
||||||
"archive_path": "templates/linux_release.x86_64",
|
"archive_path": "templates/linux_release.x86_64",
|
||||||
"install_name": "linux_release.x86_64",
|
"install_name": "linux_release.x86_64",
|
||||||
"sha256": hashlib.sha256(release).hexdigest(),
|
"sha256": hashlib.sha256(release).hexdigest(),
|
||||||
"size": len(release),
|
"size": len(release),
|
||||||
"mode": "0755",
|
"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")
|
}), encoding="utf-8")
|
||||||
destination = root / "templates"
|
destination = root / "templates"
|
||||||
with patch.object(install_godot_templates, "open_http_range_reader", return_value=reader()) as opened:
|
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()
|
opened.assert_called_once()
|
||||||
self.assertEqual(release, installed[0].read_bytes())
|
self.assertEqual(release, installed[0].read_bytes())
|
||||||
self.assertFalse((destination / "windows_release_x86_64.exe").exists())
|
self.assertFalse((destination / "windows_release_x86_64.exe").exists())
|
||||||
|
|
||||||
with patch.object(install_godot_templates, "open_http_range_reader") as opened:
|
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()
|
opened.assert_not_called()
|
||||||
self.assertEqual(installed, reused)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
96
tools/test_package_windows_x64.py
Executable file
96
tools/test_package_windows_x64.py
Executable 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()
|
||||||
@@ -2,17 +2,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import publish_gitea_release
|
import publish_gitea_release
|
||||||
|
import verify_windows_native
|
||||||
|
|
||||||
|
|
||||||
TARGET = "0123456789abcdef0123456789abcdef01234567"
|
TARGET = "0123456789abcdef0123456789abcdef01234567"
|
||||||
TAG = "v0.1.0"
|
TAG = "v0.2.0"
|
||||||
|
|
||||||
|
|
||||||
class FakeApi:
|
class FakeApi:
|
||||||
@@ -48,46 +52,127 @@ def matching_release() -> dict[str, Any]:
|
|||||||
"name": f"OpenMaidEngine Himegari {TAG}",
|
"name": f"OpenMaidEngine Himegari {TAG}",
|
||||||
"draft": False,
|
"draft": False,
|
||||||
"prerelease": False,
|
"prerelease": False,
|
||||||
"html_url": "https://gitea.invalid/releases/tag/v0.1.0",
|
"html_url": "https://gitea.invalid/releases/tag/v0.2.0",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_assets(root: Path) -> list[Path]:
|
def json_bytes(value: dict[str, Any]) -> bytes:
|
||||||
archive = root / "OpenMaidEngine-Himegari-linux-x64.tar.gz"
|
return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode()
|
||||||
archive.write_bytes(b"synthetic archive")
|
|
||||||
checksum = root / "OpenMaidEngine-Himegari-linux-x64.tar.gz.sha256"
|
|
||||||
checksum.write_text(
|
def build_info(rid: str) -> bytes:
|
||||||
f"{hashlib.sha256(archive.read_bytes()).hexdigest()} {archive.name}\n",
|
return json_bytes({
|
||||||
encoding="ascii",
|
|
||||||
)
|
|
||||||
build_info = root / "BUILD-INFO.json"
|
|
||||||
build_info.write_text(json.dumps({
|
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"source_commit": TARGET,
|
"source_commit": TARGET,
|
||||||
"source_dirty": False,
|
"source_dirty": False,
|
||||||
"target": "linux-x64",
|
"target": rid,
|
||||||
}), encoding="utf-8")
|
})
|
||||||
ledger = root / "SHA256SUMS"
|
|
||||||
ledger.write_text(f"{'0' * 64} Himegari.x86_64\n", encoding="ascii")
|
|
||||||
smoke = root / "package-smoke.log"
|
def windows_verification() -> bytes:
|
||||||
smoke.write_text("PACKAGE SMOKE OK: opcodes=548 ffmpeg-abi=3\n", encoding="utf-8")
|
return json_bytes({
|
||||||
return [archive, checksum, build_info, ledger, smoke]
|
"schema_version": 1,
|
||||||
|
"target": "win-x64",
|
||||||
|
"executable_machine": "AMD64",
|
||||||
|
"native": {
|
||||||
|
"schema_version": 1,
|
||||||
|
"target": "win-x64",
|
||||||
|
"bundle": "data_Himegari_windows_x86_64",
|
||||||
|
"machines": {
|
||||||
|
name: "AMD64"
|
||||||
|
for name in (verify_windows_native.SHIM, *verify_windows_native.RUNTIME_DLLS)
|
||||||
|
},
|
||||||
|
"shim_exports": sorted(verify_windows_native.REQUIRED_EXPORTS),
|
||||||
|
"shim_imports": [*verify_windows_native.RUNTIME_DLLS, "KERNEL32.dll"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def add_tar_bytes(archive: tarfile.TarFile, name: str, data: bytes) -> None:
|
||||||
|
info = tarfile.TarInfo(name)
|
||||||
|
info.size = len(data)
|
||||||
|
archive.addfile(info, io.BytesIO(data))
|
||||||
|
|
||||||
|
|
||||||
|
def write_checksum(directory: Path, archive: Path) -> None:
|
||||||
|
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
|
||||||
|
(directory / f"{archive.name}.sha256").write_text(
|
||||||
|
f"{digest} {archive.name}\n", encoding="ascii"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_artifacts(root: Path) -> tuple[Path, Path, Path]:
|
||||||
|
linux = root / "linux"
|
||||||
|
windows = root / "windows"
|
||||||
|
prepared = root / "prepared"
|
||||||
|
linux.mkdir()
|
||||||
|
windows.mkdir()
|
||||||
|
|
||||||
|
linux_info = build_info("linux-x64")
|
||||||
|
linux_ledger = f"{'0' * 64} Himegari.x86_64\n".encode()
|
||||||
|
linux_archive = linux / publish_gitea_release.LINUX_ARCHIVE
|
||||||
|
with tarfile.open(linux_archive, "w:gz") as archive:
|
||||||
|
add_tar_bytes(
|
||||||
|
archive,
|
||||||
|
f"{publish_gitea_release.LINUX_PACKAGE_ROOT}/BUILD-INFO.json",
|
||||||
|
linux_info,
|
||||||
|
)
|
||||||
|
add_tar_bytes(
|
||||||
|
archive,
|
||||||
|
f"{publish_gitea_release.LINUX_PACKAGE_ROOT}/SHA256SUMS",
|
||||||
|
linux_ledger,
|
||||||
|
)
|
||||||
|
(linux / "BUILD-INFO.json").write_bytes(linux_info)
|
||||||
|
(linux / "SHA256SUMS").write_bytes(linux_ledger)
|
||||||
|
(linux / "package-smoke.log").write_text(
|
||||||
|
publish_gitea_release.SMOKE_MARKER + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
write_checksum(linux, linux_archive)
|
||||||
|
|
||||||
|
windows_info = build_info("win-x64")
|
||||||
|
windows_ledger = f"{'1' * 64} Himegari.exe\n".encode()
|
||||||
|
verification = windows_verification()
|
||||||
|
windows_archive = windows / publish_gitea_release.WINDOWS_ARCHIVE
|
||||||
|
with zipfile.ZipFile(windows_archive, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
root_name = publish_gitea_release.WINDOWS_PACKAGE_ROOT
|
||||||
|
archive.writestr(f"{root_name}/BUILD-INFO.json", windows_info)
|
||||||
|
archive.writestr(f"{root_name}/SHA256SUMS", windows_ledger)
|
||||||
|
archive.writestr(f"{root_name}/WINDOWS-VERIFICATION.json", verification)
|
||||||
|
(windows / "BUILD-INFO.json").write_bytes(windows_info)
|
||||||
|
(windows / "SHA256SUMS").write_bytes(windows_ledger)
|
||||||
|
(windows / "WINDOWS-VERIFICATION.json").write_bytes(verification)
|
||||||
|
write_checksum(windows, windows_archive)
|
||||||
|
return linux, windows, prepared
|
||||||
|
|
||||||
|
|
||||||
class PublishGiteaReleaseTests(unittest.TestCase):
|
class PublishGiteaReleaseTests(unittest.TestCase):
|
||||||
def test_creates_release_and_uploads_verified_asset_set(self) -> None:
|
def test_creates_release_and_uploads_three_verified_assets(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
assets = create_assets(Path(temporary))
|
linux, windows, prepared = create_artifacts(Path(temporary))
|
||||||
api = FakeApi()
|
api = FakeApi()
|
||||||
result = publish_gitea_release.promote_release(api, api.upload, TAG, TARGET, assets)
|
result = publish_gitea_release.promote_release(
|
||||||
|
api, api.upload, TAG, TARGET, linux, windows, prepared
|
||||||
|
)
|
||||||
self.assertEqual(17, result["id"])
|
self.assertEqual(17, result["id"])
|
||||||
self.assertEqual(TAG, api.created_payload["tag_name"])
|
self.assertEqual(TAG, api.created_payload["tag_name"])
|
||||||
self.assertEqual(TARGET, api.created_payload["target_commitish"])
|
self.assertEqual(TARGET, api.created_payload["target_commitish"])
|
||||||
self.assertEqual({asset.name for asset in assets}, {asset["name"] for asset in api.assets})
|
self.assertIn("Windows archive", api.created_payload["body"])
|
||||||
|
self.assertEqual(
|
||||||
|
publish_gitea_release.EXPECTED_RELEASE_ASSETS,
|
||||||
|
{asset["name"] for asset in api.assets},
|
||||||
|
)
|
||||||
|
checksums = (prepared / publish_gitea_release.RELEASE_CHECKSUMS).read_text(
|
||||||
|
encoding="ascii"
|
||||||
|
)
|
||||||
|
self.assertIn(publish_gitea_release.LINUX_ARCHIVE, checksums)
|
||||||
|
self.assertIn(publish_gitea_release.WINDOWS_ARCHIVE, checksums)
|
||||||
|
|
||||||
def test_retry_keeps_matching_assets_and_uploads_only_missing(self) -> None:
|
def test_retry_keeps_matching_assets_and_uploads_only_missing(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
assets = create_assets(Path(temporary))
|
linux, windows, prepared = create_artifacts(Path(temporary))
|
||||||
|
assets = publish_gitea_release.prepare_release_assets(
|
||||||
|
TAG, TARGET, linux, windows, prepared
|
||||||
|
)
|
||||||
api = FakeApi(matching_release())
|
api = FakeApi(matching_release())
|
||||||
api.assets.append({"name": assets[0].name, "size": assets[0].stat().st_size})
|
api.assets.append({"name": assets[0].name, "size": assets[0].stat().st_size})
|
||||||
uploaded: list[str] = []
|
uploaded: list[str] = []
|
||||||
@@ -96,40 +181,68 @@ class PublishGiteaReleaseTests(unittest.TestCase):
|
|||||||
uploaded.append(asset.name)
|
uploaded.append(asset.name)
|
||||||
return api.upload(release_id, asset)
|
return api.upload(release_id, asset)
|
||||||
|
|
||||||
publish_gitea_release.promote_release(api, upload, TAG, TARGET, assets)
|
publish_gitea_release.promote_release(
|
||||||
|
api, upload, TAG, TARGET, linux, windows, prepared
|
||||||
|
)
|
||||||
self.assertNotIn(assets[0].name, uploaded)
|
self.assertNotIn(assets[0].name, uploaded)
|
||||||
self.assertEqual(len(assets) - 1, len(uploaded))
|
self.assertEqual(2, len(uploaded))
|
||||||
self.assertIsNone(api.created_payload)
|
self.assertIsNone(api.created_payload)
|
||||||
|
|
||||||
def test_refuses_mismatched_release_and_asset_collision(self) -> None:
|
def test_refuses_mismatched_release_and_asset_collision(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
assets = create_assets(Path(temporary))
|
linux, windows, prepared = create_artifacts(Path(temporary))
|
||||||
wrong_release = matching_release()
|
wrong_release = matching_release()
|
||||||
wrong_release["target_commitish"] = "f" * 40
|
wrong_release["target_commitish"] = "f" * 40
|
||||||
api = FakeApi(wrong_release)
|
api = FakeApi(wrong_release)
|
||||||
with self.assertRaisesRegex(ValueError, "does not match"):
|
with self.assertRaisesRegex(ValueError, "does not match"):
|
||||||
publish_gitea_release.promote_release(api, api.upload, TAG, TARGET, assets)
|
publish_gitea_release.promote_release(
|
||||||
|
api, api.upload, TAG, TARGET, linux, windows, prepared
|
||||||
|
)
|
||||||
|
|
||||||
api = FakeApi(matching_release())
|
api = FakeApi(matching_release())
|
||||||
api.assets.append({"name": assets[0].name, "size": 999})
|
api.assets.append({"name": publish_gitea_release.LINUX_ARCHIVE, "size": 999})
|
||||||
with self.assertRaisesRegex(ValueError, "will not be overwritten"):
|
with self.assertRaisesRegex(ValueError, "will not be overwritten"):
|
||||||
publish_gitea_release.promote_release(api, api.upload, TAG, TARGET, assets)
|
publish_gitea_release.promote_release(
|
||||||
|
api, api.upload, TAG, TARGET, linux, windows, prepared
|
||||||
|
)
|
||||||
|
|
||||||
def test_refuses_mismatched_build_evidence(self) -> None:
|
api = FakeApi(matching_release())
|
||||||
|
api.assets.append({"name": "stale.txt", "size": 1})
|
||||||
|
with self.assertRaisesRegex(ValueError, "unexpected assets"):
|
||||||
|
publish_gitea_release.promote_release(
|
||||||
|
api, api.upload, TAG, TARGET, linux, windows, prepared
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_refuses_mismatched_archive_commit_and_structural_evidence(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
assets = create_assets(Path(temporary))
|
linux, windows, prepared = create_artifacts(Path(temporary))
|
||||||
by_name = {asset.name: asset for asset in assets}
|
(linux / publish_gitea_release.LINUX_ARCHIVE).write_bytes(b"changed")
|
||||||
by_name["OpenMaidEngine-Himegari-linux-x64.tar.gz"].write_bytes(b"changed")
|
|
||||||
with self.assertRaisesRegex(ValueError, "external SHA-256"):
|
with self.assertRaisesRegex(ValueError, "external SHA-256"):
|
||||||
publish_gitea_release.validate_inputs(TAG, TARGET, assets)
|
publish_gitea_release.prepare_release_assets(
|
||||||
|
TAG, TARGET, linux, windows, prepared
|
||||||
|
)
|
||||||
|
|
||||||
assets = create_assets(Path(temporary))
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
by_name = {asset.name: asset for asset in assets}
|
linux, windows, prepared = create_artifacts(Path(temporary))
|
||||||
build_info = json.loads(by_name["BUILD-INFO.json"].read_text(encoding="utf-8"))
|
info = json.loads((windows / "BUILD-INFO.json").read_text(encoding="utf-8"))
|
||||||
build_info["source_commit"] = "f" * 40
|
info["source_commit"] = "f" * 40
|
||||||
by_name["BUILD-INFO.json"].write_text(json.dumps(build_info), encoding="utf-8")
|
(windows / "BUILD-INFO.json").write_bytes(json_bytes(info))
|
||||||
with self.assertRaisesRegex(ValueError, "does not match"):
|
with self.assertRaisesRegex(ValueError, "does not match"):
|
||||||
publish_gitea_release.validate_inputs(TAG, TARGET, assets)
|
publish_gitea_release.prepare_release_assets(
|
||||||
|
TAG, TARGET, linux, windows, prepared
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
linux, windows, prepared = create_artifacts(Path(temporary))
|
||||||
|
report = json.loads(
|
||||||
|
(windows / "WINDOWS-VERIFICATION.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
report["executable_machine"] = "I386"
|
||||||
|
(windows / "WINDOWS-VERIFICATION.json").write_bytes(json_bytes(report))
|
||||||
|
with self.assertRaisesRegex(ValueError, "not accepted"):
|
||||||
|
publish_gitea_release.prepare_release_assets(
|
||||||
|
TAG, TARGET, linux, windows, prepared
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
80
tools/test_release_workflow.py
Executable file
80
tools/test_release_workflow.py
Executable file
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
WORKFLOW = REPO / ".gitea/workflows/linux-release-build.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def job(text: str, name: str, next_name: str | None) -> str:
|
||||||
|
start = text.index(f" {name}:\n")
|
||||||
|
end = len(text) if next_name is None else text.index(f" {next_name}:\n", start + 1)
|
||||||
|
return text[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseWorkflowTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls.text = WORKFLOW.read_text(encoding="utf-8")
|
||||||
|
cls.linux = job(cls.text, "linux-release", "windows-release")
|
||||||
|
cls.windows = job(cls.text, "windows-release", "publish-release")
|
||||||
|
cls.publish = job(cls.text, "publish-release", None)
|
||||||
|
|
||||||
|
def test_build_jobs_are_linux_hosted_read_only_and_target_separate(self) -> None:
|
||||||
|
self.assertRegex(self.text, r"(?m)^permissions:\n contents: read$")
|
||||||
|
self.assertIn("runs-on: ubuntu-latest", self.linux)
|
||||||
|
self.assertIn("run: ./tools/build-linux-x64.sh", self.linux)
|
||||||
|
self.assertIn("runs-on: ubuntu-latest", self.windows)
|
||||||
|
self.assertIn("run: ./tools/build-windows-x64.sh", self.windows)
|
||||||
|
self.assertNotIn("releases: write", self.linux + self.windows)
|
||||||
|
self.assertNotIn("secrets.", self.linux + self.windows)
|
||||||
|
|
||||||
|
def test_windows_job_provisions_and_caches_only_its_cross_inputs(self) -> None:
|
||||||
|
self.assertRegex(
|
||||||
|
self.windows,
|
||||||
|
r"gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64",
|
||||||
|
)
|
||||||
|
self.assertIn("binutils-mingw-w64-x86-64", self.windows)
|
||||||
|
self.assertIn("ffmpeg-*-win64-lgpl-shared-*.zip", self.windows)
|
||||||
|
self.assertIn("windows_release_x86_64.exe", self.windows)
|
||||||
|
self.assertIn("dependency-win64.json", self.windows)
|
||||||
|
self.assertNotIn("linux_release.x86_64", self.windows)
|
||||||
|
self.assertNotIn("dependency-linux-x64.json", self.windows)
|
||||||
|
self.assertNotRegex(self.windows.lower(), r"\bwine(?:32|64)?\b")
|
||||||
|
self.assertNotIn("--package-smoke", self.windows)
|
||||||
|
|
||||||
|
def test_windows_artifact_is_archive_plus_structural_evidence(self) -> None:
|
||||||
|
for name in (
|
||||||
|
"OpenMaidEngine-Himegari-windows-x64.zip",
|
||||||
|
"OpenMaidEngine-Himegari-windows-x64.zip.sha256",
|
||||||
|
"BUILD-INFO.json",
|
||||||
|
"SHA256SUMS",
|
||||||
|
"WINDOWS-VERIFICATION.json",
|
||||||
|
):
|
||||||
|
self.assertIn(name, self.windows)
|
||||||
|
self.assertIn("if-no-files-found: error", self.windows)
|
||||||
|
self.assertIn("retention-days: 30", self.windows)
|
||||||
|
|
||||||
|
def test_tag_promotion_requires_and_downloads_both_platform_artifacts(self) -> None:
|
||||||
|
self.assertIn("if: startsWith(gitea.ref, 'refs/tags/v')", self.publish)
|
||||||
|
self.assertRegex(
|
||||||
|
self.publish,
|
||||||
|
r"(?m)^ needs:\n - linux-release\n - windows-release$",
|
||||||
|
)
|
||||||
|
self.assertIn("releases: write", self.publish)
|
||||||
|
self.assertIn("OpenMaidEngine-Himegari-linux-x64-${{ gitea.sha }}", self.publish)
|
||||||
|
self.assertIn("OpenMaidEngine-Himegari-windows-x64-${{ gitea.sha }}", self.publish)
|
||||||
|
self.assertIn("path: build/release-assets/linux", self.publish)
|
||||||
|
self.assertIn("path: build/release-assets/windows", self.publish)
|
||||||
|
self.assertIn("--linux-artifact-directory build/release-assets/linux", self.publish)
|
||||||
|
self.assertIn("--windows-artifact-directory build/release-assets/windows", self.publish)
|
||||||
|
self.assertIn("--output-directory build/release-assets/prepared", self.publish)
|
||||||
|
self.assertNotIn("--asset", self.publish)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -32,8 +32,10 @@ CORE_TESTS = (
|
|||||||
"test_validate.py",
|
"test_validate.py",
|
||||||
"test_install_godot_templates.py",
|
"test_install_godot_templates.py",
|
||||||
"test_package_linux_x64.py",
|
"test_package_linux_x64.py",
|
||||||
|
"test_package_windows_x64.py",
|
||||||
"test_dotnet_publish_proxy.py",
|
"test_dotnet_publish_proxy.py",
|
||||||
"test_publish_gitea_release.py",
|
"test_publish_gitea_release.py",
|
||||||
|
"test_release_workflow.py",
|
||||||
"test_verify_windows_native.py",
|
"test_verify_windows_native.py",
|
||||||
"test_diff_optrace.py",
|
"test_diff_optrace.py",
|
||||||
"test_engine_ctx.py",
|
"test_engine_ctx.py",
|
||||||
|
|||||||
@@ -59,7 +59,12 @@ def parse_objdump(output: str) -> tuple[set[str], set[str]]:
|
|||||||
return imports, exports
|
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()
|
bundle = bundle.resolve()
|
||||||
if not bundle.is_dir():
|
if not bundle.is_dir():
|
||||||
raise ValueError(f"Windows native bundle directory was not found: {bundle}")
|
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()
|
for path in bundle.iterdir()
|
||||||
if path.is_file() and path.suffix.lower() == ".dll" and path.name.lower() not in expected_dlls
|
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))
|
raise ValueError("Windows native bundle has unexpected DLLs: " + ", ".join(unexpected_dlls))
|
||||||
|
|
||||||
machines: dict[str, str] = {}
|
machines: dict[str, str] = {}
|
||||||
|
|||||||
Reference in New Issue
Block a user