From 6a1e014c65ff8f319189a409c599fdc8083a231c Mon Sep 17 00:00:00 2001 From: j4n Date: Thu, 13 Aug 2026 17:02:06 +0200 Subject: [PATCH 1/4] ci: save the incus image cache even when the job fails actions/cache only saves on job success; after a failed job, the next attempt rebuilds base and builder. Split into restore/save, count exported images and save them. --- .github/workflows/lxc-test.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lxc-test.yml b/.github/workflows/lxc-test.yml index cfa3240..5834f27 100644 --- a/.github/workflows/lxc-test.yml +++ b/.github/workflows/lxc-test.yml @@ -104,9 +104,9 @@ jobs: uv tool install ./cmlxc uv --color never tool dir --bin >> "$GITHUB_PATH" - - name: Cache Incus images + - name: Restore Incus image cache id: cache-images - uses: actions/cache@v6 + uses: actions/cache/restore@v6 with: path: /tmp/incus-cache key: incus-v6-${{ runner.os }}-${{ hashFiles('cmlxc/src/cmlxc/*.py', 'cmlxc/.github/workflows/lxc-test.yml') }} @@ -196,6 +196,7 @@ jobs: done - name: Export images for cache + id: export-images if: always() && steps.cache-images.outputs.cache-hit != 'true' run: | mkdir -p /tmp/incus-cache @@ -204,6 +205,7 @@ jobs: echo "Publishing builder container as image ..." incus publish builder-localchat --alias localchat-builder --force || true fi + exported=0 for alias in localchat-base localchat-builder; do if incus image list --format csv -c l | grep -q "^$alias$"; then echo "Exporting: $alias" @@ -211,6 +213,16 @@ jobs: if [ -f /tmp/incus-cache/$alias ] && [ ! -f /tmp/incus-cache/$alias.tar.gz ]; then mv /tmp/incus-cache/$alias /tmp/incus-cache/$alias.tar.gz fi + exported=$((exported+1)) fi done + echo "exported=$exported" >> "$GITHUB_OUTPUT" + + # Split from the restore above so a FAILED run still saves its images. + - name: Save Incus image cache + if: always() && steps.export-images.outputs.exported > 0 + uses: actions/cache/save@v6 + with: + path: /tmp/incus-cache + key: incus-v6-${{ runner.os }}-${{ hashFiles('cmlxc/src/cmlxc/*.py', 'cmlxc/.github/workflows/lxc-test.yml') }} From 909be4ef3d480af54cd516eb93ee806c7bcb3434 Mon Sep 17 00:00:00 2001 From: j4n Date: Thu, 13 Aug 2026 17:02:33 +0200 Subject: [PATCH 2/4] ci: only install incus-base Do not install the full incus package, incus-base is enough, the full Incus package pulls qemu and all sorts of drivers taking quite a bit of CI time. --- .github/workflows/lxc-test.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lxc-test.yml b/.github/workflows/lxc-test.yml index 5834f27..5efce1b 100644 --- a/.github/workflows/lxc-test.yml +++ b/.github/workflows/lxc-test.yml @@ -78,7 +78,7 @@ jobs: sudo curl -fsSL https://pkgs.zabbly.com/key.asc -o /etc/apt/keyrings/zabbly.asc echo "deb [signed-by=/etc/apt/keyrings/zabbly.asc] https://pkgs.zabbly.com/incus/stable $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/zabbly-incus.list sudo apt-get update - sudo apt-get install -y incus incus-client incus-base + sudo apt-get install -y incus-client incus-base incus --version - name: Initialise Incus diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 13ca434..5c79697 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,7 +38,7 @@ jobs: sudo curl -fsSL https://pkgs.zabbly.com/key.asc -o /etc/apt/keyrings/zabbly.asc echo "deb [signed-by=/etc/apt/keyrings/zabbly.asc] https://pkgs.zabbly.com/incus/stable $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/zabbly-incus.list sudo apt-get update - sudo apt-get install -y incus incus-client incus-base + sudo apt-get install -y incus-client incus-base incus --version - name: Initialise Incus From 73f8f31e37d05449042cab4847dfff5fbf673254 Mon Sep 17 00:00:00 2001 From: j4n Date: Wed, 5 Aug 2026 12:19:33 +0200 Subject: [PATCH 3/4] fix(driver_base): validate refs before they reach the builder shell All @ref end up in `bash -ec` inside `git fetch/checkout` on the builder, so make all three remote SourceSpec constructions go through _remote_spec(), to make them safe and surface typos cleanly as ValueError. --- src/cmlxc/cli.py | 3 ++- src/cmlxc/driver_base.py | 21 ++++++++++++++++++--- tests/test_cli.py | 18 ++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/cmlxc/cli.py b/src/cmlxc/cli.py index 02dff4c..cb6f6cb 100644 --- a/src/cmlxc/cli.py +++ b/src/cmlxc/cli.py @@ -683,7 +683,8 @@ def main(args=None): if res is None: res = 0 return res - except SetupError as exc: + except (SetupError, ValueError) as exc: + # ValueError is the bad-user-input signal out.red(str(exc)) return 1 except KeyboardInterrupt: diff --git a/src/cmlxc/driver_base.py b/src/cmlxc/driver_base.py index dc414d2..0706592 100644 --- a/src/cmlxc/driver_base.py +++ b/src/cmlxc/driver_base.py @@ -41,6 +41,21 @@ def description(self) -> str: return f"ref {self.ref!r} from {self.url}" +# A ref is interpolated into `git fetch` / `git checkout` inside `bash -ec` +# on the builder, so restrict it to characters git actually allows in a ref +_REF_RE = re.compile(r"[\w./@+-]+") + + +def _remote_spec(url, ref): + """Build a remote SourceSpec after validating *ref*.""" + if not _REF_RE.fullmatch(ref or ""): + raise ValueError( + f"Invalid ref {ref!r}." + " Refs may contain letters, digits, and any of . / @ + - _" + ) + return SourceSpec("remote", url=url, ref=ref) + + def parse_source(value: str, default_url: str) -> SourceSpec: """Turn a SOURCE string into a typed spec. @@ -52,15 +67,15 @@ def parse_source(value: str, default_url: str) -> SourceSpec: if value.startswith(("/", ".")): return SourceSpec("local", path=Path(value)) if value.startswith("@"): - return SourceSpec("remote", url=default_url, ref=value[1:]) + return _remote_spec(default_url, value[1:]) if "://" in value: if "@" in value: url, _, ref = value.rpartition("@") if url: - return SourceSpec("remote", url=url, ref=ref) + return _remote_spec(url, ref) return SourceSpec("remote", url=value, ref="main") if "/" in value: - return SourceSpec("remote", url=default_url, ref=value) + return _remote_spec(default_url, value) raise ValueError(f"Invalid SOURCE: {value!r}. Use @ref, /path, ./path, or URL@ref.") diff --git a/tests/test_cli.py b/tests/test_cli.py index c954ee7..fe991ae 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -39,6 +39,24 @@ def test_parse_source_rejects_invalid(value): parse_source(value, URL) +@pytest.mark.parametrize( + "value", + [ + "@main; rm -rf /", + "@$(id)", + "@`id`", + "@main whoami", + "@main'", + "@", + "https://github.com/fork/relay.git@main;id", + ], +) +def test_parse_source_rejects_unsafe_ref(value): + # refs reach `git checkout` inside `bash -ec` on the builder + with pytest.raises(ValueError, match="Invalid ref"): + parse_source(value, URL) + + @pytest.mark.parametrize("bad", [".", "..", "../relay", "/path", "a/b", "a.b"]) def test_validate_relay_name_rejects_invalid(bad): with pytest.raises(ValueError, match="Invalid relay name"): From 87c42dabe09c0778cb14b57f0edf437cbf55507a Mon Sep 17 00:00:00 2001 From: j4n Date: Wed, 22 Apr 2026 17:19:14 +0200 Subject: [PATCH 4/4] feat(cli): auto-detect RUNNER_DEBUG for verbose output --- src/cmlxc/cli.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/cmlxc/cli.py b/src/cmlxc/cli.py index cb6f6cb..8b3eb92 100644 --- a/src/cmlxc/cli.py +++ b/src/cmlxc/cli.py @@ -5,6 +5,7 @@ """ import argparse +import os import subprocess from pathlib import Path @@ -677,6 +678,10 @@ def main(args=None): if args.func is None: return parser.parse_args(["-h"]) + # Enable max verbosity when GitHub Actions debug logging is on + if not args.verbose and os.environ.get("RUNNER_DEBUG") == "1": + args.verbose = 3 + out = Out(verbosity=args.verbose) try: res = args.func(args, out)