Skip to content
Draft
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
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: all build build-admin lint test test-e2e clean goreleaser-dev-build install-tools install-foundry run-op gendoc
.PHONY: all build build-admin lint test test-e2e test-quick clean goreleaser-dev-build install-tools install-foundry run-op gendoc

# Go parameters
COMMIT_SHA = $(shell git rev-parse HEAD)
Expand Down Expand Up @@ -29,6 +29,10 @@ test: lint
test-e2e:
$(GOTEST) -v -p 5 ./test/

# test-quick: run tests with 30s timeout, skipping slow/flaky e2e tests. Use -short so TestE2EInit_ConvertToCustomBuild_TS is skipped.
test-quick:
$(GOTEST) ./... -v -short -skip 'MultiCommandHappyPaths|TestPostToGateway|TestBlankWorkflowSimulation|TestWaitForBackendLinkProcessing|TestTryAutoLink|TestCheckLinkStatusViaGraphQL|Fails to run tests with invalid Go code' -timeout 30s

clean:
$(GOCLEAN)
rm -f $(BINARY_NAME)
Expand Down
153 changes: 153 additions & 0 deletions cmd/common/compile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package common

import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/smartcontractkit/cre-cli/internal/constants"
)

const (
makefileName = "Makefile"
defaultWasmOutput = "wasm/workflow.wasm" // hardcoded in Makefile by convert; must match workflow-path default
)

// getBuildCmd returns a single step that builds the workflow and returns the WASM bytes.
func getBuildCmd(workflowRootFolder, mainFile, language string) (func() ([]byte, error), error) {
tmpPath := filepath.Join(workflowRootFolder, ".cre_build_tmp.wasm")
switch language {
case constants.WorkflowLanguageTypeScript:
cmd := exec.Command("bun", "cre-compile", mainFile, tmpPath)
cmd.Dir = workflowRootFolder
return func() ([]byte, error) {
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("%w\nbuild output:\n%s", err, strings.TrimSpace(string(out)))
}
b, err := os.ReadFile(tmpPath)
_ = os.Remove(tmpPath)
return b, err
}, nil
case constants.WorkflowLanguageGolang:
// Build the package (.) so all .go files (main.go, workflow.go, etc.) are compiled together
cmd := exec.Command(
"go", "build",
"-o", tmpPath,
"-trimpath",
"-ldflags=-buildid= -w -s",
".",
)
cmd.Dir = workflowRootFolder
cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm", "CGO_ENABLED=0")
return func() ([]byte, error) {
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("%w\nbuild output:\n%s", err, strings.TrimSpace(string(out)))
}
b, err := os.ReadFile(tmpPath)
_ = os.Remove(tmpPath)
return b, err
}, nil
case constants.WorkflowLanguageWasm:
makeRoot, err := findMakefileRoot(workflowRootFolder)
if err != nil {
return nil, err
}
makeCmd := exec.Command("make", "build")
makeCmd.Dir = makeRoot
builtPath := filepath.Join(makeRoot, defaultWasmOutput)
return func() ([]byte, error) {
out, err := makeCmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("%w\nbuild output:\n%s", err, strings.TrimSpace(string(out)))
}
return os.ReadFile(builtPath)
}, nil
default:
// Build the package (.) so all .go files are compiled together
cmd := exec.Command(
"go", "build",
"-o", tmpPath,
"-trimpath",
"-ldflags=-buildid= -w -s",
".",
)
cmd.Dir = workflowRootFolder
cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm", "CGO_ENABLED=0")
return func() ([]byte, error) {
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("%w\nbuild output:\n%s", err, strings.TrimSpace(string(out)))
}
b, err := os.ReadFile(tmpPath)
_ = os.Remove(tmpPath)
return b, err
}, nil
}
}

// CompileWorkflowToWasm compiles the workflow at workflowPath and returns the WASM binary.
// It runs the sequence of commands from getBuildCmds (make build + copy for WASM, or single build for Go/TS), then reads the temp WASM file.
func CompileWorkflowToWasm(workflowPath string) ([]byte, error) {
workflowRootFolder, workflowMainFile, err := WorkflowPathRootAndMain(workflowPath)
if err != nil {
return nil, fmt.Errorf("workflow path: %w", err)
}
workflowAbsFile := filepath.Join(workflowRootFolder, workflowMainFile)
language := GetWorkflowLanguage(workflowMainFile)

if language != constants.WorkflowLanguageWasm {
if _, err := os.Stat(workflowAbsFile); os.IsNotExist(err) {
return nil, fmt.Errorf("workflow file not found: %s", workflowAbsFile)
}
}

switch language {
case constants.WorkflowLanguageTypeScript:
if err := EnsureTool("bun"); err != nil {
return nil, errors.New("bun is required for TypeScript workflows but was not found in PATH; install from https://bun.com/docs/installation")
}
case constants.WorkflowLanguageGolang:
if err := EnsureTool("go"); err != nil {
return nil, errors.New("go toolchain is required for Go workflows but was not found in PATH; install from https://go.dev/dl")
}
case constants.WorkflowLanguageWasm:
if err := EnsureTool("make"); err != nil {
return nil, errors.New("make is required for WASM workflows but was not found in PATH")
}
default:
return nil, fmt.Errorf("unsupported workflow language for file %s", workflowMainFile)
}

buildStep, err := getBuildCmd(workflowRootFolder, workflowMainFile, language)
if err != nil {
return nil, err
}
wasm, err := buildStep()
if err != nil {
return nil, fmt.Errorf("failed to compile workflow: %w", err)
}
return wasm, nil
}

// findMakefileRoot walks up from dir and returns the first directory that contains a Makefile.
func findMakefileRoot(dir string) (string, error) {
abs, err := filepath.Abs(dir)
if err != nil {
return "", fmt.Errorf("resolve path: %w", err)
}
for {
if _, err := os.Stat(filepath.Join(abs, makefileName)); err == nil {
return abs, nil
}
parent := filepath.Dir(abs)
if parent == abs {
return "", errors.New("no Makefile found in directory or any parent (required for WASM workflow build)")
}
abs = parent
}
}
128 changes: 128 additions & 0 deletions cmd/common/compile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package common

import (
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func deployTestdataPath(elem ...string) string {
_, filename, _, _ := runtime.Caller(0)
dir := filepath.Dir(filename)
return filepath.Join(append([]string{dir, "..", "workflow", "deploy", "testdata"}, elem...)...)
}

func TestFindMakefileRoot(t *testing.T) {
dir := t.TempDir()

_, err := findMakefileRoot(dir)
require.Error(t, err)
require.Contains(t, err.Error(), "no Makefile found")

require.NoError(t, os.WriteFile(filepath.Join(dir, makefileName), []byte("build:\n\techo ok\n"), 0600))
root, err := findMakefileRoot(dir)
require.NoError(t, err)
absDir, _ := filepath.Abs(dir)
require.Equal(t, absDir, root)

sub := filepath.Join(dir, "wasm")
require.NoError(t, os.MkdirAll(sub, 0755))
root, err = findMakefileRoot(sub)
require.NoError(t, err)
require.Equal(t, absDir, root)
}

func TestCompileWorkflowToWasm_Go_Success(t *testing.T) {
t.Run("basic_workflow", func(t *testing.T) {
path := deployTestdataPath("basic_workflow", "main.go")
wasm, err := CompileWorkflowToWasm(path)
require.NoError(t, err)
assert.NotEmpty(t, wasm)
})

t.Run("configless_workflow", func(t *testing.T) {
path := deployTestdataPath("configless_workflow", "main.go")
wasm, err := CompileWorkflowToWasm(path)
require.NoError(t, err)
assert.NotEmpty(t, wasm)
})

t.Run("missing_go_mod", func(t *testing.T) {
path := deployTestdataPath("missing_go_mod", "main.go")
wasm, err := CompileWorkflowToWasm(path)
require.NoError(t, err)
assert.NotEmpty(t, wasm)
})
}

func TestCompileWorkflowToWasm_Go_Malformed_Fails(t *testing.T) {
path := deployTestdataPath("malformed_workflow", "main.go")
_, err := CompileWorkflowToWasm(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to compile workflow")
assert.Contains(t, err.Error(), "undefined: sdk.RemovedFunctionThatFailsCompilation")
}

func TestCompileWorkflowToWasm_Wasm_Success(t *testing.T) {
wasmPath := deployTestdataPath("custom_wasm_workflow", "wasm", "workflow.wasm")
_ = os.Remove(wasmPath)
t.Cleanup(func() { _ = os.Remove(wasmPath) })

wasm, err := CompileWorkflowToWasm(wasmPath)
require.NoError(t, err)
assert.NotEmpty(t, wasm)

_, err = os.Stat(wasmPath)
require.NoError(t, err, "make build should produce wasm/workflow.wasm")
}

func TestCompileWorkflowToWasm_Wasm_Fails(t *testing.T) {
t.Run("no_makefile", func(t *testing.T) {
dir := t.TempDir()
wasmDir := filepath.Join(dir, "wasm")
require.NoError(t, os.MkdirAll(wasmDir, 0755))
wasmPath := filepath.Join(wasmDir, "workflow.wasm")
require.NoError(t, os.WriteFile(wasmPath, []byte("not really wasm"), 0600))

_, err := CompileWorkflowToWasm(wasmPath)
require.Error(t, err)
assert.Contains(t, err.Error(), "no Makefile found")
})

t.Run("make_build_fails", func(t *testing.T) {
path := deployTestdataPath("wasm_make_fails", "wasm", "workflow.wasm")
_, err := CompileWorkflowToWasm(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to compile workflow")
assert.Contains(t, err.Error(), "build output:")
})
}

func TestCompileWorkflowToWasm_TS_Success(t *testing.T) {
if err := EnsureTool("bun"); err != nil {
t.Skip("bun not in PATH, skipping TS compile test")
}
dir := t.TempDir()
mainPath := filepath.Join(dir, "main.ts")
require.NoError(t, os.WriteFile(mainPath, []byte(`export async function main() { return "ok"; }
`), 0600))
require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"test","dependencies":{"@chainlink/cre-sdk":"latest"}}
`), 0600))
install := exec.Command("bun", "install")
install.Dir = dir
install.Stdout = os.Stdout
install.Stderr = os.Stderr
if err := install.Run(); err != nil {
t.Skipf("bun install failed (network or cre-sdk): %v", err)
}
wasm, err := CompileWorkflowToWasm(mainPath)
if err != nil {
t.Skipf("TS compile failed (published cre-sdk may lack full layout): %v", err)
}
assert.NotEmpty(t, wasm)
}
76 changes: 39 additions & 37 deletions cmd/common/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,15 +166,52 @@ func ToStringSlice(args []any) []string {
}

// GetWorkflowLanguage determines the workflow language based on the file extension
// Note: inputFile can be a file path (e.g., "main.ts" or "main.go") or a directory (for Go workflows, e.g., ".")
// Returns constants.WorkflowLanguageTypeScript for .ts or .tsx files, constants.WorkflowLanguageGolang otherwise
// Note: inputFile can be a file path (e.g., "main.ts", "main.go", or "workflow.wasm") or a directory (for Go workflows, e.g., ".")
// Returns constants.WorkflowLanguageTypeScript for .ts or .tsx files, constants.WorkflowLanguageWasm for .wasm files, constants.WorkflowLanguageGolang otherwise
func GetWorkflowLanguage(inputFile string) string {
if strings.HasSuffix(inputFile, ".ts") || strings.HasSuffix(inputFile, ".tsx") {
return constants.WorkflowLanguageTypeScript
}
if strings.HasSuffix(inputFile, ".wasm") {
return constants.WorkflowLanguageWasm
}
return constants.WorkflowLanguageGolang
}

// ResolveWorkflowPath turns a workflow-path value from YAML (e.g. "." or "main.ts") into an
// absolute path to the main file. When pathFromYAML is "." or "", looks for main.go then main.ts
// under workflowDir. Callers can use GetWorkflowLanguage on the result to get the language.
func ResolveWorkflowPath(workflowDir, pathFromYAML string) (absPath string, err error) {
workflowDir, err = filepath.Abs(workflowDir)
if err != nil {
return "", fmt.Errorf("workflow directory: %w", err)
}
if pathFromYAML == "" || pathFromYAML == "." {
mainGo := filepath.Join(workflowDir, "main.go")
mainTS := filepath.Join(workflowDir, "main.ts")
if _, err := os.Stat(mainGo); err == nil {
return mainGo, nil
}
if _, err := os.Stat(mainTS); err == nil {
return mainTS, nil
}
return "", fmt.Errorf("no main.go or main.ts in %s", workflowDir)
}
joined := filepath.Join(workflowDir, pathFromYAML)
return filepath.Abs(joined)
}

// WorkflowPathRootAndMain returns the absolute root directory and main file name for a workflow
// path (e.g. "workflowName/main.go" -> rootDir, "main.go"). Use with GetWorkflowLanguage(mainFile)
// for consistent language detection.
func WorkflowPathRootAndMain(workflowPath string) (rootDir, mainFile string, err error) {
abs, err := filepath.Abs(workflowPath)
if err != nil {
return "", "", fmt.Errorf("workflow path: %w", err)
}
return filepath.Dir(abs), filepath.Base(abs), nil
}

// EnsureTool checks that the binary exists on PATH
func EnsureTool(bin string) error {
if _, err := exec.LookPath(bin); err != nil {
Expand All @@ -183,41 +220,6 @@ func EnsureTool(bin string) error {
return nil
}

// Gets a build command for either Golang or Typescript based on the filename
func GetBuildCmd(inputFile string, outputFile string, rootFolder string) *exec.Cmd {
isTypescriptWorkflow := strings.HasSuffix(inputFile, ".ts") || strings.HasSuffix(inputFile, ".tsx")

var buildCmd *exec.Cmd
if isTypescriptWorkflow {
buildCmd = exec.Command(
"bun",
"cre-compile",
inputFile,
outputFile,
)
} else {
// The build command for reproducible and trimmed binaries.
// -trimpath removes all file system paths from the compiled binary.
// -ldflags="-buildid= -w -s" further reduces the binary size:
// -buildid= removes the build ID, ensuring reproducibility.
// -w disables DWARF debugging information.
// -s removes the symbol table.
buildCmd = exec.Command(
"go",
"build",
"-o", outputFile,
"-trimpath",
"-ldflags=-buildid= -w -s",
inputFile,
)
buildCmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm", "CGO_ENABLED=0")
}

buildCmd.Dir = rootFolder

return buildCmd
}

func WriteChangesetFile(fileName string, changesetFile *inttypes.ChangesetFile, settings *settings.Settings) error {
fullFilePath := filepath.Join(
filepath.Clean(settings.CLDSettings.CLDPath),
Expand Down
Loading
Loading