gitmote

gitmote  ·  history  ·  branches

feat: CI stage 3 — trigger a Scaleway Serverless Job per CI job

c9a73697cc55cbce0c89fcdb56ba3f740f9db17b
Atanas Minev <atmin@protonmail.com> · 2026-07-03 18:45

diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 64269bb..76d4dfe 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -21,6 +21,7 @@ import (
"github.com/atmin/gitmote/internal/githttp"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
+ "github.com/atmin/gitmote/internal/scaleway"
"github.com/atmin/gitmote/internal/store"
"github.com/atmin/gitmote/internal/webui"
"github.com/atmin/s3lite"
@@ -215,9 +216,30 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
}
cacheRoot := envOr("GITMOTE_CACHE", filepath.Join(os.TempDir(), "gitmote-repos"))
materializer := repo.New(md, objs, cacheRoot)
- // A successful, branch-advancing push discovers its workflows and enqueues a
- // CI run — fire-and-forget, so dispatch never fails the push (tasks/16-ci.md).
- dispatcher := ci.NewDispatcher(md, materializer, logger)
+ // The Scaleway Jobs client triggers a runner per CI job. It no-ops when
+ // SCW_CI_JOB_DEFINITION_ID is unset (local dev / tests / e2e), so the write
+ // path runs fully except for the external call. SCW_REGION falls back to the
+ // AWS_REGION already set for the object store.
+ jobDefID := os.Getenv("SCW_CI_JOB_DEFINITION_ID")
+ workerSecret := os.Getenv("WORKER_SECRET")
+ gitmoteURL := os.Getenv("GITMOTE_URL")
+ if jobDefID != "" && (workerSecret == "" || gitmoteURL == "") {
+ _ = writer.Close()
+ _ = md.Close()
+ return nil, nil, noop, fmt.Errorf("SCW_CI_JOB_DEFINITION_ID is set but WORKER_SECRET or GITMOTE_URL is missing")
+ }
+ jobs := scaleway.NewJobsClient(os.Getenv("SCW_SECRET_KEY"), envOr("SCW_REGION", os.Getenv("AWS_REGION")), jobDefID)
+ // A successful, branch-advancing push discovers its workflows, records a run,
+ // and triggers a runner per job — fire-and-forget, so dispatch never fails the
+ // push (tasks/16-ci.md).
+ dispatcher := ci.NewDispatcher(ci.Config{
+ Runs: md,
+ Materializer: materializer,
+ Trigger: jobs,
+ BaseURL: gitmoteURL,
+ WorkerSecret: workerSecret,
+ Logger: logger,
+ })
writer.AfterCommit = func(ctx context.Context, commits []githttp.CommitInfo) {
for _, c := range commits {
dispatcher.Dispatch(ctx, ci.Event{
diff --git a/docs/ops.md b/docs/ops.md
index 5e417ab..4bc5e5e 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -86,9 +86,19 @@ Setting these on the container does nothing for CI, and vice versa.
| `GITMOTE_CACHE` | `/tmp/gitmote/cache` — ephemeral; materialized repos rebuild from S3 |
| `GITMOTE_SOCK` | `/tmp/gitmote/gitmote.sock` — pre-receive hook RPC socket |
| `GITMOTE_COOKIE_KEY` | secret — signs management-UI session cookies (enables `/ui`) |
+| `GITMOTE_URL` | public base URL (`https://gitmote.atmin.net`) — injected into the CI runner's env so it clones and reports back here |
+| `SCW_CI_JOB_DEFINITION_ID` | the Scaleway Serverless Job definition for the CI runner; **unset disables the trigger** (runs/jobs still record). One-time `scw jobs definition create` lands with the runner (stage 4) |
+| `SCW_SECRET_KEY` | secret — Scaleway API secret key (the UUID) used to start the CI job; also the registry/deploy key |
+| `SCW_REGION` | Scaleway region for the CI job start (falls back to `AWS_REGION`) |
+| `WORKER_SECRET` | secret — shared runner-auth secret; injected into the runner env and compared on its report-back (stage 4) |
| `AWS_REGION` | `fr-par` |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | secret — Scaleway API key pair; covers both the object store and the litestream replica |
+> `SCW_CI_JOB_DEFINITION_ID` gates CI execution: unset (local dev, tests, e2e) the
+> dispatcher records runs/jobs but the Scaleway trigger no-ops. When it **is** set,
+> `WORKER_SECRET` and `GITMOTE_URL` are required — the server refuses to start
+> otherwise, so a half-configured CI can't silently trigger unreachable runners.
+
`GITMOTE_DB` and `GITMOTE_CACHE` are ephemeral on purpose: the object store +
litestream replica are the durable state, and the local disk is a cache. On a
cold start (scale-to-zero → wake, or a redeploy) the DB is restored from
diff --git a/internal/ci/dispatcher.go b/internal/ci/dispatcher.go
index 53a705b..996c368 100644
--- a/internal/ci/dispatcher.go
+++ b/internal/ci/dispatcher.go
@@ -12,6 +12,7 @@ import (
"context"
"errors"
"log/slog"
+ "strconv"
"strings"
"github.com/atmin/gitmote/internal/meta"
@@ -33,6 +34,7 @@ type Runs interface {
CreateRun(ctx context.Context, repoID int64, ref, sha string) (*meta.Run, error)
SetRunStatus(ctx context.Context, runID int64, status meta.RunStatus) error
CreateJob(ctx context.Context, runID int64, name string) (*meta.Job, error)
+ SetJobStatus(ctx context.Context, jobID int64, status meta.RunStatus) error
}
// Materializer turns a repo name into an on-disk bare repo the browse reader can
@@ -41,6 +43,12 @@ type Materializer interface {
Materialize(ctx context.Context, name string) (string, error)
}
+// Trigger starts one CI job's runner with the given env. *scaleway.JobsClient
+// satisfies it; it no-ops when unconfigured (local dev / tests).
+type Trigger interface {
+ Trigger(ctx context.Context, env map[string]string) error
+}
+
// Event is one updated ref from a completed push.
type Event struct {
RepoID int64
@@ -50,21 +58,44 @@ type Event struct {
NewSHA string
}
-// Dispatcher enqueues CI runs for ref advances. Only the leader constructs and
-// calls it (it is the only instance that processes receive-pack).
+// Config wires the dispatcher's collaborators. BaseURL and WorkerSecret are
+// injected into each triggered runner's env (the runner clones from BaseURL and
+// authenticates its report-back with WorkerSecret — stage 4).
+type Config struct {
+ Runs Runs
+ Materializer Materializer
+ Trigger Trigger
+ BaseURL string // GITMOTE_URL
+ WorkerSecret string // WORKER_SECRET
+ Logger *slog.Logger
+}
+
+// Dispatcher enqueues CI runs for ref advances and triggers a runner per job.
+// Only the leader constructs and calls it (it is the only instance that
+// processes receive-pack).
type Dispatcher struct {
- runs Runs
- mz Materializer
- logger *slog.Logger
+ runs Runs
+ mz Materializer
+ trigger Trigger
+ baseURL string
+ secret string
+ logger *slog.Logger
}
-// NewDispatcher returns a Dispatcher writing runs through runs and reading
-// workflow config via mz.
-func NewDispatcher(runs Runs, mz Materializer, logger *slog.Logger) *Dispatcher {
+// NewDispatcher returns a Dispatcher from cfg.
+func NewDispatcher(cfg Config) *Dispatcher {
+ logger := cfg.Logger
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
- return &Dispatcher{runs: runs, mz: mz, logger: logger}
+ return &Dispatcher{
+ runs: cfg.Runs,
+ mz: cfg.Materializer,
+ trigger: cfg.Trigger,
+ baseURL: cfg.BaseURL,
+ secret: cfg.WorkerSecret,
+ logger: logger,
+ }
}
// Dispatch reads the pushed commit's workflows and records a run for a branch
@@ -114,10 +145,12 @@ func (d *Dispatcher) Dispatch(ctx context.Context, ev Event) {
d.recordRun(ctx, ev, dir, files)
}
-// recordRun creates a queued run and one job per workflow file. A file that
-// fails to parse as YAML downgrades the run to failed (the first parse error is
-// logged), but the parsable files still get job rows, so the failure is visible
-// in the UI.
+// recordRun creates a queued run and one job per workflow file, then triggers a
+// runner per job. A file that fails to parse as YAML downgrades the run to failed
+// (the first parse error is logged), but the parsable files still get job rows,
+// so the failure is visible in the UI. A trigger error marks that job error and,
+// if every job failed to trigger, the run error — one job's failure never aborts
+// the others, and nothing here fails the push.
func (d *Dispatcher) recordRun(ctx context.Context, ev Event, dir string, files []repo.TreeEntry) {
run, err := d.runs.CreateRun(ctx, ev.RepoID, ev.Ref, ev.NewSHA)
if err != nil {
@@ -125,7 +158,11 @@ func (d *Dispatcher) recordRun(ctx context.Context, ev Event, dir string, files
"repo", ev.RepoName, "ref", ev.Ref, "sha", ev.NewSHA, "error", err)
return
}
- var parseErr error
+ var (
+ parseErr error
+ jobs int
+ triggerErrors int
+ )
for _, f := range files {
if err := d.validate(ctx, dir, ev.NewSHA, f.Path); err != nil {
if parseErr == nil {
@@ -133,16 +170,51 @@ func (d *Dispatcher) recordRun(ctx context.Context, ev Event, dir string, files
}
continue // a malformed file gets no job row
}
- if _, err := d.runs.CreateJob(ctx, run.ID, f.Name); err != nil {
+ job, err := d.runs.CreateJob(ctx, run.ID, f.Name)
+ if err != nil {
d.logger.Error("ci: create job failed", "run", run.ID, "job", f.Name, "error", err)
+ continue
+ }
+ jobs++
+ if err := d.trigger.Trigger(ctx, d.jobEnv(ev, run.ID, job.ID)); err != nil {
+ d.logger.Error("ci: trigger failed",
+ "run", run.ID, "job", job.ID, "name", f.Name, "error", err)
+ if err := d.runs.SetJobStatus(ctx, job.ID, meta.RunError); err != nil {
+ d.logger.Error("ci: set job error failed", "job", job.ID, "error", err)
+ }
+ triggerErrors++
}
}
- if parseErr != nil {
+ switch {
+ case parseErr != nil:
d.logger.Error("ci: malformed workflow",
"repo", ev.RepoName, "ref", ev.Ref, "error", parseErr)
- if err := d.runs.SetRunStatus(ctx, run.ID, meta.RunFailed); err != nil {
- d.logger.Error("ci: set run failed", "run", run.ID, "error", err)
- }
+ d.setRunStatus(ctx, run.ID, meta.RunFailed)
+ case jobs > 0 && triggerErrors == jobs:
+ // Every job failed to trigger — the run produced nothing.
+ d.setRunStatus(ctx, run.ID, meta.RunError)
+ }
+}
+
+// jobEnv is the per-job environment injected into the triggered runner. The
+// clone token is a placeholder until scoped tokens land (task 20).
+func (d *Dispatcher) jobEnv(ev Event, runID, jobID int64) map[string]string {
+ return map[string]string{
+ "GITMOTE_CI_RUN_ID": strconv.FormatInt(runID, 10),
+ "GITMOTE_CI_JOB_ID": strconv.FormatInt(jobID, 10),
+ "GITMOTE_URL": d.baseURL,
+ "WORKER_SECRET": d.secret,
+ "GITMOTE_REPO": ev.RepoName,
+ "GITMOTE_SHA": ev.NewSHA,
+ "GITMOTE_REF": ev.Ref,
+ "GITMOTE_CI_CLONE_TOKEN": "", // placeholder until task 20
+ }
+}
+
+// setRunStatus transitions a run, logging (not returning) any error.
+func (d *Dispatcher) setRunStatus(ctx context.Context, runID int64, status meta.RunStatus) {
+ if err := d.runs.SetRunStatus(ctx, runID, status); err != nil {
+ d.logger.Error("ci: set run status failed", "run", runID, "status", status, "error", err)
}
}
diff --git a/internal/ci/dispatcher_test.go b/internal/ci/dispatcher_test.go
index 8ce2c88..9d5e721 100644
--- a/internal/ci/dispatcher_test.go
+++ b/internal/ci/dispatcher_test.go
@@ -3,9 +3,11 @@ package ci
import (
"bytes"
"context"
+ "errors"
"os"
"os/exec"
"path/filepath"
+ "strconv"
"strings"
"testing"
@@ -106,6 +108,27 @@ func branchEvent(r *meta.Repo, sha string) Event {
return Event{RepoID: r.ID, RepoName: r.Name, Ref: "refs/heads/main", OldSHA: meta.ZeroSHA, NewSHA: sha}
}
+// stubTrigger records the env of each trigger and can be made to fail.
+type stubTrigger struct {
+ calls []map[string]string
+ err error
+}
+
+func (s *stubTrigger) Trigger(_ context.Context, env map[string]string) error {
+ s.calls = append(s.calls, env)
+ return s.err
+}
+
+func newDispatcher(md *meta.Metadata, mz *repo.Materializer, tr Trigger) *Dispatcher {
+ return NewDispatcher(Config{
+ Runs: md,
+ Materializer: mz,
+ Trigger: tr,
+ BaseURL: "https://gitmote.test",
+ WorkerSecret: "worker-secret",
+ })
+}
+
func TestDispatchOneWorkflowCreatesRunAndJob(t *testing.T) {
ctx := context.Background()
md, s, mz := newFixture(t)
@@ -113,7 +136,8 @@ func TestDispatchOneWorkflowCreatesRunAndJob(t *testing.T) {
".github/workflows/ci.yml": "name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n",
})
- NewDispatcher(md, mz, nil).Dispatch(ctx, branchEvent(r, head))
+ tr := &stubTrigger{}
+ newDispatcher(md, mz, tr).Dispatch(ctx, branchEvent(r, head))
runs, _ := md.ListRuns(ctx, r.ID, 0)
if len(runs) != 1 {
@@ -126,6 +150,24 @@ func TestDispatchOneWorkflowCreatesRunAndJob(t *testing.T) {
if len(jobs) != 1 || jobs[0].Name != "ci.yml" || jobs[0].Status != meta.RunQueued {
t.Errorf("jobs = %+v, want one queued job named ci.yml", jobs)
}
+
+ // The job was triggered exactly once, with the runner env for that run/job.
+ if len(tr.calls) != 1 {
+ t.Fatalf("trigger calls = %d, want 1", len(tr.calls))
+ }
+ env := tr.calls[0]
+ if env["GITMOTE_CI_RUN_ID"] != strconv.FormatInt(runs[0].ID, 10) ||
+ env["GITMOTE_CI_JOB_ID"] != strconv.FormatInt(jobs[0].ID, 10) {
+ t.Errorf("env run/job ids = %q/%q, want %d/%d",
+ env["GITMOTE_CI_RUN_ID"], env["GITMOTE_CI_JOB_ID"], runs[0].ID, jobs[0].ID)
+ }
+ if env["GITMOTE_REPO"] != "atmin/app" || env["GITMOTE_SHA"] != head ||
+ env["GITMOTE_REF"] != "refs/heads/main" {
+ t.Errorf("env checkout coords = %+v, want repo/sha/ref set", env)
+ }
+ if env["GITMOTE_URL"] != "https://gitmote.test" || env["WORKER_SECRET"] != "worker-secret" {
+ t.Errorf("env url/secret = %q/%q, want configured values", env["GITMOTE_URL"], env["WORKER_SECRET"])
+ }
}
func TestDispatchTwoWorkflowsCreateTwoJobs(t *testing.T) {
@@ -136,7 +178,7 @@ func TestDispatchTwoWorkflowsCreateTwoJobs(t *testing.T) {
".github/workflows/deploy.yaml": "name: Deploy\non: push\n",
})
- NewDispatcher(md, mz, nil).Dispatch(ctx, branchEvent(r, head))
+ newDispatcher(md, mz, &stubTrigger{}).Dispatch(ctx, branchEvent(r, head))
runs, _ := md.ListRuns(ctx, r.ID, 0)
if len(runs) != 1 {
@@ -157,7 +199,7 @@ func TestDispatchNoWorkflowsNoRun(t *testing.T) {
md, s, mz := newFixture(t)
r, head := seedRepo(t, md, s, "atmin/app", nil) // no .github/workflows
- NewDispatcher(md, mz, nil).Dispatch(ctx, branchEvent(r, head))
+ newDispatcher(md, mz, &stubTrigger{}).Dispatch(ctx, branchEvent(r, head))
if runs, _ := md.ListRuns(ctx, r.ID, 0); len(runs) != 0 {
t.Errorf("runs = %d, want 0 (no workflows)", len(runs))
@@ -173,7 +215,7 @@ func TestDispatchMalformedWorkflowFailsRun(t *testing.T) {
})
// Must not panic; the push (simulated by the event) is unaffected.
- NewDispatcher(md, mz, nil).Dispatch(ctx, branchEvent(r, head))
+ newDispatcher(md, mz, &stubTrigger{}).Dispatch(ctx, branchEvent(r, head))
runs, _ := md.ListRuns(ctx, r.ID, 0)
if len(runs) != 1 {
@@ -195,7 +237,7 @@ func TestDispatchIgnoresTagsAndDeletes(t *testing.T) {
r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
".github/workflows/ci.yml": "name: CI\non: push\n",
})
- d := NewDispatcher(md, mz, nil)
+ d := newDispatcher(md, mz, &stubTrigger{})
cases := []Event{
{RepoID: r.ID, RepoName: r.Name, Ref: "refs/tags/v1", OldSHA: meta.ZeroSHA, NewSHA: head},
@@ -210,3 +252,75 @@ func TestDispatchIgnoresTagsAndDeletes(t *testing.T) {
t.Errorf("runs = %d, want 0 (tags/deletes/non-branch create none)", len(runs))
}
}
+
+func TestDispatchAllTriggersFailMarksRunError(t *testing.T) {
+ ctx := context.Background()
+ md, s, mz := newFixture(t)
+ r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ ".github/workflows/ci.yml": "name: CI\non: push\n",
+ })
+
+ tr := &stubTrigger{err: errors.New("scaleway down")}
+ newDispatcher(md, mz, tr).Dispatch(ctx, branchEvent(r, head))
+
+ runs, _ := md.ListRuns(ctx, r.ID, 0)
+ if len(runs) != 1 || runs[0].Status != meta.RunError {
+ t.Fatalf("run = %+v, want one run in error", runs)
+ }
+ jobs, _ := md.ListJobs(ctx, runs[0].ID)
+ if len(jobs) != 1 || jobs[0].Status != meta.RunError {
+ t.Errorf("jobs = %+v, want the job marked error", jobs)
+ }
+}
+
+func TestDispatchOneTriggerFailsOthersProceed(t *testing.T) {
+ ctx := context.Background()
+ md, s, mz := newFixture(t)
+ r, head := seedRepo(t, md, s, "atmin/app", map[string]string{
+ ".github/workflows/a.yml": "name: A\non: push\n",
+ ".github/workflows/b.yml": "name: B\non: push\n",
+ })
+
+ // Fail the first trigger, succeed the rest — one failure must not abort others.
+ tr := &failNthTrigger{failOn: 1}
+ newDispatcher(md, mz, tr).Dispatch(ctx, branchEvent(r, head))
+
+ runs, _ := md.ListRuns(ctx, r.ID, 0)
+ if len(runs) != 1 {
+ t.Fatalf("runs = %d, want 1", len(runs))
+ }
+ // Not every job failed, so the run is not marked error; it stays queued.
+ if runs[0].Status != meta.RunQueued {
+ t.Errorf("run status = %q, want queued (not all jobs failed)", runs[0].Status)
+ }
+ jobs, _ := md.ListJobs(ctx, runs[0].ID)
+ if len(jobs) != 2 {
+ t.Fatalf("jobs = %d, want 2 (both created despite one trigger failure)", len(jobs))
+ }
+ var errored, queued int
+ for _, j := range jobs {
+ switch j.Status {
+ case meta.RunError:
+ errored++
+ case meta.RunQueued:
+ queued++
+ }
+ }
+ if errored != 1 || queued != 1 {
+ t.Errorf("job statuses = %+v, want one error + one queued", jobs)
+ }
+}
+
+// failNthTrigger fails the Nth (1-based) trigger call and succeeds the others.
+type failNthTrigger struct {
+ failOn int
+ n int
+}
+
+func (f *failNthTrigger) Trigger(_ context.Context, _ map[string]string) error {
+ f.n++
+ if f.n == f.failOn {
+ return errors.New("trigger failed")
+ }
+ return nil
+}
diff --git a/internal/githttp/receive_test.go b/internal/githttp/receive_test.go
index 72129bb..34f96bb 100644
--- a/internal/githttp/receive_test.go
+++ b/internal/githttp/receive_test.go
@@ -225,12 +225,23 @@ func TestPushChunkedLargePack(t *testing.T) {
}
}
+// noopTrigger is a ci.Trigger that does nothing — the write-path tests only care
+// that runs/jobs are recorded, not that a runner starts.
+type noopTrigger struct{}
+
+func (noopTrigger) Trigger(context.Context, map[string]string) error { return nil }
+
// wireDispatcher points the writer's after-commit hook at a real ci.Dispatcher
// reading workflow config through a materializer over store s, mirroring the
// main.go wiring.
func wireDispatcher(t *testing.T, w *Writer, m *meta.Metadata, s store.Store) {
t.Helper()
- d := ci.NewDispatcher(m, repo.New(m, s, t.TempDir()), slog.New(slog.DiscardHandler))
+ d := ci.NewDispatcher(ci.Config{
+ Runs: m,
+ Materializer: repo.New(m, s, t.TempDir()),
+ Trigger: noopTrigger{},
+ Logger: slog.New(slog.DiscardHandler),
+ })
w.AfterCommit = func(ctx context.Context, commits []CommitInfo) {
for _, c := range commits {
d.Dispatch(ctx, ci.Event{
diff --git a/internal/scaleway/jobs.go b/internal/scaleway/jobs.go
new file mode 100644
index 0000000..8c98939
--- /dev/null
+++ b/internal/scaleway/jobs.go
@@ -0,0 +1,77 @@
+// Package scaleway is a thin client for Scaleway Serverless Jobs — the CI runner
+// substrate (tasks/16-ci.md §Runner substrate). A job definition is created once
+// out of band; the app injects per-run env at start, so one definition serves
+// all repos. The client is fire-and-forget: the caller triggers a run and never
+// waits on it — the runner reports back over the internal API (stage 4).
+package scaleway
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+// triggerTimeout bounds the single start POST; the job itself runs
+// asynchronously on Scaleway well beyond this.
+const triggerTimeout = 10 * time.Second
+
+// JobsClient starts runs of one Scaleway Serverless Job definition.
+type JobsClient struct {
+ secretKey string
+ region string
+ jobDefinitionID string
+ httpClient *http.Client
+}
+
+// NewJobsClient returns a client for the given job definition. When
+// jobDefinitionID is empty (local dev / tests), Trigger is a no-op.
+func NewJobsClient(secretKey, region, jobDefinitionID string) *JobsClient {
+ return &JobsClient{
+ secretKey: secretKey,
+ region: region,
+ jobDefinitionID: jobDefinitionID,
+ httpClient: &http.Client{Timeout: triggerTimeout},
+ }
+}
+
+// Trigger starts one job run with env injected as its environment variables. It
+// is a no-op returning nil when the client has no job definition configured. A
+// response status >= 300 is an error, with the response body attached.
+func (c *JobsClient) Trigger(ctx context.Context, env map[string]string) error {
+ if c.jobDefinitionID == "" {
+ return nil
+ }
+
+ url := fmt.Sprintf(
+ "https://api.scaleway.com/serverless-jobs/v1alpha2/regions/%s/job-definitions/%s/start",
+ c.region, c.jobDefinitionID,
+ )
+ body, err := json.Marshal(map[string]any{"environment_variables": env})
+ if err != nil {
+ return err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Auth-Token", c.secretKey)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode >= 300 {
+ msg, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("scaleway: start job definition %s returned %d: %s",
+ c.jobDefinitionID, resp.StatusCode, msg)
+ }
+ return nil
+}
diff --git a/internal/scaleway/jobs_test.go b/internal/scaleway/jobs_test.go
new file mode 100644
index 0000000..4af1572
--- /dev/null
+++ b/internal/scaleway/jobs_test.go
@@ -0,0 +1,106 @@
+package scaleway
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestTriggerPostsStartRequest(t *testing.T) {
+ var (
+ gotPath string
+ gotToken string
+ gotBody map[string]map[string]string
+ )
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ gotToken = r.Header.Get("X-Auth-Token")
+ body, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(body, &gotBody)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ // Point the client at the test server by overriding the base URL via a client
+ // that rewrites the host: simplest is to build the client and swap its
+ // transport to redirect to srv.
+ c := NewJobsClient("secret-key", "fr-par", "def-123")
+ c.httpClient = srv.Client()
+ c.httpClient.Transport = rewriteHost(srv.URL)
+
+ env := map[string]string{"GITMOTE_CI_RUN_ID": "7", "WORKER_SECRET": "s3cr3t"}
+ if err := c.Trigger(context.Background(), env); err != nil {
+ t.Fatalf("Trigger: %v", err)
+ }
+
+ wantPath := "/serverless-jobs/v1alpha2/regions/fr-par/job-definitions/def-123/start"
+ if gotPath != wantPath {
+ t.Errorf("path = %q, want %q", gotPath, wantPath)
+ }
+ if gotToken != "secret-key" {
+ t.Errorf("X-Auth-Token = %q, want secret-key", gotToken)
+ }
+ vars := gotBody["environment_variables"]
+ if vars["GITMOTE_CI_RUN_ID"] != "7" || vars["WORKER_SECRET"] != "s3cr3t" {
+ t.Errorf("environment_variables = %v, want the passed env", vars)
+ }
+}
+
+func TestTriggerErrorsOnBadStatus(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte("denied"))
+ }))
+ defer srv.Close()
+
+ c := NewJobsClient("k", "fr-par", "def-123")
+ c.httpClient = srv.Client()
+ c.httpClient.Transport = rewriteHost(srv.URL)
+
+ err := c.Trigger(context.Background(), nil)
+ if err == nil {
+ t.Fatal("Trigger returned nil, want an error for a 403")
+ }
+ if !strings.Contains(err.Error(), "403") || !strings.Contains(err.Error(), "denied") {
+ t.Errorf("error = %v, want it to include the status and body", err)
+ }
+}
+
+func TestTriggerNoOpWhenUnconfigured(t *testing.T) {
+ called := false
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ }))
+ defer srv.Close()
+
+ c := NewJobsClient("", "", "") // no job definition
+ c.httpClient = srv.Client()
+ c.httpClient.Transport = rewriteHost(srv.URL)
+
+ if err := c.Trigger(context.Background(), map[string]string{"x": "y"}); err != nil {
+ t.Fatalf("Trigger no-op returned %v, want nil", err)
+ }
+ if called {
+ t.Error("Trigger made an HTTP call when the job definition is unset")
+ }
+}
+
+// rewriteHost returns a transport that redirects every request to the test
+// server's host while preserving the request path, so the client's hardcoded
+// api.scaleway.com URL reaches httptest.
+func rewriteHost(serverURL string) http.RoundTripper {
+ base := strings.TrimPrefix(strings.TrimPrefix(serverURL, "http://"), "https://")
+ return roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ r.URL.Scheme = "http"
+ r.URL.Host = base
+ return http.DefaultTransport.RoundTrip(r)
+ })
+}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
diff --git a/tasks/19-ci-scaleway-trigger.md b/tasks/19-ci-scaleway-trigger.md
deleted file mode 100644
index 73b991a..0000000
--- a/tasks/19-ci-scaleway-trigger.md
+++ /dev/null
@@ -1,84 +0,0 @@
-# CI stage 3 — Scaleway Serverless Jobs client + trigger
-
-Part of the CI epic ([16-ci.md](16-ci.md)). Depends on **stages 1–2
-([17](17-ci-data-model.md), [18](18-ci-config-discovery.md))**: the dispatcher
-now creates a run with `queued` jobs. This stage makes the dispatcher **trigger a
-Scaleway Serverless Job per CI job** — fire-and-forget. The runner binary and the
-report-back API are stage 4 ([21](21-ci-runner.md)); this stage only fires the
-trigger and records the outcome.
-
-## Spec
-
-For each `queued` job, POST a Scaleway Serverless Jobs `start` with per-run env
-(the runner reads it in stage 4). The trigger is fire-and-forget: a failure marks
-that job/run `error` and is logged, but never blocks the push or the other jobs.
-When the job-definition ID is unset (local dev / tests), the trigger is a
-**no-op** so the whole pipeline runs under a stub.
-
-## Current
-
-- The dispatcher creates runs + jobs (stages 1–2) but does nothing external.
-- **Proven, stealable pattern** in the sibling project
- `~/dev/pan0.com/lib/scaleway/jobs.go`:
- `POST https://api.scaleway.com/serverless-jobs/v1alpha2/regions/{region}/job-definitions/{id}/start`,
- header `X-Auth-Token: <SCW_SECRET_KEY>`, body
- `{"environment_variables": { … }}`; a `10s` client timeout on the trigger call
- only; a no-op when the definition ID is empty. Its `server.go` shows the env
- wiring and the "fail fast if the definition ID is set but required env is
- missing" check.
-- gitmote's public base URL isn't currently a config value; the container knows
- it as `gitmote.atmin.net` (see [ops.md](../docs/ops.md)).
-
-## Change
-
-**1. `internal/scaleway/jobs.go`** — port pan0's client, adapted:
-
-```go
-type JobsClient struct {
- secretKey, region, jobDefinitionID string
- httpClient *http.Client
-}
-func NewJobsClient(secretKey, region, jobDefinitionID string) *JobsClient { … }
-
-// Trigger starts one job run with the given env. No-op when jobDefinitionID == "".
-func (c *JobsClient) Trigger(ctx context.Context, env map[string]string) error
-```
-
-`Trigger` marshals `{"environment_variables": env}`, sets `X-Auth-Token`, POSTs to
-the `…/start` URL, and treats any `>= 300` as an error (include the response body).
-
-**2. Dispatcher wiring.** After jobs are created (stage 2), for each job call
-`Trigger` with env:
-
-| env | value |
-|---|---|
-| `GITMOTE_CI_RUN_ID` / `GITMOTE_CI_JOB_ID` | the run/job ids |
-| `GITMOTE_URL` | gitmote's public base URL (new config) |
-| `WORKER_SECRET` | shared runner-auth secret (used in stage 4) |
-| `GITMOTE_REPO` / `GITMOTE_SHA` / `GITMOTE_REF` | what to check out |
-| `GITMOTE_CI_CLONE_TOKEN` | per-run scoped token — **placeholder in this stage**, wired once [20](20-ci-scoped-tokens.md) lands |
-
-A `Trigger` error → `SetJobStatus(error)` (and the run `error` if all jobs fail);
-log and continue. Still non-fatal to the push.
-
-**3. Config/env plumbing** ([main.go](../cmd/gitmote/main.go)): read
-`SCW_SECRET_KEY`, `SCW_REGION` (fallback `AWS_REGION`), `SCW_CI_JOB_DEFINITION_ID`,
-`WORKER_SECRET`, `GITMOTE_URL`; build the `JobsClient` and pass it to the
-`Dispatcher`. When `SCW_CI_JOB_DEFINITION_ID` is unset, the dispatcher still
-creates run/job rows but the trigger no-ops (local dev behaves fully except for
-the external call). Add these to [ops.md](../docs/ops.md)'s env tables (the
-one-time `scw jobs definition create` for the runner image lands with stage 4).
-
-## Verify
-
-- **`scaleway` unit tests** with an `httptest.Server`: `Trigger` POSTs the correct
- URL path (region + definition id), sets `X-Auth-Token`, and sends
- `{"environment_variables": …}` with the expected keys; a `>= 300` response is a
- returned error including the body.
-- **no-op:** `NewJobsClient("","","")`.Trigger returns nil without any HTTP call.
-- **dispatcher tests:** with a stub trigger, a successful dispatch leaves jobs
- `queued`→(triggered) and the run intact; a trigger that errors marks the job
- `error` and the push/event still succeeds; one job's trigger failure doesn't
- abort the others.
-- `gofmt`/`golangci-lint`/`go test ./...` clean; e2e green (the trigger no-ops in
- the e2e env, so the write path is unchanged).
diff --git a/tasks/README.md b/tasks/README.md
index 491e9a9..b673d0d 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -17,8 +17,6 @@ index [16-ci.md](16-ci.md) (the *why* is in
[../docs/evolution/ci-runner.md](../docs/evolution/ci-runner.md)). Split into
dependency-ordered stages, each implementable from fresh context:
-- [19-ci-scaleway-trigger.md](19-ci-scaleway-trigger.md) — Scaleway Serverless Jobs
- client + per-job trigger (no-op when unconfigured).
- [20-ci-scoped-tokens.md](20-ci-scoped-tokens.md) — expiring, repo-scoped,
read-only tokens (precursor: the runner's clone credential).
- [21-ci-runner.md](21-ci-runner.md) — runner image + internal claim/complete API