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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ jobs:
run: echo "VERSION=${GITHUB_REF_NAME}" >> "$GITHUB_ENV"

- name: Build package
run: task package GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} PACKAGE_NAME=chaitin-cli_${VERSION}_${{ matrix.goos }}_${{ matrix.goarch }}
run: task package GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} VERSION=${VERSION} PACKAGE_NAME=chaitin-cli_${VERSION}_${{ matrix.goos }}_${{ matrix.goarch }}

- name: Upload artifact
uses: actions/upload-artifact@v4
Expand Down
14 changes: 14 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ curl -fsSL https://raw.githubusercontent.com/chaitin/chaitin-cli/main/skills/cha

Windows users should download the matching package from [GitHub Releases](https://github.com/chaitin/chaitin-cli/releases), extract `chaitin-cli.exe`, and add it to PATH.

Check the installed version:

```bash
chaitin-cli version
chaitin-cli --version
```

## Skill

This project provides an AI Agent skill. Once installed, AI agents (Claude Code, Cursor, etc.) can invoke `chaitin-cli` commands to manage Chaitin security products directly.
Expand Down Expand Up @@ -376,6 +383,13 @@ task test
task package GOOS=linux GOARCH=amd64
```

Versioning:

- `task build` / `task package` inject the version with `-ldflags "-X main.version=..."`; by default it comes from `git describe --tags --always --dirty`.
- Pass an explicit version when needed, for example `task build VERSION=v1.2.3`.
- When no version is injected (for example `go run .` or a plain `go build`), the command falls back to Go build info: an untagged build that can be traced to a commit shows `dev+<commit>`, a `go install <module>@<version>` of a released tag shows the module version, and `dev` is shown when neither is available.
- Verify with `task build VERSION=v0.0.0-test && ./bin/chaitin-cli version`.

## Maintenance and Feedback

- Main branch: [`main`](https://github.com/chaitin/chaitin-cli/tree/main)
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ curl -fsSL https://raw.githubusercontent.com/chaitin/chaitin-cli/main/skills/cha

Windows 用户请从 [GitHub Releases](https://github.com/chaitin/chaitin-cli/releases) 下载对应版本,解压 `chaitin-cli.exe` 并加入 PATH。

查看当前版本:

```bash
chaitin-cli version
chaitin-cli --version
```

## Skill

本项目提供了 skill,安装后 AI Agent(Claude Code、Cursor 等)可以直接调用 `chaitin-cli` 命令管理长亭安全产品。
Expand Down Expand Up @@ -495,6 +502,13 @@ task test
task package GOOS=linux GOARCH=amd64
```

版本信息:

- `task build` / `task package` 通过 `-ldflags "-X main.version=..."` 把版本注入二进制,默认取 `git describe --tags --always --dirty` 的结果。
- 需要指定版本时显式传入,例如 `task build VERSION=v1.2.3`。
- 未注入版本时(例如 `go run .`、直接 `go build`),命令回退到 Go 构建信息:对应到具体 commit 的未发布构建显示 `dev+<commit>`,通过 `go install <module>@<version>` 安装到正式 tag 时显示模块版本,都取不到时显示 `dev`。
- 校验方式:`task build VERSION=v0.0.0-test && ./bin/chaitin-cli version`。

## 维护与反馈

- 主线分支:[`main`](https://github.com/chaitin/chaitin-cli/tree/main)
Expand Down
7 changes: 5 additions & 2 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ vars:
BIN_NAME: chaitin-cli
BUILD_DIR: bin
DIST_DIR: dist
VERSION:
sh: git describe --tags --always --dirty 2>/dev/null || echo dev
LDFLAGS: -X main.version={{.VERSION}}

tasks:
default:
Expand Down Expand Up @@ -45,7 +48,7 @@ tasks:
desc: Build the {{ .BIN_NAME }} binary
cmds:
- mkdir -p {{.BUILD_DIR}}
- go build -o {{.BUILD_DIR}}/{{.BIN_NAME}} .
- go build -ldflags "{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.BIN_NAME}} .

build:target:
desc: Build the {{ .BIN_NAME }} binary for a target platform
Expand All @@ -58,7 +61,7 @@ tasks:
BIN_EXT: '{{if eq .GOOS "windows"}}.exe{{end}}'
cmds:
- mkdir -p {{.TARGET_DIR}}
- env CGO_ENABLED=0 GOOS={{.GOOS}} GOARCH={{.GOARCH}} go build -o {{.TARGET_DIR}}/{{.BIN_NAME}}{{.BIN_EXT}} .
- env CGO_ENABLED=0 GOOS={{.GOOS}} GOARCH={{.GOARCH}} go build -ldflags "{{.LDFLAGS}}" -o {{.TARGET_DIR}}/{{.BIN_NAME}}{{.BIN_EXT}} .

package:
desc: Build and archive the {{ .BIN_NAME }} binary for a target platform
Expand Down
5 changes: 5 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,18 @@ func newApp() (*app, error) {
SilenceErrors: true,
}

root.Version = resolveVersion()
root.SetVersionTemplate("{{.Version}}\n")

a := &app{
root: root,
aliasSubcommands: make(map[string]struct{}),
productNames: make(map[string]struct{}),
configPath: defaultConfigPath(),
}

root.AddCommand(newVersionCommand(root))

root.PersistentFlags().StringVarP(&a.configPath, "config", "c", a.configPath, "Config file path; when unset, CLI tries recognized ./config.yaml before ~/.chaitin-cli/config.yaml")
root.PersistentFlags().BoolVar(&a.dryRun, "dry-run", false, "Do not send requests; commands that support dry-run print a request summary")

Expand Down
92 changes: 92 additions & 0 deletions version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package main

import (
"fmt"
"regexp"
"runtime/debug"
"strings"

"github.com/spf13/cobra"
)

// version is overridden at build time with
// `-ldflags "-X main.version=v1.2.3"`. Local builds that do not inject it fall
// back to the metadata the Go toolchain records in the binary.
var version string

const devVersion = "dev"

// pseudoVersionSuffix matches the "<14-digit timestamp>-<12-character revision>"
// tail of a Go pseudo-version such as v0.0.0-20260920052045-2d4b5d8fd518.
var pseudoVersionSuffix = regexp.MustCompile(`-[0-9]{14}-([0-9a-f]{12})$`)

// readBuildInfo is a seam so tests can control the fallback path.
var readBuildInfo = debug.ReadBuildInfo

func resolveVersion() string {
if injected := strings.TrimSpace(version); injected != "" {
return injected
}
return versionFromBuildInfo(readBuildInfo)
}

func versionFromBuildInfo(read func() (*debug.BuildInfo, bool)) string {
info, ok := read()
if !ok || info == nil {
return devVersion
}
// A recorded revision means the binary was built from a VCS checkout.
if revision := vcsRevision(info); revision != "" {
return devVersion + "+" + shortRevision(revision)
}
moduleVersion := strings.TrimSpace(info.Main.Version)
// Without VCS settings the version still describes an untagged commit when
// it is a pseudo-version, which is what `go install <module>@latest` records
// (for example v0.0.0-20260920052045-2d4b5d8fd518). It reads like a release
// version but is not one, so report its revision the same way as a local
// build and only keep Main.Version when it names a real release.
if revision := pseudoVersionRevision(moduleVersion); revision != "" {
return devVersion + "+" + shortRevision(revision)
}
if moduleVersion != "" && moduleVersion != "(devel)" {
return moduleVersion
}
return devVersion
}

func vcsRevision(info *debug.BuildInfo) string {
for _, setting := range info.Settings {
if setting.Key == "vcs.revision" {
return strings.TrimSpace(setting.Value)
}
}
return ""
}

func pseudoVersionRevision(moduleVersion string) string {
matches := pseudoVersionSuffix.FindStringSubmatch(moduleVersion)
if matches == nil {
return ""
}
return matches[1]
}

func shortRevision(revision string) string {
const maxLength = 12
if len(revision) > maxLength {
return revision[:maxLength]
}
return revision
}

func newVersionCommand(root *cobra.Command) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the chaitin-cli version",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintln(cmd.OutOrStdout(), root.Version)
return nil
},
}
}
144 changes: 144 additions & 0 deletions version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package main

import (
"runtime/debug"
"strings"
"testing"
)

func stubVersion(t *testing.T, injected string, info *debug.BuildInfo, ok bool) {
t.Helper()
origVersion := version
origRead := readBuildInfo
t.Cleanup(func() {
version = origVersion
readBuildInfo = origRead
})
version = injected
readBuildInfo = func() (*debug.BuildInfo, bool) { return info, ok }
}

func runRootCommand(t *testing.T, args ...string) string {
t.Helper()
setTestHome(t, t.TempDir())
app, err := newApp()
if err != nil {
t.Fatalf("newApp() error = %v", err)
}
var stdout, stderr strings.Builder
app.root.SetOut(&stdout)
app.root.SetErr(&stderr)
app.root.SetArgs(args)
if err := app.execute(); err != nil {
t.Fatalf("execute() error = %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty so version output stays script-friendly on stdout", stderr.String())
}
return stdout.String()
}

func TestVersionCommandPrintsInjectedVersion(t *testing.T) {
stubVersion(t, "v1.2.3", nil, false)

got := runRootCommand(t, "version")
if got != "v1.2.3\n" {
t.Fatalf("version output = %q, want %q", got, "v1.2.3\n")
}
}

func TestVersionCommandFallsBackToBuildInfoModuleVersion(t *testing.T) {
stubVersion(t, "", &debug.BuildInfo{Main: debug.Module{Version: "v9.9.9"}}, true)

got := runRootCommand(t, "version")
if got != "v9.9.9\n" {
t.Fatalf("version output = %q, want %q", got, "v9.9.9\n")
}
}

func TestVersionCommandFallsBackToDevWithoutBuildInfo(t *testing.T) {
stubVersion(t, "", nil, false)

got := runRootCommand(t, "version")
if got != devVersion+"\n" {
t.Fatalf("version output = %q, want %q", got, devVersion+"\n")
}
}

func TestRootVersionFlagMatchesVersionCommand(t *testing.T) {
stubVersion(t, "v1.2.3", nil, false)

flagOutput := runRootCommand(t, "--version")
commandOutput := runRootCommand(t, "version")
if flagOutput != commandOutput {
t.Fatalf("--version = %q, version = %q", flagOutput, commandOutput)
}
}

func TestVersionFromBuildInfo(t *testing.T) {
tests := []struct {
name string
info *debug.BuildInfo
ok bool
want string
}{
{
name: "module version without vcs revision",
info: &debug.BuildInfo{Main: debug.Module{Version: "v1.0.0"}},
ok: true,
want: "v1.0.0",
},
{
name: "stamped pseudo-version falls back to vcs revision",
info: &debug.BuildInfo{
Main: debug.Module{Version: "v0.0.0-20260922074332-dc0b69e60f23"},
Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: "dc0b69e60f23628db60df018446d27c6053038d8"}},
},
ok: true,
want: "dev+dc0b69e60f23",
},
{
name: "pseudo-version without vcs settings",
info: &debug.BuildInfo{Main: debug.Module{Version: "v0.0.0-20260920052045-2d4b5d8fd518"}},
ok: true,
want: "dev+2d4b5d8fd518",
},
{
name: "devel falls back to vcs revision",
info: &debug.BuildInfo{
Main: debug.Module{Version: "(devel)"},
Settings: []debug.BuildSetting{{Key: "vcs.revision", Value: "abcdef1234567890"}},
},
ok: true,
want: "dev+abcdef123456",
},
{
name: "devel without settings",
info: &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}},
ok: true,
want: "dev",
},
{
name: "empty build info",
info: &debug.BuildInfo{},
ok: true,
want: "dev",
},
{
name: "build info unavailable",
ok: false,
want: "dev",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := tt.info
ok := tt.ok
got := versionFromBuildInfo(func() (*debug.BuildInfo, bool) { return info, ok })
if got != tt.want {
t.Fatalf("versionFromBuildInfo() = %q, want %q", got, tt.want)
}
})
}
}
Loading