From ddab6a26427ebafcd0c33864a5f854c7102a8466 Mon Sep 17 00:00:00 2001 From: Akanksha Trehun Date: Wed, 28 Oct 2026 20:56:20 +0530 Subject: [PATCH 1/2] feat: run the bindings tool through rc, add rc doctor, forward termination, and test on Windows --- .github/workflows/release.yml | 5 +- README.md | 18 ++++++-- bindings.go | 22 +++++++++ doctor.go | 76 +++++++++++++++++++++++++++++++ helpers_test.go | 12 +++++ plugin.go | 57 +++++++++++++++++++++++ plugin_unix_test.go | 83 ++++++++++++++++++++++++++++++++++ plugin_windows_test.go | 25 ++++++++++ profile.go | 47 ++++++++++--------- rc.go | 2 +- rc_test.go | 59 ++++++++++++++++++++---- scripts/formula.sh | 2 + scripts/go-profiler-formula.sh | 35 ++++++++++++++ 13 files changed, 405 insertions(+), 38 deletions(-) create mode 100644 bindings.go create mode 100644 doctor.go create mode 100644 helpers_test.go create mode 100644 plugin.go create mode 100644 plugin_unix_test.go create mode 100644 plugin_windows_test.go create mode 100755 scripts/go-profiler-formula.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6808d46..58e960a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: verify: strategy: matrix: - os: [ubuntu-24.04, macos-latest] + os: [ubuntu-24.04, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 @@ -69,6 +69,9 @@ jobs: done (cd dist && sha256sum rc_* > checksums.txt) scripts/formula.sh "$version" dist/checksums.txt > dist/rc.rb + profiler=$(gh release view --repo runtimeconditions/go-rc-profiler --json tagName --jq .tagName) + gh release download "$profiler" --repo runtimeconditions/go-rc-profiler --pattern checksums.txt --dir "$RUNNER_TEMP/go-profiler" + scripts/go-profiler-formula.sh "${profiler#v}" "$RUNNER_TEMP/go-profiler/checksums.txt" > dist/rc-profiler-go.rb echo "version=$version" >> "$GITHUB_OUTPUT" - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 if: steps.build.outputs.version != '' diff --git a/README.md b/README.md index e1f55b8..14bf556 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ The Runtime Conditions command line, built with [Cobra](https://github.com/spf13 ```sh rc profile [args...] +rc bindings [args...] +rc doctor rc --version ``` @@ -29,7 +31,7 @@ requirements on a profiler are in [CONTRACT.md](CONTRACT.md). Every merge to `main` with a releasable change publishes a GitHub release with archives for macOS, Linux and Windows, `checksums.txt`, build provenance attestations (`gh attestation verify --repo runtimeconditions/go-rc-cli`), and a generated Homebrew -formula, `rc.rb`. Titles follow [Conventional Commits](https://www.conventionalcommits.org/): +formulas, `rc.rb` and `rc-profiler-go.rb` (built from the latest Go profiler release). Titles follow [Conventional Commits](https://www.conventionalcommits.org/): `feat` releases a minor version, `fix`, `perf` and `revert` a patch, and `!` a major. ## Build @@ -39,8 +41,16 @@ go build -ldflags "-X main.version=0.1.0" -o rc ./cmd/rc go test ./... ``` -`rc bindings` still lives in -[`runtimeconditions/extensions`](https://github.com/runtimeconditions/extensions) and moves here once -its Go modules can be fetched from outside that repository. +`rc bindings` works the same way: it runs `rc-bindings` (or `RC_BINDINGS_BIN`) and passes every +argument through. That tool is built from +[`runtimeconditions/extensions`](https://github.com/runtimeconditions/extensions), whose Go modules +are not published, so it stays a separate executable instead of being linked into `rc`. + +`rc doctor` lists the bindings tool and every profiler it can find with its version and supported core +schemas, and exits non-zero when a profiler does not support the core schema `rc` targets. + +The Go profiler is not linked into `rc` either. It is a separate program, released with its core schema +built in, and a Go `main` package cannot be imported. The Homebrew formula for `rc` depends on a formula +for it, so `brew install rc` installs both, and `rc profile go` finds `go-rc-profiler`. MIT licensed. diff --git a/bindings.go b/bindings.go new file mode 100644 index 0000000..12d5abd --- /dev/null +++ b/bindings.go @@ -0,0 +1,22 @@ +package rc + +import "github.com/spf13/cobra" + +func bindingsCommand() *cobra.Command { + return &cobra.Command{ + Use: "bindings [args...]", + Short: "Generate and verify extension bindings", + Long: `Runs the bindings tool and passes all arguments to it unchanged. + +The tool is the executable named by RC_BINDINGS_BIN, else rc-bindings on PATH. It is built from +runtimeconditions/extensions (tooling/extension-bindings/orchestrator, cmd/rc-bindings).`, + DisableFlagParsing: true, + RunE: func(c *cobra.Command, args []string) error { + path, err := findExecutable("RC_BINDINGS_BIN", "rc-bindings") + if err != nil { + return err + } + return runPlugin(c, path, args) + }, + } +} diff --git a/doctor.go b/doctor.go new file mode 100644 index 0000000..2eb8320 --- /dev/null +++ b/doctor.go @@ -0,0 +1,76 @@ +package rc + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +func doctorCommand() *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Show the installed bindings tool and profilers, their versions and core schemas", + Args: cobra.NoArgs, + RunE: func(c *cobra.Command, _ []string) error { + out := tabwriter.NewWriter(c.OutOrStdout(), 0, 4, 2, ' ', 0) + fmt.Fprintf(out, "rc\t%s\ttargets core schema %s\n", c.Root().Version, CoreSchema) + bindings := "not found" + if path, err := findExecutable("RC_BINDINGS_BIN", "rc-bindings"); err == nil { + bindings = path + } + fmt.Fprintf(out, "bindings\t%s\n", bindings) + incompatible := 0 + for _, language := range doctorLanguages() { + path, err := profilerPath(language) + if err != nil { + fmt.Fprintf(out, "%s\tnot found\n", language) + continue + } + info, ok := queryInfo(c.Context(), path) + status := "compatible" + switch { + case !ok: + info.Version, status = versionLine(path), "unchecked: core schemas not reported" + case !slices.Contains(info.CoreSchemas, CoreSchema): + incompatible, status = incompatible+1, "incompatible: supports "+strings.Join(info.CoreSchemas, ", ") + } + fmt.Fprintf(out, "%s\t%s\t%s\t%s\n", language, path, info.Version, status) + } + if err := out.Flush(); err != nil { + return err + } + if incompatible > 0 { + return fmt.Errorf("%d profiler(s) do not support core schema %s", incompatible, CoreSchema) + } + return nil + }, + } +} + +func doctorLanguages() []string { + languages := []string{"go", "python", "typescript"} + for _, dir := range filepath.SplitList(os.Getenv("PATH")) { + entries, _ := os.ReadDir(dir) + for _, entry := range entries { + name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) + if language, found := strings.CutPrefix(name, "rc-profiler-"); found && languageName.MatchString(language) && !slices.Contains(languages, language) { + languages = append(languages, language) + } + } + } + return languages +} + +func versionLine(path string) string { + out, err := exec.Command(path, "--version").Output() + if line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n"); err == nil && line != "" { + return line + } + return "unknown" +} diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 0000000..4de6e16 --- /dev/null +++ b/helpers_test.go @@ -0,0 +1,12 @@ +package rc + +import ( + "bytes" + "context" +) + +func rc(args ...string) (string, string, error) { + var out, errout bytes.Buffer + err := Run(context.Background(), "1.2.3", args, &out, &errout) + return out.String(), errout.String(), err +} diff --git a/plugin.go b/plugin.go new file mode 100644 index 0000000..998222a --- /dev/null +++ b/plugin.go @@ -0,0 +1,57 @@ +package rc + +import ( + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "syscall" + + "github.com/spf13/cobra" +) + +func findExecutable(env string, names ...string) (string, error) { + if override := os.Getenv(env); override != "" { + names = []string{override} + } + for _, name := range names { + if path, err := exec.LookPath(name); err == nil { + return path, nil + } + } + return "", fmt.Errorf("%s not found: install it on PATH or set %s", names[0], env) +} + +// runPlugin runs a separate executable with the terminal streams. Ctrl-C reaches it through the +// terminal, so rc only forwards other termination signals and returns the plugin's exit status. +func runPlugin(c *cobra.Command, path string, args []string) error { + cmd := exec.Command(path, args...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, c.OutOrStdout(), c.ErrOrStderr() + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signals) + if err := cmd.Start(); err != nil { + return err + } + done := make(chan struct{}) + defer close(done) + go func() { + for { + select { + case s := <-signals: + if s != os.Interrupt { + _ = cmd.Process.Signal(s) + } + case <-done: + return + } + } + }() + var exit *exec.ExitError + if err := cmd.Wait(); errors.As(err, &exit) { + return &ExitError{exit.ExitCode()} + } else { + return err + } +} diff --git a/plugin_unix_test.go b/plugin_unix_test.go new file mode 100644 index 0000000..b71129d --- /dev/null +++ b/plugin_unix_test.go @@ -0,0 +1,83 @@ +//go:build !windows + +package rc + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func buildRC(t *testing.T) string { + path := filepath.Join(t.TempDir(), "rc") + if out, err := exec.Command("go", "build", "-o", path, "./cmd/rc").CombinedOutput(); err != nil { + t.Fatalf("%v\n%s", err, out) + } + return path +} + +func script(t *testing.T, name, body string) string { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body), 0755); err != nil { + t.Fatal(err) + } + return dir +} + +func TestStdinReachesTheProfilerAndItsExitStatusIsReturned(t *testing.T) { + bin := buildRC(t) + cmd := exec.Command(bin, "profile", "ruby", "generate") + cmd.Env = append(os.Environ(), "PATH="+script(t, "rc-profiler-ruby", `[ "$1" = --version ] && exit 2 +read line +echo "got: $line" +exit 7 +`)) + cmd.Stdin = strings.NewReader("hello\n") + var out bytes.Buffer + cmd.Stdout = &out + err := cmd.Run() + exit, ok := err.(*exec.ExitError) + if !ok || exit.ExitCode() != 7 || out.String() != "got: hello\n" { + t.Fatalf("exit %v, output %q", err, out.String()) + } +} + +func TestTerminationIsForwardedToTheProfiler(t *testing.T) { + bin := buildRC(t) + marker := filepath.Join(t.TempDir(), "terminated") + cmd := exec.Command(bin, "profile", "ruby", "generate") + cmd.Env = append(os.Environ(), "MARKER="+marker, "PATH="+script(t, "rc-profiler-ruby", `[ "$1" = --version ] && exit 2 +trap 'echo terminated > "$MARKER"; exit 0' TERM +echo ready +while :; do sleep 0.1; done +`)) + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err = cmd.Start(); err != nil { + t.Fatal(err) + } + if n, _ := stdout.Read(make([]byte, 6)); n != 6 { + t.Fatal("profiler did not start") + } + if err = cmd.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatal(err) + } + if err = cmd.Wait(); err != nil { + t.Fatalf("rc did not return the profiler's clean exit: %v", err) + } + for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(50 * time.Millisecond) { + if _, err = os.Stat(marker); err == nil { + return + } + if time.Now().After(deadline) { + t.Fatal("the profiler never received SIGTERM") + } + } +} diff --git a/plugin_windows_test.go b/plugin_windows_test.go new file mode 100644 index 0000000..83d7dca --- /dev/null +++ b/plugin_windows_test.go @@ -0,0 +1,25 @@ +//go:build windows + +package rc + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNpmStyleCmdShimsAreFoundAndRun(t *testing.T) { + dir := t.TempDir() + shim := "@echo off\r\necho args: %*\r\nexit /b 4\r\n" + if err := os.WriteFile(filepath.Join(dir, "runtimeconditions-typescript-profiler.cmd"), []byte(shim), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + var exit *ExitError + out, _, err := rc("profile", "typescript", "generate", "--name", "orders") + if !errors.As(err, &exit) || exit.Code != 4 || !strings.Contains(out, "args: generate --name orders") { + t.Fatalf("%v %q", err, out) + } +} diff --git a/profile.go b/profile.go index c162c1b..c8540fc 100644 --- a/profile.go +++ b/profile.go @@ -3,10 +3,8 @@ package rc import ( "context" "encoding/json" - "errors" "fmt" "io" - "os" "os/exec" "regexp" "slices" @@ -45,13 +43,7 @@ The go, python and typescript profilers are also found under their own published return err } } - cmd := exec.CommandContext(c.Context(), path, args[1:]...) - cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, c.OutOrStdout(), c.ErrOrStderr() - var exit *exec.ExitError - if err = cmd.Run(); errors.As(err, &exit) { - return &ExitError{exit.ExitCode()} - } - return err + return runPlugin(c, path, args[1:]) }, } } @@ -60,35 +52,42 @@ func profilerPath(language string) (string, error) { if !languageName.MatchString(language) { return "", fmt.Errorf("invalid language %q", language) } - env := "RC_" + strings.ToUpper(strings.ReplaceAll(language, "-", "_")) + "_PROFILER_BIN" names := []string{"rc-profiler-" + language} if published, ok := publishedProfilers[language]; ok { names = append(names, published) } - if override := os.Getenv(env); override != "" { - names = []string{override} - } - for _, name := range names { - if path, err := exec.LookPath(name); err == nil { - return path, nil - } + path, err := findExecutable(profilerEnv(language), names...) + if err != nil { + return "", fmt.Errorf("no %s profiler found: install rc-profiler-%s on PATH or set %s", language, language, profilerEnv(language)) } - return "", fmt.Errorf("no %s profiler found: install rc-profiler-%s on PATH or set %s", language, language, env) + return path, nil +} + +func profilerEnv(language string) string { + return "RC_" + strings.ToUpper(strings.ReplaceAll(language, "-", "_")) + "_PROFILER_BIN" } func runsProfiler(args []string) bool { return len(args) > 0 && !strings.HasPrefix(args[0], "-") && args[0] != "version" } -func checkCoreSchema(ctx context.Context, path, language string, stderr io.Writer) error { +type profilerInfo struct { + Name string `json:"name"` + Version string `json:"version"` + CoreSchemas []string `json:"coreSchemas"` +} + +// queryInfo asks a profiler for --version --json; ok is false when it cannot answer that way. +func queryInfo(ctx context.Context, path string) (info profilerInfo, ok bool) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - var info struct { - Version string `json:"version"` - CoreSchemas []string `json:"coreSchemas"` - } out, err := exec.CommandContext(ctx, path, "--version", "--json").Output() - if err != nil || json.Unmarshal(out, &info) != nil || len(info.CoreSchemas) == 0 { + return info, err == nil && json.Unmarshal(out, &info) == nil && len(info.CoreSchemas) > 0 +} + +func checkCoreSchema(ctx context.Context, path, language string, stderr io.Writer) error { + info, ok := queryInfo(ctx, path) + if !ok { fmt.Fprintf(stderr, "note: the %s profiler does not report its core schemas; skipping the compatibility check\n", language) return nil } diff --git a/rc.go b/rc.go index b904335..e98715b 100644 --- a/rc.go +++ b/rc.go @@ -19,6 +19,6 @@ func Run(ctx context.Context, version string, args []string, stdout, stderr io.W root.SetOut(stdout) root.SetErr(stderr) root.SetArgs(args) - root.AddCommand(profileCommand()) + root.AddCommand(bindingsCommand(), profileCommand(), doctorCommand()) return root.ExecuteContext(ctx) } diff --git a/rc_test.go b/rc_test.go index 6a3a40c..ccac3ee 100644 --- a/rc_test.go +++ b/rc_test.go @@ -1,8 +1,8 @@ +//go:build !windows + package rc import ( - "bytes" - "context" "errors" "os" "path/filepath" @@ -28,12 +28,6 @@ exit ${FAKE_EXIT:-0} return dir } -func rc(args ...string) (string, string, error) { - var out, errout bytes.Buffer - err := Run(context.Background(), "1.2.3", args, &out, &errout) - return out.String(), errout.String(), err -} - func TestRootListsProfileAndReportsItsVersion(t *testing.T) { out, _, err := rc() if err != nil || !strings.Contains(out, "profile") { @@ -116,3 +110,52 @@ func TestProfileDoesNotCheckVersionQueries(t *testing.T) { t.Fatalf("%v\n%q", err, out) } } + +func TestBindingsPassesEveryArgumentToTheBindingsTool(t *testing.T) { + t.Setenv("PATH", fakeProfiler(t, "rc-bindings")) + out, _, err := rc("bindings", "check", "--target", "common-integrations:go", "--help") + if err != nil || out != "args: check --target common-integrations:go --help\n" { + t.Fatalf("%v\n%q", err, out) + } + t.Setenv("PATH", t.TempDir()) + t.Setenv("RC_BINDINGS_BIN", filepath.Join(fakeProfiler(t, "tool"), "tool")) + if out, _, err = rc("bindings", "update"); err != nil || out != "args: update\n" { + t.Fatalf("%v\n%q", err, out) + } +} + +func TestBindingsExplainsAMissingTool(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + if _, _, err := rc("bindings", "check"); err == nil || !strings.Contains(err.Error(), "RC_BINDINGS_BIN") { + t.Fatalf("missing tool not explained: %v", err) + } +} + +func TestDoctorListsToolsAndFlagsIncompatibleProfilers(t *testing.T) { + dir := fakeProfiler(t, "rc-profiler-typescript") + for _, name := range []string{"rc-profiler-ruby", "rc-bindings"} { + if err := os.Symlink(filepath.Join(dir, "rc-profiler-typescript"), filepath.Join(dir, name)); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir) + t.Setenv("FAKE_SCHEMAS", `["`+CoreSchema+`"]`) + out, _, err := rc("doctor") + for _, want := range []string{"rc-bindings", "go ", "not found", "typescript", "1.0.0 compatible", "ruby"} { + if !strings.Contains(out, want) { + t.Fatalf("doctor output lacks %q:\n%s", want, out) + } + } + if err != nil { + t.Fatal(err) + } + t.Setenv("FAKE_SCHEMAS", `["0.2.0"]`) + out, _, err = rc("doctor") + if err == nil || !strings.Contains(out, "incompatible: supports 0.2.0") { + t.Fatalf("incompatible profiler not flagged: %v\n%s", err, out) + } + t.Setenv("FAKE_SCHEMAS", "") + if out, _, err = rc("doctor"); err != nil || !strings.Contains(out, "unchecked: core schemas not reported") || !strings.Contains(out, "args: --version") { + t.Fatalf("legacy profiler not described: %v\n%s", err, out) + } +} diff --git a/scripts/formula.sh b/scripts/formula.sh index de83271..14a5bb8 100755 --- a/scripts/formula.sh +++ b/scripts/formula.sh @@ -16,6 +16,8 @@ class Rc < Formula version "$version" license "MIT" + depends_on "rc-profiler-go" + on_macos do $(platform arm darwin_arm64) $(platform intel darwin_amd64) diff --git a/scripts/go-profiler-formula.sh b/scripts/go-profiler-formula.sh new file mode 100755 index 0000000..bbdb4b2 --- /dev/null +++ b/scripts/go-profiler-formula.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail +version=$1 +checksums=$2 +base="https://github.com/runtimeconditions/go-rc-profiler/releases/download/v$version" +platform() { + printf ' on_%s do\n' "$1" + printf ' url "%s/go-rc-profiler_v%s_%s.tar.gz"\n' "$base" "$version" "$2" + printf ' sha256 "%s"\n end\n' "$(awk -v f="go-rc-profiler_v${version}_$2.tar.gz" '$2 == f { print $1 }' "$checksums")" +} +cat < Date: Wed, 28 Oct 2026 20:58:20 +0530 Subject: [PATCH 2/2] ci: check out with LF endings and let every platform finish --- .gitattributes | 1 + .github/workflows/release.yml | 1 + 2 files changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58e960a..0946e06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,7 @@ permissions: jobs: verify: strategy: + fail-fast: false matrix: os: [ubuntu-24.04, macos-latest, windows-latest] runs-on: ${{ matrix.os }}