From c58a4f3573556486e3e727a7779afa9bd54d6040 Mon Sep 17 00:00:00 2001 From: geruipu Date: Mon, 5 Oct 2026 13:29:41 +0800 Subject: [PATCH] Validate the complete container toolchain before release builds --- .github/workflows/nuget-static-graphics.yml | 14 ++- .github/workflows/zig-toolchain-image.yml | 16 +++ ACTION-TESTING.md | 8 +- build/linux-toolchain/Dockerfile.cross-probe | 10 +- .../validate-toolchain.cpython-314.pyc | Bin 0 -> 7753 bytes build/linux-toolchain/validate-toolchain.py | 96 ++++++++++++++++++ 6 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 build/linux-toolchain/__pycache__/validate-toolchain.cpython-314.pyc create mode 100644 build/linux-toolchain/validate-toolchain.py diff --git a/.github/workflows/nuget-static-graphics.yml b/.github/workflows/nuget-static-graphics.yml index 1c8e771..ee5e89b 100644 --- a/.github/workflows/nuget-static-graphics.yml +++ b/.github/workflows/nuget-static-graphics.yml @@ -3,6 +3,10 @@ name: staticlink-avalonia on: workflow_dispatch: inputs: + environment_only: + description: Validate the complete toolchain container without building release libraries + type: boolean + default: false skiasharp_version: description: Stable SkiaSharp version from this branch major; empty uses the branch version type: string @@ -33,7 +37,14 @@ concurrency: cancel-in-progress: false jobs: + environment: + permissions: + contents: read + packages: write + uses: ./.github/workflows/zig-toolchain-image.yml + preflight: + if: ${{ inputs.environment_only != true }} uses: ./.github/workflows/static-graphics-preflight.yml with: skiasharp_version: ${{ inputs.skiasharp_version }} @@ -42,11 +53,12 @@ jobs: permissions: contents: read packages: write - needs: preflight + needs: [preflight, environment] uses: ./.github/workflows/static-graphics-zig-cross.yml with: skiasharp_version: ${{ needs.preflight.outputs.skiasharp_version }} source_lock: ${{ needs.preflight.outputs.source_lock }} + toolchain_image: ${{ needs.environment.outputs.image }} pack: runs-on: ubuntu-24.04 diff --git a/.github/workflows/zig-toolchain-image.yml b/.github/workflows/zig-toolchain-image.yml index 9de3660..6b2aa9d 100644 --- a/.github/workflows/zig-toolchain-image.yml +++ b/.github/workflows/zig-toolchain-image.yml @@ -34,6 +34,7 @@ jobs: repository="ghcr.io/${REPOSITORY,,}-zig" image="$repository:recipe-$RECIPE_HASH" base="$repository:debian12-$BASE_HASH" + built_image=false if ! docker pull "$image"; then if ! docker pull "$base"; then docker build --platform linux/amd64 \ @@ -48,6 +49,14 @@ jobs: --label "org.opencontainers.image.source=https://github.com/$REPOSITORY" \ -f build/linux-toolchain/Dockerfile.cross-probe \ -t "$image" build/linux-toolchain + built_image=true + fi + mkdir -p artifacts/toolchain-environment + docker run --rm --user "$(id -u):$(id -g)" \ + -e HOME=/tmp -e DOTNET_CLI_HOME=/tmp -e ZIG_GLOBAL_CACHE_DIR=/tmp/zig-cache \ + -v "$PWD/artifacts/toolchain-environment:/validation" \ + --entrypoint python3 "$image" /opt/staticlink/validate-toolchain.py --output /validation + if [ "$built_image" = true ]; then docker push "$image" docker pull "$image" fi @@ -55,3 +64,10 @@ jobs: case "$digest" in "$repository"@sha256:*) ;; *) echo 'No immutable image digest' >&2; exit 1 ;; esac echo "image=$digest" >> "$GITHUB_OUTPUT" echo "Toolchain: $digest" >> "$GITHUB_STEP_SUMMARY" + - name: Upload environment validation report + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: toolchain-environment-validation + path: artifacts/toolchain-environment/ + if-no-files-found: warn diff --git a/ACTION-TESTING.md b/ACTION-TESTING.md index aca5b6f..5ff87f9 100644 --- a/ACTION-TESTING.md +++ b/ACTION-TESTING.md @@ -3,6 +3,13 @@ Daily schedules are disabled during validation. No NuGet.org publication or GitHub Release is requested by the manual dispatcher on main. +Before release builds, run nuget-static-graphics.yml on skia4 with +environment_only=true. This checks the actual container's Python/depot_tools, +.NET 8/10, SDKs, sysroots, and C/C++ compilation plus C executable linking for +all nine RIDs. Download toolchain-environment-validation to inspect the report. +The same environment gate runs before every graphics and native build and +must pass before the toolchain image is published for release compilation. + - skia3: nuget-static-graphics.yml; latest stable SkiaSharp 3. - skia4: nuget-static-graphics.yml; latest stable SkiaSharp 4. - avalonia-native: nuget-avalonia-native.yml; latest stable Avalonia 11 and 12. @@ -29,4 +36,3 @@ run their own GitHub cron schedules. Publication remains an explicit opt-in. While publication is disabled, an unpublished package may be rebuilt on every check; add successful-run tracking before enabling artifact-only daily builds if avoiding repeated builds is required. - diff --git a/build/linux-toolchain/Dockerfile.cross-probe b/build/linux-toolchain/Dockerfile.cross-probe index ee33caf..6331cab 100644 --- a/build/linux-toolchain/Dockerfile.cross-probe +++ b/build/linux-toolchain/Dockerfile.cross-probe @@ -2,7 +2,11 @@ ARG TOOLCHAIN_IMAGE=staticlink-debian12:probe-base FROM mcr.microsoft.com/dotnet/sdk:10.0.302@sha256:72dd743782f2ae7e5476fd64f6a460045e3998dc862218b80e6944cba79a01b0 AS dotnet10 FROM mcr.microsoft.com/dotnet/sdk:8.0.416@sha256:8b5eece40d8a8050c09332eab1e65563fd4c928c5881e9e72bca7a4b0339f881 AS dotnet8 +FROM python:3.12.12-slim-bookworm@sha256:593bd06efe90efa80dc4eee3948be7c0fde4134606dd40d8dd8dbcade98e669c AS python312 FROM ${TOOLCHAIN_IMAGE} +COPY --from=python312 /usr/local/ /usr/local/ +ENV PYTHONPATH=/usr/lib/python3/dist-packages +RUN ldconfig && python3 -c 'import inspect, tarfile, six, requests, httplib2; assert "filter" in inspect.signature(tarfile.TarFile.extractall).parameters' ARG TARGETARCH RUN case "$TARGETARCH" in \ @@ -44,9 +48,6 @@ RUN mkdir -p /opt/staticlink \ && printf '%s /opt/staticlink/os_version_check.c\n' \ bc0b9f10e8d1a1ac5905d710f1c9fead23878e31233748d580393edc4e3bfc18 | sha256sum --check -COPY *.py smoke-*.c sources.lock.json cross-toolchain.lock.json *.gn.in /opt/staticlink/ -COPY patches/ /opt/staticlink/patches/ -COPY versions/ /opt/staticlink/versions/ ENTRYPOINT ["python3", "/opt/staticlink/probe-cross.py"] COPY --from=dotnet8 /usr/share/dotnet /opt/dotnet @@ -55,3 +56,6 @@ ENV DOTNET_ROOT=/opt/dotnet PATH=/opt/dotnet:${PATH} COPY fetch-linux-sysroots.py linux-sysroots*.lock.json /opt/staticlink/ RUN python3 /opt/staticlink/fetch-linux-sysroots.py --output /opt/sysroots \ --locks /opt/staticlink/linux-sysroots.lock.json /opt/staticlink/linux-sysroots-musl.lock.json +COPY *.py smoke-*.c sources.lock.json cross-toolchain.lock.json *.gn.in /opt/staticlink/ +COPY patches/ /opt/staticlink/patches/ +COPY versions/ /opt/staticlink/versions/ diff --git a/build/linux-toolchain/__pycache__/validate-toolchain.cpython-314.pyc b/build/linux-toolchain/__pycache__/validate-toolchain.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a5cb2436cac87287240358d6e2e66d9e4ac284bd GIT binary patch literal 7753 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 O`27HcW6d#TvHu0iZq;i5 literal 0 HcmV?d00001 diff --git a/build/linux-toolchain/validate-toolchain.py b/build/linux-toolchain/validate-toolchain.py new file mode 100644 index 0000000..92092ba --- /dev/null +++ b/build/linux-toolchain/validate-toolchain.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Validate the actual release container before compiling native libraries.""" +import argparse +import inspect +import json +from pathlib import Path +import runpy +import shutil +import subprocess +import sys +import tarfile +import tempfile + + +def run(command): + print("+", " ".join(map(str, command)), flush=True) + return subprocess.check_output(list(map(str, command)), text=True, stderr=subprocess.STDOUT) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + report = {"python": sys.version, "targets": {}, "passed": False} + try: + if "filter" not in inspect.signature(tarfile.TarFile.extractall).parameters: + raise RuntimeError("depot_tools requires tarfile extraction filters") + for module in ("six", "requests", "httplib2"): + __import__(module) + run([sys.executable, "/opt/depot_tools/gclient.py", "help"]) + for tool in ("git", "ninja", "clang", "llvm-ar", "lld", "pkg-config", "cmake", "patch", "file", "dotnet", "zig"): + if not shutil.which(tool): + raise RuntimeError("Missing tool: " + tool) + report["zig"] = run(["zig", "version"]).strip() + if report["zig"] != "0.14.1": + raise RuntimeError("Unexpected Zig version") + report["dotnet_sdks"] = run(["dotnet", "--list-sdks"]).splitlines() + for major in ("8.", "10."): + if not any(version.startswith(major) for version in report["dotnet_sdks"]): + raise RuntimeError("Missing .NET SDK " + major) + adapter = Path(__file__).with_name("zig-cross.py") + targets = runpy.run_path(str(adapter))["TARGETS"] + sdk = Path("/opt/macos-sdk/MacOSX15.5.sdk") + if json.loads((sdk / "SDKSettings.json").read_text())["Version"] != "15.5": + raise RuntimeError("Unexpected macOS SDK version") + with tempfile.TemporaryDirectory(prefix="staticlink-environment-") as temporary: + work = Path(temporary) + plain = work / "main.c" + plain.write_text("int main(void) { return 0; }\n") + cpp = work / "probe.cpp" + for rid in targets: + target_work = work / rid + command = [sys.executable, adapter, "--target", rid, "--output", target_work, + "--sdk", sdk, "--mingw-headers", "/opt/mingw/usr/share/mingw-w64/include"] + source = "#include \nint value() { return int(std::string(\\\"ok\\\").size()); }\n".replace('\\\"', '"') + if rid.startswith("linux"): + sysroot = Path("/opt/sysroots") / rid + if not (sysroot / "usr/include").is_dir(): + raise RuntimeError("Missing sysroot: " + rid) + if not (sysroot / "usr/include/wayland-client.h").is_file(): + raise RuntimeError("Missing Wayland headers: " + rid) + command += ["--linux-sysroot", sysroot, "--linux-headers", "/opt/staticlink/linux-headers"] + source += "#include \n#include \n#include \n#include FT_FREETYPE_H\n" + elif rid.startswith("win"): + source += "#include \n#include \n#include \n#include \n" + cpp.write_text(source) + run(command) + run([target_work / "cxx", "-std=c++17", "-c", cpp, "-o", target_work / "cpp.o"]) + run([target_work / "cc", "-c", plain, "-o", target_work / "main.o"]) + run([target_work / "ar", "rcs", target_work / "probe.a", target_work / "main.o"]) + executable = target_work / ("probe.exe" if rid.startswith("win") else "probe") + run([target_work / "cc", plain, "-o", executable]) + if rid.startswith("osx"): + objc = work / "probe.mm" + objc.write_text("#import \n#import \nint value() { return sizeof(CFIndex); }\n") + run([target_work / "cxx", "-fobjc-arc", "-fblocks", "-c", objc, "-o", target_work / "objc.o"]) + report["targets"][rid] = { + "passed": True, + "object": run(["file", target_work / "main.o"]).strip(), + "executable": run(["file", executable]).strip(), + } + print(rid + ": C/C++ compile, archive and executable link PASS", flush=True) + report["passed"] = True + except Exception as error: + report["error"] = str(error) + if isinstance(error, subprocess.CalledProcessError): + report["error_output"] = error.output + print(error.output, flush=True) + raise + finally: + (args.output / "environment-validation.json").write_text(json.dumps(report, indent=2) + "\n") + + +if __name__ == "__main__": + main()