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()