Avoid nested publish OOM in Linux export
All checks were successful
Core validation / Linux core gate (push) Successful in 1m30s
Linux release build / Linux x64 artifact (push) Successful in 1m35s

This commit is contained in:
gamer147
2026-08-03 18:59:46 -04:00
parent 8eb8a45e60
commit 400f431652
10 changed files with 313 additions and 10 deletions

View File

@@ -57,6 +57,11 @@ jobs:
dotnet --version
cc --version
readelf --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: Build and smoke-test Linux package
run: ./tools/build-linux-x64.sh

View File

@@ -47,12 +47,13 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
│ ├── validate.py layered core/workspace/runtime/full validation driver
│ ├── test_validate.py pure resolver + validation-plan regressions
│ ├── build-linux-x64.sh one-command pinned Linux export/package/smoke pipeline
│ ├── dotnet_publish_proxy.py guarded low-memory Godot export-time publish staging
│ ├── 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
│ ├── install_godot_templates.py selective HTTP-range installer for pinned template members
│ ├── package_linux_x64.py export verifier + notice/checksum/archive packager
│ ├── test_install_godot_templates.py, test_package_linux_x64.py
│ │ source-only bootstrap/package regressions
│ ├── test_install_godot_templates.py, test_package_linux_x64.py,
│ │ test_dotnet_publish_proxy.py source-only bootstrap/package/publish regressions
│ ├── sys4load.py loader + disassembler (opcode-decoding)
│ ├── age_opcodes.py GENERATED complete Python ABI view (do not hand-edit)
│ ├── opcodes_build.py generator/linter: vm-map/opcodes.toml -> the 5 artifacts below

View File

@@ -58,8 +58,15 @@ fast integration gate. The cache key is derived from both immutable dependency m
prefix; cached archives/templates are still size/hash checked by their owning bootstrap before use. A successful
job retains the archive, external build/checksum manifests, and smoke log for 30 days. It has only read access to
repository contents and does not use private data, secrets, registry credentials, or release-write permission.
The first hosted execution remains the acceptance gate for runner image dependencies and cache/artifact action
behavior; release publication is intentionally deferred until that artifact run succeeds.
The first hosted execution reached Godot's managed publish but was killed with status 137. Local stage-level
measurement reproduced the pressure: the resident Godot export peaks at 772,476 KiB and an isolated managed
publish peaks at 223,764 KiB, so nesting the latter under the former can cross a roughly 1 GiB runner cgroup.
The build now serializes those peaks: it creates the exact self-contained publish first with compiler/build
servers disabled, lets the real Godot export consume a strictly validated one-assembly proxy publish, and stages
the complete external managed payload only after Godot exits. The complete revised path passes locally, including
payload verification and the packaged opcode/FFmpeg smoke gate. The hosted retry remains the acceptance gate for
the mitigation plus cache/artifact action behavior; release publication is intentionally deferred until it
succeeds. The workflow reports both host memory and cgroup limits to make any further runner constraint explicit.
## Dependency inventory

View File

@@ -1005,9 +1005,14 @@ do not mix mechanical moves with semantic changes.
template, calls the same locally accepted build command, and uploads the archive with its external build info,
checksum ledger, and smoke log. Cache entries have no broad fallback and remain subject to the bootstrap's
size/SHA checks. The workflow has no game corpus, secrets, registry login, or release-write authority; tags
still produce ordinary retained workflow artifacts. The immediate gate is the first hosted build on this
server. If it passes, the next bounded slice can promote an already-verified tag artifact to a Gitea release
without rebuilding it; if it fails, fix only the observed runner/action dependency before adding publication.
still produce ordinary retained workflow artifacts. The first hosted build reached Godot's managed publish
and was killed with status 137. Stage-level measurement found a 772,476 KiB Godot peak and a 223,764 KiB
isolated publish peak, explaining why the nested processes can exceed a roughly 1 GiB cgroup. The build now
runs the exact self-contained publish first, substitutes a tightly validated one-assembly publish while Godot
creates the real PCK/executable, and stages the full external managed payload after the editor exits. The
revised complete build, payload verification, and packaged smoke gate pass locally; a hosted retry is the
immediate acceptance gate. Once that succeeds, the next bounded slice can promote an already-verified tag
artifact to a Gitea release without rebuilding it.
**Not cleanup targets:** generated `build/` output, the two intentional solution files, historical
`docs/superpowers/` plans/specifications, and fidelity-specific complexity that is directly covered by the

View File

@@ -380,7 +380,15 @@ member from Godot's all-platform archive; it does not download the complete 1.2
The build regenerates embedded opcode metadata, bootstraps/builds the pinned Linux FFmpeg bundle, performs
the Godot release export, and delegates the shared required/forbidden payload checks to
`package_linux_x64.py`. The packager adds `LICENSE`, `README.md`, `THIRD_PARTY_NOTICES.md`, a source/dependency
`package_linux_x64.py`. To remain inside constrained CI memory cgroups, it first runs the exact self-contained
`ExportRelease` publish with build/compiler servers and debug symbols disabled. Godot then performs its real
PCK/executable export against a one-assembly staging payload supplied by `dotnet_publish_proxy.py`; the proxy
refuses any project, configuration, runtime, self-contained flag, or output root other than the invocation
reserved by the build driver. Because managed outputs are external to the PCK, the complete prepublished
payload replaces that temporary staging directory only after Godot exits. The final payload verifier and
packaged smoke test therefore validate the same complete runtime that is archived.
The packager adds `LICENSE`, `README.md`, `THIRD_PARTY_NOTICES.md`, a source/dependency
`BUILD-INFO.json`, and per-file `SHA256SUMS` to a stable top-level directory. It normalizes archive ownership,
modes, ordering, and timestamps using `SOURCE_DATE_EPOCH` or the source commit time. Outputs are the loose
export under `build/export/linux-x64/`, the staged installation and `package-smoke.log` under
@@ -402,8 +410,8 @@ python3 -X utf8 tools/package_linux_x64.py package build/export/linux-x64
```
The bootstrap prints the pinned editor path. The verifier prints the accepted export directory; the packager
prints the staged root and archive path. Both Python components have source-only synthetic regressions in the
core validation level.
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.
The older Windows-hosted cross-export remains available for local development:

View File

@@ -27,3 +27,6 @@ texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
binary_format/architecture="x86_64"
ssh_remote_deploy/enabled=false
dotnet/include_scripts_content=false
dotnet/include_debug_symbols=false
dotnet/embed_build_outputs=false

View File

@@ -7,6 +7,9 @@ project_root="$repo_root/godot"
export_directory="$repo_root/build/export/linux-x64"
expected_export_directory="$repo_root/build/export/linux-x64"
package_directory="$repo_root/build/package"
managed_publish_directory="$repo_root/build/managed-publish/linux-x64"
managed_proxy_directory="$repo_root/build/managed-publish-proxy/linux-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"
@@ -22,6 +25,55 @@ godot_console="$("$script_dir/bootstrap-godot-linux-x64.sh")"
ffmpeg_sdk="$("$repo_root/native/age_movie_ffmpeg/bootstrap-linux-x64.sh")"
"$repo_root/native/age_movie_ffmpeg/build-linux-x64.sh" "$ffmpeg_sdk"
# Godot keeps the editor resident while it launches dotnet publish. On constrained CI runners, that process
# pair can exceed the job's memory cgroup. Produce the exact ExportRelease/linux-x64 payload first, without
# compiler/build servers, so the memory-heavy compile and editor stages run sequentially.
expected_managed_publish_directory="$repo_root/build/managed-publish/linux-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 linux-x64 \
--self-contained true \
--output "$managed_publish_directory" \
-p:GodotTargetPlatform=linuxbsd \
-p:UseSharedCompilation=false \
-p:BuildInParallel=false \
-p:RestoreDisableParallel=true \
-p:DebugType=None \
-p:DebugSymbols=false
# With dotnet/embed_build_outputs=false, Godot places publish files outside the PCK. Give the editor only the
# required profile assembly while it creates the real PCK, then stage the complete prepublish after the editor
# exits. This avoids making the memory-heavy editor traverse/hash the self-contained runtime and FFmpeg bundle.
expected_managed_proxy_directory="$repo_root/build/managed-publish-proxy/linux-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
@@ -29,9 +81,41 @@ 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" \
PATH="$dotnet_proxy_directory:$PATH" \
XDG_DATA_HOME="$xdg_data_home" "$godot_console" \
--headless --quit-after 120 --path "$project_root" \
--export-release "Linux x86_64" "$export_directory/Himegari.x86_64"
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_linuxbsd_x86_64"
expected_managed_export_directory="$repo_root/build/export/linux-x64/data_Himegari_linuxbsd_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_linux_x64.py" verify "$export_directory"
mapfile -t package_outputs < <(

124
tools/dotnet_publish_proxy.py Executable file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Stage an exact prepublished payload for Godot's redundant export-time publish.
All non-publish dotnet commands are delegated to AGE_REAL_DOTNET. A publish is
accepted only when its configuration, RID, self-contained flag, project, output
root, and prepublished assembly match the values supplied by the build driver.
"""
from __future__ import annotations
import os
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class PublishRequest:
project: Path
configuration: str
runtime: str
self_contained: str
output: Path
def _option(arguments: list[str], *names: str) -> str | None:
for index, argument in enumerate(arguments):
if argument in names:
if index + 1 >= len(arguments):
raise ValueError(f"missing value after {argument}")
return arguments[index + 1]
for name in names:
prefix = name + "="
if argument.startswith(prefix):
return argument[len(prefix):]
return None
def parse_publish(arguments: list[str]) -> PublishRequest:
if not arguments or arguments[0] != "publish":
raise ValueError("arguments are not a dotnet publish command")
project = next(
(Path(argument) for argument in arguments[1:] if argument.lower().endswith(".csproj")),
None,
)
configuration = _option(arguments, "-c", "--configuration")
runtime = _option(arguments, "-r", "--runtime")
self_contained = _option(arguments, "--self-contained")
output = _option(arguments, "-o", "--output")
missing = [
name for name, value in (
("project", project),
("configuration", configuration),
("runtime", runtime),
("self-contained", self_contained),
("output", output),
) if value is None
]
if missing:
raise ValueError("publish command is missing: " + ", ".join(missing))
assert project is not None and configuration is not None and runtime is not None
assert self_contained is not None and output is not None
return PublishRequest(project, configuration, runtime, self_contained, Path(output))
def _is_within(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def stage_publish(request: PublishRequest, environ: dict[str, str]) -> Path:
expected_project = Path(environ["AGE_PUBLISH_PROJECT"]).resolve()
source = Path(environ["AGE_PREPUBLISHED_OUTPUT"]).resolve()
output_root = Path(environ["AGE_PUBLISH_OUTPUT_ROOT"]).resolve()
assembly = environ["AGE_PUBLISH_ASSEMBLY"]
output = request.output.resolve()
if request.project.resolve() != expected_project:
raise ValueError(f"unexpected publish project: {request.project}")
if request.configuration != "ExportRelease":
raise ValueError(f"unexpected publish configuration: {request.configuration}")
if request.runtime != "linux-x64":
raise ValueError(f"unexpected publish runtime: {request.runtime}")
if request.self_contained.lower() != "true":
raise ValueError(f"publish is not self-contained: {request.self_contained}")
if output == output_root or not _is_within(output, output_root):
raise ValueError(f"publish output is outside the reserved root: {output}")
if not (source / assembly).is_file():
raise ValueError(f"prepublished assembly was not found: {source / assembly}")
output.mkdir(parents=True, exist_ok=True)
if any(output.iterdir()):
raise ValueError(f"publish output directory is not empty: {output}")
for child in source.iterdir():
target = output / child.name
if child.is_dir():
shutil.copytree(child, target, symlinks=True)
else:
shutil.copy2(child, target, follow_symlinks=False)
return output
def main(arguments: list[str] | None = None) -> int:
args = list(sys.argv[1:] if arguments is None else arguments)
real_dotnet = os.environ.get("AGE_REAL_DOTNET")
if not real_dotnet:
raise ValueError("AGE_REAL_DOTNET is required")
if not args or args[0] != "publish":
os.execv(real_dotnet, [real_dotnet, *args])
request = parse_publish(args)
output = stage_publish(request, dict(os.environ))
print(f"publish proxy: staged {output}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (KeyError, OSError, ValueError) as error:
print(f"dotnet publish proxy refused request: {error}", file=sys.stderr)
raise SystemExit(2)

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
import dotnet_publish_proxy
class DotnetPublishProxyTests(unittest.TestCase):
def test_stages_exact_expected_publish(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")
(source / "libhostfxr.so").write_bytes(b"runtime")
output_root = root / "godot-publish-dotnet"
output = output_root / "123-ExportRelease-linux-x64"
request = dotnet_publish_proxy.parse_publish([
"publish", str(project), "--self-contained", "true",
"-c", "ExportRelease", "-r", "linux-x64", "-o", str(output),
"-p:GodotTargetPlatform=linuxbsd",
])
staged = dotnet_publish_proxy.stage_publish(request, {
"AGE_PUBLISH_PROJECT": str(project),
"AGE_PREPUBLISHED_OUTPUT": str(source),
"AGE_PUBLISH_OUTPUT_ROOT": str(output_root),
"AGE_PUBLISH_ASSEMBLY": "Himegari.dll",
})
self.assertEqual(output.resolve(), staged)
self.assertEqual(b"managed", (staged / "Himegari.dll").read_bytes())
self.assertEqual(b"runtime", (staged / "libhostfxr.so").read_bytes())
def test_rejects_drift_and_output_escape(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")
environment = {
"AGE_PUBLISH_PROJECT": str(project),
"AGE_PREPUBLISHED_OUTPUT": str(source),
"AGE_PUBLISH_OUTPUT_ROOT": str(root / "reserved"),
"AGE_PUBLISH_ASSEMBLY": "Himegari.dll",
}
wrong_runtime = dotnet_publish_proxy.PublishRequest(
project, "ExportRelease", "win-x64", "true", root / "reserved/output"
)
with self.assertRaisesRegex(ValueError, "runtime"):
dotnet_publish_proxy.stage_publish(wrong_runtime, environment)
escaped = dotnet_publish_proxy.PublishRequest(
project, "ExportRelease", "linux-x64", "true", root / "outside"
)
with self.assertRaisesRegex(ValueError, "outside"):
dotnet_publish_proxy.stage_publish(escaped, environment)
if __name__ == "__main__":
unittest.main()

View File

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