Ship one Linux universal executable with shared network weights (bench 5551733)

Co-authored-by: GPT-6 Astra <codex@openai.com>
This commit is contained in:
nocturn9xandGPT-6 Astra committed 2026-09-21 11:30:12 +02:00
1 parent a570d38c2b
commit 1a12e3ad71
15 files changed
+799 -38

No files matched your search

+137 -1
View File
@@ -42,6 +42,7 @@ jobs:
matrix: ${{ steps.plan.outputs.matrix }}
source_sha: ${{ steps.plan.outputs.source_sha }}
tag: ${{ steps.plan.outputs.tag }}
linux_universal: ${{ steps.plan.outputs.linux_universal }}
steps:
- uses: actions/checkout@v6
with:
@@ -134,6 +135,7 @@ jobs:
unzip -q "$nim_root/nim.zip" -d "$nim_root"
- name: Build and check one target
id: build
if: matrix.publish
run: |
if [[ "$RUNNER_OS" == Windows ]]; then
export PATH="$(cygpath -u "$RUNNER_TEMP")/nim/nim-${NIM_VERSION}/bin:$PATH"
@@ -141,8 +143,20 @@ jobs:
nim --version
git lfs install --local
python3 ../ci/scripts/release.py build --target "$RELEASE_TARGET" --tag "$RELEASE_TAG" --artifacts ../artifacts
- name: Build and check Linux slice with shared weights
if: needs.plan.outputs.linux_universal == 'true' && (matrix.target == 'linux-amd64-universal' || matrix.target == 'linux-arm64-universal')
run: |
git lfs install --local
python3 ../ci/scripts/release.py slice-linux --target "$RELEASE_TARGET" --tag "$RELEASE_TAG" --artifacts ../slices
- name: Upload internal Linux slice
if: needs.plan.outputs.linux_universal == 'true' && (matrix.target == 'linux-amd64-universal' || matrix.target == 'linux-arm64-universal')
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.target }}-slice
path: slices/*
if-no-files-found: error
- name: Publish this target to Gitea
if: needs.plan.outputs.tag != ''
if: needs.plan.outputs.tag != '' && matrix.publish
env:
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
GITEA_REPO: ${{ secrets.GITEA_REPO }}
@@ -150,8 +164,130 @@ jobs:
GITEA_RELEASE_BODY: "Automated release artifacts built by GitHub Actions."
run: python3 ../ci/scripts/publish_gitea_release.py --tag "$RELEASE_TAG" --target-commitish "$SOURCE_SHA" --files ../artifacts/*
- name: Upload this target to Actions
if: matrix.publish
uses: actions/upload-artifact@v7
with:
name: ${{ steps.build.outputs.artifact_name }}
path: artifacts/*
if-no-files-found: error
linux-package:
name: Combine Linux architectures
needs: [plan, build]
# An unrelated platform failure must not prevent a complete Linux release.
# Missing or failed Linux slices still fail the download/checksum checks.
if: ${{ !cancelled() && needs.plan.outputs.linux_universal == 'true' }}
runs-on: ubuntu-24.04
outputs:
artifact_name: ${{ steps.package.outputs.artifact_name }}
env:
GIT_LFS_SKIP_SMUDGE: "1"
RELEASE_TAG: ${{ needs.plan.outputs.tag }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
path: ci
lfs: false
- uses: actions/checkout@v6
with:
ref: ${{ needs.plan.outputs.source_sha }}
path: source
fetch-depth: 0
lfs: false
- uses: actions/download-artifact@v8
with:
pattern: 'linux-*-universal-slice'
merge-multiple: true
path: slices
- name: Package both checked slices
id: package
working-directory: source
run: python3 ../ci/scripts/release.py combine-linux --slices ../slices --tag "$RELEASE_TAG" --artifacts ../artifacts
- name: Check packaging and cache regressions
working-directory: source
run: |
python3 -m unittest discover -s ../ci/tests -p 'test_release*.py'
python3 -m unittest discover -s ../ci/tests -p 'test_linux_universal.py'
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.package.outputs.artifact_name }}
path: artifacts/*
if-no-files-found: error
linux-check:
name: Check combined Linux package (${{ matrix.arch }})
needs: [plan, linux-package]
if: ${{ !cancelled() && needs.linux-package.result == 'success' }}
strategy:
fail-fast: false
matrix:
include:
- {arch: amd64, runner: ubuntu-24.04}
- {arch: arm64, runner: ubuntu-24.04-arm}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
env:
GIT_LFS_SKIP_SMUDGE: "1"
ARTIFACT_NAME: ${{ needs.linux-package.outputs.artifact_name }}
SOURCE_SHA: ${{ needs.plan.outputs.source_sha }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
path: ci
lfs: false
- uses: actions/checkout@v6
with:
ref: ${{ needs.plan.outputs.source_sha }}
path: source
fetch-depth: 0
lfs: false
- uses: actions/download-artifact@v8
with:
name: ${{ env.ARTIFACT_NAME }}
path: artifacts
- name: Verify and extract the release archive
working-directory: artifacts
run: |
sha256sum -c "$ARTIFACT_NAME.sha256"
tar -xzf "$ARTIFACT_NAME.tar.gz"
sha256sum -c "$ARTIFACT_NAME.sha256"
- name: Check native dispatch, production bench and UCI through the launcher
working-directory: source
run: |
export HEIMDALL="$GITHUB_WORKSPACE/artifacts/$ARTIFACT_NAME"
export HEIMDALL_CACHE_DIR="$RUNNER_TEMP/heimdall-cache"
"$HEIMDALL" simd
python3 ../ci/scripts/check_binary_benches.py --commit "$SOURCE_SHA" -- "$HEIMDALL"
python3 -m unittest discover -s ../ci/tests -p 'test_uci.py'
python3 -m unittest discover -s ../ci/tests -p 'test_runtime_simd.py'
linux-publish:
name: Publish combined Linux package
needs: [plan, linux-package, linux-check]
if: ${{ !cancelled() && needs.linux-check.result == 'success' && needs.plan.outputs.tag != '' }}
runs-on: ubuntu-24.04
concurrency:
group: release-${{ needs.plan.outputs.tag }}-linux-universal
cancel-in-progress: false
env:
GIT_LFS_SKIP_SMUDGE: "1"
RELEASE_TAG: ${{ needs.plan.outputs.tag }}
SOURCE_SHA: ${{ needs.plan.outputs.source_sha }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
lfs: false
- uses: actions/download-artifact@v8
with:
name: ${{ needs.linux-package.outputs.artifact_name }}
path: artifacts
- name: Publish checked package to Gitea
env:
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
GITEA_REPO: ${{ secrets.GITEA_REPO }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_RELEASE_BODY: "Automated release artifacts built by GitHub Actions."
run: python3 scripts/publish_gitea_release.py --tag "$RELEASE_TAG" --target-commitish "$SOURCE_SHA" --files artifacts/*
+3 -2
View File
@@ -28,8 +28,9 @@ compiler flags, network architecture settings, and build targets.
so no network downloads need to happen.** It invokes the native build with
`SKIP_DEPS=1`, skipping dependency installation and network fetching, and writes
the engine to `bin/heimdall` (`.exe` on Windows).
- Local prerequisites include Nim **2.2.6** (pinned in `heimdall.nimble`), the
declared Nimble packages, Clang, the platform linker (LLD on Linux/Windows,
- Local prerequisites include Nim **2.2.2 or greater** (required by
`heimdall.nimble`; CI uses 2.2.6), the declared Nimble packages, Clang,
the platform linker (LLD on Linux/Windows,
Apple ld on macOS), and the actual NNUE weights selected by `EVALFILE`.
Multilayer TI inference is the default and requires an explicit `EVALFILE`.
Use `SINGLE_LAYER=1` for the local `threans.bin` debugging fixture; the old
+3
View File
@@ -33,6 +33,8 @@ endif
endif
NET_NAME := $(notdir $(EVALFILE))
NET_ID := $(basename $(NET_NAME))
# Combined Linux releases keep one shared network beside their cached engine.
EMBED_NET ?= 1
LD := lld
SRCDIR := src
MAIN ?= $(SRCDIR)/heimdall.nim
@@ -153,6 +155,7 @@ CUSTOM_FLAGS := -d:singleLayer=$(if $(filter 1,$(SINGLE_LAYER)),true,false) \
-d:patchVersion=$(PATCH_VERSION) \
-d:evalFile=$(EVALFILE) \
-d:netID=$(NET_ID) \
-d:embedNet=$(if $(filter 1,$(EMBED_NET)),true,false) \
-d:thpPageAlignment:$(THP_PAGE_ALIGNMENT) \
-d:esc_exit_editing
+12 -7
View File
@@ -29,21 +29,26 @@ Heimdall is a strong chess engine written in Nim. As far as I know, this is the
### How to pick the right executable
Fetching the latest stable release is the easiest way to install Heimdall. For
versions after **1.5**, choose the archive for your operating system and CPU
family. The universal binaries select the supported SIMD backend automatically.
current releases after **1.5**, choose the archive for your operating system.
Universal builds select the supported SIMD backend automatically.
| Your computer | Executable filename ends with |
| Your computer | Archive filename ends with |
| --- | --- |
| Windows on Intel or AMD | `windows-amd64-universal.exe` |
| Linux on Intel or AMD | `linux-amd64-universal` |
| Linux on ARM64 / AArch64 | `linux-arm64-universal` |
| macOS on Intel or Apple Silicon | `macos-universal` |
| Windows on Intel or AMD | `windows-amd64-universal.zip` |
| Linux on Intel, AMD or ARM64 / AArch64 | `linux-universal.tar.gz` |
| macOS on Intel or Apple Silicon | `macos-universal.tar.gz` |
Download the matching `.zip` (Windows) or `.tar.gz` (Linux/macOS) archive from
the [latest release](https://git.nocturn9x.space/nocturn9x/heimdall/releases),
extract it, and select the executable in your chess GUI. All builds require a
64-bit system; the macOS build requires macOS 11 or later.
The Linux executable selects AMD64 or ARM64 and extracts its engine and shared
network into a private runtime cache on first launch. See the
[cache requirements and settings](docs/RELEASES.md#linux-universal-executable).
Earlier releases may offer separate `linux-amd64-universal` and
`linux-arm64-universal` downloads; choose the one matching your CPU.
For source builds, platform-specific binaries, testing, and release details,
see the [building guide](docs/BUILDING.md). The [testing guide](docs/TESTING.md)
covers regression tests, focused correctness checks, and benchmarks.
+9 -1
View File
@@ -6,7 +6,7 @@ for the universal archive that matches your system.
## Requirements
- Nim 2.2.6, pinned in `heimdall.nimble`
- Nim 2.2.2 or greater, as required by `heimdall.nimble`
- Clang and the platform linker (LLD on Linux/Windows, Apple `ld` on macOS)
- Git LFS when fetching network weights
@@ -32,6 +32,14 @@ backend selection. Run `bin/heimdall simd` to see the selected backend, or set
`make macos-universal SKIP_DEPS=1 EVALFILE=/absolute/path/to/net.bin` combines
Intel and Apple Silicon slices.
On Linux, `SIMD=universal` builds for the selected CPU family. The combined
`linux-universal` release is a self-extracting executable containing both native
builds and one shared network. It requires `/bin/sh`, GNU coreutils, `gzip`, and
a writable private cache on a filesystem that permits execution. See
[the Linux packaging instructions](RELEASES.md#linux-universal-executable) for
cache settings and local assembly. Internal slices use `EMBED_NET=0` and load
`network.bin` beside the executable; ordinary builds keep `EMBED_NET=1`.
Portable targets use generic CPU tuning. For example, `make avx2 TUNE=znver2`
uses `-march=x86-64-v3 -mtune=znver2` while retaining the AVX2 requirement.
The old `legacy`, `modern`, `zen2`, and `vnni` target names are gone. Intel and
+97 -12
View File
@@ -22,10 +22,90 @@ The [README](../README.md#how-to-pick-the-right-executable) explains which
universal executable to download; [SIMD documentation](SIMD.md) covers individual
backend names for manual builds and older releases.
The **Release binaries** GitHub workflow automatically publishes only universal
binaries: Linux amd64, Linux arm64, Windows amd64, and combined macOS. Each target
has its own job so it can be rebuilt independently. Individual SIMD artifacts
downloads: combined Linux AMD64/ARM64, Windows amd64, and combined macOS. Targets
can be selected independently. Individual SIMD artifacts
are available through manual runs. The SIMD correctness workflow remains manual-only.
## Linux universal executable
`heimdall-<version>-linux-universal` is one executable file for AMD64 and ARM64.
It contains a `/bin/sh` launcher, two compressed native engines built with
`SIMD=universal EMBED_NET=0`, and **one compressed copy of the network weights**.
The AMD64 engine retains runtime x86 SIMD selection; ARM64 selects scalar or NEON.
Linux ELF files specify one machine architecture, so the shell launcher chooses
and extracts the native engine before executing it. This does not use emulation.
Download the `.tar.gz` archive, extract it and select the executable in your chess
GUI. The executable can be moved or symlinked by itself. It requires `/bin/sh`,
`gzip` and GNU coreutils (`uname`, `readlink`, `dd`, `sha256sum`, `stat`, `id`,
`mkdir`, `mktemp`, `chmod`, `mv`, `rm`). The engines retain their normal Linux
library requirements. A GUI must accept executable scripts; separate
`linux-amd64-universal` and `linux-arm64-universal` ELF downloads remain manually
selectable and are also used by older releases.
On first launch, the executable extracts only the selected engine and shared
`network.bin` into a private cache. The cache location is:
1. `$HEIMDALL_CACHE_DIR`, if set;
2. `$XDG_CACHE_HOME/heimdall`, if set;
3. `$HOME/.cache/heimdall` otherwise.
The cache path must be absolute, writable, owned by the current user and have
mode `700`. The cache directory itself and each entry must not be symlinks.
Its filesystem must permit execution: if the default cache is on a `noexec`
mount, set `HEIMDALL_CACHE_DIR` to a private directory on an executable filesystem.
Each content/launcher version and CPU family gets a separate entry. Extracted
engine/network files have modes `500`/`400`. Both are SHA-256 checked before
**every** launch; missing or corrupt files are extracted again. Temporary files
are private and installed atomically, allowing simultaneous starts and repair
while an older process is running. Interrupted extraction is cleaned up on normal
exit and catchable termination signals. Old entries are not removed automatically;
you can delete the cache when no Heimdall process is using it. The next launch
recreates it. As with any process, SIGKILL or a power failure can leave temporary
files, which can also be deleted then.
First launch adds decompression and disk writes; subsequent launches still read
and hash the cached engine and weights. The launcher emits errors on stderr and
keeps stdout clear for UCI. It uses `exec`, preserving arguments, environment
(including `HEIMDALL_SIMD`), UCI pipes, working directory, PID, signals and exit
status. The engine's default network loads from `network.bin` beside its cached
executable; `EvalFile` overrides and resetting to `<default>` continue to work.
Ordinary builds still embed their weights (`EMBED_NET=1`, the default).
CI builds and bench-checks internal slices on `ubuntu-24.04` and
`ubuntu-24.04-arm` from the same source SHA. Each slice archive records that SHA,
architecture settings, and engine/network hashes. Assembly rejects mismatched
sources, settings, weights, ELF architectures or checksums. It writes one shared
weights payload, then both native runners check the resulting executable with
the production bench and UCI/runtime-SIMD regressions. Publication waits for both
checks. Internal slice archives remain available in Actions but are never
published to Gitea. The `all` and `linux` selections additionally build the
standalone ELF variants with embedded weights.
To assemble locally, build each internal slice on its matching Linux host from
the same commit, network and Makefile settings:
```sh
# On AMD64:
python scripts/release.py slice-linux --target linux-amd64-universal --skip-deps --artifacts build/slices
# On ARM64:
python scripts/release.py slice-linux --target linux-arm64-universal --skip-deps --artifacts build/slices
# Collect both *-universal-slice.tar.gz archives in build/slices, then on Linux:
python scripts/release.py combine-linux --slices build/slices --artifacts build/release
```
Supply the same `--tag` to all three commands for a tagged release. Compilation
still goes through the source checkout's Makefile; assembly compiles nothing.
Pass custom network/architecture settings through Make's environment, for example
`MAKEFLAGS='EVALFILE=/absolute/path/to/net.bin'`. The source Makefile must support
`EMBED_NET=0`; older tags without it cannot produce this combined artifact.
No cross-compiler is needed. Test the resulting executable on both CPU families.
The release publishes the executable, its `<executable>.sha256`, and a `.tar.gz`
archive containing both. After extracting, run `sha256sum -c <executable>.sha256`.
The self-extracting file is deterministic for identical inputs and packaging
Python/zlib versions; the outer download archive retains normal release metadata.
## macOS targets
| Native Make target | Required build host | Release target | Backend |
@@ -67,31 +147,34 @@ In GitHub **Actions → Release binaries → Run workflow**:
3. Set `release_tag` to the existing tag, such as `1.5.1-dev`.
4. Leave `source_ref` empty to build the exact commit referenced by that tag.
Only the selected target is compiled, bench-checked, packaged and uploaded.
Only the selected target is compiled, bench-checked, packaged and uploaded
(`linux-universal` builds and checks both Linux slices).
The existing tag is not moved. A rerun replaces only files belonging to that
target; other release assets and older aggregate archives remain intact.
Tag pushes always select `universal`. Manual runs and the local planning command
also default to `universal`; choose `all` explicitly to build every individual
SIMD variant as well. Manual `linux`, `windows`, and `macos` selections include
all variants for that platform (`macos` selects all three Mac builds). A complete
target name builds just that artifact. No push to a branch or pull request starts
the release workflow.
all variants for that platform, including the combined package (`macos` selects
all three Mac builds). A complete target name builds just that artifact. No push
to a branch or pull request starts the release workflow.
Current selectable targets:
- `linux-amd64-sse2`, `linux-amd64-ssse3`, `linux-amd64-sse41`,
`linux-amd64-avx2`, `linux-amd64-avx512`, `linux-amd64-avx512-vnni`.
- `linux-amd64-universal`, `linux-arm64-neon`, `linux-arm64-universal`.
- `linux-amd64-universal`, `linux-arm64-neon`, `linux-arm64-universal`, `linux-universal`.
- `windows-amd64-sse2`, `windows-amd64-ssse3`, `windows-amd64-sse41`,
`windows-amd64-avx2`, `windows-amd64-avx512`, `windows-amd64-avx512-vnni`.
- `windows-amd64-universal`.
- `macos-amd64-sse2`, `macos-arm64-neon`, `macos-universal`.
The catalog lives in `scripts/release.py`; Makefile owns compiler flags, network
settings and filename versioning. Each selected target becomes an independent
matrix job, including its upload. Jobs for the same release tag and target are
serialized to prevent simultaneous replacements of the same assets.
settings and filename versioning. Native targets become independent matrix jobs,
including their uploads. `linux-universal` expands into two native slice builds
followed by assembly, checks on both architectures, and publication. Publication
jobs for the same release tag and target are serialized to prevent simultaneous
replacements of the same assets.
## Source selection and publishing
@@ -109,9 +192,9 @@ added without recreating the tag.
Publishing uses the existing `GITEA_BASE_URL`, `GITEA_REPO` and `GITEA_TOKEN`
secrets. If the tag has no release record yet, the existing publisher creates one;
stable versions start as drafts, while alpha/beta/rc/dev releases are prereleases.
Every target owns three uniquely named files: its executable, an executable-name
Each target owns three uniquely named files: its executable, an executable-name
`.sha256` file, and a `.tar.gz` archive (`.zip` on Windows) containing both.
Checksums are per binary so one target cannot overwrite another target's manifest.
Checksums are per executable so one target cannot overwrite another target's manifest.
For a local check using existing dependencies and weights:
@@ -124,6 +207,8 @@ The build command runs from the source checkout on a matching host, uses its
Makefile, and checks the resulting binary against that commit's recorded bench.
It does not publish anything. The default output directory is `artifacts`; the
example keeps local artifacts under ignored `build/` instead.
Use `plan --target linux-universal` to select the combined CI package, and
`combine-linux` to assemble it locally after building both slices.
The helper requires GNU Make with `--eval` support. macOS CI installs Homebrew's
Make and places its `libexec/gnubin` directory on `PATH` ahead of Apple's Make.
+15 -3
View File
@@ -97,7 +97,16 @@ attributes on x86; LTO preserves those boundaries. Do not add native or advanced
ISA flags globally to a universal build. The scalar path may be autovectorized
within the baseline ISA. `SIMD=auto` retains the existing native build behavior.
Linux and Windows binaries cover one CPU family each. On a Mac,
Each Linux ELF and Windows executable covers one CPU family. The combined Linux
release is a self-extracting executable containing AMD64 and ARM64 ELF engines,
each built with `SIMD=universal EMBED_NET=0`, and one shared network payload. Its
shell launcher extracts the selected engine and weights into a private cache,
checks their hashes and uses `exec` to start the engine. There is no emulation.
Arguments, UCI streams, the working directory and `HEIMDALL_SIMD` pass through
unchanged. See [Linux release packaging](RELEASES.md#linux-universal-executable)
for cache requirements, tools and assembly commands.
On a Mac,
`make macos-universal SKIP_DEPS=1 EVALFILE=/absolute/path/to/net.bin` compiles
both CPU families and combines them with `xcrun lipo`. Both slices use the same
network layout; the disk weights are embedded once per slice. This target needs
@@ -226,8 +235,11 @@ Windows, Intel Mac and Apple Silicon. The Apple Silicon job also checks both
slices of the combined executable using Rosetta. QEMU's Opteron G1, Conroe,
Penryn and Haswell models check minimum ISA compatibility and runtime fallback,
including AVX2 hardware without OSXSAVE support.
The **Release binaries** workflow gives every Linux, Windows and macOS artifact
its own job. Tag pushes build only universal targets; manual dispatch can also
The **Release binaries** workflow builds each CPU slice on its native runner.
For the combined Linux package it assembles both slices, then checks the same
archive through its launcher on native AMD64 and ARM64 runners before publishing.
Tag pushes publish combined Linux, Windows amd64 and combined macOS universal
downloads; manual dispatch can also
select individual SIMD targets, a platform, or all variants and publish them to
an existing tag. Intel and Apple Silicon Mac jobs
run natively on `macos-15-intel` and `macos-15`. AVX-512/VNNI correctness runs
+1 -1
View File
@@ -10,7 +10,7 @@ and can be added to chess GUIs such as Arena, En Croissant, and Cutechess.
- `Ponder`: allow searching while the opponent searches.
- `UCI_ShowWDL`: show predicted win, draw, and loss probabilities.
- `UCI_Chess960`: enable Fischer Random and Double Fischer Random chess.
- `EvalFile`: path to a compatible external neural network; `<default>` uses the embedded network.
- `EvalFile`: path to a compatible external neural network; `<default>` restores the shipped network (embedded in ordinary builds, shared in the combined Linux executable).
- `NormalizeScore`: normalize displayed scores to a win probability. Enabled by default.
- `EnableWeirdTCs`: permit untested time controls such as sudden death and moves to go.
- `MultiPV`: number of principal variations; time limits are shared across lines.
+78
View File
@@ -0,0 +1,78 @@
#!/bin/sh
# Copyright 2026 Mattia Giambirtone & All Contributors
# SPDX-License-Identifier: Apache-2.0
# Generated release header. Compressed engine slices and shared weights follow.
# Parse the complete function before reaching the binary payload.
heimdall_main() {
fail() { echo "heimdall: $*" >&2; exit 1; }
[ "$(uname -s)" = Linux ] || fail 'this executable requires Linux'
case "$(uname -m)" in
x86_64|amd64)
arch=amd64
engine_offset=@AMD64_OFFSET@
engine_size=@AMD64_SIZE@
engine_hash=@AMD64_HASH@
;;
aarch64|arm64)
arch=arm64
engine_offset=@ARM64_OFFSET@
engine_size=@ARM64_SIZE@
engine_hash=@ARM64_HASH@
;;
*) fail 'this executable requires AMD64 or ARM64 Linux' ;;
esac
self=$(readlink -f -- "$0") || fail 'cannot locate this executable'
cache_root=${HEIMDALL_CACHE_DIR:-${XDG_CACHE_HOME:-${HOME:?HOME or HEIMDALL_CACHE_DIR must be set}/.cache}/heimdall}
case "$cache_root" in /*) ;; *) fail 'cache directory must be an absolute path' ;; esac
original_umask=$(umask)
umask 077
mkdir -p -- "$cache_root" || fail "cannot create cache directory $cache_root"
private_directory() {
[ -d "$1" ] && [ ! -L "$1" ] &&
[ "$(stat -c %u -- "$1")" = "$(id -u)" ] &&
[ "$(stat -c %a -- "$1")" = 700 ]
}
private_directory "$cache_root" || fail 'cache must be a private, user-owned directory (mode 700), not a symlink'
cache="$cache_root/@BUNDLE_ID@-$arch"
mkdir -p -- "$cache" || fail "cannot create cache directory $cache"
private_directory "$cache" || fail 'unsafe cache entry'
valid_files() {
[ -f "$1/heimdall" ] && [ ! -L "$1/heimdall" ] &&
[ -x "$1/heimdall" ] && [ -f "$1/network.bin" ] && [ ! -L "$1/network.bin" ] &&
(cd -- "$1" && sha256sum --status -c <<SUMS
$engine_hash heimdall
@NETWORK_HASH@ network.bin
SUMS
)
}
if ! valid_files "$cache"; then
stage=$(mktemp -d "$cache/.extract.XXXXXXXXXX") || fail 'cannot create extraction directory'
trap 'rm -rf -- "$stage"' 0
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
extract() {
dd if="$self" bs=65536 skip="$1" count="$2" iflag=skip_bytes,count_bytes status=none |
gzip -dc > "$3"
}
extract "$engine_offset" "$engine_size" "$stage/heimdall" || fail 'engine extraction failed'
extract @NETWORK_OFFSET@ @NETWORK_SIZE@ "$stage/network.bin" || fail 'network extraction failed'
chmod 500 "$stage/heimdall" && chmod 400 "$stage/network.bin" || fail 'cannot set cache permissions'
valid_files "$stage" || fail 'payload checksum mismatch; download the executable again'
# Atomic file replacement also repairs corrupt caches without modifying
# an inode used by a running engine. Concurrent writers publish identical
# verified bytes; no lock or stale lock recovery is needed.
mv -fT -- "$stage/network.bin" "$cache/network.bin" &&
mv -fT -- "$stage/heimdall" "$cache/heimdall" || fail 'cannot install cached files'
rm -rf -- "$stage"
trap - 0 HUP INT TERM
fi
umask "$original_umask"
# exec preserves UCI pipes, cwd, arguments, PID, signals and exit status.
# If execution fails (e.g. a noexec mount), the shell reports the error.
exec "$cache/heimdall" "$@"
}
heimdall_main "$@"
exit 1
# Compressed payload follows.
+168 -6
View File
@@ -18,15 +18,19 @@
"""Plan independent release jobs and build/package one Makefile backend."""
import argparse
import gzip
import hashlib
import io
import json
import os
from pathlib import Path
import re
import shutil
import shlex
import subprocess
import sys
import tarfile
import tempfile
import zipfile
@@ -47,16 +51,31 @@ TARGETS = {
}
TARGETS["macos-universal"] = dict(target="macos-universal", os="macos", arch="universal",
runner="macos-15", backend="universal", make_target="macos-universal")
LINUX_SLICES = ("linux-amd64-universal", "linux-arm64-universal")
TARGETS["linux-universal"] = dict(target="linux-universal", os="linux", arch="universal",
backend="universal")
def includes_linux_bundle(selection):
return selection in ("all", "universal", "linux", "linux-universal")
def matrix(selection):
targets = [value for key, value in TARGETS.items()
if selection in ("all", value["os"], key) or
(selection == "universal" and value["backend"] == "universal")]
(selection == "universal" and value["backend"] == "universal"
and key not in LINUX_SLICES)]
if not targets:
raise ValueError(f"Unknown release target {selection!r}; choose all, universal, linux, windows, macos, or "
+ ", ".join(TARGETS))
return {"include": targets}
# The combined Linux artifact uses two native builds. Individual slices are
# published only when explicitly selected (including all/platform selections).
jobs = {target["target"]: dict(target, publish=True) for target in targets
if target["target"] != "linux-universal"}
if includes_linux_bundle(selection):
for name in LINUX_SLICES:
jobs.setdefault(name, dict(TARGETS[name], publish=False))
return {"include": list(jobs.values())}
def git(*args):
@@ -91,13 +110,15 @@ def plan(args):
and os.environ.get("GITHUB_REF_TYPE") == "tag")
# Automatic releases contain only universal binaries; expanded selections
# remain available through manual workflow runs and the local CLI.
selected = matrix("universal" if tag_push else args.target)
selection = "universal" if tag_push else args.target
selected = matrix(selection)
tag = args.tag
if not tag and tag_push:
tag = os.environ["GITHUB_REF_NAME"]
version_flags(tag)
source = release_source(args.ref, tag, os.environ.get("GITHUB_REF", "HEAD"))
values = dict(matrix=json.dumps(selected, separators=(",", ":")), source_sha=source, tag=tag)
values = dict(matrix=json.dumps(selected, separators=(",", ":")), source_sha=source, tag=tag,
linux_universal=str(includes_linux_bundle(selection)).lower())
outputs(values)
print(json.dumps(values, indent=2))
@@ -147,7 +168,139 @@ def package(binary, directory):
return [copied, checksum, archive]
def validate_elf(data, machine):
if (len(data) < 64 or data[:7] != b"\x7fELF\x02\x01\x01"
or int.from_bytes(data[16:18], "little") not in (2, 3)
or int.from_bytes(data[18:20], "little") != machine):
raise ValueError(f"Expected a little-endian ELF64 executable for machine {machine}")
def linux_executable(amd64, arm64, network, output):
"""Write a shell header, two compressed engines and one shared network."""
for data, machine in ((amd64, 62), (arm64, 183)):
validate_elf(data, machine)
payloads = dict(AMD64=amd64, ARM64=arm64, NETWORK=network)
template = Path(__file__).with_name("linux-universal.sh").read_text(encoding="utf-8")
values = {key + "_HASH": hashlib.sha256(data).hexdigest() for key, data in payloads.items()}
values["BUNDLE_ID"] = hashlib.sha256((template + json.dumps(values, sort_keys=True)).encode()).hexdigest()
compressed = {key: gzip.compress(data, mtime=0) for key, data in payloads.items()}
for key, data in compressed.items():
values[key + "_SIZE"] = str(len(data))
values[key + "_OFFSET"] = "0"
# Offsets are decimal and their own lengths contribute to the header size.
while True:
header = re.sub(r"@([A-Z0-9_]+)@", lambda match: values[match[1]], template).encode("utf-8")
offset = len(header)
previous = dict(values)
for key, data in compressed.items():
values[key + "_OFFSET"] = str(offset)
offset += len(data)
if previous == values:
break
output.parent.mkdir(parents=True, exist_ok=True)
with output.open("wb") as stream:
stream.write(header)
for data in compressed.values():
stream.write(data)
output.chmod(0o755)
def slice_linux(args):
"""Build an engine without embedded weights and record its matching network."""
target = TARGETS[args.target]
flags = version_flags(args.tag)
system, arch, stable, development, _ = make_config(flags)
if system != "linux" or arch != target["arch"]:
raise ValueError(f"{args.target} requires its matching native Linux host")
source = source_commit("HEAD")
if args.tag:
release_source(source, args.tag, "HEAD")
flags.append("EMBED_NET=0")
# The tagged Makefile owns settings. Older sources must already support
# external default weights; merely passing an unknown Make variable is unsafe.
rule = ("heimdall-slice-config:\n"
"\t@echo $(EMBED_NET) $(filter -d:embedNet=%,$(CUSTOM_FLAGS))\n"
"\t@echo $(EVALFILE)\n\t@echo $(MAIN)\n\t@echo $(CUSTOM_FLAGS)")
config = subprocess.check_output(["make", "--no-print-directory", "-s", "--eval=" + rule,
"heimdall-slice-config", *flags], text=True).splitlines()
if len(config) != 4 or config[0].strip() != "0 -d:embedNet=false":
raise ValueError("The source Makefile does not support EMBED_NET=0")
network_path = (Path(config[2]).parent / config[1]).resolve()
settings = [flag for flag in shlex.split(config[3]) if not flag.startswith("-d:evalFile=")]
directory = Path("build/release-slices") / arch
binary = directory / "heimdall"
command = ["make", "universal", "NIMBLE_FLAGS=-y", *flags, f"EXE_BASE={binary}"]
if args.skip_deps:
command.append("SKIP_DEPS=1")
subprocess.run(command, check=True)
shutil.copyfile(network_path, directory / "network.bin")
subprocess.run([sys.executable, str(Path(__file__).with_name("check_binary_benches.py")),
"--commit", source, "--", str(binary)], check=True)
data = binary.read_bytes()
network = (directory / "network.bin").read_bytes()
validate_elf(data, 62 if arch == "amd64" else 183)
metadata = dict(source=source, arch=arch, settings=settings,
engine_sha256=hashlib.sha256(data).hexdigest(),
network_sha256=hashlib.sha256(network).hexdigest())
base = stable if "IS_RELEASE=1" in flags else development
args.artifacts.mkdir(parents=True, exist_ok=True)
archive = args.artifacts / (base + "-universal-slice.tar.gz")
with tarfile.open(archive, "w:gz") as bundle:
for name, contents in (("heimdall", data), ("network.bin", network),
("metadata.json", json.dumps(metadata, sort_keys=True).encode())):
info = tarfile.TarInfo(name)
info.size = len(contents)
info.mode = 0o755 if name == "heimdall" else 0o644
bundle.addfile(info, io.BytesIO(contents))
print("Packaged internal slice: " + str(archive), flush=True)
def read_linux_slice(path, arch, source):
with tarfile.open(path) as archive:
if sorted(archive.getnames()) != ["heimdall", "metadata.json", "network.bin"]:
raise ValueError(f"Unexpected members in {path}")
if any(not member.isfile() for member in archive.getmembers()):
raise ValueError(f"Non-file member in {path}")
metadata = json.load(archive.extractfile("metadata.json"))
engine = archive.extractfile("heimdall").read()
network = archive.extractfile("network.bin").read()
if not isinstance(metadata, dict) or not isinstance(metadata.get("settings"), list):
raise ValueError(f"Invalid slice metadata in {path}")
if metadata.get("source") != source or metadata.get("arch") != arch:
raise ValueError(f"Source/architecture mismatch in {path}")
for key, data in (("engine", engine), ("network", network)):
if metadata.get(key + "_sha256") != hashlib.sha256(data).hexdigest():
raise ValueError(f"Checksum mismatch for {key} in {path}")
validate_elf(engine, 62 if arch == "amd64" else 183)
return engine, network, metadata
def combine_linux(args):
flags = version_flags(args.tag)
system, arch, stable, development, _ = make_config(flags)
if system != "linux":
raise ValueError("combine-linux requires a Linux source checkout")
source = source_commit("HEAD")
if args.tag:
release_source(source, args.tag, "HEAD")
base = (stable if "IS_RELEASE=1" in flags else development).removesuffix("-" + arch)
slices = [read_linux_slice(args.slices / (base + "-" + cpu + "-universal-slice.tar.gz"), cpu, source)
for cpu in ("amd64", "arm64")]
if slices[0][1] != slices[1][1] or slices[0][2]["settings"] != slices[1][2]["settings"]:
raise ValueError("Linux slices must use identical network weights and architecture settings")
name = base + "-universal"
with tempfile.TemporaryDirectory() as temp:
binary = Path(temp) / name
linux_executable(slices[0][0], slices[1][0], slices[0][1], binary)
files = package(binary, args.artifacts)
outputs(dict(artifact_name=name))
print("Packaged: " + ", ".join(map(str, files)), flush=True)
def build(args):
if args.target == "linux-universal":
raise ValueError("Use slice-linux for linux-amd64-universal and linux-arm64-universal on their native hosts, "
"then use combine-linux --slices DIRECTORY")
target = TARGETS[args.target]
flags = version_flags(args.tag)
system, arch, release, prerelease, extension = make_config(flags)
@@ -188,10 +341,19 @@ def main():
building.add_argument("--tag", default="")
building.add_argument("--artifacts", type=Path, default=Path("artifacts"))
building.add_argument("--skip-deps", action="store_true", help="Use locally installed dependencies and weights")
slicing = commands.add_parser("slice-linux", help="Build one internal Linux slice with external default weights")
slicing.add_argument("--target", required=True, choices=LINUX_SLICES)
slicing.add_argument("--tag", default="")
slicing.add_argument("--artifacts", type=Path, default=Path("slices"))
slicing.add_argument("--skip-deps", action="store_true")
combining = commands.add_parser("combine-linux", help="Combine bench-checked Linux slice artifacts")
combining.add_argument("--slices", type=Path, required=True, help="Directory containing both internal slice archives")
combining.add_argument("--tag", default="")
combining.add_argument("--artifacts", type=Path, default=Path("artifacts"))
args = parser.parse_args()
try:
(plan if args.command == "plan" else build)(args)
except (ValueError, subprocess.CalledProcessError) as exc:
{"plan": plan, "build": build, "slice-linux": slice_linux, "combine-linux": combine_linux}[args.command](args)
except (OSError, ValueError, tarfile.TarError, subprocess.CalledProcessError) as exc:
parser.exit(1, str(exc) + "\n")
+7 -1
View File
@@ -19,6 +19,8 @@ import heimdall/util/memory/thp/alloc
when defined(simd):
import heimdall/util/simd_dispatch
import std/typetraits
when not EMBED_NET:
import std/os
when defined(simd):
import heimdall/util/simd
@@ -113,7 +115,11 @@ proc newEvalState*(networkPath: string = "", verbose: static bool = true): EvalS
# zero = true: EvalStateObj holds a managed board ref that must start nil
result = allocHugePage[EvalStateObj](zero = true)
if networkPath == "":
when not VERBATIM_NET:
when not EMBED_NET:
when verbose:
echo "info string loading bundled network"
network = loadNet(getAppDir() / "network.bin")
elif not VERBATIM_NET:
when verbose:
echo "info string loading built-in network"
network = loadNet(newStringStream(DEFAULT_NET_WEIGHTS))
+4 -1
View File
@@ -70,8 +70,9 @@ const
14, 14, 15, 15, 15, 15, 14, 14,
]
DEFAULT_NET_PATH* {.define: "evalFile".} = ""
EMBED_NET* {.booldefine: "embedNet".} = true
DEFAULT_NET_WEIGHTS* = block:
when not VERBATIM_NET:
when not VERBATIM_NET and EMBED_NET:
staticRead(DEFAULT_NET_PATH)
else:
""
@@ -314,6 +315,8 @@ proc dumpNet*(net: Network, path: string) =
proc loadNet*(path: string): Network =
let net = newFileStream(path, fmRead)
if net == nil:
raise newException(IOError, "Cannot open network: " & path)
defer: net.close()
return net.loadNet()
+3 -3
View File
@@ -88,8 +88,8 @@ const HELP_TEXT* = """heimdall help menu:
- ibucket : Print the current king input bucket
- obucket : Print the current output bucket
- material : Print the sum of material (using 1, 3, 3, 5, 9 as values) currently on the board
- verbatim <path> : Dumps the built-in network to the specified path, straight from the binary
- network : Prints the name of the network embedded into the engine
- verbatim <path> : Dumps the default network to the specified path in memory layout
- network : Prints the name of the default network shipped with the engine
- uci : Switches from mixed mode to UCI mode
- icu : The opposite of the uci command, reverts back to mixed mode.
This nonstandard command is (obviously) available even in UCI mode.
@@ -100,4 +100,4 @@ const HELP_TEXT* = """heimdall help menu:
- getStats <book> : Collect statistics about an opening book
- getScale <o> <n>: Print the new expected eval scale given the old (o) and new (n)
absolute mean values returned by getStats
"""
"""
+244
View File
@@ -0,0 +1,244 @@
# Copyright 2026 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Authored with assistance from AI agents.
"""Check the combined Linux executable, shared weights and private cache."""
import argparse
from concurrent.futures import ThreadPoolExecutor
import hashlib
import gzip
import io
import importlib.util
import json
import os
import re
from pathlib import Path
import signal
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location("release", ROOT / "scripts/release.py")
release = importlib.util.module_from_spec(spec)
spec.loader.exec_module(release)
def elf(machine):
header = bytearray(64)
header[:7] = b"\x7fELF\x02\x01\x01"
header[16:18] = (3).to_bytes(2, "little")
header[18:20] = machine.to_bytes(2, "little")
return bytes(header)
class LinuxPackageTests(unittest.TestCase):
def test_executable_contains_one_network_and_two_valid_compressed_slices(self):
with tempfile.TemporaryDirectory() as temp:
directory = Path(temp)
binary = directory / "heimdall-linux-universal"
network = b"shared network weights" * 100
release.linux_executable(elf(62), elf(183), network, binary)
data = binary.read_bytes()
header = data.split(b"# Compressed payload follows.\n", 1)[0].decode()
self.assertNotIn("@NETWORK", header)
for key, expected in (("amd64", elf(62)), ("arm64", elf(183))):
block = header.split("arch=" + key, 1)[1].split(";;", 1)[0]
offset = int(re.search(r"engine_offset=(\d+)", block)[1])
size = int(re.search(r"engine_size=(\d+)", block)[1])
self.assertEqual(gzip.decompress(data[offset:offset + size]), expected)
offset, size = map(int, re.search(r'extract (\d+) (\d+) "\$stage/network.bin"', header).groups())
self.assertEqual(gzip.decompress(data[offset:offset + size]), network)
self.assertEqual(offset + size, len(data))
release.linux_executable(elf(62), elf(183), network, binary)
self.assertEqual(binary.read_bytes(), data)
files = release.package(binary, directory / "artifacts")
self.assertEqual(files[1].read_text(), hashlib.sha256(data).hexdigest() + " " + binary.name + "\n")
with tarfile.open(files[2]) as archive:
self.assertEqual(set(archive.getnames()), {binary.name, files[1].name})
if os.name == "posix":
self.assertEqual(archive.getmember(binary.name).mode, 0o755)
def test_wrong_architecture_non_elf_and_truncated_inputs_are_rejected(self):
for bad in (elf(183), b"#!/bin/sh\n", elf(62)[:20]):
with self.assertRaisesRegex(ValueError, "ELF64"):
release.validate_elf(bad, 62)
def test_combine_rejects_wrong_source_checksums_network_and_settings(self):
with tempfile.TemporaryDirectory() as temp:
directory = Path(temp)
def write_slice(arch, **overrides):
data = elf(62 if arch == "amd64" else 183)
network = overrides.pop("network", b"shared weights")
metadata = dict(source="source", arch=arch, settings=["-d:embedNet=false"],
engine_sha256=hashlib.sha256(data).hexdigest(),
network_sha256=hashlib.sha256(network).hexdigest())
metadata.update(overrides)
path = directory / ("heimdall-dev-abcdef-linux-" + arch + "-universal-slice.tar.gz")
with tarfile.open(path, "w:gz") as archive:
for name, payload in (("heimdall", data), ("network.bin", network),
("metadata.json", json.dumps(metadata).encode())):
member = tarfile.TarInfo(name)
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
write_slice("amd64")
write_slice("arm64")
args = argparse.Namespace(tag="", slices=directory, artifacts=directory / "out")
with patch.object(release, "make_config", return_value=("linux", "amd64", "unused",
"heimdall-dev-abcdef-linux-amd64", "x")), \
patch.object(release, "source_commit", return_value="source"), \
patch.object(release, "outputs") as outputs, patch("builtins.print"):
release.combine_linux(args)
self.assertEqual(outputs.call_args.args[0]["artifact_name"], "heimdall-dev-abcdef-linux-universal")
for changes, error in ((dict(source="other"), "Source/architecture"),
(dict(engine_sha256="bad"), "Checksum"),
(dict(network=b"other weights"), "identical network"),
(dict(settings=["other settings"]), "identical network")):
write_slice("arm64", **changes)
with self.assertRaisesRegex(ValueError, error):
release.combine_linux(args)
@unittest.skipUnless(sys.platform.startswith("linux"), "Linux launcher uses Linux readlink")
class LinuxLauncherTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="heimdall bundle ")
self.addCleanup(self.temp.cleanup)
self.directory = Path(self.temp.name)
self.bundle = self.directory / "relocated package"
self.bundle.mkdir()
self.launcher = self.bundle / "heimdall"
self.network = b"one shared network"
engines = []
for arch in ("amd64", "arm64"):
engines.append(("#!" + sys.executable + "\n" +
"import json, os, sys, time\nfrom pathlib import Path\n" +
"assert Path(sys.argv[0]).with_name('network.bin').read_bytes() == " + repr(self.network) + "\n" +
"if 'wait' in sys.argv: print(os.getpid(), flush=True); time.sleep(30)\n" +
"print(json.dumps([" + repr(arch) + ", sys.argv[1:], " +
"os.getcwd(), os.environ.get('HEIMDALL_SIMD'), sys.stdin.read()]))\n" +
"print('engine stderr', file=sys.stderr)\n" +
"sys.exit(23)\n").encode())
# Launcher protocol tests use tiny scripts; production packaging validates ELF.
with patch.object(release, "validate_elf"):
release.linux_executable(*engines, self.network, self.launcher)
commands = self.directory / "commands"
commands.mkdir()
uname = commands / "uname"
uname.write_text('#!/bin/sh\ncase "$1" in -s) echo "${TEST_OS:-Linux}";; -m) echo "$TEST_ARCH";; esac\n')
uname.chmod(0o755)
self.env = {**os.environ, "PATH": str(commands) + ":" + os.environ["PATH"],
"TEST_ARCH": "x86_64", "HEIMDALL_SIMD": "scalar",
"HEIMDALL_CACHE_DIR": str(self.directory / "private cache")}
def run_launcher(self, command=None, **env):
return subprocess.run([str(command or self.launcher), "a b", "", "--flag"],
input="uci\nisready\n", capture_output=True, text=True,
cwd=self.directory, env={**self.env, **env}, timeout=10)
def test_selects_both_families_and_preserves_uci_arguments_environment_and_cwd(self):
for arch, expected in (("x86_64", "amd64"), ("amd64", "amd64"),
("aarch64", "arm64"), ("arm64", "arm64")):
with self.subTest(arch=arch):
result = self.run_launcher(TEST_ARCH=arch)
self.assertEqual(result.returncode, 23, result.stderr)
self.assertEqual(json.loads(result.stdout),
[expected, ["a b", "", "--flag"], str(self.directory),
"scalar", "uci\nisready\n"])
self.assertEqual(result.stderr, "engine stderr\n")
def test_symlink_and_path_launches_locate_the_payload(self):
link = self.directory / "engine link"
link.symlink_to(self.launcher)
for command, env in ((link, {}), ("heimdall", {"PATH": str(self.bundle) + ":" + self.env["PATH"]})):
result = self.run_launcher(command, **env)
self.assertEqual(result.returncode, 23, result.stderr)
def test_exec_preserves_pid_and_direct_signal_delivery(self):
with subprocess.Popen([str(self.launcher), "wait"], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, env=self.env) as process:
try:
self.assertEqual(int(process.stdout.readline()), process.pid)
process.terminate()
self.assertEqual(process.wait(timeout=5), -signal.SIGTERM)
finally:
if process.poll() is None:
process.kill()
def test_repeated_launch_reuses_cache_and_corruption_is_repaired(self):
self.assertEqual(self.run_launcher().returncode, 23)
root = Path(self.env["HEIMDALL_CACHE_DIR"])
cache = next(root.iterdir())
binary, network = cache / "heimdall", cache / "network.bin"
for path in (root, cache):
self.assertEqual(path.stat().st_mode & 0o777, 0o700)
self.assertEqual(binary.stat().st_mode & 0o777, 0o500)
self.assertEqual(network.stat().st_mode & 0o777, 0o400)
before = binary.stat().st_mtime_ns
self.assertEqual(self.run_launcher().returncode, 23)
self.assertEqual(binary.stat().st_mtime_ns, before)
network.chmod(0o600)
network.write_bytes(b"corrupt")
self.assertEqual(self.run_launcher().returncode, 23)
self.assertEqual(network.read_bytes(), self.network)
binary.unlink()
self.assertEqual(self.run_launcher().returncode, 23)
self.assertEqual(sorted(p.name for p in cache.iterdir()), ["heimdall", "network.bin"])
def test_concurrent_cold_starts_publish_identical_files_without_stale_locks(self):
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(lambda _: self.run_launcher(), range(16)))
for result in results:
self.assertEqual(result.returncode, 23, result.stderr)
cache = next(Path(self.env["HEIMDALL_CACHE_DIR"]).iterdir())
self.assertEqual(sorted(p.name for p in cache.iterdir()), ["heimdall", "network.bin"])
def test_unsupported_os_architecture_and_truncated_payload_fail_on_stderr(self):
for env, message in (({"TEST_OS": "Darwin"}, "requires Linux"),
({"TEST_ARCH": "riscv64"}, "AMD64 or ARM64")):
result = self.run_launcher(**env)
self.assertEqual(result.returncode, 1)
self.assertEqual(result.stdout, "")
self.assertIn(message, result.stderr)
self.launcher.write_bytes(self.launcher.read_bytes()[:-10])
result = self.run_launcher()
self.assertEqual(result.returncode, 1)
self.assertEqual(result.stdout, "")
self.assertIn("extraction failed", result.stderr)
root = Path(self.env["HEIMDALL_CACHE_DIR"])
self.assertEqual(list(root.glob("*/.extract.*")), [])
def test_unsafe_cache_permissions_symlinks_and_relative_paths_are_rejected(self):
root = Path(self.env["HEIMDALL_CACHE_DIR"])
root.mkdir(mode=0o755)
self.assertIn("private", self.run_launcher().stderr)
root.rmdir()
target = self.directory / "target"
target.mkdir(mode=0o700)
root.symlink_to(target, target_is_directory=True)
self.assertIn("not a symlink", self.run_launcher().stderr)
self.assertIn("absolute path", self.run_launcher(HEIMDALL_CACHE_DIR="relative").stderr)
def test_xdg_cache_default(self):
result = self.run_launcher(HEIMDALL_CACHE_DIR="", XDG_CACHE_HOME=str(self.directory / "xdg"))
self.assertEqual(result.returncode, 23, result.stderr)
self.assertEqual(len(list((self.directory / "xdg/heimdall").glob("*/network.bin"))), 1)
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -57,6 +57,21 @@ class ReleaseWorkflowTests(unittest.TestCase):
mac = release.matrix("macos-universal")["include"][0]
self.assertEqual(mac["make_target"], "macos-universal")
self.assertEqual(mac["arch"], "universal")
self.assertEqual({job["target"] for job in jobs if job["publish"]},
{"windows-amd64-universal", "macos-universal"})
def test_combined_linux_builds_both_slices_without_publishing_them(self):
jobs = release.matrix("linux-universal")["include"]
self.assertEqual({job["target"] for job in jobs}, set(release.LINUX_SLICES))
self.assertTrue(all(not job["publish"] for job in jobs))
self.assertTrue(release.includes_linux_bundle("universal"))
for selection in ("linux", "all"):
jobs = release.matrix(selection)["include"]
self.assertEqual(len(jobs), len({job["target"] for job in jobs}))
self.assertTrue(all(job["publish"] for job in jobs))
for selection in release.LINUX_SLICES:
self.assertFalse(release.includes_linux_bundle(selection))
self.assertTrue(release.matrix(selection)["include"][0]["publish"])
def test_published_additions_use_the_tagged_source(self):
with patch.object(release, "source_commit", side_effect=lambda ref: {"refs/tags/1.5.1-dev": "tagged", "master": "newer"}[ref]), \
@@ -84,6 +99,7 @@ class ReleaseWorkflowTests(unittest.TestCase):
patch.object(release, "outputs") as outputs, patch("builtins.print"):
release.plan(args)
self.assertEqual(outputs.call_args.args[0]["tag"], "1.5.1-dev")
self.assertEqual(outputs.call_args.args[0]["linux_universal"], "true")
self.assertEqual(json.loads(outputs.call_args.args[0]["matrix"]), release.matrix("universal"))
def test_default_cli_plan_selects_only_universal_binaries(self):
@@ -106,6 +122,8 @@ class ReleaseWorkflowTests(unittest.TestCase):
directory = Path(temp)
names = set()
for target in release.TARGETS.values():
if target["target"] == "linux-universal":
continue # The combined Linux archive is covered separately.
binary = directory / ("heimdall-1.5.1-" + target["target"] + (".exe" if target["os"] == "windows" else ""))
binary.write_bytes(b"test executable")
binary.chmod(0o755)