From 133ec898d8e3af7a9e4a80574f969e4c9de3f911 Mon Sep 17 00:00:00 2001
From: Christian Visintin
Date: Thu, 3 Sep 2026 13:31:16 +0200
Subject: [PATCH] ci(release): build Linux artifacts as static musl binaries
(#447)
Build Linux release artifacts as statically linked musl binaries for x86_64 and aarch64, update packaging and updater handling, and document the reduced runtime requirements.
Keep release version preparation locked without refreshing dependencies or committing Cargo.lock.
Closes #440
---
.github/workflows/release.yml | 113 ++++++++--------
Cargo.toml | 1 +
README.md | 6 +
dist/release/build_musl.sh | 52 ++++++++
dist/release/build_musl_container.sh | 143 +++++++++++++++++++++
docs/en-US/getting-started/installation.md | 21 ++-
docs/zh-CN/README.md | 4 +
docs/zh-CN/getting-started/installation.md | 18 ++-
just/build.just | 5 +
just/code_check.just | 4 +-
site/src/pages/install.astro | 7 +-
src/system/auto_update.rs | 47 +++++++
12 files changed, 364 insertions(+), 57 deletions(-)
create mode 100755 dist/release/build_musl.sh
create mode 100755 dist/release/build_musl_container.sh
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5413849..8dd49a1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -21,6 +21,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
version: ${{ inputs.version }}
+ prepared_ref: ${{ steps.prepared-ref.outputs.ref }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -44,7 +45,7 @@ jobs:
git config user.email "christian.visintin@veeso.dev"
- name: Install git-cliff
- uses: taiki-e/install-action@37f7c5781271959fb65b6b35224e28652ff2b63d # v2.87.0
+ uses: taiki-e/install-action@e67fa11c4b9316fa714ddf0abed07a0c3143b95b # v2.87.4
with:
tool: git-cliff
@@ -66,6 +67,9 @@ jobs:
VERSION: ${{ inputs.version }}
run: git-cliff --unreleased --tag "v$VERSION" --strip header -o RELEASE_NOTES.md
+ - name: Verify Cargo.lock is unchanged
+ run: git diff --exit-code -- Cargo.lock
+
- name: Upload release notes
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
@@ -84,10 +88,22 @@ jobs:
VERSION: ${{ inputs.version }}
run: |
rm -f RELEASE_NOTES.md
- git add -A
+ git add -A -- . ':!Cargo.lock'
+ git diff --cached --exit-code -- Cargo.lock
git commit -m "chore: release v$VERSION"
git push origin HEAD:main
+ - name: Export prepared ref
+ id: prepared-ref
+ env:
+ DRY_RUN: ${{ inputs.dry_run }}
+ run: |
+ if [ "$DRY_RUN" = "true" ]; then
+ echo "ref=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
+ else
+ echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+ fi
+
build:
needs: prepare
name: build-${{ matrix.target }}
@@ -95,14 +111,12 @@ jobs:
fail-fast: false
matrix:
include:
- - target: x86_64-unknown-linux-gnu
- os: ubuntu-latest
+ - target: x86_64-unknown-linux-musl
+ os: ubuntu-24.04
kind: linux
- deb_suffix: amd64
- - target: aarch64-unknown-linux-gnu
+ - target: aarch64-unknown-linux-musl
os: ubuntu-24.04-arm
kind: linux
- deb_suffix: arm64
- target: aarch64-apple-darwin
os: macos-latest
kind: macos
@@ -125,58 +139,44 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- ref: ${{ inputs.dry_run && github.sha || 'main' }}
+ ref: ${{ needs.prepare.outputs.prepared_ref }}
persist-credentials: false
fetch-depth: 0
fetch-tags: true
+ - name: Prepare release version
+ shell: bash
+ run: |
+ dist/release/bump_version.sh "$VERSION" "$(date +%F)"
+ cargo update --package termscp --precise "$VERSION"
+ cargo metadata --locked --no-deps --format-version 1 > /dev/null
- name: Install just
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
- name: Install Rust target
- if: matrix.kind != 'windows'
+ if: matrix.kind == 'macos'
run: rustup target add "$TARGET"
- name: Install Rust target
if: matrix.kind == 'windows'
run: rustup target add "$env:TARGET"
- # ---- Linux: native per-arch build (x86_64 on ubuntu-latest, aarch64 on ubuntu-24.04-arm) ----
- - name: Install dependencies (Linux)
+ # ---- Linux: static musl build in a pinned Alpine container ----
+ - name: Install cargo-deb (Linux)
if: matrix.kind == 'linux'
- run: |
- sudo apt-get update
- sudo apt-get install -y \
- make \
- libgit2-dev \
- build-essential \
- pkg-config \
- libbsd-dev \
- libcap-dev \
- libcups2-dev \
- libgnutls28-dev \
- libicu-dev \
- libjansson-dev \
- libkeyutils-dev \
- libldap2-dev \
- zlib1g-dev \
- libpam0g-dev \
- libacl1-dev \
- libarchive-dev \
- flex \
- bison \
- libntirpc-dev \
- libtracker-sparql-3.0-dev \
- libglib2.0-dev \
- libdbus-1-dev \
- libsasl2-dev \
- libunistring-dev \
- cpanminus
- sudo cpanm Parse::Yapp::Driver
- cargo install cargo-deb
+ run: cargo install cargo-deb --locked
- name: Build (Linux)
if: matrix.kind == 'linux'
- run: just build_release "$TARGET" "--features smb-vendored"
+ run: just build_musl "$TARGET"
- name: Build deb (Linux)
if: matrix.kind == 'linux'
- run: just package_deb "$TARGET"
+ run: cargo deb --locked --no-build --target "$TARGET" --features smb-vendored
+ - name: Verify deb declares no runtime dependencies (Linux)
+ if: matrix.kind == 'linux'
+ run: |
+ deb=$(ls target/"$TARGET"/debian/*.deb)
+ depends=$(dpkg-deb -f "$deb" Depends)
+ if [ -n "$depends" ]; then
+ echo "static deb must have no Depends, got: $depends" >&2
+ exit 1
+ fi
# ---- macOS ----
- name: Install deps (macOS)
@@ -229,7 +229,7 @@ jobs:
if-no-files-found: error
publish-homebrew:
- needs: [prepare, build]
+ needs: [prepare, build, release]
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.prepare.outputs.version }}
@@ -255,8 +255,8 @@ jobs:
cd "$GITHUB_WORKSPACE"
SHA_MAC_ARM=$(cat dl/aarch64-apple-darwin.sha256)
SHA_MAC_X64=$(cat dl/x86_64-apple-darwin.sha256)
- SHA_LIN_ARM=$(cat dl/aarch64-unknown-linux-gnu.sha256)
- SHA_LIN_X64=$(cat dl/x86_64-unknown-linux-gnu.sha256)
+ SHA_LIN_ARM=$(cat dl/aarch64-unknown-linux-musl.sha256)
+ SHA_LIN_X64=$(cat dl/x86_64-unknown-linux-musl.sha256)
BASE="https://github.com/veeso/termscp/releases/latest/download"
cat > tap/Formula/termscp.rb < /dev/null
+
- name: Download build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
@@ -399,10 +403,15 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- ref: main
+ ref: ${{ needs.prepare.outputs.prepared_ref }}
persist-credentials: false
fetch-depth: 0
fetch-tags: true
+ - name: Prepare release version
+ run: |
+ dist/release/bump_version.sh "$VERSION" "$(date +%F)"
+ cargo update --package termscp --precise "$VERSION"
+ cargo metadata --locked --no-deps --format-version 1 > /dev/null
- name: Install just
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
diff --git a/Cargo.toml b/Cargo.toml
index 287cdc3..c89cc3d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -25,6 +25,7 @@ termscp = { path = "/usr/bin/termscp" }
[package.metadata.deb]
maintainer = "Christian Visintin "
copyright = "2025, Christian Visintin "
+depends = ""
extended-description-file = "docs/misc/README.deb.txt"
[features]
diff --git a/README.md b/README.md
index 0d1577a..881d2c3 100644
--- a/README.md
+++ b/README.md
@@ -117,6 +117,12 @@ For more information or other platforms, please visit [termscp.rs](https://terms
### Requirements ❗
+The official Linux binaries and `.deb` packages are statically linked against
+musl and have **no runtime requirements**: they run on any distribution and
+any glibc version.
+
+These are only required to build termscp from source:
+
- **Linux** users:
- libdbus-1
- pkg-config
diff --git a/dist/release/build_musl.sh b/dist/release/build_musl.sh
new file mode 100755
index 0000000..ed2188a
--- /dev/null
+++ b/dist/release/build_musl.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env sh
+# Builds a static musl termscp release binary for the given target triple
+# inside a pinned Alpine container.
+#
+# Usage: dist/release/build_musl.sh
+set -eu
+
+IMAGE="rust:1.98-alpine3.22"
+
+TARGET="${1:-}"
+if [ -z "$TARGET" ]; then
+ echo "usage: $0 " >&2
+ exit 2
+fi
+
+case "$TARGET" in
+ x86_64-unknown-linux-musl) PLATFORM="linux/amd64" ;;
+ aarch64-unknown-linux-musl) PLATFORM="linux/arm64" ;;
+ *)
+ echo "unsupported target: $TARGET" >&2
+ exit 2
+ ;;
+esac
+
+WORKSPACE="$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)"
+
+# The container appends a [patch.crates-io] section to Cargo.toml and updates
+# Cargo.lock; keep byte-exact copies so the workspace is clean afterwards.
+BACKUP_DIR="$(mktemp -d)"
+cp -p "$WORKSPACE/Cargo.toml" "$BACKUP_DIR/Cargo.toml"
+cp -p "$WORKSPACE/Cargo.lock" "$BACKUP_DIR/Cargo.lock"
+
+restore_manifests() {
+ cp -p "$BACKUP_DIR/Cargo.toml" "$WORKSPACE/Cargo.toml"
+ cp -p "$BACKUP_DIR/Cargo.lock" "$WORKSPACE/Cargo.lock"
+ rm -rf "$BACKUP_DIR"
+}
+trap restore_manifests EXIT
+
+HOST_UID="$(id -u)"
+HOST_GID="$(id -g)"
+export TARGET HOST_UID HOST_GID
+
+docker run --rm \
+ --platform "$PLATFORM" \
+ --env TARGET \
+ --env HOST_UID \
+ --env HOST_GID \
+ --volume "$WORKSPACE:/work" \
+ --workdir /work \
+ "$IMAGE" \
+ sh /work/dist/release/build_musl_container.sh
diff --git a/dist/release/build_musl_container.sh b/dist/release/build_musl_container.sh
new file mode 100755
index 0000000..9074690
--- /dev/null
+++ b/dist/release/build_musl_container.sh
@@ -0,0 +1,143 @@
+#!/usr/bin/env sh
+# Builds a static musl termscp binary. Runs INSIDE the Alpine container
+# started by dist/release/build_musl.sh; /work is the mounted workspace.
+#
+# Required environment: TARGET, HOST_UID, HOST_GID.
+set -eux
+
+NETTLE_VERSION="3.10.1"
+GNUTLS_VERSION="3.8.13"
+PAVAO_SRC_VERSION="4.24.6"
+
+cleanup() {
+ chown -R "$HOST_UID:$HOST_GID" /work
+}
+trap cleanup EXIT
+
+apk add --no-cache \
+ bison \
+ binutils \
+ build-base \
+ file \
+ flex \
+ git \
+ gnutls-dev \
+ libgit2-dev \
+ libgit2-static \
+ libunistring-dev \
+ libunistring-static \
+ linux-headers \
+ openssl-dev \
+ openssl-libs-static \
+ perl \
+ perl-parse-yapp \
+ pkgconf \
+ python3 \
+ wget \
+ xz \
+ zlib-dev \
+ zlib-static
+
+rustup target add "$TARGET"
+cargo fetch --locked
+
+NATIVE_CFLAGS="-O2 -fPIC"
+if [ "$TARGET" = "aarch64-unknown-linux-musl" ]; then
+ NATIVE_CFLAGS="$NATIVE_CFLAGS -mno-outline-atomics"
+fi
+export CFLAGS="$NATIVE_CFLAGS"
+
+# -- static nettle (GnuTLS crypto backend); mini-gmp avoids a GMP dependency
+mkdir -p /tmp/native
+wget -q "https://ftp.gnu.org/gnu/nettle/nettle-$NETTLE_VERSION.tar.gz" \
+ -O /tmp/native/nettle.tar.gz
+tar -xzf /tmp/native/nettle.tar.gz -C /tmp/native
+cd "/tmp/native/nettle-$NETTLE_VERSION"
+./configure \
+ --prefix=/tmp/native/nettle \
+ --disable-shared \
+ --enable-static \
+ --disable-documentation \
+ --enable-mini-gmp
+make -j"$(getconf _NPROCESSORS_ONLN)"
+make install
+
+# -- static GnuTLS; every optional backend is disabled so nothing links
+# against a shared library
+wget -q "https://www.gnupg.org/ftp/gcrypt/gnutls/v3.8/gnutls-$GNUTLS_VERSION.tar.xz" \
+ -O /tmp/native/gnutls.tar.xz
+tar -xf /tmp/native/gnutls.tar.xz -C /tmp/native
+cd "/tmp/native/gnutls-$GNUTLS_VERSION"
+PKG_CONFIG_PATH=/tmp/native/nettle/lib/pkgconfig \
+ ./configure \
+ --prefix=/tmp/native/gnutls \
+ --disable-shared \
+ --enable-static \
+ --disable-doc \
+ --disable-tests \
+ --disable-nls \
+ --disable-hardware-acceleration \
+ --with-nettle-mini \
+ --with-included-libtasn1 \
+ --with-included-unistring \
+ --without-idn \
+ --without-p11-kit \
+ --without-brotli \
+ --without-zstd \
+ --without-zlib
+make -j"$(getconf _NPROCESSORS_ONLN)"
+make install
+
+# -- flatten gnutls.pc: pkg-config must hand the linker the static archives
+# directly, with no Requires.private chain to resolve
+mkdir -p /tmp/native/pkgconfig
+sed \
+ -e "s#^Libs:.*#Libs: -L/tmp/native/gnutls/lib -lgnutls -latomic -L/tmp/native/nettle/lib -lhogweed -lnettle#" \
+ -e "/^Requires.private:/d" \
+ -e "s#^Cflags:.*#Cflags: -I/tmp/native/gnutls/include -I/tmp/native/nettle/include#" \
+ /tmp/native/gnutls/lib/pkgconfig/gnutls.pc \
+ > /tmp/native/pkgconfig/gnutls.pc
+
+# -- pavao-src: Samba's replacement library omits two sources that musl needs
+cd /work
+PAVAO_SRC=$(find "${CARGO_HOME:-/usr/local/cargo}/registry/src" \
+ -type d -name "pavao-src-$PAVAO_SRC_VERSION" -print -quit)
+test -n "$PAVAO_SRC"
+cp -R "$PAVAO_SRC" /tmp/pavao-src
+perl -0pi -e "s#( \\\"lib/replace/replace\\.c\\\",\\n)#\$1 \\\"lib/replace/closefrom.c\\\",\\n \\\"lib/replace/strptime.c\\\",\\n#" \
+ /tmp/pavao-src/src/lib.rs
+
+cat >> Cargo.toml < /tmp/program-headers.txt; then
+ cat /tmp/program-headers.txt
+else
+ status=$?
+ cat /tmp/program-headers.txt
+ exit "$status"
+fi
+if readelf -d "target/$TARGET/release/termscp" > /tmp/dynamic-section.txt; then
+ cat /tmp/dynamic-section.txt
+else
+ status=$?
+ cat /tmp/dynamic-section.txt
+ exit "$status"
+fi
+if grep -q INTERP /tmp/program-headers.txt; then
+ exit 1
+fi
+if grep -q NEEDED /tmp/dynamic-section.txt; then
+ exit 1
+fi
diff --git a/docs/en-US/getting-started/installation.md b/docs/en-US/getting-started/installation.md
index 9264b69..9d870e7 100644
--- a/docs/en-US/getting-started/installation.md
+++ b/docs/en-US/getting-started/installation.md
@@ -44,9 +44,28 @@ Install termscp from the official repositories:
pacman -S termscp
```
+## Official binaries
+
+Official release binaries are published for these targets:
+
+- GNU/Linux:
+ - `x86_64-unknown-linux-musl`
+ - `aarch64-unknown-linux-musl`
+- macOS:
+ - `x86_64-apple-darwin`
+ - `aarch64-apple-darwin`
+- Windows:
+ - `x86_64-pc-windows-msvc`
+ - `aarch64-pc-windows-msvc`
+
+The Linux binaries, and the `.deb` package built from them, are statically
+linked against musl. They have no runtime dependencies: they run on any Linux
+distribution and any glibc version, with no system packages to install.
+
## Requirements
-The following system dependencies are required to run termscp.
+The official binaries do not require these dependencies. They are needed only
+to build termscp from source, for example with `cargo install termscp`:
- Linux users:
- libdbus-1
diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md
index 3404e7f..beab57b 100644
--- a/docs/zh-CN/README.md
+++ b/docs/zh-CN/README.md
@@ -117,6 +117,10 @@ pacman -S termscp
### 依赖 ❗
+官方 Linux 二进制文件和 `.deb` 包静态链接了 musl,**没有任何运行时依赖**:可在任意发行版、任意 glibc 版本上运行。
+
+以下依赖仅在从源码构建 termscp 时需要:
+
- **Linux** 用户:
- libdbus-1
- pkg-config
diff --git a/docs/zh-CN/getting-started/installation.md b/docs/zh-CN/getting-started/installation.md
index 0432949..81be7d5 100644
--- a/docs/zh-CN/getting-started/installation.md
+++ b/docs/zh-CN/getting-started/installation.md
@@ -42,9 +42,25 @@ pkgin install termscp
pacman -S termscp
```
+## 官方二进制文件
+
+官方发布的二进制文件支持以下目标:
+
+- GNU/Linux:
+ - `x86_64-unknown-linux-musl`
+ - `aarch64-unknown-linux-musl`
+- macOS:
+ - `x86_64-apple-darwin`
+ - `aarch64-apple-darwin`
+- Windows:
+ - `x86_64-pc-windows-msvc`
+ - `aarch64-pc-windows-msvc`
+
+Linux 二进制文件以及由其构建的 `.deb` 包都静态链接了 musl。它们没有任何运行时依赖:可在任意 Linux 发行版、任意 glibc 版本上运行,无需安装任何系统软件包。
+
## 系统要求
-运行 termscp 需要以下系统依赖。
+官方二进制文件不需要以下依赖。它们仅在从源码构建 termscp 时才需要,例如使用 `cargo install termscp`:
- Linux 用户:
- libdbus-1
diff --git a/just/build.just b/just/build.just
index 4983419..f9b1f26 100644
--- a/just/build.just
+++ b/just/build.just
@@ -17,6 +17,11 @@ build_crates_release:
build_release target features="":
cargo build --locked --release --target {{ target }} {{ features }}
+# Build a static musl Linux release binary in a pinned Alpine container
+[group('build')]
+build_musl target:
+ dist/release/build_musl.sh {{ target }}
+
# Package an already-built Linux release as a Debian package
[group('build')]
package_deb target:
diff --git a/just/code_check.just b/just/code_check.just
index 961f64e..0b5c1e6 100644
--- a/just/code_check.just
+++ b/just/code_check.just
@@ -35,7 +35,9 @@ setup_githooks:
[group('code_check')]
check_install_scripts:
sh -n install.sh
- shellcheck install.sh
+ sh -n dist/release/build_musl.sh
+ sh -n dist/release/build_musl_container.sh
+ shellcheck install.sh dist/release/build_musl.sh dist/release/build_musl_container.sh
@if command -v pwsh >/dev/null 2>&1; then \
pwsh -NoProfile -Command '$t = $null; $e = $null; $null = [System.Management.Automation.Language.Parser]::ParseFile("install.ps1", [ref]$t, [ref]$e); if ($e) { $e; exit 1 }'; \
else \
diff --git a/site/src/pages/install.astro b/site/src/pages/install.astro
index 621f76a..87bdff9 100644
--- a/site/src/pages/install.astro
+++ b/site/src/pages/install.astro
@@ -72,9 +72,12 @@ const methods = [
Update anytime with termscp --update.
- Linux build deps: libdbus-1, musl: no runtime dependencies, any distribution, any glibc version. Building
+ from source still needs libdbus-1, pkg-config, libsmbclient. More details in the and libsmbclient. More details in the docs.
diff --git a/src/system/auto_update.rs b/src/system/auto_update.rs
index 5b567b6..0ec96b1 100644
--- a/src/system/auto_update.rs
+++ b/src/system/auto_update.rs
@@ -50,6 +50,20 @@ impl Update {
self
}
+ /// Maps a build target triple onto the target triple used to name the
+ /// official release assets.
+ ///
+ /// Official Linux binaries are statically linked against musl, so a
+ /// termscp built against glibc (for example via `cargo install`) must
+ /// still download the `-musl` asset.
+ fn map_release_target(target: &str) -> String {
+ if target.contains("-linux-") {
+ target.replace("-gnu", "-musl")
+ } else {
+ target.to_string()
+ }
+ }
+
/// Installs the latest available release using the configured update options.
pub fn upgrade(self) -> Result {
info!("Updating termscp...");
@@ -58,6 +72,7 @@ impl Update {
.repo_owner("veeso")
.repo_name("termscp")
.bin_name("termscp")
+ .target(&Self::map_release_target(self_update::get_target()))
.current_version(cargo_crate_version!())
.no_confirm(!self.ask_confirm)
.show_download_progress(self.progress)
@@ -180,6 +195,38 @@ mod test {
assert_eq!(upd.progress, true);
}
+ #[test]
+ fn should_map_linux_release_target_to_musl() {
+ assert_eq!(
+ Update::map_release_target("x86_64-unknown-linux-gnu"),
+ "x86_64-unknown-linux-musl".to_string()
+ );
+ assert_eq!(
+ Update::map_release_target("aarch64-unknown-linux-gnu"),
+ "aarch64-unknown-linux-musl".to_string()
+ );
+ }
+
+ #[test]
+ fn should_leave_non_gnu_linux_release_target_unchanged() {
+ assert_eq!(
+ Update::map_release_target("x86_64-unknown-linux-musl"),
+ "x86_64-unknown-linux-musl".to_string()
+ );
+ }
+
+ #[test]
+ fn should_leave_other_platform_release_targets_unchanged() {
+ assert_eq!(
+ Update::map_release_target("aarch64-apple-darwin"),
+ "aarch64-apple-darwin".to_string()
+ );
+ assert_eq!(
+ Update::map_release_target("x86_64-pc-windows-msvc"),
+ "x86_64-pc-windows-msvc".to_string()
+ );
+ }
+
#[test]
#[cfg(all(
not(all(