commit 257889f1e28d82b64579487c710f3d5bc90faffd Author: Hermes Agent Date: Thu May 7 22:12:23 2026 +0000 feat: bootstrap static musl build pipeline for iperf3 diff --git a/.gitea/workflows/iperf3.yml b/.gitea/workflows/iperf3.yml new file mode 100644 index 0000000..8ff9639 --- /dev/null +++ b/.gitea/workflows/iperf3.yml @@ -0,0 +1,55 @@ +name: iperf3 static musl build + +on: + push: + branches: + - main + paths: + - .gitea/workflows/iperf3.yml + - scripts/** + - versions/iperf3.version + - README.md + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-and-release: + runs-on: ubuntu-latest + container: + image: alpine:3.22 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Read version + id: meta + shell: sh + run: | + version="$(tr -d ' \n\r' < versions/iperf3.version)" + artifact="iperf3-${version}-linux-amd64-musl-static" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "artifact=${artifact}" >> "$GITHUB_OUTPUT" + + - name: Build static iperf3 + shell: sh + run: | + chmod +x scripts/build_iperf3.sh + ./scripts/build_iperf3.sh "${{ steps.meta.outputs.version }}" "$PWD/dist" + + - name: Publish release assets + shell: sh + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_API_URL: ${{ gitea.api_url }} + REPO_OWNER: ${{ gitea.repository_owner }} + REPO_NAME: static-musl-builds + VERSION: ${{ steps.meta.outputs.version }} + TARGET_SHA: ${{ gitea.sha }} + run: | + python3 scripts/publish_release.py \ + "dist/${{ steps.meta.outputs.artifact }}" \ + "dist/${{ steps.meta.outputs.artifact }}.tar.gz" \ + "dist/${{ steps.meta.outputs.artifact }}.sha256" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3fc2b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +*.pyc +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b3cfcf --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# static-musl-builds + +Automated static `musl` builds for portable Linux binaries. + +Current target: +- `iperf3` (x86_64 / amd64) + +## What this repo does + +- Tracks a pinned upstream version in `versions/` +- Builds a fully static `musl` binary with Gitea Actions +- Publishes versioned release assets to Gitea Releases +- Is designed to grow with more binaries later + +## Current output + +For each `iperf3` release build, the workflow publishes: +- `iperf3--linux-amd64-musl-static` +- `iperf3--linux-amd64-musl-static.tar.gz` +- `iperf3--linux-amd64-musl-static.sha256` + +## Repo layout + +- `versions/iperf3.version` — tracked upstream version +- `scripts/build_iperf3.sh` — static build script +- `scripts/sync_iperf3_version.py` — checks upstream and updates pinned version +- `scripts/publish_release.py` — idempotent release asset publisher for Gitea +- `.gitea/workflows/iperf3.yml` — CI pipeline + +## How updates happen + +1. `scripts/sync_iperf3_version.py` checks the latest upstream iperf3 release. +2. If a newer version exists, it updates `versions/iperf3.version`. +3. A commit is pushed. +4. Gitea Actions builds the static binary and uploads release assets. + +## Manual local update example + +```bash +python3 scripts/sync_iperf3_version.py --update-file +if ! git diff --quiet -- versions/iperf3.version; then + git add versions/iperf3.version + git commit -m "chore(iperf3): bump to $(cat versions/iperf3.version)" + git push +fi +``` + +## Notes + +- The current workflow targets `amd64` first because that is the most broadly useful Linux target. +- The structure is intentionally generic so more binaries can be added later without reworking the repo. diff --git a/scripts/build_iperf3.sh b/scripts/build_iperf3.sh new file mode 100755 index 0000000..2bb5b5d --- /dev/null +++ b/scripts/build_iperf3.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -eu + +VERSION="${1:?usage: build_iperf3.sh [output_dir]}" +OUTPUT_DIR="${2:-$PWD/dist}" +WORKDIR="${TMPDIR:-/tmp}/iperf3-build-${VERSION}" +PREFIX_NAME="iperf3-${VERSION}-linux-amd64-musl-static" + +rm -rf "$WORKDIR" +mkdir -p "$WORKDIR" "$OUTPUT_DIR" +cd "$WORKDIR" + +apk add --no-cache \ + bash \ + build-base \ + curl \ + file \ + linux-headers \ + openssl-dev \ + tar \ + xz \ + zlib-dev + +curl -fsSLO "https://downloads.es.net/pub/iperf/iperf-${VERSION}.tar.gz" +tar -xzf "iperf-${VERSION}.tar.gz" +cd "iperf-${VERSION}" + +export CFLAGS="-O2 -static" +export CPPFLAGS="" +export LDFLAGS="-static" + +./configure \ + --disable-shared \ + --enable-static \ + --without-sctp + +make -j"$(getconf _NPROCESSORS_ONLN || echo 2)" +strip src/iperf3 + +install -m 0755 src/iperf3 "$OUTPUT_DIR/$PREFIX_NAME" +( + cd "$OUTPUT_DIR" + tar -czf "${PREFIX_NAME}.tar.gz" "$PREFIX_NAME" + sha256sum "$PREFIX_NAME" "${PREFIX_NAME}.tar.gz" > "${PREFIX_NAME}.sha256" +) + +file "$OUTPUT_DIR/$PREFIX_NAME" +ldd "$OUTPUT_DIR/$PREFIX_NAME" || true diff --git a/scripts/publish_release.py b/scripts/publish_release.py new file mode 100755 index 0000000..9b62756 --- /dev/null +++ b/scripts/publish_release.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +import json +import mimetypes +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + + +def api_request(method: str, url: str, token: str, data: bytes | None = None, headers: dict | None = None): + req_headers = { + "Authorization": f"token {token}", + "Accept": "application/json", + **(headers or {}), + } + req = urllib.request.Request(url, data=data, headers=req_headers, method=method) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + body = resp.read() + if not body: + return None + return json.loads(body) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", "ignore") + raise RuntimeError(f"HTTP {exc.code} for {method} {url}: {body}") from exc + + +def get_or_create_release(api_base: str, owner: str, repo: str, tag: str, title: str, body: str, token: str, target: str): + tag_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{urllib.parse.quote(tag, safe='')}" + try: + release = api_request("GET", tag_url, token) + if release is not None: + return release + except RuntimeError as exc: + if "HTTP 404" not in str(exc): + raise + + create_url = f"{api_base}/repos/{owner}/{repo}/releases" + payload = { + "tag_name": tag, + "name": title, + "body": body, + "draft": False, + "prerelease": False, + "target_commitish": target, + } + return api_request( + "POST", + create_url, + token, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + +def replace_release_assets(api_base: str, owner: str, repo: str, release: dict, files: list[Path], token: str): + release_id = release["id"] + existing_assets = {asset["name"]: asset for asset in release.get("assets", [])} + + for path in files: + name = path.name + if name in existing_assets: + asset_id = existing_assets[name]["id"] + delete_url = f"{api_base}/repos/{owner}/{repo}/releases/{release_id}/assets/{asset_id}" + api_request("DELETE", delete_url, token) + + upload_url = f"{api_base}/repos/{owner}/{repo}/releases/{release_id}/assets?name={urllib.parse.quote(name, safe='')}" + content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream" + data = path.read_bytes() + api_request( + "POST", + upload_url, + token, + data=data, + headers={"Content-Type": content_type}, + ) + + +def main() -> int: + token = os.environ["GITEA_TOKEN"] + api_base = os.environ["GITEA_API_URL"].rstrip("/") + owner = os.environ["REPO_OWNER"] + repo = os.environ["REPO_NAME"] + version = os.environ["VERSION"] + target = os.environ.get("TARGET_SHA", "main") + files = [Path(p) for p in sys.argv[1:]] + if not files: + raise SystemExit("usage: publish_release.py [file ...]") + missing = [str(p) for p in files if not p.exists()] + if missing: + raise SystemExit(f"missing files: {', '.join(missing)}") + + tag = f"iperf3-v{version}" + title = f"iperf3 {version}" + body = ( + f"Static musl build for iperf3 {version}.\\n\\n" + f"Repository: {owner}/{repo}\\n" + f"Commit: {target}" + ) + + release = get_or_create_release(api_base, owner, repo, tag, title, body, token, target) + replace_release_assets(api_base, owner, repo, release, files, token) + print(f"published_tag={tag}") + for path in files: + print(f"asset={path.name}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sync_iperf3_version.py b/scripts/sync_iperf3_version.py new file mode 100755 index 0000000..0d9e2d5 --- /dev/null +++ b/scripts/sync_iperf3_version.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +import argparse +import json +import re +import sys +import urllib.request +from pathlib import Path + +VERSION_FILE = Path("versions/iperf3.version") +LATEST_RELEASE_URL = "https://api.github.com/repos/esnet/iperf/releases/latest" + + +def fetch_latest_version() -> str: + req = urllib.request.Request( + LATEST_RELEASE_URL, + headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "static-musl-builds-updater", + }, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.load(resp) + tag = data["tag_name"].strip() + version = re.sub(r"^v", "", tag) + if not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*", version): + raise ValueError(f"Unexpected iperf3 version format: {version!r}") + return version + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--update-file", action="store_true", help="Write latest version into versions/iperf3.version if changed") + args = parser.parse_args() + + latest = fetch_latest_version() + current = VERSION_FILE.read_text(encoding="utf-8").strip() if VERSION_FILE.exists() else "" + + print(f"current={current or ''}") + print(f"latest={latest}") + + if current == latest: + print("status=up-to-date") + return 0 + + print("status=update-available") + if args.update_file: + VERSION_FILE.parent.mkdir(parents=True, exist_ok=True) + VERSION_FILE.write_text(latest + "\n", encoding="utf-8") + print(f"updated_file={VERSION_FILE}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/versions/iperf3.version b/versions/iperf3.version new file mode 100644 index 0000000..a91121c --- /dev/null +++ b/versions/iperf3.version @@ -0,0 +1 @@ +3.21