5 Commits

Author SHA1 Message Date
gamer147
93d8236371 Record dual-platform CI acceptance
All checks were successful
Core validation / Linux core gate (push) Successful in 51s
Release builds / Linux x64 artifact (push) Successful in 1m23s
Release builds / Windows x64 artifact (push) Successful in 1m36s
Release builds / Publish tagged Gitea release (push) Successful in 22s
2026-08-03 22:49:24 -04:00
gamer147
d657c63e57 Require both platform artifacts for releases
All checks were successful
Release builds / Linux x64 artifact (push) Successful in 2m17s
Release builds / Windows x64 artifact (push) Successful in 1m19s
Release builds / Publish tagged Gitea release (push) Has been skipped
Core validation / Linux core gate (push) Successful in 1m17s
2026-08-03 22:35:43 -04:00
gamer147
4799b48b9c Record hosted Windows artifact acceptance
All checks were successful
Core validation / Linux core gate (push) Successful in 56s
Release builds / Linux x64 artifact (push) Successful in 1m21s
Release builds / Windows x64 artifact (push) Successful in 1m17s
Release builds / Publish tagged Gitea release (push) Has been skipped
2026-08-03 22:22:54 -04:00
gamer147
9d3ab30bbb Build Windows release artifact in Gitea
All checks were successful
Core validation / Linux core gate (push) Successful in 1m0s
Release builds / Linux x64 artifact (push) Successful in 1m22s
Release builds / Windows x64 artifact (push) Successful in 1m52s
Release builds / Publish tagged Gitea release (push) Has been skipped
2026-08-03 22:16:35 -04:00
gamer147
02e9891baa Cross-export Windows release package on Linux
All checks were successful
Core validation / Linux core gate (push) Successful in 1m40s
Linux release build / Linux x64 artifact (push) Successful in 1m37s
Linux release build / Publish tagged Gitea release (push) Has been skipped
2026-08-03 21:59:52 -04:00
23 changed files with 1365 additions and 172 deletions

View File

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

View File

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

View File

@@ -36,7 +36,7 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
│ tracked source and generated references use canonical LF │ tracked source and generated references use canonical LF
├── .gitea/workflows/core-validation.yml read-only Ubuntu source-only core gate for the private Gitea remote ├── .gitea/workflows/core-validation.yml read-only Ubuntu source-only core gate for the private Gitea remote
├── .gitea/workflows/linux-release-build.yml ├── .gitea/workflows/linux-release-build.yml
pinned Linux export/package/smoke artifact job 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'*}"

View File

@@ -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
View File

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

View File

@@ -76,13 +76,14 @@ def stage_publish(request: PublishRequest, environ: dict[str, str]) -> Path:
source = Path(environ["AGE_PREPUBLISHED_OUTPUT"]).resolve() 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}")

View File

@@ -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"
} }
] ]
} }

View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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] = {}