From 19925bde94d27fb6c9dede410c0e6f001f3a8876 Mon Sep 17 00:00:00 2001
From: Shang En Sim <6071014+12458@users.noreply.github.com>
Date: Sat, 30 May 2026 13:39:53 -0700
Subject: [PATCH 1/7] feat(railway): add base railway integration and setup
wizard
Signed-off-by: Shang En Sim <6071014+12458@users.noreply.github.com>
---
pkg/integrations/railway/railway.go | 183 ++++++++++++
pkg/integrations/railway/setup_provider.go | 270 ++++++++++++++++++
.../railway/setup_provider_test.go | 265 +++++++++++++++++
.../templates/api-token-instructions.tpl | 6 +
.../railway/templates/setup-complete.tpl | 7 +
pkg/registryimports/registryimports.go | 1 +
6 files changed, 732 insertions(+)
create mode 100644 pkg/integrations/railway/railway.go
create mode 100644 pkg/integrations/railway/setup_provider.go
create mode 100644 pkg/integrations/railway/setup_provider_test.go
create mode 100644 pkg/integrations/railway/templates/api-token-instructions.tpl
create mode 100644 pkg/integrations/railway/templates/setup-complete.tpl
diff --git a/pkg/integrations/railway/railway.go b/pkg/integrations/railway/railway.go
new file mode 100644
index 0000000000..f8b8a42243
--- /dev/null
+++ b/pkg/integrations/railway/railway.go
@@ -0,0 +1,183 @@
+package railway
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/pkg/registry"
+)
+
+func init() {
+ registry.RegisterIntegrationWithOptions("railway", &Railway{}, registry.IntegrationRegistrationOptions{
+ WebhookHandler: &RailwayWebhookHandler{},
+ SetupProvider: &SetupProvider{},
+ })
+}
+
+type Railway struct{}
+
+func (r *Railway) Name() string {
+ return "railway"
+}
+
+func (r *Railway) Label() string {
+ return "Railway"
+}
+
+func (r *Railway) Icon() string {
+ return "railway"
+}
+
+func (r *Railway) Description() string {
+ return "Deploy services and react to deployment events on Railway"
+}
+
+func (r *Railway) Instructions() string {
+ return ""
+}
+
+func (r *Railway) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "apiToken",
+ Label: "API Token",
+ Type: configuration.FieldTypeString,
+ Sensitive: true,
+ Description: "Railway Workspace API Token",
+ Required: true,
+ },
+ }
+}
+
+func (r *Railway) Actions() []core.Action {
+ return []core.Action{
+ &TriggerDeploy{},
+ }
+}
+
+func (r *Railway) Triggers() []core.Trigger {
+ return []core.Trigger{
+ &OnDeploymentEvent{},
+ }
+}
+
+func (r *Railway) Cleanup(ctx core.IntegrationCleanupContext) error {
+ return nil
+}
+
+func (r *Railway) Sync(ctx core.SyncContext) error {
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return err
+ }
+
+ if err := client.Verify(); err != nil {
+ return fmt.Errorf("failed to verify Railway credentials: %w", err)
+ }
+
+ ctx.Integration.Ready()
+ return nil
+}
+
+func (r *Railway) HandleRequest(ctx core.HTTPRequestContext) {
+ // no-op
+}
+
+func (r *Railway) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) {
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return nil, err
+ }
+
+ switch resourceType {
+ case "project":
+ return r.listProjects(client)
+ case "service":
+ return r.listServices(client, ctx.Parameters["project"])
+ case "environment":
+ return r.listEnvironments(client, ctx.Parameters["project"])
+ default:
+ return []core.IntegrationResource{}, nil
+ }
+}
+
+func (r *Railway) listProjects(client *Client) ([]core.IntegrationResource, error) {
+ workspaces, err := client.ListWorkspaces()
+ if err != nil {
+ return nil, err
+ }
+
+ var resources []core.IntegrationResource
+ for _, workspace := range workspaces {
+ projects, err := client.ListProjects(workspace.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, p := range projects {
+ resources = append(resources, core.IntegrationResource{
+ Type: "project",
+ ID: p.ID,
+ Name: p.Name,
+ })
+ }
+ }
+
+ return resources, nil
+}
+
+func (r *Railway) listServices(client *Client, projectID string) ([]core.IntegrationResource, error) {
+ projectID = strings.TrimSpace(projectID)
+ if projectID == "" || strings.Contains(projectID, "{{") {
+ return []core.IntegrationResource{}, nil
+ }
+
+ project, err := client.GetProjectDetails(projectID)
+ if err != nil {
+ return nil, err
+ }
+
+ var resources []core.IntegrationResource
+ for _, edge := range project.Services.Edges {
+ resources = append(resources, core.IntegrationResource{
+ Type: "service",
+ ID: edge.Node.ID,
+ Name: edge.Node.Name,
+ })
+ }
+
+ return resources, nil
+}
+
+func (r *Railway) listEnvironments(client *Client, projectID string) ([]core.IntegrationResource, error) {
+ projectID = strings.TrimSpace(projectID)
+ if projectID == "" || strings.Contains(projectID, "{{") {
+ return []core.IntegrationResource{}, nil
+ }
+
+ project, err := client.GetProjectDetails(projectID)
+ if err != nil {
+ return nil, err
+ }
+
+ var resources []core.IntegrationResource
+ for _, edge := range project.Environments.Edges {
+ resources = append(resources, core.IntegrationResource{
+ Type: "environment",
+ ID: edge.Node.ID,
+ Name: edge.Node.Name,
+ })
+ }
+
+ return resources, nil
+}
+
+func (r *Railway) Hooks() []core.Hook {
+ return []core.Hook{}
+}
+
+func (r *Railway) HandleHook(ctx core.IntegrationHookContext) error {
+ return nil
+}
diff --git a/pkg/integrations/railway/setup_provider.go b/pkg/integrations/railway/setup_provider.go
new file mode 100644
index 0000000000..02b26aea45
--- /dev/null
+++ b/pkg/integrations/railway/setup_provider.go
@@ -0,0 +1,270 @@
+package railway
+
+import (
+ "bytes"
+ _ "embed"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+ "text/template"
+
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+const (
+ SetupStepCapabilitySelection = "capabilitySelection"
+ SetupStepEnterAPIToken = "enterAPIToken"
+ SetupStepDone = "done"
+)
+
+const (
+ SecretAPIToken = "apiToken"
+ PropertyWorkspaceID = "workspaceId"
+ PropertyWorkspaceName = "workspaceName"
+)
+
+//go:embed templates/api-token-instructions.tpl
+var apiTokenInstructionsTemplate []byte
+
+//go:embed templates/setup-complete.tpl
+var setupCompleteTemplate []byte
+
+type SetupProvider struct{}
+
+func (s *SetupProvider) genCapabilities(actions []core.Action, triggers []core.Trigger) []core.Capability {
+ capabilities := []core.Capability{}
+ for _, action := range actions {
+ capabilities = append(capabilities, core.Capability{
+ Type: core.IntegrationCapabilityTypeAction,
+ Name: action.Name(),
+ Label: action.Label(),
+ Description: action.Description(),
+ Configuration: action.Configuration(),
+ OutputChannels: action.OutputChannels(nil),
+ ExampleOutput: action.ExampleOutput(),
+ })
+ }
+ for _, trigger := range triggers {
+ capabilities = append(capabilities, core.Capability{
+ Type: core.IntegrationCapabilityTypeTrigger,
+ Name: trigger.Name(),
+ Label: trigger.Label(),
+ Description: trigger.Description(),
+ Configuration: trigger.Configuration(),
+ ExampleData: trigger.ExampleData(),
+ })
+ }
+ return capabilities
+}
+
+func (s *SetupProvider) capabilityDiff(capabilities []string) []string {
+ groups := s.CapabilityGroups()
+ diff := []string{}
+ for _, group := range groups {
+ for _, capability := range group.Capabilities {
+ if !slices.Contains(capabilities, capability.Name) {
+ diff = append(diff, capability.Name)
+ }
+ }
+ }
+ return diff
+}
+
+func (s *SetupProvider) CapabilityGroups() []core.CapabilityGroup {
+ return []core.CapabilityGroup{
+ {
+ Label: "Deployments",
+ Capabilities: s.genCapabilities(
+ []core.Action{
+ &TriggerDeploy{},
+ },
+ []core.Trigger{
+ &OnDeploymentEvent{},
+ },
+ ),
+ },
+ }
+}
+
+func (s *SetupProvider) OnCapabilityUpdate(ctx core.CapabilityUpdateContext) (*core.SetupStep, error) {
+ requested, ok := ctx.Changes[core.IntegrationCapabilityStateRequested]
+ if !ok {
+ return nil, errors.New("no requested capabilities")
+ }
+
+ ctx.Capabilities.Enable(requested...)
+ return nil, nil
+}
+
+func (s *SetupProvider) FirstStep(ctx core.SetupStepContext) core.SetupStep {
+ capabilities := []string{}
+ for _, group := range s.CapabilityGroups() {
+ for _, capability := range group.Capabilities {
+ capabilities = append(capabilities, capability.Name)
+ }
+ }
+
+ return core.SetupStep{
+ Type: core.SetupStepTypeCapabilitySelection,
+ Name: SetupStepCapabilitySelection,
+ Label: "Select capabilities",
+ Capabilities: capabilities,
+ }
+}
+
+func (s *SetupProvider) OnStepSubmit(ctx core.SetupStepContext) (*core.SetupStep, error) {
+ switch ctx.Step.Name {
+ case SetupStepCapabilitySelection:
+ return s.onCapabilitySelectionSubmit(ctx)
+ case SetupStepEnterAPIToken:
+ return s.onEnterAPITokenSubmit(ctx.Step.Inputs, ctx)
+ }
+ return nil, errors.New("unknown step")
+}
+
+func (s *SetupProvider) onCapabilitySelectionSubmit(ctx core.SetupStepContext) (*core.SetupStep, error) {
+ ctx.Capabilities.Request(ctx.Step.Capabilities...)
+ ctx.Capabilities.Available(s.capabilityDiff(ctx.Step.Capabilities)...)
+
+ return &core.SetupStep{
+ Type: core.SetupStepTypeInputs,
+ Name: SetupStepEnterAPIToken,
+ Label: "Enter Railway Workspace Token",
+ Inputs: []configuration.Field{
+ {
+ Name: SecretAPIToken,
+ Label: "API Token",
+ Type: configuration.FieldTypeString,
+ Required: true,
+ Sensitive: true,
+ },
+ },
+ Instructions: string(apiTokenInstructionsTemplate),
+ }, nil
+}
+
+func (s *SetupProvider) onEnterAPITokenSubmit(input any, ctx core.SetupStepContext) (*core.SetupStep, error) {
+ m, ok := input.(map[string]any)
+ if !ok {
+ return nil, errors.New("invalid input")
+ }
+
+ apiToken, ok := m[SecretAPIToken].(string)
+ if !ok {
+ return nil, errors.New("invalid API token")
+ }
+
+ apiToken = strings.TrimSpace(apiToken)
+ if apiToken == "" {
+ return nil, errors.New("API token is required")
+ }
+
+ client := NewClientWithAPIToken(ctx.HTTP, apiToken)
+ if err := client.Verify(); err != nil {
+ return nil, fmt.Errorf("invalid API token: %w", err)
+ }
+
+ workspaces, err := client.ListWorkspaces()
+ if err != nil {
+ return nil, fmt.Errorf("failed to retrieve workspaces: %w", err)
+ }
+ if len(workspaces) == 0 {
+ return nil, errors.New("no workspaces accessible with this token")
+ }
+
+ workspace := workspaces[0]
+
+ err = ctx.Properties.CreateMany([]core.IntegrationPropertyDefinition{
+ {
+ Name: PropertyWorkspaceID,
+ Label: "Workspace ID",
+ Description: "The ID of the connected Railway workspace",
+ Type: configuration.FieldTypeString,
+ Value: workspace.ID,
+ Editable: false,
+ },
+ {
+ Name: PropertyWorkspaceName,
+ Label: "Workspace Name",
+ Description: "The name of the connected Railway workspace",
+ Type: configuration.FieldTypeString,
+ Value: workspace.Name,
+ Editable: false,
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to save properties: %w", err)
+ }
+
+ err = ctx.Secrets.Create(core.IntegrationSecretDefinition{
+ Name: SecretAPIToken,
+ Label: "API Token",
+ Description: "Railway Workspace API Token",
+ Value: apiToken,
+ Editable: true,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to save secret: %w", err)
+ }
+
+ ctx.Capabilities.Enable(ctx.Capabilities.Requested()...)
+
+ tmpl, err := template.New("setupComplete").Parse(string(setupCompleteTemplate))
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse template: %w", err)
+ }
+
+ data := map[string]any{
+ "WorkspaceName": workspace.Name,
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("failed to execute template: %w", err)
+ }
+
+ return &core.SetupStep{
+ Type: core.SetupStepTypeDone,
+ Name: SetupStepDone,
+ Label: "Setup complete",
+ Instructions: buf.String(),
+ }, nil
+}
+
+func (s *SetupProvider) OnStepRevert(ctx core.SetupStepContext) error {
+ switch ctx.Step.Name {
+ case SetupStepCapabilitySelection:
+ ctx.Capabilities.Clear()
+ return nil
+ case SetupStepEnterAPIToken:
+ _ = ctx.Properties.Delete(PropertyWorkspaceID, PropertyWorkspaceName)
+ _ = ctx.Secrets.Delete(SecretAPIToken)
+ return nil
+ }
+ return errors.New("unknown step")
+}
+
+func (s *SetupProvider) OnPropertyUpdate(ctx core.PropertyUpdateContext) (*core.SetupStep, error) {
+ return nil, errors.New("property updates are not supported for Railway")
+}
+
+func (s *SetupProvider) OnSecretUpdate(ctx core.SecretUpdateContext) (*core.SetupStep, error) {
+ switch ctx.SecretName {
+ case SecretAPIToken:
+ v := strings.TrimSpace(ctx.Value)
+ if v == "" {
+ return nil, errors.New("value is required")
+ }
+
+ client := NewClientWithAPIToken(ctx.HTTP, v)
+ if err := client.Verify(); err != nil {
+ return nil, fmt.Errorf("failed to verify new API token: %w", err)
+ }
+
+ return nil, ctx.Secrets.Update(SecretAPIToken, v)
+ default:
+ return nil, fmt.Errorf("unknown secret: %s", ctx.SecretName)
+ }
+}
diff --git a/pkg/integrations/railway/setup_provider_test.go b/pkg/integrations/railway/setup_provider_test.go
new file mode 100644
index 0000000000..4d4be848a3
--- /dev/null
+++ b/pkg/integrations/railway/setup_provider_test.go
@@ -0,0 +1,265 @@
+package railway
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+ "github.com/superplanehq/superplane/test/support/logger"
+)
+
+func Test__Railway__SetupProvider__OnCapabilityUpdate(t *testing.T) {
+ s := &SetupProvider{}
+ logger := logger.DiscardLogger()
+
+ t.Run("returns error when no requested capabilities entry", func(t *testing.T) {
+ _, err := s.OnCapabilityUpdate(core.CapabilityUpdateContext{
+ Logger: logger,
+ Changes: map[core.IntegrationCapabilityState][]string{},
+ Capabilities: &contexts.CapabilityContext{},
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no requested capabilities")
+ })
+
+ t.Run("delegates Enable for requested capability names", func(t *testing.T) {
+ localCap := &contexts.CapabilityContext{}
+ _, err := s.OnCapabilityUpdate(core.CapabilityUpdateContext{
+ Logger: logger,
+ Changes: map[core.IntegrationCapabilityState][]string{
+ core.IntegrationCapabilityStateRequested: {"railway.triggerDeploy"},
+ },
+ Capabilities: localCap,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, []string{"railway.triggerDeploy"}, localCap.EnabledCapabilities)
+ })
+}
+
+func Test__Railway__SetupProvider__OnSecretUpdate(t *testing.T) {
+ s := &SetupProvider{}
+ logger := logger.DiscardLogger()
+
+ intCtx := &contexts.IntegrationContext{}
+ props := intCtx.Properties()
+ secrets := intCtx.Secrets()
+
+ t.Run("unknown secret", func(t *testing.T) {
+ _, err := s.OnSecretUpdate(core.SecretUpdateContext{
+ Logger: logger,
+ SecretName: "other",
+ Value: "x",
+ HTTP: &contexts.HTTPContext{},
+ Properties: props,
+ Secrets: secrets,
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown secret")
+ })
+
+ t.Run("api token required", func(t *testing.T) {
+ _, err := s.OnSecretUpdate(core.SecretUpdateContext{
+ Logger: logger,
+ SecretName: "apiToken",
+ Value: " ",
+ HTTP: &contexts.HTTPContext{},
+ Properties: props,
+ Secrets: secrets,
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "value is required")
+ })
+
+ t.Run("verification fails", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusInternalServerError,
+ Body: io.NopCloser(strings.NewReader("GraphQL error")),
+ },
+ },
+ }
+ _, err := s.OnSecretUpdate(core.SecretUpdateContext{
+ Logger: logger,
+ SecretName: "apiToken",
+ Value: "tok",
+ HTTP: httpCtx,
+ Properties: props,
+ Secrets: secrets,
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to verify new API token")
+ })
+
+ t.Run("success updates secret", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"apiToken":{"workspaces":[{"id":"w-1"}]}}}`)),
+ },
+ },
+ }
+ _, err := s.OnSecretUpdate(core.SecretUpdateContext{
+ Logger: logger,
+ SecretName: "apiToken",
+ Value: "valid-token",
+ HTTP: httpCtx,
+ Properties: props,
+ Secrets: secrets,
+ })
+ require.NoError(t, err)
+ v, getErr := secrets.Get("apiToken")
+ require.NoError(t, getErr)
+ assert.Equal(t, "valid-token", v)
+ })
+}
+
+func Test__Railway__SetupProvider__OnStepSubmit(t *testing.T) {
+ s := &SetupProvider{}
+ logger := logger.DiscardLogger()
+
+ t.Run("unknown step", func(t *testing.T) {
+ _, err := s.OnStepSubmit(core.SetupStepContext{
+ Step: core.StepInfo{Name: "nope"},
+ Logger: logger,
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown step")
+ })
+
+ t.Run("capabilitySelection success", func(t *testing.T) {
+ capCtx := &contexts.CapabilityContext{}
+ next, err := s.OnStepSubmit(core.SetupStepContext{
+ Step: core.StepInfo{Name: SetupStepCapabilitySelection, Capabilities: []string{"railway.triggerDeploy"}},
+ Logger: logger,
+ Capabilities: capCtx,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, next)
+ assert.Equal(t, SetupStepEnterAPIToken, next.Name)
+ assert.Equal(t, core.SetupStepTypeInputs, next.Type)
+ assert.ElementsMatch(t, []string{"railway.triggerDeploy"}, capCtx.RequestedCapabilties)
+ })
+
+ t.Run("enterAPIToken validation", func(t *testing.T) {
+ intCtx := &contexts.IntegrationContext{}
+ props := intCtx.Properties()
+ _, err := s.OnStepSubmit(core.SetupStepContext{
+ Step: core.StepInfo{Name: SetupStepEnterAPIToken, Inputs: "not-a-map"},
+ Logger: logger,
+ Properties: props,
+ Secrets: intCtx.Secrets(),
+ Capabilities: &contexts.CapabilityContext{},
+ HTTP: &contexts.HTTPContext{},
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid input")
+
+ _, err = s.OnStepSubmit(core.SetupStepContext{
+ Step: core.StepInfo{Name: SetupStepEnterAPIToken, Inputs: map[string]any{"apiToken": 123}},
+ Logger: logger,
+ Properties: props,
+ Secrets: intCtx.Secrets(),
+ Capabilities: &contexts.CapabilityContext{},
+ HTTP: &contexts.HTTPContext{},
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid API token")
+
+ _, err = s.OnStepSubmit(core.SetupStepContext{
+ Step: core.StepInfo{Name: SetupStepEnterAPIToken, Inputs: map[string]any{"apiToken": ""}},
+ Logger: logger,
+ Properties: props,
+ Secrets: intCtx.Secrets(),
+ Capabilities: &contexts.CapabilityContext{},
+ HTTP: &contexts.HTTPContext{},
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "API token is required")
+ })
+
+ t.Run("enterAPIToken success", func(t *testing.T) {
+ intCtx := &contexts.IntegrationContext{}
+ props := intCtx.Properties()
+ secrets := intCtx.Secrets()
+ capCtx := &contexts.CapabilityContext{
+ RequestedCapabilties: []string{"railway.triggerDeploy"},
+ }
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"apiToken":{"workspaces":[{"id":"w-1"}]}}}`)),
+ },
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"apiToken":{"workspaces":[{"id":"w-1","name":"Main Workspace"}]}}}`)),
+ },
+ },
+ }
+
+ next, err := s.OnStepSubmit(core.SetupStepContext{
+ Step: core.StepInfo{Name: SetupStepEnterAPIToken, Inputs: map[string]any{"apiToken": "good-token"}},
+ Logger: logger,
+ Properties: props,
+ Secrets: secrets,
+ Capabilities: capCtx,
+ HTTP: httpCtx,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, next)
+ assert.Equal(t, core.SetupStepTypeDone, next.Type)
+ assert.Equal(t, SetupStepDone, next.Name)
+
+ wsID, _ := props.GetString("workspaceId")
+ assert.Equal(t, "w-1", wsID)
+ wsName, _ := props.GetString("workspaceName")
+ assert.Equal(t, "Main Workspace", wsName)
+
+ tok, _ := secrets.Get("apiToken")
+ assert.Equal(t, "good-token", tok)
+
+ assert.ElementsMatch(t, []string{"railway.triggerDeploy"}, capCtx.EnabledCapabilities)
+ })
+}
+
+func Test__Railway__SetupProvider__OnStepRevert(t *testing.T) {
+ s := &SetupProvider{}
+ logger := logger.DiscardLogger()
+
+ t.Run("unknown step", func(t *testing.T) {
+ err := s.OnStepRevert(core.SetupStepContext{
+ Step: core.StepInfo{Name: "nope"},
+ Logger: logger,
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown step")
+ })
+
+ t.Run("enterAPIToken Revert deletes properties and secrets", func(t *testing.T) {
+ intCtx := &contexts.IntegrationContext{}
+ props := intCtx.Properties()
+ _ = props.Create(core.IntegrationPropertyDefinition{Name: "workspaceId", Value: "w-1"})
+ _ = intCtx.SetSecret("apiToken", []byte("tok"))
+
+ err := s.OnStepRevert(core.SetupStepContext{
+ Step: core.StepInfo{Name: SetupStepEnterAPIToken},
+ Logger: logger,
+ Properties: props,
+ Secrets: intCtx.Secrets(),
+ })
+ require.NoError(t, err)
+
+ _, err = props.GetString("workspaceId")
+ assert.Error(t, err)
+ _, err = intCtx.Secrets().Get("apiToken")
+ assert.Error(t, err)
+ })
+}
diff --git a/pkg/integrations/railway/templates/api-token-instructions.tpl b/pkg/integrations/railway/templates/api-token-instructions.tpl
new file mode 100644
index 0000000000..794afac1a6
--- /dev/null
+++ b/pkg/integrations/railway/templates/api-token-instructions.tpl
@@ -0,0 +1,6 @@
+To connect SuperPlane to your Railway workspace, you need to provide a **Workspace API Token**:
+
+1. Open the [Railway Tokens Settings](https://railway.com/account/tokens) page.
+2. Under **Workspace Tokens**, select your target workspace and click **Create a Workspace Token**.
+3. Name your token (e.g., `SuperPlane Integration`) and copy the token value.
+4. Paste the copied token value below.
diff --git a/pkg/integrations/railway/templates/setup-complete.tpl b/pkg/integrations/railway/templates/setup-complete.tpl
new file mode 100644
index 0000000000..53b0262367
--- /dev/null
+++ b/pkg/integrations/railway/templates/setup-complete.tpl
@@ -0,0 +1,7 @@
+### Connection Successful!
+
+SuperPlane is now connected to your Railway workspace: **{{.WorkspaceName}}**.
+
+You can now use the following capabilities in your workflows:
+- **On Deployment Event**: Trigger workflows when your Railway deployments change status.
+- **Trigger Deploy**: Programmatically trigger redeployments of your services.
diff --git a/pkg/registryimports/registryimports.go b/pkg/registryimports/registryimports.go
index 946ff2b885..30d655d902 100644
--- a/pkg/registryimports/registryimports.go
+++ b/pkg/registryimports/registryimports.go
@@ -56,6 +56,7 @@ import (
_ "github.com/superplanehq/superplane/pkg/integrations/pagerduty"
_ "github.com/superplanehq/superplane/pkg/integrations/perplexity"
_ "github.com/superplanehq/superplane/pkg/integrations/prometheus"
+ _ "github.com/superplanehq/superplane/pkg/integrations/railway"
_ "github.com/superplanehq/superplane/pkg/integrations/render"
_ "github.com/superplanehq/superplane/pkg/integrations/rootly"
_ "github.com/superplanehq/superplane/pkg/integrations/semaphore"
From 651ac8bd21b9a88a47a6046e1f5ca5f6ebb9a96e Mon Sep 17 00:00:00 2001
From: Shang En Sim <6071014+12458@users.noreply.github.com>
Date: Sat, 30 May 2026 13:39:56 -0700
Subject: [PATCH 2/7] feat(railway): implement graphql client and project
models
Signed-off-by: Shang En Sim <6071014+12458@users.noreply.github.com>
---
pkg/integrations/railway/client.go | 387 ++++++++++++++++++++++++
pkg/integrations/railway/client_test.go | 170 +++++++++++
pkg/integrations/railway/payloads.go | 95 ++++++
3 files changed, 652 insertions(+)
create mode 100644 pkg/integrations/railway/client.go
create mode 100644 pkg/integrations/railway/client_test.go
create mode 100644 pkg/integrations/railway/payloads.go
diff --git a/pkg/integrations/railway/client.go b/pkg/integrations/railway/client.go
new file mode 100644
index 0000000000..b3cd18cfac
--- /dev/null
+++ b/pkg/integrations/railway/client.go
@@ -0,0 +1,387 @@
+package railway
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+const defaultRailwayBaseURL = "https://backboard.railway.com/graphql/v2"
+
+type Client struct {
+ APIToken string
+ BaseURL string
+ http core.HTTPContext
+}
+
+type APIError struct {
+ Errors []GraphQLError
+}
+
+func (e *APIError) Error() string {
+ var msgs []string
+ for _, err := range e.Errors {
+ msgs = append(msgs, err.Message)
+ }
+ return fmt.Sprintf("GraphQL error: %s", strings.Join(msgs, "; "))
+}
+
+func NewClientWithAPIToken(http core.HTTPContext, apiToken string) *Client {
+ return &Client{
+ APIToken: strings.TrimSpace(apiToken),
+ BaseURL: defaultRailwayBaseURL,
+ http: http,
+ }
+}
+
+func NewClientWithStorageContexts(http core.HTTPContext, properties core.IntegrationPropertyStorageReader, secrets core.IntegrationSecretStorageReader) (*Client, error) {
+ apiToken, err := secrets.Get("apiToken")
+ if err != nil {
+ return nil, fmt.Errorf("apiToken not found in secrets: %w", err)
+ }
+
+ return NewClientWithAPIToken(http, apiToken), nil
+}
+
+func NewClient(http core.HTTPContext, ctx core.IntegrationContext) (*Client, error) {
+ if ctx == nil {
+ return nil, fmt.Errorf("no integration context")
+ }
+
+ if !ctx.LegacySetup() {
+ return NewClientWithStorageContexts(http, ctx.Properties(), ctx.Secrets())
+ }
+
+ apiToken, err := ctx.GetConfig("apiToken")
+ if err != nil {
+ return nil, fmt.Errorf("apiToken config not found: %w", err)
+ }
+
+ return NewClientWithAPIToken(http, string(apiToken)), nil
+}
+
+func (c *Client) execQuery(query string, variables map[string]any, responseData any) error {
+ reqBody := GraphQLRequest{
+ Query: query,
+ Variables: variables,
+ }
+
+ encodedBody, err := json.Marshal(reqBody)
+ if err != nil {
+ return fmt.Errorf("failed to marshal request payload: %w", err)
+ }
+
+ req, err := http.NewRequest(http.MethodPost, c.BaseURL, bytes.NewReader(encodedBody))
+ if err != nil {
+ return fmt.Errorf("failed to build request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+c.APIToken)
+
+ res, err := c.http.Do(req)
+ if err != nil {
+ return fmt.Errorf("request failed: %w", err)
+ }
+ defer res.Body.Close()
+
+ responseBody, err := io.ReadAll(res.Body)
+ if err != nil {
+ return fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices {
+ return fmt.Errorf("http request failed with status %d: %s", res.StatusCode, string(responseBody))
+ }
+
+ var gqlResponse GraphQLResponse
+ if err := json.Unmarshal(responseBody, &gqlResponse); err != nil {
+ return fmt.Errorf("failed to unmarshal GraphQL response: %w", err)
+ }
+
+ if len(gqlResponse.Errors) > 0 {
+ return &APIError{Errors: gqlResponse.Errors}
+ }
+
+ if responseData != nil {
+ if err := json.Unmarshal(gqlResponse.Data, responseData); err != nil {
+ return fmt.Errorf("failed to unmarshal response data: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func (c *Client) Verify() error {
+ query := `
+ query {
+ apiToken {
+ workspaces {
+ id
+ }
+ }
+ }
+ `
+ var result struct {
+ APIToken struct {
+ Workspaces []struct {
+ ID string `json:"id"`
+ } `json:"workspaces"`
+ } `json:"apiToken"`
+ }
+
+ return c.execQuery(query, nil, &result)
+}
+
+func (c *Client) ListWorkspaces() ([]Workspace, error) {
+ query := `
+ query {
+ apiToken {
+ workspaces {
+ id
+ name
+ }
+ }
+ }
+ `
+ var result struct {
+ APIToken struct {
+ Workspaces []Workspace `json:"workspaces"`
+ } `json:"apiToken"`
+ }
+
+ if err := c.execQuery(query, nil, &result); err != nil {
+ return nil, err
+ }
+
+ return result.APIToken.Workspaces, nil
+}
+
+func (c *Client) ListProjects(workspaceID string) ([]Project, error) {
+ query := `
+ query($workspaceId: String!) {
+ projects(workspaceId: $workspaceId) {
+ edges {
+ node {
+ id
+ name
+ }
+ }
+ }
+ }
+ `
+ variables := map[string]any{
+ "workspaceId": workspaceID,
+ }
+
+ var result struct {
+ Projects struct {
+ Edges []struct {
+ Node Project `json:"node"`
+ } `json:"edges"`
+ } `json:"projects"`
+ }
+
+ if err := c.execQuery(query, variables, &result); err != nil {
+ return nil, err
+ }
+
+ projects := make([]Project, 0, len(result.Projects.Edges))
+ for _, edge := range result.Projects.Edges {
+ projects = append(projects, edge.Node)
+ }
+
+ return projects, nil
+}
+
+func (c *Client) GetProjectDetails(projectID string) (*Project, error) {
+ query := `
+ query($id: String!) {
+ project(id: $id) {
+ id
+ name
+ workspaceId
+ services {
+ edges {
+ node {
+ id
+ name
+ }
+ }
+ }
+ environments {
+ edges {
+ node {
+ id
+ name
+ }
+ }
+ }
+ }
+ }
+ `
+ variables := map[string]any{
+ "id": projectID,
+ }
+
+ var result struct {
+ Project Project `json:"project"`
+ }
+
+ if err := c.execQuery(query, variables, &result); err != nil {
+ return nil, err
+ }
+
+ return &result.Project, nil
+}
+
+func (c *Client) TriggerDeploy(environmentID, serviceID string) (string, error) {
+ query := `
+ mutation($environmentId: String!, $serviceId: String!) {
+ serviceInstanceDeployV2(environmentId: $environmentId, serviceId: $serviceId)
+ }
+ `
+ variables := map[string]any{
+ "environmentId": environmentID,
+ "serviceId": serviceID,
+ }
+
+ var result struct {
+ ServiceInstanceDeployV2 string `json:"serviceInstanceDeployV2"`
+ }
+
+ if err := c.execQuery(query, variables, &result); err != nil {
+ return "", err
+ }
+
+ return result.ServiceInstanceDeployV2, nil
+}
+
+func (c *Client) GetDeployment(deploymentID string) (*Deployment, error) {
+ query := `
+ query($id: String!) {
+ deployment(id: $id) {
+ id
+ status
+ createdAt
+ updatedAt
+ }
+ }
+ `
+ variables := map[string]any{
+ "id": deploymentID,
+ }
+
+ var result struct {
+ Deployment Deployment `json:"deployment"`
+ }
+
+ if err := c.execQuery(query, variables, &result); err != nil {
+ return nil, err
+ }
+
+ return &result.Deployment, nil
+}
+
+func (c *Client) ListNotificationRules(workspaceID, projectID string) ([]NotificationRule, error) {
+ query := `
+ query($workspaceId: String!, $projectId: String!) {
+ notificationRules(workspaceId: $workspaceId, projectId: $projectId) {
+ id
+ projectId
+ eventTypes
+ severities
+ ephemeralEnvironments
+ createdAt
+ updatedAt
+ channels {
+ id
+ config
+ createdAt
+ updatedAt
+ }
+ }
+ }
+ `
+ variables := map[string]any{
+ "workspaceId": workspaceID,
+ "projectId": projectID,
+ }
+
+ var result struct {
+ NotificationRules []NotificationRule `json:"notificationRules"`
+ }
+
+ if err := c.execQuery(query, variables, &result); err != nil {
+ return nil, err
+ }
+
+ return result.NotificationRules, nil
+}
+
+func (c *Client) CreateNotificationRule(workspaceID, projectID string, eventTypes []string, webhookURL string) (*NotificationRule, error) {
+ query := `
+ mutation($input: CreateNotificationRuleInput!) {
+ notificationRuleCreate(input: $input) {
+ id
+ projectId
+ eventTypes
+ severities
+ ephemeralEnvironments
+ createdAt
+ updatedAt
+ channels {
+ id
+ config
+ createdAt
+ updatedAt
+ }
+ }
+ }
+ `
+ variables := map[string]any{
+ "input": map[string]any{
+ "workspaceId": workspaceID,
+ "projectId": projectID,
+ "eventTypes": eventTypes,
+ "channelConfigs": []map[string]any{
+ {
+ "url": webhookURL,
+ "type": "webhook",
+ },
+ },
+ },
+ }
+
+ var result struct {
+ NotificationRuleCreate NotificationRule `json:"notificationRuleCreate"`
+ }
+
+ if err := c.execQuery(query, variables, &result); err != nil {
+ return nil, err
+ }
+
+ return &result.NotificationRuleCreate, nil
+}
+
+func (c *Client) DeleteNotificationRule(ruleID string) error {
+ query := `
+ mutation($id: String!) {
+ notificationRuleDelete(id: $id)
+ }
+ `
+ variables := map[string]any{
+ "id": ruleID,
+ }
+
+ var result struct {
+ NotificationRuleDelete bool `json:"notificationRuleDelete"`
+ }
+
+ return c.execQuery(query, variables, &result)
+}
diff --git a/pkg/integrations/railway/client_test.go b/pkg/integrations/railway/client_test.go
new file mode 100644
index 0000000000..29654200c4
--- /dev/null
+++ b/pkg/integrations/railway/client_test.go
@@ -0,0 +1,170 @@
+package railway
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+func Test__Railway__Client__Verify(t *testing.T) {
+ t.Run("success", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"apiToken":{"workspaces":[]}}}`)),
+ },
+ },
+ }
+
+ client := NewClientWithAPIToken(httpCtx, "test-token")
+ err := client.Verify()
+ require.NoError(t, err)
+
+ require.Len(t, httpCtx.Requests, 1)
+ assert.Equal(t, "Bearer test-token", httpCtx.Requests[0].Header.Get("Authorization"))
+ })
+
+ t.Run("error", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"errors":[{"message":"Not Authorized"}]}`)),
+ },
+ },
+ }
+
+ client := NewClientWithAPIToken(httpCtx, "test-token")
+ err := client.Verify()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "GraphQL error: Not Authorized")
+ })
+}
+
+func Test__Railway__Client__ListWorkspaces(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"apiToken":{"workspaces":[{"id":"w-1","name":"WS 1"}]}}}`)),
+ },
+ },
+ }
+
+ client := NewClientWithAPIToken(httpCtx, "test-token")
+ workspaces, err := client.ListWorkspaces()
+ require.NoError(t, err)
+ require.Len(t, workspaces, 1)
+ assert.Equal(t, "w-1", workspaces[0].ID)
+ assert.Equal(t, "WS 1", workspaces[0].Name)
+}
+
+func Test__Railway__Client__ListProjects(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"projects":{"edges":[{"node":{"id":"p-1","name":"Project 1"}}]}}}`)),
+ },
+ },
+ }
+
+ client := NewClientWithAPIToken(httpCtx, "test-token")
+ projects, err := client.ListProjects("w-1")
+ require.NoError(t, err)
+ require.Len(t, projects, 1)
+ assert.Equal(t, "p-1", projects[0].ID)
+ assert.Equal(t, "Project 1", projects[0].Name)
+}
+
+func Test__Railway__Client__GetProjectDetails(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"project":{"id":"p-1","name":"Project 1","workspaceId":"w-2","services":{"edges":[{"node":{"id":"s-1","name":"Service 1"}}]},"environments":{"edges":[{"node":{"id":"e-1","name":"Env 1"}}]}}}}`)),
+ },
+ },
+ }
+
+ client := NewClientWithAPIToken(httpCtx, "test-token")
+ project, err := client.GetProjectDetails("p-1")
+ require.NoError(t, err)
+ assert.Equal(t, "p-1", project.ID)
+ assert.Equal(t, "Project 1", project.Name)
+ assert.Equal(t, "w-2", project.WorkspaceID)
+ require.Len(t, project.Services.Edges, 1)
+ assert.Equal(t, "s-1", project.Services.Edges[0].Node.ID)
+ require.Len(t, project.Environments.Edges, 1)
+ assert.Equal(t, "e-1", project.Environments.Edges[0].Node.ID)
+}
+
+func Test__Railway__Client__TriggerDeploy(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"serviceInstanceDeployV2":"deploy-123"}}`)),
+ },
+ },
+ }
+
+ client := NewClientWithAPIToken(httpCtx, "test-token")
+ deployID, err := client.TriggerDeploy("e-1", "s-1")
+ require.NoError(t, err)
+ assert.Equal(t, "deploy-123", deployID)
+}
+
+func Test__Railway__Client__GetDeployment(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"deployment":{"id":"deploy-123","status":"SUCCESS","createdAt":"2026-05-30T00:00:00Z","updatedAt":"2026-05-30T00:01:00Z"}}}`)),
+ },
+ },
+ }
+
+ client := NewClientWithAPIToken(httpCtx, "test-token")
+ deploy, err := client.GetDeployment("deploy-123")
+ require.NoError(t, err)
+ assert.Equal(t, "deploy-123", deploy.ID)
+ assert.Equal(t, "SUCCESS", deploy.Status)
+}
+
+func Test__Railway__Client__CreateNotificationRule(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"notificationRuleCreate":{"id":"rule-123","projectId":"p-1","eventTypes":["Deployment.deployed"],"severities":[],"ephemeralEnvironments":false,"createdAt":"","updatedAt":"","channels":[]}}}`)),
+ },
+ },
+ }
+
+ client := NewClientWithAPIToken(httpCtx, "test-token")
+ rule, err := client.CreateNotificationRule("w-1", "p-1", []string{"Deployment.deployed"}, "https://hook.superplane.dev")
+ require.NoError(t, err)
+ assert.Equal(t, "rule-123", rule.ID)
+}
+
+func Test__Railway__Client__DeleteNotificationRule(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"notificationRuleDelete":true}}`)),
+ },
+ },
+ }
+
+ client := NewClientWithAPIToken(httpCtx, "test-token")
+ err := client.DeleteNotificationRule("rule-123")
+ require.NoError(t, err)
+}
diff --git a/pkg/integrations/railway/payloads.go b/pkg/integrations/railway/payloads.go
new file mode 100644
index 0000000000..7d99bc5d24
--- /dev/null
+++ b/pkg/integrations/railway/payloads.go
@@ -0,0 +1,95 @@
+package railway
+
+import "encoding/json"
+
+type GraphQLRequest struct {
+ Query string `json:"query"`
+ Variables map[string]any `json:"variables,omitempty"`
+ OperationName string `json:"operationName,omitempty"`
+}
+
+type GraphQLResponse struct {
+ Data json.RawMessage `json:"data,omitempty"`
+ Errors []GraphQLError `json:"errors,omitempty"`
+}
+
+type GraphQLError struct {
+ Message string `json:"message"`
+ Locations []GraphQLErrorLocation `json:"locations,omitempty"`
+ Path []any `json:"path,omitempty"`
+ Extensions map[string]any `json:"extensions,omitempty"`
+}
+
+type GraphQLErrorLocation struct {
+ Line int `json:"line"`
+ Column int `json:"column"`
+}
+
+type Workspace struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type Project struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ WorkspaceID string `json:"workspaceId,omitempty"`
+ Environments ProjectEnvironments `json:"environments,omitempty"`
+ Services ProjectServices `json:"services,omitempty"`
+}
+
+type ProjectEnvironments struct {
+ Edges []EnvironmentEdge `json:"edges"`
+}
+
+type EnvironmentEdge struct {
+ Node Environment `json:"node"`
+}
+
+type Environment struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type ProjectServices struct {
+ Edges []ServiceEdge `json:"edges"`
+}
+
+type ServiceEdge struct {
+ Node Service `json:"node"`
+}
+
+type Service struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type Deployment struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+type NotificationRule struct {
+ ID string `json:"id"`
+ ProjectID string `json:"projectId,omitempty"`
+ EventTypes []string `json:"eventTypes"`
+ Severities []string `json:"severities"`
+ EphemeralEnvironments bool `json:"ephemeralEnvironments"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+ Channels []NotificationChannel `json:"channels"`
+}
+
+type NotificationChannel struct {
+ ID string `json:"id"`
+ Config NotificationChannelConfig `json:"config"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+type NotificationChannelConfig struct {
+ URL string `json:"url"`
+ Type string `json:"type"`
+}
From 046e4916563118371c8764f96d294fca972986d0 Mon Sep 17 00:00:00 2001
From: Shang En Sim <6071014+12458@users.noreply.github.com>
Date: Sat, 30 May 2026 13:39:59 -0700
Subject: [PATCH 3/7] feat(railway): implement webhook provisioning and
deployment trigger
Signed-off-by: Shang En Sim <6071014+12458@users.noreply.github.com>
---
pkg/integrations/railway/on_deployment.go | 248 ++++++++++++++++++
.../railway/on_deployment_test.go | 179 +++++++++++++
pkg/integrations/railway/webhook_handler.go | 147 +++++++++++
.../railway/webhook_handler_test.go | 183 +++++++++++++
4 files changed, 757 insertions(+)
create mode 100644 pkg/integrations/railway/on_deployment.go
create mode 100644 pkg/integrations/railway/on_deployment_test.go
create mode 100644 pkg/integrations/railway/webhook_handler.go
create mode 100644 pkg/integrations/railway/webhook_handler_test.go
diff --git a/pkg/integrations/railway/on_deployment.go b/pkg/integrations/railway/on_deployment.go
new file mode 100644
index 0000000000..eaa733960c
--- /dev/null
+++ b/pkg/integrations/railway/on_deployment.go
@@ -0,0 +1,248 @@
+package railway
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "slices"
+ "strings"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+type OnDeploymentEvent struct{}
+
+type OnDeploymentConfiguration struct {
+ Project string `json:"project" mapstructure:"project"`
+ EventTypes []string `json:"eventTypes" mapstructure:"eventTypes"`
+}
+
+type OnDeploymentPayload struct {
+ Type string `json:"type"`
+ ProjectID string `json:"projectId"`
+ EnvironmentID string `json:"environmentId"`
+ ServiceID string `json:"serviceId"`
+ DeploymentID string `json:"deploymentId"`
+ Status string `json:"status,omitempty"`
+ Timestamp string `json:"timestamp,omitempty"`
+}
+
+var defaultEventTypes = []string{"Deployment.deployed", "Deployment.failed"}
+
+var eventTypeOptions = []configuration.FieldOption{
+ {Label: "Deployed", Value: "Deployment.deployed"},
+ {Label: "Failed", Value: "Deployment.failed"},
+ {Label: "Crashed", Value: "Deployment.crashed"},
+ {Label: "Redeployed", Value: "Deployment.redeployed"},
+ {Label: "Building", Value: "Deployment.building"},
+}
+
+func (t *OnDeploymentEvent) Name() string {
+ return "railway.onDeployment"
+}
+
+func (t *OnDeploymentEvent) Label() string {
+ return "On Deployment Event"
+}
+
+func (t *OnDeploymentEvent) Description() string {
+ return "Trigger when a Railway deployment status changes"
+}
+
+func (t *OnDeploymentEvent) Documentation() string {
+ return `The On Deployment Event trigger fires when a deployment changes status in the specified Railway project.
+
+## Use Cases
+
+- **Slack Notification**: Send notifications when build/deploy fails or succeeds.
+- **Auto-verification**: Run integration test workflows on successful deploys.
+
+## Configuration
+
+- **Project**: The Railway project to watch.
+- **Event Types**: Deployment statuses to listen for (defaults to Deployed and Failed).`
+}
+
+func (t *OnDeploymentEvent) Icon() string {
+ return "railway"
+}
+
+func (t *OnDeploymentEvent) Color() string {
+ return "gray"
+}
+
+func (t *OnDeploymentEvent) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "project",
+ Label: "Project",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The Railway project to watch",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "project",
+ },
+ },
+ },
+ {
+ Name: "eventTypes",
+ Label: "Event Types",
+ Type: configuration.FieldTypeMultiSelect,
+ Required: false,
+ Default: defaultEventTypes,
+ Description: "Deployment statuses to trigger on",
+ TypeOptions: &configuration.TypeOptions{
+ MultiSelect: &configuration.MultiSelectTypeOptions{
+ Options: eventTypeOptions,
+ },
+ },
+ },
+ }
+}
+
+func decodeOnDeploymentConfiguration(config any) (OnDeploymentConfiguration, error) {
+ spec := OnDeploymentConfiguration{}
+ if err := mapstructure.Decode(config, &spec); err != nil {
+ return OnDeploymentConfiguration{}, fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ spec.Project = strings.TrimSpace(spec.Project)
+ if spec.Project == "" {
+ return OnDeploymentConfiguration{}, fmt.Errorf("project is required")
+ }
+
+ if len(spec.EventTypes) == 0 {
+ spec.EventTypes = defaultEventTypes
+ }
+
+ return spec, nil
+}
+
+func (t *OnDeploymentEvent) Setup(ctx core.TriggerContext) error {
+ config, err := decodeOnDeploymentConfiguration(ctx.Configuration)
+ if err != nil {
+ return err
+ }
+
+ // Request webhook for the specific project and event types
+ return ctx.Integration.RequestWebhook(WebhookConfiguration{
+ ProjectID: config.Project,
+ EventTypes: config.EventTypes,
+ })
+}
+
+func (t *OnDeploymentEvent) Hooks() []core.Hook {
+ return []core.Hook{}
+}
+
+func (t *OnDeploymentEvent) HandleHook(ctx core.TriggerHookContext) (map[string]any, error) {
+ return nil, nil
+}
+
+func parseRailwayDeploymentWebhook(body []byte) (OnDeploymentPayload, error) {
+ var raw struct {
+ Type string `json:"type"`
+ Timestamp string `json:"timestamp"`
+ Details struct {
+ Status string `json:"status"`
+ } `json:"details"`
+ Resource struct {
+ Project struct {
+ ID string `json:"id"`
+ } `json:"project"`
+ Environment struct {
+ ID string `json:"id"`
+ } `json:"environment"`
+ Service struct {
+ ID string `json:"id"`
+ } `json:"service"`
+ Deployment struct {
+ ID string `json:"id"`
+ } `json:"deployment"`
+ } `json:"resource"`
+ ProjectID string `json:"projectId"`
+ EnvironmentID string `json:"environmentId"`
+ ServiceID string `json:"serviceId"`
+ DeploymentID string `json:"deploymentId"`
+ Status string `json:"status"`
+ }
+
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return OnDeploymentPayload{}, fmt.Errorf("failed to parse webhook payload: %w", err)
+ }
+
+ if raw.ProjectID != "" {
+ return OnDeploymentPayload{
+ Type: raw.Type,
+ ProjectID: raw.ProjectID,
+ EnvironmentID: raw.EnvironmentID,
+ ServiceID: raw.ServiceID,
+ DeploymentID: raw.DeploymentID,
+ Status: raw.Status,
+ Timestamp: raw.Timestamp,
+ }, nil
+ }
+
+ status := raw.Details.Status
+ if status == "" {
+ status = raw.Status
+ }
+
+ return OnDeploymentPayload{
+ Type: raw.Type,
+ ProjectID: raw.Resource.Project.ID,
+ EnvironmentID: raw.Resource.Environment.ID,
+ ServiceID: raw.Resource.Service.ID,
+ DeploymentID: raw.Resource.Deployment.ID,
+ Status: status,
+ Timestamp: raw.Timestamp,
+ }, nil
+}
+
+func (t *OnDeploymentEvent) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) {
+ config, err := decodeOnDeploymentConfiguration(ctx.Configuration)
+ if err != nil {
+ return http.StatusInternalServerError, nil, fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ payload, err := parseRailwayDeploymentWebhook(ctx.Body)
+ if err != nil {
+ return http.StatusBadRequest, nil, err
+ }
+
+ // Filter by project ID
+ if payload.ProjectID != config.Project {
+ return http.StatusOK, nil, nil
+ }
+
+ // Filter by event type
+ if !slices.Contains(config.EventTypes, payload.Type) {
+ return http.StatusOK, nil, nil
+ }
+
+ // Emit event data
+ if err := ctx.Events.Emit(t.Name(), []any{payload}); err != nil {
+ return http.StatusInternalServerError, nil, err
+ }
+
+ return http.StatusOK, nil, nil
+}
+
+func (t *OnDeploymentEvent) Cleanup(ctx core.TriggerContext) error {
+ return nil
+}
+
+func (t *OnDeploymentEvent) ExampleData() map[string]any {
+ return map[string]any{
+ "type": "Deployment.deployed",
+ "projectId": "8db400fa-357e-4646-90f0-c7eb36e88a92",
+ "environmentId": "9a1d7a89-2cf4-4446-9b69-4cde850918aa",
+ "serviceId": "2a345678-bcde-4fgh-1234-567812345678",
+ "deploymentId": "ebda9796-09e4-456f-af60-d1a66dee66a0",
+ "status": "SUCCESS",
+ "timestamp": "2026-05-30T19:46:09.816Z",
+ }
+}
diff --git a/pkg/integrations/railway/on_deployment_test.go b/pkg/integrations/railway/on_deployment_test.go
new file mode 100644
index 0000000000..721ed1a9e6
--- /dev/null
+++ b/pkg/integrations/railway/on_deployment_test.go
@@ -0,0 +1,179 @@
+package railway
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+func Test__Railway__OnDeployment__Setup(t *testing.T) {
+ trigger := &OnDeploymentEvent{}
+
+ t.Run("success", func(t *testing.T) {
+ intCtx := &contexts.IntegrationContext{}
+ err := trigger.Setup(core.TriggerContext{
+ Configuration: map[string]any{
+ "project": "p-1",
+ "eventTypes": []string{"Deployment.deployed"},
+ },
+ Integration: intCtx,
+ })
+ require.NoError(t, err)
+ require.Len(t, intCtx.WebhookRequests, 1)
+
+ config, ok := intCtx.WebhookRequests[0].(WebhookConfiguration)
+ require.True(t, ok)
+ assert.Equal(t, "p-1", config.ProjectID)
+ assert.Equal(t, []string{"Deployment.deployed"}, config.EventTypes)
+ })
+
+ t.Run("validation failure", func(t *testing.T) {
+ intCtx := &contexts.IntegrationContext{}
+ err := trigger.Setup(core.TriggerContext{
+ Configuration: map[string]any{
+ "project": "",
+ },
+ Integration: intCtx,
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "project is required")
+ })
+}
+
+func Test__Railway__OnDeployment__HandleWebhook(t *testing.T) {
+ trigger := &OnDeploymentEvent{}
+
+ t.Run("emits matching payload", func(t *testing.T) {
+ payload := OnDeploymentPayload{
+ Type: "Deployment.deployed",
+ ProjectID: "p-1",
+ DeploymentID: "deploy-123",
+ }
+ body, _ := json.Marshal(payload)
+
+ eventCtx := &contexts.EventContext{}
+ code, res, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Configuration: map[string]any{
+ "project": "p-1",
+ "eventTypes": []string{"Deployment.deployed"},
+ },
+ Body: body,
+ Events: eventCtx,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, code)
+ assert.Nil(t, res)
+
+ require.Len(t, eventCtx.Payloads, 1)
+ assert.Equal(t, "railway.onDeployment", eventCtx.Payloads[0].Type)
+
+ emittedPayload, ok := eventCtx.Payloads[0].Data.([]any)
+ require.True(t, ok)
+ require.Len(t, emittedPayload, 1)
+
+ var parsed OnDeploymentPayload
+ err = mapstructureDecode(emittedPayload[0], &parsed)
+ require.NoError(t, err)
+ assert.Equal(t, "Deployment.deployed", parsed.Type)
+ assert.Equal(t, "p-1", parsed.ProjectID)
+ })
+
+ t.Run("ignores mismatched projectID", func(t *testing.T) {
+ payload := OnDeploymentPayload{
+ Type: "Deployment.deployed",
+ ProjectID: "other-project",
+ DeploymentID: "deploy-123",
+ }
+ body, _ := json.Marshal(payload)
+
+ eventCtx := &contexts.EventContext{}
+ code, _, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Configuration: map[string]any{
+ "project": "p-1",
+ "eventTypes": []string{"Deployment.deployed"},
+ },
+ Body: body,
+ Events: eventCtx,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, code)
+ assert.Len(t, eventCtx.Payloads, 0)
+ })
+
+ t.Run("emits Railway nested resource payload", func(t *testing.T) {
+ body := []byte(`{
+ "type": "Deployment.deployed",
+ "details": { "status": "SUCCESS" },
+ "resource": {
+ "project": { "id": "p-1" },
+ "environment": { "id": "e-1" },
+ "service": { "id": "s-1" },
+ "deployment": { "id": "deploy-123" }
+ },
+ "timestamp": "2025-11-21T23:48:42.311Z"
+ }`)
+
+ eventCtx := &contexts.EventContext{}
+ code, res, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Configuration: map[string]any{
+ "project": "p-1",
+ "eventTypes": []string{"Deployment.deployed"},
+ },
+ Body: body,
+ Events: eventCtx,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, code)
+ assert.Nil(t, res)
+ require.Len(t, eventCtx.Payloads, 1)
+
+ emittedPayload, ok := eventCtx.Payloads[0].Data.([]any)
+ require.True(t, ok)
+ var parsed OnDeploymentPayload
+ err = mapstructureDecode(emittedPayload[0], &parsed)
+ require.NoError(t, err)
+ assert.Equal(t, "p-1", parsed.ProjectID)
+ assert.Equal(t, "deploy-123", parsed.DeploymentID)
+ assert.Equal(t, "SUCCESS", parsed.Status)
+ })
+
+ t.Run("ignores mismatched eventType", func(t *testing.T) {
+ payload := OnDeploymentPayload{
+ Type: "Deployment.failed",
+ ProjectID: "p-1",
+ DeploymentID: "deploy-123",
+ }
+ body, _ := json.Marshal(payload)
+
+ eventCtx := &contexts.EventContext{}
+ code, _, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Configuration: map[string]any{
+ "project": "p-1",
+ "eventTypes": []string{"Deployment.deployed"},
+ },
+ Body: body,
+ Events: eventCtx,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, code)
+ assert.Len(t, eventCtx.Payloads, 0)
+ })
+}
+
+func mapstructureDecode(input any, output any) error {
+ var config = &mapstructure.DecoderConfig{
+ TagName: "json",
+ Result: output,
+ }
+ decoder, err := mapstructure.NewDecoder(config)
+ if err != nil {
+ return err
+ }
+ return decoder.Decode(input)
+}
diff --git a/pkg/integrations/railway/webhook_handler.go b/pkg/integrations/railway/webhook_handler.go
new file mode 100644
index 0000000000..0893c728ce
--- /dev/null
+++ b/pkg/integrations/railway/webhook_handler.go
@@ -0,0 +1,147 @@
+package railway
+
+import (
+ "fmt"
+ "slices"
+ "strings"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+type RailwayWebhookHandler struct{}
+
+type WebhookConfiguration struct {
+ ProjectID string `json:"projectId" mapstructure:"projectId"`
+ EventTypes []string `json:"eventTypes" mapstructure:"eventTypes"`
+}
+
+type WebhookMetadata struct {
+ RuleID string `json:"ruleId" mapstructure:"ruleId"`
+ WorkspaceID string `json:"workspaceId" mapstructure:"workspaceId"`
+}
+
+func decodeWebhookConfiguration(value any) (WebhookConfiguration, error) {
+ config := WebhookConfiguration{}
+ if err := mapstructure.Decode(value, &config); err != nil {
+ return WebhookConfiguration{}, err
+ }
+ return config, nil
+}
+
+func decodeWebhookMetadata(value any) (WebhookMetadata, error) {
+ metadata := WebhookMetadata{}
+ if err := mapstructure.Decode(value, &metadata); err != nil {
+ return WebhookMetadata{}, err
+ }
+ return metadata, nil
+}
+
+func (h *RailwayWebhookHandler) CompareConfig(a, b any) (bool, error) {
+ configA, err := decodeWebhookConfiguration(a)
+ if err != nil {
+ return false, fmt.Errorf("failed to decode webhook configuration A: %w", err)
+ }
+
+ configB, err := decodeWebhookConfiguration(b)
+ if err != nil {
+ return false, fmt.Errorf("failed to decode webhook configuration B: %w", err)
+ }
+
+ return configA.ProjectID == configB.ProjectID, nil
+}
+
+func (h *RailwayWebhookHandler) Merge(current, requested any) (any, bool, error) {
+ currentConfig, err := decodeWebhookConfiguration(current)
+ if err != nil {
+ return nil, false, fmt.Errorf("failed to decode current webhook configuration: %w", err)
+ }
+
+ requestedConfig, err := decodeWebhookConfiguration(requested)
+ if err != nil {
+ return nil, false, fmt.Errorf("failed to decode requested webhook configuration: %w", err)
+ }
+
+ if currentConfig.ProjectID != requestedConfig.ProjectID {
+ return currentConfig, false, nil
+ }
+
+ mergedEventTypes := currentConfig.EventTypes
+ for _, eventType := range requestedConfig.EventTypes {
+ if !slices.Contains(mergedEventTypes, eventType) {
+ mergedEventTypes = append(mergedEventTypes, eventType)
+ }
+ }
+
+ changed := len(mergedEventTypes) > len(currentConfig.EventTypes)
+ mergedConfig := currentConfig
+ mergedConfig.EventTypes = mergedEventTypes
+
+ return mergedConfig, changed, nil
+}
+
+func (h *RailwayWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) {
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return nil, err
+ }
+
+ config, err := decodeWebhookConfiguration(ctx.Webhook.GetConfiguration())
+ if err != nil {
+ return nil, err
+ }
+
+ project, err := client.GetProjectDetails(config.ProjectID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load Railway project %q: %w", config.ProjectID, err)
+ }
+
+ workspaceID := strings.TrimSpace(project.WorkspaceID)
+ if workspaceID == "" {
+ return nil, fmt.Errorf("project %q has no workspaceId", config.ProjectID)
+ }
+
+ webhookURL := ctx.Webhook.GetURL()
+ if webhookURL == "" {
+ return nil, fmt.Errorf("webhook URL is required")
+ }
+
+ rule, err := client.CreateNotificationRule(workspaceID, config.ProjectID, config.EventTypes, webhookURL)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create Railway notification rule: %w", err)
+ }
+
+ return WebhookMetadata{
+ RuleID: rule.ID,
+ WorkspaceID: workspaceID,
+ }, nil
+}
+
+func (h *RailwayWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error {
+ metadata, err := decodeWebhookMetadata(ctx.Webhook.GetMetadata())
+ if err != nil {
+ return fmt.Errorf("failed to decode webhook metadata: %w", err)
+ }
+
+ if metadata.RuleID == "" {
+ return nil
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return err
+ }
+
+ err = client.DeleteNotificationRule(metadata.RuleID)
+ if err != nil {
+ // Log warning and ignore "Not Authorized" or typical token permissions errors
+ // to prevent blocking node/integration deletion in SuperPlane.
+ ctx.Logger.Warnf("Failed to delete Railway notification rule %q: %v", metadata.RuleID, err)
+ if strings.Contains(strings.ToLower(err.Error()), "not authorized") || strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
+ return nil
+ }
+ return err
+ }
+
+ return nil
+}
diff --git a/pkg/integrations/railway/webhook_handler_test.go b/pkg/integrations/railway/webhook_handler_test.go
new file mode 100644
index 0000000000..0b50dfb6ff
--- /dev/null
+++ b/pkg/integrations/railway/webhook_handler_test.go
@@ -0,0 +1,183 @@
+package railway
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+ "github.com/superplanehq/superplane/test/support/logger"
+)
+
+type integrationWebhookContext struct {
+ id string
+ url string
+ configuration any
+ metadata any
+ secret []byte
+}
+
+func (w *integrationWebhookContext) GetID() string { return w.id }
+func (w *integrationWebhookContext) GetURL() string { return w.url }
+func (w *integrationWebhookContext) GetSecret() ([]byte, error) { return w.secret, nil }
+func (w *integrationWebhookContext) GetMetadata() any { return w.metadata }
+func (w *integrationWebhookContext) GetConfiguration() any { return w.configuration }
+func (w *integrationWebhookContext) SetSecret(secret []byte) error {
+ w.secret = secret
+ return nil
+}
+
+func Test__Railway__WebhookHandler__CompareConfig(t *testing.T) {
+ h := &RailwayWebhookHandler{}
+
+ equal, err := h.CompareConfig(
+ WebhookConfiguration{ProjectID: "p-1"},
+ WebhookConfiguration{ProjectID: "p-1"},
+ )
+ require.NoError(t, err)
+ assert.True(t, equal)
+
+ equal, err = h.CompareConfig(
+ WebhookConfiguration{ProjectID: "p-1"},
+ WebhookConfiguration{ProjectID: "p-2"},
+ )
+ require.NoError(t, err)
+ assert.False(t, equal)
+}
+
+func Test__Railway__WebhookHandler__Merge(t *testing.T) {
+ h := &RailwayWebhookHandler{}
+
+ merged, changed, err := h.Merge(
+ WebhookConfiguration{ProjectID: "p-1", EventTypes: []string{"Deployment.deployed"}},
+ WebhookConfiguration{ProjectID: "p-1", EventTypes: []string{"Deployment.failed"}},
+ )
+ require.NoError(t, err)
+ assert.True(t, changed)
+
+ expected := WebhookConfiguration{
+ ProjectID: "p-1",
+ EventTypes: []string{"Deployment.deployed", "Deployment.failed"},
+ }
+ assert.Equal(t, expected, merged)
+}
+
+func Test__Railway__WebhookHandler__Setup(t *testing.T) {
+ h := &RailwayWebhookHandler{}
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"project":{"id":"p-1","name":"Project 1","workspaceId":"w-2"}}}`)),
+ },
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"notificationRuleCreate":{"id":"rule-123"}}}`)),
+ },
+ },
+ }
+
+ webhookCtx := &integrationWebhookContext{
+ id: "wh-123",
+ url: "https://hook.superplane.dev",
+ configuration: WebhookConfiguration{ProjectID: "p-1", EventTypes: []string{"Deployment.deployed"}},
+ }
+
+ intCtx := &contexts.IntegrationContext{NewSetupFlow: true}
+ _ = intCtx.SetSecret("apiToken", []byte("test-token"))
+
+ metadata, err := h.Setup(core.WebhookHandlerContext{
+ HTTP: httpCtx,
+ Webhook: webhookCtx,
+ Integration: intCtx,
+ })
+ require.NoError(t, err)
+
+ storedMetadata, ok := metadata.(WebhookMetadata)
+ require.True(t, ok)
+ assert.Equal(t, "rule-123", storedMetadata.RuleID)
+ assert.Equal(t, "w-2", storedMetadata.WorkspaceID)
+ require.Len(t, httpCtx.Requests, 2)
+}
+
+func Test__Railway__WebhookHandler__Setup_missingWorkspaceId(t *testing.T) {
+ h := &RailwayWebhookHandler{}
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"project":{"id":"p-1","name":"Project 1"}}}`)),
+ },
+ },
+ }
+
+ intCtx := &contexts.IntegrationContext{NewSetupFlow: true}
+ _ = intCtx.SetSecret("apiToken", []byte("test-token"))
+
+ _, err := h.Setup(core.WebhookHandlerContext{
+ HTTP: httpCtx,
+ Webhook: &integrationWebhookContext{configuration: WebhookConfiguration{ProjectID: "p-1"}},
+ Integration: intCtx,
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no workspaceId")
+}
+
+func Test__Railway__WebhookHandler__Cleanup(t *testing.T) {
+ h := &RailwayWebhookHandler{}
+ logger := logger.DiscardLogger()
+
+ t.Run("success", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"notificationRuleDelete":true}}`)),
+ },
+ },
+ }
+
+ intCtx := &contexts.IntegrationContext{NewSetupFlow: true}
+ _ = intCtx.SetSecret("apiToken", []byte("test-token"))
+
+ err := h.Cleanup(core.WebhookHandlerContext{
+ HTTP: httpCtx,
+ Logger: logger,
+ Webhook: &integrationWebhookContext{
+ metadata: WebhookMetadata{RuleID: "rule-123", WorkspaceID: "w-1"},
+ },
+ Integration: intCtx,
+ })
+ require.NoError(t, err)
+ })
+
+ t.Run("ignores not authorized error", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"errors":[{"message":"Not Authorized"}]}`)),
+ },
+ },
+ }
+
+ intCtx := &contexts.IntegrationContext{NewSetupFlow: true}
+ _ = intCtx.SetSecret("apiToken", []byte("test-token"))
+
+ err := h.Cleanup(core.WebhookHandlerContext{
+ HTTP: httpCtx,
+ Logger: logger,
+ Webhook: &integrationWebhookContext{
+ metadata: WebhookMetadata{RuleID: "rule-123", WorkspaceID: "w-1"},
+ },
+ Integration: intCtx,
+ })
+ require.NoError(t, err) // Should succeed because Not Authorized is caught and handled
+ })
+}
From 1be9ce80f3937b7c418033d6e09286a6c785ab53 Mon Sep 17 00:00:00 2001
From: Shang En Sim <6071014+12458@users.noreply.github.com>
Date: Sat, 30 May 2026 13:40:04 -0700
Subject: [PATCH 4/7] feat(railway): implement redeployment action with status
polling
Signed-off-by: Shang En Sim <6071014+12458@users.noreply.github.com>
---
pkg/integrations/railway/trigger_deploy.go | 300 ++++++++++++++++++
.../railway/trigger_deploy_test.go | 229 +++++++++++++
2 files changed, 529 insertions(+)
create mode 100644 pkg/integrations/railway/trigger_deploy.go
create mode 100644 pkg/integrations/railway/trigger_deploy_test.go
diff --git a/pkg/integrations/railway/trigger_deploy.go b/pkg/integrations/railway/trigger_deploy.go
new file mode 100644
index 0000000000..8983b605b5
--- /dev/null
+++ b/pkg/integrations/railway/trigger_deploy.go
@@ -0,0 +1,300 @@
+package railway
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+const (
+ TriggerDeployPayloadType = "railway.deploy.finished"
+ TriggerDeploySuccessOutputChannel = "success"
+ TriggerDeployFailedOutputChannel = "failed"
+ TriggerDeployPollInterval = 15 * time.Second
+ deployExecutionKey = "deploy_id"
+)
+
+type TriggerDeploy struct{}
+
+type TriggerDeployConfiguration struct {
+ Project string `json:"project" mapstructure:"project"`
+ Service string `json:"service" mapstructure:"service"`
+ Environment string `json:"environment" mapstructure:"environment"`
+}
+
+type TriggerDeployExecutionMetadata struct {
+ Deploy *TriggerDeployMetadata `json:"deploy" mapstructure:"deploy"`
+}
+
+type TriggerDeployMetadata struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ ProjectID string `json:"projectId"`
+ ServiceID string `json:"serviceId"`
+ Environment string `json:"environmentId"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+func (c *TriggerDeploy) Name() string {
+ return "railway.triggerDeploy"
+}
+
+func (c *TriggerDeploy) Label() string {
+ return "Trigger Deploy"
+}
+
+func (c *TriggerDeploy) Description() string {
+ return "Trigger a deploy for a Railway service and wait for it to complete"
+}
+
+func (c *TriggerDeploy) Documentation() string {
+ return `The Trigger Deploy action starts a new deploy for a Railway service and waits for it to complete.
+
+## Configuration
+
+- **Project**: The Railway project containing the service.
+- **Service**: The service to deploy.
+- **Environment**: The target environment.
+
+## Output Channels
+
+- **Success**: Emitted when the deploy completes successfully.
+- **Failed**: Emitted when the deploy fails or is cancelled.`
+}
+
+func (c *TriggerDeploy) Icon() string {
+ return "railway"
+}
+
+func (c *TriggerDeploy) Color() string {
+ return "gray"
+}
+
+func (c *TriggerDeploy) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{
+ {Name: TriggerDeploySuccessOutputChannel, Label: "Success"},
+ {Name: TriggerDeployFailedOutputChannel, Label: "Failed"},
+ }
+}
+
+func (c *TriggerDeploy) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "project",
+ Label: "Project",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The Railway project",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "project",
+ },
+ },
+ },
+ {
+ Name: "service",
+ Label: "Service",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The service to deploy",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "service",
+ Parameters: []configuration.ParameterRef{
+ {
+ Name: "project",
+ ValueFrom: &configuration.ParameterValueFrom{Field: "project"},
+ },
+ },
+ },
+ },
+ },
+ {
+ Name: "environment",
+ Label: "Environment",
+ Type: configuration.FieldTypeIntegrationResource,
+ Required: true,
+ Description: "The target environment",
+ TypeOptions: &configuration.TypeOptions{
+ Resource: &configuration.ResourceTypeOptions{
+ Type: "environment",
+ Parameters: []configuration.ParameterRef{
+ {
+ Name: "project",
+ ValueFrom: &configuration.ParameterValueFrom{Field: "project"},
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+func decodeTriggerDeployConfiguration(config any) (TriggerDeployConfiguration, error) {
+ spec := TriggerDeployConfiguration{}
+ if err := mapstructure.Decode(config, &spec); err != nil {
+ return TriggerDeployConfiguration{}, fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ spec.Project = strings.TrimSpace(spec.Project)
+ spec.Service = strings.TrimSpace(spec.Service)
+ spec.Environment = strings.TrimSpace(spec.Environment)
+
+ if spec.Project == "" {
+ return TriggerDeployConfiguration{}, fmt.Errorf("project is required")
+ }
+ if spec.Service == "" {
+ return TriggerDeployConfiguration{}, fmt.Errorf("service is required")
+ }
+ if spec.Environment == "" {
+ return TriggerDeployConfiguration{}, fmt.Errorf("environment is required")
+ }
+
+ return spec, nil
+}
+
+func (c *TriggerDeploy) Setup(ctx core.SetupContext) error {
+ _, err := decodeTriggerDeployConfiguration(ctx.Configuration)
+ return err
+}
+
+func (c *TriggerDeploy) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
+ return ctx.DefaultProcessing()
+}
+
+func (c *TriggerDeploy) Execute(ctx core.ExecutionContext) error {
+ spec, err := decodeTriggerDeployConfiguration(ctx.Configuration)
+ if err != nil {
+ return err
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return err
+ }
+
+ deployID, err := client.TriggerDeploy(spec.Environment, spec.Service)
+ if err != nil {
+ return err
+ }
+
+ if deployID == "" {
+ return fmt.Errorf("deploy response missing deployment ID")
+ }
+
+ err = ctx.Metadata.Set(TriggerDeployExecutionMetadata{
+ Deploy: &TriggerDeployMetadata{
+ ID: deployID,
+ Status: "QUEUED",
+ ProjectID: spec.Project,
+ ServiceID: spec.Service,
+ Environment: spec.Environment,
+ },
+ })
+ if err != nil {
+ return err
+ }
+
+ if err := ctx.ExecutionState.SetKV(deployExecutionKey, deployID); err != nil {
+ return err
+ }
+
+ return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, TriggerDeployPollInterval)
+}
+
+func (c *TriggerDeploy) Hooks() []core.Hook {
+ return []core.Hook{
+ {
+ Name: "poll",
+ Type: core.HookTypeInternal,
+ },
+ }
+}
+
+func (c *TriggerDeploy) HandleHook(ctx core.ActionHookContext) error {
+ switch ctx.Name {
+ case "poll":
+ return c.poll(ctx)
+ }
+ return fmt.Errorf("unknown hook: %s", ctx.Name)
+}
+
+func (c *TriggerDeploy) poll(ctx core.ActionHookContext) error {
+ if ctx.ExecutionState.IsFinished() {
+ return nil
+ }
+
+ metadata := TriggerDeployExecutionMetadata{}
+ if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil {
+ return fmt.Errorf("failed to decode metadata: %w", err)
+ }
+
+ if metadata.Deploy == nil || metadata.Deploy.ID == "" {
+ return nil
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return err
+ }
+
+ deploy, err := client.GetDeployment(metadata.Deploy.ID)
+ if err != nil {
+ return err
+ }
+
+ metadata.Deploy.Status = deploy.Status
+ metadata.Deploy.CreatedAt = deploy.CreatedAt
+ metadata.Deploy.UpdatedAt = deploy.UpdatedAt
+ if err := ctx.Metadata.Set(metadata); err != nil {
+ return err
+ }
+
+ // Terminal states check
+ payload := map[string]any{
+ "deployId": deploy.ID,
+ "status": deploy.Status,
+ "projectId": metadata.Deploy.ProjectID,
+ "serviceId": metadata.Deploy.ServiceID,
+ "environmentId": metadata.Deploy.Environment,
+ }
+
+ switch deploy.Status {
+ case "SUCCESS":
+ return ctx.ExecutionState.Emit(TriggerDeploySuccessOutputChannel, TriggerDeployPayloadType, []any{payload})
+ case "FAILED", "CRASHED", "REMOVED", "SKIPPED", "SLEEPING":
+ return ctx.ExecutionState.Emit(TriggerDeployFailedOutputChannel, TriggerDeployPayloadType, []any{payload})
+ default:
+ // Not finished yet, schedule next poll
+ return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, TriggerDeployPollInterval)
+ }
+}
+
+func (c *TriggerDeploy) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) {
+ return http.StatusOK, nil, nil
+}
+
+func (c *TriggerDeploy) Cancel(ctx core.ExecutionContext) error {
+ return nil
+}
+
+func (c *TriggerDeploy) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
+
+func (c *TriggerDeploy) ExampleOutput() map[string]any {
+ return map[string]any{
+ "deployId": "ebda9796-09e4-456f-af60-d1a66dee66a0",
+ "status": "SUCCESS",
+ "projectId": "8db400fa-357e-4646-90f0-c7eb36e88a92",
+ "serviceId": "2a345678-bcde-4fgh-1234-567812345678",
+ "environmentId": "9a1d7a89-2cf4-4446-9b69-4cde850918aa",
+ }
+}
diff --git a/pkg/integrations/railway/trigger_deploy_test.go b/pkg/integrations/railway/trigger_deploy_test.go
new file mode 100644
index 0000000000..34436f3e8e
--- /dev/null
+++ b/pkg/integrations/railway/trigger_deploy_test.go
@@ -0,0 +1,229 @@
+package railway
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/mitchellh/mapstructure"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+func Test__Railway__TriggerDeploy__Setup(t *testing.T) {
+ action := &TriggerDeploy{}
+
+ t.Run("success", func(t *testing.T) {
+ err := action.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "project": "p-1",
+ "service": "s-1",
+ "environment": "e-1",
+ },
+ })
+ require.NoError(t, err)
+ })
+
+ t.Run("missing project", func(t *testing.T) {
+ err := action.Setup(core.SetupContext{
+ Configuration: map[string]any{
+ "service": "s-1",
+ "environment": "e-1",
+ },
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "project is required")
+ })
+}
+
+func Test__Railway__TriggerDeploy__Execute(t *testing.T) {
+ action := &TriggerDeploy{}
+
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"serviceInstanceDeployV2":"deploy-123"}}`)),
+ },
+ },
+ }
+
+ intCtx := &contexts.IntegrationContext{NewSetupFlow: true}
+ _ = intCtx.SetSecret("apiToken", []byte("test-token"))
+
+ execCtx := core.ExecutionContext{
+ HTTP: httpCtx,
+ Configuration: map[string]any{
+ "project": "p-1",
+ "service": "s-1",
+ "environment": "e-1",
+ },
+ Integration: intCtx,
+ Metadata: &contexts.MetadataContext{},
+ ExecutionState: &contexts.ExecutionStateContext{},
+ Requests: &contexts.RequestContext{},
+ }
+
+ err := action.Execute(execCtx)
+ require.NoError(t, err)
+
+ // Check metadata is set to QUEUED
+ metadata := TriggerDeployExecutionMetadata{}
+ err = mapstructure.Decode(execCtx.Metadata.Get(), &metadata)
+ require.NoError(t, err)
+ require.NotNil(t, metadata.Deploy)
+ assert.Equal(t, "deploy-123", metadata.Deploy.ID)
+ assert.Equal(t, "QUEUED", metadata.Deploy.Status)
+
+ // Check poll hook is scheduled
+ reqs := execCtx.Requests.(*contexts.RequestContext)
+ assert.Equal(t, "poll", reqs.Action)
+ assert.Equal(t, 15*time.Second, reqs.Duration)
+}
+
+func Test__Railway__TriggerDeploy__Poll(t *testing.T) {
+ action := &TriggerDeploy{}
+
+ t.Run("continues polling when active", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"deployment":{"id":"deploy-123","status":"BUILDING","createdAt":"","updatedAt":""}}}`)),
+ },
+ },
+ }
+
+ intCtx := &contexts.IntegrationContext{NewSetupFlow: true}
+ _ = intCtx.SetSecret("apiToken", []byte("test-token"))
+
+ metaCtx := &contexts.MetadataContext{
+ Metadata: TriggerDeployExecutionMetadata{
+ Deploy: &TriggerDeployMetadata{
+ ID: "deploy-123",
+ Status: "QUEUED",
+ ProjectID: "p-1",
+ ServiceID: "s-1",
+ Environment: "e-1",
+ },
+ },
+ }
+
+ reqsCtx := &contexts.RequestContext{}
+
+ err := action.poll(core.ActionHookContext{
+ HTTP: httpCtx,
+ Integration: intCtx,
+ Metadata: metaCtx,
+ ExecutionState: &contexts.ExecutionStateContext{},
+ Requests: reqsCtx,
+ })
+ require.NoError(t, err)
+
+ // Check metadata updated to BUILDING
+ metadata := TriggerDeployExecutionMetadata{}
+ err = mapstructure.Decode(metaCtx.Get(), &metadata)
+ require.NoError(t, err)
+ assert.Equal(t, "BUILDING", metadata.Deploy.Status)
+
+ // Check next poll is scheduled
+ assert.Equal(t, "poll", reqsCtx.Action)
+ assert.Equal(t, 15*time.Second, reqsCtx.Duration)
+ })
+
+ t.Run("emits success on SUCCESS status", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"deployment":{"id":"deploy-123","status":"SUCCESS","createdAt":"","updatedAt":""}}}`)),
+ },
+ },
+ }
+
+ intCtx := &contexts.IntegrationContext{NewSetupFlow: true}
+ _ = intCtx.SetSecret("apiToken", []byte("test-token"))
+
+ metaCtx := &contexts.MetadataContext{
+ Metadata: TriggerDeployExecutionMetadata{
+ Deploy: &TriggerDeployMetadata{
+ ID: "deploy-123",
+ Status: "BUILDING",
+ ProjectID: "p-1",
+ ServiceID: "s-1",
+ Environment: "e-1",
+ },
+ },
+ }
+
+ stateCtx := &contexts.ExecutionStateContext{}
+ reqsCtx := &contexts.RequestContext{}
+
+ err := action.poll(core.ActionHookContext{
+ HTTP: httpCtx,
+ Integration: intCtx,
+ Metadata: metaCtx,
+ ExecutionState: stateCtx,
+ Requests: reqsCtx,
+ })
+ require.NoError(t, err)
+
+ // Check emitted event
+ assert.Equal(t, "success", stateCtx.Channel)
+ assert.Equal(t, "railway.deploy.finished", stateCtx.Type)
+ require.Len(t, stateCtx.Payloads, 1)
+
+ // No further polls scheduled
+ assert.Equal(t, "", reqsCtx.Action)
+ })
+
+ t.Run("emits failed on FAILED status", func(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"deployment":{"id":"deploy-123","status":"FAILED","createdAt":"","updatedAt":""}}}`)),
+ },
+ },
+ }
+
+ intCtx := &contexts.IntegrationContext{NewSetupFlow: true}
+ _ = intCtx.SetSecret("apiToken", []byte("test-token"))
+
+ metaCtx := &contexts.MetadataContext{
+ Metadata: TriggerDeployExecutionMetadata{
+ Deploy: &TriggerDeployMetadata{
+ ID: "deploy-123",
+ Status: "BUILDING",
+ ProjectID: "p-1",
+ ServiceID: "s-1",
+ Environment: "e-1",
+ },
+ },
+ }
+
+ stateCtx := &contexts.ExecutionStateContext{}
+ reqsCtx := &contexts.RequestContext{}
+
+ err := action.poll(core.ActionHookContext{
+ HTTP: httpCtx,
+ Integration: intCtx,
+ Metadata: metaCtx,
+ ExecutionState: stateCtx,
+ Requests: reqsCtx,
+ })
+ require.NoError(t, err)
+
+ // Check emitted event
+ assert.Equal(t, "failed", stateCtx.Channel)
+ assert.Equal(t, "railway.deploy.finished", stateCtx.Type)
+ require.Len(t, stateCtx.Payloads, 1)
+
+ // No further polls scheduled
+ assert.Equal(t, "", reqsCtx.Action)
+ })
+}
From 829b91a885c0735e2b446225534acb33031b0bdc Mon Sep 17 00:00:00 2001
From: Shang En Sim <6071014+12458@users.noreply.github.com>
Date: Sat, 30 May 2026 13:40:10 -0700
Subject: [PATCH 5/7] feat(railway): add branding icon and register integration
mappers
Signed-off-by: Shang En Sim <6071014+12458@users.noreply.github.com>
---
.../src/assets/icons/integrations/railway.svg | 1 +
web_src/src/pages/workflowv2/mappers/index.ts | 8 +
.../pages/workflowv2/mappers/railway/base.ts | 46 +++++
.../pages/workflowv2/mappers/railway/index.ts | 15 ++
.../mappers/railway/on_deployment.ts | 129 +++++++++++++
.../mappers/railway/trigger_deploy.ts | 172 ++++++++++++++++++
.../componentSidebar/integrationIconMaps.ts | 3 +
7 files changed, 374 insertions(+)
create mode 100644 web_src/src/assets/icons/integrations/railway.svg
create mode 100644 web_src/src/pages/workflowv2/mappers/railway/base.ts
create mode 100644 web_src/src/pages/workflowv2/mappers/railway/index.ts
create mode 100644 web_src/src/pages/workflowv2/mappers/railway/on_deployment.ts
create mode 100644 web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts
diff --git a/web_src/src/assets/icons/integrations/railway.svg b/web_src/src/assets/icons/integrations/railway.svg
new file mode 100644
index 0000000000..619ce5071f
--- /dev/null
+++ b/web_src/src/assets/icons/integrations/railway.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts
index d346a40de5..c1765b8ecf 100644
--- a/web_src/src/pages/workflowv2/mappers/index.ts
+++ b/web_src/src/pages/workflowv2/mappers/index.ts
@@ -90,6 +90,11 @@ import {
triggerRenderers as renderTriggerRenderers,
eventStateRegistry as renderEventStateRegistry,
} from "./render";
+import {
+ componentMappers as railwayComponentMappers,
+ triggerRenderers as railwayTriggerRenderers,
+ eventStateRegistry as railwayEventStateRegistry,
+} from "./railway";
import {
componentMappers as rootlyComponentMappers,
triggerRenderers as rootlyTriggerRenderers,
@@ -307,6 +312,7 @@ const appMappers: Record> = {
sendgrid: sendgridComponentMappers,
sentry: sentryComponentMappers,
render: renderComponentMappers,
+ railway: railwayComponentMappers,
rootly: rootlyComponentMappers,
incident: incidentComponentMappers,
newrelic: newrelicComponentMappers,
@@ -353,6 +359,7 @@ const appTriggerRenderers: Record> = {
sendgrid: sendgridTriggerRenderers,
sentry: sentryTriggerRenderers,
render: renderTriggerRenderers,
+ railway: railwayTriggerRenderers,
rootly: rootlyTriggerRenderers,
incident: incidentTriggerRenderers,
newrelic: newrelicTriggerRenderers,
@@ -398,6 +405,7 @@ const appEventStateRegistries: Record
sendgrid: sendgridEventStateRegistry,
sentry: sentryEventStateRegistry,
render: renderEventStateRegistry,
+ railway: railwayEventStateRegistry,
discord: discordEventStateRegistry,
telegram: telegramEventStateRegistry,
teams: teamsEventStateRegistry,
diff --git a/web_src/src/pages/workflowv2/mappers/railway/base.ts b/web_src/src/pages/workflowv2/mappers/railway/base.ts
new file mode 100644
index 0000000000..aac9f52d2b
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/railway/base.ts
@@ -0,0 +1,46 @@
+import type { ComponentBaseProps, EventSection } from "@/ui/componentBase";
+import { getBackgroundColorClass, getColorClass } from "@/lib/colors";
+import { renderTimeAgo } from "@/components/TimeAgo";
+import railwayIcon from "@/assets/icons/integrations/railway.svg";
+import { getState, getStateMap, getTriggerRenderer } from "..";
+import type { ComponentDefinition, ExecutionInfo, NodeInfo } from "../types";
+
+export function baseProps(
+ nodes: NodeInfo[],
+ node: NodeInfo,
+ componentDefinition: ComponentDefinition,
+ lastExecutions: ExecutionInfo[],
+): ComponentBaseProps {
+ const lastExecution = lastExecutions.length > 0 ? lastExecutions[0] : null;
+ const componentName = componentDefinition.name || node.componentName || "railway.unknown";
+
+ return {
+ title: node.name || componentDefinition.label || componentDefinition.name || "Unnamed component",
+ iconSrc: railwayIcon,
+ iconColor: getColorClass(componentDefinition.color),
+ collapsedBackground: getBackgroundColorClass(componentDefinition.color),
+ collapsed: node.isCollapsed,
+ eventSections: lastExecution ? baseEventSections(nodes, lastExecution, componentName) : undefined,
+ includeEmptyState: !lastExecution,
+ eventStateMap: getStateMap(componentName),
+ };
+}
+
+function baseEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] {
+ const rootTriggerNode = nodes.find((node) => node.id === execution.rootEvent?.nodeId);
+ const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName || "");
+ const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent });
+
+ const subtitleTimestamp = execution.updatedAt || execution.createdAt;
+ const eventSubtitle = subtitleTimestamp ? renderTimeAgo(new Date(subtitleTimestamp)) : "";
+
+ return [
+ {
+ receivedAt: new Date(execution.createdAt!),
+ eventTitle: title,
+ eventSubtitle,
+ eventState: getState(componentName)(execution),
+ eventId: execution.rootEvent!.id!,
+ },
+ ];
+}
diff --git a/web_src/src/pages/workflowv2/mappers/railway/index.ts b/web_src/src/pages/workflowv2/mappers/railway/index.ts
new file mode 100644
index 0000000000..853c9fab6a
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/railway/index.ts
@@ -0,0 +1,15 @@
+import type { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../types";
+import { triggerDeployMapper, DEPLOY_STATE_REGISTRY } from "./trigger_deploy";
+import { onDeploymentTriggerRenderer } from "./on_deployment";
+
+export const componentMappers: Record = {
+ triggerDeploy: triggerDeployMapper,
+};
+
+export const triggerRenderers: Record = {
+ onDeployment: onDeploymentTriggerRenderer,
+};
+
+export const eventStateRegistry: Record = {
+ triggerDeploy: DEPLOY_STATE_REGISTRY,
+};
diff --git a/web_src/src/pages/workflowv2/mappers/railway/on_deployment.ts b/web_src/src/pages/workflowv2/mappers/railway/on_deployment.ts
new file mode 100644
index 0000000000..f6a6870c17
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/railway/on_deployment.ts
@@ -0,0 +1,129 @@
+import type { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types";
+import type React from "react";
+import type { TriggerProps } from "@/ui/trigger";
+import { getBackgroundColorClass, getColorClass } from "@/lib/colors";
+import { renderTimeAgo } from "@/components/TimeAgo";
+import railwayIcon from "@/assets/icons/integrations/railway.svg";
+
+interface RailwayEventData {
+ type?: string;
+ projectId?: string;
+ environmentId?: string;
+ serviceId?: string;
+ deploymentId?: string;
+ status?: string;
+ timestamp?: string;
+}
+
+interface OnDeploymentConfiguration {
+ project?: string;
+ eventTypes?: string[];
+}
+
+const eventLabels: Record = {
+ "Deployment.deployed": "Deployed",
+ "Deployment.failed": "Failed",
+ "Deployment.crashed": "Crashed",
+ "Deployment.redeployed": "Redeployed",
+ "Deployment.building": "Building",
+};
+
+function formatEventLabel(event?: string): string {
+ if (!event) {
+ return "Deployment Event";
+ }
+ return eventLabels[event] || event;
+}
+
+function stringOrDash(value?: unknown): string {
+ if (value === undefined || value === null || value === "") {
+ return "-";
+ }
+ return String(value);
+}
+
+function formatTimestamp(value?: string, fallback?: string): string {
+ const timestamp = value || fallback;
+ if (!timestamp) {
+ return "-";
+ }
+ const date = new Date(timestamp);
+ if (Number.isNaN(date.getTime())) {
+ return "-";
+ }
+ return date.toLocaleString();
+}
+
+export const onDeploymentTriggerRenderer: TriggerRenderer = {
+ getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string | React.ReactNode } => {
+ const event = context.event?.data as RailwayEventData | undefined;
+ const projectLabel = event?.projectId || "Project";
+ const statusLabel = formatEventLabel(context.event?.type || event?.type);
+ const title = `${projectLabel} · ${statusLabel}`;
+
+ return {
+ title,
+ subtitle: context.event?.createdAt ? renderTimeAgo(new Date(context.event.createdAt)) : "",
+ };
+ },
+
+ getRootEventValues: (context: TriggerEventContext): Record => {
+ const event = context.event?.data as RailwayEventData | undefined;
+ return {
+ "Received At": formatTimestamp(context.event?.createdAt),
+ "Event Type": stringOrDash(context.event?.type || event?.type),
+ "Project ID": stringOrDash(event?.projectId),
+ "Environment ID": stringOrDash(event?.environmentId),
+ "Service ID": stringOrDash(event?.serviceId),
+ "Deployment ID": stringOrDash(event?.deploymentId),
+ Status: stringOrDash(event?.status),
+ };
+ },
+
+ getTriggerProps: (context: TriggerRendererContext) => {
+ const { node, definition, lastEvent } = context;
+ const configuration = node.configuration as OnDeploymentConfiguration | undefined;
+
+ const metadata: TriggerProps["metadata"] = [];
+ if (configuration?.project) {
+ metadata.push({
+ icon: "folder",
+ label: `Project: ${configuration.project}`,
+ });
+ }
+
+ if (configuration?.eventTypes && configuration.eventTypes.length > 0) {
+ const formattedEvents = configuration.eventTypes.map(formatEventLabel);
+ metadata.push({
+ icon: "funnel",
+ label:
+ formattedEvents.length > 2
+ ? `Events: ${formattedEvents.length} selected`
+ : `Events: ${formattedEvents.join(", ")}`,
+ });
+ }
+
+ const props: TriggerProps = {
+ title: node.name || definition.label || "Unnamed trigger",
+ iconSrc: railwayIcon,
+ iconColor: getColorClass(definition.color),
+ collapsedBackground: getBackgroundColorClass(definition.color),
+ metadata,
+ };
+
+ if (lastEvent) {
+ const event = lastEvent.data as RailwayEventData | undefined;
+ const projectLabel = event?.projectId || "Project";
+ const statusLabel = formatEventLabel(lastEvent.type || event?.type);
+ props.lastEventData = {
+ title: `${projectLabel} · ${statusLabel}`,
+ subtitle: lastEvent.createdAt ? renderTimeAgo(new Date(lastEvent.createdAt)) : "",
+ receivedAt: new Date(lastEvent.createdAt),
+ state: "triggered",
+ eventId: lastEvent.id,
+ };
+ }
+
+ return props;
+ },
+};
diff --git a/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts b/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts
new file mode 100644
index 0000000000..9c468014a1
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts
@@ -0,0 +1,172 @@
+import type {
+ ComponentBaseContext,
+ ComponentBaseMapper,
+ EventStateRegistry,
+ ExecutionDetailsContext,
+ ExecutionInfo,
+ NodeInfo,
+ OutputPayload,
+ StateFunction,
+ SubtitleContext,
+} from "../types";
+import type { ComponentBaseProps, EventSection, EventStateMap } from "@/ui/componentBase";
+import { DEFAULT_EVENT_STATE_MAP } from "@/ui/componentBase";
+import type React from "react";
+import { getBackgroundColorClass, getColorClass } from "@/lib/colors";
+import { getState, getTriggerRenderer } from "..";
+import type { MetadataItem } from "@/ui/metadataList";
+import { renderTimeAgo } from "@/components/TimeAgo";
+import railwayIcon from "@/assets/icons/integrations/railway.svg";
+import { defaultStateFunction } from "../stateRegistry";
+
+interface TriggerDeployConfiguration {
+ project?: string;
+ service?: string;
+ environment?: string;
+}
+
+interface TriggerDeployOutput {
+ deployId?: string;
+ status?: string;
+ projectId?: string;
+ serviceId?: string;
+ environmentId?: string;
+}
+
+export const DEPLOY_STATE_MAP: EventStateMap = {
+ ...DEFAULT_EVENT_STATE_MAP,
+ failed: {
+ icon: "circle-x",
+ textColor: "text-gray-800",
+ backgroundColor: "bg-red-100",
+ badgeColor: "bg-red-500",
+ },
+ crashed: {
+ icon: "circle-x",
+ textColor: "text-gray-800",
+ backgroundColor: "bg-red-100",
+ badgeColor: "bg-red-500",
+ },
+ removed: {
+ icon: "trash-2",
+ textColor: "text-gray-800",
+ backgroundColor: "bg-gray-100",
+ badgeColor: "bg-gray-500",
+ },
+ skipped: {
+ icon: "circle-slash-2",
+ textColor: "text-gray-800",
+ backgroundColor: "bg-gray-100",
+ badgeColor: "bg-gray-500",
+ },
+};
+
+export const deployStateFunction: StateFunction = (execution) => {
+ if (!execution) return "neutral";
+
+ const outputs = execution.outputs as { failed?: OutputPayload[]; success?: OutputPayload[] } | undefined;
+ if (outputs?.failed?.length) {
+ const failedOutput = outputs.failed[0]?.data as TriggerDeployOutput | undefined;
+ const status = failedOutput?.status?.toLowerCase();
+ if (status === "crashed") return "crashed";
+ if (status === "removed") return "removed";
+ if (status === "skipped") return "skipped";
+ return "failed";
+ }
+
+ if (outputs?.success?.length) {
+ return "success";
+ }
+
+ return defaultStateFunction(execution);
+};
+
+export const DEPLOY_STATE_REGISTRY: EventStateRegistry = {
+ stateMap: DEPLOY_STATE_MAP,
+ getState: deployStateFunction,
+};
+
+function stringOrDash(value?: unknown): string {
+ if (value === undefined || value === null || value === "") {
+ return "-";
+ }
+ return String(value);
+}
+
+export const triggerDeployMapper: ComponentBaseMapper = {
+ props(context: ComponentBaseContext): ComponentBaseProps {
+ const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null;
+ const componentName = context.componentDefinition.name || context.node.componentName || "unknown";
+
+ return {
+ title:
+ context.node.name ||
+ context.componentDefinition.label ||
+ context.componentDefinition.name ||
+ "Unnamed component",
+ iconSrc: railwayIcon,
+ iconColor: getColorClass(context.componentDefinition.color),
+ collapsedBackground: getBackgroundColorClass(context.componentDefinition.color),
+ collapsed: context.node.isCollapsed,
+ eventSections: lastExecution ? deployEventSections(context.nodes, lastExecution, componentName) : undefined,
+ includeEmptyState: !lastExecution,
+ metadata: deployMetadataList(context.node),
+ eventStateMap: DEPLOY_STATE_MAP,
+ };
+ },
+
+ getExecutionDetails(context: ExecutionDetailsContext): Record {
+ const outputs = context.execution.outputs as { success?: OutputPayload[]; failed?: OutputPayload[] } | undefined;
+ const result =
+ (outputs?.success?.[0]?.data as TriggerDeployOutput | undefined) ??
+ (outputs?.failed?.[0]?.data as TriggerDeployOutput | undefined);
+
+ return {
+ "Deploy ID": stringOrDash(result?.deployId),
+ Status: stringOrDash(result?.status),
+ "Project ID": stringOrDash(result?.projectId),
+ "Service ID": stringOrDash(result?.serviceId),
+ "Environment ID": stringOrDash(result?.environmentId),
+ };
+ },
+
+ subtitle(context: SubtitleContext): string | React.ReactNode {
+ if (!context.execution.createdAt) return "";
+ return renderTimeAgo(new Date(context.execution.createdAt));
+ },
+};
+
+function deployMetadataList(node: NodeInfo): MetadataItem[] {
+ const metadata: MetadataItem[] = [];
+ const configuration = node.configuration as TriggerDeployConfiguration | undefined;
+
+ if (configuration?.project) {
+ metadata.push({ icon: "folder", label: `Project: ${configuration.project}` });
+ }
+
+ if (configuration?.service) {
+ metadata.push({ icon: "server", label: `Service: ${configuration.service}` });
+ }
+
+ if (configuration?.environment) {
+ metadata.push({ icon: "globe", label: `Environment: ${configuration.environment}` });
+ }
+
+ return metadata;
+}
+
+function deployEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] {
+ const rootTriggerNode = nodes.find((node) => node.id === execution.rootEvent?.nodeId);
+ const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName || "");
+ const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent });
+
+ return [
+ {
+ receivedAt: new Date(execution.createdAt!),
+ eventTitle: title,
+ eventSubtitle: renderTimeAgo(new Date(execution.createdAt!)),
+ eventState: getState(componentName)(execution),
+ eventId: execution.rootEvent!.id!,
+ },
+ ];
+}
diff --git a/web_src/src/ui/componentSidebar/integrationIconMaps.ts b/web_src/src/ui/componentSidebar/integrationIconMaps.ts
index a986803087..08da42c9c2 100644
--- a/web_src/src/ui/componentSidebar/integrationIconMaps.ts
+++ b/web_src/src/ui/componentSidebar/integrationIconMaps.ts
@@ -46,6 +46,7 @@ import SemaphoreLogo from "@/assets/semaphore-logo-sign-black.svg";
import sendgridIcon from "@/assets/icons/integrations/sendgrid.svg";
import prometheusIcon from "@/assets/icons/integrations/prometheus.svg";
import renderIcon from "@/assets/icons/integrations/render.svg";
+import railwayIcon from "@/assets/icons/integrations/railway.svg";
import sentryIcon from "@/assets/icons/integrations/sentry.svg";
import dockerIcon from "@/assets/icons/integrations/docker.svg";
import hetznerIcon from "@/assets/icons/integrations/hetzner.svg";
@@ -97,6 +98,7 @@ export const INTEGRATION_APP_LOGO_MAP: Record = {
sentry: sentryIcon,
prometheus: prometheusIcon,
render: renderIcon,
+ railway: railwayIcon,
dockerhub: dockerIcon,
honeycomb: honeycombIcon,
gcp: gcpIcon,
@@ -146,6 +148,7 @@ export const APP_LOGO_MAP: Record> = {
sentry: sentryIcon,
prometheus: prometheusIcon,
render: renderIcon,
+ railway: railwayIcon,
dockerhub: dockerIcon,
harness: harnessIcon,
newrelic: newrelicIcon,
From 18bdfb31bd3098134f654993acf6c9ed18b4817c Mon Sep 17 00:00:00 2001
From: Shang En Sim <6071014+12458@users.noreply.github.com>
Date: Sat, 30 May 2026 13:44:36 -0700
Subject: [PATCH 6/7] docs(railway): generate components documentation for
railway
Signed-off-by: Shang En Sim <6071014+12458@users.noreply.github.com>
---
docs/components/Railway.mdx | 83 +++++++++++++++++++++++++++++++++++++
1 file changed, 83 insertions(+)
create mode 100644 docs/components/Railway.mdx
diff --git a/docs/components/Railway.mdx b/docs/components/Railway.mdx
new file mode 100644
index 0000000000..1bc882eddf
--- /dev/null
+++ b/docs/components/Railway.mdx
@@ -0,0 +1,83 @@
+---
+title: "Railway"
+---
+
+Deploy services and react to deployment events on Railway
+
+import { CardGrid, LinkCard } from "@astrojs/starlight/components";
+
+## Triggers
+
+
+
+
+
+## Actions
+
+
+
+
+
+
+
+## On Deployment Event
+
+**Trigger key:** `railway.onDeployment`
+
+The On Deployment Event trigger fires when a deployment changes status in the specified Railway project.
+
+### Use Cases
+
+- **Slack Notification**: Send notifications when build/deploy fails or succeeds.
+- **Auto-verification**: Run integration test workflows on successful deploys.
+
+### Configuration
+
+- **Project**: The Railway project to watch.
+- **Event Types**: Deployment statuses to listen for (defaults to Deployed and Failed).
+
+### Example Data
+
+```json
+{
+ "deploymentId": "ebda9796-09e4-456f-af60-d1a66dee66a0",
+ "environmentId": "9a1d7a89-2cf4-4446-9b69-4cde850918aa",
+ "projectId": "8db400fa-357e-4646-90f0-c7eb36e88a92",
+ "serviceId": "2a345678-bcde-4fgh-1234-567812345678",
+ "status": "SUCCESS",
+ "timestamp": "2026-05-30T19:46:09.816Z",
+ "type": "Deployment.deployed"
+}
+```
+
+
+
+## Trigger Deploy
+
+**Component key:** `railway.triggerDeploy`
+
+The Trigger Deploy action starts a new deploy for a Railway service and waits for it to complete.
+
+### Configuration
+
+- **Project**: The Railway project containing the service.
+- **Service**: The service to deploy.
+- **Environment**: The target environment.
+
+### Output Channels
+
+- **Success**: Emitted when the deploy completes successfully.
+- **Failed**: Emitted when the deploy fails or is cancelled.
+
+### Example Output
+
+```json
+{
+ "deployId": "ebda9796-09e4-456f-af60-d1a66dee66a0",
+ "environmentId": "9a1d7a89-2cf4-4446-9b69-4cde850918aa",
+ "projectId": "8db400fa-357e-4646-90f0-c7eb36e88a92",
+ "serviceId": "2a345678-bcde-4fgh-1234-567812345678",
+ "status": "SUCCESS"
+}
+```
+
From 809e5b2f31caa09f32cf1e48395d17d8828685cd Mon Sep 17 00:00:00 2001
From: Shang En Sim <6071014+12458@users.noreply.github.com>
Date: Sat, 30 May 2026 15:52:50 -0700
Subject: [PATCH 7/7] feat: add Railway deployment inspection and rollback
actions
---
docs/components/Railway.mdx | 27 ++-
pkg/integrations/railway/client.go | 42 +++++
pkg/integrations/railway/client_test.go | 15 ++
pkg/integrations/railway/get_deployment.go | 160 ++++++++++++++++++
.../railway/get_deployment_test.go | 50 ++++++
pkg/integrations/railway/payloads.go | 21 ++-
pkg/integrations/railway/railway.go | 2 +
pkg/integrations/railway/rollback_deploy.go | 138 +++++++++++++++
.../railway/rollback_deploy_test.go | 49 ++++++
pkg/integrations/railway/setup_provider.go | 2 +
.../mappers/railway/get_deployment.ts | 68 ++++++++
.../pages/workflowv2/mappers/railway/index.ts | 4 +
.../mappers/railway/rollback_deploy.ts | 54 ++++++
13 files changed, 627 insertions(+), 5 deletions(-)
create mode 100644 pkg/integrations/railway/get_deployment.go
create mode 100644 pkg/integrations/railway/get_deployment_test.go
create mode 100644 pkg/integrations/railway/rollback_deploy.go
create mode 100644 pkg/integrations/railway/rollback_deploy_test.go
create mode 100644 web_src/src/pages/workflowv2/mappers/railway/get_deployment.ts
create mode 100644 web_src/src/pages/workflowv2/mappers/railway/rollback_deploy.ts
diff --git a/docs/components/Railway.mdx b/docs/components/Railway.mdx
index 1bc882eddf..8b4a4c3c5b 100644
--- a/docs/components/Railway.mdx
+++ b/docs/components/Railway.mdx
@@ -16,6 +16,8 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components";
+
+
@@ -50,6 +52,30 @@ The On Deployment Event trigger fires when a deployment changes status in the sp
}
```
+
+
+## Get Deployment
+
+**Component key:** `railway.getDeployment`
+
+The Get Deployment action retrieves the current details of a Railway deployment.
+
+### Configuration
+
+- **Deploy ID**: The Railway deployment ID to retrieve.
+
+
+
+## Rollback Deploy
+
+**Component key:** `railway.rollbackDeploy`
+
+The Rollback Deploy action rolls a Railway service back to a previous deployment.
+
+### Configuration
+
+- **Deploy ID**: The previous Railway deployment to restore.
+
## Trigger Deploy
@@ -80,4 +106,3 @@ The Trigger Deploy action starts a new deploy for a Railway service and waits fo
"status": "SUCCESS"
}
```
-
diff --git a/pkg/integrations/railway/client.go b/pkg/integrations/railway/client.go
index b3cd18cfac..1212c58495 100644
--- a/pkg/integrations/railway/client.go
+++ b/pkg/integrations/railway/client.go
@@ -270,6 +270,24 @@ func (c *Client) GetDeployment(deploymentID string) (*Deployment, error) {
status
createdAt
updatedAt
+ statusUpdatedAt
+ projectId
+ serviceId
+ environmentId
+ snapshotId
+ staticUrl
+ url
+ canRollback
+ canRedeploy
+ deploymentStopped
+ meta
+ diagnosis
+ creator {
+ id
+ name
+ email
+ avatar
+ }
}
}
`
@@ -288,6 +306,30 @@ func (c *Client) GetDeployment(deploymentID string) (*Deployment, error) {
return &result.Deployment, nil
}
+func (c *Client) RollbackDeployment(deploymentID string) error {
+ query := `
+ mutation($id: String!) {
+ deploymentRollback(id: $id)
+ }
+ `
+ variables := map[string]any{
+ "id": deploymentID,
+ }
+ var result struct {
+ DeploymentRollback bool `json:"deploymentRollback"`
+ }
+
+ if err := c.execQuery(query, variables, &result); err != nil {
+ return err
+ }
+
+ if !result.DeploymentRollback {
+ return fmt.Errorf("Railway did not accept rollback request")
+ }
+
+ return nil
+}
+
func (c *Client) ListNotificationRules(workspaceID, projectID string) ([]NotificationRule, error) {
query := `
query($workspaceId: String!, $projectId: String!) {
diff --git a/pkg/integrations/railway/client_test.go b/pkg/integrations/railway/client_test.go
index 29654200c4..334bb1f34f 100644
--- a/pkg/integrations/railway/client_test.go
+++ b/pkg/integrations/railway/client_test.go
@@ -138,6 +138,21 @@ func Test__Railway__Client__GetDeployment(t *testing.T) {
assert.Equal(t, "SUCCESS", deploy.Status)
}
+func Test__Railway__Client__RollbackDeployment(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"deploymentRollback":true}}`)),
+ },
+ },
+ }
+
+ client := NewClientWithAPIToken(httpCtx, "test-token")
+ err := client.RollbackDeployment("deploy-123")
+ require.NoError(t, err)
+}
+
func Test__Railway__Client__CreateNotificationRule(t *testing.T) {
httpCtx := &contexts.HTTPContext{
Responses: []*http.Response{
diff --git a/pkg/integrations/railway/get_deployment.go b/pkg/integrations/railway/get_deployment.go
new file mode 100644
index 0000000000..c77875b07d
--- /dev/null
+++ b/pkg/integrations/railway/get_deployment.go
@@ -0,0 +1,160 @@
+package railway
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+const GetDeploymentPayloadType = "railway.deployment"
+
+type GetDeployment struct{}
+
+type GetDeploymentConfiguration struct {
+ DeployID string `json:"deployId" mapstructure:"deployId"`
+}
+
+func (c *GetDeployment) Name() string {
+ return "railway.getDeployment"
+}
+
+func (c *GetDeployment) Label() string {
+ return "Get Deployment"
+}
+
+func (c *GetDeployment) Description() string {
+ return "Retrieve a Railway deployment by ID"
+}
+
+func (c *GetDeployment) Documentation() string {
+ return `The Get Deployment action retrieves the current details of a Railway deployment.
+
+## Configuration
+
+- **Deploy ID**: The Railway deployment ID to retrieve.`
+}
+
+func (c *GetDeployment) Icon() string {
+ return "railway"
+}
+
+func (c *GetDeployment) Color() string {
+ return "gray"
+}
+
+func (c *GetDeployment) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{core.DefaultOutputChannel}
+}
+
+func (c *GetDeployment) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "deployId",
+ Label: "Deploy ID",
+ Type: configuration.FieldTypeString,
+ Required: true,
+ Placeholder: "e.g., {{$['Trigger Deploy'].data.deployId}}",
+ Description: "Railway deployment ID to retrieve",
+ },
+ }
+}
+
+func decodeGetDeploymentConfiguration(configuration any) (GetDeploymentConfiguration, error) {
+ spec := GetDeploymentConfiguration{}
+ if err := mapstructure.Decode(configuration, &spec); err != nil {
+ return GetDeploymentConfiguration{}, fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ spec.DeployID = strings.TrimSpace(spec.DeployID)
+ if spec.DeployID == "" {
+ return GetDeploymentConfiguration{}, fmt.Errorf("deployId is required")
+ }
+
+ return spec, nil
+}
+
+func (c *GetDeployment) Setup(ctx core.SetupContext) error {
+ _, err := decodeGetDeploymentConfiguration(ctx.Configuration)
+ return err
+}
+
+func (c *GetDeployment) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
+ return ctx.DefaultProcessing()
+}
+
+func (c *GetDeployment) Execute(ctx core.ExecutionContext) error {
+ spec, err := decodeGetDeploymentConfiguration(ctx.Configuration)
+ if err != nil {
+ return err
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return err
+ }
+
+ deployment, err := client.GetDeployment(spec.DeployID)
+ if err != nil {
+ return err
+ }
+
+ return ctx.ExecutionState.Emit(core.DefaultOutputChannel.Name, GetDeploymentPayloadType, []any{deploymentData(deployment)})
+}
+
+func (c *GetDeployment) Hooks() []core.Hook {
+ return []core.Hook{}
+}
+
+func (c *GetDeployment) HandleHook(ctx core.ActionHookContext) error {
+ return nil
+}
+
+func (c *GetDeployment) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) {
+ return http.StatusOK, nil, nil
+}
+
+func (c *GetDeployment) Cancel(ctx core.ExecutionContext) error {
+ return nil
+}
+
+func (c *GetDeployment) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
+
+func (c *GetDeployment) ExampleOutput() map[string]any {
+ return map[string]any{
+ "deployId": "ebda9796-09e4-456f-af60-d1a66dee66a0",
+ "status": "SUCCESS",
+ "projectId": "8db400fa-357e-4646-90f0-c7eb36e88a92",
+ "serviceId": "2a345678-bcde-4fgh-1234-567812345678",
+ "environmentId": "9a1d7a89-2cf4-4446-9b69-4cde850918aa",
+ "canRollback": true,
+ }
+}
+
+func deploymentData(deployment *Deployment) map[string]any {
+ return map[string]any{
+ "deployId": deployment.ID,
+ "status": deployment.Status,
+ "createdAt": deployment.CreatedAt,
+ "updatedAt": deployment.UpdatedAt,
+ "statusUpdatedAt": deployment.StatusUpdatedAt,
+ "projectId": deployment.ProjectID,
+ "serviceId": deployment.ServiceID,
+ "environmentId": deployment.EnvironmentID,
+ "snapshotId": deployment.SnapshotID,
+ "staticUrl": deployment.StaticURL,
+ "url": deployment.URL,
+ "canRollback": deployment.CanRollback,
+ "canRedeploy": deployment.CanRedeploy,
+ "deploymentStopped": deployment.DeploymentStopped,
+ "meta": deployment.Meta,
+ "diagnosis": deployment.Diagnosis,
+ "creator": deployment.Creator,
+ }
+}
diff --git a/pkg/integrations/railway/get_deployment_test.go b/pkg/integrations/railway/get_deployment_test.go
new file mode 100644
index 0000000000..2f2ada8543
--- /dev/null
+++ b/pkg/integrations/railway/get_deployment_test.go
@@ -0,0 +1,50 @@
+package railway
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+func Test__Railway__GetDeployment__Setup(t *testing.T) {
+ action := &GetDeployment{}
+
+ require.ErrorContains(t, action.Setup(core.SetupContext{Configuration: map[string]any{}}), "deployId is required")
+ require.NoError(t, action.Setup(core.SetupContext{Configuration: map[string]any{"deployId": "deploy-123"}}))
+}
+
+func Test__Railway__GetDeployment__Execute(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"deployment":{"id":"deploy-123","status":"SUCCESS","createdAt":"2026-05-30T00:00:00Z","updatedAt":"2026-05-30T00:01:00Z","projectId":"p-1","serviceId":"s-1","environmentId":"e-1","canRollback":true,"canRedeploy":true,"deploymentStopped":false}}}`)),
+ },
+ },
+ }
+ intCtx := &contexts.IntegrationContext{NewSetupFlow: true}
+ _ = intCtx.SetSecret("apiToken", []byte("test-token"))
+ stateCtx := &contexts.ExecutionStateContext{}
+
+ err := (&GetDeployment{}).Execute(core.ExecutionContext{
+ HTTP: httpCtx,
+ Integration: intCtx,
+ Configuration: map[string]any{"deployId": "deploy-123"},
+ ExecutionState: stateCtx,
+ })
+ require.NoError(t, err)
+
+ assert.Equal(t, core.DefaultOutputChannel.Name, stateCtx.Channel)
+ assert.Equal(t, GetDeploymentPayloadType, stateCtx.Type)
+ require.Len(t, stateCtx.Payloads, 1)
+ payload := stateCtx.Payloads[0].(map[string]any)["data"].(map[string]any)
+ assert.Equal(t, "deploy-123", payload["deployId"])
+ assert.Equal(t, "SUCCESS", payload["status"])
+ assert.Equal(t, true, payload["canRollback"])
+}
diff --git a/pkg/integrations/railway/payloads.go b/pkg/integrations/railway/payloads.go
index 7d99bc5d24..1ec96efa06 100644
--- a/pkg/integrations/railway/payloads.go
+++ b/pkg/integrations/railway/payloads.go
@@ -65,10 +65,23 @@ type Service struct {
}
type Deployment struct {
- ID string `json:"id"`
- Status string `json:"status"`
- CreatedAt string `json:"createdAt"`
- UpdatedAt string `json:"updatedAt"`
+ ID string `json:"id"`
+ Status string `json:"status"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+ StatusUpdatedAt string `json:"statusUpdatedAt,omitempty"`
+ ProjectID string `json:"projectId,omitempty"`
+ ServiceID string `json:"serviceId,omitempty"`
+ EnvironmentID string `json:"environmentId,omitempty"`
+ SnapshotID string `json:"snapshotId,omitempty"`
+ StaticURL string `json:"staticUrl,omitempty"`
+ URL string `json:"url,omitempty"`
+ CanRollback bool `json:"canRollback"`
+ CanRedeploy bool `json:"canRedeploy"`
+ DeploymentStopped bool `json:"deploymentStopped"`
+ Meta any `json:"meta,omitempty"`
+ Diagnosis any `json:"diagnosis,omitempty"`
+ Creator any `json:"creator,omitempty"`
}
type NotificationRule struct {
diff --git a/pkg/integrations/railway/railway.go b/pkg/integrations/railway/railway.go
index f8b8a42243..611ad919fb 100644
--- a/pkg/integrations/railway/railway.go
+++ b/pkg/integrations/railway/railway.go
@@ -54,6 +54,8 @@ func (r *Railway) Configuration() []configuration.Field {
func (r *Railway) Actions() []core.Action {
return []core.Action{
&TriggerDeploy{},
+ &GetDeployment{},
+ &RollbackDeploy{},
}
}
diff --git a/pkg/integrations/railway/rollback_deploy.go b/pkg/integrations/railway/rollback_deploy.go
new file mode 100644
index 0000000000..5b25650124
--- /dev/null
+++ b/pkg/integrations/railway/rollback_deploy.go
@@ -0,0 +1,138 @@
+package railway
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/mitchellh/mapstructure"
+ "github.com/superplanehq/superplane/pkg/configuration"
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+const RollbackDeployPayloadType = "railway.deployment.rollback"
+
+type RollbackDeploy struct{}
+
+type RollbackDeployConfiguration struct {
+ DeployID string `json:"deployId" mapstructure:"deployId"`
+}
+
+func (c *RollbackDeploy) Name() string {
+ return "railway.rollbackDeploy"
+}
+
+func (c *RollbackDeploy) Label() string {
+ return "Rollback Deploy"
+}
+
+func (c *RollbackDeploy) Description() string {
+ return "Roll back to a previous Railway deployment"
+}
+
+func (c *RollbackDeploy) Documentation() string {
+ return `The Rollback Deploy action rolls a Railway service back to a previous deployment.
+
+## Configuration
+
+- **Deploy ID**: The previous Railway deployment to restore.`
+}
+
+func (c *RollbackDeploy) Icon() string {
+ return "railway"
+}
+
+func (c *RollbackDeploy) Color() string {
+ return "gray"
+}
+
+func (c *RollbackDeploy) OutputChannels(configuration any) []core.OutputChannel {
+ return []core.OutputChannel{core.DefaultOutputChannel}
+}
+
+func (c *RollbackDeploy) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "deployId",
+ Label: "Deploy ID",
+ Type: configuration.FieldTypeString,
+ Required: true,
+ Placeholder: "e.g., {{$['Get Deployment'].data.deployId}}",
+ Description: "Previous Railway deployment ID to restore",
+ },
+ }
+}
+
+func decodeRollbackDeployConfiguration(configuration any) (RollbackDeployConfiguration, error) {
+ spec := RollbackDeployConfiguration{}
+ if err := mapstructure.Decode(configuration, &spec); err != nil {
+ return RollbackDeployConfiguration{}, fmt.Errorf("failed to decode configuration: %w", err)
+ }
+
+ spec.DeployID = strings.TrimSpace(spec.DeployID)
+ if spec.DeployID == "" {
+ return RollbackDeployConfiguration{}, fmt.Errorf("deployId is required")
+ }
+
+ return spec, nil
+}
+
+func (c *RollbackDeploy) Setup(ctx core.SetupContext) error {
+ _, err := decodeRollbackDeployConfiguration(ctx.Configuration)
+ return err
+}
+
+func (c *RollbackDeploy) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) {
+ return ctx.DefaultProcessing()
+}
+
+func (c *RollbackDeploy) Execute(ctx core.ExecutionContext) error {
+ spec, err := decodeRollbackDeployConfiguration(ctx.Configuration)
+ if err != nil {
+ return err
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return err
+ }
+
+ if err := client.RollbackDeployment(spec.DeployID); err != nil {
+ return err
+ }
+
+ return ctx.ExecutionState.Emit(core.DefaultOutputChannel.Name, RollbackDeployPayloadType, []any{
+ map[string]any{
+ "deployId": spec.DeployID,
+ "rolledBack": true,
+ },
+ })
+}
+
+func (c *RollbackDeploy) Hooks() []core.Hook {
+ return []core.Hook{}
+}
+
+func (c *RollbackDeploy) HandleHook(ctx core.ActionHookContext) error {
+ return nil
+}
+
+func (c *RollbackDeploy) HandleWebhook(ctx core.WebhookRequestContext) (int, *core.WebhookResponseBody, error) {
+ return http.StatusOK, nil, nil
+}
+
+func (c *RollbackDeploy) Cancel(ctx core.ExecutionContext) error {
+ return nil
+}
+
+func (c *RollbackDeploy) Cleanup(ctx core.SetupContext) error {
+ return nil
+}
+
+func (c *RollbackDeploy) ExampleOutput() map[string]any {
+ return map[string]any{
+ "deployId": "ebda9796-09e4-456f-af60-d1a66dee66a0",
+ "rolledBack": true,
+ }
+}
diff --git a/pkg/integrations/railway/rollback_deploy_test.go b/pkg/integrations/railway/rollback_deploy_test.go
new file mode 100644
index 0000000000..b8ff2ef189
--- /dev/null
+++ b/pkg/integrations/railway/rollback_deploy_test.go
@@ -0,0 +1,49 @@
+package railway
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/superplanehq/superplane/pkg/core"
+ "github.com/superplanehq/superplane/test/support/contexts"
+)
+
+func Test__Railway__RollbackDeploy__Setup(t *testing.T) {
+ action := &RollbackDeploy{}
+
+ require.ErrorContains(t, action.Setup(core.SetupContext{Configuration: map[string]any{}}), "deployId is required")
+ require.NoError(t, action.Setup(core.SetupContext{Configuration: map[string]any{"deployId": "deploy-123"}}))
+}
+
+func Test__Railway__RollbackDeploy__Execute(t *testing.T) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"data":{"deploymentRollback":true}}`)),
+ },
+ },
+ }
+ intCtx := &contexts.IntegrationContext{NewSetupFlow: true}
+ _ = intCtx.SetSecret("apiToken", []byte("test-token"))
+ stateCtx := &contexts.ExecutionStateContext{}
+
+ err := (&RollbackDeploy{}).Execute(core.ExecutionContext{
+ HTTP: httpCtx,
+ Integration: intCtx,
+ Configuration: map[string]any{"deployId": "deploy-123"},
+ ExecutionState: stateCtx,
+ })
+ require.NoError(t, err)
+
+ assert.Equal(t, core.DefaultOutputChannel.Name, stateCtx.Channel)
+ assert.Equal(t, RollbackDeployPayloadType, stateCtx.Type)
+ require.Len(t, stateCtx.Payloads, 1)
+ payload := stateCtx.Payloads[0].(map[string]any)["data"].(map[string]any)
+ assert.Equal(t, "deploy-123", payload["deployId"])
+ assert.Equal(t, true, payload["rolledBack"])
+}
diff --git a/pkg/integrations/railway/setup_provider.go b/pkg/integrations/railway/setup_provider.go
index 02b26aea45..737c21a48f 100644
--- a/pkg/integrations/railway/setup_provider.go
+++ b/pkg/integrations/railway/setup_provider.go
@@ -79,6 +79,8 @@ func (s *SetupProvider) CapabilityGroups() []core.CapabilityGroup {
Capabilities: s.genCapabilities(
[]core.Action{
&TriggerDeploy{},
+ &GetDeployment{},
+ &RollbackDeploy{},
},
[]core.Trigger{
&OnDeploymentEvent{},
diff --git a/web_src/src/pages/workflowv2/mappers/railway/get_deployment.ts b/web_src/src/pages/workflowv2/mappers/railway/get_deployment.ts
new file mode 100644
index 0000000000..b85ed8e9e6
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/railway/get_deployment.ts
@@ -0,0 +1,68 @@
+import type { ComponentBaseProps } from "@/ui/componentBase";
+import type React from "react";
+import type {
+ ComponentBaseContext,
+ ComponentBaseMapper,
+ ExecutionDetailsContext,
+ NodeInfo,
+ OutputPayload,
+ SubtitleContext,
+} from "../types";
+import type { MetadataItem } from "@/ui/metadataList";
+import { renderTimeAgo } from "@/components/TimeAgo";
+import { baseProps } from "./base";
+
+interface GetDeploymentConfiguration {
+ deployId?: string;
+}
+
+interface GetDeploymentOutput {
+ deployId?: string;
+ status?: string;
+ projectId?: string;
+ serviceId?: string;
+ environmentId?: string;
+ createdAt?: string;
+ updatedAt?: string;
+ canRollback?: boolean;
+ canRedeploy?: boolean;
+}
+
+function stringOrDash(value?: unknown): string {
+ return value === undefined || value === null || value === "" ? "-" : String(value);
+}
+
+function metadataList(node: NodeInfo): MetadataItem[] {
+ const configuration = node.configuration as GetDeploymentConfiguration | undefined;
+ return configuration?.deployId ? [{ icon: "hash", label: `Deploy: ${configuration.deployId}` }] : [];
+}
+
+export const getDeploymentMapper: ComponentBaseMapper = {
+ props(context: ComponentBaseContext): ComponentBaseProps {
+ return {
+ ...baseProps(context.nodes, context.node, context.componentDefinition, context.lastExecutions),
+ metadata: metadataList(context.node),
+ };
+ },
+
+ subtitle(context: SubtitleContext): string | React.ReactNode {
+ return context.execution.createdAt ? renderTimeAgo(new Date(context.execution.createdAt)) : "";
+ },
+
+ getExecutionDetails(context: ExecutionDetailsContext): Record {
+ const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined;
+ const result = outputs?.default?.[0]?.data as GetDeploymentOutput | undefined;
+
+ return {
+ "Deploy ID": stringOrDash(result?.deployId),
+ Status: stringOrDash(result?.status),
+ "Project ID": stringOrDash(result?.projectId),
+ "Service ID": stringOrDash(result?.serviceId),
+ "Environment ID": stringOrDash(result?.environmentId),
+ "Created At": stringOrDash(result?.createdAt),
+ "Updated At": stringOrDash(result?.updatedAt),
+ "Can Rollback": stringOrDash(result?.canRollback),
+ "Can Redeploy": stringOrDash(result?.canRedeploy),
+ };
+ },
+};
diff --git a/web_src/src/pages/workflowv2/mappers/railway/index.ts b/web_src/src/pages/workflowv2/mappers/railway/index.ts
index 853c9fab6a..6819736482 100644
--- a/web_src/src/pages/workflowv2/mappers/railway/index.ts
+++ b/web_src/src/pages/workflowv2/mappers/railway/index.ts
@@ -1,9 +1,13 @@
import type { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../types";
import { triggerDeployMapper, DEPLOY_STATE_REGISTRY } from "./trigger_deploy";
import { onDeploymentTriggerRenderer } from "./on_deployment";
+import { getDeploymentMapper } from "./get_deployment";
+import { rollbackDeployMapper } from "./rollback_deploy";
export const componentMappers: Record = {
triggerDeploy: triggerDeployMapper,
+ getDeployment: getDeploymentMapper,
+ rollbackDeploy: rollbackDeployMapper,
};
export const triggerRenderers: Record = {
diff --git a/web_src/src/pages/workflowv2/mappers/railway/rollback_deploy.ts b/web_src/src/pages/workflowv2/mappers/railway/rollback_deploy.ts
new file mode 100644
index 0000000000..cf54b2ea9e
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/railway/rollback_deploy.ts
@@ -0,0 +1,54 @@
+import type { ComponentBaseProps } from "@/ui/componentBase";
+import type React from "react";
+import type {
+ ComponentBaseContext,
+ ComponentBaseMapper,
+ ExecutionDetailsContext,
+ NodeInfo,
+ OutputPayload,
+ SubtitleContext,
+} from "../types";
+import type { MetadataItem } from "@/ui/metadataList";
+import { renderTimeAgo } from "@/components/TimeAgo";
+import { baseProps } from "./base";
+
+interface RollbackDeployConfiguration {
+ deployId?: string;
+}
+
+interface RollbackDeployOutput {
+ deployId?: string;
+ rolledBack?: boolean;
+}
+
+function stringOrDash(value?: unknown): string {
+ return value === undefined || value === null || value === "" ? "-" : String(value);
+}
+
+function metadataList(node: NodeInfo): MetadataItem[] {
+ const configuration = node.configuration as RollbackDeployConfiguration | undefined;
+ return configuration?.deployId ? [{ icon: "rotate-ccw", label: `Rollback to: ${configuration.deployId}` }] : [];
+}
+
+export const rollbackDeployMapper: ComponentBaseMapper = {
+ props(context: ComponentBaseContext): ComponentBaseProps {
+ return {
+ ...baseProps(context.nodes, context.node, context.componentDefinition, context.lastExecutions),
+ metadata: metadataList(context.node),
+ };
+ },
+
+ subtitle(context: SubtitleContext): string | React.ReactNode {
+ return context.execution.createdAt ? renderTimeAgo(new Date(context.execution.createdAt)) : "";
+ },
+
+ getExecutionDetails(context: ExecutionDetailsContext): Record {
+ const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined;
+ const result = outputs?.default?.[0]?.data as RollbackDeployOutput | undefined;
+
+ return {
+ "Deploy ID": stringOrDash(result?.deployId),
+ "Rollback Requested": stringOrDash(result?.rolledBack),
+ };
+ },
+};