Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
[![Go](https://img.shields.io/badge/Go-1.24+-blue?logo=go)](https://golang.org/)
[![Docker](https://img.shields.io/badge/Docker-Container-blue?logo=docker)](https://www.docker.com/)

[![Beta](https://img.shields.io/badge/Status-Beta-yellow?style=for-the-badge)](https://github.com/gateixeira/live-actions/issues)

# Live Actions - GitHub Actions Monitoring 🚀

> ⚠️ **Beta Software Notice**: Live Actions is currently in beta. While functional and actively developed, expect potential instabilities. Please report issues and provide feedback to help us improve!
Expand Down Expand Up @@ -100,6 +98,13 @@ The UI updates in real time via Server-Sent Events — no manual refresh needed.
| `TLS_ENABLED` | `false` | Enable HTTPS cookie flags |
| `DATA_RETENTION_DAYS` | `30` | How long to keep historical data |
| `CLEANUP_INTERVAL_HOURS` | `24` | How often to run data cleanup |
| `WEBHOOK_TRANSPORT` | `http` | How webhooks reach the app: `http` (public endpoint) or `websocket` (relay, no public endpoint needed) |
| `GITHUB_TOKEN` | *(required for `websocket`)* | Token with `admin:repo_hook` (per-repo) or `admin:org_hook` (per-org) scope. `gh auth token` works. |
| `GITHUB_REPO` | | `owner/repo` to subscribe to. Mutually exclusive with `GITHUB_ORG` and `GITHUB_ENTERPRISE`. |
| `GITHUB_ORG` | | Org login to subscribe to. Mutually exclusive with `GITHUB_REPO` and `GITHUB_ENTERPRISE`. |
| `GITHUB_ENTERPRISE` | | Enterprise slug to subscribe to. Mutually exclusive with `GITHUB_REPO` and `GITHUB_ORG`. See enterprise caveat below. |
| `GITHUB_EVENTS` | `workflow_run,workflow_job` | Comma-separated event types for the WebSocket subscription (use `*` for all). |
| `GITHUB_HOST` | `github.com` | GitHub host. Use your `<customer>.ghe.com` subdomain for Enterprise Cloud with data residency, or your GHES hostname for GitHub Enterprise Server. |

## GitHub Webhook Configuration

Expand Down Expand Up @@ -133,6 +138,69 @@ ngrok http 8080

Update your GitHub webhook URL to the ngrok HTTPS URL (e.g., `https://a1b2c3d4.ngrok.io/webhook`).

### WebSocket transport (no public endpoint)

If the app runs somewhere GitHub can't reach (behind NAT, on a laptop, in a
private VPC), set `WEBHOOK_TRANSPORT=websocket` and the app will subscribe
to GitHub's relay over an outbound WebSocket connection. This mirrors the
approach used by the [`gh webhook`](https://github.com/cli/gh-webhook) CLI:
on startup a temporary webhook is created on the target repo or org with
`active=false`, the relay is dialed over `wss://`, the hook is activated,
and deliveries arrive as JSON frames on the open connection. The HTTP
endpoint is not registered when this mode is enabled by the operator on the
GitHub side, but the app keeps `POST /webhook` available either way so you
can still send manual replays from `curl`.

**Quick start (per-repo):**

```bash
export WEBHOOK_TRANSPORT=websocket
export GITHUB_TOKEN=$(gh auth token) # needs admin:repo_hook scope
export GITHUB_REPO=owner/repo
export GITHUB_EVENTS=workflow_run,workflow_job
make run
```

**Per-org:**

```bash
export WEBHOOK_TRANSPORT=websocket
export GITHUB_TOKEN=$(gh auth token) # needs admin:org_hook scope
export GITHUB_ORG=my-org
make run
```

**Per-enterprise:**

```bash
export WEBHOOK_TRANSPORT=websocket
export GITHUB_TOKEN=$(gh auth token) # needs manage_webhooks (or site_admin) scope
export GITHUB_ENTERPRISE=my-enterprise
make run
```

> **Note on enterprise mode:** the upstream `gh webhook` CLI only supports
> repo and org hooks; enterprise support here uses the same protocol against
> `POST /enterprises/{slug}/hooks` but the relay (`webhook.gh.io`) is not
> known to be exercised at the enterprise level. It may or may not return a
> usable `ws_url` depending on whether your account has the feature enabled
> at that scope. Test in a non-critical environment first.

**Caveats:**

- The relay endpoint (`webhook.gh.io`) is GitHub-managed and undocumented;
the protocol can change without notice.
- Each subscription is single-subscriber: only one process at a time can
consume a given hook's WebSocket stream.
- Per-frame HMAC verification is skipped because the relay itself is
authenticated by `GITHUB_TOKEN` at connection time.
- The temporary hook is best-effort deleted on graceful shutdown. A hard
kill (`SIGKILL`, OOM) will leak the hook in the repo or org settings;
delete it manually under *Settings → Webhooks* if that happens.
- Deliveries arriving over WebSocket are not visible in GitHub's "Recent
Deliveries" UI as redeliverable, since the hook stays inactive between
reconnects.

## API Endpoints

| Endpoint | Description |
Expand Down
67 changes: 67 additions & 0 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,36 @@ package server
import (
"context"
"embed"
"errors"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"

"github.com/gateixeira/live-actions/handlers"
"github.com/gateixeira/live-actions/internal/config"
"github.com/gateixeira/live-actions/internal/database"
"github.com/gateixeira/live-actions/internal/middleware"
"github.com/gateixeira/live-actions/internal/services"
"github.com/gateixeira/live-actions/internal/services/ghws"
"github.com/gateixeira/live-actions/pkg/logger"
pkgmetrics "github.com/gateixeira/live-actions/pkg/metrics"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)

// ingestAdapter bridges the *handlers.WebhookHandler.Ingest signature into
// the ghws.Ingester interface so the ghws package has no compile-time
// dependency on the handlers package.
type ingestAdapter struct{ h *handlers.WebhookHandler }

func (a ingestAdapter) Ingest(eventType, deliveryID string, body []byte) ghws.IngestResult {
r := a.h.Ingest(eventType, deliveryID, body)
return ghws.IngestResult{Status: r.Status, Message: r.Message}
}

// SetupAndRun configures the router and starts the server
func SetupAndRun(staticFS embed.FS) {
cfg, err := config.NewConfig()
Expand Down Expand Up @@ -144,6 +157,42 @@ func SetupAndRun(staticFS embed.FS) {
go metricsService.Start()
go gracefulShutdown.Start()

// Optional: open a WebSocket relay subscription so deliveries can flow
// without a publicly reachable HTTP endpoint.
var (
wsCancel context.CancelFunc
wsDone = make(chan struct{})
)
if cfg.Vars.WebhookTransport == "websocket" {
sub, err := ghws.NewSubscriber(ghws.Config{
Token: cfg.Vars.GitHubToken,
Host: cfg.Vars.GitHubHost,
Repo: cfg.Vars.GitHubRepo,
Org: cfg.Vars.GitHubOrg,
Enterprise: cfg.Vars.GitHubEnterprise,
Events: splitEvents(cfg.Vars.GitHubEvents),
Secret: cfg.Vars.WebhookSecret,
}, ingestAdapter{h: webhookHandler})
if err != nil {
logger.Logger.Fatal("Invalid WebSocket subscriber config", zap.Error(err))
}
var wsCtx context.Context
wsCtx, wsCancel = context.WithCancel(context.Background())
go func() {
defer close(wsDone)
if err := sub.Run(wsCtx); err != nil && !errors.Is(err, context.Canceled) {
logger.Logger.Error("WebSocket subscriber exited with error", zap.Error(err))
}
}()
logger.Logger.Info("WebSocket transport enabled",
zap.String("repo", cfg.Vars.GitHubRepo),
zap.String("org", cfg.Vars.GitHubOrg),
zap.String("enterprise", cfg.Vars.GitHubEnterprise),
zap.String("events", cfg.Vars.GitHubEvents))
} else {
close(wsDone)
}

logger.Logger.Info("Starting server",
zap.String("port", cfg.Vars.Port),
zap.String("environment", cfg.Vars.Environment),
Expand All @@ -163,6 +212,10 @@ func SetupAndRun(staticFS embed.FS) {
gracefulShutdown.Wait()

// Stop services
if wsCancel != nil {
wsCancel()
<-wsDone
}
webhookHandler.Shutdown()
cleanupService.Stop()
metricsService.Stop()
Expand All @@ -180,3 +233,17 @@ func spaFallbackHandler(indexHTML []byte) gin.HandlerFunc {
c.Data(http.StatusOK, "text/html; charset=utf-8", indexHTML)
}
}

// splitEvents parses a comma-separated GITHUB_EVENTS value into a slice
// suitable for ghws.Config. Whitespace is trimmed and empty entries are
// dropped so trailing/duplicate commas are forgiving.
func splitEvents(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
Comment on lines +237 to +249
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ go 1.24.0

require (
github.com/gin-gonic/gin v1.9.1
github.com/gorilla/websocket v1.5.3
github.com/prometheus/client_golang v1.22.0
github.com/prometheus/client_model v0.6.1
github.com/stretchr/testify v1.10.0
go.uber.org/zap v1.27.0
modernc.org/sqlite v1.45.0
Expand Down Expand Up @@ -35,6 +35,7 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
Expand Down
Loading
Loading