Add PLUGINS.md with complete documentation for building external GitCaddy plugins using the gRPC-based plugin protocol. Documentation includes: - Protocol overview and service definition - Lifecycle diagram (Initialize → HealthCheck → OnEvent/HandleHTTP → Shutdown) - Complete message reference for all 6 RPC methods - Plugin manifest specification (routes, events, permissions, license tiers) - Health monitoring and auto-restart behavior - Configuration guide for external vs managed mode - Transport details (h2c/HTTP2, gRPC wire format) - Full working examples in Go, C#, and Python - Debugging tips and common issues Also updates README.md to reference the plugin guide and removes outdated Chinese translations (zh-cn, zh-tw) that were not being maintained. This provides plugin developers with everything needed to build and deploy external services that integrate with GitCaddy's plugin framework.
17 KiB
GitCaddy Plugin Development Guide
This guide explains how to build external plugins for GitCaddy. Plugins are standalone services that communicate with the server over gRPC (HTTP/2) using a well-defined protocol.
Table of Contents
- Overview
- Protocol
- Plugin Manifest
- Health Monitoring
- Configuration
- Transport
- Example: Go Plugin
- Example: C# Plugin
- Example: Python Plugin
- Debugging
Overview
A GitCaddy plugin is any process that implements the PluginService gRPC interface. The server connects to the plugin on startup, calls Initialize to get its manifest, and then:
- Health checks the plugin periodically (default: every 30 seconds)
- Dispatches events the plugin has subscribed to (e.g.,
license:updated) - Proxies HTTP requests to routes the plugin has declared
- Shuts down the plugin gracefully when the server stops
Plugins can run in two modes:
- External mode - The plugin runs independently (Docker, systemd, etc.). The server connects to it.
- Managed mode - The server launches the plugin binary and manages its process lifecycle.
Protocol
The protocol is defined in modules/plugins/pluginv1/plugin.proto.
Service Definition
service PluginService {
rpc Initialize(InitializeRequest) returns (InitializeResponse);
rpc Shutdown(ShutdownRequest) returns (ShutdownResponse);
rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse);
rpc GetManifest(GetManifestRequest) returns (PluginManifest);
rpc OnEvent(PluginEvent) returns (EventResponse);
rpc HandleHTTP(HTTPRequest) returns (HTTPResponse);
}
All 6 RPCs are unary (request-response). The server is the client; the plugin is the server.
Lifecycle
Server starts
│
▼
Initialize(server_version, config)
│ Plugin returns: success + PluginManifest
│
▼
Plugin is ONLINE
│
├──► HealthCheck() every 30s
│ Plugin returns: healthy, status, details
│
├──► OnEvent(event_type, payload, repo_id, org_id)
│ Dispatched for subscribed events (fire-and-forget with 30s timeout)
│
├──► HandleHTTP(method, path, headers, body)
│ Proxied when an incoming request matches a declared route
│
▼
Server shutting down
│
▼
Shutdown(reason)
│ Plugin returns: success
│
▼
Plugin process is sent SIGINT (managed mode only)
Messages
InitializeRequest
| Field | Type | Description |
|---|---|---|
server_version |
string | The GitCaddy server version (e.g., "3.0.0") |
config |
map<string, string> | Server-provided configuration key-value pairs |
InitializeResponse
| Field | Type | Description |
|---|---|---|
success |
bool | Whether initialization succeeded |
error |
string | Error message if success is false |
manifest |
PluginManifest | The plugin's capability manifest |
HealthCheckRequest
Empty message. No fields.
HealthCheckResponse
| Field | Type | Description |
|---|---|---|
healthy |
bool | Whether the plugin considers itself healthy |
status |
string | Human-readable status (e.g., "operational", "degraded") |
details |
map<string, string> | Arbitrary key-value details (version, uptime, etc.) |
PluginEvent
| Field | Type | Description |
|---|---|---|
event_type |
string | The event name (e.g., "license:updated", "repo:push") |
payload |
google.protobuf.Struct | Event-specific data as a JSON-like structure |
timestamp |
google.protobuf.Timestamp | When the event occurred |
repo_id |
int64 | Repository ID (0 if not repo-specific) |
org_id |
int64 | Organization ID (0 if not org-specific) |
EventResponse
| Field | Type | Description |
|---|---|---|
handled |
bool | Whether the plugin handled the event |
error |
string | Error message if handling failed |
HTTPRequest / HTTPResponse
| Field | Type | Description |
|---|---|---|
method |
string | HTTP method (GET, POST, etc.) |
path |
string | Request path (e.g., /api/v1/health) |
headers |
map<string, string> | HTTP headers |
body |
bytes | Request/response body |
query_params |
map<string, string> | Query parameters (request only) |
status_code |
int32 | HTTP status code (response only) |
Plugin Manifest
The manifest declares what your plugin does. It is returned during Initialize and can be re-fetched via GetManifest.
message PluginManifest {
string name = 1;
string version = 2;
string description = 3;
repeated string subscribed_events = 4;
repeated PluginRoute routes = 5;
repeated string required_permissions = 6;
string license_tier = 7;
}
| Field | Description | Example |
|---|---|---|
name |
Display name | "My Analytics Plugin" |
version |
Semver version | "1.2.0" |
description |
What the plugin does | "Tracks repository analytics" |
subscribed_events |
Events to receive | ["repo:push", "issue:created"] |
routes |
HTTP routes the plugin handles | See below |
required_permissions |
Permissions the plugin needs | ["repo:read", "issue:write"] |
license_tier |
Minimum license tier required | "standard", "professional", "enterprise" |
Routes
Routes declare which HTTP paths your plugin handles. When the server receives a request matching a plugin's route, it proxies the request via HandleHTTP.
message PluginRoute {
string method = 1; // "GET", "POST", etc.
string path = 2; // "/api/v1/my-plugin/endpoint"
string description = 3;
}
Route matching uses prefix matching: a declared path of /api/v1/analytics will match /api/v1/analytics/events.
Events
Subscribe to server events by listing them in subscribed_events. Use "*" to subscribe to all events.
Available events include:
license:updated- License key changedrepo:push- Code pushed to a repositoryrepo:created- New repository createdissue:created- New issue openedissue:comment- Comment added to an issuepull_request:opened- New pull request openedpull_request:merged- Pull request merged
Events are dispatched asynchronously (fire-and-forget) with a 30-second timeout per plugin.
Permissions
The required_permissions field declares what server resources your plugin needs access to. The server logs these at startup for admin review.
Health Monitoring
The server health-checks every registered plugin at a configurable interval (default: 30 seconds).
Behavior:
| Consecutive Failures | Action |
|---|---|
| 1-2 | Warning logged, plugin stays online |
| 3+ | Plugin marked offline, error logged |
| 3+ (managed mode) | Automatic restart attempted |
When a previously offline plugin responds to a health check, it is marked online and an info log is emitted.
If HealthCheckResponse.healthy is false (the RPC succeeds but the plugin reports unhealthy), the plugin is marked as error status. This allows plugins to report degraded operation (e.g., missing API key, expired license) without being treated as crashed.
Health check timeout is configured per-plugin via HEALTH_TIMEOUT (default: 5 seconds).
Configuration
Plugins are configured in the server's app.ini.
External Mode
The plugin runs independently. The server connects to its gRPC endpoint.
[plugins]
ENABLED = true
HEALTH_CHECK_INTERVAL = 30s
[plugins.my-plugin]
ENABLED = true
ADDRESS = localhost:9090
HEALTH_TIMEOUT = 5s
SUBSCRIBED_EVENTS = repo:push, issue:created
Managed Mode
The server launches the plugin binary and manages its lifecycle. If the plugin crashes, the server restarts it automatically.
[plugins.my-plugin]
ENABLED = true
BINARY = /opt/plugins/my-plugin
ARGS = --port 9090 --log-level info
ADDRESS = localhost:9090
HEALTH_TIMEOUT = 5s
When BINARY is set, the server:
- Starts the process with the specified arguments
- Waits 2 seconds for the process to initialize
- Calls
Initializevia gRPC - Sends
SIGINTon server shutdown - Auto-restarts the process if health checks fail 3 consecutive times
Configuration Reference
[plugins] Section
| Key | Type | Default | Description |
|---|---|---|---|
ENABLED |
bool | true |
Master switch for the plugin framework |
PATH |
string | data/plugins |
Directory for plugin data |
HEALTH_CHECK_INTERVAL |
duration | 30s |
How often to health-check plugins |
[plugins.<name>] Section
| Key | Type | Default | Description |
|---|---|---|---|
ENABLED |
bool | true |
Whether this plugin is active |
ADDRESS |
string | (required) | gRPC endpoint (e.g., localhost:9090) |
BINARY |
string | (optional) | Path to plugin binary (enables managed mode) |
ARGS |
string | (optional) | Arguments for the binary |
HEALTH_TIMEOUT |
duration | 5s |
Timeout for health check RPCs |
SUBSCRIBED_EVENTS |
string | (optional) | Comma-separated event names |
A plugin must have either BINARY or ADDRESS (or both for managed mode). Entries with neither are skipped with a warning.
Transport
Plugins communicate over cleartext HTTP/2 (h2c) by default. The server uses the gRPC wire protocol via Connect RPC.
Requirements for your plugin's gRPC server:
- Listen on a TCP port
- Support HTTP/2 (standard for any gRPC server)
- No TLS required for local communication (h2c)
The server constructs its gRPC client with connect.WithGRPC(), which uses the standard gRPC binary protocol. This means your plugin can use any gRPC server implementation:
| Language | gRPC Library |
|---|---|
| Go | google.golang.org/grpc or connectrpc.com/connect |
| C# | Grpc.AspNetCore |
| Python | grpcio |
| Java | io.grpc |
| Rust | tonic |
| Node.js | @grpc/grpc-js |
Example: Go Plugin
package main
import (
"context"
"log"
"net/http"
"connectrpc.com/connect"
pluginv1 "code.gitcaddy.com/server/v3/modules/plugins/pluginv1"
"code.gitcaddy.com/server/v3/modules/plugins/pluginv1/pluginv1connect"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
type myPlugin struct {
pluginv1connect.UnimplementedPluginServiceHandler
}
func (p *myPlugin) Initialize(
ctx context.Context,
req *connect.Request[pluginv1.InitializeRequest],
) (*connect.Response[pluginv1.InitializeResponse], error) {
log.Printf("Initialized by server %s", req.Msg.ServerVersion)
return connect.NewResponse(&pluginv1.InitializeResponse{
Success: true,
Manifest: &pluginv1.PluginManifest{
Name: "My Plugin",
Version: "1.0.0",
Description: "Does something useful",
SubscribedEvents: []string{"repo:push"},
Routes: []*pluginv1.PluginRoute{
{Method: "GET", Path: "/api/v1/my-plugin/status", Description: "Plugin status"},
},
},
}), nil
}
func (p *myPlugin) HealthCheck(
ctx context.Context,
req *connect.Request[pluginv1.HealthCheckRequest],
) (*connect.Response[pluginv1.HealthCheckResponse], error) {
return connect.NewResponse(&pluginv1.HealthCheckResponse{
Healthy: true,
Status: "operational",
Details: map[string]string{"version": "1.0.0"},
}), nil
}
func (p *myPlugin) Shutdown(
ctx context.Context,
req *connect.Request[pluginv1.ShutdownRequest],
) (*connect.Response[pluginv1.ShutdownResponse], error) {
log.Printf("Shutdown requested: %s", req.Msg.Reason)
return connect.NewResponse(&pluginv1.ShutdownResponse{Success: true}), nil
}
func (p *myPlugin) OnEvent(
ctx context.Context,
req *connect.Request[pluginv1.PluginEvent],
) (*connect.Response[pluginv1.EventResponse], error) {
log.Printf("Event: %s for repo %d", req.Msg.EventType, req.Msg.RepoId)
return connect.NewResponse(&pluginv1.EventResponse{Handled: true}), nil
}
func main() {
mux := http.NewServeMux()
path, handler := pluginv1connect.NewPluginServiceHandler(&myPlugin{})
mux.Handle(path, handler)
server := &http.Server{
Addr: ":9090",
Handler: h2c.NewHandler(mux, &http2.Server{}),
}
log.Println("Plugin listening on :9090")
log.Fatal(server.ListenAndServe())
}
app.ini:
[plugins.my-plugin]
ENABLED = true
ADDRESS = localhost:9090
SUBSCRIBED_EVENTS = repo:push
Example: C# Plugin
using Grpc.Core;
// Assumes plugin.proto is included in the .csproj with GrpcServices="Server"
public class MyPlugin : PluginService.PluginServiceBase
{
public override Task<InitializeResponse> Initialize(
InitializeRequest request, ServerCallContext context)
{
var manifest = new PluginManifest
{
Name = "My C# Plugin",
Version = "1.0.0",
Description = "A C# plugin for GitCaddy"
};
manifest.SubscribedEvents.Add("issue:created");
return Task.FromResult(new InitializeResponse
{
Success = true,
Manifest = manifest
});
}
public override Task<HealthCheckResponse> HealthCheck(
HealthCheckRequest request, ServerCallContext context)
{
return Task.FromResult(new HealthCheckResponse
{
Healthy = true,
Status = "operational"
});
}
public override Task<ShutdownResponse> Shutdown(
ShutdownRequest request, ServerCallContext context)
{
return Task.FromResult(new ShutdownResponse { Success = true });
}
public override Task<EventResponse> OnEvent(
PluginEvent request, ServerCallContext context)
{
Console.WriteLine($"Event: {request.EventType} for repo {request.RepoId}");
return Task.FromResult(new EventResponse { Handled = true });
}
}
Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(9090, o =>
o.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2);
});
var app = builder.Build();
app.MapGrpcService<MyPlugin>();
app.Run();
Example: Python Plugin
import grpc
from concurrent import futures
# Generated from plugin.proto using grpcio-tools:
# python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. plugin.proto
import plugin_pb2
import plugin_pb2_grpc
class MyPlugin(plugin_pb2_grpc.PluginServiceServicer):
def Initialize(self, request, context):
manifest = plugin_pb2.PluginManifest(
name="My Python Plugin",
version="1.0.0",
description="A Python plugin for GitCaddy",
subscribed_events=["repo:push"],
)
return plugin_pb2.InitializeResponse(success=True, manifest=manifest)
def HealthCheck(self, request, context):
return plugin_pb2.HealthCheckResponse(
healthy=True,
status="operational",
details={"version": "1.0.0"},
)
def Shutdown(self, request, context):
print(f"Shutdown: {request.reason}")
return plugin_pb2.ShutdownResponse(success=True)
def OnEvent(self, request, context):
print(f"Event: {request.event_type} for repo {request.repo_id}")
return plugin_pb2.EventResponse(handled=True)
def HandleHTTP(self, request, context):
return plugin_pb2.HTTPResponse(status_code=501)
def GetManifest(self, request, context):
return plugin_pb2.PluginManifest(
name="My Python Plugin",
version="1.0.0",
)
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=4))
plugin_pb2_grpc.add_PluginServiceServicer_to_server(MyPlugin(), server)
server.add_insecure_port("[::]:9090")
server.start()
print("Plugin listening on :9090")
server.wait_for_termination()
if __name__ == "__main__":
serve()
Debugging
Server logs show plugin lifecycle events:
[I] Loaded external plugin config: my-plugin (managed=false)
[I] External plugin my-plugin is online (managed=false)
[W] Health check failed for plugin my-plugin: connection refused
[E] Plugin my-plugin is now offline after 3 consecutive health check failures
[I] Plugin my-plugin is back online
[I] Shutting down external plugin: my-plugin
Tips:
- Use
HEALTH_TIMEOUT = 10sduring development to avoid false positives - Set
HEALTH_CHECK_INTERVAL = 5sfor faster feedback during testing - Check that your plugin supports cleartext HTTP/2 (h2c) - this is the most common connection issue
- Use
grpcurlto test your plugin's gRPC service independently:
# List services
grpcurl -plaintext localhost:9090 list
# Call HealthCheck
grpcurl -plaintext localhost:9090 plugin.v1.PluginService/HealthCheck
# Call Initialize
grpcurl -plaintext -d '{"server_version": "3.0.0"}' \
localhost:9090 plugin.v1.PluginService/Initialize