From d6e0110b1e9d77d578c1d3e0d8650cf0a27eb6f0 Mon Sep 17 00:00:00 2001 From: Zied Yousfi Date: Sat, 18 Apr 2026 18:33:59 +0200 Subject: [PATCH 1/8] Enforce dynamic Linux linkage in CI and packaging --- .github/actions/build-linux/action.yml | 40 ++++++++++++++++- .../actions/build-windows-mingw/action.yml | 5 ++- .github/workflows/release.yml | 17 +++++++ build.py | 44 ++++++++++++++++++- docs/developers/README.md | 5 +++ 5 files changed, 107 insertions(+), 4 deletions(-) diff --git a/.github/actions/build-linux/action.yml b/.github/actions/build-linux/action.yml index ead3dac..20dad5d 100644 --- a/.github/actions/build-linux/action.yml +++ b/.github/actions/build-linux/action.yml @@ -18,7 +18,7 @@ inputs: required: false default: "false" package-output: - description: Package headers and the static library into packages/ + description: Package headers, the static library, LICENSE, and vendor/licenses into packages/ required: false default: "false" upload-binaries: @@ -43,6 +43,10 @@ runs: - name: Install dependencies (Linux) shell: bash run: | + # Install shared-library development packages from the runner image. + # These Linux dependencies must stay dynamically linked for + # legal/compliance reasons. build.py enforces that policy and only uses + # pkg-config to discover system headers and shared-library link flags. sudo apt-get update sudo apt-get install -y \ build-essential pkg-config \ @@ -62,16 +66,50 @@ runs: shell: bash run: python build.py integration-binaries + - name: Build Linux linkage probe binary + if: ${{ inputs.build-integration-tests != 'true' }} + shell: bash + run: python build.py integration-binaries + - name: Run unit tests if: ${{ inputs.run-tests == 'true' }} shell: bash run: python build.py test + - name: Verify Linux dependencies stay dynamically linked + shell: bash + run: | + set -euo pipefail + + probe="build/linux/bin/test_integration_listener" + if [ ! -x "$probe" ]; then + echo "::error::Missing Linux linkage probe binary: $probe" + exit 1 + fi + + # This explicit runtime-loader check exists for legal/compliance + # reasons. These dependencies must resolve as shared libraries and must + # not be folded into the produced binaries as static archives. + ldd_output="$(ldd "$probe")" + printf '%s\n' "$ldd_output" + + printf '%s\n' "$ldd_output" | grep -E 'libinput\.so' + printf '%s\n' "$ldd_output" | grep -E 'libudev\.so' + printf '%s\n' "$ldd_output" | grep -E 'libxkbcommon\.so' + + if printf '%s\n' "$ldd_output" | grep -E 'not found'; then + echo "::error::One or more required shared libraries were not resolved by ldd." + exit 1 + fi + - name: Package (Linux) if: ${{ inputs.package-output == 'true' }} shell: bash run: | set -euo pipefail + # Release archives must carry the project license and vendored third- + # party license notices. build.py package is the single source of truth + # for that layout. TAG='${{ inputs.version }}' if [ -z "$TAG" ]; then TAG="${GITHUB_REF#refs/tags/}" diff --git a/.github/actions/build-windows-mingw/action.yml b/.github/actions/build-windows-mingw/action.yml index c335a2d..fffd151 100644 --- a/.github/actions/build-windows-mingw/action.yml +++ b/.github/actions/build-windows-mingw/action.yml @@ -18,7 +18,7 @@ inputs: required: false default: "false" package-output: - description: Package headers and the static library into packages/ + description: Package headers, the static library, LICENSE, and vendor/licenses into packages/ required: false default: "false" upload-binaries: @@ -63,6 +63,9 @@ runs: if: ${{ inputs.package-output == 'true' }} shell: pwsh run: | + # Release archives must carry the project license and vendored third- + # party license notices. build.py package is the single source of truth + # for that layout. $tag = "${{ inputs.version }}" if (-not $tag) { $tag = $env:GITHUB_REF -replace '^refs/tags/', '' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5187413..d24814d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,6 +86,23 @@ jobs: exit 1 fi + - name: Verify packaged license files + shell: bash + run: | + set -euo pipefail + + linux_asset="release-assets/axidev-io-${{ steps.collect-assets.outputs.tag }}-linux-x64.tar.gz" + windows_asset="release-assets/axidev-io-${{ steps.collect-assets.outputs.tag }}-windows-x64.zip" + + # Every release archive must carry the project license plus the full + # vendored license directory so downstream consumers receive the + # third-party notices with the packaged library. + tar -tzf "$linux_asset" | grep -Fx './LICENSE' + tar -tzf "$linux_asset" | grep -Fx './vendor/licenses/stb.txt' + + unzip -Z1 "$windows_asset" | grep -Fx 'LICENSE' + unzip -Z1 "$windows_asset" | grep -Fx 'vendor/licenses/stb.txt' + - name: Create GitHub release id: create-release uses: actions/create-release@v1 diff --git a/build.py b/build.py index cd61984..29b6fb8 100644 --- a/build.py +++ b/build.py @@ -19,6 +19,7 @@ PLATFORM_TAG = "windows" if IS_WINDOWS else "linux" BUILD_ROOT = ROOT / "build" DEFAULT_BUILD_DIR = BUILD_ROOT / PLATFORM_TAG +VENDOR_LICENSES_DIR = ROOT / "vendor" / "licenses" OBJ_DIR_NAME = "obj" BIN_DIR_NAME = "bin" LIB_DIR_NAME = "lib" @@ -82,6 +83,33 @@ def pkg_config_flags(pkg_config: str, flag: str, packages: list[str]) -> list[st return split_flags(output) +def ensure_linux_dynamic_link_flags(flags: list[str], source: str) -> None: + forbidden_prefixes = ("-Wl,-Bstatic", "-Wl,--whole-archive") + forbidden_exact = {"-static", "--static"} + + for flag in flags: + if flag in forbidden_exact: + raise SystemExit( + f"{source} requested static linkage on Linux via {flag}. " + "Linux dependencies must stay dynamically linked for legal/compliance reasons." + ) + if flag.startswith(forbidden_prefixes): + raise SystemExit( + f"{source} requested static linkage on Linux via {flag}. " + "Linux dependencies must stay dynamically linked for legal/compliance reasons." + ) + if flag.endswith(".a") or ".a." in flag or flag.endswith(".a)"): + raise SystemExit( + f"{source} referenced a static archive on Linux via {flag}. " + "Linux dependencies must stay dynamically linked for legal/compliance reasons." + ) + if flag.startswith("-l:") and flag.endswith(".a"): + raise SystemExit( + f"{source} requested a static archive on Linux via {flag}. " + "Linux dependencies must stay dynamically linked for legal/compliance reasons." + ) + + @dataclass class BuildConfig: build_dir: Path @@ -133,12 +161,23 @@ def make_config(build_dir: Path) -> BuildConfig: else: pkg_config = os.environ.get("PKG_CONFIG", "pkg-config") cppflags.append("-DAXIDEV_IO_STATIC") + ensure_linux_dynamic_link_flags(ldflags, "LDFLAGS") + ensure_linux_dynamic_link_flags(ldlibs, "LDLIBS") + + # Legal/compliance policy: + # These Linux dependencies must remain dynamically linked. We only + # discover their compile/link flags from the system at build time and + # rely on the platform loader to resolve the shared libraries at + # runtime. Do not switch this to pkg-config --static, ship .a files, or + # force -static/-Wl,-Bstatic in environment overrides. cppflags.extend( pkg_config_flags(pkg_config, "--cflags", ["libinput", "libudev", "xkbcommon"]) ) - platform_libs.extend( - pkg_config_flags(pkg_config, "--libs", ["libinput", "libudev", "xkbcommon"]) + linux_shared_libs = pkg_config_flags( + pkg_config, "--libs", ["libinput", "libudev", "xkbcommon"] ) + ensure_linux_dynamic_link_flags(linux_shared_libs, "pkg-config --libs") + platform_libs.extend(linux_shared_libs) platform_libs.append("-pthread") library_sources.extend( [ @@ -259,6 +298,7 @@ def package_output(config: BuildConfig, version: str, arch: str) -> Path: packages_dir.mkdir(parents=True, exist_ok=True) copy_tree(ROOT / "include", dist_dir / "include") + copy_tree(VENDOR_LICENSES_DIR, dist_dir / "vendor" / "licenses") copy_file(library_path, dist_dir / "lib" / LIB_FILENAME) copy_file(ROOT / "README.md", dist_dir / "README.md") copy_file(ROOT / "LICENSE", dist_dir / "LICENSE") diff --git a/docs/developers/README.md b/docs/developers/README.md index 78349e8..e1d2d32 100644 --- a/docs/developers/README.md +++ b/docs/developers/README.md @@ -62,3 +62,8 @@ Additional targets: - No CMake, Conan, or vcpkg metadata remains. - The only vendored third-party code is `stb_ds.h`. - Linux dependencies are discovered through `pkg-config`. +- Linux uses system shared libraries for `libinput`, `libudev`, and + `xkbcommon`; they must remain dynamically linked for legal/compliance + reasons. +- `python build.py package` includes the project `LICENSE` and the full + `vendor/licenses/` directory in the release archive. From 3937667ef023de787a8a8fbe9fcfd730162cd33b Mon Sep 17 00:00:00 2001 From: Zied Yousfi Date: Sat, 18 Apr 2026 19:06:48 +0200 Subject: [PATCH 2/8] docs(agents): add repository contribution guidelines - document repo-specific build, packaging, CI, and release constraints - codify Linux dynamic-linkage and release license requirements - add verification guidance for build and packaging changes --- AGENTS.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..788b451 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,78 @@ +# AGENTS.md + +This file defines repository-specific rules for AI agents and other automated +contributors working in `axidev-io`. + +## Start Here + +Before changing build logic, packaging, CI, release workflows, vendored code, +or Linux platform code, read these files first: + +- `docs/developers/README.md` +- `build.py` +- `.github/actions/build-linux/action.yml` +- `.github/workflows/release.yml` + +Do not change build or release behavior until you understand those files. + +## Contribution Rules + +- Keep changes small and targeted. +- Preserve the current build entrypoint: `python build.py`. +- Preserve the current packaging entrypoint: `python build.py package`. +- Preserve the current CI checks unless a human maintainer explicitly asks for a + different policy. +- If a requested change would conflict with the license/compliance rules below, + stop and ask a human maintainer instead of guessing. + +## Linux Dependency Policy + +On Linux, `libinput`, `libudev`, and `xkbcommon` are system dependencies +discovered through `pkg-config`. + +They must remain dynamically linked. + +This is a hard repository policy for legal/compliance reasons. Under no +circumstances should an agent: + +- switch Linux dependency discovery to `pkg-config --static` +- add `-static`, `--static`, `-Wl,-Bstatic`, or similar static-linker flags +- link Linux dependency `.a` archives directly +- vendor `libinput`, `libudev`, or `xkbcommon` into release artifacts +- remove or weaken checks that enforce dynamic linkage + +If a task asks for any of the above, do not implement it without explicit human +approval. + +## Required Enforcement + +These safeguards must remain in place: + +- `build.py` rejects static-linkage attempts for Linux dependency flags. +- Linux CI runs an explicit `ldd` check against the listener integration binary + to confirm that `libinput`, `libudev`, and `libxkbcommon` resolve as shared + libraries at runtime. + +Agents may strengthen these checks, but must not remove or bypass them. + +## Release Packaging Policy + +Release artifacts must include: + +- the project `LICENSE` +- the full `vendor/licenses/` directory + +Agents must not ship release archives that omit those files. + +If packaging changes are made, verify that the release workflow still checks for +the packaged license files before publishing artifacts. + +## Verification + +For build or packaging changes, prefer to verify with: + +- `python -m py_compile build.py` +- `python build.py test` +- Linux CI checks, including the explicit `ldd` validation + +If local verification is not possible, say so clearly in the final summary. From 4ec554163a79ca518b8453c6c7875a36020f0b2f Mon Sep 17 00:00:00 2001 From: Zied Yousfi Date: Sat, 18 Apr 2026 19:08:03 +0200 Subject: [PATCH 3/8] docs(agents): remove redundant repository intro - trim the stale opening paragraph from AGENTS.md - keep the repository-specific guidance and policy sections intact --- AGENTS.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 788b451..2af29c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,5 @@ # AGENTS.md -This file defines repository-specific rules for AI agents and other automated -contributors working in `axidev-io`. - ## Start Here Before changing build logic, packaging, CI, release workflows, vendored code, From a8b5133ca71f7cd7322dfa290812a9fe753abbf1 Mon Sep 17 00:00:00 2001 From: Zied Yousfi Date: Sat, 18 Apr 2026 19:30:17 +0200 Subject: [PATCH 4/8] docs(consumers): clarify Linux system deps and linking - state the expected Linux packages explicitly - note that `axidev-io` may be linked statically or dynamically - document that backend dependencies must come from system shared libraries - add a linker example for consumer applications --- docs/consumers/README.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/consumers/README.md b/docs/consumers/README.md index 5cbc95c..bdb2a3e 100644 --- a/docs/consumers/README.md +++ b/docs/consumers/README.md @@ -13,11 +13,25 @@ python build.py python build.py example ``` -On Linux the project expects system packages for: +On Linux, the project expects the following system packages: -- `libinput` -- `libudev` -- `xkbcommon` +* `libinput` +* `libudev` +* `xkbcommon` + +## Linking + +`axidev-io` itself may be linked statically or dynamically. + +On Linux, backend dependencies are expected to be provided by the system as shared libraries. + +When distributing applications, integrators are responsible for complying with the licenses of any third-party libraries they link against. + +Example: + +```sh +cc main.c -laxidev-io -linput -ludev -lxkbcommon -lpthread +``` ## Basic Usage From b3f64fc59c4b27e6faaf0d226dd574e69562959b Mon Sep 17 00:00:00 2001 From: Zied Yousfi Date: Sat, 18 Apr 2026 19:43:37 +0200 Subject: [PATCH 5/8] docs(release): package consumer guide in release archives - include `docs/consumers/README.md` in packaged Linux and Windows artifacts - update release workflow checks to verify the consumer guide is present - document the added packaged file in developer guidance --- .github/actions/build-linux/action.yml | 8 ++++---- .github/actions/build-windows-mingw/action.yml | 8 ++++---- .github/workflows/release.yml | 9 ++++++--- build.py | 1 + docs/developers/README.md | 2 ++ 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/actions/build-linux/action.yml b/.github/actions/build-linux/action.yml index 20dad5d..6fc9706 100644 --- a/.github/actions/build-linux/action.yml +++ b/.github/actions/build-linux/action.yml @@ -18,7 +18,7 @@ inputs: required: false default: "false" package-output: - description: Package headers, the static library, LICENSE, and vendor/licenses into packages/ + description: Package headers, the static library, consumer docs, LICENSE, and vendor/licenses into packages/ required: false default: "false" upload-binaries: @@ -107,9 +107,9 @@ runs: shell: bash run: | set -euo pipefail - # Release archives must carry the project license and vendored third- - # party license notices. build.py package is the single source of truth - # for that layout. + # Release archives must carry the consumer guide, project license, and + # vendored third-party license notices. build.py package is the single + # source of truth for that layout. TAG='${{ inputs.version }}' if [ -z "$TAG" ]; then TAG="${GITHUB_REF#refs/tags/}" diff --git a/.github/actions/build-windows-mingw/action.yml b/.github/actions/build-windows-mingw/action.yml index fffd151..b2b0cc2 100644 --- a/.github/actions/build-windows-mingw/action.yml +++ b/.github/actions/build-windows-mingw/action.yml @@ -18,7 +18,7 @@ inputs: required: false default: "false" package-output: - description: Package headers, the static library, LICENSE, and vendor/licenses into packages/ + description: Package headers, the static library, consumer docs, LICENSE, and vendor/licenses into packages/ required: false default: "false" upload-binaries: @@ -63,9 +63,9 @@ runs: if: ${{ inputs.package-output == 'true' }} shell: pwsh run: | - # Release archives must carry the project license and vendored third- - # party license notices. build.py package is the single source of truth - # for that layout. + # Release archives must carry the consumer guide, project license, and + # vendored third-party license notices. build.py package is the single + # source of truth for that layout. $tag = "${{ inputs.version }}" if (-not $tag) { $tag = $env:GITHUB_REF -replace '^refs/tags/', '' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d24814d..5f42657 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,12 +94,15 @@ jobs: linux_asset="release-assets/axidev-io-${{ steps.collect-assets.outputs.tag }}-linux-x64.tar.gz" windows_asset="release-assets/axidev-io-${{ steps.collect-assets.outputs.tag }}-windows-x64.zip" - # Every release archive must carry the project license plus the full - # vendored license directory so downstream consumers receive the - # third-party notices with the packaged library. + # Every release archive must carry the consumer guide, the project + # license, and the full vendored license directory so downstream + # consumers receive both usage guidance and third-party notices with + # the packaged library. + tar -tzf "$linux_asset" | grep -Fx './docs/consumers/README.md' tar -tzf "$linux_asset" | grep -Fx './LICENSE' tar -tzf "$linux_asset" | grep -Fx './vendor/licenses/stb.txt' + unzip -Z1 "$windows_asset" | grep -Fx 'docs/consumers/README.md' unzip -Z1 "$windows_asset" | grep -Fx 'LICENSE' unzip -Z1 "$windows_asset" | grep -Fx 'vendor/licenses/stb.txt' diff --git a/build.py b/build.py index 29b6fb8..44505ed 100644 --- a/build.py +++ b/build.py @@ -300,6 +300,7 @@ def package_output(config: BuildConfig, version: str, arch: str) -> Path: copy_tree(ROOT / "include", dist_dir / "include") copy_tree(VENDOR_LICENSES_DIR, dist_dir / "vendor" / "licenses") copy_file(library_path, dist_dir / "lib" / LIB_FILENAME) + copy_file(ROOT / "docs" / "consumers" / "README.md", dist_dir / "docs" / "consumers" / "README.md") copy_file(ROOT / "README.md", dist_dir / "README.md") copy_file(ROOT / "LICENSE", dist_dir / "LICENSE") diff --git a/docs/developers/README.md b/docs/developers/README.md index e1d2d32..ce8cfde 100644 --- a/docs/developers/README.md +++ b/docs/developers/README.md @@ -67,3 +67,5 @@ Additional targets: reasons. - `python build.py package` includes the project `LICENSE` and the full `vendor/licenses/` directory in the release archive. +- `python build.py package` also includes `docs/consumers/README.md` in the + release archive for downstream integration guidance. From 7f51afbdb5de3ea2f165e898b0bc1962355e771f Mon Sep 17 00:00:00 2001 From: Zied Yousfi Date: Sun, 19 Apr 2026 17:51:33 +0200 Subject: [PATCH 6/8] docs(consumers): add Linux uinput permission setup - document the `/dev/uinput` and `/dev/input/event*` access requirements - add a helper script for setting up `uinput` permissions on Linux - warn against broad `udev` rules that expose raw input devices --- docs/consumers/README.md | 49 +++++++++++++++++++++++++++++ scripts/setup_uinput_permissions.sh | 25 +++++++++++++++ 2 files changed, 74 insertions(+) create mode 100755 scripts/setup_uinput_permissions.sh diff --git a/docs/consumers/README.md b/docs/consumers/README.md index bdb2a3e..5b8a143 100644 --- a/docs/consumers/README.md +++ b/docs/consumers/README.md @@ -33,6 +33,55 @@ Example: cc main.c -laxidev-io -linput -ludev -lxkbcommon -lpthread ``` +## Linux Permissions + +Linux usually needs two different kinds of access: + +- injection needs write access to `/dev/uinput` +- listening needs read access to the relevant `/dev/input/event*` devices + +For injection, load the kernel module and grant a group access to `/dev/uinput` +through `udev`: + +```sh +sudo modprobe uinput +sudo groupadd -f input +sudo usermod -aG input "$USER" +``` + +The Linux integration-test bundle also includes +`scripts/setup_uinput_permissions.sh`, which applies the same setup steps. +Review it before running it on a target system. + +Create `/etc/udev/rules.d/70-axidev-io-uinput.rules` with: + +```udev +KERNEL=="uinput", MODE="0660", GROUP="input", OPTIONS+="static_node=uinput" +``` + +Then reload rules and re-login so the new group membership applies: + +```sh +sudo udevadm control --reload-rules +sudo udevadm trigger /dev/uinput +``` + +After that, `axidev_io_keyboard_initialize()` should be able to open +`/dev/uinput`. On Linux, `axidev_io_keyboard_request_permissions()` only checks +whether access is already available; it does not open a desktop permission +prompt. + +For listening, `libinput` opens device nodes such as `/dev/input/event*`. On +most desktop Linux systems this works when the process runs in the active local +session on `seat0`. If listener startup fails, first confirm the process is +running in a real local login session with access to the input seat. + +Avoid broad `udev` rules that make all `/dev/input/event*` nodes world-readable. +Those devices expose raw keyboard events and can capture sensitive input. If a +headless or service environment needs listener access, grant that access with a +dedicated service account or seat/session setup instead of relaxing permissions +globally. + ## Basic Usage ```c diff --git a/scripts/setup_uinput_permissions.sh b/scripts/setup_uinput_permissions.sh new file mode 100755 index 0000000..47a5666 --- /dev/null +++ b/scripts/setup_uinput_permissions.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +target_user="${SUDO_USER:-${USER:-}}" +if [ -z "$target_user" ] || [ "$target_user" = "root" ]; then + echo "Run this script as a normal user; it will use sudo for the privileged steps." >&2 + exit 1 +fi + +rule_path="/etc/udev/rules.d/70-axidev-io-uinput.rules" +rule_contents='KERNEL=="uinput", MODE="0660", GROUP="input", OPTIONS+="static_node=uinput"' + +sudo modprobe uinput +sudo groupadd -f input +sudo usermod -aG input "$target_user" +printf '%s\n' "$rule_contents" | sudo tee "$rule_path" >/dev/null +sudo udevadm control --reload-rules +sudo udevadm trigger /dev/uinput + +cat < Date: Sun, 19 Apr 2026 17:53:42 +0200 Subject: [PATCH 7/8] ci(release): package integration test artifacts - add `package-integration-tests` support to Linux and Windows build actions - package integration binaries with consumer docs, LICENSE, and vendor licenses - include the Linux uinput permission helper in packaged test archives - update integration release workflow checks for the new zip assets - add `python build.py package-integration-tests` to developer guidance --- .github/actions/build-linux/action.yml | 31 ++++++- .../actions/build-windows-mingw/action.yml | 30 ++++++- .github/workflows/integration-tests.yml | 81 +++++++++++++------ build.py | 55 ++++++++++++- docs/developers/README.md | 1 + 5 files changed, 166 insertions(+), 32 deletions(-) diff --git a/.github/actions/build-linux/action.yml b/.github/actions/build-linux/action.yml index 6fc9706..34a84a0 100644 --- a/.github/actions/build-linux/action.yml +++ b/.github/actions/build-linux/action.yml @@ -21,12 +21,20 @@ inputs: description: Package headers, the static library, consumer docs, LICENSE, and vendor/licenses into packages/ required: false default: "false" + package-integration-tests: + description: Package integration binaries, consumer docs, licenses, and Linux helpers into packages/ + required: false + default: "false" upload-binaries: description: Upload built test binaries as artifacts required: false default: "false" + upload-integration-package: + description: Upload packaged integration test zip artifacts + required: false + default: "false" artifact-name: - description: Artifact name (required when upload-binaries=true) + description: Artifact name used when uploading outputs required: false version: description: Release version override (otherwise uses tag ref) @@ -57,7 +65,7 @@ runs: run: python build.py clean - name: Build library - if: ${{ inputs.run-tests != 'true' && inputs.build-integration-tests != 'true' && inputs.package-output != 'true' }} + if: ${{ inputs.run-tests != 'true' && inputs.build-integration-tests != 'true' && inputs.package-output != 'true' && inputs.package-integration-tests != 'true' }} shell: bash run: python build.py build @@ -116,6 +124,17 @@ runs: fi python build.py package --version "$TAG" --arch '${{ inputs.arch }}' + - name: Package integration tests (Linux) + if: ${{ inputs.package-integration-tests == 'true' }} + shell: bash + run: | + set -euo pipefail + TAG='${{ inputs.version }}' + if [ -z "$TAG" ]; then + TAG="dev" + fi + python build.py package-integration-tests --version "$TAG" --arch '${{ inputs.arch }}' + - name: Upload release package if: ${{ inputs.package-output == 'true' }} uses: actions/upload-artifact@v4 @@ -130,3 +149,11 @@ runs: name: ${{ inputs.artifact-name }} path: | build/linux/bin/test_* + + - name: Upload integration test package + if: ${{ inputs.upload-integration-package == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: | + packages/*.zip diff --git a/.github/actions/build-windows-mingw/action.yml b/.github/actions/build-windows-mingw/action.yml index b2b0cc2..6a6195f 100644 --- a/.github/actions/build-windows-mingw/action.yml +++ b/.github/actions/build-windows-mingw/action.yml @@ -21,12 +21,20 @@ inputs: description: Package headers, the static library, consumer docs, LICENSE, and vendor/licenses into packages/ required: false default: "false" + package-integration-tests: + description: Package integration binaries, consumer docs, and licenses into packages/ + required: false + default: "false" upload-binaries: description: Upload built test binaries as artifacts required: false default: "false" + upload-integration-package: + description: Upload packaged integration test zip artifacts + required: false + default: "false" artifact-name: - description: Artifact name (required when upload-binaries=true) + description: Artifact name used when uploading outputs required: false version: description: Release version override (otherwise uses tag ref) @@ -45,7 +53,7 @@ runs: run: python build.py clean - name: Build library - if: ${{ inputs.run-tests != 'true' && inputs.build-integration-tests != 'true' && inputs.package-output != 'true' }} + if: ${{ inputs.run-tests != 'true' && inputs.build-integration-tests != 'true' && inputs.package-output != 'true' && inputs.package-integration-tests != 'true' }} shell: pwsh run: python build.py build @@ -72,6 +80,16 @@ runs: } python build.py package --version $tag --arch "${{ inputs.arch }}" + - name: Package integration tests (Windows) + if: ${{ inputs.package-integration-tests == 'true' }} + shell: pwsh + run: | + $tag = "${{ inputs.version }}" + if (-not $tag) { + $tag = "dev" + } + python build.py package-integration-tests --version $tag --arch "${{ inputs.arch }}" + - name: Upload release package if: ${{ inputs.package-output == 'true' }} uses: actions/upload-artifact@v4 @@ -86,3 +104,11 @@ runs: name: ${{ inputs.artifact-name }} path: | build/windows/bin/test_*.exe + + - name: Upload integration test package + if: ${{ inputs.upload-integration-package == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: | + packages/*.zip diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 89fb552..e8cceb3 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -7,6 +7,9 @@ on: workflow_dispatch: # no inputs +env: + INTEGRATION_PACKAGE_VERSION: run-${{ github.run_id }} + permissions: contents: write @@ -19,10 +22,10 @@ jobs: - uses: ./.github/actions/build-linux with: arch: x64 - build-tests: "true" - build-integration-tests: "true" - upload-binaries: "true" - artifact-name: integration-test-binary-ubuntu-latest-x64 + package-integration-tests: "true" + upload-integration-package: "true" + artifact-name: integration-test-package-ubuntu-latest-x64 + version: ${{ env.INTEGRATION_PACKAGE_VERSION }} build-windows-x64: name: Build integration binaries (Windows x64) @@ -32,10 +35,10 @@ jobs: - uses: ./.github/actions/build-windows-mingw with: arch: x64 - build-tests: "true" - build-integration-tests: "true" - upload-binaries: "true" - artifact-name: integration-test-binary-windows-latest-x64 + package-integration-tests: "true" + upload-integration-package: "true" + artifact-name: integration-test-package-windows-latest-x64 + version: ${{ env.INTEGRATION_PACKAGE_VERSION }} release: name: Create integration test release @@ -58,28 +61,33 @@ jobs: - name: Prepare release assets run: | + set -euo pipefail mkdir -p release-assets if [ ! -d artifacts ]; then echo "No artifacts downloaded; skipping asset collection." exit 0 fi - while IFS= read -r -d '' file; do - artifact_dir=$(dirname "$file") - os_arch=$(basename "$artifact_dir" | sed 's/^integration-test-binary-//') - if [ -z "$os_arch" ]; then - os_arch=unknown - fi - artifact_name=$(basename "$file") - ext="${artifact_name##*.}" - if [ "$ext" = "$artifact_name" ]; then - base="$artifact_name" - ext="" - else - base="${artifact_name%.*}" - ext=".$ext" + + expected=( + "axidev-io-integration-tests-${INTEGRATION_PACKAGE_VERSION}-linux-x64.zip" + "axidev-io-integration-tests-${INTEGRATION_PACKAGE_VERSION}-windows-x64.zip" + ) + + missing=0 + for name in "${expected[@]}"; do + src="$(find artifacts -type f -name "$name" -print -quit || true)" + if [ -z "$src" ]; then + echo "::error::Missing expected integration-test asset: $name" + missing=1 + continue fi - cp "$file" "release-assets/${base}-${os_arch}${ext}" - done < <(find artifacts -type f \( -name 'test_integration_*' -o -name 'test_integration_*.exe' \) ! -name '*.o' ! -name '*.obj' -print0) + cp "$src" "release-assets/$name" + done + + if [ "$missing" -ne 0 ]; then + echo "::error::One or more integration-test assets are missing." + exit 1 + fi - name: Check for release assets id: check-assets @@ -90,6 +98,31 @@ jobs: echo "has_assets=false" >> $GITHUB_OUTPUT fi + - name: Verify packaged integration test assets + if: steps.check-assets.outputs.has_assets == 'true' + run: | + set -euo pipefail + + linux_asset="release-assets/axidev-io-integration-tests-${INTEGRATION_PACKAGE_VERSION}-linux-x64.zip" + windows_asset="release-assets/axidev-io-integration-tests-${INTEGRATION_PACKAGE_VERSION}-windows-x64.zip" + + unzip -Z1 "$linux_asset" | grep -Fx 'bin/test_integration_sender' + unzip -Z1 "$linux_asset" | grep -Fx 'bin/test_integration_listener' + unzip -Z1 "$linux_asset" | grep -Fx 'scripts/setup_uinput_permissions.sh' + unzip -Z1 "$linux_asset" | grep -Fx 'docs/consumers/README.md' + unzip -Z1 "$linux_asset" | grep -Fx 'LICENSE' + unzip -Z1 "$linux_asset" | grep -Fx 'vendor/licenses/stb.txt' + + zipinfo -l "$linux_asset" | awk '{print $1 " " $NF}' | grep -Fx -- '-rwxr-xr-x bin/test_integration_sender' + zipinfo -l "$linux_asset" | awk '{print $1 " " $NF}' | grep -Fx -- '-rwxr-xr-x bin/test_integration_listener' + zipinfo -l "$linux_asset" | awk '{print $1 " " $NF}' | grep -Fx -- '-rwxr-xr-x scripts/setup_uinput_permissions.sh' + + unzip -Z1 "$windows_asset" | grep -Fx 'bin/test_integration_sender.exe' + unzip -Z1 "$windows_asset" | grep -Fx 'bin/test_integration_listener.exe' + unzip -Z1 "$windows_asset" | grep -Fx 'docs/consumers/README.md' + unzip -Z1 "$windows_asset" | grep -Fx 'LICENSE' + unzip -Z1 "$windows_asset" | grep -Fx 'vendor/licenses/stb.txt' + - name: Generate release metadata if: steps.check-assets.outputs.has_assets == 'true' id: release-tag diff --git a/build.py b/build.py index 44505ed..872f1c0 100644 --- a/build.py +++ b/build.py @@ -43,6 +43,7 @@ Path("tests/test_integration_listener.c"), ] EXAMPLE_SOURCE = Path("examples/example_c.c") +LINUX_PERMISSION_HELPER = Path("scripts/setup_uinput_permissions.sh") def split_flags(value: str | None) -> list[str]: @@ -286,6 +287,22 @@ def copy_file(source: Path, destination: Path) -> None: shutil.copy2(source, destination) +def write_zip_tree(archive_path: Path, source_dir: Path) -> None: + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for file_path in source_dir.rglob("*"): + if not file_path.is_file(): + continue + + archive_name = file_path.relative_to(source_dir).as_posix() + info = zipfile.ZipInfo.from_file(file_path, archive_name) + info.create_system = 3 + info.external_attr = (file_path.stat().st_mode & 0xFFFF) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + + with file_path.open("rb") as handle: + archive.writestr(info, handle.read()) + + def package_output(config: BuildConfig, version: str, arch: str) -> Path: library_path = build_library(config) dist_dir = ROOT / "dist" @@ -306,10 +323,7 @@ def package_output(config: BuildConfig, version: str, arch: str) -> Path: if IS_WINDOWS: archive_path = packages_dir / f"{archive_base}.zip" - with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: - for file_path in dist_dir.rglob("*"): - if file_path.is_file(): - archive.write(file_path, file_path.relative_to(dist_dir)) + write_zip_tree(archive_path, dist_dir) else: archive_path = packages_dir / f"{archive_base}.tar.gz" with tarfile.open(archive_path, "w:gz") as archive: @@ -319,6 +333,34 @@ def package_output(config: BuildConfig, version: str, arch: str) -> Path: return archive_path +def package_integration_tests_output(config: BuildConfig, version: str, arch: str) -> Path: + binaries = [build_binary(config, source) for source in INTEGRATION_TEST_SOURCES] + dist_dir = ROOT / "dist" + packages_dir = ROOT / "packages" + archive_base = f"axidev-io-integration-tests-{version}-{PLATFORM_TAG}-{arch}" + + if dist_dir.exists(): + shutil.rmtree(dist_dir) + dist_dir.mkdir(parents=True, exist_ok=True) + packages_dir.mkdir(parents=True, exist_ok=True) + + for binary in binaries: + copy_file(binary, dist_dir / "bin" / binary.name) + + copy_tree(VENDOR_LICENSES_DIR, dist_dir / "vendor" / "licenses") + copy_file(ROOT / "docs" / "consumers" / "README.md", dist_dir / "docs" / "consumers" / "README.md") + copy_file(ROOT / "README.md", dist_dir / "README.md") + copy_file(ROOT / "LICENSE", dist_dir / "LICENSE") + + if not IS_WINDOWS: + copy_file(LINUX_PERMISSION_HELPER, dist_dir / "scripts" / LINUX_PERMISSION_HELPER.name) + + archive_path = packages_dir / f"{archive_base}.zip" + write_zip_tree(archive_path, dist_dir) + print(f"Created {archive_path}") + return archive_path + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build axidev-io without make.") parser.add_argument( @@ -332,6 +374,7 @@ def parse_args() -> argparse.Namespace: "test-integration", "unit-binaries", "integration-binaries", + "package-integration-tests", "example", "clean", "package", @@ -400,6 +443,10 @@ def main() -> int: package_output(config, args.version, args.arch) return 0 + if args.command == "package-integration-tests": + package_integration_tests_output(config, args.version, args.arch) + return 0 + raise SystemExit(f"Unsupported command: {args.command}") diff --git a/docs/developers/README.md b/docs/developers/README.md index ce8cfde..f6ef15b 100644 --- a/docs/developers/README.md +++ b/docs/developers/README.md @@ -12,6 +12,7 @@ Additional targets: - `python build.py test-unit` - `python build.py test-integration` +- `python build.py package-integration-tests --version run-123 --arch x64` - `python build.py clean` - `python build.py package --version v1.2.3` From 8b0a1cc0b3d96ff4681989c1f0bfdad9af0c2b1d Mon Sep 17 00:00:00 2001 From: Zied Yousfi Date: Sun, 19 Apr 2026 17:54:48 +0200 Subject: [PATCH 8/8] chore(release): bump version to 0.5.1 --- include/axidev-io/c_api.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/include/axidev-io/c_api.h b/include/axidev-io/c_api.h index 1cac6c5..229b25e 100644 --- a/include/axidev-io/c_api.h +++ b/include/axidev-io/c_api.h @@ -9,8 +9,8 @@ #ifndef AXIDEV_IO_VERSION #define AXIDEV_IO_VERSION "0.4.0" #define AXIDEV_IO_VERSION_MAJOR 0 -#define AXIDEV_IO_VERSION_MINOR 4 -#define AXIDEV_IO_VERSION_PATCH 0 +#define AXIDEV_IO_VERSION_MINOR 5 +#define AXIDEV_IO_VERSION_PATCH 1 #endif #ifndef AXIDEV_IO_API