From 2d8a13840f94f183fc745ebfe6972723417af2bc Mon Sep 17 00:00:00 2001 From: Donavan Fritz Date: Fri, 9 Oct 2026 19:53:10 +0000 Subject: [PATCH] Stop package cleanup after failed Tea login setup Emit a fixed credential-free login_setup warning and fail the optional prune step before any dependent package API calls if its existing login setup fails. Preserve successful pruning, retention, registry upload and local cleanup. Exercise the extracted prune shell with fake Tea and disposable configuration, including failed setup with a previous login and unchanged successful targets. This identifies the failed stage, not the suppressed authentication cause. Ticket: https://agenthub.fritzlab.net/t-c1070dtgpqqy Authored-By: Codex (GPT-5) --- README.md | 13 ++++- action.yaml | 7 ++- tests/test_action.py | 133 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c947caf..f388f06 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,13 @@ local Docker daemon, image-push uploads it. delete the rest. Failures here do not fail the workflow (`continue-on-error: true`). +Cleanup first sets up its existing Tea login. If that setup fails, the optional +prune step exits with a fixed `login_setup` warning and does not list or delete +packages, even if a previous login is available. Login output remains suppressed +to avoid exposing credentials or configuration. This identifies the failing +stage, not the underlying authentication or configuration cause; it does not +repair login setup or change the successful cleanup's targets or retention. + `prune` accepts only the strings `true` and `false`; invalid input fails before registry login or push. With `false`, remote package listing and deletion are skipped. Image upload and local Docker cleanup remain unchanged. The action does @@ -50,5 +57,7 @@ not discover deployment pins; owners choosing pruning must account for that retention contract. Run `python3 -m unittest discover -s tests` with the pinned test dependencies in -`tests/requirements.txt`. Tests inspect the composite action and execute only -its credential-free validation step; no registry or package deletion runs. +`tests/requirements.txt`. Tests inspect the composite action, execute its +credential-free validation step, and exercise the prune shell with a fake Tea +client and disposable configuration. No real login, registry operation, or +package deletion runs. diff --git a/action.yaml b/action.yaml index 17d2d36..af26143 100644 --- a/action.yaml +++ b/action.yaml @@ -78,8 +78,11 @@ runs: KEEP: ${{ inputs.keep }} run: | set -euo pipefail - tea login add --name ci --url https://code.fritzlab.net \ - --token "$TOKEN" --no-version-check >/dev/null 2>&1 || true + if ! tea login add --name ci --url https://code.fritzlab.net \ + --token "$TOKEN" --no-version-check >/dev/null 2>&1; then + echo "::warning::image-push prune failed at login_setup; package cleanup skipped" >&2 + exit 1 + fi # Paginate the package list. The org has more container versions than a # single API page returns (Gitea caps page size at MAX_RESPONSE_ITEMS), # so an unpaginated call silently drops a package's older tags from the diff --git a/tests/test_action.py b/tests/test_action.py index a068032..aecc36f 100644 --- a/tests/test_action.py +++ b/tests/test_action.py @@ -1,6 +1,9 @@ +import json import os from pathlib import Path import subprocess +import sys +import tempfile import unittest import yaml @@ -34,5 +37,135 @@ class PruningContract(unittest.TestCase): self.assertEqual(result.returncode == 0, success) +class PruneLoginContract(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.action = yaml.safe_load((Path(__file__).resolve().parents[1] / "action.yaml").read_text()) + cls.prune = next(step for step in cls.action["runs"]["steps"] if step["name"] == "Prune old tags") + + def run_prune(self, login_exit=0, keep="3", existing_login=False): + # Only this fake executable can receive the synthetic token. Do not + # inherit the caller's environment or use its real Tea configuration. + with tempfile.TemporaryDirectory(prefix="image-push-fake-tea-") as directory: + root = Path(directory) + binary = root / "bin" + binary.mkdir() + config = root / "config" + config.mkdir() + marker = config / "fake-login" + if existing_login: + marker.write_text("previous fake login") + calls = root / "calls.jsonl" + fake = binary / "tea" + fake.write_text(f"#!{sys.executable}\n" + r''' +import json +import os +from pathlib import Path +import sys + +args = sys.argv[1:] +with Path(os.environ["FAKE_CALLS"]).open("a") as calls: + calls.write(json.dumps(args) + "\n") +marker = Path(os.environ["FAKE_CONFIG_ROOT"]) / "fake-login" +if args[:2] == ["login", "add"]: + failure = int(os.environ["FAKE_LOGIN_EXIT"]) + if failure: + # Deliberately sensitive-looking synthetic output must stay suppressed. + print("SYNTHETIC_TOKEN_VALUE stdout config=/fake/private") + print("SYNTHETIC_TOKEN_VALUE stderr config=/fake/private", file=sys.stderr) + sys.exit(failure) + marker.write_text("new fake login") + sys.exit(0) +if args[:1] == ["api"] and marker.exists(): + if len(args) == 2 and args[1].startswith("/packages/example?type=container&limit=50&page="): + page = args[1].rsplit("=", 1)[1] + pages = { + "1": [ + {"name": "widget", "version": "9"}, + {"name": "widget", "version": "latest"}, + {"name": "other", "version": "0"}, + {"name": "widget", "version": "2"}, + {"name": "widget", "version": "001"}, + {"name": "widget", "version": "12"}, + ], + "2": [ + {"name": "widget", "version": "4"}, + {"name": "other", "version": "100"}, + {"name": "widget", "version": "release-1"}, + ], + "3": [], + } + if page not in pages: + sys.exit(92) + print(json.dumps(pages[page])) + sys.exit(0) + if len(args) == 4 and args[:3] == ["api", "-X", "DELETE"]: + # Log a fake request only: this executable never sends a network request. + sys.exit(0) +sys.exit(93) +''') + fake.chmod(0o700) + result = subprocess.run( + ["bash", "-c", self.prune["run"]], + env={ + "PATH": str(binary) + os.pathsep + os.defpath, + "FAKE_CONFIG_ROOT": str(config), + "TOKEN": "SYNTHETIC_TOKEN_VALUE", + "ORG": "example", + "NAME": "widget", + "KEEP": keep, + "FAKE_CALLS": str(calls), + "FAKE_LOGIN_EXIT": str(login_exit), + }, + capture_output=True, + text=True, + timeout=10, + ) + recorded = [json.loads(line) for line in calls.read_text().splitlines()] + return result, recorded + + def test_failed_setup_stops_all_dependent_calls_without_leaking_output(self): + for failure in (1, 7): + for previous in (False, True): + with self.subTest(failure=failure, previous=previous): + result, calls = self.run_prune(login_exit=failure, existing_login=previous) + self.assertEqual(result.returncode, 1) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0], [ + "login", "add", "--name", "ci", "--url", "https://code.fritzlab.net", + "--token", "SYNTHETIC_TOKEN_VALUE", "--no-version-check", + ]) + self.assertEqual(result.stdout, "") + self.assertEqual(result.stderr, + "::warning::image-push prune failed at login_setup; package cleanup skipped\n") + self.assertNotIn("SYNTHETIC_TOKEN_VALUE", result.stdout + result.stderr) + self.assertNotIn("/fake/private", result.stdout + result.stderr) + + def test_success_keeps_pagination_selection_numeric_order_and_retention(self): + for keep, removed in (("3", ["001", "2"]), ("1", ["001", "2", "4", "9"]), ("5", [])): + with self.subTest(keep=keep): + result, calls = self.run_prune(keep=keep) + self.assertEqual(result.returncode, 0, result.stderr) + lists = [args[1] for args in calls if args[:1] == ["api"] and len(args) == 2] + self.assertEqual(lists, [ + f"/packages/example?type=container&limit=50&page={page}" for page in (1, 2, 3) + ]) + deletes = [args for args in calls if args[:3] == ["api", "-X", "DELETE"]] + self.assertEqual(deletes, [ + ["api", "-X", "DELETE", f"/packages/example/container/widget/{tag}"] for tag in removed + ]) + self.assertEqual(result.stdout, "".join(f"deleting widget:{tag}\n" for tag in removed)) + self.assertEqual(result.stderr, "") + self.assertNotIn("SYNTHETIC_TOKEN_VALUE", result.stdout + result.stderr) + + def test_cleanup_failure_remains_optional_and_login_contract_unchanged(self): + self.assertIs(self.prune["continue-on-error"], True) + self.assertEqual(self.prune["if"], "${{ inputs.prune == 'true' }}") + self.assertEqual(self.prune["env"], { + "TOKEN": "${{ inputs.token }}", "ORG": "${{ inputs.org }}", + "NAME": "${{ inputs.name }}", "KEEP": "${{ inputs.keep }}", + }) + + if __name__ == "__main__": unittest.main() -- 2.54.0