diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 4257855d130..fa53d98f9d4 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -153,12 +153,6 @@ "version": "v1.314.0", "sha": "9eb537ca036ebaed86729dcb9309076e4c5c3b74" }, - "ruby/setup-ruby@v1.319.0": { - "repo": "ruby/setup-ruby", - "version": "v1.319.0", - "sha": "", - "released_at": "2026-07-16T06:10:13Z" - }, "safedep/pmg@v1": { "repo": "safedep/pmg", "version": "v1", diff --git a/pkg/actionpins/actionpins.go b/pkg/actionpins/actionpins.go index 34abf552de4..b0392c1d747 100644 --- a/pkg/actionpins/actionpins.go +++ b/pkg/actionpins/actionpins.go @@ -122,15 +122,7 @@ func getActionPins() []ActionPin { actionPinsOnce.Do(func() { actionPinsLog.Print("Unmarshaling action pins from embedded JSON (first call, will be cached)") - var data ActionPinsData - if err := json.Unmarshal(actionPinsJSON, &data); err != nil { - actionPinsLog.Printf("Failed to unmarshal action pins JSON: %v", err) - panic(fmt.Sprintf("failed to load action pins: %v", err)) - } - - if n := countPinKeyMismatches(data.Entries); n > 0 { - actionPinsLog.Printf("Found %d key/version mismatches in action_pins.json", n) - } + data := loadActionPinsData(actionPinsJSON) pins := slices.Collect(maps.Values(data.Entries)) @@ -157,6 +149,27 @@ func getActionPins() []ActionPin { return cachedActionPins } +// loadActionPinsData unmarshals embedded action pin data. +// Panics if the embedded JSON is invalid or any entry has an empty SHA, because +// those conditions indicate corrupted release data that would produce invalid workflow YAML. +func loadActionPinsData(raw []byte) ActionPinsData { + var data ActionPinsData + if err := json.Unmarshal(raw, &data); err != nil { + actionPinsLog.Printf("Failed to unmarshal action pins JSON: %v", err) + panic(fmt.Sprintf("failed to load action pins: %v", err)) + } + + if n := countPinKeyMismatches(data.Entries); n > 0 { + actionPinsLog.Printf("Found %d key/version mismatches in action_pins.json", n) + } + + if emptyKeys := collectEntriesWithEmptySHA(data.Entries); len(emptyKeys) > 0 { + panic(fmt.Sprintf("action_pins.json has %d entries with empty SHA %v — these would produce invalid workflow YAML (e.g. 'owner/repo@ # version'); remove or fix these entries before releasing", len(emptyKeys), emptyKeys)) + } + + return data +} + // countPinKeyMismatches returns the number of entries where the key version does not // match pin.Version, logging each mismatch for diagnostics. func countPinKeyMismatches(entries map[string]ActionPin) int { @@ -175,6 +188,20 @@ func countPinKeyMismatches(entries map[string]ActionPin) int { return count } +// collectEntriesWithEmptySHA returns the keys of entries whose SHA field is empty, +// logging each offending entry for diagnostics. +func collectEntriesWithEmptySHA(entries map[string]ActionPin) []string { + var keys []string + for key, pin := range entries { + if pin.SHA == "" { + keys = append(keys, key) + actionPinsLog.Printf("ERROR: Empty SHA in action_pins.json: key=%s repo=%s version=%s", key, pin.Repo, pin.Version) + } + } + slices.Sort(keys) + return keys +} + // buildByRepoIndex groups pins by repository and sorts each group by version descending. func buildByRepoIndex(pins []ActionPin) map[string][]ActionPin { byRepo := make(map[string][]ActionPin, len(pins)) @@ -226,7 +253,12 @@ func getLatestActionPinReference(repo string) string { // FormatPinnedActionReference formats a pinned action reference with repo, SHA, and version comment. // Example: "actions/checkout@abc123 # v4.1.0" +// Panics if sha is empty, because that would emit invalid workflow YAML and indicates +// a programming error or corrupted action pin data that should already have been rejected. func FormatPinnedActionReference(repo, sha, version string) string { + if sha == "" { + panic(fmt.Sprintf("FormatPinnedActionReference called with empty SHA for repo=%s version=%s — this would produce invalid workflow YAML", repo, version)) + } return repo + "@" + sha + " # " + version } diff --git a/pkg/actionpins/actionpins_internal_test.go b/pkg/actionpins/actionpins_internal_test.go index b19da35b346..3f2363e3b4a 100644 --- a/pkg/actionpins/actionpins_internal_test.go +++ b/pkg/actionpins/actionpins_internal_test.go @@ -63,6 +63,59 @@ func TestCountPinKeyMismatches_ReturnsOnlyVersionMismatches(t *testing.T) { }) } +func TestCollectEntriesWithEmptySHA_ReturnsOnlyEmptySHAEntries(t *testing.T) { + t.Run("returns empty slice for empty entries", func(t *testing.T) { + assert.Empty(t, collectEntriesWithEmptySHA(map[string]ActionPin{}), "Expected empty input to produce empty result") + }) + + t.Run("returns empty slice when all entries have non-empty SHAs", func(t *testing.T) { + entries := map[string]ActionPin{ + "actions/checkout@v5": {Repo: "actions/checkout", Version: "v5", SHA: "abc123"}, + "actions/setup-go@v4": {Repo: "actions/setup-go", Version: "v4", SHA: "def456"}, + } + assert.Empty(t, collectEntriesWithEmptySHA(entries), "Expected empty result when all SHAs are populated") + }) + + t.Run("returns key of entry with empty SHA", func(t *testing.T) { + entries := map[string]ActionPin{ + "actions/checkout@v5": {Repo: "actions/checkout", Version: "v5", SHA: "abc123"}, + "ruby/setup-ruby@v1.319.0": {Repo: "ruby/setup-ruby", Version: "v1.319.0", SHA: ""}, + } + assert.Equal(t, []string{"ruby/setup-ruby@v1.319.0"}, collectEntriesWithEmptySHA(entries), "Expected the empty-SHA entry key to be returned") + }) + + t.Run("returns sorted keys of multiple entries with empty SHA", func(t *testing.T) { + entries := map[string]ActionPin{ + "actions/checkout@v5": {Repo: "actions/checkout", Version: "v5", SHA: "abc123"}, + "ruby/setup-ruby@v1.319.0": {Repo: "ruby/setup-ruby", Version: "v1.319.0", SHA: ""}, + "owner/repo@v2": {Repo: "owner/repo", Version: "v2", SHA: ""}, + } + assert.Equal(t, []string{"owner/repo@v2", "ruby/setup-ruby@v1.319.0"}, collectEntriesWithEmptySHA(entries), "Expected sorted keys of empty-SHA entries") + }) +} + +func TestLoadActionPinsData_PanicsWhenEntrySHAIsEmpty(t *testing.T) { + fixture := []byte(`{ + "entries": { + "ruby/setup-ruby@v1.319.0": { + "repo": "ruby/setup-ruby", + "version": "v1.319.0", + "sha": "" + } + } + }`) + + assert.Panics(t, func() { + loadActionPinsData(fixture) + }, "Expected loadActionPinsData to panic when embedded pin data contains an empty SHA") +} + +func TestFormatPinnedActionReference_PanicsWhenSHAIsEmpty(t *testing.T) { + assert.Panics(t, func() { + FormatPinnedActionReference("ruby/setup-ruby", "", "v1.319.0") + }, "Expected FormatPinnedActionReference to panic when SHA is empty") +} + func TestInitWarnings_InitializesAndPreservesMap(t *testing.T) { t.Run("initializes nil warnings map", func(t *testing.T) { ctx := &PinContext{} diff --git a/pkg/actionpins/data/action_pins.json b/pkg/actionpins/data/action_pins.json index 4257855d130..fa53d98f9d4 100644 --- a/pkg/actionpins/data/action_pins.json +++ b/pkg/actionpins/data/action_pins.json @@ -153,12 +153,6 @@ "version": "v1.314.0", "sha": "9eb537ca036ebaed86729dcb9309076e4c5c3b74" }, - "ruby/setup-ruby@v1.319.0": { - "repo": "ruby/setup-ruby", - "version": "v1.319.0", - "sha": "", - "released_at": "2026-07-16T06:10:13Z" - }, "safedep/pmg@v1": { "repo": "safedep/pmg", "version": "v1", diff --git a/pkg/workflow/data/action_pins.json b/pkg/workflow/data/action_pins.json index 4257855d130..fa53d98f9d4 100644 --- a/pkg/workflow/data/action_pins.json +++ b/pkg/workflow/data/action_pins.json @@ -153,12 +153,6 @@ "version": "v1.314.0", "sha": "9eb537ca036ebaed86729dcb9309076e4c5c3b74" }, - "ruby/setup-ruby@v1.319.0": { - "repo": "ruby/setup-ruby", - "version": "v1.319.0", - "sha": "", - "released_at": "2026-07-16T06:10:13Z" - }, "safedep/pmg@v1": { "repo": "safedep/pmg", "version": "v1",