build(linux): 渲染依赖独立成包,便携版改为同一二进制加标记文件

- 新增 script/package-linux-gpu-stack{,.py,-build.sh,-docker.sh} 与
  script/linux-gpu-stack-install.sh:在 Rocky 8 容器里收集 Mesa/EGL/LLVM
  闭包,产出 navop-<version>-linux-<x64|arm64>-gpu-stack.tar.gz;安装脚本
  按 ldconfig 只补宿主缺失的库,支持 --dry-run/--force/--uninstall,卸载
  只回放自己安装过的文件
- 删除 Linux portable 变体:musl 启动器 script/linux-portable-launcher.c、
  script/package-linux-portable.{py,sh},发布矩阵移除 linux-x64-portable
  与 linux-arm64-portable
- Linux 应用包统一为默认 feature 的裸二进制,构建走
  cargo zigbuild --target <triple>.2.28,不再拆 --no-default-features 分支
- 便携包回归:Package (Linux) 用同一个二进制平铺、加一个空的
  navop.portable 标记打成 -portable.tar.gz,不新增矩阵项、不二次编译
- upload-r2.yml 的 targetFor() 识别 -portable./-gpu-stack. 后缀并映射到
  对应 target,避免这两类资产被归入 universal
- 三语言 install-update.md 更新下载表,新增图形依赖包与便携版说明
- 重写 script/test-release-packaging.mjs:退役 portable 打包器用例,新增
  依赖包三方契约、便携标记跨平台锁定、install.sh 增量卸载等断言
This commit is contained in:
胡飞
2026-09-22 13:45:05 +08:00
parent c6a3650b54
commit e38a35df99
14 changed files with 2441 additions and 1124 deletions
+72 -48
View File
@@ -16,9 +16,7 @@ on:
- macos-arm64
- macos-x64
- linux-x64
- linux-x64-portable
- linux-arm64
- linux-arm64-portable
- windows-x64
- windows-x86
build_mode:
@@ -131,23 +129,23 @@ jobs:
RELEASE_PLATFORM: ${{ inputs.platform }}
run: |
set -euo pipefail
macos_arm64='{"target":"aarch64-apple-darwin","os":"macos-latest","binary":"navop","archive":"navop-aarch64-apple-darwin.tar.gz","public_label":"macos-arm64","variant":"standard","arm_linux":false,"windows_native_rdp":false,"portable_linux":false}'
macos_x64='{"target":"x86_64-apple-darwin","os":"macos-15-intel","binary":"navop","archive":"navop-x86_64-apple-darwin.tar.gz","public_label":"macos-x64","variant":"standard","arm_linux":false,"windows_native_rdp":false,"portable_linux":false}'
linux_x64='{"target":"x86_64-unknown-linux-gnu","os":"ubuntu-latest","binary":"navop","archive":"navop-x86_64-unknown-linux-gnu.tar.gz","public_label":"linux-x64","variant":"standard","arm_linux":false,"windows_native_rdp":false,"portable_linux":false}'
linux_x64_portable='{"target":"x86_64-unknown-linux-gnu","os":"ubuntu-22.04","binary":"navop","archive":"navop-x86_64-unknown-linux-gnu-portable.tar.gz","public_label":"linux-x64-portable","variant":"portable","arm_linux":false,"windows_native_rdp":false,"portable_linux":true}'
linux_arm64='{"target":"aarch64-unknown-linux-gnu","os":"ubuntu-24.04-arm","binary":"navop","archive":"navop-aarch64-unknown-linux-gnu.tar.gz","public_label":"linux-arm64","variant":"standard","arm_linux":true,"windows_native_rdp":false,"portable_linux":false}'
linux_arm64_portable='{"target":"aarch64-unknown-linux-gnu","os":"ubuntu-24.04-arm","binary":"navop","archive":"navop-aarch64-unknown-linux-gnu-portable.tar.gz","public_label":"linux-arm64-portable","variant":"portable","arm_linux":true,"windows_native_rdp":false,"portable_linux":true}'
windows_x64='{"target":"x86_64-pc-windows-msvc","os":"windows-latest","binary":"navop.exe","archive":"navop-x86_64-pc-windows-msvc.zip","public_label":"windows-x64","variant":"standard","arm_linux":false,"windows_native_rdp":true,"portable_linux":false,"windows_arch":"x64"}'
windows_x86='{"target":"i686-pc-windows-msvc","os":"windows-latest","binary":"navop.exe","archive":"navop-i686-pc-windows-msvc.zip","public_label":"win32","variant":"standard","arm_linux":false,"windows_native_rdp":true,"portable_linux":false,"windows_arch":"x86"}'
# Linux ships one package per architecture. It is built with
# cargo-zigbuild against glibc 2.28 so it also runs on hosts that
# predate the runner image, and the Mesa stack it may need is a
# separate dependency archive rather than a second app package.
macos_arm64='{"target":"aarch64-apple-darwin","os":"macos-latest","binary":"navop","archive":"navop-aarch64-apple-darwin.tar.gz","public_label":"macos-arm64","arm_linux":false,"windows_native_rdp":false}'
macos_x64='{"target":"x86_64-apple-darwin","os":"macos-15-intel","binary":"navop","archive":"navop-x86_64-apple-darwin.tar.gz","public_label":"macos-x64","arm_linux":false,"windows_native_rdp":false}'
linux_x64='{"target":"x86_64-unknown-linux-gnu","os":"ubuntu-latest","binary":"navop","archive":"navop-x86_64-unknown-linux-gnu.tar.gz","public_label":"linux-x64","arm_linux":false,"windows_native_rdp":false}'
linux_arm64='{"target":"aarch64-unknown-linux-gnu","os":"ubuntu-24.04-arm","binary":"navop","archive":"navop-aarch64-unknown-linux-gnu.tar.gz","public_label":"linux-arm64","arm_linux":true,"windows_native_rdp":false}'
windows_x64='{"target":"x86_64-pc-windows-msvc","os":"windows-latest","binary":"navop.exe","archive":"navop-x86_64-pc-windows-msvc.zip","public_label":"windows-x64","arm_linux":false,"windows_native_rdp":true,"windows_arch":"x64"}'
windows_x86='{"target":"i686-pc-windows-msvc","os":"windows-latest","binary":"navop.exe","archive":"navop-i686-pc-windows-msvc.zip","public_label":"win32","arm_linux":false,"windows_native_rdp":true,"windows_arch":"x86"}'
case "$RELEASE_PLATFORM" in
all) matrix="[$macos_arm64,$macos_x64,$linux_x64,$linux_x64_portable,$linux_arm64,$linux_arm64_portable,$windows_x64,$windows_x86]" ;;
all) matrix="[$macos_arm64,$macos_x64,$linux_x64,$linux_arm64,$windows_x64,$windows_x86]" ;;
macos-arm64) matrix="[$macos_arm64]" ;;
macos-x64) matrix="[$macos_x64]" ;;
linux-x64) matrix="[$linux_x64,$linux_x64_portable]" ;;
linux-x64-portable) matrix="[$linux_x64_portable]" ;;
linux-arm64) matrix="[$linux_arm64,$linux_arm64_portable]" ;;
linux-arm64-portable) matrix="[$linux_arm64_portable]" ;;
linux-x64) matrix="[$linux_x64]" ;;
linux-arm64) matrix="[$linux_arm64]" ;;
windows-x64) matrix="[$windows_x64]" ;;
windows-x86) matrix="[$windows_x86]" ;;
*) echo "::error::Unsupported platform: $RELEASE_PLATFORM"; exit 1 ;;
@@ -155,7 +153,7 @@ jobs:
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
build:
name: Build (${{ matrix.target }} / ${{ matrix.variant }})
name: Build (${{ matrix.target }})
needs: prepare
strategy:
fail-fast: false
@@ -260,8 +258,8 @@ jobs:
$nasmDir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
nasm -v
- name: Install Zig toolchain (portable Linux)
if: matrix.portable_linux
- name: Install Zig toolchain (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
@@ -272,14 +270,6 @@ jobs:
"$RUNNER_TEMP/ziglang/bin/python" -m ziglang version
cargo-zigbuild --version
- name: Install portable packaging dependencies
if: matrix.portable_linux
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y binutils musl-tools
- name: Configure MSVC environment
if: runner.os == 'Windows'
uses: ilammy/msvc-dev-cmd@v1
@@ -339,13 +329,15 @@ jobs:
echo "CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS:-}"
echo "CARGO_PROFILE_RELEASE_LTO=${CARGO_PROFILE_RELEASE_LTO:-}"
echo "CARGO_PROFILE_RELEASE_CODEGEN_UNITS=${CARGO_PROFILE_RELEASE_CODEGEN_UNITS:-}"
if [ "${{ matrix.portable_linux }}" = "true" ]; then
if [ "${{ runner.os }}" = "Linux" ]; then
# The glibc 2.28 target is what makes the single Linux package run on
# hosts that predate the runner image; the dependency archive is
# built against the same baseline. Features stay at their defaults so
# this package is the one the updater has always served.
cargo zigbuild \
--release \
-p main \
--target "${{ matrix.target }}.2.28" \
--no-default-features \
--features wasm-components,shell-plugins
--target "${{ matrix.target }}.2.28"
else
cargo build --release -p main --target "${{ matrix.target }}"
fi
@@ -414,8 +406,8 @@ jobs:
throw "Release binary was not created: '$binary'"
}
- name: Verify portable Linux glibc baseline
if: matrix.portable_linux
- name: Verify Linux glibc baseline
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
@@ -472,18 +464,8 @@ jobs:
run: |
set -euo pipefail
rm -rf package
if [ "${{ matrix.portable_linux }}" = "true" ]; then
script/package-linux-portable.sh \
--binary "target/${{ matrix.target }}/release/${{ matrix.binary }}" \
--output package \
--launcher-source script/linux-portable-launcher.c \
--target "${{ matrix.target }}" \
--glibc-baseline "2.28"
else
mkdir -p package/usr/bin
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" package/usr/bin/
fi
mkdir -p package/usr/bin
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" package/usr/bin/
mkdir -p package/usr/share/applications
mkdir -p package/usr/share/mime/packages
@@ -505,14 +487,55 @@ jobs:
-C package .
cp "${{ matrix.archive }}" "${PUBLIC_BASENAME}.tar.gz"
# Portable variant: the very same binary plus the marker file the
# application looks for next to the executable. With the marker
# present Navop keeps config, state and cache under ./data instead of
# the host user directories, so the extracted directory can be moved
# around as a whole. External dependencies are still required, exactly
# like the regular package.
# Keep this in sync with PORTABLE_MARKER_FILE in
# crates/core/src/app_paths.rs; script/test-release-packaging.mjs
# asserts the two never drift apart.
PORTABLE_MARKER_FILE="navop.portable"
rm -rf portable-package
mkdir -p portable-package
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" portable-package/
: > "portable-package/${PORTABLE_MARKER_FILE}"
tar \
--sort=name \
--mtime='UTC 1970-01-01' \
--owner=0 \
--group=0 \
--numeric-owner \
-czf "${PUBLIC_BASENAME}-portable.tar.gz" \
-C portable-package .
- name: Package Linux GPU dependency stack
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
# The Mesa software renderer and the X11/Wayland client libraries ship
# as a separate dependency archive rather than inside this package, so
# the download stays small for hosts that already render. It is built
# against the same glibc 2.28 baseline as the binary above, because the
# host loader resolves the collected libraries directly.
script/package-linux-gpu-stack-docker.sh \
--binary "target/${{ matrix.target }}/release/${{ matrix.binary }}" \
--target "${{ matrix.target }}" \
--output dist-gpu-stack
# Publish it under the same versioned public name as every other
# asset, so a download page cannot mistake it for an unversioned file.
cp dist-gpu-stack/navop-gpu-stack-linux-*.tar.gz "${PUBLIC_BASENAME}-gpu-stack.tar.gz"
- name: Install Linux packaging dependencies
if: matrix.target == 'x86_64-unknown-linux-gnu' && !matrix.portable_linux
if: matrix.target == 'x86_64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y rpm
- name: Package Linux installers (x86_64)
if: matrix.target == 'x86_64-unknown-linux-gnu' && !matrix.portable_linux
if: matrix.target == 'x86_64-unknown-linux-gnu'
shell: bash
run: |
set -euo pipefail
@@ -682,7 +705,6 @@ jobs:
-ExpectedLanguage 1033
- name: Upload artifact
if: matrix.variant == 'standard'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.archive }}
@@ -694,6 +716,8 @@ jobs:
name: navop-${{ matrix.public_label }}-packages
path: |
navop-*-${{ matrix.public_label }}.tar.gz
navop-*-${{ matrix.public_label }}-portable.tar.gz
navop-*-${{ matrix.public_label }}-gpu-stack.tar.gz
navop-*-${{ matrix.public_label }}.dmg
navop-*-${{ matrix.public_label }}.zip
navop-*-${{ matrix.public_label }}-portable.zip
@@ -702,7 +726,7 @@ jobs:
if-no-files-found: error
- name: Upload Linux installer artifacts
if: matrix.target == 'x86_64-unknown-linux-gnu' && !matrix.portable_linux
if: matrix.target == 'x86_64-unknown-linux-gnu'
uses: actions/upload-artifact@v4
with:
name: navop-linux-x86_64-installers
+20 -5
View File
@@ -228,15 +228,30 @@ jobs:
const publicTargets = [
["macos-arm64", "aarch64-apple-darwin"],
["macos-x64", "x86_64-apple-darwin"],
["linux-x64-portable", "x86_64-unknown-linux-gnu"],
["linux-arm64-portable", "aarch64-unknown-linux-gnu"],
["linux-x64", "x86_64-unknown-linux-gnu"],
["linux-arm64", "aarch64-unknown-linux-gnu"],
["windows-x64", "x86_64-pc-windows-msvc"],
["win32", "i686-pc-windows-msvc"],
];
const publicTarget = publicTargets.find(([label]) => fileName.includes(`-${label}.`) || fileName.includes(`-${label}-portable.`));
if (publicTarget) return fileName.includes("-portable.") ? `${publicTarget[1]}-portable` : publicTarget[1];
// The portable variants and the Linux GPU dependency stack are
// published next to the app archive as extra assets for the same
// target, so they have to map onto that target instead of falling
// through to "universal".
const publicTarget = publicTargets.find(
([label]) =>
fileName.includes(`-${label}.`) ||
fileName.includes(`-${label}-portable.`) ||
fileName.includes(`-${label}-gpu-stack.`),
);
if (publicTarget) {
if (fileName.includes("-portable.")) {
return `${publicTarget[1]}-portable`;
}
if (fileName.includes("-gpu-stack.")) {
return `${publicTarget[1]}-gpu-stack`;
}
return publicTarget[1];
}
const knownTargets = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
@@ -246,7 +261,7 @@ jobs:
"i686-pc-windows-msvc",
];
const target = knownTargets.find((candidate) => fileName.includes(candidate));
if (target) return fileName.includes("-portable.") ? `${target}-portable` : target;
if (target) return target;
if (/_(?:[^_]+)_amd64\.(?:deb|AppImage)$/.test(fileName) || /\.x86_64\.rpm$/.test(fileName)) {
return "x86_64-unknown-linux-gnu";
}
+93 -3
View File
@@ -18,8 +18,11 @@ If Gatekeeper blocks the first macOS launch, verify the official release source
| Windows | x86_64 | `navop-<version>-windows-x64.exe` | EXE installer wrapping the same standard per-user MSI installation |
| Windows | x86_64 | `navop-<version>-windows-x64.zip` | No-install use with data kept in the normal Windows user directories |
| Windows | x86_64 | `navop-<version>-windows-x64-portable.zip` | Keep the application and data together in a movable folder |
| Linux | x86_64 | `navop-<version>-linux-x64.tar.gz`, `navop-<version>-linux-x64-portable.tar.gz`, `navop_<version>_amd64.deb`, `navop-<version>-1.x86_64.rpm`, `navop_<version>_amd64.AppImage` | Select for the distribution and desktop environment |
| Linux | ARM64 | `navop-<version>-linux-arm64.tar.gz`, `navop-<version>-linux-arm64-portable.tar.gz` | ARM64 devices |
| Linux | x86_64 | `navop-<version>-linux-x64.tar.gz`, `navop_<version>_amd64.deb`, `navop-<version>-1.x86_64.rpm`, `navop_<version>_amd64.AppImage` | Select for the distribution and desktop environment |
| Linux | x86_64 | `navop-<version>-linux-x64-portable.tar.gz` | Keep the application and data together in a movable folder; see "Linux portable archive" below |
| Linux | ARM64 | `navop-<version>-linux-arm64.tar.gz` | ARM64 devices |
| Linux | ARM64 | `navop-<version>-linux-arm64-portable.tar.gz` | Keep the application and data together in a movable folder; see "Linux portable archive" below |
| Linux | x86_64 / ARM64 | `navop-<version>-linux-x64-gpu-stack.tar.gz`, `navop-<version>-linux-arm64-gpu-stack.tar.gz` | Only when the system lacks a usable Mesa/EGL renderer; combines with any Linux package above |
Use `sha256sums.txt` from the same release to verify download integrity.
@@ -157,6 +160,93 @@ $env:NAVOP_DATA_DIR = "E:\NavopData"
`NAVOP_PORTABLE` accepts `1`, `true`, `yes`, or `on`. Data-location precedence is `--data-dir`, `--portable`, `NAVOP_DATA_DIR`, `NAVOP_PORTABLE`/`navop.portable`, and finally standard installed mode. The selected directory must be writable. Prefer an absolute path because a relative path is resolved from the process's current working directory.
## Linux portable archive
`navop-<version>-linux-x64-portable.tar.gz` (and `navop-<version>-linux-arm64-portable.tar.gz` on ARM64) **ships the very same binary** as the regular package for that architecture. The only difference is the extra `navop.portable` marker file inside the archive. When Navop finds that marker next to the executable, it relocates its data directories from the system user directories to a `data` folder beside the program, which makes the installation install-free and movable as a whole:
```bash
mkdir navop-portable
tar -xzf navop-<version>-linux-x64-portable.tar.gz -C navop-portable
cd navop-portable
./navop
```
```text
navop-portable/
├── navop
├── navop.portable
└── data/ <- created on first launch
├── config/
├── state/
└── cache/
```
Keep the following in mind:
- **The portable archive carries no graphics dependencies.** Exactly like the regular package it still relies on the host for its graphics stack; install the "Linux graphics dependency package" below when that stack is missing, and the two combine.
- Portable mode registers no `.db`, `.duckdb`, or `.md` file associations, and it supports neither in-app installation nor automatic update checks. Use `.deb`, `.rpm`, or the AppImage when you need those.
- The portable directory must be writable. Placing it on read-only media makes Navop fail to start.
- Portable mode does not remember the master key by default, so it is requested on every launch. You can opt in to remembering it; the key stays inside the portable directory.
- Move or back up the whole directory as a unit, but quit Navop completely first and never let two instances write to the same `data`.
### Updating a portable copy
The portable archive has no in-app updater. To upgrade, extract the new `-portable.tar.gz` into a fresh directory and copy the old `data` over:
```bash
mkdir navop-portable-new
tar -xzf navop-<version>-linux-x64-portable.tar.gz -C navop-portable-new
cp -a navop-portable/data navop-portable-new/data
```
Delete the old directory only after confirming that the new one starts and that your connections and extensions are intact.
### Advanced launch options
The official `-portable.tar.gz` already contains `navop.portable`, so everyday use needs no extra arguments. For debugging or custom deployments you can also enable portable mode or point at a data directory explicitly:
```bash
# Enable portable mode for this run; the data directory sits beside navop
./navop --portable
# Use a specific data directory; the flag also enables portable paths
./navop --data-dir /data/navop
# Enable portable mode through the environment
NAVOP_PORTABLE=1 ./navop
# Use a specific data directory through the environment
NAVOP_DATA_DIR=/data/navop ./navop
```
`NAVOP_PORTABLE` accepts `1`, `true`, `yes`, or `on`. The data directory is chosen in this order: `--data-dir`, `--portable`, `NAVOP_DATA_DIR`, then `NAVOP_PORTABLE`/`navop.portable`, and finally the regular installed layout. The chosen directory must be writable, and a relative path is resolved against the working directory Navop was started from.
## Linux graphics dependency package
The Linux `navop-<version>-linux-x64.tar.gz`, `.deb`, `.rpm`, and `.AppImage` packages contain Navop only. Desktops normally already provide the graphics stack Navop needs, and nothing extra is required. Minimal containers, stripped-down server installs, WSL, and trimmed distributions can be missing the Mesa software renderer or the EGL client libraries, which shows up as an immediate exit with this in the log:
```text
Failed to create surface: Failed to create surface for any enabled backend: {}
```
In that case download the **graphics dependency package** from the same release page (`navop-<version>-linux-x64-gpu-stack.tar.gz` or `navop-<version>-linux-arm64-gpu-stack.tar.gz`), extract it, and run the installer it carries:
```bash
mkdir navop-gpu-stack
tar -xzf navop-<version>-linux-x64-gpu-stack.tar.gz -C navop-gpu-stack
sudo navop-gpu-stack/install.sh
```
The installer is additive: it only supplies libraries the host cannot already resolve. It therefore combines with every Linux package form and needs no environment variables.
- A file whose SONAME the host already resolves is skipped, so the system copy always wins. When the host already exposes a usable EGL plus a DRI driver, the entire Mesa renderer is skipped.
- `./install.sh --dry-run` prints the plan first, and `./install.sh --force` overwrites existing files of the same name.
- Files land in the directories the dynamic loader already searches (`/usr/lib64` and friends). Debian-style distributions additionally get `/etc/ld.so.conf.d/navop-gpu-stack.conf` and a refreshed loader cache.
- `sudo ./install.sh --uninstall` removes only what this installer actually wrote, recorded in `/usr/lib/navop-gpu-stack/installed.tsv`; it never touches the host's own libraries, and any file modified since installation is kept with a warning. Because uninstall needs the extracted directory, keep it if you want that option later.
- Every bundled `.so` is built to the same glibc 2.28 baseline as the Linux Navop binary.
The dependency package is architecture specific: download the one matching your Navop package. The installer refuses to run when the architectures differ. After installing, start Navop again; no other configuration is required.
## Linux Flatpak
Navop is also available from [FlatPark](https://flatpark.org/apps/dev.navop.Navop/) as a developer-endorsed community Flatpak package. Add the FlatPark remote and install Navop for the current user:
@@ -176,7 +266,7 @@ Create a non-production test connection before importing real credentials. Insta
## Update and roll back
For the MSI, EXE installer, and standard ZIP edition, enable automatic update checks in Settings or check manually. Close active connections, commit or roll back manual transactions, and finish SFTP transfers before applying an update. After restart, verify important connections, extensions, and keyboard shortcuts. Follow the separate portable update procedure above for the Windows `-portable.zip` edition.
For the MSI, EXE installer, and standard ZIP edition, enable automatic update checks in Settings or check manually. Close active connections, commit or roll back manual transactions, and finish SFTP transfers before applying an update. After restart, verify important connections, extensions, and keyboard shortcuts. The Windows `-portable.zip` and Linux `-portable.tar.gz` editions have no in-app update; follow their separate portable update procedures above.
If a new release is incompatible with a critical extension, back up the Navop data directory and reinstall a known stable package from Releases. Downgrading is not a substitute for backup: local configuration formats may evolve, so confirm compatibility before opening older versions.
+93 -3
View File
@@ -20,8 +20,11 @@ Navop 提供 macOS、Windows 和 Linux 桌面版本。安装包、系统架构
| Windows | x86_64 | `navop-<version>-windows-x64.exe` | EXE 安装包,封装同一套当前用户 MSI 安装流程 |
| Windows | x86_64 | `navop-<version>-windows-x64.zip` | 免安装运行;数据仍保存在 Windows 用户目录 |
| Windows | x86_64 | `navop-<version>-windows-x64-portable.zip` | 数据与程序放在同一目录、可整体移动 |
| Linux | x86_64 | `navop-<version>-linux-x64.tar.gz`、`navop-<version>-linux-x64-portable.tar.gz`、`navop_<version>_amd64.deb`、`navop-<version>-1.x86_64.rpm`、`navop_<version>_amd64.AppImage` | 按发行版和桌面环境选择 |
| Linux | ARM64 | `navop-<version>-linux-arm64.tar.gz`、`navop-<version>-linux-arm64-portable.tar.gz` | ARM64 设备 |
| Linux | x86_64 | `navop-<version>-linux-x64.tar.gz`、`navop_<version>_amd64.deb`、`navop-<version>-1.x86_64.rpm`、`navop_<version>_amd64.AppImage` | 按发行版和桌面环境选择 |
| Linux | x86_64 | `navop-<version>-linux-x64-portable.tar.gz` | 数据与程序放在同一目录、可整体移动;详见下文“Linux 便携版” |
| Linux | ARM64 | `navop-<version>-linux-arm64.tar.gz` | ARM64 设备 |
| Linux | ARM64 | `navop-<version>-linux-arm64-portable.tar.gz` | 数据与程序放在同一目录、可整体移动;详见下文“Linux 便携版” |
| Linux | x86_64 / ARM64 | `navop-<version>-linux-x64-gpu-stack.tar.gz`、`navop-<version>-linux-arm64-gpu-stack.tar.gz` | 仅在系统缺少可用的 Mesa/EGL 渲染栈时补充安装,可配合上述任意 Linux 包使用 |
每个发布版本的 `sha256sums.txt` 用于校验下载完整性。企业环境应在安装前保存版本号、文件名和校验值,便于回溯。
@@ -159,6 +162,93 @@ $env:NAVOP_DATA_DIR = "E:\NavopData"
`NAVOP_PORTABLE` 支持 `1`、`true`、`yes` 或 `on`。数据目录的选择优先级为 `--data-dir`、`--portable`、`NAVOP_DATA_DIR`、`NAVOP_PORTABLE`/`navop.portable`,最后才是常规安装模式。指定的数据目录必须可写;建议使用绝对路径,因为相对路径会按启动 Navop 时的当前工作目录解析。
## Linux 便携版
`navop-<version>-linux-x64-portable.tar.gz`(ARM64 为 `navop-<version>-linux-arm64-portable.tar.gz`)与同架构的常规包**共用同一个二进制**,区别只是压缩包里多了一个 `navop.portable` 标记文件。Navop 启动时如果发现可执行文件同级存在这个文件,就会把数据目录从系统用户目录改到程序旁边的 `data` 目录,从而免安装、可整体移动地运行:
```bash
mkdir navop-portable
tar -xzf navop-<version>-linux-x64-portable.tar.gz -C navop-portable
cd navop-portable
./navop
```
```text
navop-portable/
├── navop
├── navop.portable
└── data/ ← 首次启动时自动创建
├── config/
├── state/
└── cache/
```
需要注意:
- **便携包不包含图形依赖。** 和常规包一样,图形栈仍由宿主提供;缺失时按下文“Linux 图形依赖包”补充安装即可,两者可以配合使用。
- 便携模式不会注册 `.db`、`.duckdb`、`.md` 的系统文件关联,也不支持应用内安装更新或自动更新检查;需要这些能力请改用 `.deb`、`.rpm` 或 AppImage。
- 便携目录必须可写,放到只读位置会导致启动直接失败。
- 便携模式默认不记住主密钥,每次启动都需要输入;可以在设置中开启记住,密钥同样只保存在便携目录内。
- 迁移或备份时整体移动目录即可,但要先完全退出 Navop,也不要让两个实例同时写入同一份 `data`。
### 更新便携版
便携版没有应用内更新。升级时把新版本的 `-portable.tar.gz` 解压到新目录,再把旧目录的 `data` 整体复制过去:
```bash
mkdir navop-portable-new
tar -xzf navop-<version>-linux-x64-portable.tar.gz -C navop-portable-new
cp -a navop-portable/data navop-portable-new/data
```
确认新目录可以正常启动、连接和扩展都在之后,再删除旧目录。
### 高级启动方式
官方 `-portable.tar.gz` 已经包含 `navop.portable`,常规使用不需要额外参数。调试或自定义部署时,也可以用下面的方式启用便携模式或指定数据目录:
```bash
# 临时启用便携模式,默认使用 navop 旁的 data 目录
./navop --portable
# 指定数据目录;该参数本身也会启用便携路径模式
./navop --data-dir /data/navop
# 通过环境变量启用便携模式
NAVOP_PORTABLE=1 ./navop
# 通过环境变量指定数据目录
NAVOP_DATA_DIR=/data/navop ./navop
```
`NAVOP_PORTABLE` 支持 `1`、`true`、`yes` 或 `on`。数据目录的选择优先级为 `--data-dir`、`--portable`、`NAVOP_DATA_DIR`、`NAVOP_PORTABLE`/`navop.portable`,最后才是常规安装模式。指定的数据目录必须可写;相对路径会按启动 Navop 时的当前工作目录解析。
## Linux 图形依赖包
Linux 版的 `navop-<version>-linux-x64.tar.gz`、`.deb`、`.rpm` 和 `.AppImage` 都只包含 Navop 本身。桌面环境通常已经提供 Navop 需要的图形栈,此时无需任何额外操作。但精简容器、最小化服务器安装、WSL 以及部分裁剪过的发行版可能缺少 Mesa 软件渲染器或 EGL 客户端库,表现为启动即退出,日志中出现:
```text
Failed to create surface: Failed to create surface for any enabled backend: {}
```
这种情况下再下载同一发布页上的**图形依赖包**(`navop-<version>-linux-x64-gpu-stack.tar.gz` 或 `navop-<version>-linux-arm64-gpu-stack.tar.gz`),解压后运行其中的安装脚本:
```bash
mkdir navop-gpu-stack
tar -xzf navop-<version>-linux-x64-gpu-stack.tar.gz -C navop-gpu-stack
sudo navop-gpu-stack/install.sh
```
安装脚本是**增量式**的,只补充系统当前解析不到的库,因此可以和任何一种 Linux 安装形态配合使用,也不需要设置任何环境变量:
- 系统已经能解析某个 SONAME 时,该文件会被跳过,宿主自带的版本始终优先;系统已经具备可用的 EGL 与 DRI 驱动时,整个 Mesa 渲染器都会被跳过。
- `./install.sh --dry-run` 可以先打印将要安装哪些文件,`./install.sh --force` 会覆盖已存在的同名文件。
- 依赖文件被放到动态加载器默认搜索的目录(`/usr/lib64` 等),Debian 系发行版会额外写入 `/etc/ld.so.conf.d/navop-gpu-stack.conf` 并刷新缓存。
- `sudo ./install.sh --uninstall` 只删除该脚本实际写入的文件(记录在 `/usr/lib/navop-gpu-stack/installed.tsv`),不会碰宿主原有的库;如果某个文件在安装后被修改过,卸载时会保留并给出提示。卸载需要这个解压目录,所以想保留撤销能力时就先别删除它。
- 包内所有 `.so` 都按 glibc 2.28 基线构建,与 Linux 版 Navop 二进制保持一致。
依赖包按 CPU 架构区分,必须下载与 Navop 包相同的架构;架构不匹配时安装脚本会直接报错退出。安装本身不需要 root 以外的额外配置,完成后重新启动 Navop 即可。
## Linux Flatpak
Navop 也在 [FlatPark](https://flatpark.org/apps/dev.navop.Navop/) 上架为开发者认可的社区 Flatpak 软件包。添加 FlatPark 软件源并为当前用户安装:
@@ -178,7 +268,7 @@ Flatpak 软件包运行在沙盒中,部分集成功能可能需要授予额外
## 应用内更新与回退
MSI、EXE 安装版和普通 ZIP 版可在设置的“更新”区域开启自动检查,或手动检查新版本。应用内更新会下载适合当前平台的构建;开始前关闭正在使用的连接,提交或回滚手动事务,并等待 SFTP 传输结束。更新完成后重新启动,检查连接、扩展和快捷键是否正常。Windows `-portable.zip` 便携版需要按照上面的“更新便携版”步骤手工升级。
MSI、EXE 安装版和普通 ZIP 版可在设置的“更新”区域开启自动检查,或手动检查新版本。应用内更新会下载适合当前平台的构建;开始前关闭正在使用的连接,提交或回滚手动事务,并等待 SFTP 传输结束。更新完成后重新启动,检查连接、扩展和快捷键是否正常。Windows `-portable.zip` 与 Linux `-portable.tar.gz` 便携版没有应用内更新,需要按照上面各自的“更新便携版”步骤手工升级。
若新版本与关键扩展不兼容,可先导出必要配置并从 Releases 重新安装上一稳定版。回退不会替代数据备份:本地配置格式可能随版本演进,降级前应保留 Navop 数据目录副本。不要用未知来源的旧安装包覆盖当前版本。
+93 -3
View File
@@ -18,8 +18,11 @@ Navop 提供 macOS、Windows 與 Linux 桌面版本。安裝包必須符合系
| Windows | x86_64 | `navop-<version>-windows-x64.exe` | EXE 安裝包,封裝同一套目前使用者 MSI 安裝流程 |
| Windows | x86_64 | `navop-<version>-windows-x64.zip` | 免安裝執行;資料仍儲存在 Windows 使用者目錄 |
| Windows | x86_64 | `navop-<version>-windows-x64-portable.zip` | 將程式與資料放在同一個可搬移目錄 |
| Linux | x86_64 | `navop-<version>-linux-x64.tar.gz`、`navop-<version>-linux-x64-portable.tar.gz`、`navop_<version>_amd64.deb`、`navop-<version>-1.x86_64.rpm`、`navop_<version>_amd64.AppImage` | 依發行版與桌面環境選擇 |
| Linux | ARM64 | `navop-<version>-linux-arm64.tar.gz`、`navop-<version>-linux-arm64-portable.tar.gz` | ARM64 裝置 |
| Linux | x86_64 | `navop-<version>-linux-x64.tar.gz`、`navop_<version>_amd64.deb`、`navop-<version>-1.x86_64.rpm`、`navop_<version>_amd64.AppImage` | 依發行版與桌面環境選擇 |
| Linux | x86_64 | `navop-<version>-linux-x64-portable.tar.gz` | 將程式與資料放在同一個可搬移目錄;詳見下文「Linux 可攜版」 |
| Linux | ARM64 | `navop-<version>-linux-arm64.tar.gz` | ARM64 裝置 |
| Linux | ARM64 | `navop-<version>-linux-arm64-portable.tar.gz` | 將程式與資料放在同一個可搬移目錄;詳見下文「Linux 可攜版」 |
| Linux | x86_64 / ARM64 | `navop-<version>-linux-x64-gpu-stack.tar.gz`、`navop-<version>-linux-arm64-gpu-stack.tar.gz` | 僅在系統缺少可用的 Mesa/EGL 繪圖堆疊時補充安裝,可搭配上述任一 Linux 套件使用 |
可使用同一發佈版本中的 `sha256sums.txt` 驗證下載完整性。
@@ -157,6 +160,93 @@ $env:NAVOP_DATA_DIR = "E:\NavopData"
`NAVOP_PORTABLE` 支援 `1`、`true`、`yes` 或 `on`。資料目錄的選擇優先順序為 `--data-dir`、`--portable`、`NAVOP_DATA_DIR`、`NAVOP_PORTABLE`/`navop.portable`,最後才是一般安裝模式。指定的資料目錄必須可寫入;建議使用絕對路徑,因為相對路徑會按照啟動 Navop 時的目前工作目錄解析。
## Linux 可攜版
`navop-<version>-linux-x64-portable.tar.gz`(ARM64 為 `navop-<version>-linux-arm64-portable.tar.gz`)與同架構的一般套件**共用同一個執行檔**,差別只在壓縮檔內多了一個 `navop.portable` 標記檔。Navop 啟動時若發現執行檔同層存在這個檔案,就會把資料目錄從系統使用者目錄改到程式旁的 `data` 目錄,因而能免安裝、整包搬移地執行:
```bash
mkdir navop-portable
tar -xzf navop-<version>-linux-x64-portable.tar.gz -C navop-portable
cd navop-portable
./navop
```
```text
navop-portable/
├── navop
├── navop.portable
└── data/ ← 首次啟動時自動建立
├── config/
├── state/
└── cache/
```
請注意:
- **可攜版不含圖形相依套件。** 與一般套件相同,繪圖堆疊仍由主機提供;缺少時依下文「Linux 圖形相依套件」補充安裝即可,兩者可搭配使用。
- 可攜模式不會註冊 `.db`、`.duckdb`、`.md` 的系統檔案關聯,也不支援應用程式內安裝更新或自動檢查更新;需要這些能力請改用 `.deb`、`.rpm` 或 AppImage。
- 可攜目錄必須可寫入,放在唯讀位置會導致啟動直接失敗。
- 可攜模式預設不記住主金鑰,每次啟動都需輸入;可在設定中開啟記住,金鑰同樣只保存在可攜目錄內。
- 搬移或備份時整包移動目錄即可,但要先完全結束 Navop,也不要讓兩個實例同時寫入同一份 `data`。
### 更新可攜版
可攜版沒有應用程式內更新。升級時把新版本的 `-portable.tar.gz` 解壓縮到新目錄,再把舊目錄的 `data` 整份複製過去:
```bash
mkdir navop-portable-new
tar -xzf navop-<version>-linux-x64-portable.tar.gz -C navop-portable-new
cp -a navop-portable/data navop-portable-new/data
```
確認新目錄能正常啟動、連線與擴充都在之後,再刪除舊目錄。
### 進階啟動方式
官方 `-portable.tar.gz` 已包含 `navop.portable`,一般使用不需額外參數。除錯或自訂部署時,也可以用下列方式啟用可攜模式或指定資料目錄:
```bash
# 臨時啟用可攜模式,預設使用 navop 旁的 data 目錄
./navop --portable
# 指定資料目錄;此參數本身也會啟用可攜路徑模式
./navop --data-dir /data/navop
# 透過環境變數啟用可攜模式
NAVOP_PORTABLE=1 ./navop
# 透過環境變數指定資料目錄
NAVOP_DATA_DIR=/data/navop ./navop
```
`NAVOP_PORTABLE` 支援 `1`、`true`、`yes` 或 `on`。資料目錄的選擇優先序為 `--data-dir`、`--portable`、`NAVOP_DATA_DIR`、`NAVOP_PORTABLE`/`navop.portable`,最後才是一般安裝模式。指定的資料目錄必須可寫入;相對路徑會依啟動 Navop 時的目前工作目錄解析。
## Linux 圖形相依套件
Linux 版的 `navop-<version>-linux-x64.tar.gz`、`.deb`、`.rpm` 與 `.AppImage` 都只包含 Navop 本身。桌面環境通常已提供 Navop 需要的繪圖堆疊,不需要任何額外步驟。但精簡容器、最小化伺服器安裝、WSL 以及部分裁剪過的發行版可能缺少 Mesa 軟體渲染器或 EGL 客戶端函式庫,表現為啟動即結束,日誌中出現:
```text
Failed to create surface: Failed to create surface for any enabled backend: {}
```
這種情況下再下載同一發佈頁面上的**圖形相依套件**(`navop-<version>-linux-x64-gpu-stack.tar.gz` 或 `navop-<version>-linux-arm64-gpu-stack.tar.gz`),解壓縮後執行隨附的安裝腳本:
```bash
mkdir navop-gpu-stack
tar -xzf navop-<version>-linux-x64-gpu-stack.tar.gz -C navop-gpu-stack
sudo navop-gpu-stack/install.sh
```
安裝腳本是**增量式**的,只補齊系統目前解析不到的函式庫,因此可以搭配任何一種 Linux 安裝形態,也不需要設定任何環境變數:
- 系統已能解析某個 SONAME 時,該檔案會被跳過,主機自帶的版本一律優先;系統已具備可用的 EGL 與 DRI 驅動時,整個 Mesa 渲染器都會被跳過。
- `./install.sh --dry-run` 可先印出即將安裝哪些檔案,`./install.sh --force` 會覆寫已存在的同名檔案。
- 相依檔案會放到動態載入器預設搜尋的目錄(`/usr/lib64` 等),Debian 系發行版會額外寫入 `/etc/ld.so.conf.d/navop-gpu-stack.conf` 並重新整理快取。
- `sudo ./install.sh --uninstall` 只刪除該腳本實際寫入的檔案(記錄於 `/usr/lib/navop-gpu-stack/installed.tsv`),不會碰觸主機原有的函式庫;若某個檔案在安裝後被修改過,卸載時會保留並提示。卸載需要這個解壓縮目錄,想保留回復能力時就先不要刪除它。
- 套件內所有 `.so` 都依 glibc 2.28 基線建置,與 Linux 版 Navop 執行檔一致。
相依套件依 CPU 架構區分,必須下載與 Navop 套件相同的架構;架構不符時安裝腳本會直接報錯結束。安裝完成後重新啟動 Navop 即可。
## Linux Flatpak
Navop 也在 [FlatPark](https://flatpark.org/apps/dev.navop.Navop/) 上架為開發者認可的社群 Flatpak 軟體包。新增 FlatPark 軟體源並為目前使用者安裝:
@@ -176,7 +266,7 @@ Flatpak 軟體包在沙盒中執行,部分整合功能可能需要額外權限
## 應用程式內更新與回退
MSI、EXE 安裝版與一般 ZIP 版可在設定開啟自動檢查,或手動檢查更新。更新前關閉活動連線,提交或回復手動交易並完成 SFTP 傳輸;重新啟動後測試重要連線、擴充與快捷鍵。Windows `-portable.zip` 便攜版請依照上方的獨立更新流程手動升級。
MSI、EXE 安裝版與一般 ZIP 版可在設定開啟自動檢查,或手動檢查更新。更新前關閉活動連線,提交或回復手動交易並完成 SFTP 傳輸;重新啟動後測試重要連線、擴充與快捷鍵。Windows `-portable.zip` 與 Linux `-portable.tar.gz` 可攜版沒有應用程式內更新,請依照上方各自的獨立更新流程手動升級。
若新版本與關鍵擴充不相容,先備份 Navop 資料目錄,再從 Releases 安裝上一個穩定版。降版不能取代備份,因本機設定格式可能已變更。
+439
View File
@@ -0,0 +1,439 @@
#!/bin/bash
#
# Navop Linux GPU and desktop dependency stack installer.
#
# Navop links against a small set of system libraries and reaches EGL through
# dlopen. On a host that ships neither a Mesa user space nor a DRI driver the
# window cannot be created and Navop reports "Failed to create surface". This
# archive carries the missing pieces: the Mesa software renderer (llvmpipe)
# plus the X11 / Wayland / input-method client libraries the binary needs.
#
# Everything is copied onto the paths the dynamic loader already searches, so
# every Navop package form (release tarball, .deb, .rpm, AppImage) picks the
# stack up with no extra configuration and no environment variables.
#
# The installer is additive. A library is only copied when the host does not
# already resolve that SONAME, and the whole Mesa stack is skipped when the
# host already exposes a usable EGL plus a DRI driver. Use --force to
# overwrite, --dry-run to preview, --uninstall to remove.
#
# Usage:
# sudo ./install.sh install everything the host is missing
# ./install.sh --dry-run print the plan without touching the system
# sudo ./install.sh --force overwrite libraries that already exist
# sudo ./install.sh --prefix DIR stage under DIR instead of / (testing)
# sudo ./install.sh --uninstall remove the files this installer wrote
# ./install.sh --help
set -euo pipefail
PACKAGE_NAME="navop-gpu-stack"
DRY_RUN=0
FORCE=0
UNINSTALL=0
PREFIX=""
# Mesa loads a DRI driver by file name from its compile-time search directory.
# Any driver found in these locations means the host already has a working Mesa
# and must be left alone.
HOST_DRI_DRIVER_DIRECTORIES=(
"/usr/lib/x86_64-linux-gnu/dri"
"/usr/lib/aarch64-linux-gnu/dri"
"/usr/lib64/dri"
"/usr/lib/dri"
)
# Records the loader configuration we may add, so --uninstall can take it back.
LD_CONF_FILENAME="navop-gpu-stack.conf"
LD_CONF_PATH="/etc/ld.so.conf.d/${LD_CONF_FILENAME}"
ARCH=""
TARGET=""
LIBDIR=""
DRI_DIR=""
EGL_VENDOR_DIR=""
GLIBC_BASELINE=""
SOURCE_DISTRIBUTION=""
INSTALLED_FILES=()
SKIPPED_FILES=()
REMOVED_FILES=()
KEPT_FILES=()
# Uninstall must not touch anything the archive merely could have installed.
# The manifest lists every candidate, including the entries that were skipped
# because the host already resolved them, so the set that was actually written
# is recorded here and --uninstall replays only that.
RECORD_PATH=""
INSTALL_RECORDS=()
info() { printf '%s\n' "$*"; }
warn() { printf '%s\n' "$*" >&2; }
fail() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
usage() {
sed -n '3,26p' "$0" | sed 's/^# \{0,1\}//'
}
parse_arguments() {
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1 ;;
--force) FORCE=1 ;;
--uninstall) UNINSTALL=1 ;;
--prefix)
shift
[ "$#" -gt 0 ] || fail "--prefix requires a directory"
PREFIX="$1"
;;
--prefix=*) PREFIX="${1#--prefix=}" ;;
-h|--help)
usage
exit 0
;;
*) fail "unknown argument: $1 (try --help)" ;;
esac
shift
done
if [ -n "$PREFIX" ]; then
case "$PREFIX" in
*/) PREFIX="${PREFIX%/}" ;;
esac
[ "$PREFIX" != "/" ] || PREFIX=""
fi
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "required command is not available: $1"
}
locate_ldconfig() {
if command -v ldconfig >/dev/null 2>&1; then
command -v ldconfig
return 0
fi
local candidate
for candidate in /sbin/ldconfig /usr/sbin/ldconfig /usr/bin/ldconfig; do
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
fail "ldconfig was not found; this host has no glibc dynamic loader"
}
resolve_package_root() {
local source="${BASH_SOURCE[0]}"
while [ -L "$source" ]; do
local directory
directory="$(cd -P -- "$(dirname -- "$source")" && pwd)"
source="$(readlink -- "$source")"
case "$source" in
/*) ;;
*) source="$directory/$source" ;;
esac
done
cd -P -- "$(dirname -- "$source")" && pwd
}
load_metadata() {
local root="$1"
local environment_file="$root/install.env"
local manifest_file="$root/manifest.tsv"
[ -f "$environment_file" ] || fail "missing ${environment_file}"
[ -f "$manifest_file" ] || fail "missing ${manifest_file}"
# shellcheck disable=SC1090
. "$environment_file"
ARCH="${NAVOP_GPU_STACK_ARCH:-}"
TARGET="${NAVOP_GPU_STACK_TARGET:-}"
LIBDIR="${NAVOP_GPU_STACK_LIBDIR:-}"
DRI_DIR="${NAVOP_GPU_STACK_DRI_DIR:-}"
EGL_VENDOR_DIR="${NAVOP_GPU_STACK_EGL_VENDOR_DIR:-}"
GLIBC_BASELINE="${NAVOP_GPU_STACK_GLIBC_BASELINE:-}"
SOURCE_DISTRIBUTION="${NAVOP_GPU_STACK_SOURCE_DISTRIBUTION:-}"
[ -n "$ARCH" ] || fail "install.env does not declare NAVOP_GPU_STACK_ARCH"
[ -n "$LIBDIR" ] || fail "install.env does not declare NAVOP_GPU_STACK_LIBDIR"
}
host_architecture() {
case "$(uname -m)" in
x86_64|amd64) printf 'x86_64\n' ;;
aarch64|arm64) printf 'aarch64\n' ;;
*) uname -m ;;
esac
}
verify_host_architecture() {
local host
host="$(host_architecture)"
if [ "$host" != "$ARCH" ]; then
fail "this archive targets ${ARCH} but the host reports ${host}; download the matching ${PACKAGE_NAME} archive"
fi
}
soname_is_resolved() {
local soname="$1"
"$LD_CONFIG" -p 2>/dev/null |
awk -v soname="$soname" '$1 == soname { found = 1 } END { exit found ? 0 : 1 }'
}
host_has_dri_driver() {
local directory entry
for directory in "${HOST_DRI_DRIVER_DIRECTORIES[@]}"; do
[ -d "$directory" ] || continue
for entry in "$directory"/*_dri.so; do
[ -e "$entry" ] && return 0
done
done
return 1
}
host_has_usable_gl() {
# Both halves are required: glvnd needs libEGL.so.1 to dispatch, and Mesa
# needs a DRI driver behind it. Either one alone leaves Navop unable to open
# a window, which is exactly the state this archive exists to repair.
soname_is_resolved "libEGL.so.1" || return 1
host_has_dri_driver
}
warn_about_glibc() {
[ -n "$GLIBC_BASELINE" ] || return 0
local host_version
host_version="$("$LD_CONFIG" --version 2>/dev/null | head -n 1 | awk '{print $NF}')"
[ -n "$host_version" ] || return 0
if [ "$(printf '%s\n%s\n' "$GLIBC_BASELINE" "$host_version" | sort -V | head -n 1)" != "$GLIBC_BASELINE" ]; then
warn "warning: this host ships glibc ${host_version}, older than the ${GLIBC_BASELINE} baseline the bundled stack was built against; the libraries will be installed but may refuse to load."
fi
}
sha256_of() {
sha256sum -- "$1" | awk '{print $1}'
}
install_file() {
local source="$1"
local destination="$2"
local expected_digest="$3"
if [ -e "$destination" ] && [ "$FORCE" -eq 0 ]; then
if [ "$(sha256_of "$destination")" = "$expected_digest" ]; then
SKIPPED_FILES+=("$destination (already installed)")
else
SKIPPED_FILES+=("$destination (host copy kept)")
fi
return 0
fi
if [ "$DRY_RUN" -eq 1 ]; then
INSTALLED_FILES+=("$destination (dry run)")
return 0
fi
mkdir -p -- "$(dirname -- "$destination")"
cp -- "$source" "$destination"
chmod 0644 -- "$destination"
INSTALLED_FILES+=("$destination")
local record
printf -v record '%s\t%s' "$expected_digest" "$destination"
INSTALL_RECORDS+=("$record")
}
write_install_record() {
if [ "$DRY_RUN" -eq 1 ] || [ "$#" -eq 0 ]; then
return 0
fi
mkdir -p -- "$(dirname -- "$RECORD_PATH")"
printf '%s\n' "$@" > "$RECORD_PATH"
}
remove_file() {
local destination="$1"
local expected_digest="$2"
[ -e "$destination" ] || return 0
if [ "$(sha256_of "$destination")" != "$expected_digest" ]; then
KEPT_FILES+=("$destination (modified since installation)")
return 0
fi
if [ "$DRY_RUN" -eq 0 ]; then
rm -f -- "$destination"
fi
REMOVED_FILES+=("$destination")
}
ensure_loader_configuration() {
local directory="$PREFIX$LIBDIR"
if [ "$DRY_RUN" -eq 0 ]; then
mkdir -p -- "$directory"
fi
[ -z "$PREFIX" ] || return 0
# RHEL-style hosts already list /usr/lib64 in ld.so.conf; Debian-style hosts
# do not, so the directory has to be registered explicitly.
local existing
for existing in /etc/ld.so.conf /etc/ld.so.conf.d/*.conf; do
[ -f "$existing" ] || continue
if grep -qE "^[[:space:]]*${directory}/?[[:space:]]*$" "$existing" 2>/dev/null; then
return 0
fi
done
info "Registering ${directory} in the dynamic loader configuration (${LD_CONF_PATH})"
if [ "$DRY_RUN" -eq 0 ]; then
mkdir -p -- /etc/ld.so.conf.d
printf '%s\n' "$directory" > "$LD_CONF_PATH"
INSTALLED_FILES+=("$LD_CONF_PATH")
fi
}
drop_loader_configuration() {
[ -f "$LD_CONF_PATH" ] || return 0
if [ "$DRY_RUN" -eq 0 ]; then
rm -f -- "$LD_CONF_PATH"
fi
REMOVED_FILES+=("$LD_CONF_PATH")
}
refresh_loader_cache() {
[ -z "$PREFIX" ] || return 0
[ "$DRY_RUN" -eq 0 ] || return 0
"$LD_CONFIG" >/dev/null 2>&1 ||
warn "warning: ldconfig failed; the loader cache may be stale until the next boot."
}
install_stack() {
local root="$1"
local kind relative group digest destination source
local install_gl=1
if [ "$FORCE" -eq 0 ] && host_has_usable_gl; then
install_gl=0
info "Host already provides a usable EGL and DRI driver; leaving the existing Mesa stack alone."
fi
while IFS=$'\t' read -r kind relative group digest; do
[ -n "$kind" ] || continue
[ -n "$relative" ] || fail "manifest.tsv contains an entry without a path"
if [ "$group" = "gl" ] && [ "$install_gl" -eq 0 ]; then
continue
fi
source="$root/payload/$relative"
destination="$PREFIX/$relative"
[ -f "$source" ] || fail "archive payload is incomplete: ${source} is missing"
if [ "$kind" = "library" ] && [ "$FORCE" -eq 0 ] &&
soname_is_resolved "$(basename -- "$relative")"; then
SKIPPED_FILES+=("$(basename -- "$relative") (already provided by the host)")
continue
fi
install_file "$source" "$destination" "$digest"
done < "$root/manifest.tsv"
if [ "$install_gl" -eq 1 ]; then
ensure_loader_configuration
fi
if [ "$DRY_RUN" -eq 0 ] && [ "${#INSTALL_RECORDS[@]}" -gt 0 ]; then
write_install_record "${INSTALL_RECORDS[@]}"
INSTALLED_FILES+=("$RECORD_PATH")
fi
}
uninstall_stack() {
local digest destination
[ -f "$RECORD_PATH" ] || fail \
"no installation record at ${RECORD_PATH}; nothing to replay. Remove the stack by hand if it was installed by an older version."
while IFS=$'\t' read -r digest destination; do
[ -n "$destination" ] || continue
remove_file "$destination" "$digest"
done < "$RECORD_PATH"
if [ "$DRY_RUN" -eq 0 ]; then
rm -f -- "$RECORD_PATH"
rmdir -- "$(dirname -- "$RECORD_PATH")" 2>/dev/null || true
fi
drop_loader_configuration
refresh_loader_cache
}
report() {
local title="$1"
shift
[ "$#" -gt 0 ] || return 0
info ""
info "$title"
local entry
for entry in "$@"; do
info " - $entry"
done
}
main() {
parse_arguments "$@"
local root
root="$(resolve_package_root)"
load_metadata "$root"
RECORD_PATH="$PREFIX/usr/lib/${PACKAGE_NAME}/installed.tsv"
require_command uname
require_command awk
require_command sha256sum
LD_CONFIG="$(locate_ldconfig)"
if [ -z "$PREFIX" ] && [ "$(id -u)" -ne 0 ]; then
fail "installing into /usr requires root; re-run with sudo, or use --prefix for a staging directory"
fi
verify_host_architecture
if [ "$DRY_RUN" -eq 1 ]; then
info "Dry run: no files will be written."
fi
info "${PACKAGE_NAME} for ${TARGET} (built on ${SOURCE_DISTRIBUTION})"
info "Loader directory: $PREFIX$LIBDIR"
info "DRI driver directory: $PREFIX$DRI_DIR"
if [ "$UNINSTALL" -eq 1 ]; then
uninstall_stack
report "Removed:" "${REMOVED_FILES[@]}"
report "Kept (modified since installation):" "${KEPT_FILES[@]}"
info ""
info "Uninstall complete."
return 0
fi
warn_about_glibc
install_stack "$root"
refresh_loader_cache
report "Installed:" "${INSTALLED_FILES[@]}"
report "Skipped:" "${SKIPPED_FILES[@]}"
info ""
if [ "$DRY_RUN" -eq 1 ]; then
info "Dry run complete. Re-run without --dry-run to apply."
else
info "Done. Start Navop again; no environment variables are required."
info "Remove this stack later with: $0 --uninstall"
fi
}
main "$@"
-135
View File
@@ -1,135 +0,0 @@
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#if defined(__aarch64__)
#define NAVOP_PORTABLE_LOADER "ld-linux-aarch64.so.1"
#elif defined(__x86_64__)
#define NAVOP_PORTABLE_LOADER "ld-linux-x86-64.so.2"
#else
#error unsupported architecture for the Navop portable launcher
#endif
static void fail(const char *message) {
fprintf(stderr, "navop portable launcher: %s\n", message);
exit(127);
}
static char *read_self_path(void) {
size_t capacity = 256;
for (;;) {
char *buffer = malloc(capacity);
if (buffer == NULL) {
fail("out of memory while resolving the launcher path");
}
ssize_t length = readlink("/proc/self/exe", buffer, capacity - 1);
if (length < 0) {
free(buffer);
fail("cannot resolve /proc/self/exe");
}
if ((size_t)length < capacity - 1) {
buffer[length] = '\0';
return buffer;
}
free(buffer);
capacity *= 2;
if (capacity > 1024 * 1024) {
fail("launcher path is unexpectedly long");
}
}
}
static char *join_path(const char *base, const char *suffix) {
size_t base_length = strlen(base);
size_t suffix_length = strlen(suffix);
char *path = malloc(base_length + suffix_length + 2);
if (path == NULL) {
fail("out of memory while building the runtime path");
}
memcpy(path, base, base_length);
path[base_length] = '/';
memcpy(path + base_length + 1, suffix, suffix_length + 1);
return path;
}
static void require_file(const char *path, const char *description) {
if (access(path, R_OK | X_OK) != 0) {
fprintf(
stderr,
"navop portable launcher: cannot access bundled %s at %s: %s\n",
description,
path,
strerror(errno)
);
exit(127);
}
}
int main(int argc, char **argv) {
char *self_path = read_self_path();
char *bin_separator = strrchr(self_path, '/');
if (bin_separator == NULL) {
fail("launcher path has no parent directory");
}
*bin_separator = '\0';
char *usr_separator = strrchr(self_path, '/');
if (usr_separator == NULL) {
fail("launcher is not installed below usr/bin");
}
*usr_separator = '\0';
char *runtime_root = join_path(self_path, "lib/navop");
char *library_path = join_path(runtime_root, "lib");
char *loader_path = join_path(library_path, NAVOP_PORTABLE_LOADER);
char *binary_path = join_path(runtime_root, "bin/navop.real");
char *gconv_path = join_path(library_path, "gconv");
require_file(loader_path, "dynamic loader");
require_file(binary_path, "application binary");
unsetenv("LD_AUDIT");
unsetenv("LD_LIBRARY_PATH");
unsetenv("LD_PRELOAD");
unsetenv("LD_PROFILE");
unsetenv("GLIBC_TUNABLES");
unsetenv("LOCPATH");
if (setenv("GCONV_PATH", gconv_path, 1) != 0 ||
setenv("NAVOP_PORTABLE_ROOT", runtime_root, 1) != 0) {
fail("cannot configure the bundled runtime environment");
}
char **loader_argv = calloc((size_t)argc + 5, sizeof(char *));
if (loader_argv == NULL) {
fail("out of memory while preparing application arguments");
}
size_t next = 0;
loader_argv[next++] = loader_path;
loader_argv[next++] = "--inhibit-cache";
loader_argv[next++] = "--library-path";
loader_argv[next++] = library_path;
loader_argv[next++] = binary_path;
for (int index = 1; index < argc; index++) {
loader_argv[next++] = argv[index];
}
loader_argv[next] = NULL;
execv(loader_path, loader_argv);
fprintf(
stderr,
"navop portable launcher: failed to start %s with the bundled loader: %s\n",
binary_path,
strerror(errno)
);
return 127;
}
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env bash
#
# Builds the standalone Linux GPU dependency stack *inside* a RHEL 8 generation
# container. scripts/package-linux-gpu-stack-docker.sh is the host side of this;
# it re-enters here with the repository and the output directory mounted.
#
# Why a container at all: the stack has to satisfy the glibc baseline Navop
# itself is built against (2.28), because the host dynamic loader resolves these
# libraries directly. Only a RHEL 8 generation distribution ships a Mesa user
# space at that baseline, and collecting it means driving dnf.
#
# The RPMs are downloaded with an empty --installroot on purpose. dnf skips
# dependencies that are already installed on the running system, so an
# installroot-less download silently omits the base libraries the closure needs
# (libmount, libblkid, ...). With an empty root dnf resolves the whole tree, and
# --releasever has to be stated explicitly because an empty root has no rpmdb
# to read the release version from.
#
# Nothing here executes the collected libraries; the packager only reads ELF
# headers, which is why a single container can also produce the other
# architecture with --forcearch.
set -euo pipefail
PROGRAM_NAME="navop-gpu-stack build"
DEFAULT_GLIBC_BASELINE="2.28"
# Packages that provide the sonames Navop's own DT_NEEDED closure and the Mesa
# EGL software renderer reach. The packager fails loudly when a needed soname
# has no provider, so this list is verified rather than guesswork: a missing
# entry surfaces as "missing required shared library ... download the RPM".
GPU_STACK_PACKAGES=(
mesa-dri-drivers mesa-libEGL mesa-libgbm mesa-libglapi
libglvnd-egl libglvnd
libwayland-client libwayland-server
libxkbcommon libxkbcommon-x11 libxcb
libX11 libX11-xcb libXext libXau libXdmcp
libdrm libxshmfence freetype libpng
libstdc++ elfutils-libelf systemd-libs libmount libblkid
libcap xz-libs lz4-libs libgcrypt libidn2
libselinux libsepol pcre2 libunistring
)
binary=""
target=""
output=""
source_distribution=""
glibc_baseline="$DEFAULT_GLIBC_BASELINE"
usage() {
cat <<'USAGE'
Usage: package-linux-gpu-stack-build.sh --binary PATH --target TRIPLE --output DIR [options]
Runs inside the RHEL 8 generation container created by
script/package-linux-gpu-stack-docker.sh and needs no arguments beyond the ones
that wrapper passes.
Options:
--binary PATH release Navop binary; its DT_NEEDED closure
decides which desktop client libraries ship
--target TRIPLE x86_64-unknown-linux-gnu or aarch64-unknown-linux-gnu
--output DIR directory to receive navop-gpu-stack-linux-<arch>.tar.gz
--source-distribution TEXT recorded in the manifest
--glibc-baseline VERSION maximum GLIBC symbol version allowed (default 2.28)
--help
USAGE
}
fail() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
say() {
printf '\n==> %s\n' "$*"
}
parse_arguments() {
while [ "$#" -gt 0 ]; do
case "$1" in
--binary)
shift
[ "$#" -gt 0 ] || fail "--binary requires a path"
binary="$1"
;;
--target)
shift
[ "$#" -gt 0 ] || fail "--target requires a triple"
target="$1"
;;
--output)
shift
[ "$#" -gt 0 ] || fail "--output requires a directory"
output="$1"
;;
--source-distribution)
shift
[ "$#" -gt 0 ] || fail "--source-distribution requires text"
source_distribution="$1"
;;
--glibc-baseline)
shift
[ "$#" -gt 0 ] || fail "--glibc-baseline requires a version"
glibc_baseline="$1"
;;
-h | --help)
usage
exit 0
;;
*)
fail "unknown argument: $1 (try --help)"
;;
esac
shift
done
[ -n "$binary" ] || fail "--binary is required"
[ -n "$target" ] || fail "--target is required"
[ -n "$output" ] || fail "--output is required"
[ -f "$binary" ] || fail "release binary does not exist: $binary"
}
# Maps a Rust target triple onto the RPM architecture dnf has to resolve for and
# the asset label the archive carries.
target_architecture() {
case "$target" in
x86_64-unknown-linux-gnu)
rpm_arch="x86_64"
asset_label="x64"
;;
aarch64-unknown-linux-gnu)
rpm_arch="aarch64"
asset_label="arm64"
;;
*)
fail "unsupported target: $target"
;;
esac
}
install_tooling() {
dnf install -y --setopt=install_weak_deps=False \
cpio findutils dnf-plugins-core python39 binutils rpm >/dev/null
}
kept_rpm_directory=""
# Scratch space is a global so the EXIT trap can still find it: an EXIT trap runs
# after main returns, by which point main's locals are gone.
work_directory=""
cleanup() {
if [ -n "$work_directory" ]; then
rm -rf "$work_directory"
fi
}
download_rpms() {
local destination="$1"
local empty_root="$2"
local release_version
release_version="$(rpm -E %{rhel})"
[ -n "$release_version" ] || fail "cannot determine the distribution release version"
mkdir -p "$destination" "$empty_root"
dnf download --resolve --setopt=install_weak_deps=False \
--releasever="$release_version" --nogpgcheck --forcearch="$rpm_arch" \
--installroot "$empty_root" --destdir "$destination" \
"${GPU_STACK_PACKAGES[@]}"
}
# An x86_64 repository also offers i686 multilib builds, and dnf happily resolves
# them. Their files land under /usr/lib (the 64-bit ones under /usr/lib64), but
# feeding them to the packager would only add ambiguity, so keep the target
# architecture and the arch-independent packages.
filter_rpms_to_architecture() {
local directory="$1"
local keep="$directory/$rpm_arch"
local dropped=0
local rpm package_arch
mkdir -p "$keep"
for rpm in "$directory"/*.rpm; do
[ -e "$rpm" ] || continue
package_arch="$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null || echo unknown)"
case "$package_arch" in
"$rpm_arch" | noarch)
mv -- "$rpm" "$keep/"
;;
*)
rm -f -- "$rpm"
dropped=$((dropped + 1))
;;
esac
done
kept_rpm_directory="$keep"
printf 'kept %s RPMs for %s, dropped %s foreign-architecture RPMs\n' \
"$(find "$keep" -name '*.rpm' | wc -l)" "$rpm_arch" "$dropped"
}
unpack_rpms() {
local rpm_directory="$1"
local destination="$2"
local rpm
mkdir -p "$destination"
for rpm in "$rpm_directory"/*.rpm; do
[ -e "$rpm" ] || continue
# cpio writes relative to its working directory. --no-absolute-filenames
# keeps a malformed archive from writing outside the tree; the RPM payloads
# only ever carry standard FHS paths.
(cd "$destination" && rpm2cpio "$rpm" | cpio -idm --no-absolute-filenames --quiet)
done
}
default_source_distribution() {
local pretty_name=""
if [ -r /etc/os-release ]; then
# shellcheck disable=SC1091
pretty_name="$(. /etc/os-release && printf '%s' "${PRETTY_NAME:-}")"
fi
[ -n "$pretty_name" ] || pretty_name="RHEL 8 generation distribution"
printf '%s (AppStream %s)' "$pretty_name" "$rpm_arch"
}
main() {
parse_arguments "$@"
target_architecture
if [ -z "$source_distribution" ]; then
source_distribution="$(default_source_distribution)"
fi
# dnf and rpm come from the base image; everything else is installed below.
for command in dnf rpm; do
command -v "$command" >/dev/null 2>&1 || fail "required command is missing: $command"
done
work_directory="$(mktemp -d)"
trap cleanup EXIT
local work="$work_directory"
say "Installing packaging tooling"
install_tooling
for command in rpm2cpio cpio python3; do
command -v "$command" >/dev/null 2>&1 || fail "required command is missing: $command"
done
say "Resolving the ${rpm_arch} dependency tree"
download_rpms "$work/rpms" "$work/empty-root"
filter_rpms_to_architecture "$work/rpms"
say "Unpacking RPMs into a file tree"
unpack_rpms "$kept_rpm_directory" "$work/root"
say "Collecting the dependency stack"
local repository_root
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
mkdir -p "$output"
python3 "$repository_root/script/package-linux-gpu-stack.py" \
--binary "$binary" \
--library-root "$work/root" \
--rpm-directory "$kept_rpm_directory" \
--target "$target" \
--output "$work/gpu-stack" \
--installer-source "$repository_root/script/linux-gpu-stack-install.sh" \
--glibc-baseline "$glibc_baseline" \
--source-distribution "$source_distribution"
local archive="navop-gpu-stack-linux-${asset_label}.tar.gz"
say "Archiving $archive"
tar \
--sort=name \
--mtime='UTC 1970-01-01' \
--owner=0 \
--group=0 \
--numeric-owner \
-czf "$output/$archive" \
-C "$work/gpu-stack" .
ls -l "$output/$archive"
}
main "$@"
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
#
# Host side of the Linux GPU dependency stack build. Drives
# script/package-linux-gpu-stack-build.sh inside a RHEL 8 generation container,
# because the stack has to match the glibc baseline Navop is built against and
# only that distribution generation ships a Mesa user space at it.
#
# Docker is used for its package manager, not for cross-architecture emulation:
# the container runs natively on the host runner while the RPMs it resolves
# target --target's architecture. Nothing in the pipeline executes a collected
# library, so no emulator is required.
set -euo pipefail
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
default_image="rockylinux:8"
binary=""
target=""
output=""
image="$default_image"
source_distribution=""
usage() {
cat <<'USAGE'
Usage: package-linux-gpu-stack-docker.sh --binary PATH --target TRIPLE --output DIR [options]
Options:
--binary PATH release Navop binary for the target
--target TRIPLE x86_64-unknown-linux-gnu or aarch64-unknown-linux-gnu
--output DIR directory to receive navop-gpu-stack-linux-<arch>.tar.gz
--image NAME build container (default rockylinux:8)
--source-distribution TEXT overrides the distribution string in the manifest
--help
USAGE
}
fail() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
parse_arguments() {
while [ "$#" -gt 0 ]; do
case "$1" in
--binary)
shift
[ "$#" -gt 0 ] || fail "--binary requires a path"
binary="$1"
;;
--target)
shift
[ "$#" -gt 0 ] || fail "--target requires a triple"
target="$1"
;;
--output)
shift
[ "$#" -gt 0 ] || fail "--output requires a directory"
output="$1"
;;
--image)
shift
[ "$#" -gt 0 ] || fail "--image requires a name"
image="$1"
;;
--source-distribution)
shift
[ "$#" -gt 0 ] || fail "--source-distribution requires text"
source_distribution="$1"
;;
-h | --help)
usage
exit 0
;;
*)
fail "unknown argument: $1 (try --help)"
;;
esac
shift
done
[ -n "$binary" ] || fail "--binary is required"
[ -n "$target" ] || fail "--target is required"
[ -n "$output" ] || fail "--output is required"
}
main() {
parse_arguments "$@"
command -v docker >/dev/null 2>&1 ||
fail "docker is required to resolve the RHEL 8 dependency tree"
[ -f "$binary" ] || fail "release binary does not exist: $binary"
binary="$(cd -- "$(dirname -- "$binary")" && pwd)/$(basename -- "$binary")"
mkdir -p "$output"
output="$(cd -- "$output" && pwd)"
# The container only ever sees three mounts: the repository (read only), the
# binary's directory, and the output directory.
local binary_directory
binary_directory="$(dirname -- "$binary")"
local container_arguments=(
--binary "/binary/$(basename -- "$binary")"
--target "$target"
--output /out
)
if [ -n "$source_distribution" ]; then
container_arguments+=(--source-distribution "$source_distribution")
fi
docker run --rm \
-v "$repository_root:/workspace:ro" \
-v "$binary_directory:/binary:ro" \
-v "$output:/out" \
--workdir /workspace \
"$image" \
bash /workspace/script/package-linux-gpu-stack-build.sh "${container_arguments[@]}"
# Resolve the newest archive with portable test operators only: the GNU-only
# `find` time predicates are unavailable on a BSD userland.
local archive="" candidate
for candidate in "$output"/navop-gpu-stack-linux-*.tar.gz; do
[ -f "$candidate" ] || continue
if [ -z "$archive" ] || [ "$candidate" -nt "$archive" ]; then
archive="$candidate"
fi
done
[ -n "$archive" ] || fail "the build container produced no dependency archive in $output"
printf 'Dependency stack written: %s\n' "$archive"
}
main "$@"
+846
View File
@@ -0,0 +1,846 @@
#!/usr/bin/env python3
"""Package the standalone Linux GPU and desktop dependency stack for Navop.
The archive is assembled from unpacked distribution RPMs rather than from the
running system. That keeps packaging architecture independent: a single
x86_64 CI runner can produce both the x86_64 and the aarch64 archive, because
nothing in here executes the collected libraries, it only reads their ELF
headers.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import fnmatch
import glob
import hashlib
import json
import os
from pathlib import Path
import re
import shlex
import shutil
import subprocess
import sys
from typing import Iterable, NoReturn
# Navop reaches EGL through dlopen, so nothing in navop's own DT_NEEDED closes
# over the graphics stack. These entry points are collected explicitly; the
# recursive expansion then pulls in the rest of Mesa (the swrast driver NEEDs
# libLLVM, for example).
GPU_STACK_ENTRY_LIBRARIES = (
"libEGL.so.1",
"libEGL_mesa.so.0",
)
# Mesa loads its DRI driver by file name from a compile-time search directory,
# which is why the driver is resolved by directory scan instead of by SONAME.
GPU_STACK_DRI_DRIVERS = (
"swrast_dri.so",
"kms_swrast_dri.so",
)
# GLVND reads this to discover the Mesa vendor library installed alongside it.
GPU_STACK_EGL_VENDOR_CONFIGURATION = "/usr/share/glvnd/egl_vendor.d/50_mesa.json"
# Navop runs on the host dynamic loader, so the C runtime must come from the
# host. Bundling these would shadow the system glibc with a foreign copy, which
# is precisely what this archive must never do.
HOST_PROVIDED_LIBRARIES = (
"ld-linux-aarch64.so.1",
"ld-linux-x86-64.so.2",
"libc.so.6",
"libdl.so.2",
"libm.so.6",
"libpthread.so.0",
"libresolv.so.2",
"librt.so.1",
)
HOST_PROVIDED_PATTERNS = (
"libnss_*.so.2",
"linux-vdso.so.1",
)
# GTK, WebKitGTK and the GLib family integrate with the running desktop: themes,
# the accessibility bus, dconf, input methods, font configuration. Shipping one
# distribution's copy of that stack onto another distribution's desktop is not
# supportable, and it would dwarf the renderer this archive exists to deliver.
# Navop's release binary links the embedded webview, so these names are reached
# on every walk; they are recorded as host requirements and belong in the
# package's declared dependencies instead.
HOST_DESKTOP_PATTERNS = (
"libwebkit2gtk-4*.so.*",
"libjavascriptcoregtk-4*.so.*",
"libgtk-3.so.*",
"libgtk-4.so.*",
"libgdk-3.so.*",
"libgdk-4.so.*",
"libsoup-2.4.so.*",
"libsoup-3.0.so.*",
"libglib-2.0.so.*",
"libgobject-2.0.so.*",
"libgio-2.0.so.*",
"libgmodule-2.0.so.*",
"libgthread-2.0.so.*",
"libpango-1.0.so.*",
"libpangocairo-1.0.so.*",
"libcairo.so.*",
"libcairo-gobject.so.*",
"libharfbuzz.so.*",
"libatk-1.0.so.*",
"libatk-bridge-2.0.so.*",
"libgdk_pixbuf-2.0.so.*",
"libepoxy.so.*",
)
@dataclass(frozen=True)
class TargetConfig:
machine: str
architecture_label: str
libdir: str
dri_dir: str
library_directories: tuple[str, ...]
dri_driver_directories: tuple[str, ...]
# The archive is built from a RHEL 8 generation distribution, where both
# architectures use /usr/lib64. That path is what Mesa's compile-time DRI
# directory points at, so the payload keeps it verbatim; the installer
# registers the directory with the loader on hosts that do not search it.
TARGET_CONFIGS = {
"aarch64-unknown-linux-gnu": TargetConfig(
machine="AArch64",
architecture_label="aarch64",
libdir="/usr/lib64",
dri_dir="/usr/lib64/dri",
library_directories=("/usr/lib64", "/lib64", "/usr/lib", "/usr/local/lib"),
dri_driver_directories=(
"/usr/lib64/dri",
"/usr/lib/dri",
"/usr/lib/aarch64-linux-gnu/dri",
),
),
"x86_64-unknown-linux-gnu": TargetConfig(
machine="Advanced Micro Devices X86-64",
architecture_label="x86_64",
libdir="/usr/lib64",
dri_dir="/usr/lib64/dri",
library_directories=("/usr/lib64", "/lib64", "/usr/lib", "/usr/local/lib"),
dri_driver_directories=(
"/usr/lib64/dri",
"/usr/lib/dri",
"/usr/lib/x86_64-linux-gnu/dri",
),
),
}
GL_GROUP = "gl"
CLIENT_GROUP = "client"
@dataclass
class CollectedLibrary:
soname: str
source: Path
group: str
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Collect the Mesa software renderer and the desktop client "
"libraries Navop needs on a host without a usable GPU driver, and "
"lay them out as an installable dependency archive."
)
)
parser.add_argument(
"--binary",
required=True,
type=Path,
help=(
"release Navop binary; its DT_NEEDED closure determines the "
"desktop client libraries that must be shipped"
),
)
parser.add_argument(
"--library-root",
required=True,
type=Path,
help="root of the unpacked distribution file tree to collect from",
)
parser.add_argument(
"--rpm-directory",
required=True,
type=Path,
help="directory holding the downloaded RPMs, used for package metadata",
)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument(
"--installer-source",
type=Path,
default=Path("script/linux-gpu-stack-install.sh"),
help="installer script copied to the archive root as install.sh",
)
parser.add_argument(
"--target",
default="aarch64-unknown-linux-gnu",
choices=tuple(TARGET_CONFIGS),
)
parser.add_argument(
"--glibc-baseline",
default="2.28",
help=(
"maximum GLIBC symbol version allowed in any bundled library; the "
"archive must stay loadable on the oldest host Navop itself runs on"
),
)
parser.add_argument(
"--source-distribution",
default="",
help="human readable build host description recorded in the manifest",
)
return parser.parse_args()
def fail(message: str) -> NoReturn:
raise SystemExit(f"Error: {message}")
def run(command: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
check=check,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
def require_command(command: str) -> None:
if shutil.which(command) is None:
fail(f"required command is not installed: {command}")
def readelf(path: Path, *arguments: str) -> str:
result = run(["readelf", *arguments, str(path)], check=False)
if result.returncode != 0:
fail(f"readelf failed for {path}: {result.stderr.strip()}")
return result.stdout
def elf_machine(path: Path) -> str:
header = readelf(path, "-hW")
match = re.search(r"^\s*Machine:\s*(.+?)\s*$", header, re.MULTILINE)
if match is None:
fail(f"cannot determine ELF machine for {path}")
return match.group(1)
def version_tuple(version: str) -> tuple[int, ...]:
return tuple(int(component) for component in version.split("."))
def highest_glibc_version(path: Path) -> str | None:
versions = re.findall(
r"\bGLIBC_(\d+(?:\.\d+)+)\b",
readelf(path, "--version-info", "-W"),
)
if not versions:
return None
return max(versions, key=version_tuple)
def verify_glibc_baseline(path: Path, maximum: str) -> str | None:
highest = highest_glibc_version(path)
if highest is None:
return None
if version_tuple(highest) > version_tuple(maximum):
fail(
f"{path} requires GLIBC_{highest}, above the supported "
f"GLIBC_{maximum} archive baseline; the dependency stack must come "
"from a distribution at or below that baseline"
)
return highest
def dynamic_metadata(path: Path) -> list[str]:
return re.findall(
r"\(NEEDED\).*?Shared library:\s*\[([^\]]+)\]",
readelf(path, "-dW"),
)
def is_host_provided(soname: str) -> bool:
if soname in HOST_PROVIDED_LIBRARIES:
return True
return any(fnmatch.fnmatch(soname, pattern) for pattern in HOST_PROVIDED_PATTERNS)
def is_host_desktop_library(soname: str) -> bool:
return any(
fnmatch.fnmatch(soname, pattern) for pattern in HOST_DESKTOP_PATTERNS
)
def inside(root: Path, path: Path) -> bool:
try:
path.relative_to(root)
except ValueError:
return False
return True
class LibraryIndex:
"""Indexes every shared object below an unpacked distribution tree.
A fixed list of search directories is not sufficient. RHEL 8 keeps its
compat LLVM runtime in /usr/lib64/llvm17/lib, a directory no dynamic loader
configuration searches, yet Mesa's single gallium driver links against the
libLLVM-17.so it holds. Indexing the tree by file name keeps the packager
independent of wherever a distribution decides to park a library, while the
configured directories still decide which copy wins when a name exists in
more than one place.
"""
def __init__(self, root: Path) -> None:
self.root = root
self._by_name: dict[str, list[Path]] = {}
for path in root.rglob("*"):
name = path.name
if ".so" not in name or not path.is_file():
continue
resolved = path.resolve()
if not inside(root, resolved) or not resolved.is_file():
continue
# Keep the path as packaged rather than its target: RPM file lists
# name the symlink (/usr/lib64/libgcc_s.so.1), so ownership lookup
# and the archive layout both have to use the same spelling.
self._by_name.setdefault(name, []).append(path)
def candidates(self, name: str, directories: Iterable[str]) -> list[Path]:
found = self._by_name.get(name)
if not found:
return []
wanted = list(directories)
preferred: list[Path] = []
remaining: list[Path] = []
for path in found:
parent = "/" + path.relative_to(self.root).parent.as_posix()
if parent in wanted:
preferred.append(path)
else:
remaining.append(path)
preferred.sort(
key=lambda path: wanted.index(
"/" + path.relative_to(self.root).parent.as_posix()
)
)
remaining.sort(
key=lambda path: (len(path.relative_to(self.root).parts), str(path))
)
return preferred + remaining
def resolve_library(
soname: str,
*,
index: LibraryIndex,
directories: Iterable[str],
machine: str,
) -> Path | None:
for candidate in index.candidates(soname, directories):
if elf_machine(candidate) == machine:
return candidate
return None
def resolve_dri_driver(
name: str,
*,
index: LibraryIndex,
directories: Iterable[str],
) -> Path | None:
# Driver directories hold one real module plus per-driver symlinks, so the
# index resolves each name through to the file it points at.
for candidate in index.candidates(name, directories):
return candidate
return None
# RHEL 8 is a merged-/usr distribution: /lib64 is a symlink to /usr/lib64, and
# the RPM file lists still name the pre-merge path. libgcc is the visible case,
# declaring /lib64/libgcc_s.so.1 while the extracted tree stores
# /usr/lib64/libgcc_s.so.1. Ownership lookup has to accept both spellings.
USR_MERGE_PREFIXES = (
("/lib64/", "/usr/lib64/"),
("/lib/", "/usr/lib/"),
)
def path_spellings(path: str) -> list[str]:
variants = {path}
for merged, canonical in USR_MERGE_PREFIXES:
if path.startswith(merged):
variants.add(canonical + path[len(merged):])
elif path.startswith(canonical):
variants.add(merged + path[len(canonical):])
return sorted(variants)
class RpmIndex:
"""Maps unpacked file paths back to the RPM that owns them."""
def __init__(self, directory: Path) -> None:
self.owners: dict[str, str] = {}
self.archives: dict[str, Path] = {}
for rpm in sorted(directory.glob("*.rpm")):
name = self.field(rpm, "%{NAME}")
if not name:
continue
self.archives[name] = rpm
for path in self.paths(rpm):
for spelling in path_spellings(path):
self.owners.setdefault(spelling, name)
@staticmethod
def field(rpm: Path, query_format: str) -> str | None:
result = run(["rpm", "-qp", "--qf", query_format, str(rpm)], check=False)
if result.returncode != 0:
return None
value = result.stdout.strip()
return value or None
@staticmethod
def paths(rpm: Path) -> list[str]:
result = run(["rpm", "-qlp", str(rpm)], check=False)
if result.returncode != 0:
return []
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
def package_of(self, library_root: Path, path: Path) -> str | None:
relative = path.relative_to(library_root)
for spelling in path_spellings(f"/{relative.as_posix()}"):
owner = self.owners.get(spelling)
if owner is not None:
return owner
return None
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def collect_closure(
roots: list[tuple[Path, str]],
*,
library_root: Path,
index: LibraryIndex,
target: TargetConfig,
host_requirements: dict[str, str] | None = None,
) -> dict[str, CollectedLibrary]:
"""Expand DT_NEEDED from every root, remembering which group reached it.
Client roots are processed first so a library needed by both Navop and
Mesa is classified as a client library. That matters because client
libraries are always reconciled against the host, while the graphics group
is skipped wholesale when the host already renders.
"""
collected: dict[str, CollectedLibrary] = {}
scanned: set[Path] = set()
queue: list[tuple[Path, str]] = list(roots)
while queue:
consumer, group = queue.pop(0)
resolved_consumer = consumer.resolve()
if resolved_consumer in scanned:
continue
scanned.add(resolved_consumer)
if elf_machine(resolved_consumer) != target.machine:
fail(f"architecture mismatch in dependency closure: {resolved_consumer}")
for soname in dynamic_metadata(resolved_consumer):
if is_host_provided(soname) or soname in collected:
continue
if is_host_desktop_library(soname):
if host_requirements is not None:
host_requirements.setdefault(soname, str(resolved_consumer))
continue
source = resolve_library(
soname,
index=index,
directories=target.library_directories,
machine=target.machine,
)
if source is None:
fail(
f"missing required shared library {soname} for "
f"{resolved_consumer}; download the RPM that provides it"
)
collected[soname] = CollectedLibrary(
soname=soname,
source=source,
group=group,
)
queue.append((source, group))
return collected
# Distributions disagree about where a package keeps its license text. RHEL 8
# uses /usr/share/licenses/<name> for some packages and /usr/share/doc/<name>
# for others, and either may carry a version suffix.
LICENSE_DIRECTORY_CANDIDATES = ("usr/share/licenses", "usr/share/doc")
def license_files_for(library_root: Path, package: str) -> list[Path]:
found: dict[str, Path] = {}
for relative in LICENSE_DIRECTORY_CANDIDATES:
base = library_root / relative
if not base.is_dir():
continue
for directory in sorted(base.glob(f"{glob.escape(package)}*")):
if not directory.is_dir():
continue
for path in sorted(directory.rglob("*")):
if path.is_file() and path.name not in found:
found[path.name] = path
return [found[name] for name in sorted(found)]
def package_records(
packaged: dict[str, Path],
*,
library_root: Path,
index: RpmIndex,
license_directory: Path,
) -> list[dict[str, object]]:
by_package: dict[str, set[str]] = {}
for relative, source in packaged.items():
owner = index.package_of(library_root, source)
if owner is None:
fail(
"cannot publish a bundled library without RPM ownership "
f"metadata: {source}"
)
by_package.setdefault(owner, set()).add(relative)
license_directory.mkdir(parents=True, exist_ok=True)
records: list[dict[str, object]] = []
for package in sorted(by_package):
declared = index.archives.get(package)
license_files = license_files_for(library_root, package)
if license_files:
destination = license_directory / package
destination.mkdir(parents=True, exist_ok=True)
for source in license_files:
shutil.copy2(source, destination / source.name)
else:
print(
f"warning: {package} ships no license files under "
"/usr/share/licenses or /usr/share/doc; recording the "
"declared license only",
file=sys.stderr,
)
records.append(
{
"package": package,
"version": (
RpmIndex.field(declared, "%{VERSION}-%{RELEASE}")
if declared
else "unknown"
),
"license": (
RpmIndex.field(declared, "%{LICENSE}") if declared else "unknown"
),
"license_files": sorted(p.name for p in license_files),
"files": sorted(by_package[package]),
}
)
return records
def manifest_files(output: Path, excluded: set[Path]) -> list[dict[str, object]]:
records: list[dict[str, object]] = []
for path in sorted(output.rglob("*")):
if not path.is_file() or path in excluded:
continue
records.append(
{
"path": path.relative_to(output).as_posix(),
"size": path.stat().st_size,
"sha256": sha256(path),
}
)
return records
def main() -> None:
args = parse_args()
target = TARGET_CONFIGS[args.target]
for command in ("readelf", "rpm"):
require_command(command)
library_root = args.library_root.resolve()
if not library_root.is_dir():
fail(f"library root does not exist: {library_root}")
rpm_directory = args.rpm_directory.resolve()
if not rpm_directory.is_dir():
fail(f"RPM directory does not exist: {rpm_directory}")
binary = args.binary.resolve()
if not binary.is_file():
fail(f"release binary does not exist: {binary}")
if elf_machine(binary) != target.machine:
fail(
f"dependency archive for {args.target} expects {target.machine}, "
f"got {elf_machine(binary)}"
)
verify_glibc_baseline(binary, args.glibc_baseline)
output = args.output.resolve()
repository_root = Path(__file__).resolve().parent.parent
if output in (Path("/"), repository_root):
fail(f"refusing to replace unsafe output directory: {output}")
installer_source = args.installer_source
if not installer_source.is_absolute():
installer_source = repository_root / installer_source
if not installer_source.is_file():
fail(f"installer script does not exist: {installer_source}")
index = RpmIndex(rpm_directory)
library_index = LibraryIndex(library_root)
host_requirements: dict[str, str] = {}
# Client libraries first: they are always reconciled against the host.
libraries = collect_closure(
[(binary, CLIENT_GROUP)],
library_root=library_root,
index=library_index,
target=target,
host_requirements=host_requirements,
)
graphics_roots: list[tuple[Path, str]] = []
graphics_entries: dict[str, Path] = {}
for soname in GPU_STACK_ENTRY_LIBRARIES:
if soname in libraries:
continue
source = resolve_library(
soname,
index=library_index,
directories=target.library_directories,
machine=target.machine,
)
if source is None:
fail(
f"missing {soname} required by the graphics stack; download "
"the RPM that provides the Mesa EGL runtime"
)
graphics_entries[soname] = source
graphics_roots.append((source, GL_GROUP))
drivers: dict[str, Path] = {}
for name in GPU_STACK_DRI_DRIVERS:
source = resolve_dri_driver(
name,
index=library_index,
directories=target.dri_driver_directories,
)
if source is None:
if name == GPU_STACK_DRI_DRIVERS[0]:
fail(
f"missing {name} required by the software renderer; "
"download the RPM that provides the Mesa DRI drivers"
)
continue
drivers[name] = source
graphics_roots.append((source, GL_GROUP))
for soname, entry in collect_closure(
graphics_roots,
library_root=library_root,
index=library_index,
target=target,
host_requirements=host_requirements,
).items():
libraries.setdefault(soname, entry)
# The entry points are payload in their own right. Navop reaches EGL through
# dlopen, so nothing in the binary's own DT_NEEDED closure pulls them in;
# the walk above therefore only ever visits them as roots and would never
# ship them.
for soname, source in graphics_entries.items():
libraries.setdefault(
soname,
CollectedLibrary(soname=soname, source=source, group=GL_GROUP),
)
vendor_source = library_root / GPU_STACK_EGL_VENDOR_CONFIGURATION.lstrip("/")
if not vendor_source.is_file():
fail(
"missing EGL vendor configuration for the software renderer: "
f"{GPU_STACK_EGL_VENDOR_CONFIGURATION}"
)
payload = output / "payload"
packaged: dict[str, Path] = {}
manifest_rows: list[tuple[str, str, str, str]] = []
stored_by_digest: dict[str, str] = {}
def stage(relative: str, source: Path, kind: str, group: str) -> None:
destination = payload / relative
destination.parent.mkdir(parents=True, exist_ok=True)
digest = sha256(source)
owner = stored_by_digest.get(digest)
if owner is not None:
# Distributions hard-link one real module under several names: RHEL
# 8 exposes six DRI drivers that are all the same 19 MiB inode.
# Filing the duplicate as a relative symlink keeps the archive from
# carrying that payload twice, and the installer dereferences it.
destination.symlink_to(os.path.relpath(payload / owner, destination.parent))
else:
shutil.copy2(source, destination)
destination.chmod(0o644)
stored_by_digest[digest] = relative
packaged[relative] = source
manifest_rows.append((kind, relative, group, digest))
for soname, entry in sorted(libraries.items()):
stage(f"{target.libdir.lstrip('/')}/{soname}", entry.source, "library", entry.group)
for name, source in sorted(drivers.items()):
stage(f"{target.dri_dir.lstrip('/')}/{name}", source, "driver", GL_GROUP)
vendor_relative = GPU_STACK_EGL_VENDOR_CONFIGURATION.lstrip("/")
stage(vendor_relative, vendor_source, "vendor", GL_GROUP)
# Every bundled shared object must stay inside the glibc baseline, because
# the host loader resolves them directly now.
baselines: dict[str, str] = {}
for relative, source in sorted(packaged.items()):
if relative == vendor_relative:
continue
highest = verify_glibc_baseline(source, args.glibc_baseline)
if highest is not None:
baselines[relative] = highest
shutil.copy2(installer_source, output / "install.sh")
(output / "install.sh").chmod(0o755)
distribution = args.source_distribution or "RHEL 8 generation distribution"
# install.sh sources this file, so values are shell-quoted: the source
# distribution string carries spaces and parentheses.
(output / "install.env").write_text(
"".join(
f"{key}={shlex.quote(value)}\n"
for key, value in (
("NAVOP_GPU_STACK_ARCH", target.architecture_label),
("NAVOP_GPU_STACK_TARGET", args.target),
("NAVOP_GPU_STACK_LIBDIR", target.libdir),
("NAVOP_GPU_STACK_DRI_DIR", target.dri_dir),
(
"NAVOP_GPU_STACK_EGL_VENDOR_DIR",
str(Path(GPU_STACK_EGL_VENDOR_CONFIGURATION).parent),
),
("NAVOP_GPU_STACK_GLIBC_BASELINE", args.glibc_baseline),
("NAVOP_GPU_STACK_SOURCE_DISTRIBUTION", distribution),
)
),
encoding="utf-8",
)
(output / "manifest.tsv").write_text(
"".join(
f"{kind}\t{relative}\t{group}\t{digest}\n"
for kind, relative, group, digest in manifest_rows
),
encoding="utf-8",
)
records = package_records(
packaged,
library_root=library_root,
index=index,
license_directory=output / "licenses",
)
(output / "runtime-packages.txt").write_text(
"".join(
f"{record['package']}\t{record['version']}\t{','.join(record['files'])}\n"
for record in records
),
encoding="utf-8",
)
manifest_path = output / "manifest.json"
graphics_libraries = sorted(
soname for soname, entry in libraries.items() if entry.group == GL_GROUP
)
client_libraries = sorted(
soname for soname, entry in libraries.items() if entry.group == CLIENT_GROUP
)
manifest = {
"schema_version": 1,
"product": "navop",
"component": "linux-gpu-stack",
"target": args.target,
"architecture": target.architecture_label,
"source_distribution": distribution,
"binary_glibc_baseline": args.glibc_baseline,
"libdir": target.libdir,
"dri_dir": target.dri_dir,
"egl_vendor_dir": str(Path(GPU_STACK_EGL_VENDOR_CONFIGURATION).parent),
"graphics_libraries": graphics_libraries,
"client_libraries": client_libraries,
"host_required_libraries": sorted(host_requirements),
"dri_drivers": sorted(drivers),
"egl_vendor_configurations": [Path(GPU_STACK_EGL_VENDOR_CONFIGURATION).name],
"glibc_requirements": baselines,
"packages": records,
"host_interfaces": [
"Linux kernel and procfs",
"X11 or Wayland display sockets",
"GPU devices and host vendor drivers",
"system fonts and font configuration",
"the distribution's GTK and WebKitGTK stack, which the release "
"binary links for the embedded webview",
],
"files": manifest_files(output, {manifest_path}),
}
manifest_path.write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
payload_size = sum(
path.stat().st_size for path in payload.rglob("*") if path.is_file()
)
print(
f"Packaged {len(libraries)} libraries ({len(graphics_libraries)} graphics, "
f"{len(client_libraries)} client) and {len(drivers)} DRI drivers into {output}"
)
if host_requirements:
print(
f"Left to the host ({len(host_requirements)} desktop libraries): "
+ ", ".join(sorted(host_requirements))
)
print(f"Uncompressed payload: {payload_size / (1024 * 1024):.1f} MiB")
print(f"Built on: {distribution} (GLIBC_{args.glibc_baseline} baseline)")
if __name__ == "__main__":
main()
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(
cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
pwd
)"
# The packager needs dataclasses and PEP 585 annotations. RHEL 8 images still
# default to Python 3.6, so pick the newest interpreter that qualifies instead
# of trusting the bare "python3" name.
python=""
for candidate in python3.13 python3.12 python3.11 python3.10 python3.9 python3; do
command -v "$candidate" >/dev/null 2>&1 || continue
if "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)'; then
python="$candidate"
break
fi
done
if [ -z "$python" ]; then
echo "Error: python 3.9 or newer is required to package the GPU stack" >&2
exit 1
fi
exec "$python" "$script_dir/package-linux-gpu-stack.py" "$@"
-763
View File
@@ -1,763 +0,0 @@
#!/usr/bin/env python3
"""Build a relocatable Linux package around a private glibc runtime."""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import fnmatch
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
from typing import Iterable, NoReturn
OPTIONAL_RUNTIME_LIBRARIES = (
"libnss_dns.so.2",
"libnss_files.so.2",
"libresolv.so.2",
)
REQUIRED_DLOPEN_RUNTIME_LIBRARIES = (
"libwayland-client.so.0",
"libwayland-cursor.so.0",
"libwayland-egl.so.1",
)
OPTIONAL_RUNTIME_PATTERNS = ("libnss_*.so.2",)
HOST_DRIVER_PATTERNS = (
"libcuda.so*",
"libnvidia-*.so*",
"libamdhip64.so*",
"libhsa-runtime64.so*",
"libroc*.so*",
"libigc.so*",
"libze_*.so*",
"libvulkan_*.so*",
)
USR_MERGE_PATH_ALIASES = (
("/bin", "/usr/bin"),
("/sbin", "/usr/sbin"),
("/lib", "/usr/lib"),
("/lib64", "/usr/lib64"),
)
COMMON_LIBRARY_DIRECTORIES = (
"/lib64",
"/lib",
"/usr/lib64",
"/usr/lib",
"/usr/local/lib",
)
@dataclass(frozen=True)
class TargetConfig:
machine: str
loader: str
platform_token: str
lib_token: str
library_directories: tuple[str, ...]
TARGET_CONFIGS = {
"aarch64-unknown-linux-gnu": TargetConfig(
machine="AArch64",
loader="ld-linux-aarch64.so.1",
platform_token="aarch64",
lib_token="lib",
library_directories=(
"/lib/aarch64-linux-gnu",
"/usr/lib/aarch64-linux-gnu",
*COMMON_LIBRARY_DIRECTORIES,
),
),
"x86_64-unknown-linux-gnu": TargetConfig(
machine="Advanced Micro Devices X86-64",
loader="ld-linux-x86-64.so.2",
platform_token="x86_64",
lib_token="lib64",
library_directories=(
"/lib/x86_64-linux-gnu",
"/usr/lib/x86_64-linux-gnu",
*COMMON_LIBRARY_DIRECTORIES,
),
),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Bundle a Linux ELF with the runner's dynamic loader and "
"recursive shared-library closure."
)
)
parser.add_argument("--binary", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--launcher-source", required=True, type=Path)
parser.add_argument(
"--target",
default="aarch64-unknown-linux-gnu",
choices=tuple(TARGET_CONFIGS),
help="portable launcher target",
)
parser.add_argument(
"--glibc-baseline",
default="2.28",
help=(
"maximum GLIBC symbol version allowed in navop.real; the bundled "
"private runtime itself may be newer"
),
)
return parser.parse_args()
def fail(message: str) -> NoReturn:
raise SystemExit(f"Error: {message}")
def run(
command: list[str],
*,
check: bool = True,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
check=check,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
def require_command(command: str) -> None:
if shutil.which(command) is None:
fail(f"required command is not installed: {command}")
def readelf(path: Path, *arguments: str) -> str:
result = run(["readelf", *arguments, str(path)], check=False)
if result.returncode != 0:
fail(f"readelf failed for {path}: {result.stderr.strip()}")
return result.stdout
def elf_machine(path: Path) -> str:
header = readelf(path, "-hW")
match = re.search(r"^\s*Machine:\s*(.+?)\s*$", header, re.MULTILINE)
if match is None:
fail(f"cannot determine ELF machine for {path}")
return match.group(1)
def elf_interpreter(path: Path) -> Path:
program_headers = readelf(path, "-lW")
matches = re.findall(
r"Requesting program interpreter:\s*([^\]]+)",
program_headers,
)
if len(matches) != 1:
fail(f"expected one PT_INTERP entry in {path}, found {len(matches)}")
return Path(matches[0])
def version_tuple(version: str) -> tuple[int, ...]:
if re.fullmatch(r"\d+(?:\.\d+)+", version) is None:
fail(f"invalid GLIBC baseline: {version}")
return tuple(int(component) for component in version.split("."))
def verify_binary_glibc_baseline(path: Path, maximum: str) -> None:
maximum_version = version_tuple(maximum)
version_info = readelf(path, "--version-info", "-W")
required_versions = {
match
for match in re.findall(r"\bGLIBC_(\d+(?:\.\d+)+)\b", version_info)
}
if not required_versions:
fail(f"readelf did not report any GLIBC versions for {path}")
highest = max(required_versions, key=version_tuple)
if version_tuple(highest) > maximum_version:
fail(
f"{path} requires GLIBC_{highest}, above the supported "
f"GLIBC_{maximum} binary baseline"
)
def dynamic_metadata(
path: Path,
target: TargetConfig,
) -> tuple[list[str], list[Path]]:
dynamic = readelf(path, "-dW")
needed = re.findall(r"\(NEEDED\).*?Shared library:\s*\[([^\]]+)\]", dynamic)
search_paths: list[Path] = []
origin = path.resolve().parent
for raw in re.findall(
r"\((?:RPATH|RUNPATH)\).*?Library (?:rpath|runpath):\s*\[([^\]]*)\]",
dynamic,
):
for item in raw.split(":"):
expanded = item
for token, value in (
("ORIGIN", str(origin)),
("LIB", target.lib_token),
("PLATFORM", target.platform_token),
):
expanded = expanded.replace(f"${{{token}}}", value).replace(
f"${token}",
value,
)
if expanded:
search_paths.append(Path(expanded))
return needed, search_paths
def ldconfig_cache() -> dict[str, list[Path]]:
result = run(["ldconfig", "-p"], check=False)
if result.returncode != 0:
fail(f"ldconfig -p failed: {result.stderr.strip()}")
cache: dict[str, list[Path]] = {}
for line in result.stdout.splitlines():
match = re.match(r"^\s*(\S+)\s+\([^)]+\)\s+=>\s+(\S+)\s*$", line)
if match is None:
continue
cache.setdefault(match.group(1), []).append(Path(match.group(2)))
return cache
def is_host_driver(soname: str) -> bool:
return any(fnmatch.fnmatch(soname, pattern) for pattern in HOST_DRIVER_PATTERNS)
def optional_runtime_sonames(cache: dict[str, list[Path]]) -> list[str]:
sonames = set(OPTIONAL_RUNTIME_LIBRARIES)
sonames.update(
soname
for soname in cache
if any(
fnmatch.fnmatch(soname, pattern)
for pattern in OPTIONAL_RUNTIME_PATTERNS
)
)
return sorted(sonames)
def resolve_library(
soname: str,
*,
consumer: Path,
consumer_search_paths: Iterable[Path],
cache: dict[str, list[Path]],
machine: str,
library_directories: Iterable[str],
) -> Path | None:
candidates: list[Path] = []
candidates.extend(path / soname for path in consumer_search_paths)
candidates.extend(cache.get(soname, []))
candidates.extend(Path(path) / soname for path in library_directories)
seen: set[Path] = set()
for candidate in candidates:
try:
resolved = candidate.resolve(strict=True)
except (FileNotFoundError, OSError):
continue
if resolved in seen or not resolved.is_file():
continue
seen.add(resolved)
try:
candidate_machine = elf_machine(resolved)
except SystemExit:
continue
if candidate_machine == machine:
return resolved
print(
f"warning: unable to resolve {soname} required by {consumer}",
file=sys.stderr,
)
return None
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def copy_runtime_file(source: Path, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.exists():
if sha256(source) != sha256(destination):
fail(
"conflicting runtime libraries share the same bundled name: "
f"{source} and {destination}"
)
return
shutil.copy2(source, destination)
destination.chmod(destination.stat().st_mode | 0o444)
def package_owner_query_paths(path: Path) -> tuple[Path, ...]:
candidates: list[Path] = []
def append(candidate: Path) -> None:
if candidate not in candidates:
candidates.append(candidate)
append(path)
try:
append(path.resolve())
except OSError:
pass
for candidate in tuple(candidates):
candidate_text = str(candidate)
for legacy_prefix, merged_prefix in USR_MERGE_PATH_ALIASES:
for source_prefix, destination_prefix in (
(legacy_prefix, merged_prefix),
(merged_prefix, legacy_prefix),
):
if candidate_text == source_prefix or candidate_text.startswith(
f"{source_prefix}/"
):
append(
Path(
f"{destination_prefix}"
f"{candidate_text[len(source_prefix):]}"
)
)
return tuple(candidates)
def package_owner(path: Path) -> str | None:
for candidate in package_owner_query_paths(path):
result = run(["dpkg-query", "-S", str(candidate)], check=False)
if result.returncode != 0:
continue
for line in result.stdout.splitlines():
package, separator, _ = line.partition(": ")
if separator and package:
return package
return None
def package_version(package: str) -> str:
result = run(
["dpkg-query", "-W", "-f=${binary:Package}\t${Version}", package],
check=False,
)
if result.returncode != 0:
fail(f"cannot determine installed version for runtime package {package}")
fields = result.stdout.strip().split("\t", 1)
if len(fields) != 2 or not fields[1]:
fail(f"invalid dpkg-query version output for runtime package {package}")
return fields[1]
def copy_package_licenses(
packaged_sources: dict[str, Path],
license_directory: Path,
) -> list[dict[str, object]]:
packages: dict[str, set[str]] = {}
for bundled_name, source in packaged_sources.items():
owner = package_owner(source)
if owner is None:
fail(
"cannot publish a bundled runtime file without Debian package "
f"ownership metadata: {source}"
)
packages.setdefault(owner, set()).add(bundled_name)
license_directory.mkdir(parents=True, exist_ok=True)
package_records: list[dict[str, object]] = []
for package in sorted(packages):
package_base = package.split(":", 1)[0]
copyright_source = Path("/usr/share/doc") / package_base / "copyright"
copyright_destination = license_directory / f"{package_base}.copyright"
if copyright_source.is_file():
shutil.copy2(copyright_source, copyright_destination)
else:
fail(
"cannot publish bundled runtime package without its copyright "
f"file: {package} ({copyright_source})"
)
package_records.append(
{
"package": package,
"version": package_version(package),
"files": sorted(packages[package]),
"license": str(copyright_destination.name),
}
)
return package_records
def compile_launcher(
source: Path,
destination: Path,
expected_machine: str,
) -> str:
destination.parent.mkdir(parents=True, exist_ok=True)
run(
[
"musl-gcc",
"-static",
"-Os",
"-s",
"-Wl,--build-id=none",
str(source),
"-o",
str(destination),
]
)
destination.chmod(0o755)
if "Requesting program interpreter" in readelf(destination, "-lW"):
fail(f"portable launcher is not static: {destination}")
launcher_machine = elf_machine(destination)
if launcher_machine != expected_machine:
fail(
"portable launcher architecture mismatch: "
f"expected {expected_machine}, got {launcher_machine}"
)
return launcher_machine
def verify_private_runtime(loader: Path, library_directory: Path, binary: Path) -> None:
environment = dict(os.environ)
for name in ("LD_AUDIT", "LD_LIBRARY_PATH", "LD_PRELOAD", "LD_PROFILE"):
environment.pop(name, None)
verify = run([str(loader), "--verify", str(binary)], check=False, env=environment)
if verify.returncode != 0:
fail(
"bundled loader rejected navop.real: "
f"{verify.stderr.strip() or verify.stdout.strip()}"
)
listed = run(
[
str(loader),
"--inhibit-cache",
"--library-path",
str(library_directory),
"--list",
str(binary),
],
check=False,
env=environment,
)
if listed.returncode != 0:
fail(
"bundled loader could not resolve navop.real dependencies: "
f"{listed.stderr.strip() or listed.stdout.strip()}"
)
print(listed.stdout.rstrip())
def manifest_files(output: Path, manifest: Path) -> list[dict[str, object]]:
records: list[dict[str, object]] = []
for path in sorted(output.rglob("*")):
if not path.is_file() or path == manifest:
continue
relative = path.relative_to(output).as_posix()
records.append(
{
"path": relative,
"size": path.stat().st_size,
"sha256": sha256(path),
}
)
return records
def main() -> None:
args = parse_args()
target = TARGET_CONFIGS[args.target]
for command in ("dpkg-query", "ldconfig", "musl-gcc", "readelf"):
require_command(command)
binary = args.binary.resolve()
launcher_source = args.launcher_source.resolve()
output = args.output.resolve()
repository_root = Path(__file__).resolve().parent.parent
if not binary.is_file():
fail(f"release binary does not exist: {binary}")
if not launcher_source.is_file():
fail(f"launcher source does not exist: {launcher_source}")
if output == Path("/") or output == repository_root:
fail(f"refusing to replace unsafe output directory: {output}")
machine = elf_machine(binary)
if machine != target.machine:
fail(
f"portable package for {args.target} expects {target.machine}, "
f"got {machine}"
)
interpreter = elf_interpreter(binary)
if interpreter.name != target.loader:
fail(
f"unexpected ELF interpreter for {args.target}: {interpreter}; "
f"expected {target.loader}"
)
if not interpreter.is_file():
fail(f"ELF interpreter does not exist on the build runner: {interpreter}")
verify_binary_glibc_baseline(binary, args.glibc_baseline)
if output.exists():
shutil.rmtree(output)
runtime_root = output / "usr/lib/navop"
binary_destination = runtime_root / "bin/navop.real"
library_directory = runtime_root / "lib"
launcher_destination = output / "usr/bin/navop"
documentation_directory = output / "usr/share/doc/navop"
runtime_license_directory = documentation_directory / "runtime-licenses"
binary_destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(binary, binary_destination)
binary_destination.chmod(0o755)
cache = ldconfig_cache()
queue: list[Path] = [binary]
scanned: set[Path] = set()
runtime_sources: dict[str, Path] = {
interpreter.name: interpreter.resolve(),
}
while queue:
consumer = queue.pop(0).resolve()
if consumer in scanned:
continue
scanned.add(consumer)
if elf_machine(consumer) != machine:
fail(f"runtime architecture mismatch: {consumer}")
needed, search_paths = dynamic_metadata(consumer, target)
for soname in needed:
if is_host_driver(soname):
fail(
f"{consumer} directly depends on host GPU driver {soname}; "
"portable packages may only use host drivers through runtime discovery"
)
source = resolve_library(
soname,
consumer=consumer,
consumer_search_paths=search_paths,
cache=cache,
machine=machine,
library_directories=target.library_directories,
)
if source is None:
fail(f"missing required shared library {soname} for {consumer}")
previous = runtime_sources.get(soname)
if previous is not None and previous != source:
if sha256(previous) != sha256(source):
fail(
f"{soname} resolves to conflicting files: {previous} and {source}"
)
else:
runtime_sources[soname] = source
queue.append(source)
for soname in REQUIRED_DLOPEN_RUNTIME_LIBRARIES:
source = resolve_library(
soname,
consumer=binary,
consumer_search_paths=(),
cache=cache,
machine=machine,
library_directories=target.library_directories,
)
if source is None:
fail(
f"missing required dlopen runtime library {soname} "
f"for portable Wayland support"
)
previous = runtime_sources.get(soname)
if previous is not None and previous != source:
if sha256(previous) != sha256(source):
fail(
f"{soname} resolves to conflicting files: {previous} and {source}"
)
else:
runtime_sources[soname] = source
queue.append(source)
for soname in optional_runtime_sonames(cache):
if soname in runtime_sources:
continue
source = resolve_library(
soname,
consumer=binary,
consumer_search_paths=(),
cache=cache,
machine=machine,
library_directories=target.library_directories,
)
if source is None:
continue
runtime_sources[soname] = source
queue.append(source)
while queue:
consumer = queue.pop(0).resolve()
if consumer in scanned:
continue
scanned.add(consumer)
if elf_machine(consumer) != machine:
fail(f"runtime architecture mismatch: {consumer}")
needed, search_paths = dynamic_metadata(consumer, target)
for soname in needed:
if is_host_driver(soname):
fail(
f"{consumer} directly depends on host GPU driver {soname}; "
"portable packages may only use host drivers through runtime discovery"
)
source = resolve_library(
soname,
consumer=consumer,
consumer_search_paths=search_paths,
cache=cache,
machine=machine,
library_directories=target.library_directories,
)
if source is None:
fail(f"missing required shared library {soname} for {consumer}")
previous = runtime_sources.get(soname)
if previous is not None and previous != source:
if sha256(previous) != sha256(source):
fail(
f"{soname} resolves to conflicting files: {previous} and {source}"
)
else:
runtime_sources[soname] = source
queue.append(source)
for bundled_name, source in sorted(runtime_sources.items()):
if elf_machine(source) != machine:
fail(f"runtime architecture mismatch: {source}")
copy_runtime_file(source, library_directory / bundled_name)
libc_source = runtime_sources.get("libc.so.6")
if libc_source is None:
fail("recursive dependency closure did not contain libc.so.6")
gconv_source = libc_source.parent / "gconv"
license_sources = dict(runtime_sources)
if gconv_source.is_dir():
for source in sorted(gconv_source.rglob("*")):
if source.is_file():
relative = source.relative_to(gconv_source).as_posix()
license_sources[f"gconv/{relative}"] = libc_source
shutil.copytree(
gconv_source,
library_directory / "gconv",
symlinks=False,
)
else:
print(
f"warning: glibc conversion modules were not found beside {libc_source}",
file=sys.stderr,
)
documentation_directory.mkdir(parents=True, exist_ok=True)
for license_name in ("LICENSE-APACHE", "NAVOP_LICENSE"):
source = repository_root / license_name
if not source.is_file():
fail(f"project license file is missing: {source}")
shutil.copy2(source, documentation_directory / license_name)
package_records = copy_package_licenses(
license_sources,
runtime_license_directory,
)
packages_file = runtime_root / "runtime-packages.txt"
packages_file.write_text(
"".join(
f"{record['package']}\t{record['version']}\t"
f"{','.join(record['files'])}\n"
for record in package_records
),
encoding="utf-8",
)
launcher_machine = compile_launcher(
launcher_source,
launcher_destination,
machine,
)
bundled_loader = library_directory / interpreter.name
verify_private_runtime(
bundled_loader,
library_directory,
binary_destination,
)
manifest_path = runtime_root / "runtime-manifest.json"
manifest = {
"schema_version": 1,
"product": "navop",
"target": args.target,
"elf_machine": machine,
"interpreter": str(interpreter),
"launcher_machine": launcher_machine,
"platform_token": target.platform_token,
"lib_token": target.lib_token,
"binary_glibc_baseline": args.glibc_baseline,
"entrypoint": "usr/bin/navop",
"binary": "usr/lib/navop/bin/navop.real",
"loader": f"usr/lib/navop/lib/{interpreter.name}",
"library_path": "usr/lib/navop/lib",
"gpu_policy": (
"host vendor libraries are discovered dynamically and are not bundled"
),
"nss_policy": (
"glibc NSS modules are bundled when available; host DNS, NSS, and "
"certificate configuration remains authoritative"
),
"packages": package_records,
"host_interfaces": [
"Linux kernel and procfs",
"Wayland or X11 display sockets",
"D-Bus session and system buses",
"GPU devices and host vendor drivers",
"system fonts and font configuration",
"CA certificates, DNS, and NSS configuration",
],
"files": manifest_files(output, manifest_path),
}
manifest_path.write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
print(
f"Packaged {len(runtime_sources)} runtime ELF files into {output}",
)
if __name__ == "__main__":
main()
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(
cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
pwd
)"
exec python3 "$script_dir/package-linux-portable.py" "$@"
+344 -154
View File
@@ -114,64 +114,47 @@ test("renamed Linux packages replace legacy onetcli installations", () => {
assert.match(release, /Obsoletes: onetcli/);
});
test("Linux keeps full-feature standard packages and publishes portable variants separately", () => {
test("Linux publishes one package per architecture plus a separate GPU dependency stack", () => {
const release = read(".github/workflows/release.yml");
const installZig = workflowStep(
release,
"Install Zig toolchain (portable Linux)",
);
const installPortable = workflowStep(
release,
"Install portable packaging dependencies",
);
const installZig = workflowStep(release, "Install Zig toolchain (Linux)");
const build = workflowStep(release, "Build release binary");
const verifyPortable = workflowStep(
release,
"Verify portable Linux glibc baseline",
);
const verifyBaseline = workflowStep(release, "Verify Linux glibc baseline");
const packageLinux = workflowStep(release, "Package (Linux)");
const packageGpuStack = workflowStep(
release,
"Package Linux GPU dependency stack",
);
const packageInstallers = workflowStep(
release,
"Package Linux installers (x86_64)",
);
// Linux ships exactly one build per architecture. The portable archive is an
// extra artifact produced from that very same binary inside the same job, not
// a second matrix entry; the private loader and its launcher are gone, and
// the Mesa stack travels as a separate dependency archive instead.
assert.doesNotMatch(release, /portable_linux/);
assert.doesNotMatch(release, /linux-x64-portable|linux-arm64-portable/);
assert.doesNotMatch(release, /package-linux-portable|linux-portable-launcher/);
assert.match(
release,
/linux_x64='\{"target":"x86_64-unknown-linux-gnu","os":"ubuntu-latest"[^']*"archive":"navop-x86_64-unknown-linux-gnu\.tar\.gz"[^']*"variant":"standard"[^']*"portable_linux":false\}'/,
/linux_x64='\{"target":"x86_64-unknown-linux-gnu","os":"ubuntu-latest"[^']*"archive":"navop-x86_64-unknown-linux-gnu\.tar\.gz"[^']*"public_label":"linux-x64"[^']*\}'/,
);
assert.match(
release,
/linux_x64_portable='\{"target":"x86_64-unknown-linux-gnu","os":"ubuntu-22\.04"[^']*"archive":"navop-x86_64-unknown-linux-gnu-portable\.tar\.gz"[^']*"variant":"portable"[^']*"portable_linux":true\}'/,
/linux_arm64='\{"target":"aarch64-unknown-linux-gnu","os":"ubuntu-24\.04-arm"[^']*"archive":"navop-aarch64-unknown-linux-gnu\.tar\.gz"[^']*"public_label":"linux-arm64"[^']*\}'/,
);
assert.match(
release,
/linux_arm64='\{"target":"aarch64-unknown-linux-gnu"[^']*"archive":"navop-aarch64-unknown-linux-gnu\.tar\.gz"[^']*"variant":"standard"[^']*"portable_linux":false\}'/,
/all\) matrix="\[\$macos_arm64,\$macos_x64,\$linux_x64,\$linux_arm64,\$windows_x64,\$windows_x86\]"/,
);
assert.match(
release,
/linux_arm64_portable='\{"target":"aarch64-unknown-linux-gnu"[^']*"archive":"navop-aarch64-unknown-linux-gnu-portable\.tar\.gz"[^']*"variant":"portable"[^']*"portable_linux":true\}'/,
);
assert.match(
release,
/all\) matrix="\[\$macos_arm64,\$macos_x64,\$linux_x64,\$linux_x64_portable,\$linux_arm64,\$linux_arm64_portable,\$windows_x64,\$windows_x86\]"/,
);
assert.match(
release,
/linux-x64\) matrix="\[\$linux_x64,\$linux_x64_portable\]"/,
);
assert.match(
release,
/linux-x64-portable\) matrix="\[\$linux_x64_portable\]"/,
);
assert.match(
release,
/linux-arm64\) matrix="\[\$linux_arm64,\$linux_arm64_portable\]"/,
);
assert.match(
release,
/linux-arm64-portable\) matrix="\[\$linux_arm64_portable\]"/,
);
assert.match(installZig, /if: matrix\.portable_linux/);
assert.match(release, /linux-x64\) matrix="\[\$linux_x64\]"/);
assert.match(release, /linux-arm64\) matrix="\[\$linux_arm64\]"/);
assert.match(release, /name: Build \(\$\{\{ matrix\.target \}\}\)$/m);
// Zig is what lowers the C runtime requirement to glibc 2.28, so it now runs
// for every Linux build rather than only for the retired portable variant.
assert.match(installZig, /if: runner\.os == 'Linux'/);
assert.match(installZig, /python3 -m venv "\$RUNNER_TEMP\/ziglang"/);
assert.match(installZig, /ziglang==0\.14\.1/);
assert.match(
@@ -184,12 +167,22 @@ test("Linux keeps full-feature standard packages and publishes portable variants
);
assert.match(installZig, /cargo-zigbuild --version/);
assert.doesNotMatch(installZig, /cargo zigbuild --version/);
assert.match(installPortable, /if: matrix\.portable_linux/);
assert.match(installPortable, /apt-get install -y binutils musl-tools/);
assert.match(build, /if \[ "\$\{\{ matrix\.portable_linux \}\}" = "true" \]/);
assert.doesNotMatch(release, /Install portable packaging dependencies/);
assert.doesNotMatch(release, /musl-tools/);
assert.match(build, /if \[ "\$\{\{ runner\.os \}\}" = "Linux" \]/);
assert.match(
build,
/cargo zigbuild[\s\S]*--release[\s\S]*-p main[\s\S]*--target "\$\{\{ matrix\.target \}\}\.2\.28"[\s\S]*--no-default-features[\s\S]*--features wasm-components,shell-plugins/,
/cargo zigbuild[\s\S]*--release[\s\S]*-p main[\s\S]*--target "\$\{\{ matrix\.target \}\}\.2\.28"/,
);
// The single Linux package serves the updater, so it must keep the default
// feature set instead of quietly dropping the embedded webview.
const linuxBranch = build.slice(
build.indexOf('if [ "${{ runner.os }}" = "Linux" ]'),
);
assert.doesNotMatch(
linuxBranch.slice(0, linuxBranch.indexOf("else")),
/--no-default-features/,
);
assert.match(
build,
@@ -199,28 +192,81 @@ test("Linux keeps full-feature standard packages and publishes portable variants
build,
/test -x "target\/\$\{\{ matrix\.target \}\}\/release\/\$\{\{ matrix\.binary \}\}"/,
);
assert.match(verifyPortable, /if: matrix\.portable_linux/);
assert.match(verifyBaseline, /if: runner\.os == 'Linux'/);
assert.match(
verifyPortable,
verifyBaseline,
/script\/check-linux-glibc-baseline\.sh[\s\S]*target\/\$\{\{ matrix\.target \}\}\/release\/\$\{\{ matrix\.binary \}\}[\s\S]*2\.28/,
);
assert.match(packageLinux, /mkdir -p package\/usr\/bin/);
assert.match(
packageLinux,
/if \[ "\$\{\{ matrix\.portable_linux \}\}" = "true" \]; then[\s\S]*script\/package-linux-portable\.sh/,
);
assert.match(
packageLinux,
/else[\s\S]*cp "target\/\$\{\{ matrix\.target \}\}\/release\/\$\{\{ matrix\.binary \}\}" package\/usr\/bin\//,
/cp "target\/\$\{\{ matrix\.target \}\}\/release\/\$\{\{ matrix\.binary \}\}" package\/usr\/bin\//,
);
assert.match(packageLinux, /--sort=name/);
assert.match(packageLinux, /--numeric-owner/);
// The portable archive carries the identical binary plus the marker file the
// application looks for next to the executable. It has to be produced from
// this step without a second compilation, and its marker name must match the
// constant the Rust side actually reads.
const appPathsSource = read("crates/core/src/app_paths.rs");
const markerDeclaration =
/pub const PORTABLE_MARKER_FILE: &str = "([^"]+)";/.exec(appPathsSource);
assert.ok(markerDeclaration, "app_paths.rs must declare PORTABLE_MARKER_FILE");
const markerFile = markerDeclaration[1];
// Portable mode exists at all only because this detection stays platform
// independent; a Windows-only guard here would make the Linux archive ship a
// marker file that nothing ever reads.
assert.match(appPathsSource, /join\(PORTABLE_MARKER_FILE\)\.is_file\(\)/);
assert.doesNotMatch(appPathsSource, /cfg\(windows\)/);
assert.doesNotMatch(appPathsSource, /cfg\(target_os = "windows"\)/);
assert.match(packageLinux, new RegExp(`PORTABLE_MARKER_FILE="${markerFile}"`));
assert.match(packageLinux, /rm -rf portable-package/);
assert.match(packageLinux, /mkdir -p portable-package/);
assert.match(
packageInstallers,
/if: matrix\.target == 'x86_64-unknown-linux-gnu' && !matrix\.portable_linux/,
packageLinux,
/cp "target\/\$\{\{ matrix\.target \}\}\/release\/\$\{\{ matrix\.binary \}\}" portable-package\//,
);
assert.match(packageLinux, /: > "portable-package\/\$\{PORTABLE_MARKER_FILE\}"/);
assert.match(
packageLinux,
/-czf "\$\{PUBLIC_BASENAME\}-portable\.tar\.gz" \\\n\s+-C portable-package \./,
);
// Reusing the release binary is the whole point: no second compilation.
assert.doesNotMatch(packageLinux, /cargo (?:zig)?build/);
assert.match(
release,
/navop-\*-\$\{\{ matrix\.public_label \}\}-portable\.tar\.gz/,
);
// The Windows portable ZIP ships the same contract and hardcodes the same
// marker name, so both platforms have to agree with the Rust constant.
assert.match(
release,
new RegExp(`"portable-package/${markerFile.replace(/\./g, "\\.")}"`),
);
// The dependency stack is a second asset for the same target, built against
// the same glibc 2.28 baseline, and published under the versioned public name.
assert.match(packageGpuStack, /if: runner\.os == 'Linux'/);
assert.match(
packageGpuStack,
/script\/package-linux-gpu-stack-docker\.sh[\s\S]*--binary "target\/\$\{\{ matrix\.target \}\}\/release\/\$\{\{ matrix\.binary \}\}"[\s\S]*--target "\$\{\{ matrix\.target \}\}"[\s\S]*--output dist-gpu-stack/,
);
assert.match(
packageGpuStack,
/cp dist-gpu-stack\/navop-gpu-stack-linux-\*\.tar\.gz "\$\{PUBLIC_BASENAME\}-gpu-stack\.tar\.gz"/,
);
assert.match(
release,
/navop-\*-\$\{\{ matrix\.public_label \}\}-gpu-stack\.tar\.gz/,
);
assert.match(packageInstallers, /if: matrix\.target == 'x86_64-unknown-linux-gnu'/);
});
test("portable Linux disables WebView while standard builds keep it", () => {
test("the embedded webview stays an opt-in crate feature", () => {
const workspaceCargo = read("Cargo.toml");
const cargo = read("crates/ai_chat_view/Cargo.toml");
const mainCargo = read("main/Cargo.toml");
@@ -291,134 +337,270 @@ test("portable Linux disables WebView while standard builds keep it", () => {
}
});
test("Linux portable packager uses a private loader and recursive ELF closure", () => {
const wrapperPath = "script/package-linux-portable.sh";
const packagerPath = "script/package-linux-portable.py";
const launcherPath = "script/linux-portable-launcher.c";
test("Linux GPU dependency stack replaces the retired portable runtime", () => {
const packagerPath = "script/package-linux-gpu-stack.py";
const wrapperPath = "script/package-linux-gpu-stack.sh";
const buildPath = "script/package-linux-gpu-stack-build.sh";
const dockerPath = "script/package-linux-gpu-stack-docker.sh";
const installPath = "script/linux-gpu-stack-install.sh";
for (const file of [wrapperPath, packagerPath, launcherPath]) {
for (const file of [
packagerPath,
wrapperPath,
buildPath,
dockerPath,
installPath,
]) {
assert.ok(fs.existsSync(file), `${file} must exist`);
}
// The launcher and the packager that produced it are deliberately gone: the
// stack now lands on the host loader instead of wrapping the binary.
for (const retired of [
"script/package-linux-portable.py",
"script/package-linux-portable.sh",
"script/linux-portable-launcher.c",
]) {
assert.equal(fs.existsSync(retired), false, `${retired} must be removed`);
}
const wrapper = read(wrapperPath);
const packager = read(packagerPath);
const launcher = read(launcherPath);
const build = read(buildPath);
const docker = read(dockerPath);
const help = spawnSync("python3", [packagerPath, "--help"], {
encoding: "utf8",
});
assert.equal(help.status, 0, help.stderr);
assert.match(wrapper, /set -euo pipefail/);
assert.match(wrapper, /package-linux-portable\.py/);
assert.match(packager, /readelf/);
assert.match(packager, /PT_INTERP/);
assert.match(packager, /verify_binary_glibc_baseline/);
assert.match(packager, /binary_glibc_baseline/);
assert.match(
packager,
/the bundled "[\s\S]*"private runtime itself may be newer/,
);
assert.match(packager, /\\\(NEEDED\\\)/);
assert.match(packager, /ldconfig/);
assert.match(packager, /dpkg-query/);
assert.match(packager, /musl-gcc/);
assert.match(packager, /"-static"/);
assert.match(packager, /runtime-manifest\.json/);
assert.match(packager, /runtime-packages\.txt/);
assert.match(packager, /runtime-licenses/);
assert.match(packager, /LICENSE-APACHE/);
assert.match(packager, /NAVOP_LICENSE/);
assert.match(packager, /libnss_dns\.so\.2/);
assert.match(packager, /libnss_files\.so\.2/);
assert.match(packager, /libnss_\*\.so\.2/);
assert.match(packager, /libwayland-client\.so\.0/);
assert.match(packager, /libwayland-cursor\.so\.0/);
assert.match(packager, /libwayland-egl\.so\.1/);
assert.match(packager, /missing required dlopen runtime library/);
assert.match(packager, /libvulkan_\*\.so\*/);
assert.match(help.stdout, /aarch64-unknown-linux-gnu/);
assert.match(help.stdout, /x86_64-unknown-linux-gnu/);
assert.match(packager, /machine="AArch64"/);
assert.match(packager, /loader="ld-linux-aarch64\.so\.1"/);
assert.match(packager, /platform_token="aarch64"/);
assert.match(packager, /lib_token="lib"/);
assert.match(packager, /machine="Advanced Micro Devices X86-64"/);
assert.match(packager, /loader="ld-linux-x86-64\.so\.2"/);
assert.match(packager, /platform_token="x86_64"/);
assert.match(packager, /lib_token="lib64"/);
assert.match(packager, /launcher architecture mismatch/);
assert.match(packager, /launcher_machine/);
assert.match(packager, /gpu_policy/);
assert.match(packager, /nss_policy/);
assert.match(packager, /license_sources\[f"gconv\/\{relative\}"\]/);
assert.match(help.stdout, /aarch64-unknown-linux-gnu/);
assert.match(wrapper, /set -euo pipefail/);
assert.match(wrapper, /package-linux-gpu-stack\.py/);
// RHEL 8 images default to Python 3.6, so the wrapper must not trust it.
assert.match(wrapper, /sys\.version_info >= \(3, 9\)/);
// Only a RHEL 8 generation distribution ships a Mesa user space at the
// glibc 2.28 baseline Navop itself is built against, so dnf is what resolves
// the tree.
assert.match(build, /rpm -E %\{rhel\}/);
assert.match(build, /dnf install -y --setopt=install_weak_deps=False/);
assert.match(build, /dnf-plugins-core/);
assert.match(build, /dnf download --resolve/);
assert.match(
packager,
/cannot publish a bundled runtime file without Debian package[\s\S]*ownership metadata/,
build,
/--releasever="\$release_version" --nogpgcheck --forcearch="\$rpm_arch"/,
);
assert.match(build, /--installroot "\$empty_root"/);
assert.match(build, /filter_rpms_to_architecture/);
assert.match(build, /rpm -qp --qf '%\{ARCH\}'/);
assert.match(build, /rpm2cpio "\$rpm" \| cpio -idm --no-absolute-filenames/);
assert.match(build, /mesa-dri-drivers/);
assert.match(build, /libglvnd-egl/);
assert.match(build, /libwayland-client/);
// The bare "liblzma" name does not exist on RHEL 8; the package is xz-libs.
assert.match(build, /xz-libs/);
assert.match(
packager,
/cannot publish bundled runtime package without its copyright[\s\S]*file/,
build,
/local archive="navop-gpu-stack-linux-\$\{asset_label\}\.tar\.gz"/,
);
assert.match(launcher, /\/proc\/self\/exe/);
assert.match(launcher, /defined\(__aarch64__\)/);
assert.match(launcher, /defined\(__x86_64__\)/);
assert.match(launcher, /ld-linux-aarch64\.so\.1/);
assert.match(launcher, /ld-linux-x86-64\.so\.2/);
assert.match(launcher, /NAVOP_PORTABLE_LOADER/);
assert.match(launcher, /--inhibit-cache/);
assert.match(launcher, /--library-path/);
assert.match(launcher, /navop\.real/);
assert.match(launcher, /GCONV_PATH/);
assert.match(launcher, /unsetenv\("LD_PRELOAD"\)/);
assert.match(launcher, /unsetenv\("GLIBC_TUNABLES"\)/);
assert.match(build, /asset_label="x64"/);
assert.match(build, /asset_label="arm64"/);
assert.match(build, /--glibc-baseline "\$glibc_baseline"/);
assert.match(
build,
/--installer-source "\$repository_root\/script\/linux-gpu-stack-install\.sh"/,
);
// The host side mounts the repository read only plus the binary and the
// output directory, and must not rely on GNU-only find predicates.
assert.match(docker, /rockylinux:8/);
assert.match(docker, /-v "\$repository_root:\/workspace:ro"/);
assert.match(docker, /-v "\$binary_directory:\/binary:ro"/);
assert.match(docker, /-v "\$output:\/out"/);
assert.match(
docker,
/bash \/workspace\/script\/package-linux-gpu-stack-build\.sh/,
);
assert.match(docker, /navop-gpu-stack-linux-\*\.tar\.gz/);
assert.doesNotMatch(docker, /-newermt/);
});
test("Linux portable packager resolves Debian ownership across usrmerge aliases", () => {
const packagerPath = path.resolve("script/package-linux-portable.py");
test("Linux GPU dependency stack packager separates host libraries from bundled ones", () => {
const packagerPath = "script/package-linux-gpu-stack.py";
const python = String.raw`
import importlib.util
from pathlib import Path
import subprocess
import sys
spec = importlib.util.spec_from_file_location("navop_portable_packager", sys.argv[1])
spec = importlib.util.spec_from_file_location("navop_gpu_stack_packager", sys.argv[1])
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
loader_in_usr = Path("/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2")
loader_in_lib = Path("/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2")
# Navop reaches EGL through dlopen, so these are roots rather than closure
# members, and Mesa resolves its driver by file name rather than by SONAME.
assert module.GPU_STACK_ENTRY_LIBRARIES == ("libEGL.so.1", "libEGL_mesa.so.0")
assert module.GPU_STACK_DRI_DRIVERS == ("swrast_dri.so", "kms_swrast_dri.so")
assert (
module.GPU_STACK_EGL_VENDOR_CONFIGURATION
== "/usr/share/glvnd/egl_vendor.d/50_mesa.json"
)
usr_candidates = module.package_owner_query_paths(loader_in_usr)
lib_candidates = module.package_owner_query_paths(loader_in_lib)
assert loader_in_usr in usr_candidates
assert loader_in_lib in usr_candidates
assert loader_in_lib in lib_candidates
assert loader_in_usr in lib_candidates
# The C runtime stays on the host: bundling it would shadow system glibc with a
# foreign copy.
for soname in ("libc.so.6", "libm.so.6", "libpthread.so.0", "ld-linux-x86-64.so.2"):
assert module.is_host_provided(soname), soname
assert module.is_host_provided("libnss_dns.so.2")
assert not module.is_host_provided("libEGL.so.1")
queries = []
def fake_run(command, *, check=True, env=None):
queries.append(command)
if command == ["dpkg-query", "-S", str(loader_in_lib)]:
return subprocess.CompletedProcess(
command,
0,
stdout=f"libc6:amd64: {loader_in_lib}\n",
stderr="",
)
return subprocess.CompletedProcess(command, 1, stdout="", stderr="")
# The desktop stack integrates with the running session, so it is recorded as a
# host requirement instead of being shipped from one distribution.
for soname in (
"libgtk-3.so.0",
"libwebkit2gtk-4.1.so.0",
"libglib-2.0.so.0",
"libgobject-2.0.so.0",
"libpango-1.0.so.0",
"libcairo.so.2",
):
assert module.is_host_desktop_library(soname), soname
assert not module.is_host_desktop_library("libEGL.so.1")
module.run = fake_run
assert module.package_owner(loader_in_usr) == "libc6:amd64"
assert ["dpkg-query", "-S", str(loader_in_lib)] in queries
# RHEL 8 is merged-/usr: an RPM that owns /lib64/libgcc_s.so.1 has to match the
# extracted /usr/lib64/libgcc_s.so.1, and the other way round.
assert module.path_spellings("/lib64/libgcc_s.so.1") == [
"/lib64/libgcc_s.so.1",
"/usr/lib64/libgcc_s.so.1",
]
assert module.path_spellings("/usr/lib64/libfoo.so") == [
"/lib64/libfoo.so",
"/usr/lib64/libfoo.so",
]
for name, config in module.TARGET_CONFIGS.items():
assert config.libdir == "/usr/lib64", name
assert config.dri_dir == "/usr/lib64/dri", name
assert config.dri_driver_directories, name
assert any(entry.endswith("/dri") for entry in config.dri_driver_directories), name
assert config.architecture_label in ("aarch64", "x86_64"), name
`;
const result = spawnSync("python3", ["-c", python, packagerPath], {
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr || result.stdout);
});
test("Linux GPU dependency stack installer is additive and reversibly removable", () => {
const installPath = "script/linux-gpu-stack-install.sh";
assert.ok(fs.existsSync(installPath), `${installPath} must exist`);
const installer = read(installPath);
assert.match(installer, /set -euo pipefail/);
assert.match(installer, /--dry-run/);
assert.match(installer, /--force/);
assert.match(installer, /--prefix/);
assert.match(installer, /--uninstall/);
// Everything lands on paths the loader already searches, so no package form
// needs environment variables to find it.
assert.match(installer, /resolve_package_root/);
assert.match(installer, /load_metadata/);
assert.match(installer, /verify_host_architecture/);
assert.match(installer, /ldconfig/);
assert.match(installer, /soname_is_resolved/);
assert.match(installer, /host_has_usable_gl/);
assert.match(installer, /host_has_dri_driver/);
assert.match(installer, /_dri\.so/);
// The host's own copy always wins: only gaps are filled.
assert.match(installer, /already provided by the host/);
assert.match(installer, /host copy kept/);
assert.match(installer, /install_gl=0/);
// Debian-style hosts do not search /usr/lib64, so it is registered, and the
// registration can be taken back.
assert.match(installer, /ensure_loader_configuration/);
assert.match(installer, /navop-gpu-stack\.conf/);
assert.match(installer, /\/etc\/ld\.so\.conf\.d/);
assert.match(installer, /drop_loader_configuration/);
assert.match(installer, /refresh_loader_cache/);
// Uninstall replays what this installer actually wrote, never the manifest it
// merely planned: that one also lists the entries the host already provided.
assert.match(installer, /installed\.tsv/);
assert.match(installer, /write_install_record/);
assert.match(installer, /KEPT_FILES\+=/);
assert.match(installer, /modified since installation/);
assert.match(installer, /no installation record at/);
const help = spawnSync("bash", [installPath, "--help"], { encoding: "utf8" });
assert.equal(help.status, 0, help.stderr);
assert.match(help.stdout, /--uninstall/);
// The help body is a sed range over the header comment; the last line must
// stay inside it.
assert.match(help.stdout, /\.\/install\.sh --help/);
});
test("Linux install guides document the GPU dependency stack and the portable archive", () => {
const guides = [
["docs-site/docs/guide/install-update.md", /图形依赖包/],
["docs-site/docs/en-US/guide/install-update.md", /graphics dependency package/],
["docs-site/docs/zh-TW/guide/install-update.md", /圖形相依套件/],
];
for (const [guidePath, heading] of guides) {
const guide = read(guidePath);
assert.match(guide, heading, `${guidePath} must document the dependency stack`);
assert.match(
guide,
/navop-<version>-linux-x64-gpu-stack\.tar\.gz/,
`${guidePath} must name the x86_64 dependency archive`,
);
assert.match(
guide,
/navop-<version>-linux-arm64-gpu-stack\.tar\.gz/,
`${guidePath} must name the arm64 dependency archive`,
);
// The documented flow has to match the archive layout, the installer name
// and the flags the script actually accepts.
assert.match(guide, /tar -xzf navop-<version>-linux-x64-gpu-stack\.tar\.gz -C navop-gpu-stack/);
assert.match(guide, /sudo navop-gpu-stack\/install\.sh/);
assert.match(guide, /--dry-run/);
assert.match(guide, /--force/);
assert.match(guide, /--uninstall/);
assert.match(guide, /installed\.tsv/);
assert.match(guide, /\/usr\/lib\/navop-gpu-stack\//);
assert.match(guide, /Failed to create surface/);
// The portable archive ships again, now as the very same binary plus a
// marker file instead of a private loader, so every guide has to describe
// the archive names and the marker contract.
assert.match(
guide,
/navop-<version>-linux-x64-portable\.tar\.gz/,
`${guidePath} must name the x86_64 portable archive`,
);
assert.match(
guide,
/navop-<version>-linux-arm64-portable\.tar\.gz/,
`${guidePath} must name the arm64 portable archive`,
);
assert.match(
guide,
/navop\.portable/,
`${guidePath} must document the marker file`,
);
assert.match(
guide,
/--portable\b/,
`${guidePath} must document the --portable flag`,
);
assert.match(
guide,
/NAVOP_PORTABLE/,
`${guidePath} must document the NAVOP_PORTABLE environment variable`,
);
}
});
test("glibc baseline checker rejects binaries above the configured version", () => {
const checker = "script/check-linux-glibc-baseline.sh";
assert.ok(fs.existsSync(checker), `${checker} must exist`);
@@ -545,7 +727,7 @@ test("Windows release publishes versioned Win32 artifacts while preserving updat
);
assert.match(
release,
/all\) matrix="\[\$macos_arm64,\$macos_x64,\$linux_x64,\$linux_x64_portable,\$linux_arm64,\$linux_arm64_portable,\$windows_x64,\$windows_x86\]"/,
/all\) matrix="\[\$macos_arm64,\$macos_x64,\$linux_x64,\$linux_arm64,\$windows_x64,\$windows_x86\]"/,
);
assert.match(
release,
@@ -801,8 +983,9 @@ test("GitHub and R2 publish every installer while the updater manifest remains c
/name: navop-\$\{\{ matrix\.public_label \}\}-packages[\s\S]*?navop-\*-\$\{\{ matrix\.public_label \}\}\.msi/,
);
assert.match(release, /new_files=\(artifacts\/navop-\* artifacts\/navop_\*\)/);
assert.match(release, /navop-aarch64-unknown-linux-gnu-portable\.tar\.gz/);
assert.match(release, /navop-x86_64-unknown-linux-gnu-portable\.tar\.gz/);
// Every asset, including the Linux GPU dependency stack, is matched by the
// release globs rather than being enumerated by hand.
assert.match(release, /navop-\*-\$\{\{ matrix\.public_label \}\}-gpu-stack\.tar\.gz/);
assert.match(upload, /--pattern "navop-\*"/);
assert.match(upload, /--pattern "navop_\*"/);
assert.match(upload, /release_files=\(artifacts\/navop-\* artifacts\/navop_\*\)/);
@@ -814,6 +997,15 @@ test("GitHub and R2 publish every installer while the updater manifest remains c
assert.match(upload, /publicUpdaterAlternatives/);
assert.match(upload, /`navop-\$\{version\}-win32\.zip`/);
assert.match(upload, /\["win32", "i686-pc-windows-msvc"\]/);
// The portable archives and the dependency stack are extra assets for the
// same target, so they must map onto that target instead of falling through
// to "universal", and the distinguishing suffix has to survive in the target
// name so a download page can tell them apart.
assert.match(upload, /fileName\.includes\(`-\$\{label\}-gpu-stack\.`\)/);
assert.match(upload, /fileName\.includes\(`-\$\{label\}-portable\.`\)/);
assert.match(upload, /return `\$\{publicTarget\[1\]\}-portable`/);
assert.match(upload, /return `\$\{publicTarget\[1\]\}-gpu-stack`/);
assert.doesNotMatch(upload, /linux-x64-portable|linux-arm64-portable/);
assert.match(upload, /\*\.dmg\) content_type="application\/x-apple-diskimage"/);
assert.match(upload, /\*\.msi\) content_type="application\/x-msi"/);
assert.match(upload, /\*\.exe\) content_type="application\/vnd\.microsoft\.portable-executable"/);
@@ -916,9 +1108,7 @@ test("release builds are cacheable and individually repairable", () => {
"macos-arm64",
"macos-x64",
"linux-x64",
"linux-x64-portable",
"linux-arm64",
"linux-arm64-portable",
"windows-x64",
"windows-x86",
]) {
@@ -938,7 +1128,7 @@ test("release builds are cacheable and individually repairable", () => {
assert.match(trigger, /-f platform=all/);
assert.match(
release,
/all\) matrix="\[\$macos_arm64,\$macos_x64,\$linux_x64,\$linux_x64_portable,\$linux_arm64,\$linux_arm64_portable,\$windows_x64,\$windows_x86\]"/,
/all\) matrix="\[\$macos_arm64,\$macos_x64,\$linux_x64,\$linux_arm64,\$windows_x64,\$windows_x86\]"/,
);
assert.equal(fs.existsSync(".github/workflows/build-arm-linux.yml"), false);
});