diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml
index 78b37e92..4600167f 100644
--- a/.github/workflows/compatibility.yml
+++ b/.github/workflows/compatibility.yml
@@ -1,25 +1,23 @@
---
-# This is a basic workflow to help you get started with Actions
-
name: CI
-# Controls when the workflow will run
+# Full compatibility suite: the e2e matrix over every supported image
+# (docs/compatibility.md -> Makefile E2E_FULL_IMAGES), both backends. Runs on main
+# pushes and CI-labelled PRs, only when distrobox files changed. The per-PR gating
+# subset is e2e.yml; both share the matrix logic in e2e-matrix.yml.
on:
- # Triggers the workflow on push or pull request events but only for the master branch
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, ready_for_review, edited, labeled]
-
- # Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
inputs:
run_always:
description: 'Run even if no files are changed'
required: true
type: boolean
-# Check if we indeed modified distrobox stuff
+
jobs:
check_changes:
runs-on: ubuntu-latest
@@ -29,201 +27,23 @@ jobs:
outputs:
distrobox_changed: ${{ steps.check_file_changed.outputs.distrobox_changed }}
steps:
-
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Checkout as many commits as needed for the diff
fetch-depth: 2
-
- # Fetch from compatibility table all the distros supported
- id: check_file_changed
run: |
- if git diff --name-only HEAD^ HEAD | grep -E '\.go$|go\.mod|go\.sum|Makefile|docs/compatibility\.md|internal/inside-distrobox/assets/'; then
- echo "::set-output name=distrobox_changed::True"
+ if git diff --name-only HEAD^ HEAD | grep -E '\.go$|go\.mod|go\.sum|Makefile|docs/compatibility\.md|internal/inside-distrobox/assets/|hack/ci/'; then
+ echo "distrobox_changed=True" >> "$GITHUB_OUTPUT"
else
- echo "::set-output name=distrobox_changed::False"
+ echo "distrobox_changed=False" >> "$GITHUB_OUTPUT"
fi
- # Prepare distros matrix
- setup:
- runs-on: ubuntu-latest
+ e2e:
needs: check_changes
- outputs:
- targets: ${{ steps.set-matrix.outputs.targets }}
if: >-
needs.check_changes.outputs.distrobox_changed == 'True' ||
github.event.inputs.run_always == 'True'
- steps:
-
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
-
- # Fetch from compatibility table all the distros supported
- - id: set-matrix
- run: |
- skip_list="bazzite|chimera|slackware|stream8|ublue|neon|steamos"
-
- echo "::set-output name=targets::$(sed -n -e '/| Alma/,/| Void/ p' docs/compatibility.md |
- cut -d'|' -f 4 |
- sed 's/
/\n/g' |
- tr -d ' ' |
- sed '/^[[:space:]]*$/d' |
- sort -u | grep -Ev "${skip_list}" |
- jq -R -s -c 'split("\n")[:-1]')"
-
- run:
- runs-on: ubuntu-latest
- needs: setup
- timeout-minutes: 30
- strategy:
- fail-fast: false
- matrix:
- distribution: ${{fromJSON(needs.setup.outputs.targets)}}
- container_manager: ["podman"] #, "docker"]
- env:
- XDG_CACHE_HOME: "/tmp/"
- DBX_CONTAINER_MANAGER: ${{ matrix.container_manager }}
-
- steps:
- # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
-
- - name: Setup Go
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
- with:
- go-version-file: go.mod
-
- - name: Build distrobox binary
- run: |
- make build
- cp ./bin/distrobox ./distrobox
-
- # Ensure distrobox create works:
- - name: Distrobox create
- shell: 'script -q -e -c "bash {0}"'
- run: |
- image=${{ matrix.distribution }}
- container_name="$(basename "${image}" | sed -E 's/[:.]/-/g')"
- ${DBX_CONTAINER_MANAGER} pull "${image}"
- # ./distrobox create --yes --absolutely-disable-root-password-i-am-really-positively-sure -i "${image}" --name "${container_name}"
- case "${container_name}" in
- *init*)
- echo "SYSTEMD DETECTED: creating container with --init..."
- ./distrobox create --yes -i "${image}" --hostname "${container_name}" --name "${container_name}" --init --unshare-all
- ;;
- *)
- ./distrobox create --yes -i "${image}" --hostname "${container_name}" --name "${container_name}"
- ;;
- esac
-
- # Ensure distrobox enter and init works:
- - name: Distrobox enter - init
- shell: 'script -q -e -c "bash {0}"'
- run: |
- image=${{ matrix.distribution }}
- container_name="$(basename "${image}" | sed -E 's/[:.]/-/g')"
- case "${container_name}" in
- *init*)
- echo "SYSTEMD DETECTED: performing systemctl check..."
- ./distrobox enter --name "${container_name}" -- systemctl is-system-running | grep -E "degraded|running|starting"
- ;;
- *)
- ./distrobox enter --name "${container_name}" -- whoami
- ;;
- esac
-
- # Ensure distrobox enter and init works:
- - name: Distrobox enter - user
- shell: 'script -q -e -c "bash {0}"'
- run: |
- image=${{ matrix.distribution }}
- container_name="$(basename "${image}" | sed -E 's/[:.]/-/g')"
- # Assert that distrobox exported binary indeed works
- set -x
- command_output="$(./distrobox enter --name "${container_name}" -- whoami | tr -d '\r' | tr -d '^@')"
- expected_output="$(whoami)"
- if [ "$command_output" != "$expected_output" ]; then
- exit 1
- fi
-
- # Ensure distrobox enter and init works:
- - name: Distrobox enter - command
- shell: 'script -q -e -c "bash {0}"'
- run: |
- image=${{ matrix.distribution }}
- container_name="$(basename "${image}" | sed -E 's/[:.]/-/g')"
- # Assert that distrobox exported binary indeed works
- set -x
- command_output="$(./distrobox enter --name "${container_name}" -- uname -n | tr -d '\r' | tr -d '^@')"
- expected_output="${container_name}"
- if [ "$command_output" != "$expected_output" ]; then
- exit 1
- fi
-
- # Ensure distrobox export works:
- - name: Distrobox export
- shell: 'script -q -e -c "bash {0}"'
- run: |
- image=${{ matrix.distribution }}
- container_name="$(basename "${image}" | sed -E 's/[:.]/-/g')"
- ./distrobox enter "${container_name}" -- distrobox-export --bin /bin/uname --export-path ${HOME}/
- # Assert that distrobox exported binary indeed works
- set -x
- command_output="$(${HOME}/uname -n | tr -d '\r' | tr -d '^@')"
- expected_output="${container_name}"
- if [ "$command_output" != "$expected_output" ]; then
- exit 1
- fi
-
- # Ensure distrobox export works:
- - name: Distrobox export - sudo
- if: matrix.container_manager != 'docker'
- shell: 'script -q -e -c "bash {0}"'
- run: |
- image=${{ matrix.distribution }}
- container_name="$(basename "${image}" | sed -E 's/[:.]/-/g')"
- ./distrobox enter "${container_name}" -- distrobox-export --sudo --bin /bin/uname --export-path ${HOME}/
- # Assert that distrobox exported binary indeed works
- set -x
- command_output="$(${HOME}/uname -n | tr -d '\r' | tr -d '^@')"
- expected_output="${container_name}"
- if [ "$command_output" != "$expected_output" ]; then
- exit 1
- fi
-
- # Ensure distrobox upgrade works:
- - name: Distrobox upgrade
- run: |
- image=${{ matrix.distribution }}
- container_name="$(basename "${image}" | sed -E 's/[:.]/-/g')"
- ./distrobox upgrade "${container_name}"
-
- # Ensure distrobox list works:
- - name: Distrobox list
- run: |
- image=${{ matrix.distribution }}
- container_name="$(basename "${image}" | sed -E 's/[:.]/-/g')"
- ./distrobox list | grep "${container_name}" | grep "${image}" | grep -E "Up|running"
-
- # Ensure distrobox stop works:
- - name: Distrobox stop
- run: |
- image=${{ matrix.distribution }}
- container_name="$(basename "${image}" | sed -E 's/[:.]/-/g')"
- ./distrobox stop --yes "${container_name}"
-
- # Ensure distrobox rm works:
- - name: Distrobox logs
- if: ${{ always() }}
- run: |
- image=${{ matrix.distribution }}
- container_name="$(basename "${image}" | sed -E 's/[:.]/-/g')"
- $DBX_CONTAINER_MANAGER logs "${container_name}"
-
- # Ensure distrobox rm works:
- - name: Distrobox rm
- if: ${{ always() }}
- run: |
- image=${{ matrix.distribution }}
- container_name="$(basename "${image}" | sed -E 's/[:.]/-/g')"
- ./distrobox rm --force "${container_name}"
- $DBX_CONTAINER_MANAGER rmi -f "${image}"
+ uses: ./.github/workflows/e2e-matrix.yml
+ with:
+ images_var: E2E_FULL_IMAGES
diff --git a/.github/workflows/e2e-matrix.yml b/.github/workflows/e2e-matrix.yml
new file mode 100644
index 00000000..a6444e14
--- /dev/null
+++ b/.github/workflows/e2e-matrix.yml
@@ -0,0 +1,44 @@
+---
+name: e2e-matrix
+
+# Reusable: run the per-distro e2e matrix (image list x both backends) via
+# `make e2e-one`. The image list is read from the Makefile variable named by
+# `images_var`, so the list lives only there.
+on:
+ workflow_call:
+ inputs:
+ images_var:
+ description: Makefile variable holding the image list (e.g. E2E_IMAGES).
+ required: true
+ type: string
+
+jobs:
+ setup:
+ runs-on: ubuntu-latest
+ outputs:
+ images: ${{ steps.set.outputs.images }}
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - id: set
+ env:
+ VAR: ${{ inputs.images_var }}
+ run: echo "images=$(make -s print-"$VAR" | jq -R -c 'split(" ") | map(select(length > 0))')" >> "$GITHUB_OUTPUT"
+
+ e2e:
+ name: ${{ matrix.image }} (${{ matrix.backend }})
+ needs: setup
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ image: ${{ fromJSON(needs.setup.outputs.images) }}
+ backend: [podman, docker]
+ env:
+ XDG_CACHE_HOME: "/tmp/"
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
+ with:
+ go-version-file: go.mod
+ - run: make e2e-one IMAGE="${{ matrix.image }}" CM="${{ matrix.backend }}"
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 00000000..c3c375c3
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,39 @@
+---
+name: E2E
+
+# Gating suite on EVERY pull request: the e2e matrix over the gating image subset
+# (Makefile E2E_IMAGES) plus the distro-agnostic commands. The full matrix runs in
+# compatibility.yml (main / CI-labelled); both share e2e-matrix.yml.
+on:
+ pull_request:
+ branches: [main]
+ types: [opened, synchronize, reopened, ready_for_review]
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ e2e:
+ uses: ./.github/workflows/e2e-matrix.yml
+ with:
+ images_var: E2E_IMAGES
+
+ # Distro-agnostic subcommands, once per backend on a fast image.
+ commands:
+ name: commands (${{ matrix.backend }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ backend: [podman, docker]
+ env:
+ XDG_CACHE_HOME: "/tmp/"
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
+ with:
+ go-version-file: go.mod
+ - run: make e2e-commands CM="${{ matrix.backend }}"
diff --git a/Makefile b/Makefile
index 5e78a7ad..9e5fd7b3 100644
--- a/Makefile
+++ b/Makefile
@@ -79,3 +79,61 @@ lint:
.PHONY: lint-fix
lint-fix:
$(GO_BUILD_ENV) golangci-lint run --fix
+
+# ---------------------------------------------------------------------------
+# Local e2e via the hack/ci/*.sh scripts, on the freshly-built binary. Pulled
+# images are kept so repeated runs do not re-download. Targets:
+# e2e mirrors the per-PR gate (.github/workflows/e2e.yml): the gating
+# image subset + commands.sh, on both backends (podman, docker).
+# e2e-full the whole compatibility matrix (docs/compatibility.md) + commands.
+# e2e-one one e2e.sh run for a single image + backend; override IMAGE=/CM=.
+# e2e-commands just commands.sh on one image/backend; override IMAGE=/CM=.
+# ---------------------------------------------------------------------------
+IMAGE ?= docker.io/library/alpine:latest
+CM ?= podman
+E2E_ENV := DBX="$(CURDIR)/bin/distrobox" DBX_E2E_KEEP_IMAGE=1
+
+E2E_IMAGES := \
+ docker.io/library/alpine:latest \
+ docker.io/library/debian:stable \
+ docker.io/library/ubuntu:24.04 \
+ docker.io/library/fedora:latest \
+ registry.opensuse.org/opensuse/distrobox:latest \
+ docker.io/library/archlinux:latest \
+ registry.access.redhat.com/ubi9/ubi-init
+
+# Full compatibility matrix, derived from docs/compatibility.md like compatibility.yml
+# (no drift). Lazy '=' so the pipeline runs only when e2e-full is invoked.
+E2E_FULL_SKIP := bazzite|chimera|slackware|stream8|ublue|neon|steamos
+E2E_FULL_IMAGES = $(shell sed -n -e '/| Alma/,/| Void/ p' docs/compatibility.md | cut -d'|' -f 4 | sed 's/
/\n/g' | tr -d ' ' | sed '/^[[:space:]]*$$/d' | sort -u | grep -Ev '$(E2E_FULL_SKIP)')
+
+# print- — echo a make variable; the CI matrices build from these lists.
+.PHONY: print-%
+print-%:
+ @echo '$($*)'
+
+.PHONY: e2e-one
+e2e-one: build
+ echo ">>> e2e: $(IMAGE) / $(CM)"; \
+ $(E2E_ENV) hack/ci/e2e.sh "$(IMAGE)" "$(CM)"
+
+.PHONY: e2e-commands
+e2e-commands: build
+ echo ">>> commands: $(IMAGE) / $(CM)"; \
+ $(E2E_ENV) hack/ci/commands.sh "$(IMAGE)" "$(CM)"
+
+.PHONY: e2e
+e2e: build
+ @for img in $(E2E_IMAGES); do \
+ for cm in podman docker; do \
+ $(MAKE) --no-print-directory e2e-one IMAGE="$$img" CM="$$cm" || exit 1; \
+ done; \
+ done
+ @for cm in podman docker; do \
+ $(MAKE) --no-print-directory e2e-commands CM="$$cm" || exit 1; \
+ done
+
+# Full compatibility sweep (100+ images x 2 backends); local twin of compatibility.yml.
+.PHONY: e2e-full
+e2e-full:
+ $(MAKE) --no-print-directory e2e E2E_IMAGES="$(E2E_FULL_IMAGES)"
diff --git a/hack/ci/commands.sh b/hack/ci/commands.sh
new file mode 100755
index 00000000..196c86a9
--- /dev/null
+++ b/hack/ci/commands.sh
@@ -0,0 +1,245 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: GPL-3.0-only
+#
+# This file is part of the distrobox project:
+# https://github.com/89luca89/distrobox
+#
+# Copyright (C) 2021 distrobox contributors
+#
+# distrobox is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3
+# as published by the Free Software Foundation.
+#
+# distrobox is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with distrobox; if not, see .
+#
+# usage: commands.sh
+
+set -u
+
+IMAGE="${1:?usage: commands.sh }"
+MODE="${2:?backend required: podman|docker}"
+
+case "${MODE}" in
+ podman) CM=podman ;;
+ docker) CM=docker ;;
+ *)
+ printf 'unknown backend: %s (use podman|docker)\n' "${MODE}" >&2
+ exit 2
+ ;;
+esac
+export DBX_CONTAINER_MANAGER="${CM}"
+DBX="${DBX:-distrobox}"
+
+dbx()
+{
+ "${DBX}" "$@"
+}
+engine()
+{
+ "${CM}" "$@"
+}
+
+fails=0
+pass()
+{
+ printf ' \033[1;32mPASS\033[0m %s\n' "${1}"
+}
+fail()
+{
+ printf ' \033[1;31mFAIL\033[0m %s\n' "${1}"
+ fails=$((fails + 1))
+ if [ -n "${2:-}" ]; then
+ printf '%s\n' "${2}" | tr '\r' '\n' | sed 's/^/ | /'
+ fi
+}
+ok()
+{
+ desc="${1}"
+ shift
+ if out="$("$@" < /dev/null 2>&1)"; then pass "${desc}"; else fail "${desc}" "${out}"; fi
+}
+count()
+{
+ engine ps -a -q 2> /dev/null | wc -l | tr -d ' '
+}
+warmup()
+{
+ dbx enter --name "${1}" -- true < /dev/null > /dev/null 2>&1 || true
+}
+eq()
+{
+ if [ "${2}" = "${3}" ]; then pass "${1}"; else fail "${1}" "got '${2}', want '${3}'"; fi
+}
+contains()
+{
+ if printf '%s' "${2}" | grep -qE "${3}"; then pass "${1}"; else fail "${1}" "want /${3}/ in: ${2}"; fi
+}
+enter_out()
+{
+ b="${1}"
+ shift
+ dbx enter --name "${b}" -- "$@" < /dev/null 2> /dev/null | tr -d '\r\000\n'
+}
+
+printf '\n== commands: image=%s backend=%s ==\n' "${IMAGE}" "${MODE}"
+engine pull "${IMAGE}" > /dev/null 2>&1 || true
+
+# Captured while pristine, to gate the destructive --all tests (which act on EVERY
+# distrobox) so a developer's own boxes are never touched. CI runners are clean.
+pre_boxes="$(dbx list 2> /dev/null | tail -n +2 | grep -c .)"
+
+# ---- ephemeral: a temporary box, auto-removed on exit ----
+before="$(count)"
+ok "ephemeral runs a command" dbx ephemeral --yes --image "${IMAGE}" -- true
+after="$(count)"
+if [ "${before}" = "${after}" ]; then
+ pass "ephemeral leaves no leftover container"
+else
+ fail "ephemeral leaves no leftover container" "before=${before} after=${after}"
+fi
+
+# ---- flag checks that spin no container (fast) ----
+ok "--version prints" dbx --version
+contains "create --compatibility lists images" "$(dbx create --compatibility < /dev/null 2>&1)" 'alpine|ubuntu|fedora|debian'
+contains "create --dry-run prints the engine command" "$(dbx create --dry-run --image "${IMAGE}" --name dbx-cmd-dry < /dev/null 2>&1)" "${CM}.*create"
+if dbx list 2> /dev/null | grep -q dbx-cmd-dry; then fail "create --dry-run creates nothing" "dbx-cmd-dry present"; else pass "create --dry-run creates nothing"; fi
+nc="$(dbx list --no-color < /dev/null 2>&1)"
+if printf '%s' "${nc}" | grep -q "$(printf '\033')"; then fail "list --no-color emits no ANSI" "escape found"; else pass "list --no-color emits no ANSI"; fi
+ok "ls alias resolves to list" dbx ls
+
+# ---- base box -> generate-entry + clone (guarded on create success) ----
+gbox="dbx-cmd-genentry"
+cbox="${gbox}-clone"
+if cout="$(dbx create --yes --no-entry --image "${IMAGE}" --name "${gbox}" < /dev/null 2>&1)"; then
+ pass "create (base box)"
+ # First enter triggers setup; sudo writes a marker into the container rootfs
+ # (not a host-shared path) so a clone must carry it.
+ dbx enter --name "${gbox}" -- sudo -n touch /dbx-clone-marker < /dev/null > /dev/null 2>&1 || true
+
+ desktop="${XDG_DATA_HOME:-${HOME}/.local/share}/applications/${gbox}.desktop"
+ geout="$(dbx generate-entry "${gbox}" < /dev/null 2>&1)"
+ if [ -f "${desktop}" ]; then pass "generate-entry writes a .desktop"; else fail "generate-entry writes a .desktop" "expected ${desktop}; generate-entry said: ${geout}"; fi
+ dbx generate-entry --delete "${gbox}" < /dev/null > /dev/null 2>&1 || true
+ if [ ! -f "${desktop}" ]; then pass "generate-entry --delete removes it"; else fail "generate-entry --delete removes it" "${desktop} still present"; fi
+
+ dbx stop --yes "${gbox}" < /dev/null > /dev/null 2>&1 || true
+ if clout="$(dbx create --yes --clone "${gbox}" --name "${cbox}" < /dev/null 2>&1)"; then
+ pass "clone a stopped box"
+ warmup "${cbox}"
+ marker="$(dbx enter --name "${cbox}" -- sh -c 'test -f /dbx-clone-marker && printf yes' < /dev/null 2>&1 | tr -d '\r\000\n')"
+ if [ "${marker}" = "yes" ]; then pass "clone carries the rootfs marker"; else fail "clone carries the rootfs marker" "marker /dbx-clone-marker missing; enter returned: '${marker}'"; fi
+ else
+ fail "clone a stopped box" "${clout}"
+ fail "clone carries the rootfs marker" "no clone created"
+ fi
+else
+ fail "create (base box)" "${cout}"
+fi
+
+# ---- create/enter flags + engine-level mechanics, on one box ----
+fbox="dbx-cmd-flags"
+vdir="$(mktemp -d)"
+: > "${vdir}/vfile"
+if fout="$(dbx create --yes --no-entry --image "${IMAGE}" --name "${fbox}" \
+ --hostname dbxflagshost \
+ --volume "${vdir}:/mnt/vol" \
+ --init-hooks 'touch /dbx-hook-ran' \
+ --additional-flags '--env CREATEVAR=cval' < /dev/null 2>&1)"; then
+ pass "create with a flag bundle"
+ warmup "${fbox}"
+ eq "--hostname sets the hostname" "$(enter_out "${fbox}" uname -n)" "dbxflagshost"
+ ok "--volume mounts a host dir" dbx enter --name "${fbox}" -- test -f /mnt/vol/vfile
+ ok "--init-hooks runs at setup" dbx enter --name "${fbox}" -- test -f /dbx-hook-ran
+ eq "create --additional-flags forwards --env" "$(enter_out "${fbox}" printenv CREATEVAR)" "cval"
+ eq "enter --additional-flags forwards --env" \
+ "$(dbx enter --name "${fbox}" --additional-flags '--env ENTERVAR=eval' -- printenv ENTERVAR < /dev/null 2> /dev/null | tr -d '\r\000\n')" "eval"
+ hm="${HOME}/.dbx-cmd-home-$$"
+ : > "${hm}"
+ ok 'host $HOME is shared into the box' dbx enter --name "${fbox}" -- test -f "${hm}"
+ rm -f "${hm}"
+ eq "host env var forwarded" \
+ "$(MY_DBX_VAR=fwd dbx enter --name "${fbox}" -- printenv MY_DBX_VAR < /dev/null 2> /dev/null | tr -d '\r\000\n')" "fwd"
+ badv="$(DBX_CMD_BAD='has$dollar' dbx enter --name "${fbox}" -- printenv DBX_CMD_BAD < /dev/null 2> /dev/null | tr -d '\r\000\n')"
+ if [ -z "${badv}" ]; then pass "env var with special chars is filtered"; else fail "env var with special chars is filtered" "leaked '${badv}'"; fi
+ pn="$(enter_out "${fbox}" printenv PATH)"
+ pc="$(dbx enter --name "${fbox}" --clean-path -- printenv PATH < /dev/null 2> /dev/null | tr -d '\r\000\n')"
+ if [ -n "${pc}" ] && [ "${pc}" != "${pn}" ] && printf '%s' ":${pc}:" | grep -q ':/usr/bin:'; then pass "enter --clean-path resets PATH to FHS"; else fail "enter --clean-path resets PATH to FHS" "normal='${pn}' clean='${pc}'"; fi
+ eq 'enter --no-workdir starts in $HOME' \
+ "$(dbx enter --name "${fbox}" --no-workdir -- pwd < /dev/null 2> /dev/null | tr -d '\r\000\n')" "${HOME}"
+ contains "enter --dry-run prints the engine command" "$(dbx enter --name "${fbox}" --dry-run -- true < /dev/null 2>&1)" "${CM}.*exec"
+else
+ fail "create with a flag bundle" "${fout}"
+fi
+dbx rm --force "${fbox}" < /dev/null > /dev/null 2>&1 || true
+rm -rf "${vdir}"
+
+# ---- --home (custom HOME) + rm --rm-home ----
+chome="$(mktemp -d)/box-home"
+if dbx create --yes --no-entry --image "${IMAGE}" --name dbx-cmd-home --home "${chome}" < /dev/null > /dev/null 2>&1; then
+ warmup dbx-cmd-home
+ eq "--home sets a custom HOME" "$(enter_out dbx-cmd-home printenv HOME)" "${chome}"
+ dbx rm --rm-home --force dbx-cmd-home < /dev/null > /dev/null 2>&1 || true
+ if [ -d "${chome}" ]; then fail "rm --rm-home removes the custom home" "${chome} still present"; else pass "rm --rm-home removes the custom home"; fi
+else
+ fail "--home sets a custom HOME" "create --home failed"
+ fail "rm --rm-home removes the custom home" "no box created"
+fi
+rm -rf "${chome%/*}"
+
+# ---- assemble: create/rm boxes from a manifest ----
+mani="$(mktemp)"
+printf '[dbx-cmd-assemble]\nimage=%s\n' "${IMAGE}" > "${mani}"
+ok "assemble create" dbx assemble create --file "${mani}"
+if dbx list | grep -q dbx-cmd-assemble; then pass "assemble created the box"; else fail "assemble created the box" "dbx-cmd-assemble not in distrobox list"; fi
+ok "assemble rm" dbx assemble rm --file "${mani}"
+if dbx list | grep -q dbx-cmd-assemble; then fail "assemble rm removed the box" "dbx-cmd-assemble still in distrobox list"; else pass "assemble rm removed the box"; fi
+rm -f "${mani}"
+
+# ---- assemble: --dry-run, --name (single entry), --replace ----
+mani2="$(mktemp)"
+printf '[dbx-cmd-asm1]\nimage=%s\n\n[dbx-cmd-asm2]\nimage=%s\n' "${IMAGE}" "${IMAGE}" > "${mani2}"
+if dbx assemble create --file "${mani2}" --dry-run < /dev/null > /dev/null 2>&1 && ! dbx list 2> /dev/null | grep -q dbx-cmd-asm; then pass "assemble --dry-run creates nothing"; else fail "assemble --dry-run creates nothing" "$(dbx list 2> /dev/null | grep dbx-cmd-asm || true)"; fi
+ok "assemble create --name (single entry)" dbx assemble create --file "${mani2}" --name dbx-cmd-asm1
+if dbx list 2> /dev/null | grep -q dbx-cmd-asm1 && ! dbx list 2> /dev/null | grep -q dbx-cmd-asm2; then pass "assemble --name builds only the named entry"; else fail "assemble --name builds only the named entry" "$(dbx list 2> /dev/null | grep dbx-cmd-asm || true)"; fi
+ok "assemble create --replace" dbx assemble create --file "${mani2}" --name dbx-cmd-asm1 --replace
+dbx assemble rm --file "${mani2}" < /dev/null > /dev/null 2>&1 || true
+rm -f "${mani2}"
+
+# ---- multi-box --all flags (act on EVERY distrobox; guarded for safety) ----
+# Runs only from a clean start so a dev's own boxes are never touched (CI is clean).
+if [ "${pre_boxes}" = "0" ]; then
+ dbx create --yes --no-entry --image "${IMAGE}" --name dbx-cmd-all1 < /dev/null > /dev/null 2>&1 || true
+ dbx create --yes --no-entry --image "${IMAGE}" --name dbx-cmd-all2 < /dev/null > /dev/null 2>&1 || true
+ warmup dbx-cmd-all1
+ warmup dbx-cmd-all2
+ appdir="${XDG_DATA_HOME:-${HOME}/.local/share}/applications"
+ icon="${HOME}/.dbx-cmd-icon.png"
+ : > "${icon}"
+ dbx generate-entry --all < /dev/null > /dev/null 2>&1 || true
+ if [ -f "${appdir}/dbx-cmd-all1.desktop" ] && [ -f "${appdir}/dbx-cmd-all2.desktop" ]; then pass "generate-entry --all covers every box"; else fail "generate-entry --all covers every box" "missing .desktop in ${appdir}"; fi
+ dbx generate-entry dbx-cmd-all1 --icon "${icon}" < /dev/null > /dev/null 2>&1 || true
+ contains "generate-entry --icon sets a custom icon" "$(cat "${appdir}/dbx-cmd-all1.desktop" 2> /dev/null)" "Icon=${icon}"
+ dbx generate-entry --all --delete < /dev/null > /dev/null 2>&1 || true
+ rm -f "${icon}"
+ ok "stop --all stops every box" dbx stop --all --yes
+ ok "upgrade --all upgrades every box" dbx upgrade --all
+ dbx rm --all --force < /dev/null > /dev/null 2>&1 || true
+ if [ "$(dbx list 2> /dev/null | tail -n +2 | grep -c .)" = "0" ]; then pass "rm --all removes every box"; else fail "rm --all removes every box" "boxes remain"; fi
+else
+ printf ' \033[1;33mSKIP\033[0m --all flag tests (%s pre-existing distrobox(es); would be destructive)\n' "${pre_boxes}"
+fi
+
+# ---- cleanup ----
+dbx rm --force "${gbox}" "${cbox}" "${fbox}" < /dev/null > /dev/null 2>&1 || true
+if [ -z "${DBX_E2E_KEEP_IMAGE:-}" ]; then
+ engine rmi -f "${IMAGE}" > /dev/null 2>&1 || true
+fi
+
+printf '== commands result: %d failed ==\n' "${fails}"
+[ "${fails}" -eq 0 ]
diff --git a/hack/ci/e2e.sh b/hack/ci/e2e.sh
new file mode 100755
index 00000000..706b65ed
--- /dev/null
+++ b/hack/ci/e2e.sh
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: GPL-3.0-only
+#
+# This file is part of the distrobox project:
+# https://github.com/89luca89/distrobox
+#
+# Copyright (C) 2021 distrobox contributors
+#
+# distrobox is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3
+# as published by the Free Software Foundation.
+#
+# distrobox is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with distrobox; if not, see .
+#
+# usage: e2e.sh
+
+set -u
+
+IMAGE="${1:?usage: e2e.sh }"
+MODE="${2:?backend required: podman|docker}"
+
+case "${MODE}" in
+ podman) CM=podman ;;
+ docker) CM=docker ;;
+ *)
+ printf 'unknown backend: %s (use podman|docker)\n' "${MODE}" >&2
+ exit 2
+ ;;
+esac
+export DBX_CONTAINER_MANAGER="${CM}"
+DBX="${DBX:-distrobox}"
+
+dbx()
+{
+ "${DBX}" "$@"
+}
+engine()
+{
+ "${CM}" "$@"
+}
+
+name="$(basename "${IMAGE}" | sed -E 's/[:.]/-/g')"
+fails=0
+
+pass()
+{
+ printf ' \033[1;32mPASS\033[0m %s\n' "${1}"
+}
+fail()
+{
+ printf ' \033[1;31mFAIL\033[0m %s\n' "${1}"
+ fails=$((fails + 1))
+ if [ -n "${2:-}" ]; then
+ # \r -> \n so distrobox progress output (carriage returns) is readable
+ # line-by-line instead of one smeared line.
+ printf '%s\n' "${2}" | tr '\r' '\n' | sed 's/^/ | /'
+ fi
+}
+eq()
+{
+ if [ "${2}" = "${3}" ]; then pass "${1}"; else fail "${1}" "got '${2}', want '${3}'"; fi
+}
+ok()
+{
+ desc="${1}"
+ shift
+ if out="$("$@" < /dev/null 2>&1)"; then
+ pass "${desc}"
+ else
+ fail "${desc}" "${out}"
+ fi
+}
+enter_out()
+{
+ dbx enter --name "${name}" -- "$@" < /dev/null 2> /dev/null | tr -d '\r\000\n'
+}
+
+# dump the box's logs (distrobox-init setup output). Only meaningful when the
+# container itself won't come up, so it's called only on create/restart failure.
+dump_logs()
+{
+ printf ' --- %s logs for %s (tail) ---\n' "${MODE}" "${name}"
+ engine logs "${name}" 2>&1 | tail -40 | sed 's/^/ | /' || true
+}
+
+printf '\n== e2e: image=%s backend=%s name=%s ==\n' "${IMAGE}" "${MODE}" "${name}"
+
+case "${name}" in
+ *init*) create_flags="--pull --yes --image ${IMAGE} --name ${name} --init --unshare-all" ;;
+ *) create_flags="--pull --yes --image ${IMAGE} --name ${name} --additional-packages nano" ;;
+esac
+# shellcheck disable=SC2086 # create_flags is intentionally word-split
+if create_out="$(dbx create ${create_flags} < /dev/null 2>&1)"; then
+ pass "create"
+else
+ fail "create" "${create_out}"
+ dump_logs
+ printf '== result: %d failed ==\n' "${fails}"
+ exit 1
+fi
+
+# Warm up: the first enter STARTS the container, and `podman start` echoes the
+# box name to stdout (plus first-boot setup streams). Do it once, discarded, so
+# the checks below capture only their own command's output.
+ok "enter" dbx enter --name "${name}" -- true
+
+# --- systemd boots for --init images ---
+case "${name}" in
+ *init*)
+ sysstate="$(enter_out systemctl is-system-running)"
+ case "${sysstate}" in
+ running | degraded | starting) pass "systemd is running (--init): ${sysstate}" ;;
+ *) fail "systemd is running (--init)" "got '${sysstate}'" ;;
+ esac
+ ;;
+ *) ;;
+esac
+
+# --- core promises (hard) ---
+eq "enter runs as the host user" "$(enter_out whoami)" "$(whoami)"
+sudo_out="$(enter_out sudo -n whoami)"
+case "${sudo_out}" in
+ *root*) pass "passwordless sudo inside the box" ;;
+ *) fail "passwordless sudo inside the box" "want 'root' in output, got '${sudo_out}'" ;;
+esac
+
+# --- additional-packages: nano (plain images only; init skips it, see create) ---
+case "${name}" in
+ *init*) ;;
+ *) ok "additional-packages installed nano" dbx enter --name "${name}" -- sh -c 'command -v nano' ;;
+esac
+
+# --- lifecycle (hard) ---
+ok "upgrade" dbx upgrade "${name}"
+ok "stop" dbx stop --yes "${name}"
+# restart: entering a stopped box must bring it back up. timeout(1) guards a
+# stuck setup, but it needs a real executable (dbx is a shell function), so call
+# the binary directly.
+restart_out="$(timeout 180 "${DBX}" enter --name "${name}" -- true < /dev/null 2>&1)"
+rc=$?
+if [ "${rc}" -eq 0 ]; then
+ pass "enter restarts a stopped box"
+else
+ fail "enter restarts a stopped box" "${restart_out}"
+ dump_logs
+fi
+
+ok "rm" dbx rm --force "${name}"
+if [ -z "${DBX_E2E_KEEP_IMAGE:-}" ]; then
+ engine rmi -f "${IMAGE}" > /dev/null 2>&1 || true
+fi
+
+printf '== result: %d failed ==\n' "${fails}"
+[ "${fails}" -eq 0 ]