-
Notifications
You must be signed in to change notification settings - Fork 109
feat: Add Buildkite integration #2990
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
00b87ff
6571c99
49cd1cc
42e9126
b16fc43
3f5ce7a
f089cf8
e7bcc8d
bd4810c
a776857
f31361f
dedb70a
e43bf66
e62da38
f557eb8
0f17905
accfc2e
b6aa311
c81937d
9a4ca91
e2340a4
e5326d8
e0900f0
05e9f7e
0f5d0a5
a67afa7
5208996
3eb9902
64d6c49
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| --- | ||
| title: "Buildkite" | ||
| --- | ||
|
|
||
| Trigger and react to your Buildkite builds | ||
|
|
||
| ## Triggers | ||
|
|
||
| <CardGrid> | ||
| <LinkCard title="On Build Finished" href="#on-build-finished" description="Listen to Buildkite build completion events" /> | ||
| </CardGrid> | ||
|
|
||
| import { CardGrid, LinkCard } from "@astrojs/starlight/components"; | ||
|
|
||
| ## Actions | ||
|
|
||
| <CardGrid> | ||
| <LinkCard title="Trigger Build" href="#trigger-build" description="Trigger a Buildkite build and wait for completion." /> | ||
| </CardGrid> | ||
|
|
||
| ## Instructions | ||
|
|
||
| To create new Buildkite API key, open [Personal Settings > API Access Tokens](https://buildkite.com/user/api-access-tokens/new). | ||
|
|
||
| <a id="on-build-finished"></a> | ||
|
|
||
| ## On Build Finished | ||
|
|
||
| The On Build Finished trigger starts a workflow execution when a Buildkite build completes. | ||
|
|
||
| ### Use Cases | ||
|
|
||
| - **CI/CD pipeline chaining**: Trigger downstream workflows when builds complete | ||
| - **Build monitoring**: Monitor build results and trigger alerts or notifications | ||
| - **Deployment orchestration**: Start deployment workflows only after successful builds | ||
| - **Build result processing**: Process build artifacts or results based on build outcome | ||
|
|
||
| ### Configuration | ||
|
|
||
| - **Pipeline**: Select the Buildkite pipeline to monitor | ||
| - **Branch** (optional): Filter to specific branch (exact match) | ||
|
|
||
| ### Event Data | ||
|
|
||
| Each build finished event includes: | ||
| - **build**: Build information including ID, state, result, commit, branch | ||
| - **pipeline**: Pipeline information including ID and name | ||
| - **organization**: Organization information | ||
| - **sender**: User who triggered the build | ||
|
|
||
| ### Webhook Setup | ||
|
|
||
| This trigger requires setting up a webhook in Buildkite to receive build events: | ||
|
|
||
| 1. When you configure this trigger, SuperPlane generates a unique webhook URL and token | ||
| 2. A browser action will guide you to the Buildkite webhook settings page | ||
| 3. In Buildkite, create a new webhook with: | ||
| - **Webhook URL**: The URL provided by SuperPlane | ||
| - **Webhook Token**: The token provided by SuperPlane | ||
| - **Events**: Select "build.finished" | ||
| - **Pipelines**: Select the specific pipeline(s) you want to monitor | ||
|
|
||
| ### Event Processing | ||
|
|
||
| SuperPlane automatically: | ||
| 1. Receives webhook events at the trigger-specific webhook URL | ||
| 2. Authenticates requests using the webhook token | ||
| 3. Filters events by pipeline and branch (if configured) | ||
| 4. Emits buildkite.build.finished events to start workflow executions | ||
|
|
||
| ### Example Data | ||
|
|
||
| ```json | ||
| { | ||
| "build": { | ||
| "blocked": false, | ||
| "branch": "main", | ||
| "commit": "a1b2c3d4e5f6789012345678901234567890abcd", | ||
| "id": "12345678-1234-1234-1234-123456789012", | ||
| "message": "Fix: Update dependencies", | ||
| "number": 123, | ||
| "state": "passed", | ||
| "web_url": "https://buildkite.com/example-org/example-pipeline/builds/123" | ||
| }, | ||
| "event": "build.finished", | ||
| "organization": { | ||
| "id": "example-org", | ||
| "name": "Example Organization" | ||
| }, | ||
| "pipeline": { | ||
| "id": "example-pipeline", | ||
| "name": "Example Pipeline" | ||
| }, | ||
| "sender": { | ||
| "email": "john@example.com", | ||
| "id": "user-123", | ||
| "name": "John Doe" | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| <a id="trigger-build"></a> | ||
|
|
||
| ## Trigger Build | ||
|
|
||
| Trigger a Buildkite build and wait for completion using polling mechanism. | ||
|
|
||
| ### How It Works | ||
|
|
||
| 1. Triggers a build via Buildkite API | ||
| 2. Monitors build status via polling | ||
| 3. Emits to success/failure channels when build finishes | ||
|
|
||
| ### Configuration | ||
|
|
||
| - **Pipeline**: Select pipeline to trigger | ||
| - **Branch**: Git branch to build | ||
| - **Commit**: Git commit SHA (optional, defaults to HEAD) | ||
| - **Message**: Optional build message | ||
| - **Environment Variables**: Optional env vars for the build | ||
| - **Metadata**: Optional metadata for the build | ||
|
|
||
| ### Output Channels | ||
|
|
||
| - **Passed**: Build completed successfully | ||
| - **Failed**: Build failed, was cancelled, or was blocked | ||
|
|
||
| ### Example Output | ||
|
|
||
| ```json | ||
| { | ||
| "build": { | ||
| "blocked": false, | ||
| "branch": "main", | ||
| "commit": "a1b2c3d4e5f678901234567890abcd", | ||
| "id": "12345678-1234-1234-123456789012", | ||
| "message": "Triggered by SuperPlane", | ||
| "number": 123, | ||
| "state": "passed", | ||
| "web_url": "https://buildkite.com/example-org/example-pipeline/builds/123" | ||
| }, | ||
| "organization": { | ||
| "id": "example-org" | ||
| }, | ||
| "pipeline": { | ||
| "id": "example-pipeline" | ||
| } | ||
| } | ||
| ``` | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| // Package buildkite implements the Buildkite integration for SuperPlane. | ||
| package buildkite | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "net/http" | ||
| "regexp" | ||
| "strings" | ||
|
|
||
| "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.RegisterIntegration("buildkite", &Buildkite{}) | ||
| } | ||
|
|
||
| type Buildkite struct{} | ||
|
|
||
| type Configuration struct { | ||
| Organization string `json:"organization"` | ||
| APIToken string `json:"apiToken"` | ||
| } | ||
|
|
||
| type Metadata struct { | ||
| Organizations []string `json:"organizations"` | ||
| SetupComplete bool `json:"setupComplete"` | ||
| OrgSlug string `json:"orgSlug"` | ||
| } | ||
|
|
||
| func extractOrgSlug(orgInput string) (string, error) { | ||
| if orgInput == "" { | ||
| return "", fmt.Errorf("organization input is empty") | ||
| } | ||
|
|
||
| urlPattern := regexp.MustCompile(`(?:https?://)?(?:www\.)?buildkite\.com/(?:organizations/)?([^/]+)`) | ||
| if matches := urlPattern.FindStringSubmatch(strings.TrimSpace(orgInput)); len(matches) > 1 { | ||
| return matches[1], nil | ||
| } | ||
|
|
||
| // Just the slug (validate it looks like a valid org slug) | ||
| slugPattern := regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*[a-zA-Z0-9]$`) | ||
| if slugPattern.MatchString(strings.TrimSpace(orgInput)) { | ||
| return strings.TrimSpace(orgInput), nil | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Slug regex rejects valid single-character org slugsLow Severity The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Single-character org slugs rejected as invalidLow Severity The slug validation regex |
||
|
|
||
| return "", fmt.Errorf("invalid organization format: %s. Expected format: https://buildkite.com/my-org or just 'my-org'", orgInput) | ||
| } | ||
|
|
||
| func (b *Buildkite) createTokenSetupAction(ctx core.SyncContext) { | ||
| ctx.Integration.NewBrowserAction(core.BrowserAction{ | ||
| Description: "Generate API token for triggering builds. Required permissions: `read_organizations`, `read_user`, `read_pipelines`, `read_builds`, `write_builds`.", | ||
| URL: "https://buildkite.com/user/api-access-tokens", | ||
| Method: "GET", | ||
| }) | ||
| } | ||
immz4 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| func (b *Buildkite) Name() string { | ||
| return "buildkite" | ||
| } | ||
|
|
||
| func (b *Buildkite) Label() string { | ||
| return "Buildkite" | ||
| } | ||
|
|
||
| func (b *Buildkite) Icon() string { | ||
| return "workflow" | ||
| } | ||
|
|
||
| func (b *Buildkite) Description() string { | ||
| return "Trigger and react to your Buildkite builds" | ||
| } | ||
|
|
||
| func (b *Buildkite) Instructions() string { | ||
| return "To create new Buildkite API key, open [Personal Settings > API Access Tokens](https://buildkite.com/user/api-access-tokens/new)." | ||
| } | ||
|
|
||
| func (b *Buildkite) Configuration() []configuration.Field { | ||
| return []configuration.Field{ | ||
| { | ||
| Name: "organization", | ||
| Label: "Organization URL", | ||
| Type: configuration.FieldTypeString, | ||
| Description: "Buildkite organization URL (e.g. https://buildkite.com/my-org or just my-org)", | ||
| Placeholder: "e.g. https://buildkite.com/my-org or my-org", | ||
| Required: true, | ||
| }, | ||
| { | ||
| Name: "apiToken", | ||
| Label: "API Token", | ||
| Type: configuration.FieldTypeString, | ||
| Sensitive: true, | ||
| Description: "Buildkite API token with permissions: read_organizations, read_user, read_pipelines, read_builds, write_builds", | ||
| Placeholder: "e.g. bkua_...", | ||
| Required: true, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| func (b *Buildkite) Cleanup(ctx core.IntegrationCleanupContext) error { | ||
| return nil | ||
| } | ||
|
|
||
| func (b *Buildkite) Sync(ctx core.SyncContext) error { | ||
| config := Configuration{} | ||
| err := mapstructure.Decode(ctx.Configuration, &config) | ||
| if err != nil { | ||
| return fmt.Errorf("Failed to decode configuration: %v", err) | ||
| } | ||
|
|
||
| if config.Organization == "" { | ||
| return fmt.Errorf("Organization is required") | ||
| } | ||
|
|
||
| orgSlug, err := extractOrgSlug(config.Organization) | ||
| if err != nil { | ||
| return fmt.Errorf("Invalid organization format: %v", err) | ||
| } | ||
|
|
||
| // Prompt user to create API token | ||
| if config.APIToken == "" { | ||
| b.createTokenSetupAction(ctx) | ||
| return nil | ||
| } | ||
|
|
||
| // Validate the API token | ||
| client, err := NewClient(ctx.HTTP, ctx.Integration) | ||
| if err != nil { | ||
| return fmt.Errorf("failed to create client: %v", err) | ||
| } | ||
| _, err = client.ValidateToken() | ||
| if err != nil { | ||
| return fmt.Errorf("Invalid API token: %v", err) | ||
| } | ||
|
|
||
| // Update metadata to track setup completion | ||
| metadata := Metadata{} | ||
| if err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata); err != nil { | ||
| metadata = Metadata{} | ||
| } | ||
|
|
||
| metadata.OrgSlug = orgSlug | ||
| metadata.SetupComplete = true | ||
| ctx.Integration.SetMetadata(metadata) | ||
|
|
||
| ctx.Integration.Ready() | ||
| return nil | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
immz4 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| func (b *Buildkite) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { | ||
| client, err := NewClient(ctx.HTTP, ctx.Integration) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("error creating client: %v", err) | ||
| } | ||
|
|
||
| switch resourceType { | ||
|
|
||
| case "pipeline": | ||
| orgConfig, err := ctx.Integration.GetConfig("organization") | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to get organization from integration config: %w", err) | ||
| } | ||
| orgSlug, err := extractOrgSlug(string(orgConfig)) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to extract organization slug: %w", err) | ||
| } | ||
|
|
||
| pipelines, err := client.ListPipelines(orgSlug) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("error listing pipelines: %v", err) | ||
| } | ||
|
|
||
| resources := make([]core.IntegrationResource, len(pipelines)) | ||
| for i, pipeline := range pipelines { | ||
| resources[i] = core.IntegrationResource{ | ||
| Type: "pipeline", | ||
| ID: pipeline.Slug, | ||
| Name: pipeline.Name, | ||
| } | ||
| } | ||
| return resources, nil | ||
|
|
||
| default: | ||
| return nil, fmt.Errorf("unsupported resource type: %s", resourceType) | ||
| } | ||
| } | ||
|
|
||
| func (b *Buildkite) Actions() []core.Action { | ||
| return []core.Action{} | ||
| } | ||
|
|
||
| func (b *Buildkite) HandleAction(ctx core.IntegrationActionContext) error { | ||
| return nil | ||
| } | ||
|
|
||
| func (b *Buildkite) HandleRequest(ctx core.HTTPRequestContext) { | ||
| ctx.Response.WriteHeader(http.StatusNotFound) | ||
| } | ||
|
|
||
| func (b *Buildkite) Components() []core.Component { | ||
| return []core.Component{ | ||
| &TriggerBuild{}, | ||
| } | ||
| } | ||
|
|
||
| func (b *Buildkite) Triggers() []core.Trigger { | ||
| return []core.Trigger{ | ||
| &OnBuildFinished{}, | ||
| } | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
URL regex captures query params and fragments in slug
Medium Severity
The URL regex capture group
([^/]+)inextractOrgSlugmatches everything afterbuildkite.com/except forward slashes, so if a user pastes a URL with query parameters or a fragment (e.g.,https://buildkite.com/my-org?tab=pipelines), the extracted slug becomesmy-org?tab=pipelines. This causes all subsequent API calls and generated setup-action URLs to use an invalid org slug, producing confusing "not found" errors during integration setup. The capture group needs to also exclude?and#.