From ef8e108c1192a9a9ff45429627c4b52e0d33b645 Mon Sep 17 00:00:00 2001 From: James Walker Date: Tue, 30 Dec 2025 19:07:01 -0500 Subject: [PATCH 1/4] add chain selector workflow --- .github/workflows/add-chain-selector.yml | 92 +++++ cre/go/go.mod | 5 + cre/go/go.sum | 16 +- cre/go/tools/add-chain-selector/github_pr.go | 189 ++++++++++ cre/go/tools/add-chain-selector/main.go | 200 +++++++++++ cre/go/tools/add-chain-selector/main_test.go | 337 ++++++++++++++++++ .../tools/add-chain-selector/proto_updater.go | 142 ++++++++ .../testdata/test_client.proto | 32 ++ 8 files changed, 1010 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/add-chain-selector.yml create mode 100644 cre/go/tools/add-chain-selector/github_pr.go create mode 100644 cre/go/tools/add-chain-selector/main.go create mode 100644 cre/go/tools/add-chain-selector/main_test.go create mode 100644 cre/go/tools/add-chain-selector/proto_updater.go create mode 100644 cre/go/tools/add-chain-selector/testdata/test_client.proto diff --git a/.github/workflows/add-chain-selector.yml b/.github/workflows/add-chain-selector.yml new file mode 100644 index 00000000..16e3ccf4 --- /dev/null +++ b/.github/workflows/add-chain-selector.yml @@ -0,0 +1,92 @@ +name: Add Chain Selector + +on: + workflow_dispatch: + inputs: + chain-selector: + description: 'The chain selector value (uint64)' + required: true + type: string + dry-run: + description: 'Dry run mode - show what would be done without making changes' + required: false + default: false + type: boolean + workflow_call: + inputs: + chain-selector: + description: 'The chain selector value (uint64)' + required: true + type: string + dry-run: + description: 'Dry run mode - show what would be done without making changes' + required: false + default: false + type: boolean + +permissions: + id-token: write + contents: write + pull-requests: write + +jobs: + add-chain-selector: + runs-on: ubuntu-latest + steps: + - name: Assume AWS GATI role + uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + with: + role-to-assume: ${{ secrets.AWS_IAM_ROLE_ARN_GATI }} + role-duration-seconds: 900 + aws-region: ${{ secrets.AWS_REGION }} + mask-aws-account-id: true + + - name: Get GitHub token from GATI + id: get-gh-token + uses: smartcontractkit/chainlink-github-actions/github-app-token-issuer@main + with: + url: ${{ secrets.AWS_LAMBDA_URL_GATI }} + + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + token: ${{ steps.get-gh-token.outputs.access-token }} + fetch-depth: 0 + + - name: Set tool versions to env vars + id: tool-versions + uses: smartcontractkit/tool-versions-to-env-action@aabd5efbaf28005284e846c5cf3a02f2cba2f4c2 # v1.0.8 + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.0.0 + with: + go-version: ${{ steps.tool-versions.outputs.golang_version }} + + - name: Build add-chain-selector tool + run: | + cd cre/go + go build -o add-chain-selector ./tools/add-chain-selector + + - name: Run add-chain-selector (dry-run) + if: ${{ inputs.dry-run == true }} + run: | + cd cre/go + ./add-chain-selector \ + -chain-selector ${{ inputs.chain-selector }} \ + -proto-file ../capabilities/blockchain/evm/v1alpha/client.proto \ + -dry-run + + - name: Run add-chain-selector + if: ${{ inputs.dry-run != true }} + env: + GITHUB_TOKEN: ${{ steps.get-gh-token.outputs.access-token }} + run: | + cd cre/go + ./add-chain-selector \ + -chain-selector ${{ inputs.chain-selector }} \ + -proto-file ../capabilities/blockchain/evm/v1alpha/client.proto \ + -github-token "$GITHUB_TOKEN" \ + -repo-owner smartcontractkit \ + -repo-name chainlink-protos \ + -base-branch main + diff --git a/cre/go/go.mod b/cre/go/go.mod index c417f2f3..a2a37642 100644 --- a/cre/go/go.mod +++ b/cre/go/go.mod @@ -4,13 +4,18 @@ go 1.24.5 require ( github.com/go-viper/mapstructure/v2 v2.4.0 + github.com/google/go-github/v60 v60.0.0 github.com/shopspring/decimal v1.4.0 + github.com/smartcontractkit/chain-selectors v1.0.85 github.com/stretchr/testify v1.10.0 + golang.org/x/oauth2 v0.25.0 google.golang.org/protobuf v1.36.7 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/cre/go/go.sum b/cre/go/go.sum index e1ac1829..9cc10ae5 100644 --- a/cre/go/go.sum +++ b/cre/go/go.sum @@ -2,15 +2,25 @@ 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-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCyONLfn8= +github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 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/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/smartcontractkit/chain-selectors v1.0.85 h1:A7eyN7KIACxmngn1MJJ0giVtfELK9vh/eYyNU/Q/sFo= +github.com/smartcontractkit/chain-selectors v1.0.85/go.mod h1:xsKM0aN3YGcQKTPRPDDtPx2l4mlTN1Djmg0VVXV40b8= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= +golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= diff --git a/cre/go/tools/add-chain-selector/github_pr.go b/cre/go/tools/add-chain-selector/github_pr.go new file mode 100644 index 00000000..8fe47665 --- /dev/null +++ b/cre/go/tools/add-chain-selector/github_pr.go @@ -0,0 +1,189 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/google/go-github/v60/github" + "golang.org/x/oauth2" +) + +// PRConfig holds configuration for creating a GitHub PR. +type PRConfig struct { + Token string + Owner string + Repo string + BaseBranch string + ChainName string + Selector uint64 + ProtoFile string +} + +// createPR creates a GitHub PR with the proto file changes. +func createPR(ctx context.Context, config PRConfig) (string, error) { + // Generate branch name + timestamp := time.Now().Format("20060102-150405") + branchName := fmt.Sprintf("add-chain-selector/%s-%s", sanitizeBranchName(config.ChainName), timestamp) + + // Get the repository root directory + repoRoot, err := getRepoRoot(config.ProtoFile) + if err != nil { + return "", fmt.Errorf("failed to get repo root: %w", err) + } + + // Create and checkout new branch + if err := gitCreateBranch(repoRoot, branchName, config.BaseBranch); err != nil { + return "", fmt.Errorf("failed to create branch: %w", err) + } + + // Stage the proto file + if err := gitAdd(repoRoot, config.ProtoFile); err != nil { + return "", fmt.Errorf("failed to stage changes: %w", err) + } + + // Commit the changes + commitMsg := fmt.Sprintf("feat(cre): add chain selector for %s", config.ChainName) + if err := gitCommit(repoRoot, commitMsg); err != nil { + return "", fmt.Errorf("failed to commit changes: %w", err) + } + + // Push the branch + if err := gitPush(repoRoot, branchName); err != nil { + return "", fmt.Errorf("failed to push branch: %w", err) + } + + // Create PR using GitHub API + prURL, err := createGitHubPR(ctx, config, branchName) + if err != nil { + return "", fmt.Errorf("failed to create GitHub PR: %w", err) + } + + return prURL, nil +} + +// sanitizeBranchName sanitizes a chain name for use in a branch name. +func sanitizeBranchName(name string) string { + // Replace underscores and spaces with hyphens + name = strings.ReplaceAll(name, "_", "-") + name = strings.ReplaceAll(name, " ", "-") + // Remove any characters that aren't alphanumeric or hyphens + var result strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { + result.WriteRune(r) + } + } + return strings.ToLower(result.String()) +} + +// getRepoRoot finds the git repository root from a file path. +func getRepoRoot(filePath string) (string, error) { + dir := filepath.Dir(filePath) + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to find git root: %w", err) + } + return strings.TrimSpace(string(output)), nil +} + +// gitCreateBranch creates and checks out a new branch. +func gitCreateBranch(repoRoot, branchName, baseBranch string) error { + // Fetch the latest from remote + cmd := exec.Command("git", "fetch", "origin", baseBranch) + cmd.Dir = repoRoot + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to fetch: %w", err) + } + + // Create and checkout new branch from base + cmd = exec.Command("git", "checkout", "-b", branchName, fmt.Sprintf("origin/%s", baseBranch)) + cmd.Dir = repoRoot + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create branch: %w", err) + } + + return nil +} + +// gitAdd stages a file for commit. +func gitAdd(repoRoot, filePath string) error { + // Get relative path from repo root + relPath, err := filepath.Rel(repoRoot, filePath) + if err != nil { + relPath = filePath + } + + cmd := exec.Command("git", "add", relPath) + cmd.Dir = repoRoot + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// gitCommit commits staged changes. +func gitCommit(repoRoot, message string) error { + cmd := exec.Command("git", "commit", "-m", message) + cmd.Dir = repoRoot + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// gitPush pushes a branch to origin. +func gitPush(repoRoot, branchName string) error { + cmd := exec.Command("git", "push", "-u", "origin", branchName) + cmd.Dir = repoRoot + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// createGitHubPR creates a pull request using the GitHub API. +func createGitHubPR(ctx context.Context, config PRConfig, branchName string) (string, error) { + // Create GitHub client with OAuth token + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: config.Token}, + ) + tc := oauth2.NewClient(ctx, ts) + client := github.NewClient(tc) + + // Create the PR + title := fmt.Sprintf("feat(cre): add chain selector for %s", config.ChainName) + body := fmt.Sprintf("## Summary\n\n"+ + "This PR adds the chain selector for **%s** to the EVM client.proto file.\n\n"+ + "### Chain Details\n"+ + "- **Chain Name**: %s\n"+ + "- **Selector**: %d\n\n"+ + "### Changes\n"+ + "- Added entry to `cre/capabilities/blockchain/evm/v1alpha/client.proto`\n\n"+ + "---\n"+ + "*This PR was automatically generated by the add-chain-selector tool.*\n", + config.ChainName, config.ChainName, config.Selector) + + newPR := &github.NewPullRequest{ + Title: github.String(title), + Head: github.String(branchName), + Base: github.String(config.BaseBranch), + Body: github.String(body), + MaintainerCanModify: github.Bool(true), + } + + pr, _, err := client.PullRequests.Create(ctx, config.Owner, config.Repo, newPR) + if err != nil { + return "", fmt.Errorf("failed to create PR: %w", err) + } + + return pr.GetHTMLURL(), nil +} + diff --git a/cre/go/tools/add-chain-selector/main.go b/cre/go/tools/add-chain-selector/main.go new file mode 100644 index 00000000..af21f572 --- /dev/null +++ b/cre/go/tools/add-chain-selector/main.go @@ -0,0 +1,200 @@ +// Package main provides a tool for adding new chain selectors to the chainlink-protos EVM client.proto file. +package main + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + + chain_selectors "github.com/smartcontractkit/chain-selectors" +) + +// Config holds the tool configuration from command-line flags. +type Config struct { + ChainSelector uint64 + DryRun bool + GithubToken string + BaseBranch string + ProtoFile string + RepoOwner string + RepoName string +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + config, err := parseFlags() + if err != nil { + return err + } + + // Look up chain information from chain-selectors package + chainName, err := lookupChainSelector(config.ChainSelector) + if err != nil { + return fmt.Errorf("failed to look up chain selector: %w", err) + } + + fmt.Printf("Found chain: %s (selector: %d)\n", chainName, config.ChainSelector) + + // Check if chain already exists in proto file + exists, err := chainSelectorExists(config.ProtoFile, chainName) + if err != nil { + return fmt.Errorf("failed to check if chain exists: %w", err) + } + + if exists { + fmt.Printf("Chain %s already exists in proto file, nothing to do\n", chainName) + return nil + } + + if config.DryRun { + fmt.Println("\n=== DRY RUN MODE ===") + fmt.Printf("Would add chain entry:\n") + fmt.Printf(" Key: %s\n", chainName) + fmt.Printf(" Value: %d\n", config.ChainSelector) + fmt.Printf("To file: %s\n", config.ProtoFile) + return nil + } + + // Update proto file with new chain + if err := updateProtoFile(config.ProtoFile, chainName, config.ChainSelector); err != nil { + return fmt.Errorf("failed to update proto file: %w", err) + } + + fmt.Printf("Successfully added chain %s to proto file\n", chainName) + + // Create GitHub PR + if config.GithubToken == "" { + fmt.Println("No GitHub token provided, skipping PR creation") + fmt.Println("Please commit and push changes manually") + return nil + } + + ctx := context.Background() + prURL, err := createPR(ctx, PRConfig{ + Token: config.GithubToken, + Owner: config.RepoOwner, + Repo: config.RepoName, + BaseBranch: config.BaseBranch, + ChainName: chainName, + Selector: config.ChainSelector, + ProtoFile: config.ProtoFile, + }) + if err != nil { + return fmt.Errorf("failed to create PR: %w", err) + } + + fmt.Printf("Created PR: %s\n", prURL) + return nil +} + +func parseFlags() (*Config, error) { + var chainSelector uint64 + var dryRun bool + var githubToken string + var baseBranch string + var protoFile string + var repoOwner string + var repoName string + + flag.Uint64Var(&chainSelector, "chain-selector", 0, "The chain selector value (required)") + flag.BoolVar(&dryRun, "dry-run", false, "Show what would be done without making changes") + flag.StringVar(&githubToken, "github-token", "", "GitHub token for PR creation (required if not dry-run)") + flag.StringVar(&baseBranch, "base-branch", "main", "Base branch for PR") + flag.StringVar(&protoFile, "proto-file", "", "Path to client.proto (default: auto-detect)") + flag.StringVar(&repoOwner, "repo-owner", "smartcontractkit", "GitHub repository owner") + flag.StringVar(&repoName, "repo-name", "chainlink-protos", "GitHub repository name") + + flag.Parse() + + if chainSelector == 0 { + return nil, fmt.Errorf("chain-selector is required") + } + + // Auto-detect proto file path if not provided + if protoFile == "" { + protoFile = findProtoFile() + if protoFile == "" { + return nil, fmt.Errorf("could not auto-detect proto file path, please provide -proto-file flag") + } + } + + // Validate proto file exists + if _, err := os.Stat(protoFile); os.IsNotExist(err) { + return nil, fmt.Errorf("proto file not found: %s", protoFile) + } + + return &Config{ + ChainSelector: chainSelector, + DryRun: dryRun, + GithubToken: githubToken, + BaseBranch: baseBranch, + ProtoFile: protoFile, + RepoOwner: repoOwner, + RepoName: repoName, + }, nil +} + +// findProtoFile attempts to find the client.proto file relative to the tool location. +func findProtoFile() string { + // Try relative paths from likely locations + candidates := []string{ + "cre/capabilities/blockchain/evm/v1alpha/client.proto", + "../../../capabilities/blockchain/evm/v1alpha/client.proto", + "../../../../cre/capabilities/blockchain/evm/v1alpha/client.proto", + } + + // First try from current working directory + cwd, err := os.Getwd() + if err == nil { + for _, candidate := range candidates { + path := filepath.Join(cwd, candidate) + if _, err := os.Stat(path); err == nil { + return path + } + } + } + + // Try to find it relative to the executable + exe, err := os.Executable() + if err == nil { + exeDir := filepath.Dir(exe) + for _, candidate := range candidates { + path := filepath.Join(exeDir, candidate) + if _, err := os.Stat(path); err == nil { + return path + } + } + } + + return "" +} + +// lookupChainSelector looks up chain information from the chain-selectors package. +func lookupChainSelector(selector uint64) (string, error) { + // Verify it's an EVM chain + family, err := chain_selectors.GetSelectorFamily(selector) + if err != nil { + return "", fmt.Errorf("chain selector %d not found: %w", selector, err) + } + + if family != chain_selectors.FamilyEVM { + return "", fmt.Errorf("chain selector %d is not an EVM chain (family: %s), only EVM chains are supported", selector, family) + } + + // Get chain name + chainName, err := chain_selectors.GetChainNameFromSelector(selector) + if err != nil { + return "", fmt.Errorf("failed to get chain name for selector %d: %w", selector, err) + } + + return chainName, nil +} + diff --git a/cre/go/tools/add-chain-selector/main_test.go b/cre/go/tools/add-chain-selector/main_test.go new file mode 100644 index 00000000..ea6ee84c --- /dev/null +++ b/cre/go/tools/add-chain-selector/main_test.go @@ -0,0 +1,337 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLookupChainSelector(t *testing.T) { + tests := []struct { + name string + selector uint64 + wantName string + wantErr bool + errContains string + }{ + { + name: "valid ethereum mainnet selector", + selector: 5009297550715157269, + wantName: "ethereum-mainnet", + wantErr: false, + }, + { + name: "valid avalanche mainnet selector", + selector: 6433500567565415381, + wantName: "avalanche-mainnet", + wantErr: false, + }, + { + name: "valid polygon mainnet selector", + selector: 4051577828743386545, + wantName: "polygon-mainnet", + wantErr: false, + }, + { + name: "invalid selector", + selector: 999999999999, + wantErr: true, + errContains: "not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, err := lookupChainSelector(tt.selector) + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantName, name) + }) + } +} + +func TestParseProtoEntries(t *testing.T) { + tests := []struct { + name string + content string + wantEntries []ChainEntry + wantErr bool + errContains string + }{ + { + name: "valid proto content", + content: ` +defaults: [ + { + key: "ethereum-mainnet" + value: 5009297550715157269 + }, + { + key: "polygon-mainnet" + value: 4051577828743386545 + } + ]`, + wantEntries: []ChainEntry{ + {Key: "ethereum-mainnet", Value: 5009297550715157269}, + {Key: "polygon-mainnet", Value: 4051577828743386545}, + }, + wantErr: false, + }, + { + name: "missing defaults array", + content: `some other content`, + wantErr: true, + errContains: "could not find defaults array", + }, + { + name: "empty defaults array", + content: ` +defaults: [ + ]`, + wantErr: true, + errContains: "no entries found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + entries, err := parseProtoEntries(tt.content) + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantEntries, entries) + }) + } +} + +func TestBuildDefaultsArray(t *testing.T) { + entries := []ChainEntry{ + {Key: "avalanche-mainnet", Value: 6433500567565415381}, + {Key: "ethereum-mainnet", Value: 5009297550715157269}, + } + + result := buildDefaultsArray(entries) + + // Check that it contains the expected structure + assert.Contains(t, result, "defaults: [") + assert.Contains(t, result, `key: "avalanche-mainnet"`) + assert.Contains(t, result, `value: 6433500567565415381`) + assert.Contains(t, result, `key: "ethereum-mainnet"`) + assert.Contains(t, result, `value: 5009297550715157269`) + assert.True(t, strings.HasSuffix(result, "]")) +} + +func TestChainSelectorExists(t *testing.T) { + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.proto") + + content := ` +service Client { + option (tools.generator.v1alpha.capability) = { + labels: { + key: "ChainSelector" + value: { + uint64_label: { + defaults: [ + { + key: "ethereum-mainnet" + value: 5009297550715157269 + } + ] + } + } + } + }; +}` + err := os.WriteFile(testFile, []byte(content), 0644) + require.NoError(t, err) + + tests := []struct { + name string + chainName string + wantExists bool + }{ + { + name: "chain exists", + chainName: "ethereum-mainnet", + wantExists: true, + }, + { + name: "chain does not exist", + chainName: "polygon-mainnet", + wantExists: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exists, err := chainSelectorExists(testFile, tt.chainName) + require.NoError(t, err) + assert.Equal(t, tt.wantExists, exists) + }) + } +} + +func TestUpdateProtoFile(t *testing.T) { + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.proto") + + originalContent := `syntax = "proto3"; + +service Client { + option (tools.generator.v1alpha.capability) = { + labels: { + key: "ChainSelector" + value: { + uint64_label: { + defaults: [ + { + key: "ethereum-mainnet" + value: 5009297550715157269 + } + ] + } + } + } + }; +}` + + t.Run("add new chain", func(t *testing.T) { + err := os.WriteFile(testFile, []byte(originalContent), 0644) + require.NoError(t, err) + + err = updateProtoFile(testFile, "avalanche-mainnet", 6433500567565415381) + require.NoError(t, err) + + // Read the updated content + updatedContent, err := os.ReadFile(testFile) + require.NoError(t, err) + + // Verify the new chain was added + assert.Contains(t, string(updatedContent), `key: "avalanche-mainnet"`) + assert.Contains(t, string(updatedContent), `value: 6433500567565415381`) + + // Verify original chain still exists + assert.Contains(t, string(updatedContent), `key: "ethereum-mainnet"`) + + // Verify alphabetical ordering (avalanche comes before ethereum) + avaxIdx := strings.Index(string(updatedContent), "avalanche-mainnet") + ethIdx := strings.Index(string(updatedContent), "ethereum-mainnet") + assert.Less(t, avaxIdx, ethIdx, "avalanche should come before ethereum alphabetically") + }) + + t.Run("add chain alphabetically after existing", func(t *testing.T) { + err := os.WriteFile(testFile, []byte(originalContent), 0644) + require.NoError(t, err) + + err = updateProtoFile(testFile, "polygon-mainnet", 4051577828743386545) + require.NoError(t, err) + + // Read the updated content + updatedContent, err := os.ReadFile(testFile) + require.NoError(t, err) + + // Verify the new chain was added + assert.Contains(t, string(updatedContent), `key: "polygon-mainnet"`) + + // Verify ordering (ethereum comes before polygon) + ethIdx := strings.Index(string(updatedContent), "ethereum-mainnet") + polyIdx := strings.Index(string(updatedContent), "polygon-mainnet") + assert.Less(t, ethIdx, polyIdx, "ethereum should come before polygon alphabetically") + }) + + t.Run("reject duplicate chain", func(t *testing.T) { + err := os.WriteFile(testFile, []byte(originalContent), 0644) + require.NoError(t, err) + + err = updateProtoFile(testFile, "ethereum-mainnet", 5009297550715157269) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + }) +} + +func TestSanitizeBranchName(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "simple name", + input: "ethereum-mainnet", + want: "ethereum-mainnet", + }, + { + name: "name with underscores", + input: "binance_smart_chain-mainnet", + want: "binance-smart-chain-mainnet", + }, + { + name: "name with special chars", + input: "chain@1.0.0", + want: "chain100", + }, + { + name: "uppercase", + input: "ETHEREUM-MAINNET", + want: "ethereum-mainnet", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sanitizeBranchName(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIntegration_DryRun(t *testing.T) { + // This test verifies the dry-run flow works correctly + // by using a real proto file from testdata + + testdataDir := filepath.Join("testdata") + testFile := filepath.Join(testdataDir, "test_client.proto") + + // Skip if testdata file doesn't exist + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Skip("testdata/test_client.proto not found") + } + + // Read original content to verify it's not modified + originalContent, err := os.ReadFile(testFile) + require.NoError(t, err) + + // Verify a chain we know exists + exists, err := chainSelectorExists(testFile, "ethereum-mainnet") + require.NoError(t, err) + assert.True(t, exists, "ethereum-mainnet should exist in test file") + + // Verify a chain that doesn't exist + exists, err = chainSelectorExists(testFile, "some-new-chain") + require.NoError(t, err) + assert.False(t, exists, "some-new-chain should not exist in test file") + + // Verify file wasn't modified + afterContent, err := os.ReadFile(testFile) + require.NoError(t, err) + assert.Equal(t, string(originalContent), string(afterContent), "file should not be modified during read operations") +} + diff --git a/cre/go/tools/add-chain-selector/proto_updater.go b/cre/go/tools/add-chain-selector/proto_updater.go new file mode 100644 index 00000000..a30ba8d1 --- /dev/null +++ b/cre/go/tools/add-chain-selector/proto_updater.go @@ -0,0 +1,142 @@ +package main + +import ( + "fmt" + "os" + "regexp" + "sort" + "strconv" + "strings" +) + +// ChainEntry represents a key-value entry in the proto file. +type ChainEntry struct { + Key string + Value uint64 +} + +// chainSelectorExists checks if a chain selector already exists in the proto file. +func chainSelectorExists(protoFile, chainName string) (bool, error) { + data, err := os.ReadFile(protoFile) + if err != nil { + return false, fmt.Errorf("failed to read proto file: %w", err) + } + + content := string(data) + // Check if the chain name already exists in the defaults array + pattern := fmt.Sprintf(`key:\s*"%s"`, regexp.QuoteMeta(chainName)) + matched, err := regexp.MatchString(pattern, content) + if err != nil { + return false, fmt.Errorf("failed to match pattern: %w", err) + } + + return matched, nil +} + +// updateProtoFile updates the proto file with a new chain selector entry. +func updateProtoFile(protoFile, chainName string, selector uint64) error { + data, err := os.ReadFile(protoFile) + if err != nil { + return fmt.Errorf("failed to read proto file: %w", err) + } + + content := string(data) + + // Parse existing entries + entries, err := parseProtoEntries(content) + if err != nil { + return fmt.Errorf("failed to parse proto entries: %w", err) + } + + // Check if entry already exists + for _, entry := range entries { + if entry.Key == chainName { + return fmt.Errorf("chain %s already exists in proto file", chainName) + } + } + + // Add new entry + entries = append(entries, ChainEntry{ + Key: chainName, + Value: selector, + }) + + // Sort entries alphabetically by key + sort.Slice(entries, func(i, j int) bool { + return entries[i].Key < entries[j].Key + }) + + // Rebuild the defaults array + newDefaults := buildDefaultsArray(entries) + + // Replace the defaults array in the content + re := regexp.MustCompile(`defaults:\s*\[[\s\S]*?\n\s*\]`) + newContent := re.ReplaceAllString(content, newDefaults) + if newContent == content { + return fmt.Errorf("failed to replace defaults array - pattern may not match") + } + + // Write back to file + if err := os.WriteFile(protoFile, []byte(newContent), 0644); err != nil { + return fmt.Errorf("failed to write proto file: %w", err) + } + + return nil +} + +// parseProtoEntries parses the defaults array from the proto file. +func parseProtoEntries(content string) ([]ChainEntry, error) { + entries := []ChainEntry{} + + // Find the defaults array - match from "defaults: [" to the closing "]" + re := regexp.MustCompile(`defaults:\s*\[([\s\S]*?)\n\s*\]`) + matches := re.FindStringSubmatch(content) + if len(matches) < 2 { + return nil, fmt.Errorf("could not find defaults array in proto file") + } + + defaultsContent := matches[1] + + // Parse each entry - match the exact format with proper whitespace + // Pattern: { key: "..." value: ... } + entryRe := regexp.MustCompile(`\{\s*key:\s*"([^"]+)"\s*value:\s*(\d+)\s*\}`) + entryMatches := entryRe.FindAllStringSubmatch(defaultsContent, -1) + + if len(entryMatches) == 0 { + return nil, fmt.Errorf("no entries found in defaults array") + } + + for _, match := range entryMatches { + if len(match) < 3 { + continue + } + key := match[1] + value, err := strconv.ParseUint(match[2], 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse selector value %s for key %s: %w", match[2], key, err) + } + entries = append(entries, ChainEntry{ + Key: key, + Value: value, + }) + } + + return entries, nil +} + +// buildDefaultsArray builds the defaults array string from entries. +func buildDefaultsArray(entries []ChainEntry) string { + var builder strings.Builder + builder.WriteString("defaults: [\n") + for i, entry := range entries { + builder.WriteString(fmt.Sprintf(" {\n key: \"%s\"\n value: %d\n }", entry.Key, entry.Value)) + if i < len(entries)-1 { + builder.WriteString(",\n") + } else { + builder.WriteString("\n") + } + } + builder.WriteString(" ]") + return builder.String() +} + diff --git a/cre/go/tools/add-chain-selector/testdata/test_client.proto b/cre/go/tools/add-chain-selector/testdata/test_client.proto new file mode 100644 index 00000000..a22a7087 --- /dev/null +++ b/cre/go/tools/add-chain-selector/testdata/test_client.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +package capabilities.blockchain.evm.v1alpha; + +service Client { + option (tools.generator.v1alpha.capability) = { + mode: MODE_DON + capability_id: "evm@1.0.0" + labels: { + key: "ChainSelector" + value: { + uint64_label: { + defaults: [ + { + key: "avalanche-mainnet" + value: 6433500567565415381 + }, + { + key: "ethereum-mainnet" + value: 5009297550715157269 + }, + { + key: "polygon-mainnet" + value: 4051577828743386545 + } + ] + } + } + } + }; +} + From 4e8ef66a96e5b521fb4641133192ec6bf66c3ab5 Mon Sep 17 00:00:00 2001 From: James Walker Date: Wed, 31 Dec 2025 14:57:44 -0500 Subject: [PATCH 2/4] update tool to be more focused --- .github/workflows/add-chain-selector.yml | 92 --------- .github/workflows/add-new-evm-chain.yml | 191 ++++++++++++++++++ cre/go/go.mod | 3 - cre/go/go.sum | 12 +- cre/go/tools/add-chain-selector/github_pr.go | 189 ----------------- cre/go/tools/add-new-evm-chain/README.md | 181 +++++++++++++++++ .../main.go | 98 ++++----- .../main_test.go | 95 ++++----- .../proto_updater.go | 75 ++++--- .../testdata/test_client.proto | 0 10 files changed, 510 insertions(+), 426 deletions(-) delete mode 100644 .github/workflows/add-chain-selector.yml create mode 100644 .github/workflows/add-new-evm-chain.yml delete mode 100644 cre/go/tools/add-chain-selector/github_pr.go create mode 100644 cre/go/tools/add-new-evm-chain/README.md rename cre/go/tools/{add-chain-selector => add-new-evm-chain}/main.go (72%) rename cre/go/tools/{add-chain-selector => add-new-evm-chain}/main_test.go (86%) rename cre/go/tools/{add-chain-selector => add-new-evm-chain}/proto_updater.go (58%) rename cre/go/tools/{add-chain-selector => add-new-evm-chain}/testdata/test_client.proto (100%) diff --git a/.github/workflows/add-chain-selector.yml b/.github/workflows/add-chain-selector.yml deleted file mode 100644 index 16e3ccf4..00000000 --- a/.github/workflows/add-chain-selector.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Add Chain Selector - -on: - workflow_dispatch: - inputs: - chain-selector: - description: 'The chain selector value (uint64)' - required: true - type: string - dry-run: - description: 'Dry run mode - show what would be done without making changes' - required: false - default: false - type: boolean - workflow_call: - inputs: - chain-selector: - description: 'The chain selector value (uint64)' - required: true - type: string - dry-run: - description: 'Dry run mode - show what would be done without making changes' - required: false - default: false - type: boolean - -permissions: - id-token: write - contents: write - pull-requests: write - -jobs: - add-chain-selector: - runs-on: ubuntu-latest - steps: - - name: Assume AWS GATI role - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 - with: - role-to-assume: ${{ secrets.AWS_IAM_ROLE_ARN_GATI }} - role-duration-seconds: 900 - aws-region: ${{ secrets.AWS_REGION }} - mask-aws-account-id: true - - - name: Get GitHub token from GATI - id: get-gh-token - uses: smartcontractkit/chainlink-github-actions/github-app-token-issuer@main - with: - url: ${{ secrets.AWS_LAMBDA_URL_GATI }} - - - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - with: - token: ${{ steps.get-gh-token.outputs.access-token }} - fetch-depth: 0 - - - name: Set tool versions to env vars - id: tool-versions - uses: smartcontractkit/tool-versions-to-env-action@aabd5efbaf28005284e846c5cf3a02f2cba2f4c2 # v1.0.8 - - - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.0.0 - with: - go-version: ${{ steps.tool-versions.outputs.golang_version }} - - - name: Build add-chain-selector tool - run: | - cd cre/go - go build -o add-chain-selector ./tools/add-chain-selector - - - name: Run add-chain-selector (dry-run) - if: ${{ inputs.dry-run == true }} - run: | - cd cre/go - ./add-chain-selector \ - -chain-selector ${{ inputs.chain-selector }} \ - -proto-file ../capabilities/blockchain/evm/v1alpha/client.proto \ - -dry-run - - - name: Run add-chain-selector - if: ${{ inputs.dry-run != true }} - env: - GITHUB_TOKEN: ${{ steps.get-gh-token.outputs.access-token }} - run: | - cd cre/go - ./add-chain-selector \ - -chain-selector ${{ inputs.chain-selector }} \ - -proto-file ../capabilities/blockchain/evm/v1alpha/client.proto \ - -github-token "$GITHUB_TOKEN" \ - -repo-owner smartcontractkit \ - -repo-name chainlink-protos \ - -base-branch main - diff --git a/.github/workflows/add-new-evm-chain.yml b/.github/workflows/add-new-evm-chain.yml new file mode 100644 index 00000000..843deb47 --- /dev/null +++ b/.github/workflows/add-new-evm-chain.yml @@ -0,0 +1,191 @@ +name: Add New EVM Chain + +on: + workflow_dispatch: + inputs: + chain-selector: + description: 'The chain selector value (uint64)' + required: true + type: string + dry-run: + description: 'Dry run mode - show what would be done without making changes' + required: false + default: false + type: boolean + workflow_call: + inputs: + chain-selector: + description: 'The chain selector value (uint64)' + required: true + type: string + dry-run: + description: 'Dry run mode - show what would be done without making changes' + required: false + default: false + type: boolean + +permissions: + id-token: write + contents: write + pull-requests: write + +jobs: + add-new-evm-chain: + runs-on: ubuntu-latest + outputs: + chain-name: ${{ steps.run-tool.outputs.chain_name }} + action: ${{ steps.run-tool.outputs.action }} + pr-url: ${{ steps.create-pr.outputs.pr_url }} + steps: + - name: Assume AWS GATI role + uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + with: + role-to-assume: ${{ secrets.AWS_IAM_ROLE_ARN_GATI }} + role-duration-seconds: 900 + aws-region: ${{ secrets.AWS_REGION }} + mask-aws-account-id: true + + - name: Get GitHub token from GATI + id: get-gh-token + uses: smartcontractkit/chainlink-github-actions/github-app-token-issuer@main + with: + url: ${{ secrets.AWS_LAMBDA_URL_GATI }} + + - name: Checkout repository + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + token: ${{ steps.get-gh-token.outputs.access-token }} + fetch-depth: 0 + + - name: Set tool versions to env vars + id: tool-versions + uses: smartcontractkit/tool-versions-to-env-action@aabd5efbaf28005284e846c5cf3a02f2cba2f4c2 # v1.0.8 + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.0.0 + with: + go-version: ${{ steps.tool-versions.outputs.golang_version }} + + - name: Build add-new-evm-chain tool + run: | + cd cre/go + go build -o add-new-evm-chain ./tools/add-new-evm-chain + + - name: Run add-new-evm-chain tool + id: run-tool + run: | + cd cre/go + + # Capture tool output + if [ "${{ inputs.dry-run }}" == "true" ]; then + OUTPUT=$(./add-new-evm-chain \ + -chain-selector ${{ inputs.chain-selector }} \ + -proto-file ../capabilities/blockchain/evm/v1alpha/client.proto \ + -dry-run 2>&1) || true + else + OUTPUT=$(./add-new-evm-chain \ + -chain-selector ${{ inputs.chain-selector }} \ + -proto-file ../capabilities/blockchain/evm/v1alpha/client.proto 2>&1) || true + fi + + echo "$OUTPUT" + + # Parse structured output + CHAIN_NAME=$(echo "$OUTPUT" | grep "^CHAIN_NAME=" | cut -d'=' -f2) + CHAIN_SELECTOR=$(echo "$OUTPUT" | grep "^CHAIN_SELECTOR=" | cut -d'=' -f2) + ACTION=$(echo "$OUTPUT" | grep "^ACTION=" | cut -d'=' -f2) + + echo "chain_name=$CHAIN_NAME" >> $GITHUB_OUTPUT + echo "chain_selector=$CHAIN_SELECTOR" >> $GITHUB_OUTPUT + echo "action=$ACTION" >> $GITHUB_OUTPUT + + echo "::notice::Chain: $CHAIN_NAME, Selector: $CHAIN_SELECTOR, Action: $ACTION" + + # Fail if action is error + if [ "$ACTION" == "error" ]; then + echo "::error::Tool failed to process chain selector" + exit 1 + fi + + - name: Check if changes needed + id: check-changes + if: ${{ inputs.dry-run != true }} + run: | + if [ "${{ steps.run-tool.outputs.action }}" == "added" ]; then + echo "changes_needed=true" >> $GITHUB_OUTPUT + else + echo "changes_needed=false" >> $GITHUB_OUTPUT + echo "::notice::No changes needed - chain already exists or dry-run mode" + fi + + - name: Create branch for PR + if: ${{ inputs.dry-run != true && steps.check-changes.outputs.changes_needed == 'true' }} + id: create-branch + run: | + BRANCH_NAME="add-evm-chain/${{ steps.run-tool.outputs.chain_name }}-$(date +%Y%m%d-%H%M%S)" + echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT + + git checkout -b "$BRANCH_NAME" + echo "::notice::Created branch: $BRANCH_NAME" + + - name: Commit changes using ghcommit + if: ${{ inputs.dry-run != true && steps.check-changes.outputs.changes_needed == 'true' }} + uses: planetscale/ghcommit-action@v0.1.45 + with: + commit_message: "feat(cre): add EVM chain ${{ steps.run-tool.outputs.chain_name }}" + repo: ${{ github.repository }} + branch: ${{ steps.create-branch.outputs.branch_name }} + file_pattern: "cre/capabilities/blockchain/evm/v1alpha/client.proto" + env: + GITHUB_TOKEN: ${{ steps.get-gh-token.outputs.access-token }} + + - name: Create Pull Request + if: ${{ inputs.dry-run != true && steps.check-changes.outputs.changes_needed == 'true' }} + id: create-pr + uses: actions/github-script@v7 + with: + github-token: ${{ steps.get-gh-token.outputs.access-token }} + script: | + const chainName = '${{ steps.run-tool.outputs.chain_name }}'; + const chainSelector = '${{ steps.run-tool.outputs.chain_selector }}'; + const branchName = '${{ steps.create-branch.outputs.branch_name }}'; + + const { data: pr } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `feat(cre): add EVM chain ${chainName}`, + head: branchName, + base: 'main', + body: `## Summary + + This PR adds the EVM chain **${chainName}** to the CRE client.proto file. + + ### Chain Details + - **Chain Name**: ${chainName} + - **Selector**: ${chainSelector} + + ### Changes + - Added entry to \`cre/capabilities/blockchain/evm/v1alpha/client.proto\` + + --- + *This PR was automatically generated by the add-new-evm-chain workflow.* + `, + maintainer_can_modify: true + }); + + core.setOutput('pr_url', pr.html_url); + core.notice(`Created PR: ${pr.html_url}`); + + - name: Summary + run: | + echo "## Add New EVM Chain Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Chain Name | ${{ steps.run-tool.outputs.chain_name }} |" >> $GITHUB_STEP_SUMMARY + echo "| Chain Selector | ${{ steps.run-tool.outputs.chain_selector }} |" >> $GITHUB_STEP_SUMMARY + echo "| Action | ${{ steps.run-tool.outputs.action }} |" >> $GITHUB_STEP_SUMMARY + echo "| Dry Run | ${{ inputs.dry-run }} |" >> $GITHUB_STEP_SUMMARY + if [ -n "${{ steps.create-pr.outputs.pr_url }}" ]; then + echo "| PR URL | ${{ steps.create-pr.outputs.pr_url }} |" >> $GITHUB_STEP_SUMMARY + fi diff --git a/cre/go/go.mod b/cre/go/go.mod index a2a37642..1a1645cd 100644 --- a/cre/go/go.mod +++ b/cre/go/go.mod @@ -4,17 +4,14 @@ go 1.24.5 require ( github.com/go-viper/mapstructure/v2 v2.4.0 - github.com/google/go-github/v60 v60.0.0 github.com/shopspring/decimal v1.4.0 github.com/smartcontractkit/chain-selectors v1.0.85 github.com/stretchr/testify v1.10.0 - golang.org/x/oauth2 v0.25.0 google.golang.org/protobuf v1.36.7 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/google/go-querystring v1.1.0 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/cre/go/go.sum b/cre/go/go.sum index 9cc10ae5..81f860a3 100644 --- a/cre/go/go.sum +++ b/cre/go/go.sum @@ -2,13 +2,8 @@ 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-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-github/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCyONLfn8= -github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -19,8 +14,7 @@ github.com/smartcontractkit/chain-selectors v1.0.85 h1:A7eyN7KIACxmngn1MJJ0giVtf github.com/smartcontractkit/chain-selectors v1.0.85/go.mod h1:xsKM0aN3YGcQKTPRPDDtPx2l4mlTN1Djmg0VVXV40b8= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= -golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= diff --git a/cre/go/tools/add-chain-selector/github_pr.go b/cre/go/tools/add-chain-selector/github_pr.go deleted file mode 100644 index 8fe47665..00000000 --- a/cre/go/tools/add-chain-selector/github_pr.go +++ /dev/null @@ -1,189 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "github.com/google/go-github/v60/github" - "golang.org/x/oauth2" -) - -// PRConfig holds configuration for creating a GitHub PR. -type PRConfig struct { - Token string - Owner string - Repo string - BaseBranch string - ChainName string - Selector uint64 - ProtoFile string -} - -// createPR creates a GitHub PR with the proto file changes. -func createPR(ctx context.Context, config PRConfig) (string, error) { - // Generate branch name - timestamp := time.Now().Format("20060102-150405") - branchName := fmt.Sprintf("add-chain-selector/%s-%s", sanitizeBranchName(config.ChainName), timestamp) - - // Get the repository root directory - repoRoot, err := getRepoRoot(config.ProtoFile) - if err != nil { - return "", fmt.Errorf("failed to get repo root: %w", err) - } - - // Create and checkout new branch - if err := gitCreateBranch(repoRoot, branchName, config.BaseBranch); err != nil { - return "", fmt.Errorf("failed to create branch: %w", err) - } - - // Stage the proto file - if err := gitAdd(repoRoot, config.ProtoFile); err != nil { - return "", fmt.Errorf("failed to stage changes: %w", err) - } - - // Commit the changes - commitMsg := fmt.Sprintf("feat(cre): add chain selector for %s", config.ChainName) - if err := gitCommit(repoRoot, commitMsg); err != nil { - return "", fmt.Errorf("failed to commit changes: %w", err) - } - - // Push the branch - if err := gitPush(repoRoot, branchName); err != nil { - return "", fmt.Errorf("failed to push branch: %w", err) - } - - // Create PR using GitHub API - prURL, err := createGitHubPR(ctx, config, branchName) - if err != nil { - return "", fmt.Errorf("failed to create GitHub PR: %w", err) - } - - return prURL, nil -} - -// sanitizeBranchName sanitizes a chain name for use in a branch name. -func sanitizeBranchName(name string) string { - // Replace underscores and spaces with hyphens - name = strings.ReplaceAll(name, "_", "-") - name = strings.ReplaceAll(name, " ", "-") - // Remove any characters that aren't alphanumeric or hyphens - var result strings.Builder - for _, r := range name { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' { - result.WriteRune(r) - } - } - return strings.ToLower(result.String()) -} - -// getRepoRoot finds the git repository root from a file path. -func getRepoRoot(filePath string) (string, error) { - dir := filepath.Dir(filePath) - cmd := exec.Command("git", "rev-parse", "--show-toplevel") - cmd.Dir = dir - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("failed to find git root: %w", err) - } - return strings.TrimSpace(string(output)), nil -} - -// gitCreateBranch creates and checks out a new branch. -func gitCreateBranch(repoRoot, branchName, baseBranch string) error { - // Fetch the latest from remote - cmd := exec.Command("git", "fetch", "origin", baseBranch) - cmd.Dir = repoRoot - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to fetch: %w", err) - } - - // Create and checkout new branch from base - cmd = exec.Command("git", "checkout", "-b", branchName, fmt.Sprintf("origin/%s", baseBranch)) - cmd.Dir = repoRoot - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to create branch: %w", err) - } - - return nil -} - -// gitAdd stages a file for commit. -func gitAdd(repoRoot, filePath string) error { - // Get relative path from repo root - relPath, err := filepath.Rel(repoRoot, filePath) - if err != nil { - relPath = filePath - } - - cmd := exec.Command("git", "add", relPath) - cmd.Dir = repoRoot - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// gitCommit commits staged changes. -func gitCommit(repoRoot, message string) error { - cmd := exec.Command("git", "commit", "-m", message) - cmd.Dir = repoRoot - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// gitPush pushes a branch to origin. -func gitPush(repoRoot, branchName string) error { - cmd := exec.Command("git", "push", "-u", "origin", branchName) - cmd.Dir = repoRoot - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// createGitHubPR creates a pull request using the GitHub API. -func createGitHubPR(ctx context.Context, config PRConfig, branchName string) (string, error) { - // Create GitHub client with OAuth token - ts := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: config.Token}, - ) - tc := oauth2.NewClient(ctx, ts) - client := github.NewClient(tc) - - // Create the PR - title := fmt.Sprintf("feat(cre): add chain selector for %s", config.ChainName) - body := fmt.Sprintf("## Summary\n\n"+ - "This PR adds the chain selector for **%s** to the EVM client.proto file.\n\n"+ - "### Chain Details\n"+ - "- **Chain Name**: %s\n"+ - "- **Selector**: %d\n\n"+ - "### Changes\n"+ - "- Added entry to `cre/capabilities/blockchain/evm/v1alpha/client.proto`\n\n"+ - "---\n"+ - "*This PR was automatically generated by the add-chain-selector tool.*\n", - config.ChainName, config.ChainName, config.Selector) - - newPR := &github.NewPullRequest{ - Title: github.String(title), - Head: github.String(branchName), - Base: github.String(config.BaseBranch), - Body: github.String(body), - MaintainerCanModify: github.Bool(true), - } - - pr, _, err := client.PullRequests.Create(ctx, config.Owner, config.Repo, newPR) - if err != nil { - return "", fmt.Errorf("failed to create PR: %w", err) - } - - return pr.GetHTMLURL(), nil -} - diff --git a/cre/go/tools/add-new-evm-chain/README.md b/cre/go/tools/add-new-evm-chain/README.md new file mode 100644 index 00000000..1095fdae --- /dev/null +++ b/cre/go/tools/add-new-evm-chain/README.md @@ -0,0 +1,181 @@ +# Add New EVM Chain Tool + +A CLI tool for adding new EVM chain selectors to the chainlink-protos EVM client.proto file. + +## Overview + +This tool automates the process of adding a new EVM chain to the CRE (Chainlink Runtime Environment) by: + +1. Looking up chain information from the [chain-selectors](https://github.com/smartcontractkit/chain-selectors) package +2. Validating that the chain is an EVM chain +3. Checking if the chain already exists in the proto file +4. Adding the chain entry to `cre/capabilities/blockchain/evm/v1alpha/client.proto` (sorted alphabetically) + +## Installation + +Build the tool from the `cre/go` directory: + +```bash +cd cre/go +go build -o add-new-evm-chain ./tools/add-new-evm-chain +``` + +## Usage + +### Basic Usage + +```bash +# From the cre/go directory +./add-new-evm-chain -chain-selector +``` + +### Flags + +| Flag | Required | Default | Description | +|------|----------|---------|-------------| +| `-chain-selector` | Yes | - | The chain selector value (uint64) | +| `-proto-file` | No | Auto-detect | Path to client.proto file | +| `-dry-run` | No | `false` | Preview changes without modifying files | + +### Examples + +#### Dry Run (Preview Changes) + +```bash +./add-new-evm-chain -chain-selector 5009297550715157269 -dry-run +``` + +Output: +``` +Found chain: ethereum-mainnet (selector: 5009297550715157269) + +=== DRY RUN MODE === +Would add chain entry: + Key: ethereum-mainnet + Value: 5009297550715157269 +To file: ../capabilities/blockchain/evm/v1alpha/client.proto + +--- OUTPUT --- +CHAIN_NAME=ethereum-mainnet +CHAIN_SELECTOR=5009297550715157269 +ACTION=dry-run +``` + +#### Add a New Chain + +```bash +./add-new-evm-chain -chain-selector 5009297550715157269 +``` + +Output: +``` +Found chain: ethereum-mainnet (selector: 5009297550715157269) +Successfully added chain ethereum-mainnet to proto file + +--- OUTPUT --- +CHAIN_NAME=ethereum-mainnet +CHAIN_SELECTOR=5009297550715157269 +ACTION=added +``` + +#### Existing Chain + +If the chain already exists: +``` +Found chain: ethereum-mainnet (selector: 5009297550715157269) +Chain ethereum-mainnet already exists in proto file, nothing to do + +--- OUTPUT --- +CHAIN_NAME=ethereum-mainnet +CHAIN_SELECTOR=5009297550715157269 +ACTION=exists +``` + +## Output Format + +The tool outputs structured data for workflow consumption: + +``` +--- OUTPUT --- +CHAIN_NAME= +CHAIN_SELECTOR= +ACTION= +``` + +| Action | Description | +|--------|-------------| +| `added` | Chain was successfully added to the proto file | +| `exists` | Chain already exists in the proto file | +| `dry-run` | Dry run mode - no changes made | +| `error` | An error occurred | + +## Finding Chain Selectors + +Chain selectors can be found in the [chain-selectors repository](https://github.com/smartcontractkit/chain-selectors): + +- EVM chains: `selectors.yml` +- Look for the `selector` field for each chain + +Example from `selectors.yml`: +```yaml +selectors: + 1: # Ethereum Mainnet chain ID + selector: 5009297550715157269 + name: "ethereum-mainnet" +``` + +## Running Tests + +```bash +cd cre/go +go test ./tools/add-new-evm-chain/... -v +``` + +## GitHub Workflow + +This tool is also available as a GitHub Actions workflow that can be triggered manually: + +1. Go to **Actions** → **Add Chain Selector** +2. Click **Run workflow** +3. Enter the chain selector value +4. Optionally enable dry-run mode +5. Click **Run workflow** + +The workflow will: +- Run this tool to update the proto file +- Create a branch with the changes +- Open a Pull Request for review + +## Proto File Format + +The tool adds entries to the `defaults` array in `client.proto`: + +```protobuf +defaults: [ + { + key: "avalanche-mainnet" + value: 6433500567565415381 + }, + { + key: "ethereum-mainnet" + value: 5009297550715157269 + } +] +``` + +Entries are automatically sorted alphabetically by chain name. + +## Code Practices & References + +This tool follows Go best practices and idioms from the following resources: + +- [Ardan Labs / Bill Kennedy's Ultimate Go Training](https://www.ardanlabs.com/training/ultimate-go/) +- [Go in Action](https://www.manning.com/books/go-in-action) (Manning Publications) +- [The Go Blog](https://go.dev/blog/) +- [Effective Go](https://go.dev/doc/effective_go) (Official) +- [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments) (Official Wiki) +- [The Go Memory Model](https://go.dev/ref/mem) (Official) +- [Go Proverbs](https://go-proverbs.github.io/) (Rob Pike) +- [Google Go Style Guide](https://google.github.io/styleguide/go/) (Style Guide + Best Practices) +- [100 Go Mistakes and How to Avoid Them](https://www.manning.com/books/100-go-mistakes-and-how-to-avoid-them) (Teiva Harsanyi) +- [Concurrency in Go](https://www.oreilly.com/library/view/concurrency-in-go/9781491941294/) (Katherine Cox-Buday) diff --git a/cre/go/tools/add-chain-selector/main.go b/cre/go/tools/add-new-evm-chain/main.go similarity index 72% rename from cre/go/tools/add-chain-selector/main.go rename to cre/go/tools/add-new-evm-chain/main.go index af21f572..4407252f 100644 --- a/cre/go/tools/add-chain-selector/main.go +++ b/cre/go/tools/add-new-evm-chain/main.go @@ -2,7 +2,6 @@ package main import ( - "context" "flag" "fmt" "os" @@ -11,34 +10,58 @@ import ( chain_selectors "github.com/smartcontractkit/chain-selectors" ) +// Action represents the outcome action of the tool execution. +type Action string + +// Action constants define the possible outcomes of the tool execution. +const ( + ActionAdded Action = "added" + ActionExists Action = "exists" + ActionDryRun Action = "dry-run" + ActionError Action = "error" +) + // Config holds the tool configuration from command-line flags. type Config struct { ChainSelector uint64 DryRun bool - GithubToken string - BaseBranch string ProtoFile string - RepoOwner string - RepoName string +} + +// Result represents the outcome of the tool execution. +type Result struct { + ChainName string + ChainSelector uint64 + Action Action } func main() { - if err := run(); err != nil { + result, err := run() + if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) + // Output structured result for workflow + fmt.Println("\n--- OUTPUT ---") + fmt.Printf("ACTION=%s\n", ActionError) os.Exit(1) } + + // Output structured result for workflow consumption + fmt.Println("\n--- OUTPUT ---") + fmt.Printf("CHAIN_NAME=%s\n", result.ChainName) + fmt.Printf("CHAIN_SELECTOR=%d\n", result.ChainSelector) + fmt.Printf("ACTION=%s\n", result.Action) } -func run() error { +func run() (*Result, error) { config, err := parseFlags() if err != nil { - return err + return nil, err } // Look up chain information from chain-selectors package chainName, err := lookupChainSelector(config.ChainSelector) if err != nil { - return fmt.Errorf("failed to look up chain selector: %w", err) + return nil, fmt.Errorf("failed to look up chain selector: %w", err) } fmt.Printf("Found chain: %s (selector: %d)\n", chainName, config.ChainSelector) @@ -46,12 +69,16 @@ func run() error { // Check if chain already exists in proto file exists, err := chainSelectorExists(config.ProtoFile, chainName) if err != nil { - return fmt.Errorf("failed to check if chain exists: %w", err) + return nil, fmt.Errorf("failed to check if chain exists: %w", err) } if exists { fmt.Printf("Chain %s already exists in proto file, nothing to do\n", chainName) - return nil + return &Result{ + ChainName: chainName, + ChainSelector: config.ChainSelector, + Action: ActionExists, + }, nil } if config.DryRun { @@ -60,57 +87,35 @@ func run() error { fmt.Printf(" Key: %s\n", chainName) fmt.Printf(" Value: %d\n", config.ChainSelector) fmt.Printf("To file: %s\n", config.ProtoFile) - return nil + return &Result{ + ChainName: chainName, + ChainSelector: config.ChainSelector, + Action: ActionDryRun, + }, nil } // Update proto file with new chain if err := updateProtoFile(config.ProtoFile, chainName, config.ChainSelector); err != nil { - return fmt.Errorf("failed to update proto file: %w", err) + return nil, fmt.Errorf("failed to update proto file: %w", err) } fmt.Printf("Successfully added chain %s to proto file\n", chainName) - // Create GitHub PR - if config.GithubToken == "" { - fmt.Println("No GitHub token provided, skipping PR creation") - fmt.Println("Please commit and push changes manually") - return nil - } - - ctx := context.Background() - prURL, err := createPR(ctx, PRConfig{ - Token: config.GithubToken, - Owner: config.RepoOwner, - Repo: config.RepoName, - BaseBranch: config.BaseBranch, - ChainName: chainName, - Selector: config.ChainSelector, - ProtoFile: config.ProtoFile, - }) - if err != nil { - return fmt.Errorf("failed to create PR: %w", err) - } - - fmt.Printf("Created PR: %s\n", prURL) - return nil + return &Result{ + ChainName: chainName, + ChainSelector: config.ChainSelector, + Action: ActionAdded, + }, nil } func parseFlags() (*Config, error) { var chainSelector uint64 var dryRun bool - var githubToken string - var baseBranch string var protoFile string - var repoOwner string - var repoName string flag.Uint64Var(&chainSelector, "chain-selector", 0, "The chain selector value (required)") flag.BoolVar(&dryRun, "dry-run", false, "Show what would be done without making changes") - flag.StringVar(&githubToken, "github-token", "", "GitHub token for PR creation (required if not dry-run)") - flag.StringVar(&baseBranch, "base-branch", "main", "Base branch for PR") flag.StringVar(&protoFile, "proto-file", "", "Path to client.proto (default: auto-detect)") - flag.StringVar(&repoOwner, "repo-owner", "smartcontractkit", "GitHub repository owner") - flag.StringVar(&repoName, "repo-name", "chainlink-protos", "GitHub repository name") flag.Parse() @@ -134,11 +139,7 @@ func parseFlags() (*Config, error) { return &Config{ ChainSelector: chainSelector, DryRun: dryRun, - GithubToken: githubToken, - BaseBranch: baseBranch, ProtoFile: protoFile, - RepoOwner: repoOwner, - RepoName: repoName, }, nil } @@ -197,4 +198,3 @@ func lookupChainSelector(selector uint64) (string, error) { return chainName, nil } - diff --git a/cre/go/tools/add-chain-selector/main_test.go b/cre/go/tools/add-new-evm-chain/main_test.go similarity index 86% rename from cre/go/tools/add-chain-selector/main_test.go rename to cre/go/tools/add-new-evm-chain/main_test.go index ea6ee84c..0061ffc1 100644 --- a/cre/go/tools/add-chain-selector/main_test.go +++ b/cre/go/tools/add-new-evm-chain/main_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "os" "path/filepath" "strings" @@ -11,6 +12,8 @@ import ( ) func TestLookupChainSelector(t *testing.T) { + t.Parallel() + tests := []struct { name string selector uint64 @@ -46,6 +49,8 @@ func TestLookupChainSelector(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + name, err := lookupChainSelector(tt.selector) if tt.wantErr { require.Error(t, err) @@ -61,11 +66,13 @@ func TestLookupChainSelector(t *testing.T) { } func TestParseProtoEntries(t *testing.T) { + t.Parallel() + tests := []struct { name string content string wantEntries []ChainEntry - wantErr bool + wantErr error errContains string }{ { @@ -85,32 +92,30 @@ defaults: [ {Key: "ethereum-mainnet", Value: 5009297550715157269}, {Key: "polygon-mainnet", Value: 4051577828743386545}, }, - wantErr: false, + wantErr: nil, }, { - name: "missing defaults array", - content: `some other content`, - wantErr: true, - errContains: "could not find defaults array", + name: "missing defaults array", + content: `some other content`, + wantErr: ErrNoDefaultsArray, }, { name: "empty defaults array", content: ` defaults: [ ]`, - wantErr: true, - errContains: "no entries found", + wantErr: ErrNoEntriesFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + entries, err := parseProtoEntries(tt.content) - if tt.wantErr { + if tt.wantErr != nil { require.Error(t, err) - if tt.errContains != "" { - assert.Contains(t, err.Error(), tt.errContains) - } + assert.True(t, errors.Is(err, tt.wantErr), "expected error %v, got %v", tt.wantErr, err) return } require.NoError(t, err) @@ -120,6 +125,8 @@ defaults: [ } func TestBuildDefaultsArray(t *testing.T) { + t.Parallel() + entries := []ChainEntry{ {Key: "avalanche-mainnet", Value: 6433500567565415381}, {Key: "ethereum-mainnet", Value: 5009297550715157269}, @@ -137,6 +144,8 @@ func TestBuildDefaultsArray(t *testing.T) { } func TestChainSelectorExists(t *testing.T) { + t.Parallel() + // Create a temporary test file tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test.proto") @@ -181,6 +190,8 @@ service Client { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + exists, err := chainSelectorExists(testFile, tt.chainName) require.NoError(t, err) assert.Equal(t, tt.wantExists, exists) @@ -189,9 +200,7 @@ service Client { } func TestUpdateProtoFile(t *testing.T) { - // Create a temporary test file - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.proto") + t.Parallel() originalContent := `syntax = "proto3"; @@ -214,6 +223,11 @@ service Client { }` t.Run("add new chain", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.proto") + err := os.WriteFile(testFile, []byte(originalContent), 0644) require.NoError(t, err) @@ -238,6 +252,11 @@ service Client { }) t.Run("add chain alphabetically after existing", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.proto") + err := os.WriteFile(testFile, []byte(originalContent), 0644) require.NoError(t, err) @@ -258,52 +277,23 @@ service Client { }) t.Run("reject duplicate chain", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.proto") + err := os.WriteFile(testFile, []byte(originalContent), 0644) require.NoError(t, err) err = updateProtoFile(testFile, "ethereum-mainnet", 5009297550715157269) require.Error(t, err) - assert.Contains(t, err.Error(), "already exists") + assert.True(t, errors.Is(err, ErrChainExists), "expected ErrChainExists, got %v", err) }) } -func TestSanitizeBranchName(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - { - name: "simple name", - input: "ethereum-mainnet", - want: "ethereum-mainnet", - }, - { - name: "name with underscores", - input: "binance_smart_chain-mainnet", - want: "binance-smart-chain-mainnet", - }, - { - name: "name with special chars", - input: "chain@1.0.0", - want: "chain100", - }, - { - name: "uppercase", - input: "ETHEREUM-MAINNET", - want: "ethereum-mainnet", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := sanitizeBranchName(tt.input) - assert.Equal(t, tt.want, got) - }) - } -} - func TestIntegration_DryRun(t *testing.T) { + t.Parallel() + // This test verifies the dry-run flow works correctly // by using a real proto file from testdata @@ -334,4 +324,3 @@ func TestIntegration_DryRun(t *testing.T) { require.NoError(t, err) assert.Equal(t, string(originalContent), string(afterContent), "file should not be modified during read operations") } - diff --git a/cre/go/tools/add-chain-selector/proto_updater.go b/cre/go/tools/add-new-evm-chain/proto_updater.go similarity index 58% rename from cre/go/tools/add-chain-selector/proto_updater.go rename to cre/go/tools/add-new-evm-chain/proto_updater.go index a30ba8d1..073eeafa 100644 --- a/cre/go/tools/add-chain-selector/proto_updater.go +++ b/cre/go/tools/add-new-evm-chain/proto_updater.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "os" "regexp" @@ -9,6 +10,27 @@ import ( "strings" ) +// Sentinel errors for common error conditions. +var ( + ErrChainExists = errors.New("chain already exists in proto file") + ErrNoDefaultsArray = errors.New("could not find defaults array in proto file") + ErrNoEntriesFound = errors.New("no entries found in defaults array") + ErrPatternNotMatched = errors.New("failed to replace defaults array - pattern may not match") +) + +// Pre-compiled regex patterns for better performance. +// See: 100 Go Mistakes #42 - Not using compiled regular expressions. +var ( + // defaultsArrayRe matches the entire defaults array block. + defaultsArrayRe = regexp.MustCompile(`defaults:\s*\[[\s\S]*?\n\s*\]`) + + // defaultsContentRe extracts the content inside the defaults array. + defaultsContentRe = regexp.MustCompile(`defaults:\s*\[([\s\S]*?)\n\s*\]`) + + // entryRe matches individual chain entries within the defaults array. + entryRe = regexp.MustCompile(`\{\s*key:\s*"([^"]+)"\s*value:\s*(\d+)\s*\}`) +) + // ChainEntry represents a key-value entry in the proto file. type ChainEntry struct { Key string @@ -17,33 +39,27 @@ type ChainEntry struct { // chainSelectorExists checks if a chain selector already exists in the proto file. func chainSelectorExists(protoFile, chainName string) (bool, error) { - data, err := os.ReadFile(protoFile) + fileContent, err := os.ReadFile(protoFile) if err != nil { return false, fmt.Errorf("failed to read proto file: %w", err) } - content := string(data) - // Check if the chain name already exists in the defaults array - pattern := fmt.Sprintf(`key:\s*"%s"`, regexp.QuoteMeta(chainName)) - matched, err := regexp.MatchString(pattern, content) - if err != nil { - return false, fmt.Errorf("failed to match pattern: %w", err) - } - - return matched, nil + // Build pattern to check if the chain name already exists + pattern := regexp.MustCompile(fmt.Sprintf(`key:\s*"%s"`, regexp.QuoteMeta(chainName))) + return pattern.MatchString(string(fileContent)), nil } // updateProtoFile updates the proto file with a new chain selector entry. func updateProtoFile(protoFile, chainName string, selector uint64) error { - data, err := os.ReadFile(protoFile) + fileContent, err := os.ReadFile(protoFile) if err != nil { return fmt.Errorf("failed to read proto file: %w", err) } - content := string(data) + protoContent := string(fileContent) // Parse existing entries - entries, err := parseProtoEntries(content) + entries, err := parseProtoEntries(protoContent) if err != nil { return fmt.Errorf("failed to parse proto entries: %w", err) } @@ -51,7 +67,7 @@ func updateProtoFile(protoFile, chainName string, selector uint64) error { // Check if entry already exists for _, entry := range entries { if entry.Key == chainName { - return fmt.Errorf("chain %s already exists in proto file", chainName) + return fmt.Errorf("%w: %s", ErrChainExists, chainName) } } @@ -70,10 +86,9 @@ func updateProtoFile(protoFile, chainName string, selector uint64) error { newDefaults := buildDefaultsArray(entries) // Replace the defaults array in the content - re := regexp.MustCompile(`defaults:\s*\[[\s\S]*?\n\s*\]`) - newContent := re.ReplaceAllString(content, newDefaults) - if newContent == content { - return fmt.Errorf("failed to replace defaults array - pattern may not match") + newContent := defaultsArrayRe.ReplaceAllString(protoContent, newDefaults) + if newContent == protoContent { + return ErrPatternNotMatched } // Write back to file @@ -85,36 +100,33 @@ func updateProtoFile(protoFile, chainName string, selector uint64) error { } // parseProtoEntries parses the defaults array from the proto file. -func parseProtoEntries(content string) ([]ChainEntry, error) { - entries := []ChainEntry{} - - // Find the defaults array - match from "defaults: [" to the closing "]" - re := regexp.MustCompile(`defaults:\s*\[([\s\S]*?)\n\s*\]`) - matches := re.FindStringSubmatch(content) +func parseProtoEntries(protoContent string) ([]ChainEntry, error) { + // Find the defaults array content + matches := defaultsContentRe.FindStringSubmatch(protoContent) if len(matches) < 2 { - return nil, fmt.Errorf("could not find defaults array in proto file") + return nil, ErrNoDefaultsArray } defaultsContent := matches[1] - // Parse each entry - match the exact format with proper whitespace - // Pattern: { key: "..." value: ... } - entryRe := regexp.MustCompile(`\{\s*key:\s*"([^"]+)"\s*value:\s*(\d+)\s*\}`) + // Parse each entry entryMatches := entryRe.FindAllStringSubmatch(defaultsContent, -1) - if len(entryMatches) == 0 { - return nil, fmt.Errorf("no entries found in defaults array") + return nil, ErrNoEntriesFound } + entries := make([]ChainEntry, 0, len(entryMatches)) for _, match := range entryMatches { if len(match) < 3 { continue } + key := match[1] value, err := strconv.ParseUint(match[2], 10, 64) if err != nil { return nil, fmt.Errorf("failed to parse selector value %s for key %s: %w", match[2], key, err) } + entries = append(entries, ChainEntry{ Key: key, Value: value, @@ -128,6 +140,7 @@ func parseProtoEntries(content string) ([]ChainEntry, error) { func buildDefaultsArray(entries []ChainEntry) string { var builder strings.Builder builder.WriteString("defaults: [\n") + for i, entry := range entries { builder.WriteString(fmt.Sprintf(" {\n key: \"%s\"\n value: %d\n }", entry.Key, entry.Value)) if i < len(entries)-1 { @@ -136,7 +149,7 @@ func buildDefaultsArray(entries []ChainEntry) string { builder.WriteString("\n") } } + builder.WriteString(" ]") return builder.String() } - diff --git a/cre/go/tools/add-chain-selector/testdata/test_client.proto b/cre/go/tools/add-new-evm-chain/testdata/test_client.proto similarity index 100% rename from cre/go/tools/add-chain-selector/testdata/test_client.proto rename to cre/go/tools/add-new-evm-chain/testdata/test_client.proto From 08169caff0970d847e9a4b0e4137e43dea0f2dd5 Mon Sep 17 00:00:00 2001 From: James Walker Date: Wed, 31 Dec 2025 15:14:31 -0500 Subject: [PATCH 3/4] update workflow --- .github/workflows/add-new-evm-chain.yml | 139 +++++++++++++++++------- 1 file changed, 101 insertions(+), 38 deletions(-) diff --git a/.github/workflows/add-new-evm-chain.yml b/.github/workflows/add-new-evm-chain.yml index 843deb47..0859a074 100644 --- a/.github/workflows/add-new-evm-chain.yml +++ b/.github/workflows/add-new-evm-chain.yml @@ -29,14 +29,39 @@ permissions: contents: write pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ inputs.chain-selector }} + cancel-in-progress: true + jobs: add-new-evm-chain: runs-on: ubuntu-latest + timeout-minutes: 30 outputs: chain-name: ${{ steps.run-tool.outputs.chain_name }} action: ${{ steps.run-tool.outputs.action }} pr-url: ${{ steps.create-pr.outputs.pr_url }} steps: + - name: Validate inputs + run: | + set -euo pipefail + + CHAIN_SELECTOR="${{ inputs.chain-selector }}" + + # Validate chain-selector is a positive integer (uint64) + if ! [[ "$CHAIN_SELECTOR" =~ ^[0-9]+$ ]]; then + echo "::error::chain-selector must be a positive integer, got: $CHAIN_SELECTOR" + exit 1 + fi + + # Validate it's within uint64 range (max: 18446744073709551615) + if [[ ${#CHAIN_SELECTOR} -gt 20 ]]; then + echo "::error::chain-selector exceeds uint64 maximum value" + exit 1 + fi + + echo "::notice::Input validation passed: chain-selector=$CHAIN_SELECTOR" + - name: Assume AWS GATI role uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 with: @@ -68,68 +93,94 @@ jobs: - name: Build add-new-evm-chain tool run: | + set -euo pipefail cd cre/go go build -o add-new-evm-chain ./tools/add-new-evm-chain - name: Run add-new-evm-chain tool id: run-tool + env: + INPUT_CHAIN_SELECTOR: ${{ inputs.chain-selector }} + INPUT_DRY_RUN: ${{ inputs.dry-run }} run: | + set -euo pipefail cd cre/go - # Capture tool output - if [ "${{ inputs.dry-run }}" == "true" ]; then - OUTPUT=$(./add-new-evm-chain \ - -chain-selector ${{ inputs.chain-selector }} \ - -proto-file ../capabilities/blockchain/evm/v1alpha/client.proto \ - -dry-run 2>&1) || true - else - OUTPUT=$(./add-new-evm-chain \ - -chain-selector ${{ inputs.chain-selector }} \ - -proto-file ../capabilities/blockchain/evm/v1alpha/client.proto 2>&1) || true + # Build command arguments + ARGS="-chain-selector $INPUT_CHAIN_SELECTOR -proto-file ../capabilities/blockchain/evm/v1alpha/client.proto" + if [[ "$INPUT_DRY_RUN" == "true" ]]; then + ARGS="$ARGS -dry-run" fi + # Run tool and capture output + echo "::group::Tool output" + if ! OUTPUT=$(./add-new-evm-chain $ARGS 2>&1); then + echo "$OUTPUT" + echo "::endgroup::" + + # Try to parse ACTION even on failure + ACTION=$(echo "$OUTPUT" | grep "^ACTION=" | cut -d'=' -f2 || echo "error") + echo "action=$ACTION" >> $GITHUB_OUTPUT + + echo "::error::Tool execution failed" + exit 1 + fi echo "$OUTPUT" + echo "::endgroup::" # Parse structured output CHAIN_NAME=$(echo "$OUTPUT" | grep "^CHAIN_NAME=" | cut -d'=' -f2) CHAIN_SELECTOR=$(echo "$OUTPUT" | grep "^CHAIN_SELECTOR=" | cut -d'=' -f2) ACTION=$(echo "$OUTPUT" | grep "^ACTION=" | cut -d'=' -f2) + # Validate outputs + if [[ -z "$CHAIN_NAME" || -z "$ACTION" ]]; then + echo "::error::Failed to parse tool output - missing CHAIN_NAME or ACTION" + exit 1 + fi + echo "chain_name=$CHAIN_NAME" >> $GITHUB_OUTPUT echo "chain_selector=$CHAIN_SELECTOR" >> $GITHUB_OUTPUT echo "action=$ACTION" >> $GITHUB_OUTPUT echo "::notice::Chain: $CHAIN_NAME, Selector: $CHAIN_SELECTOR, Action: $ACTION" - - # Fail if action is error - if [ "$ACTION" == "error" ]; then - echo "::error::Tool failed to process chain selector" - exit 1 - fi - - name: Check if changes needed - id: check-changes - if: ${{ inputs.dry-run != true }} + - name: Sanitize chain name for branch + id: sanitize + if: ${{ !inputs.dry-run && steps.run-tool.outputs.action == 'added' }} + env: + CHAIN_NAME: ${{ steps.run-tool.outputs.chain_name }} run: | - if [ "${{ steps.run-tool.outputs.action }}" == "added" ]; then - echo "changes_needed=true" >> $GITHUB_OUTPUT - else - echo "changes_needed=false" >> $GITHUB_OUTPUT - echo "::notice::No changes needed - chain already exists or dry-run mode" + set -euo pipefail + + # Sanitize chain name for use in git branch names + # Only allow alphanumeric, hyphens, and underscores + SAFE_CHAIN_NAME=$(echo "$CHAIN_NAME" | tr -cd '[:alnum:]-_' | tr '[:upper:]' '[:lower:]') + + if [[ -z "$SAFE_CHAIN_NAME" ]]; then + echo "::error::Chain name sanitization resulted in empty string" + exit 1 fi + + echo "safe_chain_name=$SAFE_CHAIN_NAME" >> $GITHUB_OUTPUT + echo "::notice::Sanitized chain name: $SAFE_CHAIN_NAME" - name: Create branch for PR - if: ${{ inputs.dry-run != true && steps.check-changes.outputs.changes_needed == 'true' }} + if: ${{ !inputs.dry-run && steps.run-tool.outputs.action == 'added' }} id: create-branch + env: + SAFE_CHAIN_NAME: ${{ steps.sanitize.outputs.safe_chain_name }} run: | - BRANCH_NAME="add-evm-chain/${{ steps.run-tool.outputs.chain_name }}-$(date +%Y%m%d-%H%M%S)" + set -euo pipefail + + BRANCH_NAME="add-evm-chain/${SAFE_CHAIN_NAME}-$(date +%Y%m%d-%H%M%S)" echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT git checkout -b "$BRANCH_NAME" echo "::notice::Created branch: $BRANCH_NAME" - name: Commit changes using ghcommit - if: ${{ inputs.dry-run != true && steps.check-changes.outputs.changes_needed == 'true' }} + if: ${{ !inputs.dry-run && steps.run-tool.outputs.action == 'added' }} uses: planetscale/ghcommit-action@v0.1.45 with: commit_message: "feat(cre): add EVM chain ${{ steps.run-tool.outputs.chain_name }}" @@ -140,15 +191,19 @@ jobs: GITHUB_TOKEN: ${{ steps.get-gh-token.outputs.access-token }} - name: Create Pull Request - if: ${{ inputs.dry-run != true && steps.check-changes.outputs.changes_needed == 'true' }} + if: ${{ !inputs.dry-run && steps.run-tool.outputs.action == 'added' }} id: create-pr - uses: actions/github-script@v7 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + CHAIN_NAME: ${{ steps.run-tool.outputs.chain_name }} + CHAIN_SELECTOR: ${{ steps.run-tool.outputs.chain_selector }} + BRANCH_NAME: ${{ steps.create-branch.outputs.branch_name }} with: github-token: ${{ steps.get-gh-token.outputs.access-token }} script: | - const chainName = '${{ steps.run-tool.outputs.chain_name }}'; - const chainSelector = '${{ steps.run-tool.outputs.chain_selector }}'; - const branchName = '${{ steps.create-branch.outputs.branch_name }}'; + const chainName = process.env.CHAIN_NAME; + const chainSelector = process.env.CHAIN_SELECTOR; + const branchName = process.env.BRANCH_NAME; const { data: pr } = await github.rest.pulls.create({ owner: context.repo.owner, @@ -177,15 +232,23 @@ jobs: core.notice(`Created PR: ${pr.html_url}`); - name: Summary + env: + CHAIN_NAME: ${{ steps.run-tool.outputs.chain_name }} + CHAIN_SELECTOR: ${{ steps.run-tool.outputs.chain_selector }} + ACTION: ${{ steps.run-tool.outputs.action }} + DRY_RUN: ${{ inputs.dry-run }} + PR_URL: ${{ steps.create-pr.outputs.pr_url }} run: | + set -euo pipefail + echo "## Add New EVM Chain Results" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY - echo "| Chain Name | ${{ steps.run-tool.outputs.chain_name }} |" >> $GITHUB_STEP_SUMMARY - echo "| Chain Selector | ${{ steps.run-tool.outputs.chain_selector }} |" >> $GITHUB_STEP_SUMMARY - echo "| Action | ${{ steps.run-tool.outputs.action }} |" >> $GITHUB_STEP_SUMMARY - echo "| Dry Run | ${{ inputs.dry-run }} |" >> $GITHUB_STEP_SUMMARY - if [ -n "${{ steps.create-pr.outputs.pr_url }}" ]; then - echo "| PR URL | ${{ steps.create-pr.outputs.pr_url }} |" >> $GITHUB_STEP_SUMMARY + echo "| Chain Name | $CHAIN_NAME |" >> $GITHUB_STEP_SUMMARY + echo "| Chain Selector | $CHAIN_SELECTOR |" >> $GITHUB_STEP_SUMMARY + echo "| Action | $ACTION |" >> $GITHUB_STEP_SUMMARY + echo "| Dry Run | $DRY_RUN |" >> $GITHUB_STEP_SUMMARY + if [[ -n "$PR_URL" ]]; then + echo "| PR URL | $PR_URL |" >> $GITHUB_STEP_SUMMARY fi From 58ce96242d7c7dc3ba31a16ffa61691d9deb5fde Mon Sep 17 00:00:00 2001 From: "app-token-issuer-engops[bot]" <144731339+app-token-issuer-engops[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 20:17:00 +0000 Subject: [PATCH 4/4] Auto-fix: buf format, gofmt, go generate, go mod tidy --- cre/go/tools/add-new-evm-chain/testdata/test_client.proto | 1 - 1 file changed, 1 deletion(-) diff --git a/cre/go/tools/add-new-evm-chain/testdata/test_client.proto b/cre/go/tools/add-new-evm-chain/testdata/test_client.proto index a22a7087..9adfe93b 100644 --- a/cre/go/tools/add-new-evm-chain/testdata/test_client.proto +++ b/cre/go/tools/add-new-evm-chain/testdata/test_client.proto @@ -29,4 +29,3 @@ service Client { } }; } -