Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 8 additions & 3 deletions pkg/cli/update_container_pins.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,17 @@ func UpdateContainerPins(ctx context.Context, workflowDir string, verbose bool)
}
}

// Build a set of images currently referenced in the compiled lock files so
// that stale entries (e.g. superseded AWF versions) can be pruned.
// Build a set of base image tags (without @sha256: digest suffix) currently
// referenced in the compiled lock files so that stale entries (e.g. superseded
// AWF versions) can be pruned. Lock files that were previously compiled may
// already embed pinned references (image:tag@sha256:...), so we strip the
// digest before comparing against container pin keys, which always use the
// base tag as the key.
imageSet := make(map[string]struct {
}, len(images))
for _, img := range images {
imageSet[img] = struct {
base, _, _ := strings.Cut(img, "@sha256:")
imageSet[base] = struct {
}{}
}

Expand Down
71 changes: 71 additions & 0 deletions pkg/cli/update_container_pins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package cli

import (
"context"
"os"
"path/filepath"
"testing"
Comment on lines 5 to 9
Expand Down Expand Up @@ -170,6 +171,76 @@ Manifests:
}
}

// TestUpdateContainerPins_PinnedLockFilesPreserveContainerPins verifies that when
// lock files already contain digest-pinned image references (image:tag@sha256:...),
// the existing container pins in actions-lock.json are NOT pruned. This is the
// regression test for the bug where gh aw update wiped out all container pins
// because collectImagesFromLockFiles returned digest-suffixed keys that did not
// match the base-tag keys used in the container pins map.
//
// The test calls UpdateContainerPins end-to-end: because all images in the lock
// file are already digest-pinned, Docker is never invoked.
func TestUpdateContainerPins_PinnedLockFilesPreserveContainerPins(t *testing.T) {
tmpDir := t.TempDir()

// actions-lock.json has an existing container pin using the base image tag as key.
actionsLockDir := filepath.Join(tmpDir, ".github", "aw")
require.NoError(t, os.MkdirAll(actionsLockDir, 0755))
actionsLockContent := `{
"entries": {},
"containers": {
"ghcr.io/github/gh-aw-firewall/agent:0.27.9": {
"image": "ghcr.io/github/gh-aw-firewall/agent:0.27.9",
"digest": "sha256:13f522853a688bfe24b04adbbe40b68101e8ef4b6fe0b636068527141bf1c269",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.27.9@sha256:13f522853a688bfe24b04adbbe40b68101e8ef4b6fe0b636068527141bf1c269"
}
}
}
`
actionsLockPath := filepath.Join(actionsLockDir, "actions-lock.json")
require.NoError(t, os.WriteFile(actionsLockPath, []byte(actionsLockContent), 0644))

// The compiled lock file already embeds the digest-pinned reference (image:tag@sha256:...).
// This is the real-world case after a prior successful gh aw update run.
workflowsDir := filepath.Join(tmpDir, ".github", "workflows")
require.NoError(t, os.MkdirAll(workflowsDir, 0755))
lockFileContent := `name: test
jobs:
setup:
steps:
- name: Download container images
run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.9@sha256:13f522853a688bfe24b04adbbe40b68101e8ef4b6fe0b636068527141bf1c269
`
require.NoError(t, os.WriteFile(filepath.Join(workflowsDir, "my-workflow.lock.yml"), []byte(lockFileContent), 0644))

// UpdateContainerPins uses "." as the repo root for the action cache, so we
// chdir into tmpDir before calling it and restore the original directory after.
originalDir, _ := os.Getwd()
defer os.Chdir(originalDir) //nolint:errcheck
require.NoError(t, os.Chdir(tmpDir))

// Call UpdateContainerPins end-to-end. Because the lock file image is already
// digest-pinned (@sha256:...), Docker is never invoked. The function should
// prune zero pins (the bug caused it to prune all of them) and return false
// (no new pins were added).
added, err := UpdateContainerPins(context.Background(), workflowsDir, false)
require.NoError(t, err)
assert.False(t, added, "no new pins should be added when all images are already pinned")

// Reload the cache from disk and confirm the original pin is still present.
cache := workflow.NewActionCache(tmpDir)
require.NoError(t, cache.Load())

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

// Verify the on-disk lock file is unchanged.
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.

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.

}

// TestUpdateContainerPins_PrunesStaleEntries verifies that UpdateContainerPins
// removes container pin entries from actions-lock.json that are no longer
// referenced in the compiled lock files (e.g. superseded AWF versions).
Expand Down
Loading