From f3a2a1f0992d9db259a815d8566f04c0592a7860 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 25 Oct 2026 16:28:54 -0400 Subject: [PATCH] fix(worker): commit as the token's account and check it can push The worker committed as a hard-coded "gbr-bot", and a token without Write access only showed up when a build finished and its push failed. On start it now reads the token's account (GET /user) for the commit author, and refuses to run if that account can't push to the games repo. Co-Authored-By: Claude Opus 5.5 --- BUILDER.md | 2 +- cmd/gbrgames/worker.go | 54 +++++++++++++++++++++++++++++++++++-- cmd/gbrgames/worker_test.go | 47 ++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 cmd/gbrgames/worker_test.go diff --git a/BUILDER.md b/BUILDER.md index c82fb3f..d6ac700 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -42,7 +42,7 @@ All through environment variables; nothing about where it runs lives in this rep | --- | --- | | `PANEL_URL` | The panel the worker takes jobs from | | `BUILDER_TOKEN` | Shared secret the panel checks on `/api/v1/builder/**` (same value in the panel's env) | -| `GITEA_TOKEN` | The bot account's token (repository read/write on this repo): pushes draft branches, opens PRs | +| `GITEA_TOKEN` | The bot account's token (repository read/write; the account needs Write on this repo): pushes draft branches, opens PRs as that account. Checked when the worker starts | | `OLLAMA_URL` | Ollama for the model (or `MODEL_PROVIDER=gemini` with `GEMINI_API_KEY`) | | `SEARXNG_URL`, `READER_URL` | A SearXNG instance (JSON API) and a page-to-text reader (`READER_URL/`) | | `MODEL`, `POLL_INTERVAL`, `WORKER_NAME`, `WORK_DIR` | Optional: model name, poll interval (20s), name shown in the panel, work folder | diff --git a/cmd/gbrgames/worker.go b/cmd/gbrgames/worker.go index 4e54e6d..d98c920 100644 --- a/cmd/gbrgames/worker.go +++ b/cmd/gbrgames/worker.go @@ -49,6 +49,53 @@ type workerConfig struct { workDir, name string poll time.Duration provider, model string + botLogin, botEmail string // the token's account, used as the commit author +} + +// checkBot reads which account GITEA_TOKEN belongs to and makes sure it can push to the games repo, so a +// wrong token or missing permission shows when the worker starts, not at the end of a 30-minute build. +func (c *workerConfig) checkBot() error { + var user struct { + Login string `json:"login"` + Email string `json:"email"` + } + if err := c.giteaGet("/user", &user); err != nil { + return fmt.Errorf("GITEA_TOKEN: %w", err) + } + var repo struct { + Permissions struct { + Push bool `json:"push"` + } `json:"permissions"` + } + if err := c.giteaGet("/repos/"+c.repo, &repo); err != nil { + return fmt.Errorf("%s: %w", c.repo, err) + } + if !repo.Permissions.Push { + return fmt.Errorf("the account %s can't push to %s: give it Write access there", user.Login, c.repo) + } + c.botLogin, c.botEmail = user.Login, user.Email + if c.botEmail == "" { + c.botEmail = user.Login + "@users.noreply" + } + return nil +} + +func (c *workerConfig) giteaGet(p string, out any) error { + req, err := http.NewRequest(http.MethodGet, c.gitea+"/api/v1"+p, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "token "+c.botToken) + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + raw, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HTTP %d: %.200s", resp.StatusCode, raw) + } + return json.NewDecoder(resp.Body).Decode(out) } func runWorker(args []string) error { @@ -71,7 +118,10 @@ func runWorker(args []string) error { return fmt.Errorf("set %s", v.name) } } - fmt.Printf("[worker] %s polling %s every %s\n", c.name, c.panel, c.poll) + if err := c.checkBot(); err != nil { + return err + } + fmt.Printf("[worker] %s polling %s every %s; pull requests as %s\n", c.name, c.panel, c.poll, c.botLogin) for { job, err := c.claim() if err != nil { @@ -313,7 +363,7 @@ func (c *workerConfig) openPR(dir string, job *builderJob, res *buildResult) (st }{ {"checkout", []string{"checkout", "-q", "-b", branch}}, {"add", []string{"add", filepath.ToSlash(res.Dir)}}, - {"commit", []string{"-c", "user.name=gbr-bot", "-c", "user.email=gbr-bot@users.noreply.gitea.gbreed.com", "commit", "-q", "-m", + {"commit", []string{"-c", "user.name=" + c.botLogin, "-c", "user.email=" + c.botEmail, "commit", "-q", "-m", fmt.Sprintf("feat(games): draft %s\n\nDrafted and boot-tested by the GBR builder (job %s).", job.GameName, job.ID)}}, {"push", []string{"-c", auth, "push", "-q", "origin", branch}}, } diff --git a/cmd/gbrgames/worker_test.go b/cmd/gbrgames/worker_test.go new file mode 100644 index 0000000..c1259c5 --- /dev/null +++ b/cmd/gbrgames/worker_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCheckBotNeedsPushAccess(t *testing.T) { + push := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "token secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + switch r.URL.Path { + case "/api/v1/user": + w.Write([]byte(`{"login":"git_ai_bot","email":"bot@example.com"}`)) + case "/api/v1/repos/org/games": + if push { + w.Write([]byte(`{"permissions":{"push":true}}`)) + } else { + w.Write([]byte(`{"permissions":{"push":false}}`)) + } + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + c := &workerConfig{gitea: srv.URL, repo: "org/games", botToken: "secret"} + if err := c.checkBot(); err == nil || !strings.Contains(err.Error(), "git_ai_bot can't push to org/games") { + t.Errorf("read-only bot: got %v", err) + } + push = true + if err := c.checkBot(); err != nil { + t.Fatal(err) + } + if c.botLogin != "git_ai_bot" || c.botEmail != "bot@example.com" { + t.Errorf("identity = %s <%s>", c.botLogin, c.botEmail) + } + c.botToken = "wrong" + if err := c.checkBot(); err == nil || !strings.Contains(err.Error(), "GITEA_TOKEN") { + t.Errorf("bad token: got %v", err) + } +}