diff --git a/internal/generate/action_pins.go b/internal/generate/action_pins.go index fb261b61..3daf4630 100644 --- a/internal/generate/action_pins.go +++ b/internal/generate/action_pins.go @@ -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)) @@ -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 diff --git a/internal/generate/action_pins_anchor_test.go b/internal/generate/action_pins_anchor_test.go index bed1fbc8..311e3e77 100644 --- a/internal/generate/action_pins_anchor_test.go +++ b/internal/generate/action_pins_anchor_test.go @@ -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 diff --git a/internal/generate/action_pins_test.go b/internal/generate/action_pins_test.go index d52fae2b..988c6688 100644 --- a/internal/generate/action_pins_test.go +++ b/internal/generate/action_pins_test.go @@ -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 +// "@", so writing " # " 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")) +} diff --git a/internal/generate/pin_load.go b/internal/generate/pin_load.go new file mode 100644 index 00000000..e225bf4f --- /dev/null +++ b/internal/generate/pin_load.go @@ -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 "# " 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 +} diff --git a/internal/generate/pin_load_test.go b/internal/generate/pin_load_test.go new file mode 100644 index 00000000..25ac5b36 --- /dev/null +++ b/internal/generate/pin_load_test.go @@ -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") +} diff --git a/internal/generate/pin_scan.go b/internal/generate/pin_scan.go new file mode 100644 index 00000000..2d4aab62 --- /dev/null +++ b/internal/generate/pin_scan.go @@ -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 "# " 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 +} diff --git a/internal/generate/pin_scan_test.go b/internal/generate/pin_scan_test.go new file mode 100644 index 00000000..503929f4 --- /dev/null +++ b/internal/generate/pin_scan_test.go @@ -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) +} diff --git a/internal/generate/workflow_consistency_test.go b/internal/generate/workflow_consistency_test.go index e3da98fb..6bbed6d7 100644 --- a/internal/generate/workflow_consistency_test.go +++ b/internal/generate/workflow_consistency_test.go @@ -4,77 +4,14 @@ import ( "fmt" "os" "path/filepath" - "regexp" "runtime" "sort" "strings" "testing" "github.com/stretchr/testify/require" - "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") { - groups := usesRefRe.FindStringSubmatch(line) - if groups == nil { - continue - } - action, ref, comment := groups[1], groups[2], groups[3] - 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 -} - // repoGitHubDir walks up from this test file's own source location to the module // root (the directory holding go.mod) and returns that root's ".github" path. // It anchors on runtime.Caller rather than os.Getwd so a sibling test that @@ -126,10 +63,17 @@ func governedFiles(t *testing.T, githubDir string) []string { // action across cascade's own .github tree, not just the generator-emitted ones. func loadActionPinsManifest(t *testing.T) map[string]actionPinEntry { t.Helper() - var manifest actionPinsManifest - require.NoError(t, yaml.Unmarshal(actionPinsYAML, &manifest)) - require.NotEmpty(t, manifest.Actions, "action_pins.yaml parsed to an empty action set") - return manifest.Actions + manifest, err := LoadEmbeddedPinManifest() + require.NoError(t, err) + require.NotEmpty(t, manifest, "action_pins.yaml parsed to an empty action set") + return manifest +} + +// loadActionPinsManifestForTest is a thin alias of loadActionPinsManifest for +// tests outside this file that need the same embedded manifest. +func loadActionPinsManifestForTest(t *testing.T) map[string]actionPinEntry { + t.Helper() + return loadActionPinsManifest(t) } // TestWorkflowsConsistentWithActionPins is the merge gate that keeps cascade's @@ -143,13 +87,13 @@ func TestWorkflowsConsistentWithActionPins(t *testing.T) { manifest := loadActionPinsManifest(t) githubDir := repoGitHubDir(t) - var mismatches []pinMismatch + var mismatches []PinMismatch for _, file := range governedFiles(t, githubDir) { content, err := os.ReadFile(file) //nolint:gosec // path comes from a fixed glob under the repo's .github tree. require.NoError(t, err) rel, err := filepath.Rel(filepath.Dir(githubDir), file) require.NoError(t, err) - mismatches = append(mismatches, scanUsesForPinDrift(rel, string(content), manifest)...) + mismatches = append(mismatches, ScanUsesForPinDrift(rel, string(content), manifest)...) } if len(mismatches) > 0 { @@ -164,7 +108,7 @@ func TestWorkflowsConsistentWithActionPins(t *testing.T) { } // TestWorkflowConsistencyLint_DetectsDivergence is the negative control that -// keeps the lint honest: it feeds scanUsesForPinDrift a fixture whose checkout +// keeps the lint honest: it feeds ScanUsesForPinDrift a fixture whose checkout // SHA is deliberately wrong and asserts the scan reports exactly that line with // the manifest's canonical SHA as the "want". If the detector ever silently // stopped flagging drift, this test goes red even though the real repo is clean. @@ -179,9 +123,9 @@ func TestWorkflowConsistencyLint_DetectsDivergence(t *testing.T) { " steps:\n" + " - uses: actions/checkout@" + stale + " # " + want.Version + "\n" - mismatches := scanUsesForPinDrift("fixture.yaml", fixture, manifest) + mismatches := ScanUsesForPinDrift("fixture.yaml", fixture, manifest) require.Len(t, mismatches, 1, "a flipped checkout SHA must produce exactly one mismatch") - require.Equal(t, actionCheckout, mismatches[0].action) - require.Equal(t, stale, mismatches[0].gotRef) - require.Equal(t, want.SHA, mismatches[0].wantSHA, "want must carry the manifest's canonical SHA") + require.Equal(t, actionCheckout, mismatches[0].Action) + require.Equal(t, stale, mismatches[0].GotRef) + require.Equal(t, want.SHA, mismatches[0].WantSHA, "want must carry the manifest's canonical SHA") } diff --git a/internal/pinreconcile/consensus.go b/internal/pinreconcile/consensus.go new file mode 100644 index 00000000..58c7c2ad --- /dev/null +++ b/internal/pinreconcile/consensus.go @@ -0,0 +1,27 @@ +package pinreconcile + +import ( + "errors" + "fmt" +) + +// ErrAmbiguousSource means governed source files disagree on the ref for one +// action key. The engine refuses that key and falls through to a comment rather +// than guessing, matching the consensus-over-source rule. +var ErrAmbiguousSource = errors.New("governed source files disagree on the action ref") + +// consensusRef folds every source-observed ref for one action key into a single +// adopted value, refusing when the sources disagree. Only source files feed this; +// generated files are targets and are never read back. +func consensusRef(action string, refs []string) (string, error) { + if len(refs) == 0 { + return "", fmt.Errorf("%s: no source ref observed", action) + } + first := refs[0] + for _, r := range refs[1:] { + if r != first { + return "", fmt.Errorf("%w: %s (%q vs %q)", ErrAmbiguousSource, action, first, r) + } + } + return first, nil +} diff --git a/internal/pinreconcile/consensus_test.go b/internal/pinreconcile/consensus_test.go new file mode 100644 index 00000000..8bbcea4e --- /dev/null +++ b/internal/pinreconcile/consensus_test.go @@ -0,0 +1,18 @@ +package pinreconcile + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConsensusRef_AgreesAcrossSources(t *testing.T) { + ref, err := consensusRef("actions/checkout", []string{"v5 # v5.0.0", "v5 # v5.0.0"}) + require.NoError(t, err) + require.Equal(t, "v5 # v5.0.0", ref) +} + +func TestConsensusRef_RefusesDisagreement(t *testing.T) { + _, err := consensusRef("actions/checkout", []string{"v5 # v5.0.0", "v4 # v4.0.0"}) + require.ErrorIs(t, err, ErrAmbiguousSource) +} diff --git a/internal/pinreconcile/reconcile.go b/internal/pinreconcile/reconcile.go new file mode 100644 index 00000000..1b3bba71 --- /dev/null +++ b/internal/pinreconcile/reconcile.go @@ -0,0 +1,37 @@ +package pinreconcile + +// Input is the source-agnostic reconcile input: the governed action set and, +// per governed action, every ref observed in an authoritative SOURCE file (the +// triggering change and, in cascade's own repo, hand-written workflows plus the +// anchor). Generated files are targets and are deliberately absent here. +type Input struct { + Governed map[string]bool + SourceRefs map[string][]string +} + +// Adoptions is the verbatim set to write into the manifest's action_pins map, +// keyed by action path. A sha adoption carries its trailing "# ". +type Adoptions struct { + Pins map[string]string +} + +// Relevant reports whether any governed pin actually changed and must be adopted. +func (a Adoptions) Relevant() bool { return len(a.Pins) > 0 } + +// PlanAdoptions decides relevance and computes the verbatim adoptions. It reads +// the incoming ref from source with consensus-over-source, refuses on ambiguity, +// and never touches an ungoverned action. It performs no I/O. +func PlanAdoptions(in Input) (Adoptions, error) { + out := Adoptions{Pins: map[string]string{}} + for action, refs := range in.SourceRefs { + if !in.Governed[action] { + continue + } + ref, err := consensusRef(action, refs) + if err != nil { + return Adoptions{}, err + } + out.Pins[action] = ref + } + return out, nil +} diff --git a/internal/pinreconcile/reconcile_test.go b/internal/pinreconcile/reconcile_test.go new file mode 100644 index 00000000..a1e6bbe6 --- /dev/null +++ b/internal/pinreconcile/reconcile_test.go @@ -0,0 +1,47 @@ +package pinreconcile + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPlanAdoptions_TagAndSha(t *testing.T) { + // Governed set the engine may touch. + governed := map[string]bool{"actions/checkout": true, "actions/upload-artifact": true} + + // A tag-mode bump on an owned file: checkout moved v5 -> v6. + adopts, err := PlanAdoptions(Input{ + Governed: governed, + SourceRefs: map[string][]string{ + "actions/checkout": {"v6"}, + }, + }) + require.NoError(t, err) + require.Equal(t, map[string]string{"actions/checkout": "v6"}, adopts.Pins) + + // An ungoverned uses: is never adopted. + adopts, err = PlanAdoptions(Input{ + Governed: governed, + SourceRefs: map[string][]string{"some/other-action": {"v1"}}, + }) + require.NoError(t, err) + require.Empty(t, adopts.Pins) +} + +func TestPlanAdoptions_ShaKeepsComment(t *testing.T) { + adopts, err := PlanAdoptions(Input{ + Governed: map[string]bool{"actions/checkout": true}, + SourceRefs: map[string][]string{"actions/checkout": {"deadbeef... # v6.0.1"}}, + }) + require.NoError(t, err) + require.Equal(t, "deadbeef... # v6.0.1", adopts.Pins["actions/checkout"]) +} + +func TestPlanAdoptions_RefusesAmbiguous(t *testing.T) { + _, err := PlanAdoptions(Input{ + Governed: map[string]bool{"actions/checkout": true}, + SourceRefs: map[string][]string{"actions/checkout": {"v5", "v6"}}, + }) + require.ErrorIs(t, err, ErrAmbiguousSource) +}