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 0000000..a5cb243 Binary files /dev/null and b/build/linux-toolchain/__pycache__/validate-toolchain.cpython-314.pyc differ 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()