// deployd receives Gogs webhooks and releases a repo when a pull request is // merged into main. It listens on 127.0.0.1 only; Gogs reaches it locally. package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "log" "net/http" "os" "os/exec" "sync" "time" ) const releaseBin = "/usr/local/bin/release" // deployRef is what a merge releases: the tip of main after release fetches. // Not the payload's merge_commit_sha: Gogs fills that with the head branch's // commit (database/pull.go, BranchCommitID(pr.HeadBranch)), which lacks any // other PRs merged since the branch was made. const deployRef = "origin/main" type payload struct { Action string `json:"action"` PullRequest struct { Merged bool `json:"merged"` BaseBranch string `json:"base_branch"` MergeCommitSHA string `json:"merge_commit_sha"` // the head branch's commit; logged only } `json:"pull_request"` Repository struct { FullName string `json:"full_name"` } `json:"repository"` Sender struct { Username string `json:"username"` } `json:"sender"` } // releaser runs one release; swapped out in tests. type releaser func(repo, sha, by string) // handler verifies the Gogs signature and starts a release for a pull // request merged into main. Releases run one at a time, in the background, // so Gogs gets its reply straight away. func handler(secret []byte, release releaser) http.Handler { var mu sync.Mutex return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "read", http.StatusBadRequest) return } mac := hmac.New(sha256.New, secret) mac.Write(body) want := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(want), []byte(r.Header.Get("X-Gogs-Signature"))) { http.Error(w, "bad signature", http.StatusUnauthorized) return } var p payload if r.Header.Get("X-Gogs-Event") != "pull_request" || json.Unmarshal(body, &p) != nil { w.WriteHeader(http.StatusNoContent) return } pr := p.PullRequest if p.Action != "closed" || !pr.Merged || pr.BaseBranch != "main" || p.Repository.FullName == "" { w.WriteHeader(http.StatusNoContent) return } by := "PR merged by " + p.Sender.Username if head := pr.MergeCommitSHA; len(head) >= 7 { by += " (head " + head[:7] + ")" } w.WriteHeader(http.StatusAccepted) go func() { mu.Lock() defer mu.Unlock() release(p.Repository.FullName, deployRef, by) }() }) } // runRelease calls the release script and logs its output to the journal. func runRelease(repo, sha, by string) { cmd := exec.Command(releaseBin, repo, sha) cmd.Env = append(os.Environ(), "DEPLOY_BY="+by) out, err := cmd.CombinedOutput() log.Printf("release %s %s err=%v\n%s", repo, sha, err, out) } func main() { secret := os.Getenv("WEBHOOK_SECRET") if secret == "" { log.Fatal("WEBHOOK_SECRET not set") } mux := http.NewServeMux() mux.Handle("/deploy", handler([]byte(secret), runRelease)) srv := &http.Server{ Addr: "127.0.0.1:9000", Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, } log.Printf("listening on %s", srv.Addr) log.Fatal(srv.ListenAndServe()) }