From 6404aac38b8f12f5ddbddf530fbd02a4c653d2a5 Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Tue, 14 Apr 2026 18:37:41 +0200 Subject: [PATCH 1/9] feat: add version package and automated release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add internal/version package with a source constant as single source of truth for the release version, overridable via ldflags for dev builds. Update the Makefile to inject version + commit at build time via git describe. Add a GitHub Actions release workflow (workflow_dispatch) that auto-computes the next version from the latest git tag. Pick patch/minor/major from a dropdown and click Run — no version to type. The workflow bumps the constant, commits, tags, and creates the release. Access is gated by a "release" environment requiring reviewer approval. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 89 ++++++++++++++++++++++++++++++++ Makefile | 7 ++- internal/version/version.go | 29 +++++++++++ internal/version/version_test.go | 47 +++++++++++++++++ 4 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..1f50617b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,89 @@ +name: Release + +on: + workflow_dispatch: + inputs: + bump: + description: "Version bump type" + required: true + default: "patch" + type: choice + options: + - patch + - minor + - major + +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" + + BUMP="${{ inputs.bump }}" + case "$BUMP" in + patch) PATCH=$((PATCH + 1)) ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + esac + + NEXT="${MAJOR}.${MINOR}.${PATCH}" + echo "current=$CURRENT" >> "$GITHUB_OUTPUT" + echo "next=$NEXT" >> "$GITHUB_OUTPUT" + echo "Releasing: v$CURRENT → v$NEXT ($BUMP 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: Update version constant + run: | + NEXT="${{ steps.version.outputs.next }}" + sed -i "s/^const version = \".*\"/const version = \"$NEXT\"/" internal/version/version.go + grep -q "const version = \"$NEXT\"" internal/version/version.go || { + echo "::error::Failed to update version constant." + exit 1 + } + + - name: Commit version bump + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add internal/version/version.go + git commit -m "release: bump version to ${{ steps.version.outputs.next }}" + + - 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/Makefile b/Makefile index f3a15812..cdd1f329 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,12 @@ .PHONY: build fmt test test_all test_against_bash compliance +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo unknown) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +LDFLAGS = -X github.com/DataDog/rshell/internal/version.Version=$(VERSION) \ + -X github.com/DataDog/rshell/internal/version.Commit=$(COMMIT) + build: - go build -o rshell ./cmd/rshell + go build -ldflags "$(LDFLAGS)" -o rshell ./cmd/rshell fmt: go fmt ./... diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 00000000..e9058072 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,29 @@ +// 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. +// +// The source constant [version] is the single source of truth for the release +// version. It must be updated before tagging a new release. +// +// For development builds, Version can be overridden via ldflags to include +// git metadata (commit offset, dirty state, etc.): +// +// go build -ldflags "-X github.com/DataDog/rshell/internal/version.Version=v0.0.10-3-gabcdef1-dirty" +// +// When not overridden, Version defaults to the source constant, which is +// correct for both direct builds and library consumers who import rshell. +package version + +// version is the release version. This constant is the single source of truth +// and must match the git tag at release time. Update this before tagging. +const version = "0.0.10" + +// Version is the build version string. Defaults to the source constant. +// Overridden via ldflags at build time for dev builds (e.g. "0.0.10-3-gabcdef1-dirty"). +var Version = version + +// Commit is the short git commit hash. Set via ldflags at build time. +var Commit string diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 00000000..a69c48d1 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,47 @@ +// 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 TestVersionConstantNotEmpty(t *testing.T) { + if version == "" { + t.Fatal("version constant must not be empty") + } +} + +func TestVersionDefaultMatchesConstant(t *testing.T) { + // When ldflags haven't overridden Version, it should equal the constant. + // In tests, ldflags are not set, so this always holds. + if Version != version { + t.Errorf("Version = %q, want source constant %q (was it overridden by ldflags in a test?)", Version, version) + } +} + +// TestVersionMatchesGitTag verifies that the source constant matches the +// latest git tag. This catches forgotten version bumps before a release. +// +// Skipped when: +// - git is not available +// - there are no tags (new clone / shallow clone) +// - HEAD is not exactly on a tag (development builds between releases) +func TestVersionMatchesGitTag(t *testing.T) { + // Check if HEAD is exactly a tag (git describe --exact-match fails otherwise). + out, err := exec.Command("git", "describe", "--tags", "--exact-match", "HEAD").CombinedOutput() + if err != nil { + t.Skipf("HEAD is not on a tag (expected during development): %v", err) + } + tag := strings.TrimSpace(string(out)) + tag = strings.TrimPrefix(tag, "v") + + if version != tag { + t.Errorf("version constant %q does not match git tag %q — update the constant in version.go before tagging", version, tag) + } +} From c847561d088a6282ba825d2a46df4f297935d60a Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Wed, 15 Apr 2026 17:04:31 +0200 Subject: [PATCH 2/9] simplify release to patch-only and add --version flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the major/minor/patch dropdown from the release workflow — it now always bumps the patch version, reducing room for human error. Add `--version` flag to the CLI via cobra's built-in Version field. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 19 ++----------------- cmd/rshell/main.go | 2 ++ 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f50617b..4428ed51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,16 +2,6 @@ name: Release on: workflow_dispatch: - inputs: - bump: - description: "Version bump type" - required: true - default: "patch" - type: choice - options: - - patch - - minor - - major permissions: contents: write @@ -40,17 +30,12 @@ jobs: CURRENT="${LATEST#v}" IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" - BUMP="${{ inputs.bump }}" - case "$BUMP" in - patch) PATCH=$((PATCH + 1)) ;; - minor) MINOR=$((MINOR + 1)); PATCH=0 ;; - major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; - esac + PATCH=$((PATCH + 1)) NEXT="${MAJOR}.${MINOR}.${PATCH}" echo "current=$CURRENT" >> "$GITHUB_OUTPUT" echo "next=$NEXT" >> "$GITHUB_OUTPUT" - echo "Releasing: v$CURRENT → v$NEXT ($BUMP bump)" + echo "Releasing: v$CURRENT → v$NEXT (patch bump)" - name: Check tag does not already exist run: | 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, From 4e371afe80c3494e94326e343c4c48d826e27861 Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Wed, 15 Apr 2026 17:12:36 +0200 Subject: [PATCH 3/9] use runtime/debug for version instead of hardcoded constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hardcoded version constant with debug.ReadBuildInfo() to read the version from Go's embedded dependency info. When rshell is imported as a library (e.g. by the Datadog Agent), this automatically picks up the version from go.mod — no source constant to maintain. This also simplifies the release workflow: it no longer needs to sed the version file and commit before tagging. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 16 ---------- internal/version/version.go | 50 +++++++++++++++++++++++--------- internal/version/version_test.go | 49 ++++++++++--------------------- 3 files changed, 53 insertions(+), 62 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4428ed51..80c6c5ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,22 +44,6 @@ jobs: exit 1 fi - - name: Update version constant - run: | - NEXT="${{ steps.version.outputs.next }}" - sed -i "s/^const version = \".*\"/const version = \"$NEXT\"/" internal/version/version.go - grep -q "const version = \"$NEXT\"" internal/version/version.go || { - echo "::error::Failed to update version constant." - exit 1 - } - - - name: Commit version bump - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add internal/version/version.go - git commit -m "release: bump version to ${{ steps.version.outputs.next }}" - - name: Tag and push run: | git tag "v${{ steps.version.outputs.next }}" diff --git a/internal/version/version.go b/internal/version/version.go index e9058072..39b4b1ac 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -5,25 +5,49 @@ // Package version exposes the build version of rshell. // -// The source constant [version] is the single source of truth for the release -// version. It must be updated before tagging a new release. +// 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]. // -// For development builds, Version can be overridden via ldflags to include -// git metadata (commit offset, dirty state, etc.): +// For development builds, Version can be overridden via ldflags: // // go build -ldflags "-X github.com/DataDog/rshell/internal/version.Version=v0.0.10-3-gabcdef1-dirty" -// -// When not overridden, Version defaults to the source constant, which is -// correct for both direct builds and library consumers who import rshell. package version -// version is the release version. This constant is the single source of truth -// and must match the git tag at release time. Update this before tagging. -const version = "0.0.10" +import "runtime/debug" + +const modulePath = "github.com/DataDog/rshell" -// Version is the build version string. Defaults to the source constant. -// Overridden via ldflags at build time for dev builds (e.g. "0.0.10-3-gabcdef1-dirty"). -var Version = version +// Version is the build version string. Set via ldflags at build time for dev +// builds (e.g. "v0.0.10-3-gabcdef1-dirty"). When not set, falls back to the +// module version from build info. +var Version string + +func init() { + if Version == "" { + Version = buildVersion() + } +} // Commit is the short git commit hash. Set via ldflags at build time. var Commit string + +// 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". +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 index a69c48d1..334ad3db 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -5,43 +5,26 @@ package version -import ( - "os/exec" - "strings" - "testing" -) +import "testing" -func TestVersionConstantNotEmpty(t *testing.T) { - if version == "" { - t.Fatal("version constant must not be empty") +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 TestVersionDefaultMatchesConstant(t *testing.T) { - // When ldflags haven't overridden Version, it should equal the constant. - // In tests, ldflags are not set, so this always holds. - if Version != version { - t.Errorf("Version = %q, want source constant %q (was it overridden by ldflags in a test?)", Version, version) +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") } -} - -// TestVersionMatchesGitTag verifies that the source constant matches the -// latest git tag. This catches forgotten version bumps before a release. -// -// Skipped when: -// - git is not available -// - there are no tags (new clone / shallow clone) -// - HEAD is not exactly on a tag (development builds between releases) -func TestVersionMatchesGitTag(t *testing.T) { - // Check if HEAD is exactly a tag (git describe --exact-match fails otherwise). - out, err := exec.Command("git", "describe", "--tags", "--exact-match", "HEAD").CombinedOutput() - if err != nil { - t.Skipf("HEAD is not on a tag (expected during development): %v", err) - } - tag := strings.TrimSpace(string(out)) - tag = strings.TrimPrefix(tag, "v") - - if version != tag { - t.Errorf("version constant %q does not match git tag %q — update the constant in version.go before tagging", version, tag) + // In a test binary, we expect "dev" since rshell is the main module. + if v != "dev" { + t.Logf("buildVersion() = %q (expected 'dev' in test, got something else — ldflags?)", v) } } From 1fb7b0d9e4b646b870b047ad32447fadffc4eae2 Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Wed, 15 Apr 2026 17:16:47 +0200 Subject: [PATCH 4/9] simplify Makefile and remove ldflags/Commit machinery rshell is only used as a library via the agent, so the ldflags version override for standalone dev builds adds complexity for no benefit. Remove VERSION/COMMIT/LDFLAGS from the Makefile and the Commit variable from the version package. ldflags can still be used if needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 7 +------ internal/version/version.go | 23 ++++++----------------- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/Makefile b/Makefile index cdd1f329..f3a15812 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,7 @@ .PHONY: build fmt test test_all test_against_bash compliance -VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo unknown) -COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) -LDFLAGS = -X github.com/DataDog/rshell/internal/version.Version=$(VERSION) \ - -X github.com/DataDog/rshell/internal/version.Commit=$(COMMIT) - build: - go build -ldflags "$(LDFLAGS)" -o rshell ./cmd/rshell + go build -o rshell ./cmd/rshell fmt: go fmt ./... diff --git a/internal/version/version.go b/internal/version/version.go index 39b4b1ac..4ebc621d 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -7,29 +7,18 @@ // // 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]. -// -// For development builds, Version can be overridden via ldflags: -// -// go build -ldflags "-X github.com/DataDog/rshell/internal/version.Version=v0.0.10-3-gabcdef1-dirty" package version import "runtime/debug" const modulePath = "github.com/DataDog/rshell" -// Version is the build version string. Set via ldflags at build time for dev -// builds (e.g. "v0.0.10-3-gabcdef1-dirty"). When not set, falls back to the -// module version from build info. -var Version string - -func init() { - if Version == "" { - Version = buildVersion() - } -} - -// Commit is the short git commit hash. Set via ldflags at build time. -var Commit string +// Version is the build version string. Read from Go's embedded dependency +// info when rshell is imported as a library (e.g. by the Datadog Agent). +// Returns "dev" for standalone builds. Can be overridden via ldflags: +// +// go build -ldflags "-X github.com/DataDog/rshell/internal/version.Version=custom" +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 eb2fc63853edd45f4863c60f4baa5cd49e6f24e4 Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Wed, 15 Apr 2026 17:40:05 +0200 Subject: [PATCH 5/9] add e2e test proving runtime/debug version works as dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use testdata/depcheck/ — a small Go program that imports rshell as a dependency and prints the version from debug.ReadBuildInfo(). The test copies it to a temp dir with a replace directive pointing to local rshell, builds and runs it, and verifies it finds a version in the deps list. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../version/testdata/depcheck/go.mod.test | 7 ++ internal/version/testdata/depcheck/main.go | 25 +++++++ internal/version/version_test.go | 75 ++++++++++++++++++- 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 internal/version/testdata/depcheck/go.mod.test create mode 100644 internal/version/testdata/depcheck/main.go diff --git a/internal/version/testdata/depcheck/go.mod.test b/internal/version/testdata/depcheck/go.mod.test new file mode 100644 index 00000000..4cc07412 --- /dev/null +++ b/internal/version/testdata/depcheck/go.mod.test @@ -0,0 +1,7 @@ +module depcheck + +go 1.25.6 + +require github.com/DataDog/rshell v0.0.0 + +// replace directive is patched by the test to point to the local rshell module. diff --git a/internal/version/testdata/depcheck/main.go b/internal/version/testdata/depcheck/main.go new file mode 100644 index 00000000..07bfc8ff --- /dev/null +++ b/internal/version/testdata/depcheck/main.go @@ -0,0 +1,25 @@ +// 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_test.go b/internal/version/version_test.go index 334ad3db..0349d6b1 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -5,7 +5,14 @@ package version -import "testing" +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) func TestVersionNotEmpty(t *testing.T) { // In normal test runs (go test), buildVersion returns "dev" because @@ -23,8 +30,72 @@ func TestBuildVersionFallback(t *testing.T) { if v == "" { t.Fatal("buildVersion() must not return empty string") } - // In a test binary, we expect "dev" since rshell is the main module. if v != "dev" { t.Logf("buildVersion() = %q (expected 'dev' in test, got something else — ldflags?)", v) } } + +// TestBuildVersionAsDependency verifies that buildVersion returns a real +// version (not "dev") 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 small Go program that imports rshell +// and prints the version from debug.ReadBuildInfo(). The test copies it to a +// temp dir, adds a replace directive pointing to the local rshell module, +// builds and runs it, and checks the output. +func TestBuildVersionAsDependency(t *testing.T) { + if testing.Short() { + t.Skip("skipping: requires building an external module") + } + + // Find the rshell module root (two levels up from internal/version/). + modRoot, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(modRoot, "go.mod")); err != nil { + t.Fatalf("could not find rshell go.mod at %s: %v", modRoot, err) + } + + tmp := t.TempDir() + + // Copy main.go from testdata. + mainSrc, err := os.ReadFile("testdata/depcheck/main.go") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmp, "main.go"), mainSrc, 0o644); err != nil { + t.Fatal(err) + } + + // Copy go.mod template and append the replace directive. + goModSrc, err := os.ReadFile("testdata/depcheck/go.mod.test") + if err != nil { + t.Fatal(err) + } + goMod := string(goModSrc) + fmt.Sprintf("\nreplace github.com/DataDog/rshell => %s\n", modRoot) + if err := os.WriteFile(filepath.Join(tmp, "go.mod"), []byte(goMod), 0o644); err != nil { + t.Fatal(err) + } + + // Resolve dependencies. + tidy := exec.Command("go", "mod", "tidy") + tidy.Dir = tmp + if out, err := tidy.CombinedOutput(); err != nil { + t.Fatalf("go mod tidy failed: %v\n%s", err, out) + } + + // Build and run. + run := exec.Command("go", "run", ".") + run.Dir = tmp + out, err := run.CombinedOutput() + if err != nil { + t.Fatalf("go run failed: %v\n%s", err, out) + } + + got := strings.TrimSpace(string(out)) + if got == "NOT_FOUND" || got == "NO_BUILD_INFO" || got == "" { + t.Fatalf("expected a version from build info deps, got %q", got) + } + t.Logf("rshell version from dependency build info: %s", got) +} From 16d2e136e6403d9fbba1cf5e49eb1082d60a9eec Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Wed, 15 Apr 2026 17:46:33 +0200 Subject: [PATCH 6/9] improve version e2e test: use v1.2.3 and assert exact match Use a realistic version (v1.2.3) instead of v0.0.0, and assert the exact value instead of just checking it's non-empty. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/version/testdata/depcheck/go.mod.test | 2 +- internal/version/version_test.go | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/version/testdata/depcheck/go.mod.test b/internal/version/testdata/depcheck/go.mod.test index 4cc07412..c931337c 100644 --- a/internal/version/testdata/depcheck/go.mod.test +++ b/internal/version/testdata/depcheck/go.mod.test @@ -2,6 +2,6 @@ module depcheck go 1.25.6 -require github.com/DataDog/rshell v0.0.0 +require github.com/DataDog/rshell v1.2.3 // replace directive is patched by the test to point to the local rshell module. diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 0349d6b1..fca477d9 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -94,8 +94,10 @@ func TestBuildVersionAsDependency(t *testing.T) { } got := strings.TrimSpace(string(out)) - if got == "NOT_FOUND" || got == "NO_BUILD_INFO" || got == "" { - t.Fatalf("expected a version from build info deps, got %q", got) + // The test go.mod requires rshell at v1.2.3 — verify that's what + // debug.ReadBuildInfo reports back. + const want = "v1.2.3" + if got != want { + t.Fatalf("expected version %q from build info deps, got %q", want, got) } - t.Logf("rshell version from dependency build info: %s", got) } From 850201aab166f6dc02318f1875e1c6991eedd7fc Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Wed, 15 Apr 2026 17:54:54 +0200 Subject: [PATCH 7/9] fix: add copyright header and clean up version.go comments Add missing copyright header to testdata/depcheck/main.go (compliance check failure). Move ldflags note to buildVersion doc comment to reduce redundancy. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/version/testdata/depcheck/main.go | 5 +++++ internal/version/version.go | 9 ++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/version/testdata/depcheck/main.go b/internal/version/testdata/depcheck/main.go index 07bfc8ff..2e0a4550 100644 --- a/internal/version/testdata/depcheck/main.go +++ b/internal/version/testdata/depcheck/main.go @@ -1,3 +1,8 @@ +// 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 diff --git a/internal/version/version.go b/internal/version/version.go index 4ebc621d..f6614f7b 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -13,16 +13,15 @@ import "runtime/debug" const modulePath = "github.com/DataDog/rshell" -// Version is the build version string. Read from Go's embedded dependency -// info when rshell is imported as a library (e.g. by the Datadog Agent). -// Returns "dev" for standalone builds. Can be overridden via ldflags: -// -// go build -ldflags "-X github.com/DataDog/rshell/internal/version.Version=custom" +// 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 { From 9b93dbbb04674a1c91c8868ead3023c5efd1e494 Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Thu, 16 Apr 2026 11:56:52 +0200 Subject: [PATCH 8/9] expose RSHELL_VERSION variable, add --version test, simplify depcheck - Set $RSHELL_VERSION in the interpreter environment (like $BASH_VERSION) - Add TestVersion for rshell --version CLI output - Simplify depcheck e2e test: use published v0.0.10 with a plain go.mod instead of runtime replace directive patching - Add scenario test for $RSHELL_VERSION Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/rshell/main_test.go | 12 ++++ internal/version/testdata/depcheck/go.mod | 15 +++++ .../version/testdata/depcheck/go.mod.test | 7 --- internal/version/testdata/depcheck/go.sum | 34 ++++++++++ internal/version/version_test.go | 63 ++++--------------- interp/api.go | 2 + .../environment/rshell_version_is_set.yaml | 9 +++ 7 files changed, 84 insertions(+), 58 deletions(-) create mode 100644 internal/version/testdata/depcheck/go.mod delete mode 100644 internal/version/testdata/depcheck/go.mod.test create mode 100644 internal/version/testdata/depcheck/go.sum create mode 100644 tests/scenarios/shell/environment/rshell_version_is_set.yaml 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.mod.test b/internal/version/testdata/depcheck/go.mod.test deleted file mode 100644 index c931337c..00000000 --- a/internal/version/testdata/depcheck/go.mod.test +++ /dev/null @@ -1,7 +0,0 @@ -module depcheck - -go 1.25.6 - -require github.com/DataDog/rshell v1.2.3 - -// replace directive is patched by the test to point to the local rshell module. 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/version_test.go b/internal/version/version_test.go index fca477d9..21ea9874 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -6,10 +6,7 @@ package version import ( - "fmt" - "os" "os/exec" - "path/filepath" "strings" "testing" ) @@ -35,68 +32,32 @@ func TestBuildVersionFallback(t *testing.T) { } } -// TestBuildVersionAsDependency verifies that buildVersion returns a real -// version (not "dev") when rshell is imported as a dependency by another +// 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 small Go program that imports rshell -// and prints the version from debug.ReadBuildInfo(). The test copies it to a -// temp dir, adds a replace directive pointing to the local rshell module, -// builds and runs it, and checks the output. +// 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") } - // Find the rshell module root (two levels up from internal/version/). - modRoot, err := filepath.Abs(filepath.Join("..", "..")) - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(modRoot, "go.mod")); err != nil { - t.Fatalf("could not find rshell go.mod at %s: %v", modRoot, err) - } - - tmp := t.TempDir() - - // Copy main.go from testdata. - mainSrc, err := os.ReadFile("testdata/depcheck/main.go") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(tmp, "main.go"), mainSrc, 0o644); err != nil { - t.Fatal(err) - } - - // Copy go.mod template and append the replace directive. - goModSrc, err := os.ReadFile("testdata/depcheck/go.mod.test") - if err != nil { - t.Fatal(err) - } - goMod := string(goModSrc) + fmt.Sprintf("\nreplace github.com/DataDog/rshell => %s\n", modRoot) - if err := os.WriteFile(filepath.Join(tmp, "go.mod"), []byte(goMod), 0o644); err != nil { - t.Fatal(err) - } - - // Resolve dependencies. - tidy := exec.Command("go", "mod", "tidy") - tidy.Dir = tmp - if out, err := tidy.CombinedOutput(); err != nil { - t.Fatalf("go mod tidy failed: %v\n%s", err, out) - } - - // Build and run. + // Build and run the depcheck program directly from testdata. run := exec.Command("go", "run", ".") - run.Dir = tmp + run.Dir = "testdata/depcheck" out, err := run.CombinedOutput() if err != nil { t.Fatalf("go run failed: %v\n%s", err, out) } got := strings.TrimSpace(string(out)) - // The test go.mod requires rshell at v1.2.3 — verify that's what - // debug.ReadBuildInfo reports back. - const want = "v1.2.3" + 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 From 137f50ca276d325ac0de12f8c0ef62c4f1d8e610 Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Thu, 16 Apr 2026 12:06:46 +0200 Subject: [PATCH 9/9] fix: use Output() instead of CombinedOutput() in depcheck test CombinedOutput() captures stderr too, so "go: downloading ..." progress lines from `go run` polluted stdout on CI (where the module isn't cached). Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/version/version_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 21ea9874..e5d610c5 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -51,9 +51,9 @@ func TestBuildVersionAsDependency(t *testing.T) { // Build and run the depcheck program directly from testdata. run := exec.Command("go", "run", ".") run.Dir = "testdata/depcheck" - out, err := run.CombinedOutput() + out, err := run.Output() if err != nil { - t.Fatalf("go run failed: %v\n%s", err, out) + t.Fatalf("go run failed: %v", err) } got := strings.TrimSpace(string(out))