diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..80c6c5ce --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: Release + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: release + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Compute next version + id: version + run: | + # Find the latest semver tag. + LATEST=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -1) + if [ -z "$LATEST" ]; then + echo "::error::No existing version tags found." + exit 1 + fi + + # Strip the leading "v" and split into components. + CURRENT="${LATEST#v}" + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + + PATCH=$((PATCH + 1)) + + NEXT="${MAJOR}.${MINOR}.${PATCH}" + echo "current=$CURRENT" >> "$GITHUB_OUTPUT" + echo "next=$NEXT" >> "$GITHUB_OUTPUT" + echo "Releasing: v$CURRENT → v$NEXT (patch bump)" + + - name: Check tag does not already exist + run: | + if git rev-parse "v${{ steps.version.outputs.next }}" >/dev/null 2>&1; then + echo "::error::Tag v${{ steps.version.outputs.next }} already exists." + exit 1 + fi + + - name: Tag and push + run: | + git tag "v${{ steps.version.outputs.next }}" + git push origin main "v${{ steps.version.outputs.next }}" + + - name: Create GitHub release + run: | + gh release create "v${{ steps.version.outputs.next }}" \ + --title "v${{ steps.version.outputs.next }}" \ + --generate-notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/cmd/rshell/main.go b/cmd/rshell/main.go index d5f6dd7c..91032d03 100644 --- a/cmd/rshell/main.go +++ b/cmd/rshell/main.go @@ -17,6 +17,7 @@ import ( "time" "github.com/DataDog/rshell/internal/interpoption" + "github.com/DataDog/rshell/internal/version" "github.com/DataDog/rshell/interp" "github.com/spf13/cobra" ) @@ -40,6 +41,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. cmd := &cobra.Command{ Use: "rshell [file ...]", Short: "A restricted shell interpreter for AI agents", + Version: version.Version, SilenceUsage: true, SilenceErrors: true, Args: cobra.ArbitraryArgs, diff --git a/cmd/rshell/main_test.go b/cmd/rshell/main_test.go index 0405c96b..3a6bf1e9 100644 --- a/cmd/rshell/main_test.go +++ b/cmd/rshell/main_test.go @@ -154,6 +154,18 @@ func TestHelp(t *testing.T) { assert.NotContains(t, stdout, "--command", "-c/--command should be hidden from help") } +// TestVersion verifies that --version exits 0 and prints the version. +// In tests rshell is the main module, so debug.ReadBuildInfo returns "(devel)" +// and the version falls back to "dev". When imported as a library (e.g. by the +// Datadog Agent) it reports the real version from go.mod — see +// TestBuildVersionAsDependency in internal/version/. +func TestVersion(t *testing.T) { + code, stdout, _ := runCLI(t, "--version") + t.Logf("stdout: %q", stdout) + assert.Equal(t, 0, code) + assert.Equal(t, "rshell version dev\n", stdout) +} + func TestFileArg(t *testing.T) { dir := t.TempDir() script := filepath.Join(dir, "test.sh") diff --git a/internal/version/testdata/depcheck/go.mod b/internal/version/testdata/depcheck/go.mod new file mode 100644 index 00000000..7f086744 --- /dev/null +++ b/internal/version/testdata/depcheck/go.mod @@ -0,0 +1,15 @@ +module depcheck + +go 1.25.6 + +require github.com/DataDog/rshell v0.0.10 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/prometheus-community/pro-bing v0.8.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + mvdan.cc/sh/v3 v3.13.0 // indirect +) diff --git a/internal/version/testdata/depcheck/go.sum b/internal/version/testdata/depcheck/go.sum new file mode 100644 index 00000000..5f170023 --- /dev/null +++ b/internal/version/testdata/depcheck/go.sum @@ -0,0 +1,34 @@ +github.com/DataDog/rshell v0.0.10 h1:1jFuWzxxBNCv4tec1Rn1yZJrj9p3ZK7sRZEaHdxUk0Q= +github.com/DataDog/rshell v0.0.10/go.mod h1:e6IP68xHScumYqM/9dT9y5H41EmStHDVEyCZKv5mEt4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc= +github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +mvdan.cc/sh/v3 v3.13.0 h1:dSfq/MVsY4w0Vsi6Lbs0IcQquMVqLdKLESAOZjuHdLg= +mvdan.cc/sh/v3 v3.13.0/go.mod h1:KV1GByGPc/Ho0X1E6Uz9euhsIQEj4hwyKnodLlFLoDM= diff --git a/internal/version/testdata/depcheck/main.go b/internal/version/testdata/depcheck/main.go new file mode 100644 index 00000000..2e0a4550 --- /dev/null +++ b/internal/version/testdata/depcheck/main.go @@ -0,0 +1,30 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Program depcheck prints the rshell version as seen from an external module +// that imports rshell as a dependency. Used by TestBuildVersionAsDependency. +package main + +import ( + "fmt" + "runtime/debug" + + _ "github.com/DataDog/rshell/interp" // ensure rshell appears in deps +) + +func main() { + info, ok := debug.ReadBuildInfo() + if !ok { + fmt.Println("NO_BUILD_INFO") + return + } + for _, dep := range info.Deps { + if dep.Path == "github.com/DataDog/rshell" { + fmt.Println(dep.Version) + return + } + } + fmt.Println("NOT_FOUND") +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 00000000..f6614f7b --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,41 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Package version exposes the build version of rshell. +// +// When rshell is imported as a library (e.g. by the Datadog Agent), Version +// is read from Go's embedded dependency info via [debug.ReadBuildInfo]. +package version + +import "runtime/debug" + +const modulePath = "github.com/DataDog/rshell" + +// Version is the build version string. +var Version = buildVersion() + +// buildVersion reads the rshell version from Go's embedded build info. +// When rshell is a dependency (e.g. in the Datadog Agent), the version +// from go.mod is embedded automatically. For standalone builds it returns "dev". +// Can be overridden via ldflags: +// +// go build -ldflags "-X github.com/DataDog/rshell/internal/version.Version=custom" +func buildVersion() string { + info, ok := debug.ReadBuildInfo() + if !ok { + return "dev" + } + // When built as a standalone binary, check the main module. + if info.Main.Path == modulePath && info.Main.Version != "" && info.Main.Version != "(devel)" { + return info.Main.Version + } + // When imported as a library, find ourselves in the dependency list. + for _, dep := range info.Deps { + if dep.Path == modulePath { + return dep.Version + } + } + return "dev" +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 00000000..e5d610c5 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,64 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package version + +import ( + "os/exec" + "strings" + "testing" +) + +func TestVersionNotEmpty(t *testing.T) { + // In normal test runs (go test), buildVersion returns "dev" because + // the module is the main module built as (devel). That's fine — we + // just verify it's never empty. + if Version == "" { + t.Fatal("Version must not be empty") + } +} + +func TestBuildVersionFallback(t *testing.T) { + // When running tests, the module is the main module so ReadBuildInfo + // returns (devel) for Main.Version. buildVersion should return "dev". + v := buildVersion() + if v == "" { + t.Fatal("buildVersion() must not return empty string") + } + if v != "dev" { + t.Logf("buildVersion() = %q (expected 'dev' in test, got something else — ldflags?)", v) + } +} + +// TestBuildVersionAsDependency verifies that debug.ReadBuildInfo() reports +// the correct version when rshell is imported as a dependency by another +// module. This is the primary use case (the Datadog Agent imports rshell). +// +// The test uses testdata/depcheck/ — a standalone Go module that depends on +// a published version of rshell (v0.0.10). The depcheck program doesn't use +// rshell's version package — it only blank-imports rshell/interp so that +// rshell appears in the binary's dependency list, then calls ReadBuildInfo() +// to verify the version is present. This tests the Go embedding mechanism +// that our buildVersion() relies on, not rshell's code itself, so the +// specific version imported doesn't matter. +func TestBuildVersionAsDependency(t *testing.T) { + if testing.Short() { + t.Skip("skipping: requires building an external module") + } + + // Build and run the depcheck program directly from testdata. + run := exec.Command("go", "run", ".") + run.Dir = "testdata/depcheck" + out, err := run.Output() + if err != nil { + t.Fatalf("go run failed: %v", err) + } + + got := strings.TrimSpace(string(out)) + const want = "v0.0.10" + if got != want { + t.Fatalf("expected version %q from build info deps, got %q", want, got) + } +} diff --git a/interp/api.go b/interp/api.go index a421374e..b5065a4a 100644 --- a/interp/api.go +++ b/interp/api.go @@ -28,6 +28,7 @@ import ( "github.com/DataDog/rshell/allowedpaths" "github.com/DataDog/rshell/builtins" + "github.com/DataDog/rshell/internal/version" ) // runnerConfig holds the immutable configuration of a [Runner]. @@ -421,6 +422,7 @@ func (r *Runner) Reset() { } r.writeEnv = &overlayEnviron{parent: r.Env} r.setVarString("PWD", r.Dir) + r.setVarString("RSHELL_VERSION", version.Version) // IFS is intentionally mutable: scripts may set it to customise field splitting, // which is standard POSIX behaviour. Callers that provide a custom ExecHandler // should be aware that a script can set IFS to a non-whitespace value (e.g. diff --git a/tests/scenarios/shell/environment/rshell_version_is_set.yaml b/tests/scenarios/shell/environment/rshell_version_is_set.yaml new file mode 100644 index 00000000..a75da249 --- /dev/null +++ b/tests/scenarios/shell/environment/rshell_version_is_set.yaml @@ -0,0 +1,9 @@ +description: "RSHELL_VERSION is set (like BASH_VERSION in bash)" +skip_assert_against_bash: true +input: + script: |+ + echo "$RSHELL_VERSION" +expect: + stdout: |+ + dev + exit_code: 0