diff --git a/cmd/gh-actions-pin/check.go b/cmd/gh-actions-pin/check.go index db0c529e..99658132 100644 --- a/cmd/gh-actions-pin/check.go +++ b/cmd/gh-actions-pin/check.go @@ -269,12 +269,6 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) // Strict gate — any blocking finding is a non-zero exit. if opts.noFix { console.StopProgress() - if gc := r.GHClient(); gc != nil { - if ssoURL := gc.SSOURL(); ssoURL != "" { - console.TermBlank() - console.TermDetail("Authorize in your web browser: %s", ssoURL) - } - } if opts.jsonFields != "" { if err := format.WriteJSON(out, report, valid, opts.jsonFields, cliVersion(), store.File().Version); err != nil { return err @@ -332,7 +326,7 @@ func runCheck(cmd *cobra.Command, opts *checkOptions, newResolver resolverFunc) if path, werr := record.WriteJSON(); werr == nil { defer func() { console.TermBlank() - console.TermNeutral("Resolution record: %s", path) + console.TermDetail("Resolution record: %s", path) }() } diff --git a/cmd/gh-actions-pin/format/terminal.go b/cmd/gh-actions-pin/format/terminal.go index 1aa8900c..6fedb693 100644 --- a/cmd/gh-actions-pin/format/terminal.go +++ b/cmd/gh-actions-pin/format/terminal.go @@ -37,9 +37,7 @@ func PresentResults(out *ui.UI, report *checks.Report, valid bool, willRemediate } checked := validCount + failedCount - if valid && checked > 0 { - out.Success("All %d %s valid", checked, ui.Pluralize(checked, "workflow", "workflows")) - } else if checked > 0 { + if !valid && checked > 0 { renderErrorFindings(out, report, failedCount, checked) } diff --git a/cmd/gh-actions-pin/format/terminal_test.go b/cmd/gh-actions-pin/format/terminal_test.go index ca7a1b5c..63f451f1 100644 --- a/cmd/gh-actions-pin/format/terminal_test.go +++ b/cmd/gh-actions-pin/format/terminal_test.go @@ -135,6 +135,24 @@ func TestPresentResults_WarningsReachTerminal(t *testing.T) { } } +// TestPresentResults_NoSuccessLine verifies PresentResults no longer emits +// the "All N workflows valid" success line — renderPinSummary owns that. +func TestPresentResults_NoSuccessLine(t *testing.T) { + u, buf := newTestUI() + report := &checks.Report{ + Workflows: []checks.WorkflowReport{ + {Path: ".github/workflows/a.yml"}, + {Path: ".github/workflows/b.yml"}, + }, + } + PresentResults(u, report, true, false) + + got := buf.String() + if strings.Contains(got, "All") && strings.Contains(got, "valid") { + t.Errorf("PresentResults should not print success line, got:\n%s", got) + } +} + // TestPresentResults_RemediateHints locks the willRemediate-aware "↳" // follow-up lines that PresentResults emits under each warning headline. // Categories the remediator auto-fixes (NotPinned, SHAAsRef) flip between diff --git a/cmd/gh-actions-pin/pin_summary.go b/cmd/gh-actions-pin/pin_summary.go index e5d8a99a..7abf8a4e 100644 --- a/cmd/gh-actions-pin/pin_summary.go +++ b/cmd/gh-actions-pin/pin_summary.go @@ -35,6 +35,10 @@ func renderPinSummary(console *ui.UI, record *pin.Record, report *checks.Report, } total := len(report.Workflows) + if total == 0 { + console.TermNeutral("No workflows to check") + return nil + } if len(pinned) == 0 && len(investigated) == 0 && len(unresolvedEntries) == 0 && !hasInconclusive { console.TermSuccess("All %d %s valid", total, ui.Pluralize(total, "workflow", "workflows")) if skippedRescan > 0 { diff --git a/cmd/gh-actions-pin/root.go b/cmd/gh-actions-pin/root.go index 58dfba97..0ba89b7b 100644 --- a/cmd/gh-actions-pin/root.go +++ b/cmd/gh-actions-pin/root.go @@ -124,7 +124,8 @@ $ gh actions-pin --no-fix --json=valid,findings // from the existing lockfile so repeat scans short-circuit the per-branch // Compare walk. newResolver is the DI seam; pass nil for production wiring. func newRun(workflowPaths []string, hostname string, pool *pinpool.Pool, newResolver resolverFunc) ([]string, *resolve.Resolver, *lockfile.State, error) { - paths, err := discoverWorkflowPaths(workflowPaths) + workflowsDir := os.Getenv("GH_ACTIONS_PIN_WORKFLOWS_DIR") + paths, err := discoverWorkflowPaths(workflowPaths, workflowsDir) if err != nil { return nil, nil, nil, err } @@ -139,7 +140,12 @@ func newRun(workflowPaths []string, hostname string, pool *pinpool.Pool, newReso return nil, nil, nil, err } - store, err := lockfile.LoadState(".", r) + var store *lockfile.State + if workflowsDir != "" { + store, err = lockfile.LoadStateAt(filepath.Join(workflowsDir, "actions.lock"), r) + } else { + store, err = lockfile.LoadState(".", r) + } if err != nil { return nil, nil, nil, fmt.Errorf("opening lockfile: %w", err) } @@ -148,11 +154,22 @@ func newRun(workflowPaths []string, hostname string, pool *pinpool.Pool, newReso return paths, r, store, nil } -func discoverWorkflowPaths(existing []string) ([]string, error) { +func discoverWorkflowPaths(existing []string, workflowsDir string) ([]string, error) { if len(existing) > 0 { return expandWorkflowPaths(existing) } + if workflowsDir != "" { + paths, err := workflowfile.DiscoverWorkflowsIn(workflowsDir) + if err != nil { + return nil, err + } + if len(paths) == 0 { + return nil, fmt.Errorf("no workflow files found in %s", workflowsDir) + } + return paths, nil + } + paths, err := workflowfile.DiscoverWorkflows() if err != nil { return nil, err diff --git a/internal/lockfile/state.go b/internal/lockfile/state.go index 57b1c28e..f7f00d54 100644 --- a/internal/lockfile/state.go +++ b/internal/lockfile/state.go @@ -38,7 +38,7 @@ type MetadataResolver interface { // a single store instance without external synchronization. type State struct { mu sync.Mutex - repoRoot string + lockPath string // full path to actions.lock on disk file parserlock.File meta MetadataResolver idCache map[string][2]int64 @@ -48,8 +48,14 @@ type State struct { // LoadState reads the lockfile at repoRoot, returning an empty in-memory file // when none exists on disk. func LoadState(repoRoot string, meta MetadataResolver) (*State, error) { - full := filepath.Join(repoRoot, parserlock.Path) - contents, err := os.ReadFile(full) + return LoadStateAt(filepath.Join(repoRoot, parserlock.Path), meta) +} + +// LoadStateAt reads the lockfile at the given path, returning an empty +// in-memory file when none exists on disk. Use this when the lockfile +// lives outside the standard .github/workflows/ location. +func LoadStateAt(lockfilePath string, meta MetadataResolver) (*State, error) { + contents, err := os.ReadFile(lockfilePath) var file parserlock.File switch { @@ -88,7 +94,7 @@ func LoadState(repoRoot string, meta MetadataResolver) (*State, error) { } s := &State{ - repoRoot: repoRoot, + lockPath: lockfilePath, file: file, meta: meta, idCache: map[string][2]int64{}, @@ -360,7 +366,7 @@ func (s *State) Save() error { } } - full := filepath.Join(s.repoRoot, parserlock.Path) + full := s.lockPath if len(s.file.Dependencies) == 0 && len(s.file.Workflows) == 0 { if err := os.Remove(full); err != nil && !errors.Is(err, os.ErrNotExist) { diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 9a6dc8be..0a6621ca 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -52,35 +52,21 @@ type UI struct { // mode so the same phase label isn't printed more than once. headlessLabelStem string - // progLabel and progDetail hold the two halves of the active spinner line - // (the per-workflow label and the resolver's current-action detail). They - // are recombined and truncated to one terminal row on every update so the - // spinner never wraps — a wrapped spinner breaks the library's - // backspace-based erase and causes line jumping/leftover fragments. - progLabel string + // progDetail holds the current resolver detail shown in worker slot 0. progDetail string - // progLast holds the most recent non-empty rendered line. If an update - // transiently leaves both label and detail empty (e.g. detail cleared - // between phases before the next label is set), we keep showing progLast - // so the spinner never flashes a bare, label-less glyph. - progLast string - // progPaused is set while the spinner is temporarily halted (e.g. to let an // interactive prompt own the terminal). The spinner object is retained so // ResumeProgress can restart it with the same label/detail. progPaused bool - // progHasDetail tracks whether the last renderProgress call rendered a - // second detail line. clearSpinnerLines uses this to know whether to also - // erase line 2 after stopping the spinner. + // progHasDetail tracks whether the last renderProgress call put something + // in worker slot 0. Used by clearSpinnerLines to know the row count. progHasDetail bool // spinWriter is a thin io.Writer wrapper set while a spinner is active. - // It intercepts each spinner tick write (which starts with '\r') and - // appends the detail line below the spinner WITHOUT putting the detail text - // in the spinner Suffix — keeping the suffix short so the library's - // byte-count wrap detection never triggers on the second line's content. + // It intercepts each spinner tick write and appends worker status rows + // below the spinner line in a single synchronized write. spinWriter *spinnerWriter // progGrace delays spinner visibility so fast runs never flicker. When @@ -344,6 +330,11 @@ type spinnerWriter struct { nRendered int // number of worker lines written in the last tick noColor bool output *termenv.Output + // prefix is the static label text written before the spinner glyph + // (e.g. "Resolving actions "). Stored here so startAnimator can write + // an immediate frame on resume, avoiding the one-tick blank gap that + // occurs because briandowns never fires a tick immediately on Start(). + prefix string // stop closes to signal the independent worker-redraw ticker to exit. // done is closed once the ticker goroutine has returned. The ticker // keeps worker glyphs animating even when the spinner library coalesces, @@ -380,20 +371,52 @@ func (sw *spinnerWriter) Write(p []byte) (n int, err error) { sw.mu.Lock() defer sw.mu.Unlock() - n, err = sw.w.Write(p) - if err != nil || len(p) == 0 || p[0] != '\r' { + if len(p) == 0 || p[0] != '\r' { + n, err = sw.w.Write(p) + return + } + + // briandowns calls Write twice per tick: + // 1. erase: \r\033[K — wipe the previous frame + // 2. frame: \r{Prefix}{glyph}{Suffix} — paint the new frame + // + // Only render worker rows on the frame write. Rendering them on the + // erase write too means two cursor-down/up sequences per 120ms tick, + // which is the primary cause of residual flicker on embedded terminals. + // + // Match the specific erase sequence the library emits rather than any + // generic CSI prefix — frame writes that start with a color SGR + // (e.g. \r\033[36m…) must not be misclassified as erases. + // briandowns.erase() always writes "\r\033[K" (4 bytes) for single-line + // spinners, optionally followed by "\033[F\033[K" per additional line. + isErase := len(p) >= 4 && p[0] == '\r' && p[1] == '\033' && p[2] == '[' && p[3] == 'K' + if isErase { + // Just pass the erase through; worker rows are still on screen + // from the previous frame and will be refreshed momentarily. + n, err = sw.w.Write(p) return } - sw.renderWorkersLocked() + + // Combine the spinner frame and worker rows into a single + // synchronized write so the terminal never shows a partial frame. + var buf strings.Builder + buf.WriteString("\033[?2026h") // begin synchronized output + // Replace the leading \r with \r\033[2K (go-to-col-0 + erase line) + // so that when the label shrinks between ticks the old longer text + // is fully cleared instead of leaving leftover characters visible. + buf.WriteString("\r\033[2K") + buf.Write(p[1:]) // p[0] is the \r we already emitted above + sw.buildWorkerFrameLocked(&buf) + buf.WriteString("\033[?2026l") // end synchronized output + _, err = io.WriteString(sw.w, buf.String()) + n = len(p) return } -// renderWorkersLocked redraws the worker rows below the spinner line, leaving -// the cursor back on the spinner line. Caller must hold sw.mu and the cursor -// must currently be on the spinner line. Glyph frames are picked from the wall -// clock so animation continues even when triggered by the independent ticker -// instead of by a spinner Write. -func (sw *spinnerWriter) renderWorkersLocked() { +// buildWorkerFrameLocked appends the escape sequences for worker rows into +// buf, leaving the cursor back on the spinner line. Caller must hold sw.mu +// and the cursor must currently be on the spinner line. +func (sw *spinnerWriter) buildWorkerFrameLocked(buf *strings.Builder) { step := int(time.Now().UnixNano() / int64(workerFrameInterval)) // Per-slot phase offset so rows visibly cascade instead of all hitting // the same frame in lockstep — that lockstep was what made them look @@ -407,7 +430,7 @@ func (sw *spinnerWriter) renderWorkersLocked() { body := w if len(w) >= len("→ ") && w[:len("→ ")] == "→ " { frame := workerSpinFrames[(step+slot)%len(workerSpinFrames)] - body = frame + " " + w[len("→ "):] + body = w[len("→ "):] + " " + frame } hint := "" if slot < len(sw.hints) { @@ -452,24 +475,34 @@ func (sw *spinnerWriter) renderWorkersLocked() { // is a no-op at the last row and the subsequent ESC[NA cursor-up // would then overshoot, landing on (and clobbering) lines above // the spinner — including the user's typed command line. - fmt.Fprintf(sw.w, "\n\r\033[2K%s", line) + fmt.Fprintf(buf, "\n\r\033[2K%s", line) } // Erase stale lines left over from a previous render that had more // active workers. Without this, ghost "→ dep" rows from finished // workers persist below the current set. for i := nLines; i < sw.nRendered; i++ { - fmt.Fprintf(sw.w, "\n\r\033[2K") + buf.WriteString("\n\r\033[2K") } totalDown := nLines if sw.nRendered > nLines { totalDown = sw.nRendered } if totalDown > 0 { - fmt.Fprintf(sw.w, "\033[%dA\r", totalDown) + fmt.Fprintf(buf, "\033[%dA\r", totalDown) } sw.nRendered = nLines } +// renderWorkersLocked redraws the worker rows below the spinner line as a +// single synchronized write. Caller must hold sw.mu. +func (sw *spinnerWriter) renderWorkersLocked() { + var buf strings.Builder + buf.WriteString("\033[?2026h") // begin synchronized output + sw.buildWorkerFrameLocked(&buf) + buf.WriteString("\033[?2026l") // end synchronized output + io.WriteString(sw.w, buf.String()) +} + // startAnimator launches a goroutine that periodically redraws the worker // rows so their glyphs keep pulsing even when the spinner library's own // writes stall or coalesce. Caller MUST NOT hold sw.mu. @@ -485,6 +518,27 @@ func (sw *spinnerWriter) startAnimator() { done := sw.done sw.mu.Unlock() + sw.mu.Lock() + // Wrap cursor-hide in a synchronized block so it can't interleave + // with a concurrent spinner frame write mid-output. + io.WriteString(sw.w, "\033[?2026h\033[?25l\033[?2026l") + sw.mu.Unlock() + + // Write a synthetic first frame immediately so the spinner line is never + // blank during the ~120ms gap before the library's first ticker tick. + // briandowns never writes a frame on Start() — it always waits for the + // first tick — so without this, every Resume causes a visible blank line. + sw.mu.Lock() + var buf strings.Builder + buf.WriteString("\033[?2026h") + buf.WriteString("\r\033[2K") + buf.WriteString(sw.prefix) + buf.WriteString(workerSpinFrames[0]) // placeholder glyph from shared braille charset; real tick replaces it + sw.buildWorkerFrameLocked(&buf) + buf.WriteString("\033[?2026l") + io.WriteString(sw.w, buf.String()) + sw.mu.Unlock() + go func() { defer close(done) t := time.NewTicker(workerFrameInterval) @@ -525,6 +579,11 @@ func (sw *spinnerWriter) stopAnimator() { } close(stop) <-done + // Restore the cursor that startAnimator hid, synchronized so it can't + // interleave with any write still draining from the ticker goroutine. + sw.mu.Lock() + io.WriteString(sw.w, "\033[?2026h\033[?25h\033[?2026l") + sw.mu.Unlock() } // setDetail is a backward-compat shim that sets a single worker slot (slot 0). @@ -589,17 +648,17 @@ func (u *UI) clearSpinnerLines() { u.spinWriter.nRendered = 0 u.spinWriter.mu.Unlock() } - // Erase the spinner's own line plus exactly the known worker lines - // below it, then move the cursor back up. Using targeted \033[2K - // per line instead of \033[J (erase-to-end-of-screen) avoids - // clobbering the shell prompt if it starts drawing before we exit. - fmt.Fprint(u.w, "\r\033[2K") + // Buffer the entire clear into a single write so the terminal + // never shows a partially erased frame. + var buf strings.Builder + buf.WriteString("\r\033[2K") for i := 0; i < lines; i++ { - fmt.Fprint(u.w, "\n\033[2K") + buf.WriteString("\n\033[2K") } if lines > 0 { - fmt.Fprintf(u.w, "\033[%dA\r", lines) + fmt.Fprintf(&buf, "\033[%dA\r", lines) } + fmt.Fprint(u.w, buf.String()) u.progHasDetail = false } @@ -832,15 +891,15 @@ func (u *UI) TermWarn(msg string, args ...any) { fmt.Fprintf(u.w, "%s %s\n", u.paint("3", IconWarning), fmt.Sprintf(msg, args...)) } -// TermCaution prints a red "!" summary line directly to the terminal. Use for +// TermCaution prints a yellow "!" summary line directly to the terminal. Use for // non-fatal but attention-worthy signals (e.g. a commit pinned only after a -// full-branch-scan fallback) that warrant red without the "✗ failure" framing. +// full-branch-scan fallback) that warrant emphasis without the "✗ failure" framing. func (u *UI) TermCaution(msg string, args ...any) { if u.headless { u.headlessEmit(fmt.Sprintf(msg, args...)) return } - fmt.Fprintf(u.w, "%s %s\n", u.paint("1", IconWarning), fmt.Sprintf(msg, args...)) + fmt.Fprintf(u.w, "%s %s\n", u.paint("3", IconWarning), fmt.Sprintf(msg, args...)) } // TermDetail prints an indented summary detail line directly to the terminal. @@ -1025,12 +1084,15 @@ func (u *UI) StartProgress(label string) { opts = append(opts, spinner.WithColor("fgCyan")) } sp := spinner.New(spinner.CharSets[11], 120*time.Millisecond, opts...) + // The label is static for the lifetime of this spinner. Set Prefix + // before sp.Start() — the goroutine isn't running yet so no lock needed. + if label != "" { + sp.Prefix = label + " " + sw.prefix = label + " " + } u.spinner = sp - u.progLabel = label u.progDetail = "" - u.progLast = "" u.progPaused = false - u.renderProgress() // Defer the visible start so fast runs never flicker. done := make(chan struct{}) @@ -1121,9 +1183,7 @@ func (u *UI) StopProgress() { u.spinner.Stop() u.spinner = nil u.spinWriter = nil - u.progLabel = "" u.progDetail = "" - u.progLast = "" u.progPaused = false } } @@ -1182,26 +1242,27 @@ func (u *UI) ClearWorkerStatuses() { for i := range u.spinWriter.hints { u.spinWriter.hints[i] = "" } + // Immediately erase the old rows from the terminal rather than + // waiting for the next spinner tick (~120ms). Without this, the + // stale rows sit on screen for one tick and then all vanish at once, + // which looks like a jump at phase transitions. + u.spinWriter.renderWorkersLocked() u.spinWriter.mu.Unlock() } -// UpdateLabel changes the spinner prefix label (e.g. to show per-workflow -// "[i/N] path" progress). No-op when no spinner is active. +// UpdateLabel is a no-op on TTY — the spinner label is static for the +// lifetime of the spinner. In headless mode it logs a plain-text phase +// boundary when the label changes. func (u *UI) UpdateLabel(label string) { u.traceProgress("label", label) - if u.headless { - stem := labelStem(label) - if stem != "" && stem != u.headlessLabelStem { - u.headlessEmit(stem) - u.headlessLabelStem = stem - } + if !u.headless { return } - if u.spinner == nil { - return + stem := labelStem(label) + if stem != "" && stem != u.headlessLabelStem { + u.headlessEmit(stem) + u.headlessLabelStem = stem } - u.progLabel = label - u.renderProgress() } // labelStem returns the label trimmed of whitespace, used as a phase @@ -1211,80 +1272,43 @@ func labelStem(label string) string { return strings.TrimSpace(label) } -// renderProgress recombines the label and detail into a single line that is -// truncated to fit the terminal width (leaving room for the spinner glyph and -// a space). The spinner glyph is anchored at the left edge (column 0): the -// combined line is assigned to the spinner Suffix with an empty Prefix, so the -// library always renders "\r{glyph} {label} — {detail}". Keeping the glyph -// fixed on the left stops it from drifting as the detail text changes width. -// The whole string is truncated to one terminal row — wrapping would defeat -// the library's backspace-based erase and cause the line jumping the user -// sees. +// renderProgress updates worker slot 0 with the current detail string. +// The label (top-line prefix) is managed separately by UpdateLabel. func (u *UI) renderProgress() { if u.spinner == nil { return } - label := u.progLabel detail := u.progDetail - - if label == "" { - if u.progLast == "" { - return + if detail == "" { + if u.progHasDetail { + if u.spinWriter != nil { + u.spinWriter.setDetail("") + } + u.progHasDetail = false } - label = u.progLast - } else { - u.progLast = label + return } + // Worker rows animate only when text starts with "→ "; UpdateProgress + // callers (resolver hooks) pass plain strings. Prepend the arrow so + // the slot pulses instead of looking frozen. + if !strings.HasPrefix(detail, "→ ") { + detail = "→ " + detail + } width := u.termWidth() - if width > 8 { - budget := width - 6 + if width > 4 { + budget := width - 4 if !u.noColor { - budget -= 7 // bold escape open+close + budget -= 7 } - label = truncateBytes(label, budget) + detail = truncateBytes(detail, budget) } - if detail != "" { - // Worker rows render a pulsing glyph only when the text starts - // with "→ "; UpdateProgress callers (resolver progress hooks) - // pass plain strings like "resolving foo@bar". Prepend the - // arrow so the slot animates instead of looking frozen. - if !strings.HasPrefix(detail, "→ ") { - detail = "→ " + detail - } - if width > 4 { - budget := width - 4 // " " indent + faint open+close - if !u.noColor { - budget -= 7 - } - detail = truncateBytes(detail, budget) - } - } - - // Pass detail (slot 0) to the writer; it appends worker lines on every - // spinner tick without inflating the Suffix byte count. - // Only write when detail is non-empty: calling setDetail("") would - // overwrite slot 0 that the pool's worker status may have set. - if u.spinWriter != nil && detail != "" { + if u.spinWriter != nil { u.spinWriter.setDetail(detail) } - u.progHasDetail = detail != "" - - var suffix string - if !u.noColor { - suffix = u.output.String(label).Bold().String() - } else { - suffix = label - } - - u.spinner.Prefix = "" - if suffix != "" { - u.spinner.Suffix = " " + suffix - } else { - u.spinner.Suffix = "" - } + u.progHasDetail = true } // termWidth returns the terminal column count for the spinner writer, or 0 if diff --git a/internal/workflowfile/workflowfile.go b/internal/workflowfile/workflowfile.go index c32dd7b4..6ac049ab 100644 --- a/internal/workflowfile/workflowfile.go +++ b/internal/workflowfile/workflowfile.go @@ -90,7 +90,12 @@ func (f *File) ExtractActionRefs() ([]parserlock.ActionRef, []string, []string) // DiscoverWorkflows finds all workflow files in .github/workflows/ relative to // the current directory. Returns nil if the directory doesn't exist. func DiscoverWorkflows() ([]string, error) { - dir := filepath.Join(".github", "workflows") + return DiscoverWorkflowsIn(filepath.Join(".github", "workflows")) +} + +// DiscoverWorkflowsIn finds all workflow files (*.yml, *.yaml) in dir. +// Returns nil if the directory doesn't exist. +func DiscoverWorkflowsIn(dir string) ([]string, error) { entries, err := os.ReadDir(dir) if os.IsNotExist(err) { return nil, nil diff --git a/internal/workflowfile/workflowfile_test.go b/internal/workflowfile/workflowfile_test.go index 95be9e79..0677efbd 100644 --- a/internal/workflowfile/workflowfile_test.go +++ b/internal/workflowfile/workflowfile_test.go @@ -43,6 +43,25 @@ func TestExtractActionRefsMixed(t *testing.T) { assert.Contains(t, warnings[0], "expression-based") } +func TestDiscoverWorkflowsIn(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "ci.yml"), []byte("name: ci\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "deploy.yaml"), []byte("name: deploy\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# ignore\n"), 0o644)) + + paths, err := DiscoverWorkflowsIn(dir) + require.NoError(t, err) + assert.Len(t, paths, 2) + assert.Equal(t, filepath.Join(dir, "ci.yml"), paths[0]) + assert.Equal(t, filepath.Join(dir, "deploy.yaml"), paths[1]) +} + +func TestDiscoverWorkflowsIn_MissingDir(t *testing.T) { + paths, err := DiscoverWorkflowsIn(filepath.Join(t.TempDir(), "nope")) + require.NoError(t, err) + assert.Nil(t, paths) +} + func TestExtractLocalCompositeRefs_RejectsPathTraversal(t *testing.T) { repoRoot := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(repoRoot, ".git"), 0o755)) diff --git a/test/scenarios/catalog.yml b/test/scenarios/catalog.yml index 1ac3631e..b49f023a 100644 --- a/test/scenarios/catalog.yml +++ b/test/scenarios/catalog.yml @@ -164,6 +164,21 @@ scenarios: - "actions/github-script" custom: sso_dedup_count + - name: sso_no_fix_single_url + category: sso_auth + description: "SSO URL shown only once in --no-fix path" + needs_stub: true + tags: [stub] + flags: ["--no-fix"] + fixtures: + workflows: + ci.yml: + name: CI + actions: ["actions/checkout@v4"] + expect: + exit: 1 + custom: sso_url_shown_once + - name: mixed_failures category: sso_auth description: "SSO + repo-not-found — different errors not deduped" @@ -302,6 +317,16 @@ scenarios: expect: exit: 2 + - name: no_workflows_summary + category: workflow_parsing + description: "No workflows to check — summary says so, not 'All 0 valid'" + tags: [stub] + fixtures: + workflows: {} + expect: + exit: 2 + output_excludes: ["All 0"] + - name: run_only_workflow category: workflow_parsing description: "Workflow with only run: steps (no actions) — RunOnly finding"