Repository navigation
feat(events): Event subscriptions and GitHub Actions runners - #255
Conversation
Webhook deliveries from any provider become one event shape, and configured subscriptions decide what each event does: deploy, preview, start a run or notify. A queued GitHub Actions job with matching labels gets a one job runner in its own run, isolated in a microVM by default. Repeated deliveries are processed once, and a failed one can be redelivered.
Event subscriptions are readable only with global access, so scoped keys cannot see run commands or environment values. Redelivering a partly failed delivery retries only the subscriptions that failed, so no run starts twice.
Webhook deliveries now use the same event as notifications, so there is one shape to match on. Delivery outcomes survive a restart, so a repeated delivery stays a duplicate after the agent restarts. GitHub runners are their own action with only the settings they use.
Code Review SummaryAdds a normalized event pipeline: github, gitlab and generic receivers feed subscriptions that deploy, create previews, run sandboxes, publish notifications, or start one-shot GitHub Actions runners through a GitHub App JIT config. Existing deployment webhooks now flow through the same normalization and, when a delivery ID header is present, share delivery logging and deduplication, so a repeated ID returns 200 instead of deploying again. Delivery records persist in SQLite with per-subscription outcomes and a bounded retention, and the events configuration is restricted to global admins. 🚀 Key Improvements
📉 Regressions
|
| c.JSON(http.StatusOK, gin.H{"status": "duplicate"}) | ||
| return | ||
| } | ||
| if err != nil { |
There was a problem hiding this comment.
Every beginDelivery error — including deliveryStore failing because the notification store is unavailable or deployments_path is unset — now answers 503, so existing deployment webhooks stop deploying where they previously worked. On store errors, log and continue without deduplication instead of rejecting the delivery.
| return event.Source + ":" + event.Type | ||
| } | ||
|
|
||
| func (e Event) Attribute(key string) string { |
There was a problem hiding this comment.
Attribute only returns string values, so the documented attributes filter on installation_id (stored as int64 by the GitHub adapter) always resolves to "" and the subscription silently never matches. Stringify non-nil values so every documented attribute is filterable.
| func (e Event) Attribute(key string) string { | |
| func (e Event) Attribute(key string) string { | |
| if value, ok := e.Attributes[key].(string); ok { | |
| return value | |
| } | |
| if value := e.Attributes[key]; value != nil { | |
| return fmt.Sprint(value) | |
| } | |
| return "" | |
| } |
| func (state *deliveryState) finish(record *deliveryRecord, outcome string, outcomes map[string]string) { | ||
| state.mu.Lock() | ||
| defer state.mu.Unlock() | ||
| record.Outcome, record.Subscriptions = outcome, outcomes |
There was a problem hiding this comment.
Legacy deploy/preview deliveries call finish(record, outcome, nil), so this assignment stores a nil map and GET /api/events/deliveries serialises "subscriptions": null, while openapi.json declares an object and the events path always writes {}. Normalise nil to an empty map so every delivery matches the published schema.
| record.Outcome, record.Subscriptions = outcome, outcomes | |
| if outcomes == nil { | |
| outcomes = map[string]string{} | |
| } | |
| record.Outcome, record.Subscriptions = outcome, outcomes |
| e.Attributes[k] = v | ||
| } | ||
| } | ||
| e.Attributes["repository"] = e.Attributes["subject"] |
There was a problem hiding this comment.
Branch, revision and pr are only filled when the payload did not supply them, but repository is always overwritten, so a generic delivery that sends attributes: {repository: acme/api} loses it and a subscription filtering on that value silently never matches. Fill repository only when it is absent, like the other derived attributes.
| e.Attributes["repository"] = e.Attributes["subject"] | |
| if value, ok := e.Attributes["repository"]; !ok || value == "" { | |
| e.Attributes["repository"] = e.Attributes["subject"] | |
| } |
| if err := tx.QueryRow("SELECT COUNT(*) FROM deliveries").Scan(&count); err != nil { | ||
| return nil, false, err | ||
| } | ||
| if count >= limit { |
There was a problem hiding this comment.
Eviction only removes rows whose outcome is not processing. A row left processing by an unclean shutdown is therefore never removed: it permanently suppresses redelivery of its ID, and once limit such rows exist every new delivery, including legacy deploy hooks, fails with event queue full and returns 503 forever. Mark stale processing rows failed when the store is opened.
A delivery cut off by an agent restart stayed in progress forever, so its redelivery was refused as a duplicate and enough of them blocked all new deliveries. They are now marked failed when the agent starts, so they can be delivered again. Deployment webhooks keep deploying when the delivery log is unavailable, instead of being rejected. Numeric attributes such as the GitHub installation can be filtered on, a repository sent by the caller is kept, and every delivery reports its subscriptions as an object.
There was a problem hiding this comment.
internal/events/events.go
fmt.Sprint prints a JSON number such as 3456996 (decoded as float64) as 3.456996e+06, so a generic delivery with attributes: {installation_id: 3456996} never matches a subscription filter of "3456996". Format integral float64 values as decimal integers before falling back to fmt.Sprint.
Suggested replacement:
func (e Event) Attribute(key string) string {
value := e.Attributes[key]
if value == nil {
return ""
}
if text, ok := value.(string); ok {
return text
}
if number, ok := value.(float64); ok && number == float64(int64(number)) {
return fmt.Sprintf("%d", int64(number))
}
return fmt.Sprint(value)
}
The opening line named only Compose deployments. It now names the three things people come for: deploying apps, running sandboxes for AI agents, and running CI jobs from GitHub, GitLab or any webhook, in every translation.
The feature lists now describe runs and the CI jobs that events start, and say that deploy history keeps the last 50 deploys of each project rather than every one.
Event subscriptions let operators trigger automation from provider events. GitHub Actions jobs can use temporary runners with microVM isolation by default.
Existing deployment webhooks remain compatible.