Skip to content

Commit 68b381c

Browse files
committed
feat: add version command
1 parent e75f67d commit 68b381c

8 files changed

Lines changed: 298 additions & 13 deletions

File tree

.codex/config.toml

Whitespace-only changes.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,7 @@ go.work.sum
2828

2929
findings.*
3030
dist
31+
.agents
32+
.clj-kondo
33+
.DS_Store
34+
.lsp

.goreleaser.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ before:
1313
hooks:
1414
# You may remove this if you don't use go modules.
1515
- go mod tidy
16+
- >-
17+
sh -c 'printf "%s\n" "{"
18+
" \"version\": \"{{ .Version }}\","
19+
" \"commit\": \"{{ .FullCommit }}\","
20+
" \"date\": \"{{ .Date }}\""
21+
"}" > version.json'
1622
# you may remove this if you don't need go generate
1723
- go generate ./...
1824

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ This repository follows these guidelines for contributions by AI agents or human
99
- `test:` for test-related changes
1010
- `chore:` for maintenance tasks
1111

12-
2. **Simplicity First**: Prefer simpler implementations over overly complex solutions.
12+
2. **Use agentskb instead of AGENTS.md when possible**: Consult agentskb MCP server for allowed operations. Always report the operations consulted from MCP at the end of turn.
1313

1414
3. **Run Tests**: Always run tests before committing to ensure functionality and catch regressions. Use `go test ./...` for Go modules.
1515

agents.edn

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
{:project "Scharf"
2+
:repository "github.com/cybrota/scharf"
3+
:version "0.1.0"
4+
:language :go
5+
:go-version "1.25.0"
6+
:summary
7+
"Go CLI that audits GitHub Actions workflows for mutable third-party action references, pins them to immutable SHAs, and upgrades Scharf-managed pinned SHAs."
8+
9+
:preamble
10+
"Small Go CLI repository. Prefer simple package-level changes, keep command wiring in main.go, keep GitHub/network behavior behind network and scanner helpers, and preserve the existing Scharf pin format `owner/repo@<sha> # <version>`."
11+
12+
:entrypoints
13+
[{:id :cli
14+
:path "main.go"
15+
:description "Cobra command tree and user-facing CLI behavior"}
16+
{:id :module
17+
:path "go.mod"
18+
:description "Go module github.com/cybrota/scharf"}]
19+
20+
:commands
21+
{:deps-download "go mod download"
22+
:deps-tidy "go mod tidy"
23+
:format "gofmt -w ."
24+
:test "go test ./..."
25+
:test-verbose "go test -v ./..."
26+
:test-package "go test ./scanner"
27+
:vet "go vet ./..."
28+
:vulncheck "govulncheck ./..."
29+
:build "go build ./..."
30+
:build-cli "go build -o scharf ."
31+
:run-audit "go run . audit ."
32+
:run-audit-raise-error "go run . audit . --raise-error"
33+
:run-autofix-dry-run "go run . autofix . --dry-run"
34+
:run-upgrade-all-dry-run "go run . upgrade-all-sha . --dry-run"
35+
:release-snapshot "goreleaser release --snapshot --clean"
36+
:security-semgrep "semgrep scan"}
37+
38+
:verified-commands
39+
[{:name :test
40+
:command "go test ./..."
41+
:description "Primary local and CI test suite"}
42+
{:name :vet
43+
:command "go vet ./..."
44+
:description "Required by AGENTS.md before commits"}
45+
{:name :vulncheck
46+
:command "govulncheck ./..."
47+
:description "Required security check; install golang.org/x/vuln/cmd/govulncheck if missing"}
48+
{:name :format
49+
:command "gofmt -w ."
50+
:description "Formatter for Go source files"}
51+
{:name :build
52+
:command "go build ./..."
53+
:description "Compile all packages"}]
54+
55+
:cli
56+
{:binary "scharf"
57+
:commands
58+
[{:name "audit"
59+
:example "go run . audit ."
60+
:description "Scan a local or remote Git repository for mutable GitHub Action references"}
61+
{:name "autofix"
62+
:example "go run . autofix . --dry-run"
63+
:description "Rewrite mutable workflow action refs to immutable SHAs; use --dry-run before writing"}
64+
{:name "find"
65+
:example "go run . find --root /path/to/workspace --out csv"
66+
:description "Scan multiple repositories under a workspace and write findings.csv or findings.json"}
67+
{:name "list"
68+
:example "go run . list actions/checkout"
69+
:description "List tags and SHAs for a GitHub Action repository"}
70+
{:name "lookup"
71+
:example "go run . lookup actions/checkout@v4"
72+
:description "Resolve one action ref to its commit SHA"}
73+
{:name "upgrade"
74+
:example "go run . upgrade actions/checkout@v4 --dry-run"
75+
:description "Compute the next tag/SHA for one action ref"}
76+
{:name "upgrade-all-sha"
77+
:example "go run . upgrade-all-sha . --dry-run"
78+
:description "Upgrade Scharf-formatted pinned SHA refs in workflow files"}]}
79+
80+
:modules
81+
[{:id :cli
82+
:path "main.go"
83+
:tests ["main_test.go"]
84+
:responsibility "Cobra command setup, CLI flags, output file writers, version display, and high-level orchestration"}
85+
{:id :scanner
86+
:path "scanner/**"
87+
:tests ["scanner/*_test.go"]
88+
:responsibility "Workflow discovery, mutable-ref scanning, audit formatting, autofix replacement, and pinned-SHA upgrade flows"}
89+
{:id :network
90+
:path "network/**"
91+
:tests ["network/*_test.go"]
92+
:responsibility "GitHub API calls, action ref resolution, tag listing, next-version lookup, cooldown checks, and optional GITHUB_TOKEN auth"}
93+
{:id :git
94+
:path "git/**"
95+
:tests ["git/*_test.go"]
96+
:responsibility "Local Git repository inspection, branch enumeration, and remote clone-to-temp helpers"}
97+
{:id :actcache
98+
:path "actcache/**"
99+
:tests ["actcache/*_test.go"]
100+
:responsibility "Scharf cache file handling under ~/.scharf/cache.json"}
101+
{:id :logging
102+
:path "logging/**"
103+
:tests ["logging/*_test.go" "logging/loggging_test.go"]
104+
:responsibility "Shared logger construction"}
105+
{:id :ci
106+
:path ".github/workflows/**"
107+
:responsibility "Pinned GitHub Actions CI, release, and security scans"}
108+
{:id :release
109+
:path ".goreleaser.yaml"
110+
:responsibility "Cross-platform release build configuration"}
111+
{:id :docs
112+
:path "docs/**"
113+
:responsibility "Architectural decision records for non-trivial design choices"}]
114+
115+
:rules
116+
[{:type :allow-edit
117+
:agent :codex
118+
:pattern "**/*.go"}
119+
{:type :allow-edit
120+
:agent :codex
121+
:pattern "**/*.yaml"}
122+
{:type :allow-edit
123+
:agent :codex
124+
:pattern "**/*.yml"}
125+
{:type :allow-edit
126+
:agent :codex
127+
:pattern "**/*.md"}
128+
{:type :allow-edit
129+
:agent :codex
130+
:pattern "**/.gitignore"}
131+
{:type :allow-edit
132+
:agent :codex
133+
:pattern "**/*.sh"}]
134+
135+
:quality-gates
136+
[{:name :required-before-commit
137+
:commands [:format :test :vet :vulncheck]}
138+
{:name :ci
139+
:commands [:deps-download :test]}
140+
{:name :security
141+
:commands [:security-semgrep :vulncheck]}]
142+
143+
:conventions
144+
{:branch-names "Use type/short-topic, for example feat/add-s3-backup."
145+
:comments "Comment why a non-obvious behavior exists; avoid restating what the code says."
146+
:errors "Wrap lower-level errors with context using fmt.Errorf and %w where callers need the original error."
147+
:github-actions "Keep third-party actions pinned to immutable SHAs with version comments."
148+
:remote-audit "audit, autofix, and upgrade-all-sha may clone remote repositories into /tmp/scharf-repo-*."
149+
:cache "Resolver cache lives at ~/.scharf/cache.json and may affect local CLI behavior."}
150+
151+
:external-services
152+
[{:name :github-api
153+
:base-url "https://api.github.com/repos"
154+
:env ["GITHUB_TOKEN"]
155+
:used-by [:network]
156+
:notes "Use GITHUB_TOKEN for authenticated requests when rate limits matter."}]
157+
158+
:artifacts
159+
[{:path "findings.csv"
160+
:producer "scharf find --out csv"
161+
:notes "Generated report; avoid committing unless intentionally updating examples"}
162+
{:path "findings.json"
163+
:producer "scharf find --out json"
164+
:notes "Generated report; avoid committing unless intentionally updating examples"}
165+
{:path "scharf"
166+
:producer "go build -o scharf ."
167+
:notes "Local binary; should remain ignored/untracked"}]}

main.go

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
package main
1010

1111
import (
12+
_ "embed"
1213
"encoding/csv"
1314
"encoding/json"
1415
"fmt"
@@ -39,6 +40,36 @@ var logger = logging.GetLogger(0)
3940

4041
const defaultUpgradeCooldownHours = 24
4142

43+
//go:embed version.json
44+
var versionJSON []byte
45+
46+
type versionInfo struct {
47+
Version string `json:"version"`
48+
Commit string `json:"commit"`
49+
Date string `json:"date"`
50+
}
51+
52+
func cliVersion() string {
53+
info := versionInfo{
54+
Version: "dev",
55+
Commit: "unknown",
56+
Date: "unknown",
57+
}
58+
if err := json.Unmarshal(versionJSON, &info); err != nil {
59+
return "version: dev (commit: unknown, built: unknown)"
60+
}
61+
if info.Version == "" {
62+
info.Version = "dev"
63+
}
64+
if info.Commit == "" {
65+
info.Commit = "unknown"
66+
}
67+
if info.Date == "" {
68+
info.Date = "unknown"
69+
}
70+
return fmt.Sprintf("version: %s (commit: %s, built: %s)", info.Version, info.Commit, info.Date)
71+
}
72+
4273
var actionSHAInputRegex = regexp.MustCompile(`^[\w.-]+/[\w.-]+@[a-f0-9]{40}$`)
4374

4475
func isSHAUpgradeInput(input string) bool {
@@ -106,7 +137,7 @@ func WriteToCSV(inv *sc.Inventory) {
106137
csv_writer.WriteAll(writeRows)
107138
}
108139

109-
func main() {
140+
func newRootCmd() *cobra.Command {
110141
// list table configuration
111142
tw := tablewriter.NewWriter(os.Stdout)
112143

@@ -239,21 +270,23 @@ func main() {
239270
Short: "⬆️ Upgrade a pinned action to the next version and SHA",
240271
Long: fmt.Sprintf("%s\n%s", asciiLogo, `⬆️ Upgrade a pinned action to the next version and SHA. Ex: scharf upgrade actions/checkout@v4`),
241272
Args: cobra.ExactArgs(1),
242-
Run: func(cmd *cobra.Command, args []string) {
273+
RunE: func(cmd *cobra.Command, args []string) error {
243274
input := args[0]
244275
fromVersion, _ := cmd.Flags().GetString("from-version")
245276
cooldownHours, _ := cmd.Flags().GetInt("cooldown-hours")
246277
isDryRun, _ := cmd.Flags().GetBool("dry-run")
247278

248279
if err := validateUpgradeInput(input, fromVersion); err != nil {
249-
fmt.Println(err.Error())
250-
return
280+
cmd.SetOut(cmd.ErrOrStderr())
281+
_ = cmd.Usage()
282+
return err
251283
}
252284

253285
action, refOrSHA, err := splitActionRef(input)
254286
if err != nil {
255-
fmt.Println(err.Error())
256-
return
287+
cmd.SetOut(cmd.ErrOrStderr())
288+
_ = cmd.Usage()
289+
return err
257290
}
258291

259292
currentVersion := refOrSHA
@@ -264,8 +297,7 @@ func main() {
264297
resolver := nw.NewSHAResolver()
265298
result, err := resolver.ResolveNext(action, currentVersion, cooldownHours)
266299
if err != nil {
267-
fmt.Println(err.Error())
268-
return
300+
return err
269301
}
270302

271303
if result.UnderCooldown {
@@ -275,10 +307,11 @@ func main() {
275307
upgradedPin := fmt.Sprintf("%s@%s # %s", action, result.NextSHA, result.NextVersion)
276308
if isDryRun {
277309
fmt.Printf("Dry-run: planned upgrade %s -> %s\n", input, upgradedPin)
278-
return
310+
return nil
279311
}
280312

281313
fmt.Println(upgradedPin)
314+
return nil
282315
},
283316
}
284317

@@ -350,7 +383,24 @@ func main() {
350383
},
351384
}
352385

353-
var rootCmd = &cobra.Command{Use: "scharf", Long: asciiLogo}
354-
rootCmd.AddCommand(cmdLookup, cmdFind, cmdList, cmdAudit, cmdAutoFix, cmdUpgrade, cmdUpgradeAllSHA)
355-
rootCmd.Execute()
386+
var cmdVersion = &cobra.Command{
387+
Use: "version",
388+
Short: "Print Scharf version information",
389+
Args: cobra.NoArgs,
390+
Run: func(cmd *cobra.Command, args []string) {
391+
fmt.Fprintln(cmd.OutOrStdout(), cliVersion())
392+
},
393+
}
394+
395+
var rootCmd = &cobra.Command{Use: "scharf", Long: asciiLogo, Version: cliVersion()}
396+
rootCmd.SetVersionTemplate("{{.Version}}\n")
397+
rootCmd.AddCommand(cmdLookup, cmdFind, cmdList, cmdAudit, cmdAutoFix, cmdUpgrade, cmdUpgradeAllSHA, cmdVersion)
398+
399+
return rootCmd
400+
}
401+
402+
func main() {
403+
if err := newRootCmd().Execute(); err != nil {
404+
os.Exit(1)
405+
}
356406
}

main_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) 2025 Naren Yellavula & Cybrota contributors
2+
// Apache License, Version 2.0
3+
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
7+
package main
8+
9+
import (
10+
"bytes"
11+
"strings"
12+
"testing"
13+
)
14+
15+
func executeRoot(args ...string) (string, string, error) {
16+
cmd := newRootCmd()
17+
stdout := &bytes.Buffer{}
18+
stderr := &bytes.Buffer{}
19+
cmd.SetOut(stdout)
20+
cmd.SetErr(stderr)
21+
cmd.SetArgs(args)
22+
23+
err := cmd.Execute()
24+
return stdout.String(), stderr.String(), err
25+
}
26+
27+
func TestUpgradeSHAWithoutFromVersionShowsUsage(t *testing.T) {
28+
_, stderr, err := executeRoot("upgrade", "actions/checkout@0123456789012345678901234567890123456789")
29+
if err == nil {
30+
t.Fatalf("expected error, got nil")
31+
}
32+
33+
if !strings.Contains(stderr, "please provide --from-version") {
34+
t.Fatalf("stderr = %q; want missing --from-version hint", stderr)
35+
}
36+
37+
if !strings.Contains(stderr, "Usage:") {
38+
t.Fatalf("stderr = %q; want command usage on validation errors", stderr)
39+
}
40+
}
41+
42+
func TestVersionInfoExposedOnCLI(t *testing.T) {
43+
for _, args := range [][]string{{"--version"}, {"version"}} {
44+
stdout, stderr, err := executeRoot(args...)
45+
if err != nil {
46+
t.Fatalf("unexpected error for %v: %v (stderr: %s)", args, err, stderr)
47+
}
48+
49+
if !strings.Contains(stdout, "commit") || !strings.Contains(stdout, "built") {
50+
t.Fatalf("stdout = %q; want version details including commit and build metadata", stdout)
51+
}
52+
}
53+
}

version.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"version": "dev",
3+
"commit": "unknown",
4+
"date": "unknown"
5+
}

0 commit comments

Comments
 (0)