Skip to content

feat(events): Event subscriptions and GitHub Actions runners - #255

Merged
nfebe merged 6 commits into
mainfrom
feat/events
Nov 10, 2026
Merged

nfebe merged 6 commits into
mainfrom
feat/events

Conversation

@nfebe

@nfebe nfebe commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

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.

nfebe added 3 commits November 6, 2026 01:12
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.
@sourceant

sourceant Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Code Review Summary

Adds 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

  • Provider webhooks can trigger deploys, previews, sandbox runs and notifications through declarative config.
  • Delivery log with per-subscription outcomes survives restarts and retries only the subscriptions that failed.
  • Events secrets and GitHub App keys stay out of the config API for non-global callers.

📉 Regressions

  • Deployment webhooks can now return 503 when 128 deliveries are in flight, where they previously always processed.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread internal/api/webhook_handlers.go Outdated
c.JSON(http.StatusOK, gin.H{"status": "duplicate"})
return
}
if err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/events/events.go
return event.Source + ":" + event.Type
}

func (e Event) Attribute(key string) string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
record.Outcome, record.Subscriptions = outcome, outcomes
if outcomes == nil {
outcomes = map[string]string{}
}
record.Outcome, record.Subscriptions = outcome, outcomes

Comment thread internal/webhook/normalize.go Outdated
e.Attributes[k] = v
}
}
e.Attributes["repository"] = e.Attributes["subject"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
}

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

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.
@nfebe
nfebe merged commit 9904ea9 into main Nov 10, 2026
6 checks passed
@nfebe
nfebe deleted the feat/events branch November 10, 2026 10:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant