APIs for CI/CD and DevOps
Continuous integration and deployment pipelines are themselves driven by APIs — GitHub Actions, GitLab CI, and every cloud provider's deployment service expose an HTTP API underneath their UI, and your Go services frequently need to talk to those APIs directly: triggering a deployment from your own internal tool, reacting to a build's completion, or building a custom dashboard that aggregates pipeline status across many repositories. This chapter covers both directions: calling CI/CD platform APIs from Go, and designing your own API endpoints to support deployment strategies like blue-green and canary releases.
Talking to GitHub's API from Go
github.com/google/go-github is Google's actively maintained, comprehensive client for the GitHub REST API, and it's the standard choice when a Go service needs to interact with GitHub programmatically — triggering a workflow, reading check statuses, or reacting to repository events.
package main
import (
"context"
"github.com/google/go-github/v66/github"
"golang.org/x/oauth2"
)
func newGitHubClient(ctx context.Context, token string) *github.Client {
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
return github.NewClient(oauth2.NewClient(ctx, ts))
}
func triggerDeployWorkflow(
ctx context.Context, client *github.Client, owner, repo string,
) error {
_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
ctx, owner, repo, "deploy.yml",
github.CreateWorkflowDispatchEventRequest{Ref: "main"},
)
return err
}
This is the same mechanism behind a "Deploy" button in an internal admin tool: rather than reimplementing deployment logic in your Go service, you trigger the existing, tested GitHub Actions workflow and let it do the work, which keeps a single source of truth for how deployments actually happen.
Receiving CI/CD webhooks
The other direction — reacting when a pipeline finishes — uses the same webhook-verification pattern from the earlier webhooks chapter, applied to GitHub's specific payload shape and signature header:
func githubWebhookHandler(secret []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
payload, err := github.ValidatePayload(r, secret)
if err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
event, err := github.ParseWebHook(github.WebHookType(r), payload)
if err != nil {
http.Error(w, "unparseable event", http.StatusBadRequest)
return
}
switch e := event.(type) {
case *github.WorkflowRunEvent:
if e.GetWorkflowRun().GetConclusion() == "success" {
handleDeploySucceeded(e.GetWorkflowRun())
}
}
w.WriteHeader(http.StatusOK)
}
}
github.ValidatePayload does the HMAC verification for you against GitHub's X-Hub-Signature-256 header — the same idea from the webhooks chapter, already implemented and tested for GitHub's specific conventions, so there's no reason to reimplement it by hand here.
Designing your API for progressive delivery
Blue-green and canary deployments are as much an API design concern as an infrastructure one: your service needs to support running two versions simultaneously and routing traffic between them in a controlled way. A minimal canary router based on a weighted random choice, sitting in front of two backend versions:
type CanaryRouter struct {
stableProxy *httputil.ReverseProxy
canaryProxy *httputil.ReverseProxy
canaryWeight int // percentage of traffic, 0-100
}
func (cr *CanaryRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if rand.Intn(100) < cr.canaryWeight {
cr.canaryProxy.ServeHTTP(w, r)
return
}
cr.stableProxy.ServeHTTP(w, r)
}
In practice, most teams delegate this routing to their gateway or mesh (see the API gateway chapter) rather than reimplementing it in application code, but understanding the mechanism helps you reason about what's actually happening when a deployment tool reports "canary at 10%."
Exposing deployment status through your own API
Internal tools built around CI/CD usually need a unified view across many pipelines, which means your Go service becomes an aggregator: poll or receive webhooks from several sources, normalize their differing status vocabularies into one shape, and expose that as a clean API:
type DeploymentStatus struct {
Service string `json:"service"`
Version string `json:"version"`
// State is one of: "pending", "running", "succeeded", "failed".
State string `json:"state"`
UpdatedAt time.Time `json:"updated_at"`
}
func deploymentsHandler(w http.ResponseWriter, r *http.Request) {
statuses := aggregateFromAllSources(r.Context())
writeJSON(w, http.StatusOK, statuses)
}
This normalization step matters because GitHub Actions, GitLab CI, and a cloud provider's own deployment service each use different words for the same concept ("success" versus "succeeded" versus a numeric exit code) — the value of your internal API is precisely that it hides those differences behind one consistent contract for every other tool in your organization to consume.
CI/CD platforms are just APIs with a UI in front of them — treating them that way, and building thin, well-tested Go clients against them, beats clicking through a web console every time you need something the UI doesn't expose.
Rollbacks as a first-class API operation
A deployment API that only supports moving forward is incomplete. When a canary or a full rollout starts failing, the fastest recovery path is usually re-pointing traffic at the previous known-good version rather than waiting for a fixed-forward deploy to build and ship. Model rollback as an explicit, auditable endpoint rather than something an operator does by hand against raw infrastructure:
func rollbackHandler(w http.ResponseWriter, r *http.Request) {
service := r.PathValue("service")
previous, err := lastKnownGoodVersion(r.Context(), service)
if err != nil {
http.Error(w, "no known-good version recorded", http.StatusConflict)
return
}
if err := deployVersion(r.Context(), service, previous); err != nil {
http.Error(w, "rollback failed", http.StatusInternalServerError)
return
}
logger.Warn("rolled back",
"service", service, "version", previous, "actor", currentUser(r))
writeJSON(w, http.StatusOK, map[string]string{
"status": "rolled back",
"version": previous,
})
}
Recording every deploy's version alongside a health signal (did it pass its readiness checks, did error rates stay flat) is what makes lastKnownGoodVersion meaningful — a rollback endpoint that just replays "the previous version, whatever it was" can just as easily roll back into a version that was already broken.
Frequently Asked Questions
Why call GitHub's API through go-github instead of just hitting the REST endpoints with net/http directly?
You could, but you'd end up hand-rolling pagination, rate-limit handling, and JSON shapes that Google's client already gets right and keeps updated as GitHub's API evolves. github.NewClient(oauth2.NewClient(ctx, ts)) gives you a typed, tested surface over the same HTTP calls, so triggerDeployWorkflow reads as one clear intent instead of a pile of request-building boilerplate.
Do I still need to verify the webhook signature if the request came over HTTPS?
Yes — TLS only proves the connection to GitHub's servers wasn't tampered with in transit, it says nothing about whether the payload actually originated from GitHub rather than anyone who discovered your webhook URL. That's exactly what github.ValidatePayload checks against the X-Hub-Signature-256 header, and skipping it means anyone with your endpoint's address could forge a fake "deploy succeeded" event.
Isn't a random weighted coin flip like rand.Intn(100) < cr.canaryWeight a bad way to route production traffic?
It's a fine mechanism for distributing traffic, but weight alone tells you nothing about whether the canary is healthy — that's precisely the gap the DeepDive above calls out. A production-grade canary router pairs the weight with a live health signal from your observability instrumentation, so a bad canary gets pulled back to zero automatically instead of quietly serving errors to whatever percentage of users the schedule assigned it.
Why does deploymentsHandler bother normalizing state names like "success" versus "succeeded"?
Because every CI/CD platform invented its own vocabulary for the same handful of concepts, and if your internal API just passed those differences through, every consumer of that API would need to know the quirks of every underlying platform. Normalizing into one DeploymentStatus shape is what lets a dashboard, a Slack bot, and a CLI tool all consume the same aggregator without caring whether a given service happens to deploy through GitHub Actions or GitLab CI.
Why give rollbacks their own endpoint instead of just letting operators redeploy the previous version manually?
A dedicated rollbackHandler makes the operation auditable and repeatable — it records who triggered it and which version it moved from and to, the same way the handler above logs with logger.Warn. Treating rollback as an explicit API operation, guarded by the same auth as a forward deploy, avoids the failure mode where a stressed operator manually redeploys "whatever seemed to work last time" with no record of what actually happened.