Skip to content

fix: UpdateContainerPins wipes containers section on every gh aw update run#41262

Open
Copilot wants to merge 3 commits into
mainfrom
copilot/fix-update-command-issue
Open

fix: UpdateContainerPins wipes containers section on every gh aw update run#41262
Copilot wants to merge 3 commits into
mainfrom
copilot/fix-update-command-issue

Conversation

Copilot AI commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

gh aw update was deleting the entire "containers" section from actions-lock.json on every run. After the first successful pin run, lock files embed digest-pinned references (image:tag@sha256:...). collectImagesFromLockFiles returns those full strings, but container pin keys use plain base tags (image:tag). PruneStaleContainerPins found zero key matches and pruned everything.

Changes

  • update_container_pins.go — strip @sha256:... suffix with strings.Cut when building imageSet, so keys match the base-tag keys in the container pins map:
// before
imageSet[img] = struct{}{}

// after
base, _, _ := strings.Cut(img, "@sha256:")
imageSet[base] = struct{}{}
  • update_container_pins_test.go — adds TestUpdateContainerPins_PinnedLockFilesPreserveContainerPins to cover the case where lock files already contain digest-pinned references.

Generated by 👨‍🍳 PR Sous Chef · 92.1 AIC · ⌖ 1.08 AIC · ⊞ 17.6K ·

…ent pruning all container pins

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title fix: UpdateContainerPins strips digest from imageSet to prevent pruning all container pins fix: UpdateContainerPins wipes containers section on every gh aw update run Jun 24, 2026
Copilot AI requested a review from pelikhan June 24, 2026 16:13
@pelikhan pelikhan marked this pull request as ready for review June 24, 2026 16:23
Copilot AI review requested due to automatic review settings June 24, 2026 16:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes gh aw update unintentionally pruning all container pins from .github/aw/actions-lock.json when compiled .lock.yml files already contain digest-pinned references (e.g. image:tag@sha256:...). The change normalizes image keys used for pruning so they match the base-tag keys used in the container pins map.

Changes:

  • Strip the @sha256:... digest suffix when building the knownImages set used by PruneStaleContainerPins, preventing false “stale” pruning.
  • Add a regression test covering the pinned-lock-file scenario (but it should exercise UpdateContainerPins end-to-end to avoid duplicating production logic).
Show a summary per file
File Description
pkg/cli/update_container_pins.go Normalizes lock-file image refs to base tags before pruning container pins.
pkg/cli/update_container_pins_test.go Adds a regression test for pinned lock-file refs (currently duplicates normalization logic rather than calling UpdateContainerPins).

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 2

Comment on lines 5 to 9
import (
"os"
"path/filepath"
"strings"
"testing"
Comment on lines +218 to +237
// Build imageSet the same way UpdateContainerPins does: strip @sha256: suffix so
// the keys match the base-tag keys used in the container pins map.
imageSet := make(map[string]struct{})
for _, img := range images {
base, _, _ := strings.Cut(img, "@sha256:")
imageSet[base] = struct{}{}
}
assert.Equal(t, map[string]struct{}{"ghcr.io/github/gh-aw-firewall/agent:0.27.9": {}}, imageSet)

// With the correct imageSet (base tags), PruneStaleContainerPins should keep the pin.
cache := workflow.NewActionCache(tmpDir)
require.NoError(t, cache.Load())

pruned := cache.PruneStaleContainerPins(imageSet)
assert.Equal(t, 0, pruned, "no container pins should be pruned when lock files use pinned references")

pin, ok := cache.GetContainerPin("ghcr.io/github/gh-aw-firewall/agent:0.27.9")
require.True(t, ok, "container pin should still be present")
assert.Equal(t, "sha256:13f522853a688bfe24b04adbbe40b68101e8ef4b6fe0b636068527141bf1c269", pin.Digest)

@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR #41262 does not have the 'implementation' label and has 79 new lines (≤100) in business logic directories.

@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions github-actions Bot mentioned this pull request Jun 24, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Skills-Based Review 🧠

Applied /diagnose and /tdd — the production fix is correct; two test quality observations noted.

📋 Key Themes & Highlights

Key Themes

  • Test duplicates production logic (line 225): the imageSet-building loop is re-implemented in the test rather than exercised through the production path, creating a latent regression blind spot.
  • Weak file assertion (line 241): cache.Save() is never called before the final os.ReadFile check, so that assertion always passes and adds no safety — it reads the original setup fixture unchanged.

Positive Highlights

  • ✅ Root cause correctly identified and fixed with a single surgical strings.Cut call — exactly the right approach.
  • ✅ Regression test is well-named, well-commented, and covers the exact failure scenario end-to-end at the component level.
  • ✅ The core assertions (pruned == 0 and GetContainerPin) are strong guards against regressions.
  • ✅ No unrelated behaviour changes; the fix is confined to the key-normalisation step.

Note on end-to-end coverage

Because every image in this lock file is already digest-pinned, UpdateContainerPins would skip all Docker calls (line 127 early-return). A future improvement could call UpdateContainerPins directly here for full integration coverage without needing a Docker daemon.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 60.6 AIC · ⌖ 7.79 AIC · ⊞ 6.5K

base, _, _ := strings.Cut(img, "@sha256:")
imageSet[base] = struct{}{}
}
assert.Equal(t, map[string]struct{}{"ghcr.io/github/gh-aw-firewall/agent:0.27.9": {}}, imageSet)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] The test re-implements the imageSet-building loop from UpdateContainerPins (lines 220–224), duplicating production logic. If that loop changes, this test may not detect the regression — it just verifies its own copy of the logic.

💡 Suggested improvement

Either:

  • Extract a small buildNormalizedImageSet(images []string) map[string]struct{} helper, then call it from both UpdateContainerPins and the test — so the test exercises the real implementation.
  • Or call UpdateContainerPins end-to-end. No Docker daemon is needed here because all images in the lock file are already digest-pinned (line 127 strings.Contains(image, "@sha256:") exits early for all of them). An e2e call would provide stronger regression coverage.

The pruned == 0 assertion below is the real guard; this block mostly documents the logic rather than testing the production path.

// Verify the lock file is unchanged (the existing pin covers this image).
data, err := os.ReadFile(actionsLockPath)
require.NoError(t, err)
assert.Contains(t, string(data), "ghcr.io/github/gh-aw-firewall/agent:0.27.9", "container pin should still be in actions-lock.json")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] This file assertion always passes regardless of whether the bug is present. cache.Save() is never called in this test, so actionsLockPath still contains the content written during setup — the file was never modified.

💡 How to make this assertion meaningful

Option A — remove it: the pruned == 0 check and GetContainerPin assertions already cover the invariant at the in-memory level, which is where the bug manifests.

Option B — make it a real disk-level guard by adding a Save+reload cycle:

require.NoError(t, cache.Save())
data, err := os.ReadFile(actionsLockPath)
require.NoError(t, err)
assert.Contains(t, string(data), "ghcr.io/github/gh-aw-firewall/agent:0.27.9")

This would catch regressions where pruning happens correctly in-memory but Save serialises the wrong state.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔎 Code quality review by PR Code Quality Reviewer · 109.9 AIC · ⌖ 6.4 AIC · ⊞ 5.2K

// Verify the lock file is unchanged (the existing pin covers this image).
data, err := os.ReadFile(actionsLockPath)
require.NoError(t, err)
assert.Contains(t, string(data), "ghcr.io/github/gh-aw-firewall/agent:0.27.9", "container pin should still be in actions-lock.json")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Trivially-true assertion provides no signal: cache.Save() is never called in this test, so the file on disk is byte-for-byte identical to what os.WriteFile wrote at setup — the assertion cannot fail under any code path exercised here.

💡 Details

The comment says "Verify the lock file is unchanged", implying a post-save check, but since cache.Save() is never called the file trivially cannot have changed. The actual regression (pruning firing when it should not) is already fully caught at:

assert.Equal(t, 0, pruned, "no container pins should be pruned...")

When pruned == 0 the cache is not dirty, so even if Save() were called it would be a no-op. The assert.Contains adds noise without coverage. Consider removing it, or — better — follow the pattern in TestUpdateContainerPins_PrunesStaleEntries by calling cache.Save() and then reading the file back to meaningfully verify the on-disk state.

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 90/100 — Excellent

Analyzed 1 test: 1 design, 0 implementation, 0 guideline violations.

📊 Metrics & Test Classification (1 test analyzed)
Metric Value
New/modified tests analyzed 1
✅ Design tests (behavioral contracts) 1 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 1 (100%)
Duplicate test clusters 0
Test inflation detected YES — 71 test lines added vs 8 production lines (8.9:1 ratio); inflation is largely fixture setup code for a realistic regression scenario
🚨 Coding-guideline violations 0
Test File Classification Issues Detected
TestUpdateContainerPins_PinnedLockFilesPreserveContainerPins pkg/cli/update_container_pins_test.go ✅ Design 10/13 assertions missing message arg (suggestion)

Go: 1 (*_test.go); JavaScript: 0. Other languages: none detected.

Assertion detail (13 total):

# Call Has message?
1 require.NoError(t, os.MkdirAll(actionsLockDir, ...))
2 require.NoError(t, os.WriteFile(actionsLockPath, ...))
3 require.NoError(t, os.MkdirAll(workflowsDir, ...))
4 require.NoError(t, os.WriteFile(...lock.yml, ...))
5 require.NoError(t, err) after collectImagesFromLockFiles
6 require.Equal(t, []string{...}, images)
7 assert.Equal(t, map[string]struct{}{...}, imageSet)
8 require.NoError(t, cache.Load())
9 assert.Equal(t, 0, pruned, "no container pins should be pruned...")
10 require.True(t, ok, "container pin should still be present")
11 assert.Equal(t, "sha256:...", pin.Digest)
12 require.NoError(t, err) after os.ReadFile
13 assert.Contains(t, string(data), "...:0.27.9", "container pin should still be in actions-lock.json")

Scaffolding require.NoError calls (rows 1–5, 8, 12) do not require message args. Rows 6, 7, and 11 are the main candidates.

⚠️ Flagged Tests — Suggestions Only (not blocking)

TestUpdateContainerPins_PinnedLockFilesPreserveContainerPins (pkg/cli/update_container_pins_test.go) — ✅ Design test (regression coverage for #bug-fix).

Suggestion 1 — Add message args to bare behavioral assertions:

// Row 6 — clarify what images is expected to contain
require.Equal(t, []string{"ghcr.io/...:0.27.9@sha256:..."}, images,
    "collectImagesFromLockFiles should return digest-pinned reference as-is")

// Row 7 — clarify why imageSet uses base tags
assert.Equal(t, map[string]struct{}{"ghcr.io/...:0.27.9": {}}, imageSet,
    "imageSet keys should be base tags (digest suffix stripped)")

// Row 11 — clarify which field is being verified
assert.Equal(t, "sha256:13f522...", pin.Digest, "retained pin should carry the original digest")

Suggestion 2 — Consider inlining the imageSet intermediate assertion:
Rows 6–7 re-implement a fragment of the production logic (strings.Cut) inside the test, making this a partial implementation test. The behavioral outcome (pin is not pruned) is already verified by rows 9–13. Consider removing the imageSet assertion or replacing it with a direct end-to-end UpdateContainerPins call so the test does not duplicate internal logic.

Verdict

Check passed. 0% implementation tests (threshold: 30%). The single new test is a focused regression test that verifies the behavioral contract — container pins are preserved when lock files already embed digest-pinned references — using real filesystem I/O and the live workflow.ActionCache API. No mocks, no missing build tags.

🧪 Test quality analysis by Test Quality Sentinel · 101.7 AIC · ⌖ 21.2 AIC · ⊞ 8.4K ·

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Test Quality Sentinel: 90/100. Test quality is excellent - 0% of new tests are implementation tests (threshold: 30%). The regression test covers a clear behavioral contract using real I/O, no mocks.

@github-actions

Copy link
Copy Markdown
Contributor

@copilot refresh the branch and rerun checks, then summarize any remaining blockers.

Generated by 👨‍🍳 PR Sous Chef · 92.1 AIC · ⌖ 1.08 AIC · ⊞ 17.6K ·

@github-actions

Copy link
Copy Markdown
Contributor

@copilot review all comments and address the unresolved review feedback in pkg/cli/update_container_pins_test.go.

Generated by 👨‍🍳 PR Sous Chef · 92.1 AIC · ⌖ 1.08 AIC · ⊞ 17.6K ·

@github-actions

Copy link
Copy Markdown
Contributor

``
@copilot review all comments and address unresolved review feedback.

Please address the review feedback in pkg/cli/update_container_pins_test.go, then re-run the focused tests.

Generated by 👨‍🍳 PR Sous Chef · 66.4 AIC · ⌖ 1.12 AIC · ⊞ 17.6K ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants