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
48 changes: 33 additions & 15 deletions internal/generate/action_pins.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,39 @@ var defaultActionPins = mustParseActionPins(actionPinsYAML)
// keeping only emit: true actions (the set the generator renders). It panics on
// a malformed manifest, a non-40-hex SHA, or a missing generator action so the
// failure surfaces at init rather than as silently wrong generated output.
//
// The generator-action completeness check applies only here, not in
// parseActionPins: the embedded manifest must define every action the
// generator references by const, but an arbitrary disk-loaded manifest (for
// example a reconcile overlay) legitimately carries only the subset of
// actions it overrides.
func mustParseActionPins(data []byte) map[string]actionPin {
pins, err := parseActionPins(data)
if err != nil {
panic(fmt.Sprintf("generate: %v", err))
}

for _, name := range []string{
actionCheckout, actionGithubScript, actionDownloadArtifact,
actionUploadArtifact, actionCreateAppToken,
} {
if _, ok := pins[name]; !ok {
panic(fmt.Sprintf("generate: action_pins.yaml is missing emit:true entry for %s", name))
}
}

return pins
}

// parseActionPins parses a manifest's bytes into the generator pin table,
// keeping only emit: true actions (the set the generator renders). It returns
// an error on a malformed manifest or a non-40-hex SHA instead of panicking,
// so a disk-loaded manifest can be rejected gracefully rather than crashing
// the process.
func parseActionPins(data []byte) (map[string]actionPin, error) {
var manifest actionPinsManifest
if err := yaml.Unmarshal(data, &manifest); err != nil {
panic(fmt.Sprintf("generate: parsing action_pins.yaml: %v", err))
return nil, fmt.Errorf("parsing action_pins.yaml: %w", err)
}

pins := make(map[string]actionPin, len(manifest.Actions))
Expand All @@ -87,26 +116,15 @@ func mustParseActionPins(data []byte) map[string]actionPin {
continue
}
if !commitSHAPattern.MatchString(entry.SHA) {
panic(fmt.Sprintf("generate: action_pins.yaml: %s sha %q is not a 40-char commit SHA", name, entry.SHA))
return nil, fmt.Errorf("action_pins.yaml: %s sha %q is not a 40-char commit SHA", name, entry.SHA)
}
if entry.Tag == "" || entry.Version == "" {
panic(fmt.Sprintf("generate: action_pins.yaml: %s is missing a tag or version", name))
return nil, fmt.Errorf("action_pins.yaml: %s is missing a tag or version", name)
}
pins[name] = actionPin{tag: entry.Tag, sha: entry.SHA, shaVersion: entry.Version}
}

// Every action the generator references by const must be present and emit:true,
// so a manifest edit can never drop a governed action without a build-time panic.
for _, name := range []string{
actionCheckout, actionGithubScript, actionDownloadArtifact,
actionUploadArtifact, actionCreateAppToken,
} {
if _, ok := pins[name]; !ok {
panic(fmt.Sprintf("generate: action_pins.yaml is missing emit:true entry for %s", name))
}
}

return pins
return pins, nil
}

// actionRef returns the fully-rendered uses: value for a third-party action
Expand Down
2 changes: 1 addition & 1 deletion internal/generate/action_pins_anchor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestActionPinsAnchorCoversManifest(t *testing.T) {
require.NoError(t, err)

// Correctness: no governed uses: line in the anchor may diverge from the manifest.
mismatches := scanUsesForPinDrift(anchorWorkflowPath, string(content), manifest)
mismatches := ScanUsesForPinDrift(anchorWorkflowPath, string(content), manifest)
require.Emptyf(t, mismatches, "dependabot anchor refs diverge from action_pins.yaml: %v", mismatches)

// Completeness: every manifest action must appear in the anchor so Dependabot
Expand Down
14 changes: 14 additions & 0 deletions internal/generate/action_pins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,3 +323,17 @@ func TestPinPolicy_E2E_TagModeDefaultUnchanged(t *testing.T) {
"tag mode must not emit a SHA for %s", action)
}
}

// TestActionRef_OverridePreservesVersionComment locks the seam a reconciled sha
// adoption relies on: an action_pins override already emits verbatim as
// "<action>@<override>", so writing "<sha> # <version>" into action_pins keeps
// the version comment with no generator change. Charset validation (see
// validateActionPins in internal/config) is what makes this raw splice safe.
func TestActionRef_OverridePreservesVersionComment(t *testing.T) {
cfg := &config.TrunkConfig{ActionPins: map[string]string{
"actions/checkout": "abc123def4567890abc123def4567890abc12345 # v5.1.0",
}}
require.Equal(t,
"actions/checkout@abc123def4567890abc123def4567890abc12345 # v5.1.0",
actionRef(cfg, "actions/checkout"))
}
49 changes: 49 additions & 0 deletions internal/generate/pin_load.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package generate

import (
"fmt"
"os"

"github.com/stablekernel/cascade/internal/config"
)

// LoadActionPinTable parses an action_pins.yaml from an arbitrary disk path into
// the generator pin table (emit:true entries only). A version-pinned binary uses
// it to regenerate against the repo's current on-disk pins instead of its stale
// compiled-in copy, which is the mechanism the #438 self-heal regenerate needs.
func LoadActionPinTable(path string) (map[string]actionPin, error) {
data, err := os.ReadFile(path) //nolint:gosec // caller supplies a trusted repo path.
if err != nil {
return nil, fmt.Errorf("read action pins %q: %w", path, err)
}
return parseActionPins(data)
}

// ApplyDiskPinOverrides overlays the pins from an on-disk action_pins.yaml onto
// cfg.ActionPins so a version-pinned binary regenerates against the repo's
// current pins instead of its stale compiled-in copy. It fills only keys the
// config does not already override (an explicit user pin still wins), and it
// reaches every emitted ref because actionRef resolves cfg.ActionPins first.
// The overlaid value carries the trailing "# <version>" in sha mode so an
// adopted sha stays auditable.
func ApplyDiskPinOverrides(cfg *config.TrunkConfig, path string) error {
table, err := LoadActionPinTable(path)
if err != nil {
return err
}
if cfg.ActionPins == nil {
cfg.ActionPins = map[string]string{}
}
sha := cfg.GetPinMode() == config.PinModeSHA
for action, pin := range table {
if _, set := cfg.ActionPins[action]; set {
continue
}
if sha {
cfg.ActionPins[action] = pin.sha + " # " + pin.shaVersion
} else {
cfg.ActionPins[action] = pin.tag
}
}
return nil
}
51 changes: 51 additions & 0 deletions internal/generate/pin_load_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package generate

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"

"github.com/stablekernel/cascade/internal/config"
)

func TestLoadActionPinTable_FromDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "action_pins.yaml")
require.NoError(t, os.WriteFile(path, actionPinsYAML, 0o600))

table, err := LoadActionPinTable(path)
require.NoError(t, err)
require.Equal(t, defaultActionPins[actionCheckout], table[actionCheckout])
}

func TestLoadActionPinTable_RejectsBadSHA(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "action_pins.yaml")
require.NoError(t, os.WriteFile(path, []byte("actions:\n actions/checkout:\n tag: v5\n sha: nope\n version: v5.0.0\n emit: true\n"), 0o600))
_, err := LoadActionPinTable(path)
require.Error(t, err)
}

func TestApplyDiskPinOverrides_ReachesConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "action_pins.yaml")
require.NoError(t, os.WriteFile(path, []byte(
"actions:\n"+
" actions/checkout:\n tag: v9\n sha: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n version: v9.9.9\n emit: true\n"), 0o600))

cfg := &config.TrunkConfig{PinMode: config.PinModeSHA}
require.NoError(t, ApplyDiskPinOverrides(cfg, path))
require.Equal(t, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v9.9.9", cfg.ActionPins["actions/checkout"])
require.Equal(t, "actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v9.9.9", actionRef(cfg, "actions/checkout"))
}

func TestApplyDiskPinOverrides_DoesNotClobberUserPins(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "action_pins.yaml")
require.NoError(t, os.WriteFile(path, actionPinsYAML, 0o600))
cfg := &config.TrunkConfig{ActionPins: map[string]string{"actions/checkout": "v3"}}
require.NoError(t, ApplyDiskPinOverrides(cfg, path))
require.Equal(t, "v3", cfg.ActionPins["actions/checkout"], "an explicit user override must win over the disk overlay")
}
108 changes: 108 additions & 0 deletions internal/generate/pin_scan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package generate

import (
"fmt"
"regexp"
"strings"

"gopkg.in/yaml.v3"
)

// usesRefRe extracts the action path, pinned ref, and trailing version comment
// from a single workflow/composite "uses:" line. It accepts an optional leading
// "- " and optional surrounding quotes so it matches both step-list and
// continuation forms. The ref is everything up to whitespace or "#"; the comment
// is the first token after "# ", if present.
var usesRefRe = regexp.MustCompile(`uses:\s*["']?([^"'\s@]+)@([^"'\s#]+)["']?\s*(?:#\s*(\S+))?`)

// PinMismatch is one governed "uses:" line whose pinned ref or version comment
// disagrees with the action-pins manifest. File and Line locate it; Want* hold
// the manifest's canonical values; Got* hold what the file actually carries.
type PinMismatch struct {
File string
Line int
Action string
WantSHA string
WantVersion string
GotRef string
GotComment string
}

// String renders a mismatch as a single "file:line want ... got ..." row for the
// aggregated failure table.
func (m PinMismatch) String() string {
return fmt.Sprintf("%s:%d %s want %s # %s got %s # %s",
m.File, m.Line, m.Action, m.WantSHA, m.WantVersion, m.GotRef, m.GotComment)
}

// ScanUsesForPinDrift walks every line of one file's content and returns the
// governed "uses:" lines that diverge from the manifest. A line is governed when
// its action path is a manifest key; local ("./...") and cascade self-action
// refs are never manifest keys and so are skipped implicitly. For a governed
// line the pinned ref must equal the manifest SHA and the trailing comment must
// equal the manifest version, so a silent SHA-or-comment drift is caught.
func ScanUsesForPinDrift(file, content string, manifest map[string]actionPinEntry) []PinMismatch {
var mismatches []PinMismatch
for i, line := range strings.Split(content, "\n") {
action, ref, comment, ok := parseUsesLine(line)
if !ok {
continue
}
entry, governed := manifest[action]
if !governed {
continue
}
if ref == entry.SHA && comment == entry.Version {
continue
}
mismatches = append(mismatches, PinMismatch{
File: file,
Line: i + 1,
Action: action,
WantSHA: entry.SHA,
WantVersion: entry.Version,
GotRef: ref,
GotComment: comment,
})
}
return mismatches
}

// parseUsesLine extracts the action path, the bare ref, and the trailing
// version comment (each as a separate capture) from a single "uses:" line. ok
// is false for a line that is not an action uses:.
func parseUsesLine(line string) (action, ref, comment string, ok bool) {
g := usesRefRe.FindStringSubmatch(line)
if g == nil {
return "", "", "", false
}
return g[1], g[2], g[3], true
}

// ParseUsesLine extracts the action path and the verbatim ref (including any
// trailing "# <version>" comment, so a sha adoption keeps its comment) from a
// single "uses:" line. ok is false for a line that is not an action uses:.
func ParseUsesLine(line string) (action, ref string, ok bool) {
g := usesRefRe.FindStringSubmatch(line)
if g == nil {
return "", "", false
}
ref = g[2]
if g[3] != "" {
ref += " # " + g[3]
}
return g[1], ref, true
}

// ActionPinEntry is one action's pin record as authored in action_pins.yaml.
type ActionPinEntry = actionPinEntry

// LoadEmbeddedPinManifest returns the full action set (emit:true and false) from
// the committed action_pins.yaml, so callers lock every governed action.
func LoadEmbeddedPinManifest() (map[string]actionPinEntry, error) {
var m actionPinsManifest
if err := yaml.Unmarshal(actionPinsYAML, &m); err != nil {
return nil, fmt.Errorf("parse embedded action_pins.yaml: %w", err)
}
return m.Actions, nil
}
35 changes: 35 additions & 0 deletions internal/generate/pin_scan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package generate

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestScanUsesForPinDrift_Exported(t *testing.T) {
manifest := loadActionPinsManifestForTest(t)
want := manifest[actionCheckout]
const stale = "0000000000000000000000000000000000000000"
fixture := " steps:\n - uses: actions/checkout@" + stale + " # " + want.Version + "\n"

got := ScanUsesForPinDrift("f.yaml", fixture, manifest)
require.Len(t, got, 1)
require.Equal(t, actionCheckout, got[0].Action)
require.Equal(t, stale, got[0].GotRef)
require.Equal(t, want.SHA, got[0].WantSHA)
}

func TestParseUsesLine(t *testing.T) {
action, ref, ok := ParseUsesLine(" - uses: actions/checkout@abc123 # v5.1.0")
require.True(t, ok)
require.Equal(t, "actions/checkout", action)
require.Equal(t, "abc123 # v5.1.0", ref) // ref carries the trailing comment verbatim

action, ref, ok = ParseUsesLine(" - uses: actions/checkout@v6")
require.True(t, ok)
require.Equal(t, "actions/checkout", action)
require.Equal(t, "v6", ref)

_, _, ok = ParseUsesLine(" - run: echo hi")
require.False(t, ok)
}
Loading