main.go 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. // deployd receives Gogs webhooks and releases a repo when a pull request is
  2. // merged into main. It listens on 127.0.0.1 only; Gogs reaches it locally.
  3. package main
  4. import (
  5. "crypto/hmac"
  6. "crypto/sha256"
  7. "encoding/hex"
  8. "encoding/json"
  9. "io"
  10. "log"
  11. "net/http"
  12. "os"
  13. "os/exec"
  14. "sync"
  15. "time"
  16. )
  17. const releaseBin = "/usr/local/bin/release"
  18. // deployRef is what a merge releases: the tip of main after release fetches.
  19. // Not the payload's merge_commit_sha: Gogs fills that with the head branch's
  20. // commit (database/pull.go, BranchCommitID(pr.HeadBranch)), which lacks any
  21. // other PRs merged since the branch was made.
  22. const deployRef = "origin/main"
  23. type payload struct {
  24. Action string `json:"action"`
  25. PullRequest struct {
  26. Merged bool `json:"merged"`
  27. BaseBranch string `json:"base_branch"`
  28. MergeCommitSHA string `json:"merge_commit_sha"` // the head branch's commit; logged only
  29. } `json:"pull_request"`
  30. Repository struct {
  31. FullName string `json:"full_name"`
  32. } `json:"repository"`
  33. Sender struct {
  34. Username string `json:"username"`
  35. } `json:"sender"`
  36. }
  37. // releaser runs one release; swapped out in tests.
  38. type releaser func(repo, sha, by string)
  39. // handler verifies the Gogs signature and starts a release for a pull
  40. // request merged into main. Releases run one at a time, in the background,
  41. // so Gogs gets its reply straight away.
  42. func handler(secret []byte, release releaser) http.Handler {
  43. var mu sync.Mutex
  44. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  45. if r.Method != http.MethodPost {
  46. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  47. return
  48. }
  49. body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
  50. if err != nil {
  51. http.Error(w, "read", http.StatusBadRequest)
  52. return
  53. }
  54. mac := hmac.New(sha256.New, secret)
  55. mac.Write(body)
  56. want := hex.EncodeToString(mac.Sum(nil))
  57. if !hmac.Equal([]byte(want), []byte(r.Header.Get("X-Gogs-Signature"))) {
  58. http.Error(w, "bad signature", http.StatusUnauthorized)
  59. return
  60. }
  61. var p payload
  62. if r.Header.Get("X-Gogs-Event") != "pull_request" || json.Unmarshal(body, &p) != nil {
  63. w.WriteHeader(http.StatusNoContent)
  64. return
  65. }
  66. pr := p.PullRequest
  67. if p.Action != "closed" || !pr.Merged || pr.BaseBranch != "main" || p.Repository.FullName == "" {
  68. w.WriteHeader(http.StatusNoContent)
  69. return
  70. }
  71. by := "PR merged by " + p.Sender.Username
  72. if head := pr.MergeCommitSHA; len(head) >= 7 {
  73. by += " (head " + head[:7] + ")"
  74. }
  75. w.WriteHeader(http.StatusAccepted)
  76. go func() {
  77. mu.Lock()
  78. defer mu.Unlock()
  79. release(p.Repository.FullName, deployRef, by)
  80. }()
  81. })
  82. }
  83. // runRelease calls the release script and logs its output to the journal.
  84. func runRelease(repo, sha, by string) {
  85. cmd := exec.Command(releaseBin, repo, sha)
  86. cmd.Env = append(os.Environ(), "DEPLOY_BY="+by)
  87. out, err := cmd.CombinedOutput()
  88. log.Printf("release %s %s err=%v\n%s", repo, sha, err, out)
  89. }
  90. func main() {
  91. secret := os.Getenv("WEBHOOK_SECRET")
  92. if secret == "" {
  93. log.Fatal("WEBHOOK_SECRET not set")
  94. }
  95. mux := http.NewServeMux()
  96. mux.Handle("/deploy", handler([]byte(secret), runRelease))
  97. srv := &http.Server{
  98. Addr: "127.0.0.1:9000",
  99. Handler: mux,
  100. ReadHeaderTimeout: 5 * time.Second,
  101. ReadTimeout: 10 * time.Second,
  102. WriteTimeout: 10 * time.Second,
  103. }
  104. log.Printf("listening on %s", srv.Addr)
  105. log.Fatal(srv.ListenAndServe())
  106. }