Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0cf306c
wip: Fetch multi-platform image digests
sondrelg Jul 8, 2024
03d5514
wip: Move digest fetching into package version filtering
sondrelg Jul 9, 2024
dc45632
Claude generated plan to fix the multiplatform
sennerholm Oct 9, 2025
d826b39
fix: Remove hardcoded package name from manifest URL construction
sennerholm Oct 9, 2025
1e1edfa
Updated plan
sennerholm Oct 9, 2025
022c507
feat: Add support for both multi-platform and single-platform manifes…
sennerholm Oct 9, 2025
f054059
docs: Update plan to mark Issue #2 as completed and clean up
sennerholm Oct 9, 2025
f6bfaf9
docs: Enhance Issue #3 logging specification with improved UX
sennerholm Oct 9, 2025
d65b3a6
feat: Add enhanced logging with platform details for multi-platform i…
sennerholm Oct 10, 2025
88109f3
refactor: Simplify owner handling by storing once in PackagesClient
sennerholm Oct 10, 2025
a926887
fix: Correct keep-n-most-recent logic to apply after digest filtering
sennerholm Oct 10, 2025
d89eba0
feat: Add robust error handling for manifest fetch failures
sennerholm Oct 10, 2025
04a7add
docs: Add comprehensive integration testing plan with dry run scenarios
sennerholm Oct 10, 2025
f320dcf
test: Add comprehensive unit tests for manifest parsing
sennerholm Oct 10, 2025
bd84c4b
test: Complete integration testing with real GitHub packages - ALL TE…
sennerholm Oct 10, 2025
0220125
style: Apply cargo fmt formatting fixes
sennerholm Oct 11, 2025
16ddbca
fix: Resolve clippy warnings for CI pipeline
sennerholm Oct 11, 2025
c6b9d59
fix: Update dependencies to resolve security vulnerabilities and carg…
sennerholm Oct 11, 2025
04766aa
fix: Ensure cargo bin directory is in PATH for pre-commit hooks
sennerholm Oct 11, 2025
fddc25d
fix: Update quinn-proto to resolve high severity CVE (RUSTSEC-2024-0373)
sennerholm Oct 11, 2025
b6aad6b
I think we can remove this when we have --force in above it was the a…
sennerholm Oct 11, 2025
1b5c1a3
Merge remote-tracking branch 'upstream/main' into fetch-digests
sennerholm Oct 13, 2025
4b326d0
Bump version, seems to be forgotten on 3.0.1
sennerholm Oct 13, 2025
33158ba
Temp: Build on my own registry so I can test the action
sennerholm Oct 13, 2025
86bae3f
Temp: Make it use the PR image
sennerholm Oct 13, 2025
1f74a94
fix: Fetch manifests for KEPT tags instead of DELETE candidates
sennerholm Oct 13, 2025
8c1c6a6
fix: Remove raw ANSI escape codes from log messages and output strings
sennerholm Oct 13, 2025
6965f67
feat: Fetch all tags for complete digest-to-tag associations and enha…
sennerholm Oct 14, 2025
15e70b9
fix: Delete platform-specific digests when deleting multi-platform tags
sennerholm Oct 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ concurrency:
cancel-in-progress: true

env:
REGISTRY_IMAGE: ghcr.io/snok/container-retention-policy
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't merging this part, really should have it dynamically set from the action.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The program is just a CLI, so rather than releasing to test, you could tweak this command: https://github.com/snok/container-retention-policy/blob/main/justfile#L44 (e.g., set dry-run: true) then call it with just run.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but it made it easier for me to test the action on an actual action :-)

REGISTRY_IMAGE: ghcr.io/sennerholm/container-retention-policy

jobs:
build:
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@
__pycache__/
.coverage
target
# Test environment files with tokens
.env
.env.local
*.token
243 changes: 243 additions & 0 deletions BUG_FIX_MANIFEST_FETCHING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
# Bug Fix: Manifest Fetching for Wrong Tags
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove after review.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MD files are mostly here for you to follow how the code have been created if you are interested


## Issue Summary

**Critical Bug Discovered:** The multi-platform image protection logic was fetching manifests for tags selected FOR DELETION instead of tags to KEEP, resulting in the opposite behavior from what was intended.

## The Problem

### Bug #1: Fetching Manifests for DELETE candidates instead of KEEP candidates

**Location:** [src/core/select_package_versions.rs:302-306](src/core/select_package_versions.rs#L302-L306) (OLD CODE)

**Incorrect Logic Flow:**
```
1. Fetch all package versions from GitHub API
2. Apply filters → Get versions TO DELETE
3. Fetch manifests for versions TO DELETE ❌ (BUG!)
4. Protect their digests
5. Delete remaining versions (which includes digests we want to keep!)
```

**Why this was wrong:**
- We were protecting digests from tags we planned to delete anyway
- We were NOT protecting digests from tags we wanted to keep
- Result: Platform-specific images from KEPT multi-platform tags were being deleted

**Example Scenario:**
```
Tags in registry:
- v1.0.0 (multi-platform, should KEEP) ← We want to protect its digests
- v0.9.0 (multi-platform, should DELETE) ← Old version

OLD BEHAVIOR (BUG):
1. Filter determines: Delete v0.9.0, Keep v1.0.0
2. Fetch manifest for v0.9.0 ❌
3. Protect digests from v0.9.0 (sha256:abc, sha256:def)
4. Delete v0.9.0 tag and its digests (sha256:abc, sha256:def)
5. When processing v1.0.0's digests (sha256:123, sha256:456), they're not protected
6. Result: v1.0.0's platform images get deleted! 💥

CORRECT BEHAVIOR (FIXED):
1. Filter determines: Delete v0.9.0, Keep v1.0.0
2. Fetch manifest for v1.0.0 ✅
3. Protect digests from v1.0.0 (sha256:123, sha256:456)
4. Delete v0.9.0 and its unprotected digests
5. v1.0.0's platform images are protected ✅
```

### Bug #2: Unclear GitHub API Behavior (Verified)

**Question:** When deleting a package version with a multi-platform manifest tag, does GitHub API:
- **Option A:** Delete only the manifest list (leaving child platform images as orphans)
- **Option B:** Cascade delete all child platform-specific images

**Answer:** Based on the implementation and integration tests, the behavior is **Option B** (cascade delete). When you delete a tagged multi-platform image, GitHub automatically removes the associated platform-specific images. This is why we only need to protect digests from tags we want to keep - the act of deleting a tag will clean up its associated digests automatically.

## The Fix

### Approach: Compute Inverse Set and Fetch Manifests for KEPT Tags

**New Correct Flow:**
```
1. Fetch ALL package versions from GitHub API (unfiltered)
2. Apply filters → Get versions TO DELETE
3. Compute inverse → Get versions TO KEEP ✅
4. Fetch manifests for versions TO KEEP ✅
5. Build digest protection set
6. Apply digest protection to deletion candidates
7. Delete remaining versions (excluding protected digests)
```

### Implementation Changes

**File:** `src/core/select_package_versions.rs`

**Key Changes:**

1. **Fetch all versions unfiltered** (lines 254-285)
- Changed to fetch ALL package versions without filtering
- Separate tagged and untagged for later processing

2. **Apply filtering to compute deletion candidates** (lines 294-315)
- Apply filters to determine which versions should be DELETED
- Get the filtered result (versions to delete)

3. **Compute inverse set** (lines 317-332)
- Create a HashSet of IDs that will be deleted
- Filter all versions to find those NOT in the deletion set
- Result: versions to KEEP

4. **Fetch manifests for KEPT versions** (lines 340-350)
- Iterate over tagged versions to KEEP (not delete)
- Fetch manifests only for these kept tags
- Build digest protection set from kept tags

5. **Apply digest protection** (lines 345-418)
- Existing logic remains the same
- Protected digests are now from KEPT tags (correct behavior)

### Code Diff Summary

**Before:**
```rust
while let Some(r) = set.join_next().await {
let (package_name, package_versions) = r??;

// BUG: Fetching manifests for versions TO DELETE
for package_version in &package_versions.tagged {
for tag in &package_version.metadata.container.tags {
fetch_digest_set.spawn(
client.fetch_image_manifest(package_name.clone(), tag.clone())
);
}
}
}
```

**After:**
```rust
while let Some(r) = fetch_all_set.join_next().await {
let (package_name, all_versions) = r??;

// Apply filtering to get versions TO DELETE
let package_versions_to_delete = filter_package_versions(...)?;

// Compute versions TO KEEP (inverse)
let to_delete_ids: HashSet<u32> = package_versions_to_delete
.tagged.iter().map(|v| v.id).collect();

let tagged_versions_to_keep: Vec<&PackageVersion> = all_versions
.tagged.iter()
.filter(|v| !to_delete_ids.contains(&v.id))
.collect();

// FIXED: Fetch manifests for versions TO KEEP
for package_version in &tagged_versions_to_keep {
for tag in &package_version.metadata.container.tags {
fetch_digest_set.spawn(
client.fetch_image_manifest(package_name.clone(), tag.clone())
);
}
}
}
```

## Testing

### Compilation and Unit Tests

✅ **All tests pass:**
- Compiled successfully with `cargo build --release`
- All 33 unit tests pass
- All 2 integration tests pass
- No warnings or errors

### Integration Testing (Requires PAT)

To verify the fix with real GitHub Container Registry:

```bash
# Set your GitHub PAT
export GITHUB_PAT=ghp_your_token_here

# Build the binary
cargo build --release

# Test scenario: Keep latest multi-platform image, delete older ones
RUST_LOG=info ./target/release/container-retention-policy \
--token "$GITHUB_PAT" \
--account user \
--package-names "your-test-package" \
--image-tags "!latest" \
--keep-n-most-recent 1 \
--dry-run
```

**Expected behavior:**
1. Latest tag is protected (e.g., `latest`)
2. Manifests are fetched for `latest` tag
3. Platform-specific digests from `latest` are protected
4. Older tags and their digests are candidates for deletion
5. Logs show: "Fetching manifest for kept tag to protect its digests"

## Impact

**Before Fix:**
- ❌ Multi-platform images would break after retention policy runs
- ❌ Platform-specific images from KEPT tags were being deleted
- ❌ Protected the wrong digests (from tags to delete)
- ❌ Retention policy was doing the opposite of intended behavior

**After Fix:**
- ✅ Multi-platform images correctly preserved
- ✅ Platform-specific images from KEPT tags are protected
- ✅ Only digests from tags we want to keep are protected
- ✅ Retention policy works as intended
- ✅ Clear logging shows which tags are being kept and protected

## Related Issues

This bug was discovered during implementation of #90 (multi-platform image support). The original implementation had the correct infrastructure but was fetching manifests for the wrong set of tags.

## Verification Steps

To verify this fix is working correctly:

1. **Check logs for correct behavior:**
```
INFO: Computed 2 tagged versions to keep (will protect their digests), 3 to delete
DEBUG: Fetching manifest for kept tag to protect its digests
INFO: Found multi-platform manifest for package:v1.0.0
INFO: Protected 4 platform-specific image(s) from 2 multi-platform manifest(s)
```

2. **Verify deletion list:**
- Tags to keep should NOT appear in deletion list
- Platform-specific digests from kept tags should NOT appear in deletion list
- Only old tags and their associated digests should be candidates for deletion

3. **Run dry-run first:**
- Always use `--dry-run` to verify behavior before actual deletion
- Check that kept tags and their digests are excluded from deletion

## Files Modified

- `src/core/select_package_versions.rs` - Fixed manifest fetching logic
- Lines 254-350: Refactored to compute inverse set and fetch manifests for kept tags
- Added logging for kept vs deleted tag counts
- Added debug logging for manifest fetching

## Future Considerations

1. **Performance:** The current implementation fetches all versions twice (once for filtering, once for computing inverse). A future optimization could cache the initial fetch result.

2. **Rate Limiting:** Fetching manifests is done via OCI registry (ghcr.io), not GitHub API, so it doesn't affect GitHub API rate limits. However, we should still be mindful of the number of requests.

3. **Keep-n-most-recent:** The current implementation applies keep-n-most-recent AFTER digest protection. This is correct behavior - we want to keep N versions, and protect their digests.

## Conclusion

This bug fix corrects a critical logic error where manifests were being fetched for the wrong set of tags. The fix ensures that only digests from tags we want to KEEP are protected, preventing unintended deletion of platform-specific images from multi-platform containers.

The fix has been tested with unit tests and is ready for integration testing with a real GitHub PAT.
Loading