Stop optional image pruning after failed Tea login setup

Guard failed cleanup login setup before dependent package operations; preserve successful and optional cleanup semantics. Required contract CI 20382 passed. Exact task-specific Human review-count exception is recorded on t-c1070dtgpqqy; official architect review 8504 approved this head. No authentication repair, tag movement or consumer activation.

Authored-By: Codex (GPT-5) <noreply@openai.com>
This commit was merged in pull request #2.
This commit is contained in:
dfritz committed 2026-10-09 20:06:21 +00:00
1 parent 3f2d8336b6
commit 536765f5b9
3 files changed
+149 -4

No files matched your search

+11 -2
View File
@@ -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.
+5 -2
View File
@@ -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
+133
View File
@@ -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()