From fdeb389c6813bb5bde7f5f6b1ee41cc229614dfa Mon Sep 17 00:00:00 2001 From: Piotr Zaniewski Date: Fri, 6 Nov 2026 09:51:15 +0100 Subject: [PATCH 1/2] fix(delete-previous-head-image): keep referrers of retained manifests A packages-only tag can retain a platform manifest after referrer protection has already run, leaving its signatures eligible for deletion. Compute retained manifests first so their referrers survive cleanup. --- .../delete-previous-head-image/src/action.py | 28 +++++++++---------- .../test/test_action.py | 27 ++++++++++++++++++ 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/.github/actions/delete-previous-head-image/src/action.py b/.github/actions/delete-previous-head-image/src/action.py index f7f346b1..9d2c55dc 100644 --- a/.github/actions/delete-previous-head-image/src/action.py +++ b/.github/actions/delete-previous-head-image/src/action.py @@ -240,11 +240,6 @@ def delete(owner, name, token, previous, current, dry_run): if previous in protected: log(f"{previous} is still tagged {protected[previous]}; nothing to delete") return - # A kept manifest keeps its signatures and attestations too. - for digest, subject in signs.items(): - if subject in protected: - protected.setdefault(digest, protected[subject]) - packages = Packages(owner, name, token) versions = packages.versions(set(tree)) # The registry and the packages API are separate reads; trust the stricter one. @@ -255,17 +250,22 @@ def delete(owner, name, token, previous, current, dry_run): tags = ", ".join(sorted(versions[previous][1])) log(f"{previous} is still tagged {tags}; nothing to delete") return - plan, kept = [], [] + kept = {} for digest, allowed in tree.items(): if digest in protected: - kept.append(f"{digest}: also part of tag {protected[digest]}") + kept[digest] = f"also part of tag {protected[digest]}" elif digest not in versions: - kept.append(f"{digest}: no package version found") + kept[digest] = "no package version found" elif not versions[digest][1] <= allowed: extra = ", ".join(sorted(versions[digest][1] - allowed)) - kept.append(f"{digest}: carries tag {extra}") - else: - plan.append((digest, versions[digest][0])) + kept[digest] = f"carries tag {extra}" + + # A kept manifest keeps its signatures and attestations too, including when + # only the packages API revealed the reason to keep it. + for digest, subject in signs.items(): + if subject in kept: + kept.setdefault(digest, f"refers to kept manifest {subject}") + plan = [(digest, versions[digest][0]) for digest in tree if digest not in kept] verb = "would delete" if dry_run else "deleting" lines = [f"### Previous head image {previous}"] @@ -274,9 +274,9 @@ def delete(owner, name, token, previous, current, dry_run): lines.append(f"- {verb} `{digest}` (version {version_id})") if not dry_run: packages.delete(version_id) - for reason in kept: - log(f"keeping {reason}") - lines.append(f"- keeping {reason}") + for digest, reason in kept.items(): + log(f"keeping {digest}: {reason}") + lines.append(f"- keeping {digest}: {reason}") summary(lines) diff --git a/.github/actions/delete-previous-head-image/test/test_action.py b/.github/actions/delete-previous-head-image/test/test_action.py index f7a129d3..55852d8f 100644 --- a/.github/actions/delete-previous-head-image/test/test_action.py +++ b/.github/actions/delete-previous-head-image/test/test_action.py @@ -321,6 +321,33 @@ def test_version_with_an_unexpected_tag_is_kept(fake, tmp_path): assert "carries tag debug" in result.stdout +@pytest.mark.parametrize("dry_run", [False, True]) +def test_packages_only_child_tag_keeps_its_referrers(fake, tmp_path, dry_run): + old, new, manifests, tags, versions = candy() + child = d("1") + referrer_tag = "sha256-" + child[7:] + manifests[d("9")] = index(d("8")) + manifests[d("8")] = image() + tags[referrer_tag] = d("9") + server = fake(manifests=manifests, tags=tags) + server.versions[1] = version(2, child, "debug") + server.versions += [version(13, d("9"), referrer_tag), version(14, d("8"))] + + result = run( + server, tmp_path, mode="delete", previous_digest=old, current_digest=new, + dry_run=str(dry_run), + ) + + assert result.returncode == 0, result.stdout + assert sorted(server.deleted) == ([] if dry_run else [1, 3, 4, 5]) + verb = "would delete" if dry_run else "deleting" + for digest in (old, d("2"), d("3"), d("e")): + assert f"{verb} {digest}" in result.stdout + for digest in (child, d("9"), d("8")): + assert f"{verb} {digest}" not in result.stdout + assert "carries tag debug" in result.stdout + + def test_no_previous_digest_is_a_no_op(fake, tmp_path): server = fake() result = run( From 900f0aafc9addfaaa6ac6219411ab34788e67177 Mon Sep 17 00:00:00 2001 From: Caue Santos Date: Fri, 13 Nov 2026 12:55:53 -0600 Subject: [PATCH 2/2] docs(delete-previous-head-image): say every kept manifest keeps its referrers Related to DEVOPS-1831 --- .github/actions/delete-previous-head-image/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/delete-previous-head-image/README.md b/.github/actions/delete-previous-head-image/README.md index ad7c863b..2146a2d9 100644 --- a/.github/actions/delete-previous-head-image/README.md +++ b/.github/actions/delete-previous-head-image/README.md @@ -15,9 +15,9 @@ removes exactly that image after the new one is pushed: and each manifest in it It keeps anything that is still reachable from another tag, such as a release -whose image shares a manifest, together with that manifest's signatures and -attestations, and any version that carries a tag it did not -expect. If the previous image is still tagged (for example, promoted to a +whose image shares a manifest, and any version that carries a tag it did not +expect. Every manifest it keeps also keeps its signatures and attestations, +whichever reason kept it. If the previous image is still tagged (for example, promoted to a release), it deletes nothing. It checks that against both the registry tag list and the packages API, and if either shows a tag, it deletes nothing. Every deleted and kept version is logged and written to the step summary.