Skip to content

Commit 5d7d797

Browse files
sethvargoCopilot
andauthored
Replace "check" command with a full linter (#105)
This deprecates (and hides) the `check` command in favor of a new `lint` command that outputs more information about violations including filenames and specific line numbers. Closes #103 --------- Signed-off-by: Seth Vargo <seth@sethvargo.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent b11efe3 commit 5d7d797

20 files changed

Lines changed: 377 additions & 85 deletions

‎README.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,13 @@ ratchet upgrade -out workflow-compiled.yml workflow.yml
167167
> [!NOTE]
168168
> Performs an `update` if the constraint ref is for a branch.
169169

170-
#### Check
170+
#### Lint
171171

172-
The `check` command checks if all versions are pinned, exiting with a non-zero
173-
error code when entries are not pinned:
172+
The `lint` command reports if all versions are pinned, printing any violations,
173+
and exiting with a non-zero error code when entries are not pinned:
174174

175175
```shell
176-
ratchet check workflow.yml
176+
ratchet lint workflow.yml
177177
```
178178

179179
## Examples

‎command/check.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const checkCommandDesc = `Check if all versions are pinned`
1515
const checkCommandHelp = `
1616
Usage: ratchet check [FILE...]
1717
18+
DEPRECATED: Use the "lint" command instead.
19+
1820
The "check" command checks if all versions are pinned to an absolute version,
1921
ignoring any versions with the "ratchet:exclude" comment.
2022
@@ -33,6 +35,8 @@ type CheckCommand struct {
3335
flagParser string
3436
}
3537

38+
func (c *CheckCommand) Hidden() {}
39+
3640
func (c *CheckCommand) Desc() string {
3741
return checkCommandDesc
3842
}
@@ -50,6 +54,8 @@ func (c *CheckCommand) Flags() *flag.FlagSet {
5054
}
5155

5256
func (c *CheckCommand) Run(ctx context.Context, originalArgs []string) error {
57+
fmt.Fprintf(os.Stderr, "⚠️ DEPRECATED: Use the \"lint\" command instead.\n\n")
58+
5359
args, err := parseFlags(c.Flags(), originalArgs)
5460
if err != nil {
5561
return fmt.Errorf("failed to parse flags: %w", err)

‎command/cmd/gen/main.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func realMain() error {
4747

4848
func folderRoot() string {
4949
_, filename, _, _ := runtime.Caller(0)
50-
for i := 0; i < 3; i++ {
50+
for range 3 {
5151
filename = path.Dir(filename)
5252
}
5353
return filename

‎command/command.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
// Commands is the main list of all commands.
2525
var Commands = map[string]Command{
2626
"check": &CheckCommand{},
27+
"lint": &LintCommand{},
2728
"pin": &PinCommand{},
2829
"unpin": &UnpinCommand{},
2930
"update": &UpdateCommand{},

‎command/command_gen.go‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎command/lint.go‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package command
2+
3+
import (
4+
"context"
5+
"errors"
6+
"flag"
7+
"fmt"
8+
"os"
9+
"strings"
10+
11+
"github.com/sethvargo/ratchet/formatter"
12+
"github.com/sethvargo/ratchet/parser"
13+
)
14+
15+
const lintCommandDesc = `Lint and report unpinned versions`
16+
17+
const lintCommandHelp = `
18+
Usage: ratchet lint [FILE...]
19+
20+
The "lint" command reports any unpinned versions, ignoring any versions with
21+
the "ratchet:exclude" comment.
22+
23+
If any versions are unpinned, it returns a non-zero exit code. This command does
24+
not communicate with upstream APIs or services.
25+
26+
EXAMPLES
27+
28+
ratchet lint ./path/to/file.yaml
29+
30+
FLAGS
31+
32+
`
33+
34+
type LintCommand struct {
35+
flagFormat string
36+
flagParser string
37+
}
38+
39+
func (c *LintCommand) Desc() string {
40+
return lintCommandDesc
41+
}
42+
43+
func (c *LintCommand) Flags() *flag.FlagSet {
44+
f := flag.NewFlagSet("", flag.ExitOnError)
45+
f.Usage = func() {
46+
fmt.Fprintf(os.Stderr, "%s\n\n", strings.TrimSpace(lintCommandHelp))
47+
f.PrintDefaults()
48+
}
49+
50+
format := "human"
51+
if v := os.Getenv("GITHUB_ACTIONS"); v != "" {
52+
format = "actions"
53+
}
54+
55+
f.StringVar(&c.flagFormat, "format", format, "linter output format")
56+
f.StringVar(&c.flagParser, "parser", "actions", "parser to use")
57+
58+
return f
59+
}
60+
61+
func (c *LintCommand) Run(ctx context.Context, originalArgs []string) error {
62+
args, err := parseFlags(c.Flags(), originalArgs)
63+
if err != nil {
64+
return fmt.Errorf("failed to parse flags: %w", err)
65+
}
66+
67+
par, err := parser.For(ctx, c.flagParser)
68+
if err != nil {
69+
return err
70+
}
71+
72+
loadResult, err := loadYAMLFiles(os.DirFS("."), args)
73+
if err != nil {
74+
return err
75+
}
76+
77+
violations, err := parser.Lint(ctx, par, loadResult.nodes())
78+
if err != nil {
79+
return fmt.Errorf("failed to run linter: %w", err)
80+
}
81+
82+
fmter, err := formatter.For(ctx, c.flagFormat)
83+
if err != nil {
84+
return err
85+
}
86+
87+
if err := fmter.Format(os.Stdout, violations); err != nil {
88+
return err
89+
}
90+
91+
if l := len(violations); l > 0 {
92+
return errors.New("") // empty error to force a non-zero exit code
93+
}
94+
95+
return nil
96+
}

‎formatter/formatter.go‎

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package formatter
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"maps"
10+
"slices"
11+
"strings"
12+
"sync"
13+
14+
"github.com/sethvargo/ratchet/linter"
15+
)
16+
17+
type Violation = linter.Violation
18+
19+
type Formatter interface {
20+
Format(io.Writer, []*Violation) error
21+
}
22+
23+
var formatterFactory = map[string]Formatter{
24+
"actions": FormatterFunc(formatActions),
25+
"human": FormatterFunc(formatHuman),
26+
"json": FormatterFunc(formatJSON),
27+
"lsp": FormatterFunc(formatLSP),
28+
"null": FormatterFunc(formatNull),
29+
}
30+
31+
var formatters = sync.OnceValue(func() []string {
32+
return slices.Sorted(maps.Keys(formatterFactory))
33+
})
34+
35+
// For returns the parser that corresponds to the given name.
36+
func For(ctx context.Context, name string) (Formatter, error) {
37+
typ := strings.ToLower(strings.TrimSpace(name))
38+
if v, ok := formatterFactory[typ]; ok {
39+
return v, nil
40+
}
41+
return nil, fmt.Errorf("unknown formatter %q, valid formatters are %q",
42+
typ, List())
43+
}
44+
45+
// List returns the list of parsers.
46+
func List() []string {
47+
return formatters()
48+
}
49+
50+
// FormatterFunc is a function that implements the [Formatter] interface.
51+
type FormatterFunc func(io.Writer, []*Violation) error
52+
53+
// Format implements the [Formatter] interface.
54+
func (f FormatterFunc) Format(w io.Writer, v []*Violation) error {
55+
return f(w, v)
56+
}
57+
58+
// formatActions formats in GitHub Actions error output, which will also be
59+
// annotated in the UI.
60+
func formatActions(w io.Writer, violations []*Violation) error {
61+
var merr error
62+
for _, v := range violations {
63+
message := fmt.Sprintf("The reference `%s` is unpinned. Either pin the reference to a SHA or mark the line with `ratchet:exclude`.", v.Contents)
64+
if _, err := fmt.Fprintf(w, "::error file=%s,line=%d,col=%d,title=Ratchet - Unpinned Reference::%s\n",
65+
v.Filename, v.Line, v.Column,
66+
message); err != nil {
67+
merr = errors.Join(merr, err)
68+
}
69+
}
70+
return merr
71+
}
72+
73+
// formatHuman reports a human-friendly output format.
74+
//
75+
// <path>:<line>:<column>: <message>
76+
//
77+
// For example:
78+
//
79+
// .github/workflows/test.yml:37:8: Unpinned reference "actions/checkout@v4"
80+
func formatHuman(w io.Writer, violations []*Violation) error {
81+
var merr error
82+
83+
for _, v := range violations {
84+
if _, err := fmt.Fprintf(w, "%s:%d:%d: Unpinned reference %q\n",
85+
v.Filename, v.Line, v.Column, v.Contents); err != nil {
86+
merr = errors.Join(merr, err)
87+
}
88+
}
89+
90+
if len(violations) > 0 {
91+
if _, err := fmt.Fprintf(w, "\n❌ found %d violation(s)\n",
92+
len(violations)); err != nil {
93+
merr = errors.Join(merr, err)
94+
}
95+
}
96+
97+
return merr
98+
}
99+
100+
// formatNull produces no output.
101+
func formatNull(w io.Writer, violations []*Violation) error {
102+
return nil
103+
}
104+
105+
// formatJSON formats in JSON output.
106+
func formatJSON(w io.Writer, violations []*Violation) error {
107+
type InternalJSON struct {
108+
Filename string `json:"filename,omitempty"`
109+
Contents string `json:"contents,omitempty"`
110+
Line int `json:"line,omitempty"`
111+
Column int `json:"column,omitempty"`
112+
}
113+
114+
list := make([]*InternalJSON, 0, len(violations))
115+
for _, v := range violations {
116+
list = append(list, &InternalJSON{
117+
Filename: v.Filename,
118+
Contents: v.Contents,
119+
Line: v.Line,
120+
Column: v.Column,
121+
})
122+
}
123+
124+
return json.NewEncoder(w).Encode(list)
125+
}
126+
127+
// formatLSP formats a JSON response that is compatible with the Language Server
128+
// Protocol. This is useful for surfacing findings in an IDE that uses an LSP.
129+
func formatLSP(w io.Writer, violations []*Violation) error {
130+
type Position struct {
131+
Line int `json:"line,omitempty"`
132+
Character int `json:"character,omitempty"`
133+
}
134+
135+
type Range struct {
136+
Start *Position `json:"start,omitempty"`
137+
End *Position `json:"end,omitempty"`
138+
}
139+
140+
type InternalJSON struct {
141+
Message string `json:"message,omitempty"`
142+
Code string `json:"code,omitempty"`
143+
Severity string `json:"severity,omitempty"`
144+
Range *Range `json:"range,omitempty"`
145+
}
146+
147+
list := make([]*InternalJSON, 0, len(violations))
148+
for _, v := range violations {
149+
list = append(list, &InternalJSON{
150+
Message: "Reference is unpinned",
151+
Code: "unpinned",
152+
Severity: "Error",
153+
Range: &Range{
154+
Start: &Position{
155+
Line: v.Line,
156+
Character: v.Column,
157+
},
158+
End: &Position{
159+
Line: v.Line,
160+
Character: v.Column + len(v.Contents),
161+
},
162+
},
163+
})
164+
}
165+
166+
return json.NewEncoder(w).Encode(list)
167+
}

‎go.mod‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,17 @@ go 1.23.0
55
toolchain go1.23.5
66

77
require (
8-
github.com/braydonk/yaml v0.7.0
9-
github.com/google/go-cmp v0.6.0
8+
github.com/braydonk/yaml v0.9.0
9+
github.com/google/go-cmp v0.7.0
1010
github.com/google/go-containerregistry v0.20.3
11-
github.com/google/go-github/v58 v58.0.0
12-
golang.org/x/oauth2 v0.25.0
13-
golang.org/x/sync v0.10.0
11+
github.com/google/go-github/v70 v70.0.0
12+
golang.org/x/oauth2 v0.28.0
13+
golang.org/x/sync v0.12.0
1414
)
1515

1616
require (
1717
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
18-
github.com/docker/cli v27.5.1+incompatible // indirect
18+
github.com/docker/cli v27.5.0+incompatible // indirect
1919
github.com/docker/distribution v2.8.3+incompatible // indirect
2020
github.com/docker/docker-credential-helpers v0.8.2 // indirect
2121
github.com/google/go-querystring v1.1.0 // indirect
@@ -25,6 +25,6 @@ require (
2525
github.com/opencontainers/image-spec v1.1.0 // indirect
2626
github.com/pkg/errors v0.9.1 // indirect
2727
github.com/sirupsen/logrus v1.9.3 // indirect
28-
github.com/vbatts/tar-split v0.11.7 // indirect
28+
github.com/vbatts/tar-split v0.11.6 // indirect
2929
golang.org/x/sys v0.29.0 // indirect
3030
)

0 commit comments

Comments
 (0)