1
0

main.go 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  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. type payload struct {
  19. Action string `json:"action"`
  20. PullRequest struct {
  21. Merged bool `json:"merged"`
  22. BaseBranch string `json:"base_branch"`
  23. MergeCommitSHA string `json:"merge_commit_sha"`
  24. } `json:"pull_request"`
  25. Repository struct {
  26. FullName string `json:"full_name"`
  27. } `json:"repository"`
  28. Sender struct {
  29. Username string `json:"username"`
  30. } `json:"sender"`
  31. }
  32. // releaser runs one release; swapped out in tests.
  33. type releaser func(repo, sha, by string)
  34. // handler verifies the Gogs signature and starts a release for a pull
  35. // request merged into main. Releases run one at a time, in the background,
  36. // so Gogs gets its reply straight away.
  37. func handler(secret []byte, release releaser) http.Handler {
  38. var mu sync.Mutex
  39. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  40. if r.Method != http.MethodPost {
  41. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  42. return
  43. }
  44. body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
  45. if err != nil {
  46. http.Error(w, "read", http.StatusBadRequest)
  47. return
  48. }
  49. mac := hmac.New(sha256.New, secret)
  50. mac.Write(body)
  51. want := hex.EncodeToString(mac.Sum(nil))
  52. if !hmac.Equal([]byte(want), []byte(r.Header.Get("X-Gogs-Signature"))) {
  53. http.Error(w, "bad signature", http.StatusUnauthorized)
  54. return
  55. }
  56. var p payload
  57. if r.Header.Get("X-Gogs-Event") != "pull_request" || json.Unmarshal(body, &p) != nil {
  58. w.WriteHeader(http.StatusNoContent)
  59. return
  60. }
  61. pr := p.PullRequest
  62. if p.Action != "closed" || !pr.Merged || pr.BaseBranch != "main" || pr.MergeCommitSHA == "" || p.Repository.FullName == "" {
  63. w.WriteHeader(http.StatusNoContent)
  64. return
  65. }
  66. w.WriteHeader(http.StatusAccepted)
  67. go func() {
  68. mu.Lock()
  69. defer mu.Unlock()
  70. release(p.Repository.FullName, pr.MergeCommitSHA, "PR merged by "+p.Sender.Username)
  71. }()
  72. })
  73. }
  74. // runRelease calls the release script and logs its output to the journal.
  75. func runRelease(repo, sha, by string) {
  76. cmd := exec.Command(releaseBin, repo, sha)
  77. cmd.Env = append(os.Environ(), "DEPLOY_BY="+by)
  78. out, err := cmd.CombinedOutput()
  79. log.Printf("release %s %s err=%v\n%s", repo, sha, err, out)
  80. }
  81. func main() {
  82. secret := os.Getenv("WEBHOOK_SECRET")
  83. if secret == "" {
  84. log.Fatal("WEBHOOK_SECRET not set")
  85. }
  86. mux := http.NewServeMux()
  87. mux.Handle("/deploy", handler([]byte(secret), runRelease))
  88. srv := &http.Server{
  89. Addr: "127.0.0.1:9000",
  90. Handler: mux,
  91. ReadHeaderTimeout: 5 * time.Second,
  92. ReadTimeout: 10 * time.Second,
  93. WriteTimeout: 10 * time.Second,
  94. }
  95. log.Printf("listening on %s", srv.Addr)
  96. log.Fatal(srv.ListenAndServe())
  97. }