| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114 |
- // 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())
- }
|