# badge: https://github.com/borgbackup/borg/workflows/CI/badge.svg?branch=master name: CI on: push: branches: [ master ] tags: - '2.*' pull_request: branches: [ master ] paths: - '**.py' - '**.pyx' - '**.c' - '**.h' - '**.yml' - '**.toml' - '**.cfg' - '**.ini' - 'requirements.d/*' - '!docs/**' concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read env: # Force colored tox and pytest output even without a tty - the GitHub # Actions log viewer renders ANSI colors. tox passes both vars through # to pytest (pass_env = ["*"]). PY_COLORS: "1" # pytest TOX_COLORED: "yes" # tox's own output jobs: lint: runs-on: ubuntu-26.04 timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: astral-sh/ruff-action@278981a28ce3188b1e39527901f38254bf3aac89 # v4.1.0 security: runs-on: ubuntu-26.04 timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.11' - name: Install dependencies run: | python -m pip install --upgrade pip pip install bandit[toml] - name: Run Bandit run: | bandit -r src/borg -c pyproject.toml asan_ubsan: runs-on: ubuntu-26.04 timeout-minutes: 25 needs: [lint] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Just fetching one commit is not enough for setuptools-scm, so we fetch all. fetch-depth: 0 fetch-tags: true persist-credentials: false - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' - name: Install system packages run: | sudo apt-get update sudo apt-get install -y pkg-config build-essential sudo apt-get install -y libssl-dev libacl1-dev liblz4-dev - name: Install Python dependencies run: | python -m pip install --upgrade pip pip install -r requirements.d/development.lock.txt - name: Build Borg with ASan/UBSan # Build the C/Cython extensions with AddressSanitizer and UndefinedBehaviorSanitizer enabled. # How this works: # - The -fsanitize=address,undefined flags inject runtime checks into our native code. If a bug is hit # (e.g., buffer overflow, use-after-free, out-of-bounds, or undefined behavior), the sanitizer prints # a detailed error report to stderr, including a stack trace, and forces the process to exit with # non-zero status. In CI, this will fail the step/job so you will notice. # - ASAN_OPTIONS/UBSAN_OPTIONS configure the sanitizers' runtime behavior (see below for meanings). env: CFLAGS: "-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined" CXXFLAGS: "-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined" LDFLAGS: "-fsanitize=address,undefined" # ASAN_OPTIONS controls AddressSanitizer runtime tweaks: # - detect_leaks=0: Disable LeakSanitizer to avoid false positives with CPython/pymalloc in short-lived tests. # - strict_string_checks=1: Make invalid string operations (e.g., over-reads) more likely to be detected. # - check_initialization_order=1: Catch uses that depend on static initialization order (C++). # - detect_stack_use_after_return=1: Detect stack-use-after-return via stack poisoning (may increase overhead). ASAN_OPTIONS: "detect_leaks=0:strict_string_checks=1:check_initialization_order=1:detect_stack_use_after_return=1" # UBSAN_OPTIONS controls UndefinedBehaviorSanitizer runtime: # - print_stacktrace=1: Include a stack trace for UB reports to ease debugging. # Note: UBSan is recoverable by default (process may continue after reporting). If you want CI to # abort immediately and fail on the first UB, add `halt_on_error=1` (e.g., UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1"). UBSAN_OPTIONS: "print_stacktrace=1" # PYTHONDEVMODE enables additional Python runtime checks and warnings. PYTHONDEVMODE: "1" run: pip install -e . - name: Run tests under sanitizers env: ASAN_OPTIONS: "detect_leaks=0:strict_string_checks=1:check_initialization_order=1:detect_stack_use_after_return=1" UBSAN_OPTIONS: "print_stacktrace=1" PYTHONDEVMODE: "1" # Ensure the ASan runtime is loaded first to avoid "ASan runtime does not come first" warnings. # We discover libasan/libubsan paths via gcc and preload them for the Python test process. # the remote tests are slow and likely won't find anything useful run: | set -euo pipefail export LD_PRELOAD="$(gcc -print-file-name=libasan.so):$(gcc -print-file-name=libubsan.so)" echo "Using LD_PRELOAD=$LD_PRELOAD" # the sanitizer runtimes write their reports to stderr, so they still show up per worker pytest -v -n auto --benchmark-skip -k "not remote" native_tests: needs: [lint] permissions: contents: read id-token: write attestations: write strategy: # entries with "allow-failure": true (e.g. prerelease pythons) do not fail # the matrix, see continue-on-error below. fail-fast: true # noinspection YAMLSchemaValidation # "coverage": "1" collects coverage in that entry - we only do that for the # oldest and the newest supported python, measuring is not free, see #9470. matrix: >- ${{ fromJSON( github.event_name == 'pull_request' && '{ "include": [ {"os": "ubuntu-26.04", "python-version": "3.11", "toxenv": "mypy"}, {"os": "ubuntu-26.04", "python-version": "3.11", "toxenv": "docs"}, {"os": "ubuntu-26.04", "python-version": "3.11", "toxenv": "py311-llfuse", "coverage": "1"}, {"os": "ubuntu-26.04", "python-version": "3.12", "toxenv": "py312-pyfuse3", "store_cache": "1"}, {"os": "ubuntu-26.04", "python-version": "3.14", "toxenv": "py314-mfusepy", "coverage": "1"}, {"os": "ubuntu-26.04", "python-version": "3.15-dev", "toxenv": "py315-mfusepy", "allow-failure": true} ] }' || '{ "include": [ {"os": "ubuntu-26.04", "python-version": "3.11", "toxenv": "py311-llfuse", "coverage": "1"}, {"os": "ubuntu-26.04", "python-version": "3.12", "toxenv": "py312-pyfuse3"}, {"os": "ubuntu-26.04", "python-version": "3.13", "toxenv": "py313-mfusepy", "store_cache": "1"}, {"os": "ubuntu-26.04", "python-version": "3.14", "toxenv": "py314-pyfuse3", "coverage": "1", "binary": "borg-linux-glibc243-x86_64-gh"}, {"os": "ubuntu-26.04-arm", "python-version": "3.14", "toxenv": "py314-pyfuse3", "coverage": "1", "binary": "borg-linux-glibc243-arm64-gh"}, {"os": "macos-15", "python-version": "3.14", "toxenv": "py314-none", "coverage": "1", "binary": "borg-macos-15-arm64-gh"}, {"os": "macos-15-intel", "python-version": "3.14", "toxenv": "py314-none", "coverage": "1", "binary": "borg-macos-15-x86_64-gh"}, {"os": "ubuntu-26.04", "python-version": "3.15-dev", "toxenv": "py315-mfusepy", "allow-failure": true} ] }' ) }} env: TOXENV: ${{ matrix.toxenv }} runs-on: ${{ matrix.os }} # Python 3.15 is not released yet, so do not fail the whole matrix if it (or a # dependency not having wheels / not compiling for it yet) breaks. # Remove the "allow-failure" matrix entries once 3.15 is final. continue-on-error: ${{ matrix.allow-failure || false }} # macOS machines can be slow, if overloaded. timeout-minutes: 360 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Just fetching one commit is not enough for setuptools-scm, so we fetch all. fetch-depth: 0 fetch-tags: true persist-credentials: false - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} - name: Cache pip # Not on tags: that is when the release binaries are built and attested, # and they should not be able to pick up a poisoned cache entry. if: ${{ !startsWith(github.ref, 'refs/tags/') }} uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ runner.os }}-${{ runner.arch }}-pip-${{ hashFiles('requirements.d/development.lock.txt') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-pip- - name: Cache tox environments # Not on tags: that is when the release binaries are built and attested, # and they should not be able to pick up a poisoned cache entry. if: ${{ !startsWith(github.ref, 'refs/tags/') }} uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .tox key: ${{ runner.os }}-${{ runner.arch }}-tox-${{ matrix.toxenv }}-${{ hashFiles('requirements.d/development.lock.txt', 'pyproject.toml') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-tox-${{ matrix.toxenv }}- ${{ runner.os }}-${{ runner.arch }}-tox- - name: Install Linux packages if: ${{ runner.os == 'Linux' }} run: | sudo apt-get update sudo apt-get install -y pkg-config build-essential sudo apt-get install -y libssl-dev libacl1-dev liblz4-dev sudo apt-get install -y bash zsh fish # for shell completion tests sudo apt-get install -y rclone openssh-server curl if [[ "$TOXENV" == *"llfuse"* ]]; then sudo apt-get install -y libfuse-dev fuse # Required for Python llfuse module elif [[ "$TOXENV" == *"pyfuse3"* || "$TOXENV" == *"mfusepy"* ]]; then sudo apt-get install -y libfuse3-dev fuse3 # Required for Python pyfuse3 module fi - name: Install macOS packages if: ${{ runner.os == 'macOS' }} run: | brew unlink pkg-config@0.29.2 || true brew bundle install # the runner's unresolvable .local hostname takes macOS ~35s to give up on, # which borg paid per spawned process (import-time fqdn) - hours of test time, # see #9470. .local is mDNS territory, an /etc/hosts entry alone does not help. sudo scutil --set HostName borg-ci-mac echo "127.0.0.1 borg-ci-mac" | sudo tee -a /etc/hosts - name: Configure OpenSSH SFTP server (test only) if: ${{ runner.os == 'Linux' && !contains(matrix.toxenv, 'mypy') && !contains(matrix.toxenv, 'docs') }} run: | sudo mkdir -p /run/sshd sudo useradd -m -s /bin/bash sftpuser || true # Create SSH key for the CI user and authorize it for sftpuser mkdir -p ~/.ssh chmod 700 ~/.ssh test -f ~/.ssh/id_ed25519 || ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519 sudo mkdir -p /home/sftpuser/.ssh sudo chmod 700 /home/sftpuser/.ssh sudo cp ~/.ssh/id_ed25519.pub /home/sftpuser/.ssh/authorized_keys sudo chown -R sftpuser:sftpuser /home/sftpuser/.ssh sudo chmod 600 /home/sftpuser/.ssh/authorized_keys # Allow publickey auth and enable Subsystem sftp sudo sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config sudo sed -i 's/^#\?PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config if ! grep -q '^Subsystem sftp' /etc/ssh/sshd_config; then echo 'Subsystem sftp /usr/lib/openssh/sftp-server' | sudo tee -a /etc/ssh/sshd_config; fi # Ensure host keys exist to avoid slow generation on first sshd start sudo ssh-keygen -A # Start sshd (listen on default 22 inside runner) sudo /usr/sbin/sshd -D & # Add host key to known_hosts so paramiko trusts it ssh-keyscan -H localhost 127.0.0.1 | tee -a ~/.ssh/known_hosts # Start ssh-agent and add our key so paramiko can use the agent eval "$(ssh-agent -s)" ssh-add ~/.ssh/id_ed25519 # The rest test starts "borg serve --rest" over ssh as sftpuser, which runs the borg # under test from the tox venv under $HOME. Allow sftpuser to traverse into the runner # home so it can reach that borg (the venv dirs/files are created world-r/x by tox/pip). sudo chmod o+x "$HOME" # Export SFTP test URL for tox via GITHUB_ENV echo "BORG_TEST_SFTP_REPO=sftp://sftpuser@localhost:22/borg/sftp-repo" >> $GITHUB_ENV echo "BORG_TEST_REST_REPO=rest://sftpuser@localhost:22/borg/rest-repo" >> $GITHUB_ENV - name: Install and configure MinIO S3 server (test only) if: ${{ runner.os == 'Linux' && !contains(matrix.toxenv, 'mypy') && !contains(matrix.toxenv, 'docs') }} run: | set -e arch=$(uname -m) case "$arch" in x86_64|amd64) srv_url=https://dl.min.io/server/minio/release/linux-amd64/minio; cli_url=https://dl.min.io/client/mc/release/linux-amd64/mc ;; aarch64|arm64) srv_url=https://dl.min.io/server/minio/release/linux-arm64/minio; cli_url=https://dl.min.io/client/mc/release/linux-arm64/mc ;; *) echo "Unsupported arch: $arch"; exit 1 ;; esac curl -fsSL -o /usr/local/bin/minio "$srv_url" curl -fsSL -o /usr/local/bin/mc "$cli_url" sudo chmod +x /usr/local/bin/minio /usr/local/bin/mc export PATH=/usr/local/bin:$PATH # Start MinIO on :9000 with default credentials (minioadmin/minioadmin) MINIO_DIR="$GITHUB_WORKSPACE/.minio-data" MINIO_LOG="$GITHUB_WORKSPACE/.minio.log" mkdir -p "$MINIO_DIR" nohup minio server "$MINIO_DIR" --address ":9000" >"$MINIO_LOG" 2>&1 & # Wait for MinIO port to be ready for i in $(seq 1 60); do (echo > /dev/tcp/127.0.0.1/9000) >/dev/null 2>&1 && break; sleep 1; done # Configure client and create bucket mc alias set local http://127.0.0.1:9000 minioadmin minioadmin mc mb --ignore-existing local/borg # Export S3 test URL for tox via GITHUB_ENV echo "BORG_TEST_S3_REPO=s3:minioadmin:minioadmin@http://127.0.0.1:9000/borg/s3-repo" >> $GITHUB_ENV - name: Enable store cache (test only) if: ${{ matrix.store_cache == '1' }} run: echo "BORG_STORE_CACHE=1" >> $GITHUB_ENV - name: Install Python requirements run: | python -m pip install --upgrade pip setuptools wheel pip install -r requirements.d/development.lock.txt - name: Install borgbackup run: | if [[ "$TOXENV" == *"llfuse"* ]]; then pip install -ve ".[llfuse,cockpit,s3,sftp,rclone]" elif [[ "$TOXENV" == *"pyfuse3"* ]]; then pip install -ve ".[pyfuse3,cockpit,s3,sftp,rclone]" elif [[ "$TOXENV" == *"mfusepy"* ]]; then pip install -ve ".[mfusepy,cockpit,s3,sftp,rclone]" else pip install -ve ".[cockpit,s3,sftp,rclone]" fi - name: Build Borg fat binaries (${{ matrix.binary }}) if: ${{ matrix.binary && startsWith(github.ref, 'refs/tags/') }} run: | pip install -r requirements.d/pyinstaller.txt ./scripts/build-borg-using-pyinstaller.sh - name: Smoke-test the built binary (${{ matrix.binary }}) if: ${{ matrix.binary && startsWith(github.ref, 'refs/tags/') }} run: | pushd dist/binary echo "single-file binary" chmod +x borg.exe ./borg.exe -V echo "single-directory binary" chmod +x borg-dir/borg.exe ./borg-dir/borg.exe -V tar czf borg.tgz borg-dir popd # Ensure locally built binary in ./dist/binary/borg-dir is found during tests export PATH="$GITHUB_WORKSPACE/dist/binary/borg-dir:$PATH" echo "borg.exe binary in PATH" borg.exe -V - name: Prepare binaries (${{ matrix.binary }}) if: ${{ matrix.binary && startsWith(github.ref, 'refs/tags/') }} env: BINARY: ${{ matrix.binary }} run: | mkdir -p artifacts if [ -f dist/binary/borg.exe ]; then cp dist/binary/borg.exe "artifacts/$BINARY" fi if [ -f dist/binary/borg.tgz ]; then cp dist/binary/borg.tgz "artifacts/$BINARY.tgz" fi echo "binary files" ls -l artifacts/ - name: Attest binaries provenance (${{ matrix.binary }}) id: attest-binaries if: ${{ matrix.binary && startsWith(github.ref, 'refs/tags/') }} uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: 'artifacts/*' - name: Name the sigstore bundle after the binaries (${{ matrix.binary }}) # The attestation is also stored in the GitHub attestations API, but # fetching it from there needs a recent gh and a GitHub token (see # #10187) - so the sigstore bundle rides along with the binaries and the # release job attaches it to the release as .sigstore.jsonl, just # like it does for the sdist, see .github/workflows/release.yml. # # One attestation covers both the single-file binary and the .tgz, so the # same bundle is stored under both names - one for each asset. if: ${{ matrix.binary && startsWith(github.ref, 'refs/tags/') }} env: BUNDLE_PATH: ${{ steps.attest-binaries.outputs.bundle-path }} run: | set -euxo pipefail # the glob is expanded before the loop body creates any file: for asset in artifacts/*; do cp "$BUNDLE_PATH" "$asset.sigstore.jsonl" done ls -l artifacts/ - name: Upload binaries (${{ matrix.binary }}) if: ${{ matrix.binary && startsWith(github.ref, 'refs/tags/') }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ matrix.binary }} path: artifacts/* if-no-files-found: error - name: run tox env env: # tox passes this through to pytest (pass_env = ["*"]) PYTEST_ADDOPTS: ${{ !matrix.coverage && '--no-cov' || '' }} run: | # do not use fakeroot, but run as root. avoids the dreaded EISDIR sporadic failures. see #2482. #sudo -E bash -c "tox -e py" # Ensure locally built binary in ./dist/binary/borg-dir is found during tests export PATH="$GITHUB_WORKSPACE/dist/binary/borg-dir:$PATH" tox --skip-missing-interpreters - name: Upload test results to Codecov if: ${{ !cancelled() && !contains(matrix.toxenv, 'mypy') && !contains(matrix.toxenv, 'docs') }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 env: OS: ${{ runner.os }} python: ${{ matrix.python-version }} with: token: ${{ secrets.CODECOV_TOKEN }} report_type: test_results env_vars: OS,python files: test-results.xml - name: Upload coverage to Codecov if: ${{ !cancelled() && matrix.coverage }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 env: OS: ${{ runner.os }} python: ${{ matrix.python-version }} with: token: ${{ secrets.CODECOV_TOKEN }} report_type: coverage env_vars: OS,python files: coverage.xml vm_tests: permissions: contents: read id-token: write attestations: write runs-on: ubuntu-26.04 timeout-minutes: 180 needs: [lint] continue-on-error: true strategy: fail-fast: false matrix: # pyver: python the test step installs; part of the pip cache key. # 3.14+ gets coverage's near-zero-overhead sysmon core, see #9470. include: - os: freebsd version: '15.1' display_name: FreeBSD pyver: '3.14' # Controls binary build and provenance attestation on tags do_binaries: true artifact_prefix: borg-freebsd-15-x86_64-gh - os: netbsd version: '11.0' display_name: NetBSD pyver: '3.14' do_binaries: false - os: openbsd version: '7.9' display_name: OpenBSD pyver: '3.13' do_binaries: false # extra RAM for the mfs-backed TMPDIR, see the openbsd test step memory: 12G - os: omnios version: 'r151056' display_name: OmniOS pyver: '3.13' do_binaries: false - os: haiku version: 'r1beta6' display_name: Haiku pyver: '3.14' do_binaries: false steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 fetch-tags: true persist-credentials: false # .pip-cache lives inside the workspace, which cross-platform-actions # rsyncs into the VM and back, so wheels built from sdists in one run # (most of the VM setup time) are reused by the next one. - name: Cache pip-built wheels # Not on tags: that is when the release binaries are built and attested, # and they should not be able to pick up a poisoned cache entry. if: ${{ !startsWith(github.ref, 'refs/tags/') }} uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .pip-cache key: ${{ matrix.os }}-${{ matrix.version }}-py${{ matrix.pyver }}-pip-${{ hashFiles('requirements.d/development.lock.txt') }} restore-keys: | ${{ matrix.os }}-${{ matrix.version }}-py${{ matrix.pyver }}-pip- - name: Start VM for ${{ matrix.display_name }} id: cross_os # a normal boot takes < 1 minute; since 1.4.0 the action bounds # waiting for boot itself, this is just an additional safety net. timeout-minutes: 15 uses: cross-platform-actions/action@faa0c6197e94aacf1c5956460152c8380d3560a5 # v1.5.0 with: operating_system: ${{ matrix.os }} version: ${{ matrix.version }} shell: bash # the default VM size (2 cpus) leaves half of the runner's 4 vcpus idle cpu_count: 4 memory: ${{ matrix.memory || '8G' }} - name: Test on ${{ matrix.display_name }} shell: cpa.sh {0} env: DO_BINARIES: ${{ matrix.do_binaries }} run: | set -euxo pipefail # a hung test must fail instead of stalling the suite until the # job timeout (tox passes all env vars through to pytest). export PYTEST_TIMEOUT=300 # colored tox/pytest output - the workflow-level env vars do not # reach into the VM, so export them here again. export PY_COLORS=1 export TOX_COLORED=yes # see the "Cache pip-built wheels" step export PIP_CACHE_DIR="$PWD/.pip-cache" case "${{ matrix.os }}" in freebsd) export IGNORE_OSVERSION=yes sudo -E pkg update -f sudo -E pkg install -y liblz4 pkgconf sudo -E pkg install -y fusefs-libs sudo -E kldload fusefs sudo -E sysctl vfs.usermount=1 sudo -E chmod 666 /dev/fuse # enable POSIX.1e ACLs on the UFS root fs (covers /tmp), so the ACL tests do not skip (#9144) sudo mount -u -o acls / mount | grep ' / ' | grep -w acls sudo -E pkg install -y rust sudo -E pkg install -y gmake sudo -E pkg install -y git sudo -E pkg install -y python314 py314-sqlite3 sudo ln -sf /usr/local/bin/python3.14 /usr/local/bin/python3 sudo ln -sf /usr/local/bin/python3.14 /usr/local/bin/python sudo ln -sf /usr/local/bin/pip3.14 /usr/local/bin/pip3 sudo ln -sf /usr/local/bin/pip3.14 /usr/local/bin/pip # required for libsodium/pynacl build export MAKE=gmake python -m venv .venv . .venv/bin/activate python -V pip -V python -m pip install --upgrade pip wheel pip install -r requirements.d/development.lock.txt pip install -e ".[mfusepy,cockpit,s3,sftp,rclone]" if [[ "${{ matrix.do_binaries }}" == "true" && "${{ startsWith(github.ref, 'refs/tags/') }}" == "true" ]]; then python -m pip install -r requirements.d/pyinstaller.txt ./scripts/build-borg-using-pyinstaller.sh pushd dist/binary echo "single-file binary" chmod +x borg.exe ./borg.exe -V echo "single-directory binary" chmod +x borg-dir/borg.exe ./borg-dir/borg.exe -V tar czf borg.tgz borg-dir popd mkdir -p artifacts if [ -f dist/binary/borg.exe ]; then cp -v dist/binary/borg.exe artifacts/${{ matrix.artifact_prefix }} fi if [ -f dist/binary/borg.tgz ]; then cp -v dist/binary/borg.tgz artifacts/${{ matrix.artifact_prefix }}.tgz fi fi export PATH="$(pwd)/dist/binary:$PATH" tox -e py314-mfusepy ;; netbsd) arch="$(uname -m)" sudo -E mkdir -p /usr/pkg/etc/pkgin echo "https://ftp.NetBSD.org/pub/pkgsrc/packages/NetBSD/${arch}/11.0/All" | sudo tee /usr/pkg/etc/pkgin/repositories.conf > /dev/null sudo -E pkgin update sudo -E pkgin -y upgrade sudo -E pkgin -y install lz4 git sudo -E pkgin -y install rust sudo -E pkgin -y install pkg-config # python 3.14 for coverage's fast sysmon core, see #9470 sudo -E pkgin -y install py314-pip py314-virtualenv py314-tox sudo -E ln -sf /usr/pkg/bin/python3.14 /usr/pkg/bin/python3 sudo -E ln -sf /usr/pkg/bin/pip3.14 /usr/pkg/bin/pip3 sudo -E ln -sf /usr/pkg/bin/virtualenv-3.14 /usr/pkg/bin/virtualenv3 sudo -E ln -sf /usr/pkg/bin/tox-3.14 /usr/pkg/bin/tox3 # Ensure base system admin tools are on PATH for the non-root shell export PATH="/sbin:/usr/sbin:$PATH" # On the netbsd 11 VM, / is a fs with extended attributes. export TMPDIR="/tmp_eafs" sudo -E mkdir -p ${TMPDIR} sudo -E chmod 1777 ${TMPDIR} touch ${TMPDIR}/testfile lsextattr user ${TMPDIR}/testfile && echo "[xattr] *** xattrs SUPPORTED on ${TMPDIR}! ***" tox3 -e py314-none ;; openbsd) # Put the temp tree (pip/cc build temps, pytest tmp dirs and the # borg test repos in them) on a memory-backed filesystem instead # of the slow FFS disk. Sized generously (the pytest tmp tree is # only cleaned up after the run); the VM gets 12G RAM for this. sudo mkdir -p /mfs # -O2: FFS2 format - mfs defaults to FFS1, whose 32 bit # timestamps silently wrap (breaks test_extract_y2261). sudo mount_mfs -O2 -s 6g swap /mfs sudo chmod 1777 /mfs export TMPDIR=/mfs # 7.9 has no python 3.14 pkg yet - move to 3.14 + update pyver when it ships (#9470) sudo -E pkg_add lz4 git rust openssl%3.5 py3-pip py3-virtualenv py3-tox export BORG_OPENSSL_NAME=eopenssl35 tox -e py313-none ;; omnios) # r151056 has no python-314 pkg yet - move to 3.14 + update pyver when it ships (#9470) sudo pkg install gcc14 git pkg-config python-313 gnu-make gnu-coreutils rust sudo ln -sf /usr/bin/python3.13 /usr/bin/python3 sudo ln -sf /usr/bin/python3.13-config /usr/bin/python3-config sudo python3 -m ensurepip sudo python3 -m pip install virtualenv # On omniOS /tmp is swap-backed tmpfs (small, RAM-bound), so the pip/cargo # build temps and the pytest temp tree quickly exhaust it ("no space left on # device"). /var/tmp is disk-backed (ZFS), so redirect TMPDIR there. export TMPDIR=/var/tmp/borg-ci mkdir -p "$TMPDIR" # show whether xattrs work on the ZFS-backed TMPDIR (they are files in a # hidden per-file attribute directory there, listed via runat(1)) touch "$TMPDIR/testfile" /usr/bin/runat "$TMPDIR/testfile" ls -a && echo "*** xattrs supported on $TMPDIR ***" rm "$TMPDIR/testfile" python3 -m venv .venv . .venv/bin/activate python -V pip -V python -m pip install --upgrade pip wheel pip install -r requirements.d/development.lock.txt # no fuse support on omnios in our tests usually pip install -e . tox -e py313-none ;; haiku) pkgman refresh # already installed: # pkgman install -y git pkgconfig lz4 openssl3 pkgman install -y lz4_devel openssl3_devel pkgman install -y rust_bin python3 -m ensurepip --upgrade python3 -m pip install --upgrade pip wheel python3 -m venv .venv . .venv/bin/activate export PKG_CONFIG_PATH="/system/develop/lib/pkgconfig:/system/lib/pkgconfig:${PKG_CONFIG_PATH:-}" export BORG_LIBLZ4_PREFIX=/system/develop export BORG_OPENSSL_PREFIX=/system/develop # this VM corrupts data every now and then: rustc crashed on crate metadata it # had written itself ("assertion failed: bytes[len] == STR_SENTINEL") and pip # found sha256 mismatches reading big wheels back from its own cache. Build # outside /boot/system/cache/tmp (haiku's /tmp) and run one rustc at a time, # in case that corruption comes from disk or memory pressure. export TMPDIR=/boot/home/borg-tmp mkdir -p "$TMPDIR" export CARGO_TARGET_DIR="$TMPDIR/cargo-target" export CARGO_BUILD_JOBS=1 # retry a pip install that hit a corrupted file, dropping the cached (possibly # corrupted) files first, so one of them does not fail the whole job - and does # not end up in the actions cache, failing every following run, too. pip_install() { for attempt in 1 2 3; do pip install "$@" && return 0 echo "*** pip install failed (attempt $attempt), purging the pip cache and retrying ***" pip cache purge || true done return 1 } pip_install -r requirements.d/development.lock.txt pip_install -e . # troubles with either tox or pytest xdist, so we run pytest manually: pytest -v -n auto -rs --cov=borg --cov-config=pyproject.toml --cov-report=xml --junitxml=test-results.xml --benchmark-skip -k "not remote and not socket" ;; esac - name: Attest provenance id: attest-binaries if: startsWith(github.ref, 'refs/tags/') && matrix.do_binaries uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: 'artifacts/*' - name: Name the sigstore bundle after the binaries # The attestation is also stored in the GitHub attestations API, but # fetching it from there needs a recent gh and a GitHub token (see # #10187) - so the sigstore bundle rides along with the binaries and the # release job attaches it to the release as .sigstore.jsonl, just # like it does for the sdist, see .github/workflows/release.yml. # # One attestation covers both the single-file binary and the .tgz, so # the same bundle is stored under both names - one for each asset. # This is also why the attestation is made before the artifact is # uploaded and not after it. if: startsWith(github.ref, 'refs/tags/') && matrix.do_binaries env: BUNDLE_PATH: ${{ steps.attest-binaries.outputs.bundle-path }} run: | set -euxo pipefail # the glob is expanded before the loop body creates any file: for asset in artifacts/*; do cp "$BUNDLE_PATH" "$asset.sigstore.jsonl" done ls -l artifacts/ - name: Upload artifacts if: startsWith(github.ref, 'refs/tags/') && matrix.do_binaries uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ matrix.artifact_prefix }} path: artifacts/* if-no-files-found: ignore - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 env: OS: ${{ matrix.os }} with: token: ${{ secrets.CODECOV_TOKEN }} report_type: test_results env_vars: OS files: test-results.xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 env: OS: ${{ matrix.os }} with: token: ${{ secrets.CODECOV_TOKEN }} report_type: coverage env_vars: OS files: coverage.xml windows_tests: if: true # can be used to temporarily disable the build runs-on: windows-latest timeout-minutes: 90 needs: [lint] permissions: contents: read id-token: write attestations: write env: MSYS2_ARG_CONV_EXCL: "*" MSYS2_ENV_CONV_EXCL: "*" # see the "Cache pip-built wheels" step. MSYS2_ENV_CONV_EXCL above keeps # this a Windows path when it enters the msys2 shell. PIP_CACHE_DIR: ${{ github.workspace }}\.pip-cache defaults: run: shell: msys2 {0} steps: # actions/checkout runs Git for Windows, whose system config sets # core.autocrlf=true, while the msys2 git the build steps below use does # not: it would see every text file as modified, so setuptools-scm called # the checkout dirty and gave the binaries a guessed-next .devN version # instead of the tag version, see #10199. .gitattributes takes care of # this as well, this is here so that it also works for older tags. - name: Do not convert line endings on checkout shell: pwsh run: git config --global core.autocrlf false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false # MSYS2's mingw Python cannot use PyPI's win_amd64 wheels, so pip builds # all compiled deps from source - incl. building maturin via cargo, just # to build the blake3 wheel. Persist the wheels pip builds. - name: Cache pip-built wheels # Not on tags: that is when the release binaries are built and attested, # and they should not be able to pick up a poisoned cache entry. if: ${{ !startsWith(github.ref, 'refs/tags/') }} uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .pip-cache key: windows-msys2-pip-${{ hashFiles('pyproject.toml', 'requirements.d/pyinstaller.txt') }} restore-keys: | windows-msys2-pip- - uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 with: msystem: UCRT64 update: true - name: Install system packages run: ./scripts/msys2-install-deps development - name: Build python venv run: | # building native extensions in the venv fails, so we try to use the system packages python -m venv --system-site-packages env . env/bin/activate # python -m pip install --upgrade pip # pip install --upgrade setuptools build wheel pip install -r requirements.d/pyinstaller.txt - name: Build run: | # build borg.exe . env/bin/activate pip install -e ".[cockpit,s3,sftp,rclone]" ./scripts/build-borg-using-pyinstaller.sh # build sdist and wheel in dist/... python -m build # Same layout and naming as the binaries of the other platforms, so that # the release job picks this up as a release asset, too. The single-file # binary keeps its .exe extension - Windows needs it to run the file. - name: Prepare binaries (borg-windows-x86_64-gh) run: | pushd dist/binary echo "single-file binary" ./borg.exe -V echo "single-directory binary" ./borg-dir/borg.exe -V tar czf borg.tgz borg-dir popd mkdir -p artifacts cp dist/binary/borg.exe artifacts/borg-windows-x86_64-gh.exe cp dist/binary/borg.tgz artifacts/borg-windows-x86_64-gh.tgz echo "binary files" ls -l artifacts/ - name: Attest binaries provenance (borg-windows-x86_64-gh) id: attest-binaries if: ${{ startsWith(github.ref, 'refs/tags/') }} uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: 'artifacts/*' - name: Name the sigstore bundle after the binaries (borg-windows-x86_64-gh) # The attestation is also stored in the GitHub attestations API, but # fetching it from there needs a recent gh and a GitHub token (see # #10187) - so the sigstore bundle rides along with the binaries and the # release job attaches it to the release as .sigstore.jsonl, just # like it does for the sdist, see .github/workflows/release.yml. # # One attestation covers both the .exe and the .tgz, so the same bundle # is stored under both names - one for each asset. # # pwsh, not the msys2 shell this job otherwise uses: bundle-path is a # native Windows path and MSYS2_ARG_CONV_EXCL above keeps msys2 from # converting it. if: ${{ startsWith(github.ref, 'refs/tags/') }} shell: pwsh env: BUNDLE_PATH: ${{ steps.attest-binaries.outputs.bundle-path }} run: | Get-ChildItem artifacts -File | ForEach-Object { Copy-Item -Path $env:BUNDLE_PATH -Destination "$($_.FullName).sigstore.jsonl" } Get-ChildItem artifacts - name: Upload binaries (borg-windows-x86_64-gh) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: borg-windows-x86_64-gh path: artifacts/* if-no-files-found: error - name: Run tests run: | # Ensure locally built binary in ./dist/binary/borg-dir is found during tests export PATH="$GITHUB_WORKSPACE/dist/binary/borg-dir:$PATH" borg.exe -V . env/bin/activate python -m pytest -n4 --benchmark-skip -vv -rs -k "not remote" --cov=borg --cov-config=pyproject.toml --cov-report=xml --junitxml=test-results.xml - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 env: OS: ${{ runner.os }} python: '3.11' with: token: ${{ secrets.CODECOV_TOKEN }} report_type: test_results env_vars: OS,python files: test-results.xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 env: OS: ${{ runner.os }} python: '3.11' with: token: ${{ secrets.CODECOV_TOKEN }} report_type: coverage env_vars: OS,python files: coverage.xml release: # Build the sdist and draft the GitHub release with the binaries the jobs # above built for this tag, see .github/workflows/release.yml. # # vm_tests is continue-on-error (a flaky BSD VM must not stop a release), so # this waits for it, but only requires native_tests to have succeeded. A # binary that did not get built is warned about while drafting the release. if: ${{ !cancelled() && startsWith(github.ref, 'refs/tags/') && needs.native_tests.result == 'success' }} needs: [native_tests, vm_tests] permissions: contents: write id-token: write attestations: write uses: ./.github/workflows/release.yml pypi: # Upload the sdist the release job built to PyPI. # # This job can not live in release.yml with the rest of the release code: # PyPI trusted publishing does not work from a reusable workflow, see # https://docs.pypi.org/trusted-publishers/troubleshooting/ # # One-time setup, so that no API token has to be stored anywhere: # - on pypi.org, add a trusted publisher to the "borgbackup" project: # owner "borgbackup", repository "borg", workflow "ci.yml", # environment "pypi". # - create the "pypi" environment in the repository settings. Configuring # required reviewers for it makes this (irreversible) upload wait for an # approval, which is the last chance to stop a release. if: ${{ startsWith(github.ref, 'refs/tags/') && needs.release.result == 'success' }} needs: [release] runs-on: ubuntu-26.04 timeout-minutes: 30 environment: name: pypi url: https://pypi.org/project/borgbackup/ permissions: contents: read id-token: write # trusted publishing steps: - name: Get the sdist built by the release job uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: sdist path: dist - name: What we are about to upload run: ls -l dist/ - name: Upload to PyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2