From 2359606d712cbb5d1e86c390bd3833ea1f44dbf4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:12:16 +0000 Subject: [PATCH 1/5] Initial plan From 81ae052143ee935432258bde910aa1f38269d25f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:34:15 +0000 Subject: [PATCH 2/5] fix: add runtime guard for empty SHA in action pins + remove bad ruby/setup-ruby@v1.319.0 entry - Remove ruby/setup-ruby@v1.319.0 with empty SHA from .github/aw/actions-lock.json and pkg/actionpins/data/action_pins.json - Add countEntriesWithEmptySHA validation function in pkg/actionpins/actionpins.go - Panic in getActionPins() when any embedded entry has an empty SHA to prevent invalid compiled workflows - Add panic guard in FormatPinnedActionReference when SHA is empty (defense-in-depth) - Add tests for countEntriesWithEmptySHA and FormatPinnedActionReference empty SHA guard Closes #46791 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/actions-lock.json | 6 ---- pkg/actionpins/actionpins.go | 20 ++++++++++++ pkg/actionpins/actionpins_internal_test.go | 37 ++++++++++++++++++++++ pkg/actionpins/data/action_pins.json | 6 ---- pkg/workflow/data/action_pins.json | 6 ---- 5 files changed, 57 insertions(+), 18 deletions(-) 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..5a037087aed 100644 --- a/pkg/actionpins/actionpins.go +++ b/pkg/actionpins/actionpins.go @@ -132,6 +132,10 @@ func getActionPins() []ActionPin { actionPinsLog.Printf("Found %d key/version mismatches in action_pins.json", n) } + if n := countEntriesWithEmptySHA(data.Entries); n > 0 { + panic(fmt.Sprintf("action_pins.json has %d entries with empty SHA — these would produce invalid workflow YAML (e.g. 'owner/repo@ # version'); remove or fix these entries before releasing", n)) + } + pins := slices.Collect(maps.Values(data.Entries)) slices.SortFunc(pins, func(a, b ActionPin) int { @@ -175,6 +179,19 @@ func countPinKeyMismatches(entries map[string]ActionPin) int { return count } +// countEntriesWithEmptySHA returns the number of entries whose SHA field is empty, +// logging each offending entry for diagnostics. +func countEntriesWithEmptySHA(entries map[string]ActionPin) int { + count := 0 + for key, pin := range entries { + if pin.SHA == "" { + count++ + actionPinsLog.Printf("ERROR: Empty SHA in action_pins.json: key=%s repo=%s version=%s", key, pin.Repo, pin.Version) + } + } + return count +} + // 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)) @@ -227,6 +244,9 @@ func getLatestActionPinReference(repo string) string { // FormatPinnedActionReference formats a pinned action reference with repo, SHA, and version comment. // Example: "actions/checkout@abc123 # v4.1.0" 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..c30310b9c48 100644 --- a/pkg/actionpins/actionpins_internal_test.go +++ b/pkg/actionpins/actionpins_internal_test.go @@ -63,6 +63,43 @@ func TestCountPinKeyMismatches_ReturnsOnlyVersionMismatches(t *testing.T) { }) } +func TestCountEntriesWithEmptySHA_ReturnsOnlyEmptySHAEntries(t *testing.T) { + t.Run("returns zero for empty entries", func(t *testing.T) { + assert.Zero(t, countEntriesWithEmptySHA(map[string]ActionPin{}), "Expected empty input to produce zero results") + }) + + t.Run("returns zero 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.Zero(t, countEntriesWithEmptySHA(entries), "Expected zero empty SHA entries when all SHAs are populated") + }) + + t.Run("counts 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: ""}, + } + assert.Equal(t, 1, countEntriesWithEmptySHA(entries), "Expected one entry with empty SHA to be counted") + }) + + t.Run("counts 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, 2, countEntriesWithEmptySHA(entries), "Expected two entries with empty SHA to be counted") + }) +} + +func TestFormatPinnedActionReference_PanicsOnEmptySHA(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", From 69eff5df2042538e2f96eaf0b755a8c9610d4a16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:36:44 +0000 Subject: [PATCH 3/5] refactor: use collectEntriesWithEmptySHA to include keys in panic message - Rename countEntriesWithEmptySHA to collectEntriesWithEmptySHA (returns []string keys) - Include the specific problematic entry keys in the panic message for actionability - Update test names to be consistent with the codebase (PanicsWhenSHAIsEmpty) - Improve test cases to verify sorted key output Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/actionpins/actionpins.go | 15 ++++++++------- pkg/actionpins/actionpins_internal_test.go | 20 ++++++++++---------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/pkg/actionpins/actionpins.go b/pkg/actionpins/actionpins.go index 5a037087aed..4cad8e80f09 100644 --- a/pkg/actionpins/actionpins.go +++ b/pkg/actionpins/actionpins.go @@ -132,8 +132,8 @@ func getActionPins() []ActionPin { actionPinsLog.Printf("Found %d key/version mismatches in action_pins.json", n) } - if n := countEntriesWithEmptySHA(data.Entries); n > 0 { - panic(fmt.Sprintf("action_pins.json has %d entries with empty SHA — these would produce invalid workflow YAML (e.g. 'owner/repo@ # version'); remove or fix these entries before releasing", 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)) } pins := slices.Collect(maps.Values(data.Entries)) @@ -179,17 +179,18 @@ func countPinKeyMismatches(entries map[string]ActionPin) int { return count } -// countEntriesWithEmptySHA returns the number of entries whose SHA field is empty, +// collectEntriesWithEmptySHA returns the keys of entries whose SHA field is empty, // logging each offending entry for diagnostics. -func countEntriesWithEmptySHA(entries map[string]ActionPin) int { - count := 0 +func collectEntriesWithEmptySHA(entries map[string]ActionPin) []string { + var keys []string for key, pin := range entries { if pin.SHA == "" { - count++ + keys = append(keys, key) actionPinsLog.Printf("ERROR: Empty SHA in action_pins.json: key=%s repo=%s version=%s", key, pin.Repo, pin.Version) } } - return count + slices.Sort(keys) + return keys } // buildByRepoIndex groups pins by repository and sorts each group by version descending. diff --git a/pkg/actionpins/actionpins_internal_test.go b/pkg/actionpins/actionpins_internal_test.go index c30310b9c48..eaaf6e1d4e9 100644 --- a/pkg/actionpins/actionpins_internal_test.go +++ b/pkg/actionpins/actionpins_internal_test.go @@ -63,38 +63,38 @@ func TestCountPinKeyMismatches_ReturnsOnlyVersionMismatches(t *testing.T) { }) } -func TestCountEntriesWithEmptySHA_ReturnsOnlyEmptySHAEntries(t *testing.T) { - t.Run("returns zero for empty entries", func(t *testing.T) { - assert.Zero(t, countEntriesWithEmptySHA(map[string]ActionPin{}), "Expected empty input to produce zero results") +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 zero when all entries have non-empty SHAs", func(t *testing.T) { + 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.Zero(t, countEntriesWithEmptySHA(entries), "Expected zero empty SHA entries when all SHAs are populated") + assert.Empty(t, collectEntriesWithEmptySHA(entries), "Expected empty result when all SHAs are populated") }) - t.Run("counts entries with empty SHA", func(t *testing.T) { + 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, 1, countEntriesWithEmptySHA(entries), "Expected one entry with empty SHA to be counted") + assert.Equal(t, []string{"ruby/setup-ruby@v1.319.0"}, collectEntriesWithEmptySHA(entries), "Expected the empty-SHA entry key to be returned") }) - t.Run("counts multiple entries with empty SHA", func(t *testing.T) { + 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, 2, countEntriesWithEmptySHA(entries), "Expected two entries with empty SHA to be counted") + assert.Equal(t, []string{"owner/repo@v2", "ruby/setup-ruby@v1.319.0"}, collectEntriesWithEmptySHA(entries), "Expected sorted keys of empty-SHA entries") }) } -func TestFormatPinnedActionReference_PanicsOnEmptySHA(t *testing.T) { +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") From 9d2aee1f66192828bf095519acaee6517e60b40a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:35:54 +0000 Subject: [PATCH 4/5] fix: document and test empty-SHA panic guards Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/actionpins/actionpins.go | 34 +++++++++++++--------- pkg/actionpins/actionpins_internal_test.go | 16 ++++++++++ 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/pkg/actionpins/actionpins.go b/pkg/actionpins/actionpins.go index 4cad8e80f09..16e08b6244f 100644 --- a/pkg/actionpins/actionpins.go +++ b/pkg/actionpins/actionpins.go @@ -122,19 +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) - } - - 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)) - } + data := loadActionPinsData(actionPinsJSON) pins := slices.Collect(maps.Values(data.Entries)) @@ -161,6 +149,24 @@ func getActionPins() []ActionPin { return cachedActionPins } +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 { @@ -244,6 +250,8 @@ 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)) diff --git a/pkg/actionpins/actionpins_internal_test.go b/pkg/actionpins/actionpins_internal_test.go index eaaf6e1d4e9..3f2363e3b4a 100644 --- a/pkg/actionpins/actionpins_internal_test.go +++ b/pkg/actionpins/actionpins_internal_test.go @@ -94,6 +94,22 @@ func TestCollectEntriesWithEmptySHA_ReturnsOnlyEmptySHAEntries(t *testing.T) { }) } +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") From 7b298aa31cbb6ce7eac8c2f0426ddb29ba8ba0c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:08:00 +0000 Subject: [PATCH 5/5] fix: document loadActionPinsData panic contract Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/actionpins/actionpins.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/actionpins/actionpins.go b/pkg/actionpins/actionpins.go index 16e08b6244f..b0392c1d747 100644 --- a/pkg/actionpins/actionpins.go +++ b/pkg/actionpins/actionpins.go @@ -149,6 +149,9 @@ 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 {