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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
6 changes: 5 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ permissions:
jobs:
verify:
strategy:
fail-fast: false
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
Expand Down Expand Up @@ -69,6 +70,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 != ''
Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ The Runtime Conditions command line, built with [Cobra](https://github.com/spf13

```sh
rc profile <language> [args...]
rc bindings <command> [args...]
rc doctor
rc --version
```

Expand All @@ -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 <archive> --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
Expand All @@ -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.
22 changes: 22 additions & 0 deletions bindings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package rc

import "github.com/spf13/cobra"

func bindingsCommand() *cobra.Command {
return &cobra.Command{
Use: "bindings <command> [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)
},
}
}
76 changes: 76 additions & 0 deletions doctor.go
Original file line number Diff line number Diff line change
@@ -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"
}
12 changes: 12 additions & 0 deletions helpers_test.go
Original file line number Diff line number Diff line change
@@ -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
}
57 changes: 57 additions & 0 deletions plugin.go
Original file line number Diff line number Diff line change
@@ -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
}
}
83 changes: 83 additions & 0 deletions plugin_unix_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
25 changes: 25 additions & 0 deletions plugin_windows_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading