diff --git a/.editorconfig b/.editorconfig index 376ee8706..930052fbc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,7 +1,6 @@ # SPDX-FileCopyrightText: 2026 Bernard Ladenthin -# SPDX-FileCopyrightText: 2023-2025 Konstantin Herud # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: MIT OR Apache-2.0 # https://editorconfig.org root = true diff --git a/.gitattributes b/.gitattributes index 65c62247c..440bfb574 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: 2026 Bernard Ladenthin -# SPDX-FileCopyrightText: 2023-2025 Konstantin Herud # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: MIT OR Apache-2.0 * text=auto eol=lf +*.gguf binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f0429fc07..eaead4762 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners # Default reviewer for all paths. * @bernardladenthin diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 089a39ceb..c5f202d6a 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,7 +1,6 @@ # SPDX-FileCopyrightText: 2026 Bernard Ladenthin -# SPDX-FileCopyrightText: 2023-2025 Konstantin Herud # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: MIT OR Apache-2.0 # Sponsorship configuration for github.com/bernardladenthin. # Uncomment any line below to advertise a funding channel on the repository. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 7b3fb3601..fdaf828cc 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,6 @@ # SPDX-FileCopyrightText: 2026 Bernard Ladenthin -# SPDX-FileCopyrightText: 2023-2025 Konstantin Herud # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: MIT OR Apache-2.0 blank_issues_enabled: false contact_links: diff --git a/.github/actions/build-core/action.yml b/.github/actions/build-core/action.yml new file mode 100644 index 000000000..63bb30ff0 --- /dev/null +++ b/.github/actions/build-core/action.yml @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# Builds the parent POM and net.ladenthin:llama -- plus further reactor modules when asked -- as plain +# Java: no tests, no checks, no javadoc or sources jar, no signing. With `install` they land in the +# local Maven repository for a job that builds a module or an artifact on top of them; with `package` +# only in target/. The one place for that skip-flag list, which nine jobs used to repeat. +# Needs the repository checked out and a JDK set up. +name: Build the core classes +description: Build parent + net.ladenthin:llama (and more reactor modules) without tests, checks, javadoc or signing. +inputs: + modules: + description: The reactor modules to build (-pl); their reactor dependencies are built too (-am). + required: false + default: llama + goal: + description: install (into the local Maven repository) or package (target/ only). + required: false + default: install +runs: + using: composite + steps: + - name: mvn -pl ${{ inputs.modules }} -am ${{ inputs.goal }} (no tests, checks or signing) + shell: bash + env: + MODULES: ${{ inputs.modules }} + GOAL: ${{ inputs.goal }} + run: > + mvn -B --no-transfer-progress -pl "$MODULES" -am -DskipTests -Denforcer.skip=true + -Dspotless.check.skip=true -Dspotbugs.skip=true + -Dmaven.javadoc.skip=true -Dmaven.source.skip=true -Dgpg.skip=true "$GOAL" diff --git a/.github/actions/install-sccache-windows/action.yml b/.github/actions/install-sccache-windows/action.yml new file mode 100644 index 000000000..7a09be08b --- /dev/null +++ b/.github/actions/install-sccache-windows/action.yml @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# Puts sccache on PATH on a Windows runner, from the release zip (x86_64, or the native +# aarch64-pc-windows-msvc build on windows-11-arm, where it wraps clang-cl). The caller keeps the +# `if:` (USE_CACHE and a Depot token present) and `continue-on-error: true`: a failed install is +# only a slower build, because build.bat probes sccache before trusting it and retries a build that +# fails with it as the launcher once uncached (CLAUDE.md, "sccache on every Windows Ninja job"). +# Keep `version` equal to SCCACHE_DL_VERSION in .github/build.sh (the Linux fetch). +name: Install sccache (Windows) +description: Download the sccache release zip matching the runner's architecture and add it to PATH. +inputs: + version: + description: sccache release to install. + required: false + default: '0.18.0' +runs: + using: composite + steps: + - name: Install sccache ${{ inputs.version }} + shell: pwsh + env: + SCCACHE_VERSION: ${{ inputs.version }} + run: | + $arch = if ($env:RUNNER_ARCH -eq 'ARM64') { 'aarch64' } else { 'x86_64' } + $rel = "sccache-v$env:SCCACHE_VERSION-$arch-pc-windows-msvc" + $url = "https://github.com/mozilla/sccache/releases/download/v$env:SCCACHE_VERSION/$rel.zip" + Write-Host "Downloading $url" + Invoke-WebRequest -Uri $url -OutFile "$env:RUNNER_TEMP\sccache.zip" + Expand-Archive -Path "$env:RUNNER_TEMP\sccache.zip" -DestinationPath "$env:RUNNER_TEMP\sccache" -Force + Add-Content -Path $env:GITHUB_PATH -Value "$env:RUNNER_TEMP\sccache\$rel" diff --git a/.github/actions/publish-cpu-aar-local/action.yml b/.github/actions/publish-cpu-aar-local/action.yml new file mode 100644 index 000000000..089c57f5d --- /dev/null +++ b/.github/actions/publish-cpu-aar-local/action.yml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# Downloads the Android CPU natives (arm64-v8a for devices, x86_64 for the emulator), stages them where +# llama-android's Gradle build expects them and publishes the CPU AAR to mavenLocal -- what the +# emulator test and the two llmservice jobs consume. Needs the repository checked out, the core jar +# built (build-core, `package`) and Gradle on PATH; the calling job must `need` both Android CPU +# build jobs. +name: Publish the CPU AAR to mavenLocal +description: Download + stage the Android CPU natives (arm64-v8a, x86_64) and publish the llama-android AAR to mavenLocal. +runs: + using: composite + steps: + - name: Download Android CPU natives (arm64) + uses: actions/download-artifact@v8 + with: + name: natives-cpu-android-aarch64 + path: stage/cpu/ + - name: Download Android CPU natives (x86_64) + uses: actions/download-artifact@v8 + with: + name: natives-cpu-android-x86-64 + path: stage/cpu-x86_64/ + - name: Stage natives + publish the CPU AAR to mavenLocal + shell: bash + run: | + mkdir -p llama-android/natives/cpu/arm64-v8a llama-android/natives/cpu/x86_64 + cp stage/cpu/Linux-Android/aarch64/cpu/libjllama.so llama-android/natives/cpu/arm64-v8a/ + cp stage/cpu-x86_64/Linux-Android/x86_64/cpu/libjllama.so llama-android/natives/cpu/x86_64/ + gradle -p llama-android publishLlamaAndroidPublicationToMavenLocal diff --git a/.github/actions/restore-models/action.yml b/.github/actions/restore-models/action.yml new file mode 100644 index 000000000..7f0bc30ee --- /dev/null +++ b/.github/actions/restore-models/action.yml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# Restores the shared GGUF model cache and validates it -- the one place a job gets its models. +# The download-models job is the cache's ONLY writer (actions/cache, same key); every consumer +# uses this restore-only action, so a job running on a cache miss can never re-save an empty or +# partial entry under the immutable key. enableCrossOsArchive matches the writer's entry version, +# so macOS and Windows restore the same ubuntu-built entry (without it the entries are per OS, and +# the unreachable Windows-side one was once found re-saved EMPTY after an eviction). The key is +# the hash of .github/models.csv, so editing the manifest creates a fresh complete entry. +# validate-models.sh then fails a partial or absent restore loudly instead of letting the +# model-backed tests self-skip. Needs the repository checked out. +name: Restore GGUF models +description: Restore the shared GGUF model cache (written by download-models) and validate every model of .github/models.csv. +inputs: + fail-on-cache-miss: + description: Fail at the restore already when the cache entry is missing ('true' in verify-model-cache). + required: false + default: 'false' +runs: + using: composite + steps: + - name: Restore shared GGUF model cache (populated by download-models; no re-download) + uses: actions/cache/restore@v6 + with: + path: models/ + key: gguf-models-${{ hashFiles('.github/models.csv') }} + enableCrossOsArchive: true + fail-on-cache-miss: ${{ inputs.fail-on-cache-miss }} + - name: Validate model files + shell: bash + run: bash .github/validate-models.sh diff --git a/.github/android-consumer-test/README.md b/.github/android-consumer-test/README.md index 91e2885ae..84cf62b95 100644 --- a/.github/android-consumer-test/README.md +++ b/.github/android-consumer-test/README.md @@ -1,7 +1,7 @@ # llama-android consumer-test fixture (CI only) diff --git a/.github/android-consumer-test/app/build.gradle.kts b/.github/android-consumer-test/app/build.gradle.kts index a2176f7d1..a4548558d 100644 --- a/.github/android-consumer-test/app/build.gradle.kts +++ b/.github/android-consumer-test/app/build.gradle.kts @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2026 Bernard Ladenthin // -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: MIT OR Apache-2.0 plugins { id("com.android.application") diff --git a/.github/android-consumer-test/app/src/androidTest/java/net/ladenthin/llama/consumertest/OnDeviceInferenceTest.java b/.github/android-consumer-test/app/src/androidTest/java/net/ladenthin/llama/consumertest/OnDeviceInferenceTest.java index 55230e944..f3747e162 100644 --- a/.github/android-consumer-test/app/src/androidTest/java/net/ladenthin/llama/consumertest/OnDeviceInferenceTest.java +++ b/.github/android-consumer-test/app/src/androidTest/java/net/ladenthin/llama/consumertest/OnDeviceInferenceTest.java @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2026 Bernard Ladenthin // -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: MIT OR Apache-2.0 package net.ladenthin.llama.consumertest; diff --git a/.github/android-consumer-test/app/src/main/AndroidManifest.xml b/.github/android-consumer-test/app/src/main/AndroidManifest.xml index 04cf4db8b..6c3172e9f 100644 --- a/.github/android-consumer-test/app/src/main/AndroidManifest.xml +++ b/.github/android-consumer-test/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ diff --git a/.github/android-consumer-test/app/src/main/java/net/ladenthin/llama/consumertest/ConsumerSmoke.java b/.github/android-consumer-test/app/src/main/java/net/ladenthin/llama/consumertest/ConsumerSmoke.java index 2e47e66e1..1e82eafb8 100644 --- a/.github/android-consumer-test/app/src/main/java/net/ladenthin/llama/consumertest/ConsumerSmoke.java +++ b/.github/android-consumer-test/app/src/main/java/net/ladenthin/llama/consumertest/ConsumerSmoke.java @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2026 Bernard Ladenthin // -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: MIT OR Apache-2.0 package net.ladenthin.llama.consumertest; diff --git a/.github/android-consumer-test/gradle.properties b/.github/android-consumer-test/gradle.properties index 5687bb111..3a23d1f62 100644 --- a/.github/android-consumer-test/gradle.properties +++ b/.github/android-consumer-test/gradle.properties @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 Bernard Ladenthin # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: MIT OR Apache-2.0 org.gradle.jvmargs=-Xmx2g android.useAndroidX=true diff --git a/.github/android-consumer-test/settings.gradle.kts b/.github/android-consumer-test/settings.gradle.kts index 9b91b9b0e..89e61122b 100644 --- a/.github/android-consumer-test/settings.gradle.kts +++ b/.github/android-consumer-test/settings.gradle.kts @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2026 Bernard Ladenthin // -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: MIT OR Apache-2.0 // CI fixture (NOT a shipped project): a minimal AGP app that consumes the // net.ladenthin:llama-android AAR from mavenLocal and runs a full R8 release diff --git a/.github/build.bat b/.github/build.bat index 7976f75f9..8842c12f5 100755 --- a/.github/build.bat +++ b/.github/build.bat @@ -1,7 +1,6 @@ REM SPDX-FileCopyrightText: 2026 Bernard Ladenthin -REM SPDX-FileCopyrightText: 2023-2025 Konstantin Herud REM -REM SPDX-License-Identifier: MIT +REM SPDX-License-Identifier: MIT OR Apache-2.0 @echo off setlocal enabledelayedexpansion diff --git a/.github/build.sh b/.github/build.sh index 87d21a1ea..0ea4ba4b8 100755 --- a/.github/build.sh +++ b/.github/build.sh @@ -1,9 +1,8 @@ #!/bin/bash # SPDX-FileCopyrightText: 2026 Bernard Ladenthin -# SPDX-FileCopyrightText: 2023-2025 Konstantin Herud # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: MIT OR Apache-2.0 # The core project (CMakeLists.txt + src/) lives in the `llama/` module of the Maven # reactor. Re-root here once — every native build delegates to this script (incl. the diff --git a/.github/build_cuda_linux.sh b/.github/build_cuda_linux.sh index 66aad22fb..de755466e 100755 --- a/.github/build_cuda_linux.sh +++ b/.github/build_cuda_linux.sh @@ -1,9 +1,8 @@ #!/bin/sh # SPDX-FileCopyrightText: 2026 Bernard Ladenthin -# SPDX-FileCopyrightText: 2023-2025 Konstantin Herud # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: MIT OR Apache-2.0 # A Cuda 13.4 install script for RHEL8/Rocky8/Manylinux_2.28 # Available versions can be found at: diff --git a/.github/build_opencl_android.sh b/.github/build_opencl_android.sh index 491a59b52..73a724a74 100755 --- a/.github/build_opencl_android.sh +++ b/.github/build_opencl_android.sh @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: 2026 Bernard Ladenthin # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: MIT OR Apache-2.0 # # Android arm64 build with the OpenCL backend enabled and Adreno-tuned # kernels embedded. Runs inside the dockcross/android-arm64 container. diff --git a/.github/build_opencl_windows.bat b/.github/build_opencl_windows.bat index dbc9c5b47..3ea25c718 100644 --- a/.github/build_opencl_windows.bat +++ b/.github/build_opencl_windows.bat @@ -1,6 +1,6 @@ REM SPDX-FileCopyrightText: 2026 Bernard Ladenthin REM -REM SPDX-License-Identifier: MIT +REM SPDX-License-Identifier: MIT OR Apache-2.0 REM REM Windows x86_64 build with the OpenCL backend enabled, shipped as the REM `opencl-windows-x86-64` classifier. The windows-2025 runner image ships diff --git a/.github/buildcheck/__init__.py b/.github/buildcheck/__init__.py new file mode 100644 index 000000000..add4f0f54 --- /dev/null +++ b/.github/buildcheck/__init__.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 +"""Build checks as a library, so buildcheck/tests can test them. Standard library only. + +Two kinds of module live here. workflow.py, releasegate.py, sharedfiles.py, versions.py and +runscripts.py (with their tests and the check-*.py entry points next to this package) are kept +BYTE-IDENTICAL in java-llama.cpp, BitcoinAddressFinder, srcmorph and streambuffer: each repository +lists them in .github/shared-files.sha256, which its `shared-files` job checks (see sharedfiles.py). +Every other module is the repository's own. + +Run the tests from the repository root: + python3 -m unittest discover -s .github/buildcheck/tests -t .github +""" diff --git a/.github/buildcheck/hipoffload.py b/.github/buildcheck/hipoffload.py new file mode 100644 index 000000000..2aab7882c --- /dev/null +++ b/.github/buildcheck/hipoffload.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 + +"""Fail when a ROCm/HIP build ships its GPU code uncompressed. + +llama/CMakeLists.txt compiles ggml-hip with clang's --offload-compress, so every embedded +device-code bundle is a compressed "CCOB" bundle. An uncompressed bundle starts with the magic +"__CLANG_OFFLOAD_BUNDLE__"; one of those in the shipped library means the flag was lost and the +library is back to carrying every GPU target's code uncompressed (~1 GB on Windows). + +Usage (the CLI is .github/verify-hip-offload-compressed.py): verify-hip-offload-compressed.py ... + +Scans every jllama.dll / libjllama.so found, prints its size and the bundle counts, and exits +non-zero if a library has an uncompressed bundle, has no compressed bundle at all, or if no +library was found. Only the standard library, so it runs on any runner. +""" + +import os +import sys + +UNCOMPRESSED = b"__CLANG_OFFLOAD_BUNDLE__" +COMPRESSED = b"CCOB" +NAMES = ("jllama.dll", "libjllama.so") +CHUNK = 64 * 1024 * 1024 + + +def count(path, chunk=CHUNK): + """Counts both magics in one streaming pass (the file can be ~1 GB).""" + overlap = len(UNCOMPRESSED) - 1 + uncompressed = compressed = 0 + tail = b"" + with open(path, "rb") as f: + while True: + block = f.read(chunk) + if not block: + break + data = tail + block + # a match lying entirely inside `tail` was counted in the previous round + uncompressed += data.count(UNCOMPRESSED) - tail.count(UNCOMPRESSED) + compressed += data.count(COMPRESSED) - tail.count(COMPRESSED) + tail = data[-overlap:] + return uncompressed, compressed + + +def libraries(paths): + for p in paths: + if os.path.isfile(p): + yield p + for root, _, files in os.walk(p): + for name in files: + if name in NAMES: + yield os.path.join(root, name) + + +def main(argv): + if len(argv) < 2: + print("usage: verify-hip-offload-compressed.py ...", file=sys.stderr) + return 2 + found = failed = 0 + summary = [] + for lib in libraries(argv[1:]): + found += 1 + size = os.path.getsize(lib) + uncompressed, compressed = count(lib) + line = f"{lib}: {size / 1024 / 1024:.0f} MiB, {compressed} compressed / {uncompressed} uncompressed offload bundle(s)" + print(line) + summary.append(line) + if uncompressed: + print(f"::error::{lib} embeds {uncompressed} uncompressed GPU code bundle(s); is --offload-compress still set on ggml-hip?") + failed += 1 + elif not compressed: + print(f"::error::{lib} embeds no compressed GPU code bundle; was it built with GGML_HIP=ON?") + failed += 1 + if not found: + print(f"::error::no {' / '.join(NAMES)} found under {argv[1:]}") + return 2 + step_summary = os.environ.get("GITHUB_STEP_SUMMARY") + if step_summary: + with open(step_summary, "a", encoding="utf-8") as f: + f.write("### ROCm/HIP device code\n\n" + "\n".join(f"- `{s}`" for s in summary) + "\n") + return 1 if failed else 0 diff --git a/.github/buildcheck/models.py b/.github/buildcheck/models.py new file mode 100644 index 000000000..2fdffe02b --- /dev/null +++ b/.github/buildcheck/models.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 +"""The GGUF file names publish.yml's `env:` names (for the smoke scripts, the Android emulator jobs +and the integration jobs outside the llama module), checked against .github/models.csv -- the list +the download-models job fetches and validate-models.sh requires. A name the list lacks is a model +no job downloads: its consumer would fail or self-skip. (The llama module's tests default to the +list itself; TestConstantsTest checks that side.) +""" + +import re + +ENV_MODEL = re.compile(r'^ ([A-Z0-9_]+):\s*"?([^"\s]+\.gguf)"?\s*$', re.M) + + +def filenames(models_csv_text): + return {line.split(",", 1)[0].strip() for line in models_csv_text.splitlines() + if line.strip() and not line.startswith("#")} + + +def check(models_csv_text, workflow_text): + listed = filenames(models_csv_text) + return [f"publish.yml env {name}={value} is not a filename of .github/models.csv -- no job downloads it" + for name, value in ENV_MODEL.findall(workflow_text) if value not in listed] diff --git a/.github/buildcheck/nativedeps.py b/.github/buildcheck/nativedeps.py new file mode 100755 index 000000000..e82f868e8 --- /dev/null +++ b/.github/buildcheck/nativedeps.py @@ -0,0 +1,252 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 +"""Fail when a shipped native library needs a runtime library it did not need before. + +Every jllama library is ONE file with llama.cpp and ggml linked in statically, so its dynamic +dependencies are exactly what a consumer's machine must provide. A new one is a silent break on +every machine that lacks it -- the case this guards against is ggml-rpc's RDMA transport, which +upstream switches on whenever the build host has libibverbs/librdma and which would make the +library unloadable without rdma-core. It reads the dependency list straight from the file (ELF +DT_NEEDED, PE import table, Mach-O LC_LOAD_DYLIB) with the standard library only, so it runs on +any runner and checks every architecture, including the ones binutils cannot read (Windows arm64, +Mach-O). + +Usage (the CLI is .github/verify-native-deps.py): + verify-native-deps.py + +Checks every library under /...////. The CPU builds (backend cpu, +metal, msvc) and the Android OpenCL build are held to the exact allowlist in ALLOWED: a dependency +outside it fails, and so does a CPU build without a list (a new platform must be listed +consciously). The other GPU backends are checked against DENIED only, because they legitimately need +their vendor runtime. Android libraries must also have every LOAD segment 16 KB aligned. +That every listed build arrived is merge-native-artifacts.sh's check. + +Exit codes: 0 clean, 1 violation, 2 nothing found to check. +""" + +import os +import struct +import sys + +# What each CPU library needed when this check was introduced (5.1.0 plus the RPC backend, +# which adds nothing: its sockets are libc/libSystem/WS2_32, all already present). +ALLOWED = { + "Linux/x86_64/cpu": {"libdl.so.2", "libgomp.so.1", "libpthread.so.0", "librt.so.1", "libstdc++.so.6", + "libm.so.6", "libgcc_s.so.1", "libc.so.6", "ld-linux-x86-64.so.2"}, + "Linux/aarch64/cpu": {"libgomp.so.1", "libstdc++.so.6", "libm.so.6", "libgcc_s.so.1", "libc.so.6", + "ld-linux-aarch64.so.1"}, + "Linux/s390x/cpu": {"libstdc++.so.6", "libm.so.6", "libgcc_s.so.1", "libc.so.6", "ld64.so.1"}, + "Linux-Android/aarch64/cpu": {"liblog.so", "libm.so", "libdl.so", "libc.so", "libandroid.so"}, + "Linux-Android/x86_64/cpu": {"liblog.so", "libm.so", "libdl.so", "libc.so", "libandroid.so"}, + "Windows/x86_64/cpu": {"ws2_32.dll", "kernel32.dll", "shell32.dll", "advapi32.dll", "vcomp140.dll"}, + "Windows/x86/cpu": {"ws2_32.dll", "kernel32.dll", "shell32.dll", "advapi32.dll", "vcomp140.dll"}, + "Windows/aarch64/cpu": {"ws2_32.dll", "kernel32.dll", "shell32.dll", "advapi32.dll"}, + "Mac/aarch64/metal": {"/usr/lib/libc++.1.dylib", "/usr/lib/libSystem.B.dylib", + "/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation", + "/System/Library/Frameworks/Metal.framework/Versions/A/Metal", + "/System/Library/Frameworks/MetalKit.framework/Versions/A/MetalKit", + "/System/Library/Frameworks/Accelerate.framework/Versions/A/Accelerate", + "/usr/lib/libobjc.A.dylib", + "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation", + "/System/Library/Frameworks/Security.framework/Versions/A/Security", + # KNOWN DEFECT, allowed only so this check reports NEW dependencies: the macOS + # build picks up the runner's Homebrew OpenSSL, so the shipped dylib does not load + # on a Mac without `brew install openssl@3`. See TODO.md ("macOS dylib links + # Homebrew OpenSSL"); remove these two lines with the fix. + "/opt/homebrew/opt/openssl@3/lib/libssl.3.dylib", + "/opt/homebrew/opt/openssl@3/lib/libcrypto.3.dylib"}, +} +# The Visual Studio generator build of the same compiler and runtime. +ALLOWED["Windows/x86_64/msvc"] = ALLOWED["Windows/x86_64/cpu"] +ALLOWED["Windows/x86/msvc"] = ALLOWED["Windows/x86/cpu"] +# The OpenCL AAR flavour: an app bundles no other native library, so the Android GPU build is held +# to an exact list as well -- the bionic system libraries plus the vendor ICD. (libomp.so and +# libc++_shared.so once shipped exactly this way and failed System.loadLibrary on every device.) +ALLOWED["Linux-Android/aarch64/opencl"] = ALLOWED["Linux-Android/aarch64/cpu"] | {"libOpenCL.so"} + +# Google Play's 16 KB page-size requirement (Android 15+ targets): every LOAD segment of an +# Android library must be aligned to a multiple of it. CMake pins -Wl,-z,max-page-size=16384. +ANDROID_PAGE_ALIGNMENT = 16384 +CPU_BACKENDS = ("cpu", "metal", "msvc") +LIBRARY_NAMES = ("libjllama.so", "jllama.dll", "libjllama.dylib") + +# Never acceptable in any artifact: libraries a consumer cannot be expected to have. +DENIED = ("libibverbs", "librdma", "rdma.dylib", "libmlx") + + +def elf_needed(data): + if data[:4] != b"\x7fELF": + raise ValueError("not an ELF file") + is64 = data[4] == 2 + end = "<" if data[5] == 1 else ">" + if is64: + shoff = struct.unpack_from(end + "Q", data, 0x28)[0] + shentsize, shnum = struct.unpack_from(end + "HH", data, 0x3A) + else: + shoff = struct.unpack_from(end + "I", data, 0x20)[0] + shentsize, shnum = struct.unpack_from(end + "HH", data, 0x2E) + sections = [] + for i in range(shnum): + off = shoff + i * shentsize + if is64: + _, sh_type, _, _, sh_offset, sh_size, sh_link = struct.unpack_from(end + "IIQQQQI", data, off) + else: + _, sh_type, _, _, sh_offset, sh_size, sh_link = struct.unpack_from(end + "IIIIIII", data, off) + sections.append((sh_type, sh_offset, sh_size, sh_link)) + out = [] + for sh_type, sh_offset, sh_size, sh_link in sections: + if sh_type != 6: # SHT_DYNAMIC + continue + strtab = sections[sh_link] + entry = 16 if is64 else 8 + for off in range(sh_offset, sh_offset + sh_size, entry): + tag, val = struct.unpack_from(end + ("qQ" if is64 else "iI"), data, off) + if tag == 0: + break + if tag == 1: # DT_NEEDED + start = strtab[1] + val + out.append(data[start:data.index(b"\0", start)].decode()) + return out + + +def elf_load_alignments(data): + """p_align of every PT_LOAD program header.""" + if data[:4] != b"\x7fELF": + raise ValueError("not an ELF file") + is64 = data[4] == 2 + end = "<" if data[5] == 1 else ">" + if is64: + phoff = struct.unpack_from(end + "Q", data, 0x20)[0] + phentsize, phnum = struct.unpack_from(end + "HH", data, 0x36) + else: + phoff = struct.unpack_from(end + "I", data, 0x1C)[0] + phentsize, phnum = struct.unpack_from(end + "HH", data, 0x2A) + out = [] + for i in range(phnum): + off = phoff + i * phentsize + if struct.unpack_from(end + "I", data, off)[0] == 1: # PT_LOAD + out.append(struct.unpack_from(end + ("Q" if is64 else "I"), data, off + (0x30 if is64 else 0x1C))[0]) + return out + + +def pe_imports(data): + if data[:2] != b"MZ": + raise ValueError("not a PE file") + pe = struct.unpack_from("///); `alignments` are its LOAD segment alignments (checked + for Android libraries).""" + failures = [f"{rel} needs {d}, which no consumer can be expected to have" for d in denied(deps)] + parts = rel.split("/") + key = "/".join(parts[-4:-1]) if len(parts) >= 4 else "" + if key.startswith("Linux-Android/"): + failures += [f"{rel}: LOAD alignment {a} is not a multiple of {ANDROID_PAGE_ALIGNMENT} " + f"(Google Play 16 KB page-size requirement)" for a in alignments if a % ANDROID_PAGE_ALIGNMENT] + if parts[-1] not in LIBRARY_NAMES: + return failures + allowed = ALLOWED.get(key) + if allowed is None: + if key.rsplit("/", 1)[-1] not in CPU_BACKENDS: + return failures + return failures + [f"{rel}: no dependency allowlist for '{key}' -- add one to ALLOWED"] + lowered = {a.lower() for a in allowed} + return failures + [f"{rel} needs {d}, which it did not need before (allowed: {sorted(allowed)})" + for d in deps if d.lower() not in lowered] + + +def main(argv): + if len(argv) != 2: + print(__doc__, file=sys.stderr) + return 2 + root = argv[1] + checked = 0 + failures = [] + for path in sorted(find_libraries(root)): + deps = dependencies(path) + checked += 1 + rel = os.path.relpath(path, root).replace(os.sep, "/") + alignments = () + if "/Linux-Android/" in "/" + rel: + with open(path, "rb") as f: + alignments = elf_load_alignments(f.read()) + print(f"{rel}: {' '.join(deps)}") + failures += violations(rel, deps, alignments) + if checked == 0: + print(f"no native library found under {root}", file=sys.stderr) + return 2 + for f in failures: + print(f"::error::{f}", file=sys.stderr) + print(f"{checked} native libraries checked, {len(failures)} violations") + return 1 if failures else 0 diff --git a/.github/buildcheck/natives.py b/.github/buildcheck/natives.py new file mode 100644 index 000000000..7904c5a2f --- /dev/null +++ b/.github/buildcheck/natives.py @@ -0,0 +1,274 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 +"""Everything that names a natives jar, checked against .github/natives.csv. + +The list is the one place a natives jar is declared. Everything else either reads it (the merge, +the fat-jar assembly) or has to repeat it, and each repetition is checked here: + * llama/pom.xml one jar execution per row: classifier, directory, Automatic-Module-Name + * llama-platform depends on exactly the rows marked platform=yes + * publish.yml a build job uploads natives- for every row and no other, and + `package` waits for each of them (else it packages without that build) + * LlamaLoader BACKEND_PRIORITY tries every backend (else a jar ships and never loads) + * CMakeLists.txt names exactly the backend directories of the list + * nativedeps.ALLOWED holds an allowlist for every CPU directory (cpu, metal, msvc), and for no + directory the list lacks + * README.md documents every classifier +and, for the all-backends fat jars derived from the list (fatjar_targets), that each one is +uploaded as llama-fatjar-smoke-, launched by a smoke job (a script line naming it, or a row +of the smoke-fatjar matrix), named in the agent jar's Class-Path and in the README. +package-fatjars.sh does not repeat the targets at all: it asks this module for them. + +Every check is a function of the texts it compares, so the tests drive them with literals. +""" + +import csv +import io +import os +import re +import xml.etree.ElementTree as ET + +from . import nativedeps, workflow + +NS = {"m": "http://maven.apache.org/POM/4.0.0"} + +# Backends that get no place in an all-backends jar: msvc is the same CPU build from another +# generator, so it would only be a second CPU library the loader tries before the first. +FATJAR_EXCLUDED_BACKENDS = ("msvc",) +# Platforms that get no all-backends jar: `java -jar` does not apply on Android (the AAR does). +FATJAR_EXCLUDED_OSES = ("Linux-Android",) + +FATJAR_NAME = re.compile(r"all-([a-z0-9]+(?:-[a-z0-9]+)*?)-jar-with-dependencies") + +# package-fatjars uploads each all-backends fat jar alone, as llama-fatjar-smoke-, for the +# smoke-fatjar matrix; a matrix row is a flow mapping that starts with its target. +SMOKE_ARTIFACT = "llama-fatjar-smoke-" +MATRIX_ROW = re.compile(r"^\s*-\s*\{\s*target:\s*([a-z0-9-]+)\s*[,}]") +MATRIX_FATJAR = "all-${{ matrix.target }}-jar-with-dependencies" + + +def rows(text): + """The rows of natives.csv (comment lines and blank lines skipped).""" + lines = [line for line in io.StringIO(text) if line.strip() and not line.startswith("#")] + return list(csv.DictReader(lines)) + + +def backend(row): + return row["directory"].rsplit("/", 1)[1] + + +def tree(row): + """/ of the row.""" + return row["directory"].rsplit("/", 1)[0] + + +def module_name(classifier): + """Each natives jar needs its own: without one, every jar derives the module name `llama` + from its file name, and the module path silently keeps only the first.""" + return "net.ladenthin.llama.natives." + classifier.replace("-", "_") + + +def pom_execution(row): + return f"""\t\t\t\t\t\t\t +\t\t\t\t\t\t\t\tnatives-{row['classifier']} +\t\t\t\t\t\t\t\tpackage +\t\t\t\t\t\t\t\t +\t\t\t\t\t\t\t\t\tjar +\t\t\t\t\t\t\t\t +\t\t\t\t\t\t\t\t +\t\t\t\t\t\t\t\t\t{row['classifier']} +\t\t\t\t\t\t\t\t\t${{project.basedir}}/src/main/natives +\t\t\t\t\t\t\t\t\t +\t\t\t\t\t\t\t\t\t\tnet/ladenthin/llama/{row['directory']}/** +\t\t\t\t\t\t\t\t\t +\t\t\t\t\t\t\t\t\t +\t\t\t\t\t\t\t\t\t\tfalse +\t\t\t\t\t\t\t\t\t\t +\t\t\t\t\t\t\t\t\t\t\t{module_name(row['classifier'])} +\t\t\t\t\t\t\t\t\t\t +\t\t\t\t\t\t\t\t\t +\t\t\t\t\t\t\t\t +\t\t\t\t\t\t\t""" + + +def fatjar_targets(natives): + """The all-backends fat jars: one per - (the classifier suffix) with more than one + natives jar, excluded backends and platforms left out. Sorted. package-fatjars.sh builds + exactly these (it asks for them: check-natives.py fatjar-targets).""" + groups = {} + for row in natives: + if backend(row) in FATJAR_EXCLUDED_BACKENDS or tree(row).split("/")[0] in FATJAR_EXCLUDED_OSES: + continue + target = row["classifier"][len(backend(row)) + 1:] + groups.setdefault(target, set()).add(backend(row)) + return sorted(t for t, backends in groups.items() if len(backends) > 1) + + +def fatjar_names(text): + """The fat-jar targets a text names (`...-all--jar-with-dependencies...`).""" + return set(FATJAR_NAME.findall(text)) + + +def compare(what, expected, actual): + return ([f"{what}: missing {name}" for name in sorted(set(expected) - set(actual))] + + [f"{what}: {name} is not in .github/natives.csv" for name in sorted(set(actual) - set(expected))]) + + +def check_rows(natives): + failures = [] + classifiers = [r["classifier"] for r in natives] + if len(set(classifiers)) != len(classifiers): + failures.append("natives.csv lists a classifier twice") + for r in natives: + if not r["classifier"].startswith(backend(r) + "-") or r["platform"] not in ("yes", "no"): + failures.append(f"natives.csv: row {r['classifier']} -- classifier must start with its " + f"directory's backend '{backend(r)}', platform must be yes or no") + return failures + + +def check_pom(natives, pom_text): + """The natives profile of llama/pom.xml: one jar execution per row.""" + found = {} + for profile in ET.fromstring(pom_text).iterfind("m:profiles/m:profile", NS): + if profile.findtext("m:id", namespaces=NS) != "natives": + continue + for ex in profile.iterfind(".//m:plugin/m:executions/m:execution", NS): + conf = ex.find("m:configuration", NS) + classifier = conf.findtext("m:classifier", namespaces=NS) if conf is not None else None + if classifier: + found[classifier] = (conf.findtext("m:includes/m:include", namespaces=NS), + conf.findtext("m:archive/m:manifestEntries/m:Automatic-Module-Name", + namespaces=NS)) + by_classifier = {r["classifier"]: r for r in natives} + failures = compare("llama/pom.xml natives profile", by_classifier, found) + for classifier in sorted(set(found) & set(by_classifier)): + want = (f"net/ladenthin/llama/{by_classifier[classifier]['directory']}/**", module_name(classifier)) + if found[classifier] != want: + failures.append(f"llama/pom.xml: {classifier} has {found[classifier]}, expected {want} " + f"(check-natives.py pom prints the executions)") + return failures + + +def check_platform(natives, platform_pom_text): + deps = {d.findtext("m:classifier", namespaces=NS) + for d in ET.fromstring(platform_pom_text).iterfind("m:dependencies/m:dependency", NS)} - {None} + return compare("llama-platform/pom.xml", [r["classifier"] for r in natives if r["platform"] == "yes"], deps) + + +def check_workflow(natives, jobs): + """The build jobs upload natives- for every row, `package` waits for each of them, + and every fat-jar target is uploaded for a smoke job and launched by one.""" + uploads = {} + for job in jobs.values(): + for name in job.uploads(): + if name.startswith("natives-"): + uploads[name[len("natives-"):]] = job.name + failures = compare("publish.yml natives-* uploads", [r["classifier"] for r in natives], uploads) + if "package" not in jobs: + return failures + ["publish.yml has no `package` job"] + waited_for = workflow.closure(jobs, "package") + for classifier, job in sorted(uploads.items()): + if job not in waited_for: + failures.append(f"publish.yml: package does not wait for {job}, which uploads natives-{classifier} " + f"-- add it to package's needs") + targets = fatjar_targets(natives) + assembler = jobs.get("package-fatjars") + if assembler is None: + return failures + ["publish.yml has no `package-fatjars` job"] + failures += check_smoke_uploads(targets, assembler) + launched = set() + for job in jobs.values(): + if job.name != "package-fatjars": + launched |= smoke_runs(job, failures) + failures += compare("publish.yml fat-jar smoke runs", targets, launched) + return failures + + +def check_smoke_uploads(targets, assembler): + """package-fatjars uploads llama-fatjar-smoke- for every target, each holding that + target's jar (the path names the same target as the artifact).""" + failures, uploaded = [], set() + for step in assembler.steps(): + text = "\n".join(step) + names = [n for n in re.findall(r"name:\s*(\S+)", text) if n.startswith(SMOKE_ARTIFACT)] + if "actions/upload-artifact@" not in text or not names: + continue + target = names[0][len(SMOKE_ARTIFACT):] + uploaded.add(target) + if fatjar_names("\n".join(line for line in step if "path:" in line)) != {target}: + failures.append(f"publish.yml package-fatjars: {names[0]} does not upload the {target} fat jar") + return compare("publish.yml package-fatjars smoke-jar uploads", targets, uploaded) + failures + + +def smoke_runs(job, failures): + """The fat-jar targets a job launches: the ones its smoke-script lines name, and, when those run + `all-${{ matrix.target }}-...`, every row of its matrix -- which must then download each row's + own smoke jar.""" + smoke = "\n".join(line for line in job.lines if ".github/smoke-" in line) + if not smoke: + return set() + launched = fatjar_names(smoke) + if MATRIX_FATJAR in job.text: + launched |= {m.group(1) for m in map(MATRIX_ROW.match, job.lines) if m} + if f"name: {SMOKE_ARTIFACT}${{{{ matrix.target }}}}" not in job.text: + failures.append(f"publish.yml: {job.name} does not download {SMOKE_ARTIFACT}${{{{ matrix.target }}}}") + return launched + + +def check_loader(natives, loader_text): + block = re.search(r"BACKEND_PRIORITY\s*=(.*?);", loader_text, re.S) + priority = set(re.findall(r'"([^"]+)"', block.group(1))) if block else set() + return [f"LlamaLoader.BACKEND_PRIORITY does not try '{b}' -- its jars would never load" + for b in sorted({backend(r) for r in natives} - priority)] + + +def check_cmake(natives, cmake_text): + named = set(re.findall(r"set\(JLLAMA_BACKEND\s+([A-Za-z0-9_-]+)\)", cmake_text)) + listed = {backend(r) for r in natives} + return ([f"llama/CMakeLists.txt never sets JLLAMA_BACKEND {b} -- no build writes that directory" + for b in sorted(listed - named)] + + [f"llama/CMakeLists.txt sets JLLAMA_BACKEND {b}, which no natives jar ships" + for b in sorted(named - listed)]) + + +def check_dependency_allowlist(natives, allowed): + """nativedeps.ALLOWED holds a list for every CPU directory (the package job would otherwise fail + on a new one only after every build finished), and none for a directory no jar ships.""" + directories = {r["directory"] for r in natives} + cpu = {r["directory"] for r in natives if backend(r) in nativedeps.CPU_BACKENDS} + return ([f"buildcheck/nativedeps.py ALLOWED has no allowlist for {d}" for d in sorted(cpu - set(allowed))] + + [f"buildcheck/nativedeps.py ALLOWED lists {d}, which no natives jar of natives.csv ships" + for d in sorted(set(allowed) - directories)]) + + +def check_readme(natives, readme_text): + failures = [f"README.md does not document the natives jar `{r['classifier']}`" + for r in natives if f"`{r['classifier']}`" not in readme_text] + return failures + compare("README.md all-backends fat jars", fatjar_targets(natives), fatjar_names(readme_text)) + + +def check_agent_class_path(natives, agent_pom_text): + """`java -jar` on the agent jar finds the core through its manifest Class-Path, which names + every all-backends fat jar.""" + match = re.search(r"(.*?)", agent_pom_text, re.S) + named = fatjar_names(match.group(1)) if match else set() + return compare("llama-atmosphere-agent/pom.xml Class-Path", fatjar_targets(natives), named) + + +def read(root, path): + with open(os.path.join(root, path), encoding="utf-8") as f: + return f.read() + + +def check(root): + """Every check, over the files of the repository at `root`.""" + natives = rows(read(root, ".github/natives.csv")) + return (check_rows(natives) + + check_pom(natives, read(root, "llama/pom.xml")) + + check_platform(natives, read(root, "llama-platform/pom.xml")) + + check_workflow(natives, workflow.parse(read(root, ".github/workflows/publish.yml"))) + + check_loader(natives, read(root, "llama/src/main/java/net/ladenthin/llama/loader/LlamaLoader.java")) + + check_cmake(natives, read(root, "llama/CMakeLists.txt")) + + check_dependency_allowlist(natives, nativedeps.ALLOWED) + + check_readme(natives, read(root, "README.md")) + + check_agent_class_path(natives, read(root, "llama-atmosphere-agent/pom.xml"))) diff --git a/.github/buildcheck/releasegate.py b/.github/buildcheck/releasegate.py new file mode 100644 index 000000000..729e2150b --- /dev/null +++ b/.github/buildcheck/releasegate.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 +"""Every job of publish.yml gates both publish jobs, unless the repository lists it in +.github/release-gate-exemptions.txt with a reason. + +A job that nothing waits for can go red and a release still ships -- the natives-build jobs that +`package` once forgot to wait for, and the aarch64 fat jars that were signed and attached for +releases without any job launching them, were both of that shape. So "not gating" has to be a +decision written down here, not the default a new job gets by being forgotten in two `needs:` lists. + +The check runs both ways: a job outside the gates and outside the exemptions fails, and so does an +exemption that names no job or a job that gates both publish jobs after all (a stale +exemption would hide the next job of that name). +""" + +from . import workflow + +GATES = ("publish-snapshot", "publish-release") + +# Each repository lists its own exemptions here, one `: ` per line (# comments). +EXEMPTIONS_FILE = ".github/release-gate-exemptions.txt" + + +def read_exemptions(text): + """The exemptions file: job -> reason. A line without a reason is an error -- the point of the + file is that every job allowed to stay red says why.""" + exemptions = {} + for number, line in enumerate(text.splitlines(), 1): + line = line.strip() + if not line or line.startswith("#"): + continue + job, _, reason = line.partition(":") + if not reason.strip() or not job.strip(): + raise ValueError(f"line {number}: expected `: `, got {line!r}") + if job.strip() in exemptions: + raise ValueError(f"line {number}: {job.strip()} is listed twice") + exemptions[job.strip()] = reason.strip() + return exemptions + + +def check(jobs, non_gating, gates=GATES): + missing = [g for g in gates if g not in jobs] + if missing: + return [f"publish.yml has no job {g}" for g in missing] + closures = [workflow.closure(jobs, g) for g in gates] + failures = [] + for name in jobs: + gated = [g for g, c in zip(gates, closures) if name in c] + if name in non_gating: + if len(gated) == len(gates): + failures.append(f"release-gate-exemptions.txt lists {name}, which gates {', '.join(gates)} " + f"-- remove the stale exemption") + elif len(gated) != len(gates): + ungated = [g for g in gates if g not in gated] + failures.append(f"publish.yml: {', '.join(ungated)} does not wait for {name} -- add it to the " + f"needs, or to release-gate-exemptions.txt with the reason it may stay red") + failures += [f"release-gate-exemptions.txt lists {name}, which is no job of publish.yml" + for name in sorted(set(non_gating) - set(jobs))] + return failures diff --git a/.github/buildcheck/runscripts.py b/.github/buildcheck/runscripts.py new file mode 100644 index 000000000..ea6850588 --- /dev/null +++ b/.github/buildcheck/runscripts.py @@ -0,0 +1,273 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 +"""Every `run:` script of the workflows and composite actions that runs in bash, parsed with +`bash -n` -- so a broken script fails the `shared-files` job in the first minutes of a run instead +of the job that executes it, possibly hours later or only on a release path. + +The case this exists for: a cleanup lost the trailing backslash of six continued lines, leaving a +line that starts with `||` or `-e` -- in a step only a publish run or a native build reaches. Neither +actionlint (it parses scripts only when shellcheck is installed) nor a review saw it; `bash -n` sees +every one of them. + +Which scripts are bash is decided the way the runner decides it: the step's `shell:`, else the job's +and then the workflow's `defaults.run.shell`, else the runner's default -- PowerShell on a Windows +runner, bash everywhere else. A runner chosen by an expression (a matrix, a workflow input) counts +as Windows only when the step's `if:` says so; a step without `shell:` on such a job runs on every +OS of the matrix and has to be valid bash anyway. Scripts in `sh`, `bash -el {0}` or a bash given by +path are bash; pwsh, powershell, cmd and python are skipped. + +Like workflow.py this reads the two-space layout the workflows are written in, not general YAML. +The run value is taken as YAML defines it: a literal block (`|`) line for line, a folded block +(`>`) and a plain scalar folded into one line, a single-quoted scalar unquoted. A double-quoted +scalar is skipped (its escapes are not worth a YAML parser). Every `${{ ... }}` expression becomes +a word, which is what the runner substitutes before bash sees the script. +""" + +import glob +import os +import re +import shutil +import subprocess +import sys + +RUN = re.compile(r"^(?P\s*(?:- )?)run:(?:\s+(?P.*?))?\s*$") +BLOCK_HEADER = re.compile(r"^(?P