From 83e49a2812a6d23f0af66ac4c516c52db731c6d7 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Wed, 11 Feb 2026 01:35:10 +0300 Subject: [PATCH 01/29] feat: Add Railway Integration Signed-off-by: Shambel Amare --- docs/components/Railway.mdx | 176 ++++++++++ pkg/integrations/railway/client.go | 313 ++++++++++++++++++ .../example_data_on_deployment_event.json | 30 ++ .../example_output_trigger_deploy.json | 6 + .../railway/on_deployment_event.go | 271 +++++++++++++++ .../railway/on_deployment_event_test.go | 268 +++++++++++++++ pkg/integrations/railway/railway.go | 265 +++++++++++++++ pkg/integrations/railway/trigger_deploy.go | 270 +++++++++++++++ .../railway/trigger_deploy_test.go | 95 ++++++ pkg/integrations/railway/webhook_handler.go | 60 ++++ .../railway/webhook_handler_test.go | 197 +++++++++++ pkg/server/server.go | 1 + test/support/support.go | 1 + .../src/assets/icons/integrations/railway.svg | 1 + web_src/src/pages/workflowv2/mappers/index.ts | 10 + .../mappers/railway/custom_field_renderer.tsx | 125 +++++++ .../pages/workflowv2/mappers/railway/index.ts | 56 ++++ .../mappers/railway/on_deployment_event.ts | 115 +++++++ .../mappers/railway/trigger_deploy.ts | 98 ++++++ 19 files changed, 2358 insertions(+) create mode 100644 docs/components/Railway.mdx create mode 100644 pkg/integrations/railway/client.go create mode 100644 pkg/integrations/railway/example_data_on_deployment_event.json create mode 100644 pkg/integrations/railway/example_output_trigger_deploy.json create mode 100644 pkg/integrations/railway/on_deployment_event.go create mode 100644 pkg/integrations/railway/on_deployment_event_test.go create mode 100644 pkg/integrations/railway/railway.go create mode 100644 pkg/integrations/railway/trigger_deploy.go create mode 100644 pkg/integrations/railway/trigger_deploy_test.go create mode 100644 pkg/integrations/railway/webhook_handler.go create mode 100644 pkg/integrations/railway/webhook_handler_test.go create mode 100644 web_src/src/assets/icons/integrations/railway.svg create mode 100644 web_src/src/pages/workflowv2/mappers/railway/custom_field_renderer.tsx create mode 100644 web_src/src/pages/workflowv2/mappers/railway/index.ts create mode 100644 web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts create mode 100644 web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts diff --git a/docs/components/Railway.mdx b/docs/components/Railway.mdx new file mode 100644 index 0000000000..007c830176 --- /dev/null +++ b/docs/components/Railway.mdx @@ -0,0 +1,176 @@ +--- +title: "Railway" +--- + +Deploy and monitor Railway applications + +## Triggers + + + + + +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + +## Actions + + + + + +## Instructions + +## Connect Railway + +### Creating an API Token + +1. Go to [Railway Account Settings → Tokens](https://railway.app/account/tokens) +2. Click **"Create Token"** to generate a new API token +3. Give your token a descriptive name (e.g., "SuperPlane Integration") + +### Token Scoping + +Railway offers different token scoping options: + +| Scope | Access Level | Use Case | +|-------|--------------|----------| +| **No Workspace** | All workspaces and projects | Recommended for SuperPlane | +| **Team/Workspace** | Projects in a specific workspace only | Limited to one workspace | + +**Recommendation:** Select **"No Workspace"** when creating your token. This allows SuperPlane to access all your projects across workspaces. If you scope the token to a specific workspace, only projects in that workspace will be available. + +### Permissions + +The API token allows SuperPlane to: +- List your projects, services, and environments +- Trigger deployments on your services +- Receive deployment status webhooks + +### Webhook Configuration + +For the **On Deployment Event** trigger, you'll need to manually configure webhooks in Railway: + +1. Create the trigger in SuperPlane and save the canvas +2. Copy the generated webhook URL from the trigger settings +3. Go to your Railway project → Settings → Webhooks +4. Add the webhook URL and select "Deploy" events + +### Troubleshooting + +- **"Not Authorized" error**: Your token may be scoped to a workspace that doesn't include the project you're trying to access. Create a new token with "No Scoping" selected. +- **Projects not showing**: Try disconnecting and reconnecting the integration to refresh the project list. + + + +## On Deployment Event + +The On Deployment Event trigger starts a workflow when Railway sends deployment status webhooks. + +### Setup + +After configuring this trigger: +1. Copy the webhook URL shown in the trigger settings +2. Go to Railway → Your Project → Settings → Webhooks +3. Add the webhook URL and select "Deploy" events +4. Save the webhook configuration + +### Use Cases + +- **Deployment notifications**: Notify Slack when deployments succeed or fail +- **Incident creation**: Create tickets when deployments crash +- **Pipeline chaining**: Trigger downstream workflows after successful deployments + +### Configuration + +- **Project**: Select the Railway project to monitor +- **Event Filter**: Optionally filter by deployment event type (succeeded, failed, crashed, etc.) + - Leave empty to receive all deployment events + +### Event Data + +Each deployment event includes: +- `type`: Event type (e.g., Deployment.succeeded, Deployment.failed) +- `details.status`: Deployment status +- `resource.deployment.id`: Deployment ID +- `resource.service`: Service name and ID +- `resource.environment`: Environment name and ID +- `resource.project`: Project information +- `timestamp`: When the event occurred + +### Example Data + +```json +{ + "details": { + "status": "SUCCESS" + }, + "resource": { + "deployment": { + "id": "deploy-jkl345" + }, + "environment": { + "id": "env-def456", + "name": "production" + }, + "owner": { + "email": "user@example.com", + "id": "owner-abc123" + }, + "project": { + "id": "proj-xyz789", + "name": "my-project" + }, + "service": { + "id": "srv-ghi012", + "name": "api-server" + } + }, + "timestamp": "2024-01-15T10:30:00.000Z", + "type": "Deployment.succeeded" +} +``` + + + +## Trigger Deploy + +The Trigger Deploy component starts a new deployment for a Railway service in a specific environment. + +### Use Cases + +- **Deploy on merge**: Automatically deploy when code is merged to main +- **Scheduled deployments**: Deploy on a schedule (e.g., nightly releases) +- **Manual approval**: Deploy after approval in the workflow +- **Cross-service orchestration**: Deploy services in sequence + +### Configuration + +- **Project**: Select the Railway project +- **Service**: Select the service to deploy +- **Environment**: Select the target environment (e.g., production, staging) + +### How It Works + +1. Calls Railway's `environmentTriggersDeploy` API +2. Railway queues a new deployment for the service +3. Component emits the deployment trigger result + +### Output + +The component emits: +- `project`: Project ID +- `service`: Service ID +- `environment`: Environment ID +- `triggered`: Whether the deployment was triggered + +### Example Output + +```json +{ + "environment": "env-def456", + "project": "proj-xyz789", + "service": "srv-ghi012", + "triggered": true +} +``` + diff --git a/pkg/integrations/railway/client.go b/pkg/integrations/railway/client.go new file mode 100644 index 0000000000..0d6f5a7517 --- /dev/null +++ b/pkg/integrations/railway/client.go @@ -0,0 +1,313 @@ +package railway + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/superplanehq/superplane/pkg/core" +) + +const GraphQLEndpoint = "https://backboard.railway.com/graphql/v2" + +type Client struct { + apiToken string + http core.HTTPContext +} + +type User struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Services []Service `json:"services"` + Environments []Environment `json:"environments"` +} + +type Service struct { + ID string `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` +} + +type Environment struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type graphqlRequest struct { + Query string `json:"query"` + Variables map[string]any `json:"variables,omitempty"` +} + +type graphqlResponse struct { + Data map[string]any `json:"data"` + Errors []graphqlError `json:"errors,omitempty"` +} + +type graphqlError struct { + Message string `json:"message"` +} + +func NewClient(httpCtx core.HTTPContext, integration core.IntegrationContext) (*Client, error) { + token, err := integration.GetConfig("apiToken") + if err != nil { + return nil, fmt.Errorf("failed to get API token: %v", err) + } + + if len(token) == 0 { + return nil, fmt.Errorf("API token is empty") + } + + return &Client{ + apiToken: string(token), + http: httpCtx, + }, nil +} + +func (c *Client) graphql(query string, variables map[string]any) (map[string]any, error) { + payload := graphqlRequest{ + Query: query, + Variables: variables, + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %v", err) + } + + req, err := http.NewRequest("POST", GraphQLEndpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Set("Authorization", "Bearer "+c.apiToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %v", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %v", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf( + "Railway GraphQL API request failed with status %d: %s", + resp.StatusCode, + string(respBody), + ) + } + + var gqlResp graphqlResponse + if err := json.Unmarshal(respBody, &gqlResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %v", err) + } + + if len(gqlResp.Errors) > 0 { + return nil, fmt.Errorf("Railway GraphQL error: %s", gqlResp.Errors[0].Message) + } + + return gqlResp.Data, nil +} + +func (c *Client) GetCurrentUser() (*User, error) { + query := ` + query { + me { + id + name + email + } + } + ` + + data, err := c.graphql(query, nil) + if err != nil { + return nil, err + } + + meData, ok := data["me"].(map[string]any) + if !ok { + return nil, fmt.Errorf("unexpected response format") + } + + return &User{ + ID: getString(meData, "id"), + Name: getString(meData, "name"), + Email: getString(meData, "email"), + }, nil +} + +func (c *Client) ListProjects() ([]Project, error) { + query := ` + query { + projects { + edges { + node { + id + name + description + createdAt + updatedAt + } + } + } + } + ` + + data, err := c.graphql(query, nil) + if err != nil { + return nil, err + } + + projectsData, ok := data["projects"].(map[string]any) + if !ok { + return nil, fmt.Errorf("unexpected response format for projects") + } + + edges, ok := projectsData["edges"].([]any) + if !ok { + return []Project{}, nil + } + + projects := make([]Project, 0, len(edges)) + for _, edge := range edges { + edgeMap, ok := edge.(map[string]any) + if !ok { + continue + } + node, ok := edgeMap["node"].(map[string]any) + if !ok { + continue + } + + projects = append(projects, Project{ + ID: getString(node, "id"), + Name: getString(node, "name"), + Description: getString(node, "description"), + CreatedAt: getString(node, "createdAt"), + UpdatedAt: getString(node, "updatedAt"), + }) + } + + return projects, nil +} + +func (c *Client) GetProject(projectID string) (*Project, error) { + query := ` + query project($id: String!) { + project(id: $id) { + id + name + description + services { + edges { + node { + id + name + icon + } + } + } + environments { + edges { + node { + id + name + } + } + } + } + } + ` + + data, err := c.graphql(query, map[string]any{"id": projectID}) + if err != nil { + return nil, err + } + + projectData, ok := data["project"].(map[string]any) + if !ok { + return nil, fmt.Errorf("project not found") + } + + project := &Project{ + ID: getString(projectData, "id"), + Name: getString(projectData, "name"), + Description: getString(projectData, "description"), + } + + // Parse services + if servicesData, ok := projectData["services"].(map[string]any); ok { + if edges, ok := servicesData["edges"].([]any); ok { + for _, edge := range edges { + if edgeMap, ok := edge.(map[string]any); ok { + if node, ok := edgeMap["node"].(map[string]any); ok { + project.Services = append(project.Services, Service{ + ID: getString(node, "id"), + Name: getString(node, "name"), + Icon: getString(node, "icon"), + }) + } + } + } + } + } + + // Parse environments + if envsData, ok := projectData["environments"].(map[string]any); ok { + if edges, ok := envsData["edges"].([]any); ok { + for _, edge := range edges { + if edgeMap, ok := edge.(map[string]any); ok { + if node, ok := edgeMap["node"].(map[string]any); ok { + project.Environments = append(project.Environments, Environment{ + ID: getString(node, "id"), + Name: getString(node, "name"), + }) + } + } + } + } + } + + return project, nil +} + +func (c *Client) TriggerDeploy(serviceID, environmentID string) error { + // serviceInstanceDeployV2 returns a String! (deployment ID), not an object + mutation := ` + mutation serviceInstanceDeployV2($serviceId: String!, $environmentId: String!) { + serviceInstanceDeployV2(serviceId: $serviceId, environmentId: $environmentId) + } + ` + + variables := map[string]any{ + "serviceId": serviceID, + "environmentId": environmentID, + } + + _, err := c.graphql(mutation, variables) + return err +} + +// Helper function to safely get string from map +func getString(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} diff --git a/pkg/integrations/railway/example_data_on_deployment_event.json b/pkg/integrations/railway/example_data_on_deployment_event.json new file mode 100644 index 0000000000..cc8bf1415f --- /dev/null +++ b/pkg/integrations/railway/example_data_on_deployment_event.json @@ -0,0 +1,30 @@ +{ + "type": "DEPLOY", + "details": { + "message": "Deployment succeeded" + }, + "resource": { + "workspace": { + "id": "ws-abc123", + "name": "my-workspace" + }, + "project": { + "id": "proj-xyz789", + "name": "my-project" + }, + "environment": { + "id": "env-def456", + "name": "production" + }, + "service": { + "id": "srv-ghi012", + "name": "api-server" + }, + "deployment": { + "id": "deploy-jkl345", + "status": "SUCCESS" + } + }, + "severity": "info", + "timestamp": "2024-01-15T10:30:00.000Z" +} diff --git a/pkg/integrations/railway/example_output_trigger_deploy.json b/pkg/integrations/railway/example_output_trigger_deploy.json new file mode 100644 index 0000000000..02ae0ae32f --- /dev/null +++ b/pkg/integrations/railway/example_output_trigger_deploy.json @@ -0,0 +1,6 @@ +{ + "project": "proj-xyz789", + "service": "srv-ghi012", + "environment": "env-def456", + "triggered": true +} diff --git a/pkg/integrations/railway/on_deployment_event.go b/pkg/integrations/railway/on_deployment_event.go new file mode 100644 index 0000000000..28adcc10ff --- /dev/null +++ b/pkg/integrations/railway/on_deployment_event.go @@ -0,0 +1,271 @@ +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 OnDeploymentEventConfiguration struct { + Project string `json:"project" mapstructure:"project"` + Statuses []string `json:"statuses" mapstructure:"statuses"` +} + +type OnDeploymentEventMetadata struct { + Project *ProjectInfo `json:"project" mapstructure:"project"` + WebhookURL string `json:"webhookUrl,omitempty" mapstructure:"webhookUrl,omitempty"` +} + +type ProjectInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func (t *OnDeploymentEvent) Name() string { + return "railway.onDeploymentEvent" +} + +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 starts a workflow when Railway sends deployment status webhooks. + +## Setup + +After configuring this trigger: +1. Copy the webhook URL shown in the trigger settings +2. Go to Railway → Your Project → Settings → Webhooks +3. Add the webhook URL and select "Deploy" events +4. Save the webhook configuration + +## Use Cases + +- **Deployment notifications**: Notify Slack when deployments succeed or fail +- **Incident creation**: Create tickets when deployments crash +- **Pipeline chaining**: Trigger downstream workflows after successful deployments + +## Configuration + +- **Project**: Select the Railway project to monitor +- **Event Filter**: Optionally filter by deployment event type (succeeded, failed, crashed, etc.) + - Leave empty to receive all deployment events + +## Event Data + +Each deployment event includes: +- ` + "`type`" + `: Event type (e.g., Deployment.succeeded, Deployment.failed) +- ` + "`details.status`" + `: Deployment status +- ` + "`resource.deployment.id`" + `: Deployment ID +- ` + "`resource.service`" + `: Service name and ID +- ` + "`resource.environment`" + `: Environment name and ID +- ` + "`resource.project`" + `: Project information +- ` + "`timestamp`" + `: When the event occurred` +} + +func (t *OnDeploymentEvent) Icon() string { + return "railway" +} + +func (t *OnDeploymentEvent) Color() string { + return "purple" +} + +func (t *OnDeploymentEvent) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "project", + Label: "Project", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "Railway project to monitor for deployment events", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "project", + }, + }, + }, + { + Name: "statuses", + Label: "Event Filter", + Type: configuration.FieldTypeMultiSelect, + Required: false, + Description: "Only trigger for these deployment events. Leave empty to receive all events.", + TypeOptions: &configuration.TypeOptions{ + MultiSelect: &configuration.MultiSelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Succeeded", Value: "succeeded"}, + {Label: "Failed", Value: "failed"}, + {Label: "Crashed", Value: "crashed"}, + {Label: "Building", Value: "building"}, + {Label: "Deploying", Value: "deploying"}, + {Label: "Initializing", Value: "initializing"}, + {Label: "Removing", Value: "removing"}, + {Label: "Removed", Value: "removed"}, + }, + }, + }, + }, + } +} + +func (t *OnDeploymentEvent) ExampleData() map[string]any { + return map[string]any{ + "type": "Deployment.succeeded", + "details": map[string]any{ + "status": "SUCCESS", + }, + "resource": map[string]any{ + "owner": map[string]any{ + "id": "owner-abc123", + "email": "user@example.com", + }, + "project": map[string]any{ + "id": "proj-xyz789", + "name": "my-project", + }, + "environment": map[string]any{ + "id": "env-def456", + "name": "production", + }, + "service": map[string]any{ + "id": "srv-ghi012", + "name": "api-server", + }, + "deployment": map[string]any{ + "id": "deploy-jkl345", + }, + }, + "timestamp": "2024-01-15T10:30:00.000Z", + } +} + +func (t *OnDeploymentEvent) Setup(ctx core.TriggerContext) error { + var metadata OnDeploymentEventMetadata + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return fmt.Errorf("failed to parse metadata: %w", err) + } + + // If already set up with webhook URL, nothing to do + if metadata.Project != nil && metadata.WebhookURL != "" { + return nil + } + + config := OnDeploymentEventConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if config.Project == "" { + return fmt.Errorf("project is required") + } + + // Fetch project details to validate it exists + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + project, err := client.GetProject(config.Project) + if err != nil { + return fmt.Errorf("failed to get project: %w", err) + } + + // Setup webhook and get the URL for manual configuration in Railway + webhookURL := metadata.WebhookURL + if webhookURL == "" { + webhookURL, err = ctx.Webhook.Setup() + if err != nil { + return fmt.Errorf("failed to setup webhook: %w", err) + } + } + + // Store metadata with webhook URL + if err := ctx.Metadata.Set(OnDeploymentEventMetadata{ + Project: &ProjectInfo{ + ID: project.ID, + Name: project.Name, + }, + WebhookURL: webhookURL, + }); err != nil { + return fmt.Errorf("failed to set metadata: %w", err) + } + + return nil +} + +func (t *OnDeploymentEvent) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + // Note: Railway does NOT provide webhook signatures + // We cannot verify the request authenticity + + // Parse the webhook payload + var payload map[string]any + if err := json.Unmarshal(ctx.Body, &payload); err != nil { + return http.StatusBadRequest, fmt.Errorf("failed to parse webhook payload: %w", err) + } + + // Check if this is a deployment event (format: Deployment.succeeded, Deployment.failed, etc.) + eventType, _ := payload["type"].(string) + if !strings.HasPrefix(eventType, "Deployment.") { + // Not a deployment event, ignore silently + return http.StatusOK, nil + } + + // Extract event action from type (e.g., "succeeded" from "Deployment.succeeded") + eventAction := extractEventAction(eventType) + + // Load configuration to check status filter + config := OnDeploymentEventConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err) + } + + // Filter by event action if configured + if len(config.Statuses) > 0 && eventAction != "" { + if !slices.Contains(config.Statuses, eventAction) { + return http.StatusOK, nil + } + } + + // Emit the event + if err := ctx.Events.Emit("railway.deployment", payload); err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to emit event: %w", err) + } + + return http.StatusOK, nil +} + +func (t *OnDeploymentEvent) Actions() []core.Action { + return []core.Action{} +} + +func (t *OnDeploymentEvent) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { + return nil, nil +} + +func (t *OnDeploymentEvent) Cleanup(ctx core.TriggerContext) error { + return nil +} + +// extractEventAction extracts the event action from the type field +// e.g., "Deployment.succeeded" -> "succeeded" +func extractEventAction(eventType string) string { + parts := strings.SplitN(eventType, ".", 2) + if len(parts) != 2 { + return "" + } + return parts[1] +} diff --git a/pkg/integrations/railway/on_deployment_event_test.go b/pkg/integrations/railway/on_deployment_event_test.go new file mode 100644 index 0000000000..cbb790fcd0 --- /dev/null +++ b/pkg/integrations/railway/on_deployment_event_test.go @@ -0,0 +1,268 @@ +package railway + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + contexts "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__OnDeploymentEvent__HandleWebhook(t *testing.T) { + trigger := &OnDeploymentEvent{} + + t.Run("invalid JSON payload -> 400", func(t *testing.T) { + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: []byte(`{invalid json`), + Headers: http.Header{}, + }) + + assert.Equal(t, http.StatusBadRequest, code) + assert.ErrorContains(t, err, "failed to parse webhook payload") + }) + + t.Run("non-deployment event is ignored", func(t *testing.T) { + body := []byte(`{"type": "OTHER_EVENT"}`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Zero(t, eventContext.Count()) + }) + + t.Run("deployment event with no status filter -> event is emitted", func(t *testing.T) { + body := []byte(`{ + "type": "Deployment.succeeded", + "details": { + "status": "SUCCESS" + }, + "resource": { + "deployment": { + "id": "deploy-123" + } + } + }`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, eventContext.Count()) + }) + + t.Run("deployment event matching status filter -> event is emitted", func(t *testing.T) { + body := []byte(`{ + "type": "Deployment.succeeded", + "details": { + "status": "SUCCESS" + }, + "resource": { + "deployment": { + "id": "deploy-123" + } + } + }`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{"succeeded", "failed"}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, eventContext.Count()) + }) + + t.Run("deployment event not matching status filter -> event is not emitted", func(t *testing.T) { + body := []byte(`{ + "type": "Deployment.building", + "details": { + "status": "BUILDING" + }, + "resource": { + "deployment": { + "id": "deploy-123" + } + } + }`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{"succeeded", "failed"}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Zero(t, eventContext.Count()) + }) + + t.Run("deployment event with failed status -> event is emitted", func(t *testing.T) { + body := []byte(`{ + "type": "Deployment.failed", + "details": { + "status": "FAILED" + }, + "resource": { + "deployment": { + "id": "deploy-456" + }, + "service": { + "id": "srv-123", + "name": "api-server" + } + } + }`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{"failed"}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, eventContext.Count()) + }) + + t.Run("deployment event with crashed status -> event is emitted", func(t *testing.T) { + body := []byte(`{ + "type": "Deployment.crashed", + "details": { + "status": "CRASHED" + }, + "resource": { + "deployment": { + "id": "deploy-789" + } + } + }`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{"crashed"}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, eventContext.Count()) + }) + + t.Run("deployment event without action in type -> event is emitted when no filter", func(t *testing.T) { + body := []byte(`{ + "type": "Deployment.", + "resource": { + "deployment": { + "id": "deploy-123" + } + } + }`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, eventContext.Count()) + }) +} + +func Test__OnDeploymentEvent__Setup(t *testing.T) { + trigger := OnDeploymentEvent{} + + t.Run("project is required", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + err := trigger.Setup(core.TriggerContext{ + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"project": ""}, + }) + + require.ErrorContains(t, err, "project is required") + }) +} + +func Test__ExtractEventAction(t *testing.T) { + t.Run("extracts action from valid event type", func(t *testing.T) { + assert.Equal(t, "succeeded", extractEventAction("Deployment.succeeded")) + }) + + t.Run("extracts action from failed event type", func(t *testing.T) { + assert.Equal(t, "failed", extractEventAction("Deployment.failed")) + }) + + t.Run("extracts action from crashed event type", func(t *testing.T) { + assert.Equal(t, "crashed", extractEventAction("Deployment.crashed")) + }) + + t.Run("returns empty string for event type without dot", func(t *testing.T) { + assert.Equal(t, "", extractEventAction("Deployment")) + }) + + t.Run("returns empty string for empty event type", func(t *testing.T) { + assert.Equal(t, "", extractEventAction("")) + }) + + t.Run("handles event type with empty action", func(t *testing.T) { + assert.Equal(t, "", extractEventAction("Deployment.")) + }) + + t.Run("handles event type with multiple dots", func(t *testing.T) { + assert.Equal(t, "succeeded.extra", extractEventAction("Deployment.succeeded.extra")) + }) +} diff --git a/pkg/integrations/railway/railway.go b/pkg/integrations/railway/railway.go new file mode 100644 index 0000000000..ed5636d285 --- /dev/null +++ b/pkg/integrations/railway/railway.go @@ -0,0 +1,265 @@ +package railway + +import ( + "fmt" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/registry" +) + +func init() { + registry.RegisterIntegrationWithWebhookHandler("railway", &Railway{}, &RailwayWebhookHandler{}) +} + +type Railway struct{} + +type Configuration struct { + APIToken string `json:"apiToken" mapstructure:"apiToken"` +} + +type Metadata struct { + Projects []ProjectResource `json:"projects,omitempty" mapstructure:"projects,omitempty"` +} + +type ProjectResource struct { + ID string `json:"id" mapstructure:"id"` + Name string `json:"name" mapstructure:"name"` +} + +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 and monitor Railway applications" +} + +func (r *Railway) Instructions() string { + return `## Connect Railway + +### Creating an API Token + +1. Go to [Railway Account Settings → Tokens](https://railway.app/account/tokens) +2. Click **"Create Token"** to generate a new API token +3. Give your token a descriptive name (e.g., "SuperPlane Integration") + + +### Token Scoping + +Railway offers different token scoping options: + +| Scope | Access Level | Use Case | +|-------|--------------|----------| +| **No Workspace** | All workspaces and projects | Recommended for SuperPlane | +| **Project-specific** | Projects in a specific workspace only | Limited to one workspace | + +**Recommendation:** Select **"No Workspace"** when creating your token. This allows SuperPlane to access all your projects across workspaces. If you scope the token to a specific workspace, only projects in that workspace will be available. + +### Permissions + +The API token allows SuperPlane to: +- List your projects, services, and environments +- Trigger deployments on your services +- Receive deployment status webhooks + +### Webhook Configuration + +For the **On Deployment Event** trigger, you'll need to manually configure webhooks in Railway: + +1. Create the trigger in SuperPlane and save the canvas +2. Copy the generated webhook URL from the trigger settings +3. Go to your Railway project → Settings → Webhooks +4. Add the webhook URL and select "Deploy" events + +### Troubleshooting + +- **"Not Authorized" error**: Your token may be scoped to a workspace that doesn't include the project you're trying to access. Create a new token with "No Scoping" selected. +- **Projects not showing**: Try disconnecting and reconnecting the integration to refresh the project list.` +} + +func (r *Railway) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "apiToken", + Label: "API Token", + Type: configuration.FieldTypeString, + Sensitive: true, + Required: true, + Description: "Create a token at railway.app/account/tokens. Use 'No Workspace' to access all projects.", + Placeholder: "YOUR RAILWAY API TOKEN", + }, + } +} + +func (r *Railway) Components() []core.Component { + return []core.Component{ + &TriggerDeploy{}, + } +} + +func (r *Railway) Triggers() []core.Trigger { + return []core.Trigger{ + &OnDeploymentEvent{}, + } +} + +func (r *Railway) Sync(ctx core.SyncContext) error { + config := Configuration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %v", err) + } + + if config.APIToken == "" { + return fmt.Errorf("API token is required") + } + + // Validate API token by making a test request + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %v", err) + } + + // Fetch and cache projects (the 'me' query requires additional permissions) + projects, err := client.ListProjects() + if err != nil { + return fmt.Errorf("failed to validate API token: %w", err) + } + + // Store projects in metadata for ListResources + projectResources := make([]ProjectResource, 0, len(projects)) + for _, p := range projects { + projectResources = append(projectResources, ProjectResource{ + ID: p.ID, + Name: p.Name, + }) + } + + ctx.Integration.SetMetadata(Metadata{ + Projects: projectResources, + }) + + ctx.Integration.Ready() + return nil +} + +func (r *Railway) Cleanup(ctx core.IntegrationCleanupContext) error { + return nil +} + +func (r *Railway) HandleRequest(ctx core.HTTPRequestContext) { + // No OAuth or special HTTP handling needed +} + +func (r *Railway) Actions() []core.Action { + return []core.Action{} +} + +func (r *Railway) HandleAction(ctx core.IntegrationActionContext) error { + return nil +} + +func (r *Railway) ListResources( + resourceType string, + ctx core.ListResourcesContext, +) ([]core.IntegrationResource, error) { + switch resourceType { + case "project": + return r.listProjectsFromMetadata(ctx) + case "service": + projectID := ctx.Parameters["projectId"] + if projectID == "" { + return []core.IntegrationResource{}, nil + } + return r.listServices(ctx, projectID) + case "environment": + projectID := ctx.Parameters["projectId"] + if projectID == "" { + return []core.IntegrationResource{}, nil + } + return r.listEnvironments(ctx, projectID) + default: + return []core.IntegrationResource{}, nil + } +} + +func (r *Railway) listProjectsFromMetadata( + ctx core.ListResourcesContext, +) ([]core.IntegrationResource, error) { + metadata := Metadata{} + if err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata); err != nil { + return nil, fmt.Errorf("failed to decode metadata: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(metadata.Projects)) + for _, project := range metadata.Projects { + resources = append(resources, core.IntegrationResource{ + Type: "project", + ID: project.ID, + Name: project.Name, + }) + } + + return resources, nil +} + +func (r *Railway) listServices( + ctx core.ListResourcesContext, + projectID string, +) ([]core.IntegrationResource, error) { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %v", err) + } + + project, err := client.GetProject(projectID) + if err != nil { + return nil, fmt.Errorf("failed to get project: %v", err) + } + + resources := make([]core.IntegrationResource, 0, len(project.Services)) + for _, service := range project.Services { + resources = append(resources, core.IntegrationResource{ + Type: "service", + ID: service.ID, + Name: service.Name, + }) + } + + return resources, nil +} + +func (r *Railway) listEnvironments( + ctx core.ListResourcesContext, + projectID string, +) ([]core.IntegrationResource, error) { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %v", err) + } + + project, err := client.GetProject(projectID) + if err != nil { + return nil, fmt.Errorf("failed to get project: %v", err) + } + + resources := make([]core.IntegrationResource, 0, len(project.Environments)) + for _, env := range project.Environments { + resources = append(resources, core.IntegrationResource{ + Type: "environment", + ID: env.ID, + Name: env.Name, + }) + } + + return resources, nil +} diff --git a/pkg/integrations/railway/trigger_deploy.go b/pkg/integrations/railway/trigger_deploy.go new file mode 100644 index 0000000000..7dc6f6949a --- /dev/null +++ b/pkg/integrations/railway/trigger_deploy.go @@ -0,0 +1,270 @@ +package railway + +import ( + "fmt" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +// TriggerDeploy is a stub for now - will be fully implemented in the next phase +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 TriggerDeployMetadata struct { + Project *ProjectInfo `json:"project" mapstructure:"project"` + Service *ServiceInfo `json:"service" mapstructure:"service"` + Environment *EnvironmentInfo `json:"environment" mapstructure:"environment"` +} + +type ServiceInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type EnvironmentInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func (c *TriggerDeploy) Name() string { + return "railway.triggerDeploy" +} + +func (c *TriggerDeploy) Label() string { + return "Trigger Deploy" +} + +func (c *TriggerDeploy) Description() string { + return "Trigger a new deployment for a Railway service" +} + +func (c *TriggerDeploy) Documentation() string { + return `The Trigger Deploy component starts a new deployment for a Railway service in a specific environment. + +## Use Cases + +- **Deploy on merge**: Automatically deploy when code is merged to main +- **Scheduled deployments**: Deploy on a schedule (e.g., nightly releases) +- **Manual approval**: Deploy after approval in the workflow +- **Cross-service orchestration**: Deploy services in sequence + +## Configuration + +- **Project**: Select the Railway project +- **Service**: Select the service to deploy +- **Environment**: Select the target environment (e.g., production, staging) + +## How It Works + +1. Calls Railway's ` + "`environmentTriggersDeploy`" + ` API +2. Railway queues a new deployment for the service +3. Component emits the deployment trigger result + +## Output + +The component emits: +- ` + "`project`" + `: Project ID +- ` + "`service`" + `: Service ID +- ` + "`environment`" + `: Environment ID +- ` + "`triggered`" + `: Whether the deployment was triggered` +} + +func (c *TriggerDeploy) Icon() string { + return "railway" +} + +func (c *TriggerDeploy) Color() string { + return "purple" +} + +func (c *TriggerDeploy) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "project", + Label: "Project", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "Railway project containing the service", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "project", + }, + }, + }, + { + Name: "service", + Label: "Service", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "Service to deploy", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "service", + Parameters: []configuration.ParameterRef{ + { + Name: "projectId", + ValueFrom: &configuration.ParameterValueFrom{Field: "project"}, + }, + }, + }, + }, + }, + { + Name: "environment", + Label: "Environment", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Description: "Target environment for the deployment", + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "environment", + Parameters: []configuration.ParameterRef{ + { + Name: "projectId", + ValueFrom: &configuration.ParameterValueFrom{Field: "project"}, + }, + }, + }, + }, + }, + } +} + +func (c *TriggerDeploy) OutputChannels(config any) []core.OutputChannel { + return []core.OutputChannel{ + core.DefaultOutputChannel, + } +} + +func (c *TriggerDeploy) ExampleOutput() map[string]any { + return map[string]any{ + "project": "proj-xyz789", + "service": "srv-ghi012", + "environment": "env-def456", + "triggered": true, + } +} + +func (c *TriggerDeploy) Setup(ctx core.SetupContext) error { + config := TriggerDeployConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if config.Project == "" { + return fmt.Errorf("project is required") + } + if config.Service == "" { + return fmt.Errorf("service is required") + } + if config.Environment == "" { + return fmt.Errorf("environment is required") + } + + // Validate the resources exist + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + project, err := client.GetProject(config.Project) + if err != nil { + return fmt.Errorf("failed to get project: %w", err) + } + + // Find service and environment in project + var serviceName, environmentName string + for _, svc := range project.Services { + if svc.ID == config.Service { + serviceName = svc.Name + break + } + } + for _, env := range project.Environments { + if env.ID == config.Environment { + environmentName = env.Name + break + } + } + + if serviceName == "" { + return fmt.Errorf("service not found in project") + } + if environmentName == "" { + return fmt.Errorf("environment not found in project") + } + + // Store metadata for display + return ctx.Metadata.Set(TriggerDeployMetadata{ + Project: &ProjectInfo{ID: project.ID, Name: project.Name}, + Service: &ServiceInfo{ID: config.Service, Name: serviceName}, + Environment: &EnvironmentInfo{ID: config.Environment, Name: environmentName}, + }) +} + +func (c *TriggerDeploy) Execute(ctx core.ExecutionContext) error { + config := TriggerDeployConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + // Call environmentTriggersDeploy mutation + if err := client.TriggerDeploy(config.Service, config.Environment); err != nil { + return fmt.Errorf("failed to trigger deployment: %w", err) + } + + ctx.Logger.Infof( + "Triggered deployment for service %s in environment %s", + config.Service, + config.Environment, + ) + + // Emit result + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "railway.deployment.triggered", + []any{map[string]any{ + "project": config.Project, + "service": config.Service, + "environment": config.Environment, + "triggered": true, + }}, + ) +} + +func (c *TriggerDeploy) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *TriggerDeploy) Actions() []core.Action { + return []core.Action{} +} + +func (c *TriggerDeploy) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *TriggerDeploy) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *TriggerDeploy) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *TriggerDeploy) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return 200, nil +} diff --git a/pkg/integrations/railway/trigger_deploy_test.go b/pkg/integrations/railway/trigger_deploy_test.go new file mode 100644 index 0000000000..e41b2dfd71 --- /dev/null +++ b/pkg/integrations/railway/trigger_deploy_test.go @@ -0,0 +1,95 @@ +package railway + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + contexts "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__TriggerDeploy__Setup(t *testing.T) { + component := TriggerDeploy{} + + t.Run("project is required", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + err := component.Setup(core.SetupContext{ + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"project": "", "service": "srv-123", "environment": "env-123"}, + }) + + require.ErrorContains(t, err, "project is required") + }) + + t.Run("service is required", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + err := component.Setup(core.SetupContext{ + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"project": "proj-123", "service": "", "environment": "env-123"}, + }) + + require.ErrorContains(t, err, "service is required") + }) + + t.Run("environment is required", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "test-token", + }, + } + err := component.Setup(core.SetupContext{ + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"project": "proj-123", "service": "srv-123", "environment": ""}, + }) + + require.ErrorContains(t, err, "environment is required") + }) +} + +func Test__TriggerDeploy__Configuration(t *testing.T) { + component := TriggerDeploy{} + config := component.Configuration() + + t.Run("has three required fields", func(t *testing.T) { + require.Len(t, config, 3) + + // Verify project field + assert.Equal(t, "project", config[0].Name) + assert.Equal(t, "Project", config[0].Label) + assert.True(t, config[0].Required) + assert.Equal(t, "integration-resource", config[0].Type) + assert.Equal(t, "project", config[0].TypeOptions.Resource.Type) + + // Verify service field + assert.Equal(t, "service", config[1].Name) + assert.Equal(t, "Service", config[1].Label) + assert.True(t, config[1].Required) + assert.Equal(t, "integration-resource", config[1].Type) + assert.Equal(t, "service", config[1].TypeOptions.Resource.Type) + require.Len(t, config[1].TypeOptions.Resource.Parameters, 1) + assert.Equal(t, "projectId", config[1].TypeOptions.Resource.Parameters[0].Name) + assert.Equal(t, "project", config[1].TypeOptions.Resource.Parameters[0].ValueFrom.Field) + + // Verify environment field + assert.Equal(t, "environment", config[2].Name) + assert.Equal(t, "Environment", config[2].Label) + assert.True(t, config[2].Required) + assert.Equal(t, "integration-resource", config[2].Type) + assert.Equal(t, "environment", config[2].TypeOptions.Resource.Type) + require.Len(t, config[2].TypeOptions.Resource.Parameters, 1) + assert.Equal(t, "projectId", config[2].TypeOptions.Resource.Parameters[0].Name) + assert.Equal(t, "project", config[2].TypeOptions.Resource.Parameters[0].ValueFrom.Field) + }) +} diff --git a/pkg/integrations/railway/webhook_handler.go b/pkg/integrations/railway/webhook_handler.go new file mode 100644 index 0000000000..0da1ed699b --- /dev/null +++ b/pkg/integrations/railway/webhook_handler.go @@ -0,0 +1,60 @@ +package railway + +import ( + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/core" +) + +type WebhookConfiguration struct { + Project string `json:"project" mapstructure:"project"` +} + +type WebhookMetadata struct { + // Railway webhooks are configured via UI, so we don't have a webhook ID + // This is kept for potential future use if Railway adds API support + Project string `json:"project" mapstructure:"project"` +} + +type RailwayWebhookHandler struct{} + +// CompareConfig checks if two webhook configurations are equivalent. +// Webhooks with the same project can be shared. +func (h *RailwayWebhookHandler) CompareConfig(a, b any) (bool, error) { + configA := WebhookConfiguration{} + if err := mapstructure.Decode(a, &configA); err != nil { + return false, err + } + + configB := WebhookConfiguration{} + if err := mapstructure.Decode(b, &configB); err != nil { + return false, err + } + + return configA.Project == configB.Project, nil +} + +// Setup is called when a webhook needs to be created. +// Since Railway webhooks are UI-only, we just return metadata. +// The webhook URL will be displayed to the user for manual setup. +func (h *RailwayWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) { + config := WebhookConfiguration{} + if err := mapstructure.Decode(ctx.Webhook.GetConfiguration(), &config); err != nil { + return nil, err + } + + // Railway doesn't have an API for creating webhooks + // Users must manually configure the webhook URL in Railway UI + // We just return metadata indicating the project + return WebhookMetadata{ + Project: config.Project, + }, nil +} + +// Cleanup is called when a webhook should be deleted. +// Since Railway webhooks are UI-only, we cannot delete them programmatically. +// Users must manually remove the webhook from Railway UI. +func (h *RailwayWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { + // Railway doesn't have an API for deleting webhooks + // Users must manually remove the webhook from Railway UI + 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..28077291cc --- /dev/null +++ b/pkg/integrations/railway/webhook_handler_test.go @@ -0,0 +1,197 @@ +package railway + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" +) + +func Test__RailwayWebhookHandler__CompareConfig(t *testing.T) { + handler := &RailwayWebhookHandler{} + + testCases := []struct { + name string + configA any + configB any + expectEqual bool + expectError bool + }{ + { + name: "identical configurations", + configA: WebhookConfiguration{ + Project: "proj-123", + }, + configB: WebhookConfiguration{ + Project: "proj-123", + }, + expectEqual: true, + expectError: false, + }, + { + name: "different projects", + configA: WebhookConfiguration{ + Project: "proj-123", + }, + configB: WebhookConfiguration{ + Project: "proj-456", + }, + expectEqual: false, + expectError: false, + }, + { + name: "comparing map representations", + configA: map[string]any{ + "project": "proj-123", + }, + configB: map[string]any{ + "project": "proj-123", + }, + expectEqual: true, + expectError: false, + }, + { + name: "map representations with different projects", + configA: map[string]any{ + "project": "proj-123", + }, + configB: map[string]any{ + "project": "proj-456", + }, + expectEqual: false, + expectError: false, + }, + { + name: "invalid first configuration", + configA: "invalid", + configB: WebhookConfiguration{ + Project: "proj-123", + }, + expectEqual: false, + expectError: true, + }, + { + name: "invalid second configuration", + configA: WebhookConfiguration{ + Project: "proj-123", + }, + configB: "invalid", + expectEqual: false, + expectError: true, + }, + { + name: "both configurations invalid", + configA: "invalid", + configB: 123, + expectEqual: false, + expectError: true, + }, + { + name: "empty project strings are equal", + configA: WebhookConfiguration{ + Project: "", + }, + configB: WebhookConfiguration{ + Project: "", + }, + expectEqual: true, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + equal, err := handler.CompareConfig(tc.configA, tc.configB) + + if tc.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + } + + assert.Equal(t, tc.expectEqual, equal) + }) + } +} + +func Test__RailwayWebhookHandler__Setup(t *testing.T) { + t.Run("returns metadata with project ID", func(t *testing.T) { + handler := &RailwayWebhookHandler{} + webhookCtx := &mockWebhookContext{ + configuration: WebhookConfiguration{ + Project: "proj-123", + }, + } + + ctx := core.WebhookHandlerContext{ + Webhook: webhookCtx, + } + + metadata, err := handler.Setup(ctx) + require.NoError(t, err) + + webhookMetadata, ok := metadata.(WebhookMetadata) + require.True(t, ok) + assert.Equal(t, "proj-123", webhookMetadata.Project) + }) + + t.Run("returns metadata from map configuration", func(t *testing.T) { + handler := &RailwayWebhookHandler{} + webhookCtx := &mockWebhookContext{ + configuration: map[string]any{ + "project": "proj-456", + }, + } + + ctx := core.WebhookHandlerContext{ + Webhook: webhookCtx, + } + + metadata, err := handler.Setup(ctx) + require.NoError(t, err) + + webhookMetadata, ok := metadata.(WebhookMetadata) + require.True(t, ok) + assert.Equal(t, "proj-456", webhookMetadata.Project) + }) +} + +func Test__RailwayWebhookHandler__Cleanup(t *testing.T) { + t.Run("cleanup returns no error", func(t *testing.T) { + handler := &RailwayWebhookHandler{} + ctx := core.WebhookHandlerContext{} + err := handler.Cleanup(ctx) + assert.NoError(t, err) + }) +} + +// Mock implementation of core.WebhookContext for testing + +type mockWebhookContext struct { + configuration any +} + +func (m *mockWebhookContext) GetID() string { + return "webhook-123" +} + +func (m *mockWebhookContext) GetURL() string { + return "https://example.com/webhook" +} + +func (m *mockWebhookContext) GetConfiguration() any { + return m.configuration +} + +func (m *mockWebhookContext) GetMetadata() any { + return nil +} + +func (m *mockWebhookContext) GetSecret() ([]byte, error) { + return nil, nil +} + +func (m *mockWebhookContext) SetSecret(secret []byte) error { + return nil +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 40c4986339..3b2c73d0e3 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -43,6 +43,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/integrations/jira" _ "github.com/superplanehq/superplane/pkg/integrations/openai" _ "github.com/superplanehq/superplane/pkg/integrations/pagerduty" + _ "github.com/superplanehq/superplane/pkg/integrations/railway" _ "github.com/superplanehq/superplane/pkg/integrations/rootly" _ "github.com/superplanehq/superplane/pkg/integrations/semaphore" _ "github.com/superplanehq/superplane/pkg/integrations/sendgrid" diff --git a/test/support/support.go b/test/support/support.go index 1d06772021..8d78d34fa5 100644 --- a/test/support/support.go +++ b/test/support/support.go @@ -30,6 +30,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/components/ssh" _ "github.com/superplanehq/superplane/pkg/components/wait" _ "github.com/superplanehq/superplane/pkg/integrations/github" + _ "github.com/superplanehq/superplane/pkg/integrations/railway" _ "github.com/superplanehq/superplane/pkg/integrations/semaphore" _ "github.com/superplanehq/superplane/pkg/triggers/schedule" _ "github.com/superplanehq/superplane/pkg/triggers/start" 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..93106345a1 --- /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 645e2cda38..ab339163c1 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -91,6 +91,12 @@ import { triggerRenderers as claudeTriggerRenderers, eventStateRegistry as claudeEventStateRegistry, } from "./claude/index"; +import { + componentMappers as railwayComponentMappers, + triggerRenderers as railwayTriggerRenderers, + customFieldRenderers as railwayCustomFieldRenderers, + eventStateRegistry as railwayEventStateRegistry, +} from "./railway/index"; import { filterMapper, FILTER_STATE_REGISTRY } from "./filter"; import { sshMapper, SSH_STATE_REGISTRY } from "./ssh"; @@ -139,6 +145,7 @@ const appMappers: Record> = { discord: discordComponentMappers, openai: openaiComponentMappers, claude: claudeComponentMappers, + railway: railwayComponentMappers, }; const appTriggerRenderers: Record> = { @@ -157,6 +164,7 @@ const appTriggerRenderers: Record> = { discord: discordTriggerRenderers, openai: openaiTriggerRenderers, claude: claudeTriggerRenderers, + railway: railwayTriggerRenderers, }; const appEventStateRegistries: Record> = { @@ -175,6 +183,7 @@ const appEventStateRegistries: Record openai: openaiEventStateRegistry, claude: claudeEventStateRegistry, aws: awsEventStateRegistry, + railway: railwayEventStateRegistry, }; const componentAdditionalDataBuilders: Record = { @@ -200,6 +209,7 @@ const customFieldRenderers: Record = { const appCustomFieldRenderers: Record> = { github: githubCustomFieldRenderers, + railway: railwayCustomFieldRenderers, }; /** diff --git a/web_src/src/pages/workflowv2/mappers/railway/custom_field_renderer.tsx b/web_src/src/pages/workflowv2/mappers/railway/custom_field_renderer.tsx new file mode 100644 index 0000000000..f5c4487c5e --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/railway/custom_field_renderer.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import { CustomFieldRenderer, NodeInfo } from "../types"; +import { Icon } from "@/components/Icon"; +import { showErrorToast } from "@/utils/toast"; + +interface OnDeploymentEventMetadata { + project?: { + id: string; + name: string; + }; + webhookUrl?: string; +} + +/** + * Copy button component for code blocks + */ +const CopyButton: React.FC<{ text: string }> = ({ text }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (_err) { + showErrorToast("Failed to copy text"); + } + }; + + return ( + + ); +}; + +/** + * Custom field renderer for Railway On Deployment Event trigger + * Shows the webhook URL that needs to be configured in Railway + */ +export const onDeploymentEventCustomFieldRenderer: CustomFieldRenderer = { + render: (node: NodeInfo) => { + const metadata = node.metadata as OnDeploymentEventMetadata | undefined; + const webhookUrl = metadata?.webhookUrl || "[URL GENERATED ONCE THE CANVAS IS SAVED]"; + + const curlExample = `curl -X POST \\ + -H "Content-Type: application/json" \\ + --data '{"type":"Deployment.succeeded","details":{"status":"SUCCESS"},"resource":{"deployment":{"id":"test-123"}}}' \\ + ${webhookUrl}`; + + return ( +
+
+
+ + Railway Webhook Configuration + +

+ Copy this URL and add it to your Railway project's webhook settings. +

+ + {/* Webhook URL Copy Field */} +
+ +
+ + +
+
+ + {/* Setup Instructions */} +
+
+ +
+

Setup Instructions

+
    +
  1. Go to your Railway project
  2. +
  3. Navigate to Settings → Webhooks
  4. +
  5. Click "Add Webhook"
  6. +
  7. Paste the webhook URL above
  8. +
  9. Select "Deploy" events
  10. +
  11. Save the webhook
  12. +
+
+
+
+ + {/* Test Command */} + {metadata?.webhookUrl && ( +
+ +
+
+                    {curlExample}
+                  
+ +
+
+ )} + + {!metadata?.webhookUrl && ( +

+ Save the canvas to generate the webhook URL. +

+ )} +
+
+
+ ); + }, +}; 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..81648b19b0 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/railway/index.ts @@ -0,0 +1,56 @@ +import { + ComponentBaseMapper, + TriggerRenderer, + CustomFieldRenderer, + EventStateRegistry, + ExecutionInfo, + StateFunction, +} from "../types"; +import { onDeploymentEventTriggerRenderer } from "./on_deployment_event"; +import { triggerDeployMapper } from "./trigger_deploy"; +import { onDeploymentEventCustomFieldRenderer } from "./custom_field_renderer"; +import { DEFAULT_EVENT_STATE_MAP, EventState, EventStateMap } from "@/ui/componentBase"; +import { defaultStateFunction } from "../stateRegistry"; + +export const TRIGGER_DEPLOY_STATE_MAP: EventStateMap = { + ...DEFAULT_EVENT_STATE_MAP, + failed: { + icon: "circle-x", + textColor: "text-gray-800", + backgroundColor: "bg-red-100", + badgeColor: "bg-red-400", + }, +}; + +export const triggerDeployStateFunction: StateFunction = (execution: ExecutionInfo): EventState => { + if (!execution) return "neutral"; + + // Check for failed output + const outputs = execution.outputs as { failed?: { data?: unknown }[] } | undefined; + if (outputs?.failed?.length) { + return "failed"; + } + + return defaultStateFunction(execution); +}; + +export const TRIGGER_DEPLOY_STATE_REGISTRY: EventStateRegistry = { + stateMap: TRIGGER_DEPLOY_STATE_MAP, + getState: triggerDeployStateFunction, +}; + +export const componentMappers: Record = { + triggerDeploy: triggerDeployMapper, +}; + +export const triggerRenderers: Record = { + onDeploymentEvent: onDeploymentEventTriggerRenderer, +}; + +export const customFieldRenderers: Record = { + onDeploymentEvent: onDeploymentEventCustomFieldRenderer, +}; + +export const eventStateRegistry: Record = { + triggerDeploy: TRIGGER_DEPLOY_STATE_REGISTRY, +}; diff --git a/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts b/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts new file mode 100644 index 0000000000..bdac6c3d75 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts @@ -0,0 +1,115 @@ +import { getColorClass, getBackgroundColorClass } from "@/utils/colors"; +import { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; +import { TriggerProps } from "@/ui/trigger"; +import RailwayLogo from "@/assets/icons/integrations/railway.svg"; +import { formatTimeAgo } from "@/utils/date"; + +interface OnDeploymentEventMetadata { + project?: { + id: string; + name: string; + }; +} + +interface OnDeploymentEventConfiguration { + statuses?: string[]; +} + +interface DeploymentResource { + workspace?: { id?: string; name?: string }; + project?: { id?: string; name?: string }; + environment?: { id?: string; name?: string }; + service?: { id?: string; name?: string }; + deployment?: { id?: string; status?: string }; +} + +interface OnDeploymentEventData { + type?: string; + details?: { message?: string }; + resource?: DeploymentResource; + severity?: string; + timestamp?: string; +} + +/** + * Renderer for the "railway.onDeploymentEvent" trigger type + */ +export const onDeploymentEventTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => { + const eventData = context.event?.data as OnDeploymentEventData; + const serviceName = eventData?.resource?.service?.name || "Service"; + const status = eventData?.resource?.deployment?.status || ""; + const timeAgo = context.event?.createdAt ? formatTimeAgo(new Date(context.event?.createdAt)) : ""; + const subtitle = status && timeAgo ? `${status.toLowerCase()} · ${timeAgo}` : status.toLowerCase() || timeAgo; + + return { + title: `${serviceName} deployment`, + subtitle, + }; + }, + + getRootEventValues: (context: TriggerEventContext): Record => { + const eventData = context.event?.data as OnDeploymentEventData; + const resource = eventData?.resource; + const timestamp = eventData?.timestamp ? new Date(eventData.timestamp).toLocaleString() : ""; + + return { + Service: resource?.service?.name || "", + Status: resource?.deployment?.status || "", + Environment: resource?.environment?.name || "", + Project: resource?.project?.name || "", + Workspace: resource?.workspace?.name || "", + Timestamp: timestamp, + Severity: eventData?.severity || "", + }; + }, + + getTriggerProps: (context: TriggerRendererContext): TriggerProps => { + const { node, definition, lastEvent } = context; + const metadata = node.metadata as unknown as OnDeploymentEventMetadata; + const configuration = node.configuration as unknown as OnDeploymentEventConfiguration; + const metadataItems = []; + + // Show project name + if (metadata?.project?.name) { + metadataItems.push({ + icon: "folder", + label: metadata.project.name, + }); + } + + // Show status filter if configured + if (configuration?.statuses && configuration.statuses.length > 0) { + metadataItems.push({ + icon: "filter", + label: configuration.statuses.join(", "), + }); + } + + const props: TriggerProps = { + title: node.name || definition.label || "On Deployment Event", + iconSrc: RailwayLogo, + iconColor: getColorClass(definition.color), + collapsedBackground: getBackgroundColorClass(definition.color), + metadata: metadataItems, + }; + + if (lastEvent) { + const eventData = lastEvent.data as OnDeploymentEventData; + const serviceName = eventData?.resource?.service?.name || "Service"; + const status = eventData?.resource?.deployment?.status || ""; + const timeAgo = lastEvent.createdAt ? formatTimeAgo(new Date(lastEvent.createdAt)) : ""; + const subtitle = status && timeAgo ? `${status.toLowerCase()} · ${timeAgo}` : status.toLowerCase() || timeAgo; + + props.lastEventData = { + title: `${serviceName} deployment`, + subtitle, + 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..49d484dcd2 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts @@ -0,0 +1,98 @@ +import { getColorClass, getBackgroundColorClass } from "@/utils/colors"; +import { + ComponentBaseMapper, + ComponentBaseContext, + SubtitleContext, + ExecutionDetailsContext, +} from "../types"; +import RailwayLogo from "@/assets/icons/integrations/railway.svg"; +import { formatTimeAgo } from "@/utils/date"; + +interface TriggerDeployMetadata { + project?: { id?: string; name?: string }; + service?: { id?: string; name?: string }; + environment?: { id?: string; name?: string }; +} + +/** + * Mapper for the "railway.triggerDeploy" component type + */ +export const triggerDeployMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext) { + const { node, componentDefinition } = context; + const metadata = node.metadata as unknown as TriggerDeployMetadata; + const metadataItems = []; + + // Show service name + if (metadata?.service?.name) { + metadataItems.push({ + icon: "box", + label: metadata.service.name, + }); + } + + // Show environment name + if (metadata?.environment?.name) { + metadataItems.push({ + icon: "globe", + label: metadata.environment.name, + }); + } + + return { + iconSrc: RailwayLogo, + iconColor: getColorClass(componentDefinition.color), + collapsedBackground: getBackgroundColorClass(componentDefinition.color), + title: node.name || componentDefinition.label || "Trigger Deploy", + metadata: metadataItems, + }; + }, + + subtitle(context: SubtitleContext): string { + const { execution } = context; + + if (execution.state === "STATE_FINISHED" && execution.result === "RESULT_PASSED") { + const updatedAt = execution.updatedAt ? new Date(execution.updatedAt) : null; + return updatedAt ? `Triggered ${formatTimeAgo(updatedAt)}` : "Triggered"; + } + + if (execution.state === "STATE_FINISHED" && execution.result === "RESULT_FAILED") { + const updatedAt = execution.updatedAt ? new Date(execution.updatedAt) : null; + return updatedAt ? `Failed ${formatTimeAgo(updatedAt)}` : "Failed"; + } + + if (execution.state === "STATE_STARTED") { + return "Deploying..."; + } + + const createdAt = execution.createdAt ? new Date(execution.createdAt) : null; + return createdAt ? formatTimeAgo(createdAt) : ""; + }, + + getExecutionDetails(context: ExecutionDetailsContext) { + const { execution, node } = context; + const details: Record = {}; + + if (execution.createdAt) { + details["Started At"] = new Date(execution.createdAt).toLocaleString(); + } + + if (execution.updatedAt && execution.state === "STATE_FINISHED") { + details["Finished At"] = new Date(execution.updatedAt).toLocaleString(); + } + + // Add metadata info + const metadata = node.metadata as unknown as TriggerDeployMetadata; + if (metadata?.project?.name) { + details["Project"] = metadata.project.name; + } + if (metadata?.service?.name) { + details["Service"] = metadata.service.name; + } + if (metadata?.environment?.name) { + details["Environment"] = metadata.environment.name; + } + + return details; + }, +}; From 97fe0cbc1911a37de57d037652375b661d28c918 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Wed, 11 Feb 2026 13:05:46 +0300 Subject: [PATCH 02/29] fix: implemented missing method Signed-off-by: Shambel Amare --- pkg/integrations/railway/webhook_handler.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/integrations/railway/webhook_handler.go b/pkg/integrations/railway/webhook_handler.go index 0da1ed699b..c359f2f6fe 100644 --- a/pkg/integrations/railway/webhook_handler.go +++ b/pkg/integrations/railway/webhook_handler.go @@ -33,6 +33,12 @@ func (h *RailwayWebhookHandler) CompareConfig(a, b any) (bool, error) { return configA.Project == configB.Project, nil } +// Merge combines current and requested webhook configurations. +// Since Railway webhooks are manually configured, we just return the current config. +func (h *RailwayWebhookHandler) Merge(current, requested any) (any, bool, error) { + return current, false, nil +} + // Setup is called when a webhook needs to be created. // Since Railway webhooks are UI-only, we just return metadata. // The webhook URL will be displayed to the user for manual setup. From 6ff356201cb2f8db3cc8f7ca4eac3547e1005a3c Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Wed, 11 Feb 2026 13:21:41 +0300 Subject: [PATCH 03/29] fix: check for empty event action - setup early skips metadata update on config change Signed-off-by: Shambel Amare --- .../railway/on_deployment_event.go | 46 ++++--------------- .../railway/on_deployment_event_test.go | 26 +++++++++++ 2 files changed, 34 insertions(+), 38 deletions(-) diff --git a/pkg/integrations/railway/on_deployment_event.go b/pkg/integrations/railway/on_deployment_event.go index 28adcc10ff..1e9b313ba1 100644 --- a/pkg/integrations/railway/on_deployment_event.go +++ b/pkg/integrations/railway/on_deployment_event.go @@ -122,48 +122,12 @@ func (t *OnDeploymentEvent) Configuration() []configuration.Field { } } -func (t *OnDeploymentEvent) ExampleData() map[string]any { - return map[string]any{ - "type": "Deployment.succeeded", - "details": map[string]any{ - "status": "SUCCESS", - }, - "resource": map[string]any{ - "owner": map[string]any{ - "id": "owner-abc123", - "email": "user@example.com", - }, - "project": map[string]any{ - "id": "proj-xyz789", - "name": "my-project", - }, - "environment": map[string]any{ - "id": "env-def456", - "name": "production", - }, - "service": map[string]any{ - "id": "srv-ghi012", - "name": "api-server", - }, - "deployment": map[string]any{ - "id": "deploy-jkl345", - }, - }, - "timestamp": "2024-01-15T10:30:00.000Z", - } -} - func (t *OnDeploymentEvent) Setup(ctx core.TriggerContext) error { var metadata OnDeploymentEventMetadata if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { return fmt.Errorf("failed to parse metadata: %w", err) } - // If already set up with webhook URL, nothing to do - if metadata.Project != nil && metadata.WebhookURL != "" { - return nil - } - config := OnDeploymentEventConfiguration{} if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { return fmt.Errorf("failed to decode configuration: %w", err) @@ -173,6 +137,11 @@ func (t *OnDeploymentEvent) Setup(ctx core.TriggerContext) error { return fmt.Errorf("project is required") } + // If already set up with matching project and webhook URL, nothing to do + if metadata.Project != nil && metadata.Project.ID == config.Project && metadata.WebhookURL != "" { + return nil + } + // Fetch project details to validate it exists client, err := NewClient(ctx.HTTP, ctx.Integration) if err != nil { @@ -234,8 +203,9 @@ func (t *OnDeploymentEvent) HandleWebhook(ctx core.WebhookRequestContext) (int, } // Filter by event action if configured - if len(config.Statuses) > 0 && eventAction != "" { - if !slices.Contains(config.Statuses, eventAction) { + if len(config.Statuses) > 0 { + // Reject events with empty action when filter is active + if eventAction == "" || !slices.Contains(config.Statuses, eventAction) { return http.StatusOK, nil } } diff --git a/pkg/integrations/railway/on_deployment_event_test.go b/pkg/integrations/railway/on_deployment_event_test.go index cbb790fcd0..84af8e42a5 100644 --- a/pkg/integrations/railway/on_deployment_event_test.go +++ b/pkg/integrations/railway/on_deployment_event_test.go @@ -216,6 +216,32 @@ func Test__OnDeploymentEvent__HandleWebhook(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 1, eventContext.Count()) }) + + t.Run("deployment event with empty action and filter configured -> event is not emitted", func(t *testing.T) { + body := []byte(`{ + "type": "Deployment.", + "resource": { + "deployment": { + "id": "deploy-123" + } + } + }`) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: http.Header{}, + Configuration: map[string]any{ + "project": "proj-123", + "statuses": []string{"succeeded", "failed"}, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Zero(t, eventContext.Count()) + }) } func Test__OnDeploymentEvent__Setup(t *testing.T) { From 0988b3eb3621318c9df36fc9ecaef2f9ea34626c Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Wed, 11 Feb 2026 13:22:22 +0300 Subject: [PATCH 04/29] fix: use example json files similar to other integrations Signed-off-by: Shambel Amare --- pkg/integrations/railway/example.go | 36 +++++++++++++++++++ .../example_data_on_deployment_event.json | 14 ++++---- pkg/integrations/railway/trigger_deploy.go | 9 ----- 3 files changed, 42 insertions(+), 17 deletions(-) create mode 100644 pkg/integrations/railway/example.go diff --git a/pkg/integrations/railway/example.go b/pkg/integrations/railway/example.go new file mode 100644 index 0000000000..7b0aebe7a1 --- /dev/null +++ b/pkg/integrations/railway/example.go @@ -0,0 +1,36 @@ +package railway + +import ( + _ "embed" + "sync" + + "github.com/superplanehq/superplane/pkg/utils" +) + +//go:embed example_output_trigger_deploy.json +var exampleOutputTriggerDeployBytes []byte + +//go:embed example_data_on_deployment_event.json +var exampleDataOnDeploymentEventBytes []byte + +var exampleOutputTriggerDeployOnce sync.Once +var exampleOutputTriggerDeploy map[string]any + +var exampleDataOnDeploymentEventOnce sync.Once +var exampleDataOnDeploymentEvent map[string]any + +func (c *TriggerDeploy) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON( + &exampleOutputTriggerDeployOnce, + exampleOutputTriggerDeployBytes, + &exampleOutputTriggerDeploy, + ) +} + +func (t *OnDeploymentEvent) ExampleData() map[string]any { + return utils.UnmarshalEmbeddedJSON( + &exampleDataOnDeploymentEventOnce, + exampleDataOnDeploymentEventBytes, + &exampleDataOnDeploymentEvent, + ) +} diff --git a/pkg/integrations/railway/example_data_on_deployment_event.json b/pkg/integrations/railway/example_data_on_deployment_event.json index cc8bf1415f..88db8a80a7 100644 --- a/pkg/integrations/railway/example_data_on_deployment_event.json +++ b/pkg/integrations/railway/example_data_on_deployment_event.json @@ -1,12 +1,12 @@ { - "type": "DEPLOY", + "type": "Deployment.succeeded", "details": { - "message": "Deployment succeeded" + "status": "SUCCESS" }, "resource": { - "workspace": { - "id": "ws-abc123", - "name": "my-workspace" + "owner": { + "id": "owner-abc123", + "email": "user@example.com" }, "project": { "id": "proj-xyz789", @@ -21,10 +21,8 @@ "name": "api-server" }, "deployment": { - "id": "deploy-jkl345", - "status": "SUCCESS" + "id": "deploy-jkl345" } }, - "severity": "info", "timestamp": "2024-01-15T10:30:00.000Z" } diff --git a/pkg/integrations/railway/trigger_deploy.go b/pkg/integrations/railway/trigger_deploy.go index 7dc6f6949a..91d35d7957 100644 --- a/pkg/integrations/railway/trigger_deploy.go +++ b/pkg/integrations/railway/trigger_deploy.go @@ -144,15 +144,6 @@ func (c *TriggerDeploy) OutputChannels(config any) []core.OutputChannel { } } -func (c *TriggerDeploy) ExampleOutput() map[string]any { - return map[string]any{ - "project": "proj-xyz789", - "service": "srv-ghi012", - "environment": "env-def456", - "triggered": true, - } -} - func (c *TriggerDeploy) Setup(ctx core.SetupContext) error { config := TriggerDeployConfiguration{} if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { From 529e8b9726ecf61dde5de84b41b384a53b33bdb8 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Wed, 11 Feb 2026 13:39:21 +0300 Subject: [PATCH 05/29] fix: read status from correct path Signed-off-by: Shambel Amare --- .../mappers/railway/on_deployment_event.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts b/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts index bdac6c3d75..9b33af63d9 100644 --- a/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts +++ b/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts @@ -16,18 +16,17 @@ interface OnDeploymentEventConfiguration { } interface DeploymentResource { - workspace?: { id?: string; name?: string }; + owner?: { id?: string; email?: string }; project?: { id?: string; name?: string }; environment?: { id?: string; name?: string }; service?: { id?: string; name?: string }; - deployment?: { id?: string; status?: string }; + deployment?: { id?: string }; } interface OnDeploymentEventData { type?: string; - details?: { message?: string }; + details?: { status?: string }; resource?: DeploymentResource; - severity?: string; timestamp?: string; } @@ -38,7 +37,7 @@ export const onDeploymentEventTriggerRenderer: TriggerRenderer = { getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => { const eventData = context.event?.data as OnDeploymentEventData; const serviceName = eventData?.resource?.service?.name || "Service"; - const status = eventData?.resource?.deployment?.status || ""; + const status = eventData?.details?.status || ""; const timeAgo = context.event?.createdAt ? formatTimeAgo(new Date(context.event?.createdAt)) : ""; const subtitle = status && timeAgo ? `${status.toLowerCase()} · ${timeAgo}` : status.toLowerCase() || timeAgo; @@ -55,12 +54,10 @@ export const onDeploymentEventTriggerRenderer: TriggerRenderer = { return { Service: resource?.service?.name || "", - Status: resource?.deployment?.status || "", + Status: eventData?.details?.status || "", Environment: resource?.environment?.name || "", Project: resource?.project?.name || "", - Workspace: resource?.workspace?.name || "", Timestamp: timestamp, - Severity: eventData?.severity || "", }; }, @@ -97,7 +94,7 @@ export const onDeploymentEventTriggerRenderer: TriggerRenderer = { if (lastEvent) { const eventData = lastEvent.data as OnDeploymentEventData; const serviceName = eventData?.resource?.service?.name || "Service"; - const status = eventData?.resource?.deployment?.status || ""; + const status = eventData?.details?.status || ""; const timeAgo = lastEvent.createdAt ? formatTimeAgo(new Date(lastEvent.createdAt)) : ""; const subtitle = status && timeAgo ? `${status.toLowerCase()} · ${timeAgo}` : status.toLowerCase() || timeAgo; From 32136a507b78751e1dfc3766a6a5a4b045497185 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Wed, 11 Feb 2026 13:40:24 +0300 Subject: [PATCH 06/29] fix: add prettier syntax Signed-off-by: Shambel Amare --- .../workflowv2/mappers/railway/custom_field_renderer.tsx | 4 +--- .../src/pages/workflowv2/mappers/railway/trigger_deploy.ts | 7 +------ 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/web_src/src/pages/workflowv2/mappers/railway/custom_field_renderer.tsx b/web_src/src/pages/workflowv2/mappers/railway/custom_field_renderer.tsx index f5c4487c5e..d73c902340 100644 --- a/web_src/src/pages/workflowv2/mappers/railway/custom_field_renderer.tsx +++ b/web_src/src/pages/workflowv2/mappers/railway/custom_field_renderer.tsx @@ -56,9 +56,7 @@ export const onDeploymentEventCustomFieldRenderer: CustomFieldRenderer = {
- - Railway Webhook Configuration - + Railway Webhook Configuration

Copy this URL and add it to your Railway project's webhook settings.

diff --git a/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts b/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts index 49d484dcd2..cc0d9f9162 100644 --- a/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts +++ b/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts @@ -1,10 +1,5 @@ import { getColorClass, getBackgroundColorClass } from "@/utils/colors"; -import { - ComponentBaseMapper, - ComponentBaseContext, - SubtitleContext, - ExecutionDetailsContext, -} from "../types"; +import { ComponentBaseMapper, ComponentBaseContext, SubtitleContext, ExecutionDetailsContext } from "../types"; import RailwayLogo from "@/assets/icons/integrations/railway.svg"; import { formatTimeAgo } from "@/utils/date"; From 5a175e0f2a174200a131ef4b05b3dc113fcf20a0 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Wed, 11 Feb 2026 13:53:53 +0300 Subject: [PATCH 07/29] fix: generate latest doc Signed-off-by: Shambel Amare --- docs/components/Railway.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/components/Railway.mdx b/docs/components/Railway.mdx index 007c830176..758f225fc8 100644 --- a/docs/components/Railway.mdx +++ b/docs/components/Railway.mdx @@ -28,6 +28,7 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; 2. Click **"Create Token"** to generate a new API token 3. Give your token a descriptive name (e.g., "SuperPlane Integration") + ### Token Scoping Railway offers different token scoping options: @@ -35,7 +36,7 @@ Railway offers different token scoping options: | Scope | Access Level | Use Case | |-------|--------------|----------| | **No Workspace** | All workspaces and projects | Recommended for SuperPlane | -| **Team/Workspace** | Projects in a specific workspace only | Limited to one workspace | +| **Project-specific** | Projects in a specific workspace only | Limited to one workspace | **Recommendation:** Select **"No Workspace"** when creating your token. This allows SuperPlane to access all your projects across workspaces. If you scope the token to a specific workspace, only projects in that workspace will be available. From 54b2743c202dd05a25b17ddf44d463e40aca07fd Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Wed, 11 Feb 2026 13:58:06 +0300 Subject: [PATCH 08/29] fix: validate project-id from incomming webhook payload before processing Signed-off-by: Shambel Amare --- pkg/integrations/railway/on_deployment_event.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pkg/integrations/railway/on_deployment_event.go b/pkg/integrations/railway/on_deployment_event.go index 1e9b313ba1..829d3bccf8 100644 --- a/pkg/integrations/railway/on_deployment_event.go +++ b/pkg/integrations/railway/on_deployment_event.go @@ -202,6 +202,19 @@ func (t *OnDeploymentEvent) HandleWebhook(ctx core.WebhookRequestContext) (int, return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err) } + // Validate the project matches the configured project + // This is important since Railway doesn't provide webhook signatures + if resource, ok := payload["resource"].(map[string]any); ok { + if project, ok := resource["project"].(map[string]any); ok { + if projectID, ok := project["id"].(string); ok { + if projectID != config.Project { + // Event is from a different project, ignore + return http.StatusOK, nil + } + } + } + } + // Filter by event action if configured if len(config.Statuses) > 0 { // Reject events with empty action when filter is active From b8ceb8520d8f482cc26710e20a8bf437f9a9439e Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Wed, 11 Feb 2026 13:58:33 +0300 Subject: [PATCH 09/29] fix: remove unused method Signed-off-by: Shambel Amare --- pkg/integrations/railway/client.go | 34 ------------------------------ 1 file changed, 34 deletions(-) diff --git a/pkg/integrations/railway/client.go b/pkg/integrations/railway/client.go index 0d6f5a7517..03b12580d9 100644 --- a/pkg/integrations/railway/client.go +++ b/pkg/integrations/railway/client.go @@ -17,12 +17,6 @@ type Client struct { http core.HTTPContext } -type User struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` -} - type Project struct { ID string `json:"id"` Name string `json:"name"` @@ -124,34 +118,6 @@ func (c *Client) graphql(query string, variables map[string]any) (map[string]any return gqlResp.Data, nil } -func (c *Client) GetCurrentUser() (*User, error) { - query := ` - query { - me { - id - name - email - } - } - ` - - data, err := c.graphql(query, nil) - if err != nil { - return nil, err - } - - meData, ok := data["me"].(map[string]any) - if !ok { - return nil, fmt.Errorf("unexpected response format") - } - - return &User{ - ID: getString(meData, "id"), - Name: getString(meData, "name"), - Email: getString(meData, "email"), - }, nil -} - func (c *Client) ListProjects() ([]Project, error) { query := ` query { From 77fdeb32374a04fc5284ed09978747d876827b26 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 00:32:03 +0300 Subject: [PATCH 10/29] fix: non-workspace railway tokens have no access to projects project scoped token required, updated docs with proper description Signed-off-by: Shambel Amare --- docs/components/Railway.mdx | 16 +++++++++------- pkg/integrations/railway/railway.go | 18 ++++++++++-------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/components/Railway.mdx b/docs/components/Railway.mdx index 758f225fc8..d6427560c2 100644 --- a/docs/components/Railway.mdx +++ b/docs/components/Railway.mdx @@ -29,16 +29,18 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; 3. Give your token a descriptive name (e.g., "SuperPlane Integration") -### Token Scoping +### Token Scoping (Important!) -Railway offers different token scoping options: +When creating a Railway token, you must select a **specific workspace** to grant access to its projects: | Scope | Access Level | Use Case | |-------|--------------|----------| -| **No Workspace** | All workspaces and projects | Recommended for SuperPlane | -| **Project-specific** | Projects in a specific workspace only | Limited to one workspace | +| **Specific Workspace** | Projects in that workspace | **Recommended for SuperPlane** | +| **No Workspace** | No projects accessible | Not suitable for SuperPlane | -**Recommendation:** Select **"No Workspace"** when creating your token. This allows SuperPlane to access all your projects across workspaces. If you scope the token to a specific workspace, only projects in that workspace will be available. +**Important:** Select the **workspace containing your projects** when creating your token. The "No Workspace" option does not provide access to any projects because it lacks a workspace context. + +If you have projects in multiple workspaces, you'll need to create separate integrations with tokens scoped to each workspace. ### Permissions @@ -58,8 +60,8 @@ For the **On Deployment Event** trigger, you'll need to manually configure webho ### Troubleshooting -- **"Not Authorized" error**: Your token may be scoped to a workspace that doesn't include the project you're trying to access. Create a new token with "No Scoping" selected. -- **Projects not showing**: Try disconnecting and reconnecting the integration to refresh the project list. +- **"Not Authorized" error**: Your token may be scoped to a workspace that doesn't include the project you're trying to access. +- **Projects not showing**: Make sure your token is scoped to a specific workspace, not "No Workspace". Try disconnecting and reconnecting the integration after creating a new token. diff --git a/pkg/integrations/railway/railway.go b/pkg/integrations/railway/railway.go index ed5636d285..42d7d86410 100644 --- a/pkg/integrations/railway/railway.go +++ b/pkg/integrations/railway/railway.go @@ -54,16 +54,18 @@ func (r *Railway) Instructions() string { 3. Give your token a descriptive name (e.g., "SuperPlane Integration") -### Token Scoping +### Token Scoping (Important!) -Railway offers different token scoping options: +When creating a Railway token, you must select a **specific workspace** to grant access to its projects: | Scope | Access Level | Use Case | |-------|--------------|----------| -| **No Workspace** | All workspaces and projects | Recommended for SuperPlane | -| **Project-specific** | Projects in a specific workspace only | Limited to one workspace | +| **Specific Workspace** | Projects in that workspace | **Recommended for SuperPlane** | +| **No Workspace** | No projects accessible | Not suitable for SuperPlane | -**Recommendation:** Select **"No Workspace"** when creating your token. This allows SuperPlane to access all your projects across workspaces. If you scope the token to a specific workspace, only projects in that workspace will be available. +**Important:** Select the **workspace containing your projects** when creating your token. The "No Workspace" option does not provide access to any projects because it lacks a workspace context. + +If you have projects in multiple workspaces, you'll need to create separate integrations with tokens scoped to each workspace. ### Permissions @@ -83,8 +85,8 @@ For the **On Deployment Event** trigger, you'll need to manually configure webho ### Troubleshooting -- **"Not Authorized" error**: Your token may be scoped to a workspace that doesn't include the project you're trying to access. Create a new token with "No Scoping" selected. -- **Projects not showing**: Try disconnecting and reconnecting the integration to refresh the project list.` +- **"Not Authorized" error**: Your token may be scoped to a workspace that doesn't include the project you're trying to access. +- **Projects not showing**: Make sure your token is scoped to a specific workspace, not "No Workspace". Try disconnecting and reconnecting the integration after creating a new token.` } func (r *Railway) Configuration() []configuration.Field { @@ -95,7 +97,7 @@ func (r *Railway) Configuration() []configuration.Field { Type: configuration.FieldTypeString, Sensitive: true, Required: true, - Description: "Create a token at railway.app/account/tokens. Use 'No Workspace' to access all projects.", + Description: "Create a token at railway.app/account/tokens. Select a specific workspace to access its projects.", Placeholder: "YOUR RAILWAY API TOKEN", }, } From 9d468774a5ff60415488f65580a68d96edc15598 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 01:15:44 +0300 Subject: [PATCH 11/29] fix: use correct railway webhook event keys Signed-off-by: Shambel Amare --- .../example_data_on_deployment_event.json | 25 ++++++++---- .../railway/on_deployment_event.go | 38 ++++++++++++------- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/pkg/integrations/railway/example_data_on_deployment_event.json b/pkg/integrations/railway/example_data_on_deployment_event.json index 88db8a80a7..fc61081e66 100644 --- a/pkg/integrations/railway/example_data_on_deployment_event.json +++ b/pkg/integrations/railway/example_data_on_deployment_event.json @@ -1,12 +1,21 @@ { - "type": "Deployment.succeeded", + "type": "Deployment.deployed", + "severity": "INFO", + "timestamp": "2024-01-15T10:30:00.000Z", "details": { - "status": "SUCCESS" + "id": "deploy-abc123", + "status": "SUCCESS", + "source": "GitHub", + "branch": "main", + "commitHash": "abc123def456", + "commitMessage": "Update deployment", + "commitAuthor": "developer", + "serviceId": "srv-xyz789" }, "resource": { - "owner": { - "id": "owner-abc123", - "email": "user@example.com" + "workspace": { + "id": "ws-abc123", + "name": "My Workspace" }, "project": { "id": "proj-xyz789", @@ -14,7 +23,8 @@ }, "environment": { "id": "env-def456", - "name": "production" + "name": "production", + "isEphemeral": false }, "service": { "id": "srv-ghi012", @@ -23,6 +33,5 @@ "deployment": { "id": "deploy-jkl345" } - }, - "timestamp": "2024-01-15T10:30:00.000Z" + } } diff --git a/pkg/integrations/railway/on_deployment_event.go b/pkg/integrations/railway/on_deployment_event.go index 829d3bccf8..f47e239c10 100644 --- a/pkg/integrations/railway/on_deployment_event.go +++ b/pkg/integrations/railway/on_deployment_event.go @@ -54,21 +54,21 @@ After configuring this trigger: ## Use Cases -- **Deployment notifications**: Notify Slack when deployments succeed or fail +- **Deployment notifications**: Notify Slack when deployments complete or fail - **Incident creation**: Create tickets when deployments crash - **Pipeline chaining**: Trigger downstream workflows after successful deployments ## Configuration - **Project**: Select the Railway project to monitor -- **Event Filter**: Optionally filter by deployment event type (succeeded, failed, crashed, etc.) +- **Event Filter**: Optionally filter by deployment event type (deployed, failed, crashed, etc.) - Leave empty to receive all deployment events ## Event Data Each deployment event includes: -- ` + "`type`" + `: Event type (e.g., Deployment.succeeded, Deployment.failed) -- ` + "`details.status`" + `: Deployment status +- ` + "`type`" + `: Event type (e.g., Deployment.deployed, Deployment.failed) +- ` + "`details.status`" + `: Deployment status (SUCCESS, FAILED, etc.) - ` + "`resource.deployment.id`" + `: Deployment ID - ` + "`resource.service`" + `: Service name and ID - ` + "`resource.environment`" + `: Environment name and ID @@ -107,14 +107,21 @@ func (t *OnDeploymentEvent) Configuration() []configuration.Field { TypeOptions: &configuration.TypeOptions{ MultiSelect: &configuration.MultiSelectTypeOptions{ Options: []configuration.FieldOption{ - {Label: "Succeeded", Value: "succeeded"}, - {Label: "Failed", Value: "failed"}, + {Label: "Triggered", Value: "triggered"}, + {Label: "Resolved", Value: "resolved"}, + {Label: "Deployed", Value: "deployed"}, {Label: "Crashed", Value: "crashed"}, + {Label: "Oom Killed", Value: "oom_killed"}, + {Label: "Redeployed", Value: "redeployed"}, + {Label: "Slept", Value: "slept"}, + {Label: "Resumed", Value: "resumed"}, + {Label: "Restarted", Value: "restarted"}, + {Label: "Removed", Value: "removed"}, {Label: "Building", Value: "building"}, {Label: "Deploying", Value: "deploying"}, - {Label: "Initializing", Value: "initializing"}, - {Label: "Removing", Value: "removing"}, - {Label: "Removed", Value: "removed"}, + {Label: "Waiting", Value: "waiting"}, + {Label: "Needs Approval", Value: "needs_approval"}, + {Label: "Queued", Value: "queued"}, }, }, }, @@ -138,7 +145,8 @@ func (t *OnDeploymentEvent) Setup(ctx core.TriggerContext) error { } // If already set up with matching project and webhook URL, nothing to do - if metadata.Project != nil && metadata.Project.ID == config.Project && metadata.WebhookURL != "" { + if metadata.Project != nil && metadata.Project.ID == config.Project && + metadata.WebhookURL != "" { return nil } @@ -204,17 +212,19 @@ func (t *OnDeploymentEvent) HandleWebhook(ctx core.WebhookRequestContext) (int, // Validate the project matches the configured project // This is important since Railway doesn't provide webhook signatures + var payloadProjectID string if resource, ok := payload["resource"].(map[string]any); ok { if project, ok := resource["project"].(map[string]any); ok { if projectID, ok := project["id"].(string); ok { - if projectID != config.Project { - // Event is from a different project, ignore - return http.StatusOK, nil - } + payloadProjectID = projectID } } } + if payloadProjectID != "" && payloadProjectID != config.Project { + return http.StatusOK, nil + } + // Filter by event action if configured if len(config.Statuses) > 0 { // Reject events with empty action when filter is active From 91a7f7237a097342a5b5175c82125e7e92383526 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 01:15:55 +0300 Subject: [PATCH 12/29] fix: generate docs Signed-off-by: Shambel Amare --- docs/components/Railway.mdx | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/components/Railway.mdx b/docs/components/Railway.mdx index d6427560c2..1bb0032fef 100644 --- a/docs/components/Railway.mdx +++ b/docs/components/Railway.mdx @@ -79,21 +79,21 @@ After configuring this trigger: ### Use Cases -- **Deployment notifications**: Notify Slack when deployments succeed or fail +- **Deployment notifications**: Notify Slack when deployments complete or fail - **Incident creation**: Create tickets when deployments crash - **Pipeline chaining**: Trigger downstream workflows after successful deployments ### Configuration - **Project**: Select the Railway project to monitor -- **Event Filter**: Optionally filter by deployment event type (succeeded, failed, crashed, etc.) +- **Event Filter**: Optionally filter by deployment event type (deployed, failed, crashed, etc.) - Leave empty to receive all deployment events ### Event Data Each deployment event includes: -- `type`: Event type (e.g., Deployment.succeeded, Deployment.failed) -- `details.status`: Deployment status +- `type`: Event type (e.g., Deployment.deployed, Deployment.failed) +- `details.status`: Deployment status (SUCCESS, FAILED, etc.) - `resource.deployment.id`: Deployment ID - `resource.service`: Service name and ID - `resource.environment`: Environment name and ID @@ -105,6 +105,13 @@ Each deployment event includes: ```json { "details": { + "branch": "main", + "commitAuthor": "developer", + "commitHash": "abc123def456", + "commitMessage": "Update deployment", + "id": "deploy-abc123", + "serviceId": "srv-xyz789", + "source": "GitHub", "status": "SUCCESS" }, "resource": { @@ -113,12 +120,9 @@ Each deployment event includes: }, "environment": { "id": "env-def456", + "isEphemeral": false, "name": "production" }, - "owner": { - "email": "user@example.com", - "id": "owner-abc123" - }, "project": { "id": "proj-xyz789", "name": "my-project" @@ -126,10 +130,15 @@ Each deployment event includes: "service": { "id": "srv-ghi012", "name": "api-server" + }, + "workspace": { + "id": "ws-abc123", + "name": "My Workspace" } }, + "severity": "INFO", "timestamp": "2024-01-15T10:30:00.000Z", - "type": "Deployment.succeeded" + "type": "Deployment.deployed" } ``` From 03fd7205b5ae2bd72492b88b91ce5da806b37a76 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 01:29:35 +0300 Subject: [PATCH 13/29] fix: update deployment event options to include 'Failed' and reorder existing options Signed-off-by: Shambel Amare --- pkg/integrations/railway/on_deployment_event.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/integrations/railway/on_deployment_event.go b/pkg/integrations/railway/on_deployment_event.go index f47e239c10..f6d5a479c2 100644 --- a/pkg/integrations/railway/on_deployment_event.go +++ b/pkg/integrations/railway/on_deployment_event.go @@ -107,10 +107,11 @@ func (t *OnDeploymentEvent) Configuration() []configuration.Field { TypeOptions: &configuration.TypeOptions{ MultiSelect: &configuration.MultiSelectTypeOptions{ Options: []configuration.FieldOption{ - {Label: "Triggered", Value: "triggered"}, - {Label: "Resolved", Value: "resolved"}, {Label: "Deployed", Value: "deployed"}, + {Label: "Failed", Value: "failed"}, {Label: "Crashed", Value: "crashed"}, + {Label: "Triggered", Value: "triggered"}, + {Label: "Resolved", Value: "resolved"}, {Label: "Oom Killed", Value: "oom_killed"}, {Label: "Redeployed", Value: "redeployed"}, {Label: "Slept", Value: "slept"}, From 1202da36147f511da1437666859482f2fc36cfd9 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 14:14:57 +0300 Subject: [PATCH 14/29] fix: use correct event type on curl Signed-off-by: Shambel Amare --- .../mappers/railway/trigger_deploy.ts | 228 ++++++++++++++++-- 1 file changed, 211 insertions(+), 17 deletions(-) diff --git a/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts b/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts index cc0d9f9162..b4963db5c1 100644 --- a/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts +++ b/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts @@ -1,7 +1,16 @@ import { getColorClass, getBackgroundColorClass } from "@/utils/colors"; -import { ComponentBaseMapper, ComponentBaseContext, SubtitleContext, ExecutionDetailsContext } from "../types"; +import { + ComponentBaseMapper, + ComponentBaseContext, + SubtitleContext, + ExecutionDetailsContext, + ExecutionInfo, + StateFunction, + EventStateRegistry, +} from "../types"; import RailwayLogo from "@/assets/icons/integrations/railway.svg"; import { formatTimeAgo } from "@/utils/date"; +import { DEFAULT_EVENT_STATE_MAP, EventState, EventStateMap } from "@/ui/componentBase"; interface TriggerDeployMetadata { project?: { id?: string; name?: string }; @@ -9,6 +18,152 @@ interface TriggerDeployMetadata { environment?: { id?: string; name?: string }; } +interface TriggerDeployExecutionMetadata { + deploymentId?: string; + status?: string; + url?: string; +} + +// Railway deployment status constants (matching backend) +const DeploymentStatus = { + QUEUED: "QUEUED", + WAITING: "WAITING", + BUILDING: "BUILDING", + DEPLOYING: "DEPLOYING", + SUCCESS: "SUCCESS", + FAILED: "FAILED", + CRASHED: "CRASHED", + REMOVED: "REMOVED", + SLEEPING: "SLEEPING", + SKIPPED: "SKIPPED", +} as const; + +/** + * Custom state map for Railway deployment statuses + */ +export const TRIGGER_DEPLOY_STATE_MAP: EventStateMap = { + ...DEFAULT_EVENT_STATE_MAP, + queued: { + icon: "clock", + textColor: "text-gray-800", + backgroundColor: "bg-gray-100", + badgeColor: "bg-gray-500", + }, + building: { + icon: "hammer", + textColor: "text-gray-800", + backgroundColor: "bg-blue-100", + badgeColor: "bg-blue-500", + }, + deploying: { + icon: "loader-circle", + textColor: "text-gray-800", + backgroundColor: "bg-purple-100", + badgeColor: "bg-purple-500", + }, + passed: { + icon: "circle-check", + textColor: "text-gray-800", + backgroundColor: "bg-green-100", + badgeColor: "bg-emerald-500", + }, + failed: { + icon: "circle-x", + textColor: "text-gray-800", + backgroundColor: "bg-red-100", + badgeColor: "bg-red-400", + }, + crashed: { + icon: "alert-triangle", + textColor: "text-gray-800", + backgroundColor: "bg-orange-100", + badgeColor: "bg-orange-500", + }, +}; + +/** + * Maps Railway deployment status to UI event state + */ +function mapDeploymentStatusToState(status: string | undefined): EventState { + switch (status) { + case DeploymentStatus.QUEUED: + case DeploymentStatus.WAITING: + return "queued"; + case DeploymentStatus.BUILDING: + return "building"; + case DeploymentStatus.DEPLOYING: + return "deploying"; + case DeploymentStatus.SUCCESS: + return "passed"; + case DeploymentStatus.CRASHED: + return "crashed"; + case DeploymentStatus.FAILED: + case DeploymentStatus.REMOVED: + case DeploymentStatus.SKIPPED: + return "failed"; + default: + return "neutral"; + } +} + +/** + * State function for Railway TriggerDeploy component + */ +export const triggerDeployStateFunction: StateFunction = (execution: ExecutionInfo): EventState => { + if (!execution) return "neutral"; + + // Check for errors first + if ( + execution.resultMessage && + (execution.resultReason === "RESULT_REASON_ERROR" || + (execution.result === "RESULT_FAILED" && execution.resultReason !== "RESULT_REASON_ERROR_RESOLVED")) + ) { + return "error"; + } + + if (execution.result === "RESULT_CANCELLED") { + return "cancelled"; + } + + // If execution is finished, map based on final status + if (execution.state === "STATE_FINISHED") { + const metadata = execution.metadata as TriggerDeployExecutionMetadata; + if (metadata?.status) { + return mapDeploymentStatusToState(metadata.status); + } + // Fallback based on result + return execution.result === "RESULT_PASSED" ? "passed" : "failed"; + } + + // If still running, show the current deployment status from metadata + if (execution.state === "STATE_STARTED" || execution.state === "STATE_PENDING") { + const metadata = execution.metadata as TriggerDeployExecutionMetadata; + if (metadata?.status) { + return mapDeploymentStatusToState(metadata.status); + } + return "queued"; // Default to queued if no status yet + } + + return "neutral"; +}; + +/** + * State registry for Railway TriggerDeploy component + */ +export const TRIGGER_DEPLOY_STATE_REGISTRY: EventStateRegistry = { + stateMap: TRIGGER_DEPLOY_STATE_MAP, + getState: triggerDeployStateFunction, +}; + +/** + * Formats the deployment status for display + */ +function formatDeploymentStatus(status: string | undefined): string { + if (!status) return ""; + // Convert SCREAMING_CASE to Title Case + return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase(); +} + /** * Mapper for the "railway.triggerDeploy" component type */ @@ -40,24 +195,51 @@ export const triggerDeployMapper: ComponentBaseMapper = { collapsedBackground: getBackgroundColorClass(componentDefinition.color), title: node.name || componentDefinition.label || "Trigger Deploy", metadata: metadataItems, + eventStateMap: TRIGGER_DEPLOY_STATE_MAP, }; }, subtitle(context: SubtitleContext): string { const { execution } = context; + const execMetadata = execution.metadata as TriggerDeployExecutionMetadata; + const status = execMetadata?.status; - if (execution.state === "STATE_FINISHED" && execution.result === "RESULT_PASSED") { - const updatedAt = execution.updatedAt ? new Date(execution.updatedAt) : null; - return updatedAt ? `Triggered ${formatTimeAgo(updatedAt)}` : "Triggered"; + // Show current deployment status while running + if (execution.state === "STATE_STARTED" || execution.state === "STATE_PENDING") { + switch (status) { + case DeploymentStatus.QUEUED: + case DeploymentStatus.WAITING: + return "Queued..."; + case DeploymentStatus.BUILDING: + return "Building..."; + case DeploymentStatus.DEPLOYING: + return "Deploying..."; + default: + return "Starting..."; + } } - if (execution.state === "STATE_FINISHED" && execution.result === "RESULT_FAILED") { + // Finished states + if (execution.state === "STATE_FINISHED") { const updatedAt = execution.updatedAt ? new Date(execution.updatedAt) : null; - return updatedAt ? `Failed ${formatTimeAgo(updatedAt)}` : "Failed"; - } + const timeAgo = updatedAt ? formatTimeAgo(updatedAt) : ""; - if (execution.state === "STATE_STARTED") { - return "Deploying..."; + switch (status) { + case DeploymentStatus.SUCCESS: + return timeAgo ? `Deployed ${timeAgo}` : "Deployed"; + case DeploymentStatus.CRASHED: + return timeAgo ? `Crashed ${timeAgo}` : "Crashed"; + case DeploymentStatus.FAILED: + case DeploymentStatus.REMOVED: + case DeploymentStatus.SKIPPED: + return timeAgo ? `Failed ${timeAgo}` : "Failed"; + default: + // Fallback to result-based status + if (execution.result === "RESULT_PASSED") { + return timeAgo ? `Deployed ${timeAgo}` : "Deployed"; + } + return timeAgo ? `Failed ${timeAgo}` : "Failed"; + } } const createdAt = execution.createdAt ? new Date(execution.createdAt) : null; @@ -76,16 +258,28 @@ export const triggerDeployMapper: ComponentBaseMapper = { details["Finished At"] = new Date(execution.updatedAt).toLocaleString(); } - // Add metadata info - const metadata = node.metadata as unknown as TriggerDeployMetadata; - if (metadata?.project?.name) { - details["Project"] = metadata.project.name; + // Add execution metadata info (deployment status, ID, URL) + const execMetadata = execution.metadata as TriggerDeployExecutionMetadata; + if (execMetadata?.status) { + details["Deployment Status"] = formatDeploymentStatus(execMetadata.status); } - if (metadata?.service?.name) { - details["Service"] = metadata.service.name; + if (execMetadata?.deploymentId) { + details["Deployment ID"] = execMetadata.deploymentId; } - if (metadata?.environment?.name) { - details["Environment"] = metadata.environment.name; + if (execMetadata?.url) { + details["Deployment URL"] = execMetadata.url; + } + + // Add node metadata info (project, service, environment) + const nodeMetadata = node.metadata as unknown as TriggerDeployMetadata; + if (nodeMetadata?.project?.name) { + details["Project"] = nodeMetadata.project.name; + } + if (nodeMetadata?.service?.name) { + details["Service"] = nodeMetadata.service.name; + } + if (nodeMetadata?.environment?.name) { + details["Environment"] = nodeMetadata.environment.name; } return details; From 6b59c90bf19d11a134583fe8695adcb72ebe1cae Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 14:16:53 +0300 Subject: [PATCH 15/29] chore: simplified instruction Signed-off-by: Shambel Amare --- docs/components/Railway.mdx | 70 +++++++---------------------- pkg/integrations/railway/railway.go | 45 ++----------------- 2 files changed, 21 insertions(+), 94 deletions(-) diff --git a/docs/components/Railway.mdx b/docs/components/Railway.mdx index 1bb0032fef..e724d40bef 100644 --- a/docs/components/Railway.mdx +++ b/docs/components/Railway.mdx @@ -20,48 +20,11 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; ## Instructions -## Connect Railway +Create an API token in Railway and paste it below. -### Creating an API Token +**Important:** When creating the token, select a **specific workspace** to access its projects. The "No Workspace" option will not work. -1. Go to [Railway Account Settings → Tokens](https://railway.app/account/tokens) -2. Click **"Create Token"** to generate a new API token -3. Give your token a descriptive name (e.g., "SuperPlane Integration") - - -### Token Scoping (Important!) - -When creating a Railway token, you must select a **specific workspace** to grant access to its projects: - -| Scope | Access Level | Use Case | -|-------|--------------|----------| -| **Specific Workspace** | Projects in that workspace | **Recommended for SuperPlane** | -| **No Workspace** | No projects accessible | Not suitable for SuperPlane | - -**Important:** Select the **workspace containing your projects** when creating your token. The "No Workspace" option does not provide access to any projects because it lacks a workspace context. - -If you have projects in multiple workspaces, you'll need to create separate integrations with tokens scoped to each workspace. - -### Permissions - -The API token allows SuperPlane to: -- List your projects, services, and environments -- Trigger deployments on your services -- Receive deployment status webhooks - -### Webhook Configuration - -For the **On Deployment Event** trigger, you'll need to manually configure webhooks in Railway: - -1. Create the trigger in SuperPlane and save the canvas -2. Copy the generated webhook URL from the trigger settings -3. Go to your Railway project → Settings → Webhooks -4. Add the webhook URL and select "Deploy" events - -### Troubleshooting - -- **"Not Authorized" error**: Your token may be scoped to a workspace that doesn't include the project you're trying to access. -- **Projects not showing**: Make sure your token is scoped to a specific workspace, not "No Workspace". Try disconnecting and reconnecting the integration after creating a new token. +[Create Railway Token →](https://railway.com/account/tokens) @@ -146,7 +109,7 @@ Each deployment event includes: ## Trigger Deploy -The Trigger Deploy component starts a new deployment for a Railway service in a specific environment. +The Trigger Deploy component starts a new deployment for a Railway service and waits for it to complete. ### Use Cases @@ -155,25 +118,26 @@ The Trigger Deploy component starts a new deployment for a Railway service in a - **Manual approval**: Deploy after approval in the workflow - **Cross-service orchestration**: Deploy services in sequence +### How It Works + +1. Triggers a new deployment via Railway's API +2. Polls for deployment status updates (Queued → Building → Deploying → Success/Failed) +3. Routes execution based on final deployment status: + - **Deployed channel**: Deployment succeeded + - **Failed channel**: Deployment failed + - **Crashed channel**: Deployment crashed + ### Configuration - **Project**: Select the Railway project - **Service**: Select the service to deploy - **Environment**: Select the target environment (e.g., production, staging) -### How It Works - -1. Calls Railway's `environmentTriggersDeploy` API -2. Railway queues a new deployment for the service -3. Component emits the deployment trigger result - -### Output +### Output Channels -The component emits: -- `project`: Project ID -- `service`: Service ID -- `environment`: Environment ID -- `triggered`: Whether the deployment was triggered +- **Deployed**: Emitted when deployment succeeds +- **Failed**: Emitted when deployment fails +- **Crashed**: Emitted when deployment crashes ### Example Output diff --git a/pkg/integrations/railway/railway.go b/pkg/integrations/railway/railway.go index 42d7d86410..bc966b21a8 100644 --- a/pkg/integrations/railway/railway.go +++ b/pkg/integrations/railway/railway.go @@ -45,48 +45,11 @@ func (r *Railway) Description() string { } func (r *Railway) Instructions() string { - return `## Connect Railway + return `Create an API token in Railway and paste it below. -### Creating an API Token +**Important:** When creating the token, select a **specific workspace** to access its projects. The "No Workspace" option will not work. -1. Go to [Railway Account Settings → Tokens](https://railway.app/account/tokens) -2. Click **"Create Token"** to generate a new API token -3. Give your token a descriptive name (e.g., "SuperPlane Integration") - - -### Token Scoping (Important!) - -When creating a Railway token, you must select a **specific workspace** to grant access to its projects: - -| Scope | Access Level | Use Case | -|-------|--------------|----------| -| **Specific Workspace** | Projects in that workspace | **Recommended for SuperPlane** | -| **No Workspace** | No projects accessible | Not suitable for SuperPlane | - -**Important:** Select the **workspace containing your projects** when creating your token. The "No Workspace" option does not provide access to any projects because it lacks a workspace context. - -If you have projects in multiple workspaces, you'll need to create separate integrations with tokens scoped to each workspace. - -### Permissions - -The API token allows SuperPlane to: -- List your projects, services, and environments -- Trigger deployments on your services -- Receive deployment status webhooks - -### Webhook Configuration - -For the **On Deployment Event** trigger, you'll need to manually configure webhooks in Railway: - -1. Create the trigger in SuperPlane and save the canvas -2. Copy the generated webhook URL from the trigger settings -3. Go to your Railway project → Settings → Webhooks -4. Add the webhook URL and select "Deploy" events - -### Troubleshooting - -- **"Not Authorized" error**: Your token may be scoped to a workspace that doesn't include the project you're trying to access. -- **Projects not showing**: Make sure your token is scoped to a specific workspace, not "No Workspace". Try disconnecting and reconnecting the integration after creating a new token.` +[Create Railway Token →](https://railway.com/account/tokens)` } func (r *Railway) Configuration() []configuration.Field { @@ -97,7 +60,7 @@ func (r *Railway) Configuration() []configuration.Field { Type: configuration.FieldTypeString, Sensitive: true, Required: true, - Description: "Create a token at railway.app/account/tokens. Select a specific workspace to access its projects.", + Description: "Your Railway API token scoped to a workspace", Placeholder: "YOUR RAILWAY API TOKEN", }, } From b65d04cd2f6dfe10302879b0cafb295634622989 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 14:17:37 +0300 Subject: [PATCH 16/29] feat: Add polling based status tracking for deployment trigger Signed-off-by: Shambel Amare --- pkg/integrations/railway/client.go | 78 +++++++- .../railway/on_deployment_event.go | 13 +- pkg/integrations/railway/trigger_deploy.go | 188 +++++++++++++++--- .../mappers/railway/custom_field_renderer.tsx | 35 ++-- .../pages/workflowv2/mappers/railway/index.ts | 45 +---- .../mappers/railway/on_deployment_event.ts | 18 +- 6 files changed, 281 insertions(+), 96 deletions(-) diff --git a/pkg/integrations/railway/client.go b/pkg/integrations/railway/client.go index 03b12580d9..0dba9e0360 100644 --- a/pkg/integrations/railway/client.go +++ b/pkg/integrations/railway/client.go @@ -253,8 +253,40 @@ func (c *Client) GetProject(projectID string) (*Project, error) { return project, nil } -func (c *Client) TriggerDeploy(serviceID, environmentID string) error { - // serviceInstanceDeployV2 returns a String! (deployment ID), not an object +// Deployment represents a Railway deployment with its current status +type Deployment struct { + ID string `json:"id"` + Status string `json:"status"` // BUILDING, DEPLOYING, SUCCESS, FAILED, CRASHED, REMOVED, SLEEPING, SKIPPED, WAITING, QUEUED + URL string `json:"url"` +} + +// DeploymentStatus constants +const ( + DeploymentStatusQueued = "QUEUED" + DeploymentStatusWaiting = "WAITING" + DeploymentStatusBuilding = "BUILDING" + DeploymentStatusDeploying = "DEPLOYING" + DeploymentStatusSuccess = "SUCCESS" + DeploymentStatusFailed = "FAILED" + DeploymentStatusCrashed = "CRASHED" + DeploymentStatusRemoved = "REMOVED" + DeploymentStatusSleeping = "SLEEPING" + DeploymentStatusSkipped = "SKIPPED" +) + +// IsDeploymentFinalStatus returns true if the status is a final/terminal status +func IsDeploymentFinalStatus(status string) bool { + switch status { + case DeploymentStatusSuccess, DeploymentStatusFailed, DeploymentStatusCrashed, + DeploymentStatusRemoved, DeploymentStatusSkipped: + return true + default: + return false + } +} + +func (c *Client) TriggerDeploy(serviceID, environmentID string) (string, error) { + // serviceInstanceDeployV2 returns a String! (deployment ID) mutation := ` mutation serviceInstanceDeployV2($serviceId: String!, $environmentId: String!) { serviceInstanceDeployV2(serviceId: $serviceId, environmentId: $environmentId) @@ -266,8 +298,46 @@ func (c *Client) TriggerDeploy(serviceID, environmentID string) error { "environmentId": environmentID, } - _, err := c.graphql(mutation, variables) - return err + data, err := c.graphql(mutation, variables) + if err != nil { + return "", err + } + + deploymentID, ok := data["serviceInstanceDeployV2"].(string) + if !ok { + return "", fmt.Errorf("unexpected response format: deployment ID not found") + } + + return deploymentID, nil +} + +// GetDeployment retrieves the current status of a deployment +func (c *Client) GetDeployment(deploymentID string) (*Deployment, error) { + query := ` + query deployment($id: String!) { + deployment(id: $id) { + id + status + url + } + } + ` + + data, err := c.graphql(query, map[string]any{"id": deploymentID}) + if err != nil { + return nil, err + } + + deploymentData, ok := data["deployment"].(map[string]any) + if !ok { + return nil, fmt.Errorf("deployment not found") + } + + return &Deployment{ + ID: getString(deploymentData, "id"), + Status: getString(deploymentData, "status"), + URL: getString(deploymentData, "url"), + }, nil } // Helper function to safely get string from map diff --git a/pkg/integrations/railway/on_deployment_event.go b/pkg/integrations/railway/on_deployment_event.go index f6d5a479c2..6317fb25e1 100644 --- a/pkg/integrations/railway/on_deployment_event.go +++ b/pkg/integrations/railway/on_deployment_event.go @@ -20,8 +20,9 @@ type OnDeploymentEventConfiguration struct { } type OnDeploymentEventMetadata struct { - Project *ProjectInfo `json:"project" mapstructure:"project"` - WebhookURL string `json:"webhookUrl,omitempty" mapstructure:"webhookUrl,omitempty"` + Project *ProjectInfo `json:"project" mapstructure:"project"` + WebhookURL string `json:"webhookUrl,omitempty" mapstructure:"webhookUrl,omitempty"` + WebhookConfigURL string `json:"webhookConfigUrl,omitempty" mapstructure:"webhookConfigUrl,omitempty"` } type ProjectInfo struct { @@ -171,13 +172,17 @@ func (t *OnDeploymentEvent) Setup(ctx core.TriggerContext) error { } } - // Store metadata with webhook URL + // Build direct link to Railway webhook configuration page + webhookConfigURL := fmt.Sprintf("https://railway.com/project/%s/settings/webhooks/new", project.ID) + + // Store metadata with webhook URL and config link if err := ctx.Metadata.Set(OnDeploymentEventMetadata{ Project: &ProjectInfo{ ID: project.ID, Name: project.Name, }, - WebhookURL: webhookURL, + WebhookURL: webhookURL, + WebhookConfigURL: webhookConfigURL, }); err != nil { return fmt.Errorf("failed to set metadata: %w", err) } diff --git a/pkg/integrations/railway/trigger_deploy.go b/pkg/integrations/railway/trigger_deploy.go index 91d35d7957..dac4312d26 100644 --- a/pkg/integrations/railway/trigger_deploy.go +++ b/pkg/integrations/railway/trigger_deploy.go @@ -2,6 +2,7 @@ package railway import ( "fmt" + "time" "github.com/google/uuid" "github.com/mitchellh/mapstructure" @@ -9,7 +10,20 @@ import ( "github.com/superplanehq/superplane/pkg/core" ) -// TriggerDeploy is a stub for now - will be fully implemented in the next phase +const ( + // Output channel names + DeployedOutputChannel = "deployed" + FailedOutputChannel = "failed" + CrashedOutputChannel = "crashed" + + // Event type + DeploymentPayloadType = "railway.deployment.finished" + + // Poll interval for checking deployment status + DeploymentPollInterval = 15 * time.Second +) + +// TriggerDeploy triggers a deployment and tracks its status until completion type TriggerDeploy struct{} type TriggerDeployConfiguration struct { @@ -24,6 +38,13 @@ type TriggerDeployMetadata struct { Environment *EnvironmentInfo `json:"environment" mapstructure:"environment"` } +// TriggerDeployExecutionMetadata tracks the deployment state during execution +type TriggerDeployExecutionMetadata struct { + DeploymentID string `json:"deploymentId" mapstructure:"deploymentId"` + Status string `json:"status" mapstructure:"status"` + URL string `json:"url" mapstructure:"url"` +} + type ServiceInfo struct { ID string `json:"id"` Name string `json:"name"` @@ -47,7 +68,7 @@ func (c *TriggerDeploy) Description() string { } func (c *TriggerDeploy) Documentation() string { - return `The Trigger Deploy component starts a new deployment for a Railway service in a specific environment. + return `The Trigger Deploy component starts a new deployment for a Railway service and waits for it to complete. ## Use Cases @@ -56,25 +77,26 @@ func (c *TriggerDeploy) Documentation() string { - **Manual approval**: Deploy after approval in the workflow - **Cross-service orchestration**: Deploy services in sequence +## How It Works + +1. Triggers a new deployment via Railway's API +2. Polls for deployment status updates (Queued → Building → Deploying → Success/Failed) +3. Routes execution based on final deployment status: + - **Deployed channel**: Deployment succeeded + - **Failed channel**: Deployment failed + - **Crashed channel**: Deployment crashed + ## Configuration - **Project**: Select the Railway project - **Service**: Select the service to deploy - **Environment**: Select the target environment (e.g., production, staging) -## How It Works - -1. Calls Railway's ` + "`environmentTriggersDeploy`" + ` API -2. Railway queues a new deployment for the service -3. Component emits the deployment trigger result - -## Output +## Output Channels -The component emits: -- ` + "`project`" + `: Project ID -- ` + "`service`" + `: Service ID -- ` + "`environment`" + `: Environment ID -- ` + "`triggered`" + `: Whether the deployment was triggered` +- **Deployed**: Emitted when deployment succeeds +- **Failed**: Emitted when deployment fails +- **Crashed**: Emitted when deployment crashes` } func (c *TriggerDeploy) Icon() string { @@ -140,7 +162,18 @@ func (c *TriggerDeploy) Configuration() []configuration.Field { func (c *TriggerDeploy) OutputChannels(config any) []core.OutputChannel { return []core.OutputChannel{ - core.DefaultOutputChannel, + { + Name: DeployedOutputChannel, + Label: "Deployed", + }, + { + Name: FailedOutputChannel, + Label: "Failed", + }, + { + Name: CrashedOutputChannel, + Label: "Crashed", + }, } } @@ -212,28 +245,36 @@ func (c *TriggerDeploy) Execute(ctx core.ExecutionContext) error { return fmt.Errorf("failed to create client: %w", err) } - // Call environmentTriggersDeploy mutation - if err := client.TriggerDeploy(config.Service, config.Environment); err != nil { + // Trigger the deployment and get the deployment ID + deploymentID, err := client.TriggerDeploy(config.Service, config.Environment) + if err != nil { return fmt.Errorf("failed to trigger deployment: %w", err) } ctx.Logger.Infof( - "Triggered deployment for service %s in environment %s", + "Triggered deployment %s for service %s in environment %s", + deploymentID, config.Service, config.Environment, ) - // Emit result - return ctx.ExecutionState.Emit( - core.DefaultOutputChannel.Name, - "railway.deployment.triggered", - []any{map[string]any{ - "project": config.Project, - "service": config.Service, - "environment": config.Environment, - "triggered": true, - }}, - ) + // Store deployment ID in execution metadata + err = ctx.Metadata.Set(TriggerDeployExecutionMetadata{ + DeploymentID: deploymentID, + Status: DeploymentStatusQueued, + }) + if err != nil { + return fmt.Errorf("failed to set execution metadata: %w", err) + } + + // Store deployment ID in KV for later retrieval + err = ctx.ExecutionState.SetKV("deployment_id", deploymentID) + if err != nil { + return fmt.Errorf("failed to store deployment ID: %w", err) + } + + // Schedule the first poll to check deployment status + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, DeploymentPollInterval) } func (c *TriggerDeploy) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { @@ -241,14 +282,99 @@ func (c *TriggerDeploy) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UU } func (c *TriggerDeploy) Actions() []core.Action { - return []core.Action{} + return []core.Action{ + { + Name: "poll", + UserAccessible: false, + }, + } } func (c *TriggerDeploy) HandleAction(ctx core.ActionContext) error { - return nil + switch ctx.Name { + case "poll": + return c.poll(ctx) + } + return fmt.Errorf("unknown action: %s", ctx.Name) +} + +func (c *TriggerDeploy) poll(ctx core.ActionContext) error { + // Check if execution is already finished + if ctx.ExecutionState.IsFinished() { + return nil + } + + // Get current execution metadata + execMetadata := TriggerDeployExecutionMetadata{} + if err := mapstructure.Decode(ctx.Metadata.Get(), &execMetadata); err != nil { + return fmt.Errorf("failed to decode execution metadata: %w", err) + } + + // If already in final state, nothing to do + if IsDeploymentFinalStatus(execMetadata.Status) { + return nil + } + + // Create client to check deployment status + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + // Get the latest deployment status + deployment, err := client.GetDeployment(execMetadata.DeploymentID) + if err != nil { + ctx.Logger.WithError(err).Warn("Failed to get deployment status, will retry") + // Schedule another poll - don't fail on transient errors + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, DeploymentPollInterval) + } + + ctx.Logger.Infof("Deployment %s status: %s", deployment.ID, deployment.Status) + + // Update metadata with current status + execMetadata.Status = deployment.Status + execMetadata.URL = deployment.URL + if err := ctx.Metadata.Set(execMetadata); err != nil { + return fmt.Errorf("failed to update metadata: %w", err) + } + + // If not in final state, schedule another poll + if !IsDeploymentFinalStatus(deployment.Status) { + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, DeploymentPollInterval) + } + + // Deployment reached final state - emit to appropriate channel + config := TriggerDeployConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + eventData := map[string]any{ + "deploymentId": deployment.ID, + "status": deployment.Status, + "url": deployment.URL, + "project": config.Project, + "service": config.Service, + "environment": config.Environment, + } + + switch deployment.Status { + case DeploymentStatusSuccess: + ctx.Logger.Info("Deployment succeeded") + return ctx.ExecutionState.Emit(DeployedOutputChannel, DeploymentPayloadType, []any{eventData}) + case DeploymentStatusCrashed: + ctx.Logger.Info("Deployment crashed") + return ctx.ExecutionState.Emit(CrashedOutputChannel, DeploymentPayloadType, []any{eventData}) + default: + // FAILED, REMOVED, SKIPPED all go to failed channel + ctx.Logger.Infof("Deployment ended with status: %s", deployment.Status) + return ctx.ExecutionState.Emit(FailedOutputChannel, DeploymentPayloadType, []any{eventData}) + } } func (c *TriggerDeploy) Cancel(ctx core.ExecutionContext) error { + // Railway doesn't have a cancel deployment API, so we just log and return + ctx.Logger.Info("Cancel requested - Railway deployments cannot be cancelled via API") return nil } diff --git a/web_src/src/pages/workflowv2/mappers/railway/custom_field_renderer.tsx b/web_src/src/pages/workflowv2/mappers/railway/custom_field_renderer.tsx index d73c902340..1017d51561 100644 --- a/web_src/src/pages/workflowv2/mappers/railway/custom_field_renderer.tsx +++ b/web_src/src/pages/workflowv2/mappers/railway/custom_field_renderer.tsx @@ -9,6 +9,7 @@ interface OnDeploymentEventMetadata { name: string; }; webhookUrl?: string; + webhookConfigUrl?: string; } /** @@ -49,7 +50,7 @@ export const onDeploymentEventCustomFieldRenderer: CustomFieldRenderer = { const curlExample = `curl -X POST \\ -H "Content-Type: application/json" \\ - --data '{"type":"Deployment.succeeded","details":{"status":"SUCCESS"},"resource":{"deployment":{"id":"test-123"}}}' \\ + --data '{"type":"Deployment.deployed","details":{"status":"SUCCESS"},"resource":{"deployment":{"id":"test-123"}}}' \\ ${webhookUrl}`; return ( @@ -77,23 +78,23 @@ export const onDeploymentEventCustomFieldRenderer: CustomFieldRenderer = {
- {/* Setup Instructions */} -
-
- -
-

Setup Instructions

-
    -
  1. Go to your Railway project
  2. -
  3. Navigate to Settings → Webhooks
  4. -
  5. Click "Add Webhook"
  6. -
  7. Paste the webhook URL above
  8. -
  9. Select "Deploy" events
  10. -
  11. Save the webhook
  12. -
-
+ {/* Configure Webhook Button */} + {metadata?.webhookConfigUrl && ( +
+ + + Configure Webhook in Railway + +

+ Paste the webhook URL above and select "Deploy" events. +

-
+ )} {/* Test Command */} {metadata?.webhookUrl && ( diff --git a/web_src/src/pages/workflowv2/mappers/railway/index.ts b/web_src/src/pages/workflowv2/mappers/railway/index.ts index 81648b19b0..d0a37c10a7 100644 --- a/web_src/src/pages/workflowv2/mappers/railway/index.ts +++ b/web_src/src/pages/workflowv2/mappers/railway/index.ts @@ -1,43 +1,14 @@ -import { - ComponentBaseMapper, - TriggerRenderer, - CustomFieldRenderer, - EventStateRegistry, - ExecutionInfo, - StateFunction, -} from "../types"; +import { ComponentBaseMapper, TriggerRenderer, CustomFieldRenderer, EventStateRegistry } from "../types"; import { onDeploymentEventTriggerRenderer } from "./on_deployment_event"; -import { triggerDeployMapper } from "./trigger_deploy"; +import { + triggerDeployMapper, + TRIGGER_DEPLOY_STATE_MAP, + triggerDeployStateFunction, + TRIGGER_DEPLOY_STATE_REGISTRY, +} from "./trigger_deploy"; import { onDeploymentEventCustomFieldRenderer } from "./custom_field_renderer"; -import { DEFAULT_EVENT_STATE_MAP, EventState, EventStateMap } from "@/ui/componentBase"; -import { defaultStateFunction } from "../stateRegistry"; - -export const TRIGGER_DEPLOY_STATE_MAP: EventStateMap = { - ...DEFAULT_EVENT_STATE_MAP, - failed: { - icon: "circle-x", - textColor: "text-gray-800", - backgroundColor: "bg-red-100", - badgeColor: "bg-red-400", - }, -}; - -export const triggerDeployStateFunction: StateFunction = (execution: ExecutionInfo): EventState => { - if (!execution) return "neutral"; - // Check for failed output - const outputs = execution.outputs as { failed?: { data?: unknown }[] } | undefined; - if (outputs?.failed?.length) { - return "failed"; - } - - return defaultStateFunction(execution); -}; - -export const TRIGGER_DEPLOY_STATE_REGISTRY: EventStateRegistry = { - stateMap: TRIGGER_DEPLOY_STATE_MAP, - getState: triggerDeployStateFunction, -}; +export { TRIGGER_DEPLOY_STATE_MAP, triggerDeployStateFunction, TRIGGER_DEPLOY_STATE_REGISTRY }; export const componentMappers: Record = { triggerDeploy: triggerDeployMapper, diff --git a/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts b/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts index 9b33af63d9..07f6f1837b 100644 --- a/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts +++ b/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts @@ -50,14 +50,26 @@ export const onDeploymentEventTriggerRenderer: TriggerRenderer = { getRootEventValues: (context: TriggerEventContext): Record => { const eventData = context.event?.data as OnDeploymentEventData; const resource = eventData?.resource; - const timestamp = eventData?.timestamp ? new Date(eventData.timestamp).toLocaleString() : ""; + const receivedAt = context.event?.createdAt + ? new Date(context.event?.createdAt).toLocaleString() + : ""; + + // Build Railway deployment URL + const projectId = resource?.project?.id; + const serviceId = resource?.service?.id; + const deploymentId = resource?.deployment?.id; + const deploymentLink = + projectId && serviceId + ? `https://railway.com/project/${projectId}/service/${serviceId}${deploymentId ? `?deploymentId=${deploymentId}` : ""}` + : ""; return { - Service: resource?.service?.name || "", + "Received at": receivedAt, Status: eventData?.details?.status || "", + Service: resource?.service?.name || "", Environment: resource?.environment?.name || "", Project: resource?.project?.name || "", - Timestamp: timestamp, + "Deployment link": deploymentLink, }; }, From ab0fef51d2d9d8b2f406c3333b21df6eb480faa5 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 14:33:29 +0300 Subject: [PATCH 17/29] fix: use correct event name Signed-off-by: Shambel Amare --- .../railway/on_deployment_event_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/integrations/railway/on_deployment_event_test.go b/pkg/integrations/railway/on_deployment_event_test.go index 84af8e42a5..090327f4a9 100644 --- a/pkg/integrations/railway/on_deployment_event_test.go +++ b/pkg/integrations/railway/on_deployment_event_test.go @@ -44,7 +44,7 @@ func Test__OnDeploymentEvent__HandleWebhook(t *testing.T) { t.Run("deployment event with no status filter -> event is emitted", func(t *testing.T) { body := []byte(`{ - "type": "Deployment.succeeded", + "type": "Deployment.deployed", "details": { "status": "SUCCESS" }, @@ -73,7 +73,7 @@ func Test__OnDeploymentEvent__HandleWebhook(t *testing.T) { t.Run("deployment event matching status filter -> event is emitted", func(t *testing.T) { body := []byte(`{ - "type": "Deployment.succeeded", + "type": "Deployment.deployed", "details": { "status": "SUCCESS" }, @@ -90,7 +90,7 @@ func Test__OnDeploymentEvent__HandleWebhook(t *testing.T) { Headers: http.Header{}, Configuration: map[string]any{ "project": "proj-123", - "statuses": []string{"succeeded", "failed"}, + "statuses": []string{"deployed", "failed"}, }, Events: eventContext, }) @@ -119,7 +119,7 @@ func Test__OnDeploymentEvent__HandleWebhook(t *testing.T) { Headers: http.Header{}, Configuration: map[string]any{ "project": "proj-123", - "statuses": []string{"succeeded", "failed"}, + "statuses": []string{"deployed", "failed"}, }, Events: eventContext, }) @@ -233,7 +233,7 @@ func Test__OnDeploymentEvent__HandleWebhook(t *testing.T) { Headers: http.Header{}, Configuration: map[string]any{ "project": "proj-123", - "statuses": []string{"succeeded", "failed"}, + "statuses": []string{"deployed", "failed"}, }, Events: eventContext, }) @@ -264,8 +264,8 @@ func Test__OnDeploymentEvent__Setup(t *testing.T) { } func Test__ExtractEventAction(t *testing.T) { - t.Run("extracts action from valid event type", func(t *testing.T) { - assert.Equal(t, "succeeded", extractEventAction("Deployment.succeeded")) + t.Run("extracts action from deployed event type", func(t *testing.T) { + assert.Equal(t, "deployed", extractEventAction("Deployment.deployed")) }) t.Run("extracts action from failed event type", func(t *testing.T) { @@ -289,6 +289,6 @@ func Test__ExtractEventAction(t *testing.T) { }) t.Run("handles event type with multiple dots", func(t *testing.T) { - assert.Equal(t, "succeeded.extra", extractEventAction("Deployment.succeeded.extra")) + assert.Equal(t, "deployed.extra", extractEventAction("Deployment.deployed.extra")) }) } From ec68015a2e85c17ed69e393acb30bcec121d9510 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 14:43:22 +0300 Subject: [PATCH 18/29] fix: fix prettier formating Signed-off-by: Shambel Amare --- .../workflowv2/mappers/railway/on_deployment_event.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts b/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts index 07f6f1837b..5cb4e37df0 100644 --- a/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts +++ b/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts @@ -39,7 +39,8 @@ export const onDeploymentEventTriggerRenderer: TriggerRenderer = { const serviceName = eventData?.resource?.service?.name || "Service"; const status = eventData?.details?.status || ""; const timeAgo = context.event?.createdAt ? formatTimeAgo(new Date(context.event?.createdAt)) : ""; - const subtitle = status && timeAgo ? `${status.toLowerCase()} · ${timeAgo}` : status.toLowerCase() || timeAgo; + const subtitle = + status && timeAgo ? `${status.toLowerCase()} · ${timeAgo}` : status.toLowerCase() || timeAgo; return { title: `${serviceName} deployment`, @@ -50,9 +51,7 @@ export const onDeploymentEventTriggerRenderer: TriggerRenderer = { getRootEventValues: (context: TriggerEventContext): Record => { const eventData = context.event?.data as OnDeploymentEventData; const resource = eventData?.resource; - const receivedAt = context.event?.createdAt - ? new Date(context.event?.createdAt).toLocaleString() - : ""; + const receivedAt = context.event?.createdAt ? new Date(context.event?.createdAt).toLocaleString() : ""; // Build Railway deployment URL const projectId = resource?.project?.id; @@ -108,7 +107,8 @@ export const onDeploymentEventTriggerRenderer: TriggerRenderer = { const serviceName = eventData?.resource?.service?.name || "Service"; const status = eventData?.details?.status || ""; const timeAgo = lastEvent.createdAt ? formatTimeAgo(new Date(lastEvent.createdAt)) : ""; - const subtitle = status && timeAgo ? `${status.toLowerCase()} · ${timeAgo}` : status.toLowerCase() || timeAgo; + const subtitle = + status && timeAgo ? `${status.toLowerCase()} · ${timeAgo}` : status.toLowerCase() || timeAgo; props.lastEventData = { title: `${serviceName} deployment`, From dcda7f8b9ae46d113960e294b2da5c858224613a Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 18:47:36 +0300 Subject: [PATCH 19/29] fix: fix wrong webhook handler setup Signed-off-by: Shambel Amare --- pkg/integrations/railway/railway.go | 5 +- pkg/integrations/railway/webhook_handler.go | 66 --------------------- 2 files changed, 4 insertions(+), 67 deletions(-) delete mode 100644 pkg/integrations/railway/webhook_handler.go diff --git a/pkg/integrations/railway/railway.go b/pkg/integrations/railway/railway.go index bc966b21a8..8b0472f314 100644 --- a/pkg/integrations/railway/railway.go +++ b/pkg/integrations/railway/railway.go @@ -10,7 +10,10 @@ import ( ) func init() { - registry.RegisterIntegrationWithWebhookHandler("railway", &Railway{}, &RailwayWebhookHandler{}) + // Railway webhooks are manually configured by users via Railway UI + // (Railway doesn't have an API for creating webhooks) + // So we use RegisterIntegration without a webhook handler, like DockerHub + registry.RegisterIntegration("railway", &Railway{}) } type Railway struct{} diff --git a/pkg/integrations/railway/webhook_handler.go b/pkg/integrations/railway/webhook_handler.go deleted file mode 100644 index c359f2f6fe..0000000000 --- a/pkg/integrations/railway/webhook_handler.go +++ /dev/null @@ -1,66 +0,0 @@ -package railway - -import ( - "github.com/mitchellh/mapstructure" - "github.com/superplanehq/superplane/pkg/core" -) - -type WebhookConfiguration struct { - Project string `json:"project" mapstructure:"project"` -} - -type WebhookMetadata struct { - // Railway webhooks are configured via UI, so we don't have a webhook ID - // This is kept for potential future use if Railway adds API support - Project string `json:"project" mapstructure:"project"` -} - -type RailwayWebhookHandler struct{} - -// CompareConfig checks if two webhook configurations are equivalent. -// Webhooks with the same project can be shared. -func (h *RailwayWebhookHandler) CompareConfig(a, b any) (bool, error) { - configA := WebhookConfiguration{} - if err := mapstructure.Decode(a, &configA); err != nil { - return false, err - } - - configB := WebhookConfiguration{} - if err := mapstructure.Decode(b, &configB); err != nil { - return false, err - } - - return configA.Project == configB.Project, nil -} - -// Merge combines current and requested webhook configurations. -// Since Railway webhooks are manually configured, we just return the current config. -func (h *RailwayWebhookHandler) Merge(current, requested any) (any, bool, error) { - return current, false, nil -} - -// Setup is called when a webhook needs to be created. -// Since Railway webhooks are UI-only, we just return metadata. -// The webhook URL will be displayed to the user for manual setup. -func (h *RailwayWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) { - config := WebhookConfiguration{} - if err := mapstructure.Decode(ctx.Webhook.GetConfiguration(), &config); err != nil { - return nil, err - } - - // Railway doesn't have an API for creating webhooks - // Users must manually configure the webhook URL in Railway UI - // We just return metadata indicating the project - return WebhookMetadata{ - Project: config.Project, - }, nil -} - -// Cleanup is called when a webhook should be deleted. -// Since Railway webhooks are UI-only, we cannot delete them programmatically. -// Users must manually remove the webhook from Railway UI. -func (h *RailwayWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { - // Railway doesn't have an API for deleting webhooks - // Users must manually remove the webhook from Railway UI - return nil -} From bd5c0dc01b1aba6984531d3b6d2cc2668ef83c01 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 18:48:11 +0300 Subject: [PATCH 20/29] fix: add omitted from final status check Signed-off-by: Shambel Amare --- pkg/integrations/railway/client.go | 2 +- pkg/integrations/railway/trigger_deploy.go | 5 +++-- .../src/pages/workflowv2/mappers/railway/trigger_deploy.ts | 4 ++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/integrations/railway/client.go b/pkg/integrations/railway/client.go index 0dba9e0360..3435a25b81 100644 --- a/pkg/integrations/railway/client.go +++ b/pkg/integrations/railway/client.go @@ -278,7 +278,7 @@ const ( func IsDeploymentFinalStatus(status string) bool { switch status { case DeploymentStatusSuccess, DeploymentStatusFailed, DeploymentStatusCrashed, - DeploymentStatusRemoved, DeploymentStatusSkipped: + DeploymentStatusRemoved, DeploymentStatusSkipped, DeploymentStatusSleeping: return true default: return false diff --git a/pkg/integrations/railway/trigger_deploy.go b/pkg/integrations/railway/trigger_deploy.go index dac4312d26..3aa9af5438 100644 --- a/pkg/integrations/railway/trigger_deploy.go +++ b/pkg/integrations/railway/trigger_deploy.go @@ -359,8 +359,9 @@ func (c *TriggerDeploy) poll(ctx core.ActionContext) error { } switch deployment.Status { - case DeploymentStatusSuccess: - ctx.Logger.Info("Deployment succeeded") + case DeploymentStatusSuccess, DeploymentStatusSleeping: + // SLEEPING means the deployment succeeded but the app went to sleep due to inactivity + ctx.Logger.Infof("Deployment succeeded (status: %s)", deployment.Status) return ctx.ExecutionState.Emit(DeployedOutputChannel, DeploymentPayloadType, []any{eventData}) case DeploymentStatusCrashed: ctx.Logger.Info("Deployment crashed") diff --git a/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts b/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts index b4963db5c1..6efb22ad35 100644 --- a/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts +++ b/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts @@ -94,6 +94,8 @@ function mapDeploymentStatusToState(status: string | undefined): EventState { case DeploymentStatus.DEPLOYING: return "deploying"; case DeploymentStatus.SUCCESS: + case DeploymentStatus.SLEEPING: + // SLEEPING means deployment succeeded but app went to sleep due to inactivity return "passed"; case DeploymentStatus.CRASHED: return "crashed"; @@ -226,6 +228,8 @@ export const triggerDeployMapper: ComponentBaseMapper = { switch (status) { case DeploymentStatus.SUCCESS: + case DeploymentStatus.SLEEPING: + // SLEEPING means deployment succeeded but app went to sleep return timeAgo ? `Deployed ${timeAgo}` : "Deployed"; case DeploymentStatus.CRASHED: return timeAgo ? `Crashed ${timeAgo}` : "Crashed"; From 752e78c632fb97d3f94bcc21af3b95347bc62b98 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 19:09:22 +0300 Subject: [PATCH 21/29] chore: removed unused test Signed-off-by: Shambel Amare --- .../railway/webhook_handler_test.go | 197 ------------------ 1 file changed, 197 deletions(-) delete mode 100644 pkg/integrations/railway/webhook_handler_test.go diff --git a/pkg/integrations/railway/webhook_handler_test.go b/pkg/integrations/railway/webhook_handler_test.go deleted file mode 100644 index 28077291cc..0000000000 --- a/pkg/integrations/railway/webhook_handler_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package railway - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/superplanehq/superplane/pkg/core" -) - -func Test__RailwayWebhookHandler__CompareConfig(t *testing.T) { - handler := &RailwayWebhookHandler{} - - testCases := []struct { - name string - configA any - configB any - expectEqual bool - expectError bool - }{ - { - name: "identical configurations", - configA: WebhookConfiguration{ - Project: "proj-123", - }, - configB: WebhookConfiguration{ - Project: "proj-123", - }, - expectEqual: true, - expectError: false, - }, - { - name: "different projects", - configA: WebhookConfiguration{ - Project: "proj-123", - }, - configB: WebhookConfiguration{ - Project: "proj-456", - }, - expectEqual: false, - expectError: false, - }, - { - name: "comparing map representations", - configA: map[string]any{ - "project": "proj-123", - }, - configB: map[string]any{ - "project": "proj-123", - }, - expectEqual: true, - expectError: false, - }, - { - name: "map representations with different projects", - configA: map[string]any{ - "project": "proj-123", - }, - configB: map[string]any{ - "project": "proj-456", - }, - expectEqual: false, - expectError: false, - }, - { - name: "invalid first configuration", - configA: "invalid", - configB: WebhookConfiguration{ - Project: "proj-123", - }, - expectEqual: false, - expectError: true, - }, - { - name: "invalid second configuration", - configA: WebhookConfiguration{ - Project: "proj-123", - }, - configB: "invalid", - expectEqual: false, - expectError: true, - }, - { - name: "both configurations invalid", - configA: "invalid", - configB: 123, - expectEqual: false, - expectError: true, - }, - { - name: "empty project strings are equal", - configA: WebhookConfiguration{ - Project: "", - }, - configB: WebhookConfiguration{ - Project: "", - }, - expectEqual: true, - expectError: false, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - equal, err := handler.CompareConfig(tc.configA, tc.configB) - - if tc.expectError { - assert.Error(t, err) - } else { - require.NoError(t, err) - } - - assert.Equal(t, tc.expectEqual, equal) - }) - } -} - -func Test__RailwayWebhookHandler__Setup(t *testing.T) { - t.Run("returns metadata with project ID", func(t *testing.T) { - handler := &RailwayWebhookHandler{} - webhookCtx := &mockWebhookContext{ - configuration: WebhookConfiguration{ - Project: "proj-123", - }, - } - - ctx := core.WebhookHandlerContext{ - Webhook: webhookCtx, - } - - metadata, err := handler.Setup(ctx) - require.NoError(t, err) - - webhookMetadata, ok := metadata.(WebhookMetadata) - require.True(t, ok) - assert.Equal(t, "proj-123", webhookMetadata.Project) - }) - - t.Run("returns metadata from map configuration", func(t *testing.T) { - handler := &RailwayWebhookHandler{} - webhookCtx := &mockWebhookContext{ - configuration: map[string]any{ - "project": "proj-456", - }, - } - - ctx := core.WebhookHandlerContext{ - Webhook: webhookCtx, - } - - metadata, err := handler.Setup(ctx) - require.NoError(t, err) - - webhookMetadata, ok := metadata.(WebhookMetadata) - require.True(t, ok) - assert.Equal(t, "proj-456", webhookMetadata.Project) - }) -} - -func Test__RailwayWebhookHandler__Cleanup(t *testing.T) { - t.Run("cleanup returns no error", func(t *testing.T) { - handler := &RailwayWebhookHandler{} - ctx := core.WebhookHandlerContext{} - err := handler.Cleanup(ctx) - assert.NoError(t, err) - }) -} - -// Mock implementation of core.WebhookContext for testing - -type mockWebhookContext struct { - configuration any -} - -func (m *mockWebhookContext) GetID() string { - return "webhook-123" -} - -func (m *mockWebhookContext) GetURL() string { - return "https://example.com/webhook" -} - -func (m *mockWebhookContext) GetConfiguration() any { - return m.configuration -} - -func (m *mockWebhookContext) GetMetadata() any { - return nil -} - -func (m *mockWebhookContext) GetSecret() ([]byte, error) { - return nil, nil -} - -func (m *mockWebhookContext) SetSecret(secret []byte) error { - return nil -} From 6db7195454caa2171b87facb719ca0337781bc95 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 19:09:42 +0300 Subject: [PATCH 22/29] chore: match example data to code Signed-off-by: Shambel Amare --- pkg/integrations/railway/example_output_trigger_deploy.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/integrations/railway/example_output_trigger_deploy.json b/pkg/integrations/railway/example_output_trigger_deploy.json index 02ae0ae32f..11bcd24d10 100644 --- a/pkg/integrations/railway/example_output_trigger_deploy.json +++ b/pkg/integrations/railway/example_output_trigger_deploy.json @@ -1,6 +1,8 @@ { + "deploymentId": "deploy-abc123", + "status": "SUCCESS", + "url": "https://my-service-production.up.railway.app", "project": "proj-xyz789", "service": "srv-ghi012", - "environment": "env-def456", - "triggered": true + "environment": "env-def456" } From f8f8feb57d07077ca0f53c3213730d624ff380ed Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 19:17:06 +0300 Subject: [PATCH 23/29] chore: generate components doc Signed-off-by: Shambel Amare --- docs/components/Railway.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/components/Railway.mdx b/docs/components/Railway.mdx index e724d40bef..12977b7371 100644 --- a/docs/components/Railway.mdx +++ b/docs/components/Railway.mdx @@ -143,10 +143,12 @@ The Trigger Deploy component starts a new deployment for a Railway service and w ```json { + "deploymentId": "deploy-abc123", "environment": "env-def456", "project": "proj-xyz789", "service": "srv-ghi012", - "triggered": true + "status": "SUCCESS", + "url": "https://my-service-production.up.railway.app" } ``` From 82ff5a30d9a7c28a374c14adcdddeca0181ad459 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 19:17:54 +0300 Subject: [PATCH 24/29] fix: fix prettier formating Signed-off-by: Shambel Amare --- web_src/src/pages/workflowv2/mappers/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index 4575145d58..c088ede1e0 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -112,7 +112,7 @@ import { customFieldRenderers as railwayCustomFieldRenderers, eventStateRegistry as railwayEventStateRegistry, } from "./railway/index"; -import{ +import { componentMappers as cursorComponentMappers, triggerRenderers as cursorTriggerRenderers, eventStateRegistry as cursorEventStateRegistry, From 9940fa0094646f920e93cd315fcccf7f4775ce13 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 19:18:32 +0300 Subject: [PATCH 25/29] fix: fix prettier formating Signed-off-by: Shambel Amare --- .../mappers/railway/on_deployment_event.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts b/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts index 5cb4e37df0..c536dab33e 100644 --- a/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts +++ b/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts @@ -38,7 +38,9 @@ export const onDeploymentEventTriggerRenderer: TriggerRenderer = { const eventData = context.event?.data as OnDeploymentEventData; const serviceName = eventData?.resource?.service?.name || "Service"; const status = eventData?.details?.status || ""; - const timeAgo = context.event?.createdAt ? formatTimeAgo(new Date(context.event?.createdAt)) : ""; + const timeAgo = context.event?.createdAt + ? formatTimeAgo(new Date(context.event?.createdAt)) + : ""; const subtitle = status && timeAgo ? `${status.toLowerCase()} · ${timeAgo}` : status.toLowerCase() || timeAgo; @@ -51,7 +53,9 @@ export const onDeploymentEventTriggerRenderer: TriggerRenderer = { getRootEventValues: (context: TriggerEventContext): Record => { const eventData = context.event?.data as OnDeploymentEventData; const resource = eventData?.resource; - const receivedAt = context.event?.createdAt ? new Date(context.event?.createdAt).toLocaleString() : ""; + const receivedAt = context.event?.createdAt + ? new Date(context.event?.createdAt).toLocaleString() + : ""; // Build Railway deployment URL const projectId = resource?.project?.id; @@ -108,7 +112,9 @@ export const onDeploymentEventTriggerRenderer: TriggerRenderer = { const status = eventData?.details?.status || ""; const timeAgo = lastEvent.createdAt ? formatTimeAgo(new Date(lastEvent.createdAt)) : ""; const subtitle = - status && timeAgo ? `${status.toLowerCase()} · ${timeAgo}` : status.toLowerCase() || timeAgo; + status && timeAgo + ? `${status.toLowerCase()} · ${timeAgo}` + : status.toLowerCase() || timeAgo; props.lastEventData = { title: `${serviceName} deployment`, From 710561efb8573446006566572eac49f0be79a3fd Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 19:19:15 +0300 Subject: [PATCH 26/29] chore: increase polling interval Signed-off-by: Shambel Amare --- pkg/integrations/railway/trigger_deploy.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/pkg/integrations/railway/trigger_deploy.go b/pkg/integrations/railway/trigger_deploy.go index 3aa9af5438..23a4f50738 100644 --- a/pkg/integrations/railway/trigger_deploy.go +++ b/pkg/integrations/railway/trigger_deploy.go @@ -20,7 +20,7 @@ const ( DeploymentPayloadType = "railway.deployment.finished" // Poll interval for checking deployment status - DeploymentPollInterval = 15 * time.Second + DeploymentPollInterval = 30 * time.Second ) // TriggerDeploy triggers a deployment and tracks its status until completion @@ -362,10 +362,18 @@ func (c *TriggerDeploy) poll(ctx core.ActionContext) error { case DeploymentStatusSuccess, DeploymentStatusSleeping: // SLEEPING means the deployment succeeded but the app went to sleep due to inactivity ctx.Logger.Infof("Deployment succeeded (status: %s)", deployment.Status) - return ctx.ExecutionState.Emit(DeployedOutputChannel, DeploymentPayloadType, []any{eventData}) + return ctx.ExecutionState.Emit( + DeployedOutputChannel, + DeploymentPayloadType, + []any{eventData}, + ) case DeploymentStatusCrashed: ctx.Logger.Info("Deployment crashed") - return ctx.ExecutionState.Emit(CrashedOutputChannel, DeploymentPayloadType, []any{eventData}) + return ctx.ExecutionState.Emit( + CrashedOutputChannel, + DeploymentPayloadType, + []any{eventData}, + ) default: // FAILED, REMOVED, SKIPPED all go to failed channel ctx.Logger.Infof("Deployment ended with status: %s", deployment.Status) From 8501c5c474f0f92e5bb9b5b8c4cfa64a911cf8df Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Thu, 12 Feb 2026 20:16:12 +0300 Subject: [PATCH 27/29] chore: format with prettier Signed-off-by: Shambel Amare --- .../mappers/railway/on_deployment_event.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts b/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts index c536dab33e..9faa9a5428 100644 --- a/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts +++ b/web_src/src/pages/workflowv2/mappers/railway/on_deployment_event.ts @@ -38,11 +38,8 @@ export const onDeploymentEventTriggerRenderer: TriggerRenderer = { const eventData = context.event?.data as OnDeploymentEventData; const serviceName = eventData?.resource?.service?.name || "Service"; const status = eventData?.details?.status || ""; - const timeAgo = context.event?.createdAt - ? formatTimeAgo(new Date(context.event?.createdAt)) - : ""; - const subtitle = - status && timeAgo ? `${status.toLowerCase()} · ${timeAgo}` : status.toLowerCase() || timeAgo; + const timeAgo = context.event?.createdAt ? formatTimeAgo(new Date(context.event?.createdAt)) : ""; + const subtitle = status && timeAgo ? `${status.toLowerCase()} · ${timeAgo}` : status.toLowerCase() || timeAgo; return { title: `${serviceName} deployment`, @@ -53,9 +50,7 @@ export const onDeploymentEventTriggerRenderer: TriggerRenderer = { getRootEventValues: (context: TriggerEventContext): Record => { const eventData = context.event?.data as OnDeploymentEventData; const resource = eventData?.resource; - const receivedAt = context.event?.createdAt - ? new Date(context.event?.createdAt).toLocaleString() - : ""; + const receivedAt = context.event?.createdAt ? new Date(context.event?.createdAt).toLocaleString() : ""; // Build Railway deployment URL const projectId = resource?.project?.id; @@ -111,10 +106,7 @@ export const onDeploymentEventTriggerRenderer: TriggerRenderer = { const serviceName = eventData?.resource?.service?.name || "Service"; const status = eventData?.details?.status || ""; const timeAgo = lastEvent.createdAt ? formatTimeAgo(new Date(lastEvent.createdAt)) : ""; - const subtitle = - status && timeAgo - ? `${status.toLowerCase()} · ${timeAgo}` - : status.toLowerCase() || timeAgo; + const subtitle = status && timeAgo ? `${status.toLowerCase()} · ${timeAgo}` : status.toLowerCase() || timeAgo; props.lastEventData = { title: `${serviceName} deployment`, From e230307f3e7aec3ece9d35650b38de34f8b1461a Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Wed, 18 Feb 2026 04:09:19 +0300 Subject: [PATCH 28/29] fix: added missing mapper for railway trigger Signed-off-by: Shambel Amare --- web_src/src/pages/workflowv2/mappers/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index f465e99916..0870617a43 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -112,7 +112,7 @@ import { customFieldRenderers as railwayCustomFieldRenderers, eventStateRegistry as railwayEventStateRegistry, } from "./railway/index"; -import{ +import { componentMappers as prometheusComponentMappers, customFieldRenderers as prometheusCustomFieldRenderers, triggerRenderers as prometheusTriggerRenderers, @@ -204,6 +204,7 @@ const appTriggerRenderers: Record> = { openai: openaiTriggerRenderers, circleci: circleCITriggerRenderers, claude: claudeTriggerRenderers, + railway: railwayTriggerRenderers, prometheus: prometheusTriggerRenderers, cursor: cursorTriggerRenderers, dockerhub: dockerhubTriggerRenderers, @@ -258,6 +259,7 @@ const customFieldRenderers: Record = { const appCustomFieldRenderers: Record> = { github: githubCustomFieldRenderers, prometheus: prometheusCustomFieldRenderers, + railway: railwayCustomFieldRenderers, dockerhub: dockerhubCustomFieldRenderers, }; From fc6c9e83b60417900c34c0b492298e388350ef96 Mon Sep 17 00:00:00 2001 From: Shambel Amare Date: Wed, 18 Feb 2026 17:58:58 +0300 Subject: [PATCH 29/29] fix: add missed evens on trigger block Signed-off-by: Shambel Amare --- .../mappers/railway/trigger_deploy.ts | 127 +++++++++++------- 1 file changed, 79 insertions(+), 48 deletions(-) diff --git a/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts b/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts index 6efb22ad35..20bf5e3d87 100644 --- a/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts +++ b/web_src/src/pages/workflowv2/mappers/railway/trigger_deploy.ts @@ -5,12 +5,14 @@ import { SubtitleContext, ExecutionDetailsContext, ExecutionInfo, + NodeInfo, StateFunction, EventStateRegistry, } from "../types"; import RailwayLogo from "@/assets/icons/integrations/railway.svg"; import { formatTimeAgo } from "@/utils/date"; -import { DEFAULT_EVENT_STATE_MAP, EventState, EventStateMap } from "@/ui/componentBase"; +import { DEFAULT_EVENT_STATE_MAP, EventSection, EventState, EventStateMap } from "@/ui/componentBase"; +import { getTriggerRenderer } from ".."; interface TriggerDeployMetadata { project?: { id?: string; name?: string }; @@ -166,12 +168,84 @@ function formatDeploymentStatus(status: string | undefined): string { return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase(); } +/** + * Builds the subtitle string for a Railway deployment execution + */ +function getDeploymentSubtitle(execution: ExecutionInfo): string { + const execMetadata = execution.metadata as TriggerDeployExecutionMetadata; + const status = execMetadata?.status; + + // Show current deployment status while running + if (execution.state === "STATE_STARTED" || execution.state === "STATE_PENDING") { + switch (status) { + case DeploymentStatus.QUEUED: + case DeploymentStatus.WAITING: + return "Queued..."; + case DeploymentStatus.BUILDING: + return "Building..."; + case DeploymentStatus.DEPLOYING: + return "Deploying..."; + default: + return "Starting..."; + } + } + + // Finished states + if (execution.state === "STATE_FINISHED") { + const updatedAt = execution.updatedAt ? new Date(execution.updatedAt) : null; + const timeAgo = updatedAt ? formatTimeAgo(updatedAt) : ""; + + switch (status) { + case DeploymentStatus.SUCCESS: + case DeploymentStatus.SLEEPING: + // SLEEPING means deployment succeeded but app went to sleep + return timeAgo ? `Deployed ${timeAgo}` : "Deployed"; + case DeploymentStatus.CRASHED: + return timeAgo ? `Crashed ${timeAgo}` : "Crashed"; + case DeploymentStatus.FAILED: + case DeploymentStatus.REMOVED: + case DeploymentStatus.SKIPPED: + return timeAgo ? `Failed ${timeAgo}` : "Failed"; + default: + // Fallback to result-based status + if (execution.result === "RESULT_PASSED") { + return timeAgo ? `Deployed ${timeAgo}` : "Deployed"; + } + return timeAgo ? `Failed ${timeAgo}` : "Failed"; + } + } + + const createdAt = execution.createdAt ? new Date(execution.createdAt) : null; + return createdAt ? formatTimeAgo(createdAt) : ""; +} + +/** + * Builds event sections for a Railway TriggerDeploy execution + */ +function getTriggerDeployEventSections(nodes: NodeInfo[], execution: ExecutionInfo): EventSection[] { + const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName!); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent! }); + const isRunning = execution.state === "STATE_STARTED" || execution.state === "STATE_PENDING"; + + return [ + { + showAutomaticTime: isRunning, + receivedAt: new Date(execution.createdAt!), + eventTitle: title, + eventSubtitle: getDeploymentSubtitle(execution), + eventState: triggerDeployStateFunction(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} + /** * Mapper for the "railway.triggerDeploy" component type */ export const triggerDeployMapper: ComponentBaseMapper = { props(context: ComponentBaseContext) { - const { node, componentDefinition } = context; + const { node, nodes, componentDefinition, lastExecutions } = context; const metadata = node.metadata as unknown as TriggerDeployMetadata; const metadataItems = []; @@ -198,56 +272,13 @@ export const triggerDeployMapper: ComponentBaseMapper = { title: node.name || componentDefinition.label || "Trigger Deploy", metadata: metadataItems, eventStateMap: TRIGGER_DEPLOY_STATE_MAP, + eventSections: lastExecutions[0] ? getTriggerDeployEventSections(nodes, lastExecutions[0]) : undefined, + includeEmptyState: !lastExecutions[0], }; }, subtitle(context: SubtitleContext): string { - const { execution } = context; - const execMetadata = execution.metadata as TriggerDeployExecutionMetadata; - const status = execMetadata?.status; - - // Show current deployment status while running - if (execution.state === "STATE_STARTED" || execution.state === "STATE_PENDING") { - switch (status) { - case DeploymentStatus.QUEUED: - case DeploymentStatus.WAITING: - return "Queued..."; - case DeploymentStatus.BUILDING: - return "Building..."; - case DeploymentStatus.DEPLOYING: - return "Deploying..."; - default: - return "Starting..."; - } - } - - // Finished states - if (execution.state === "STATE_FINISHED") { - const updatedAt = execution.updatedAt ? new Date(execution.updatedAt) : null; - const timeAgo = updatedAt ? formatTimeAgo(updatedAt) : ""; - - switch (status) { - case DeploymentStatus.SUCCESS: - case DeploymentStatus.SLEEPING: - // SLEEPING means deployment succeeded but app went to sleep - return timeAgo ? `Deployed ${timeAgo}` : "Deployed"; - case DeploymentStatus.CRASHED: - return timeAgo ? `Crashed ${timeAgo}` : "Crashed"; - case DeploymentStatus.FAILED: - case DeploymentStatus.REMOVED: - case DeploymentStatus.SKIPPED: - return timeAgo ? `Failed ${timeAgo}` : "Failed"; - default: - // Fallback to result-based status - if (execution.result === "RESULT_PASSED") { - return timeAgo ? `Deployed ${timeAgo}` : "Deployed"; - } - return timeAgo ? `Failed ${timeAgo}` : "Failed"; - } - } - - const createdAt = execution.createdAt ? new Date(execution.createdAt) : null; - return createdAt ? formatTimeAgo(createdAt) : ""; + return getDeploymentSubtitle(context.execution); }, getExecutionDetails(context: ExecutionDetailsContext) {