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
This commit is contained in:
Christian Visintin
2026-09-03 13:31:16 +02:00
committed by GitHub
parent 1587d3d625
commit 133ec898d8
12 changed files with 364 additions and 57 deletions
+61 -52
View File
@@ -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 <<EOF
class Termscp < Formula
@@ -293,14 +293,12 @@ jobs:
end
on_linux do
depends_on "dbus"
on_arm do
url "$BASE/termscp-v$VERSION-aarch64-unknown-linux-gnu.tar.gz"
url "$BASE/termscp-v$VERSION-aarch64-unknown-linux-musl.tar.gz"
sha256 "$SHA_LIN_ARM"
end
on_intel do
url "$BASE/termscp-v$VERSION-x86_64-unknown-linux-gnu.tar.gz"
url "$BASE/termscp-v$VERSION-x86_64-unknown-linux-musl.tar.gz"
sha256 "$SHA_LIN_X64"
end
end
@@ -336,9 +334,15 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
token: ${{ secrets.RELEASE_PAT }}
ref: ${{ inputs.dry_run && github.sha || 'main' }}
ref: ${{ needs.prepare.outputs.prepared_ref }}
persist-credentials: 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: 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
+1
View File
@@ -25,6 +25,7 @@ termscp = { path = "/usr/bin/termscp" }
[package.metadata.deb]
maintainer = "Christian Visintin <christian.visintin@veeso.dev>"
copyright = "2025, Christian Visintin <christian.visintin@veeso.dev>"
depends = ""
extended-description-file = "docs/misc/README.deb.txt"
[features]
+6
View File
@@ -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
Vendored Executable
+52
View File
@@ -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 <target-triple>
set -eu
IMAGE="rust:1.98-alpine3.22"
TARGET="${1:-}"
if [ -z "$TARGET" ]; then
echo "usage: $0 <target-triple>" >&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
+143
View File
@@ -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 <<EOF
[patch.crates-io]
pavao-src = { path = "/tmp/pavao-src" }
EOF
cargo update -p "pavao-src@$PAVAO_SRC_VERSION"
export PKG_CONFIG_ALL_STATIC=1
export PKG_CONFIG_PATH=/tmp/native/pkgconfig:/tmp/native/nettle/lib/pkgconfig:/usr/lib/pkgconfig
export RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-static"
cargo build --locked --release --target "$TARGET" --features smb-vendored
# -- prove the binary is static: no interpreter, no shared libraries
file "target/$TARGET/release/termscp"
if readelf -l "target/$TARGET/release/termscp" > /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
+20 -1
View File
@@ -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
+4
View File
@@ -117,6 +117,10 @@ pacman -S termscp
### 依赖 ❗
官方 Linux 二进制文件和 `.deb` 包静态链接了 musl,**没有任何运行时依赖**:可在任意发行版、任意 glibc 版本上运行。
以下依赖仅在从源码构建 termscp 时需要:
- **Linux** 用户:
- libdbus-1
- pkg-config
+17 -1
View File
@@ -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
+5
View File
@@ -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:
+3 -1
View File
@@ -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 \
+5 -2
View File
@@ -72,9 +72,12 @@ const methods = [
Update anytime with <span class="text-green">termscp --update</span>.
</p>
<p class="mt-2 text-sm text-overlay">
Linux build deps: <span class="text-text">libdbus-1</span>, <span
Official Linux binaries and .deb packages are statically linked against <span
class="text-text">musl</span
>: no runtime dependencies, any distribution, any glibc version. Building
from source still needs <span class="text-text">libdbus-1</span>, <span
class="text-text">pkg-config</span
>, <span class="text-text">libsmbclient</span>. More details in the <a
> and <span class="text-text">libsmbclient</span>. More details in the <a
href={DOCS_URL}
class="text-blue hover:underline">docs</a
>.
+47
View File
@@ -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<UpdateStatus, UpdateError> {
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(