tenseleyflow/shithub / 33b66c6

Browse files

actions/runner: support scoped checkout

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
33b66c65ffa26a1acdee4991d7b588d6d55c1377
Parents
03b66e3
Tree
e20f0c1

17 changed files

StatusFile+-
M internal/actions/queries/workflow_jobs.sql 7 2
M internal/actions/sqlc/workflow_jobs.sql.go 10 1
M internal/auth/runnerjwt/jwt.go 27 12
M internal/auth/runnerjwt/jwt_test.go 29 0
M internal/runner/api/client.go 2 0
M internal/runner/engine/docker.go 150 2
M internal/runner/engine/docker_test.go 91 1
M internal/runner/engine/types.go 2 0
M internal/runner/runner.go 2 0
M internal/web/githttp_wiring.go 6 3
M internal/web/handlers/api/runners.go 36 3
M internal/web/handlers/api/runners_test.go 24 5
M internal/web/handlers/githttp/auth.go 45 4
M internal/web/handlers/githttp/githttp.go 8 4
M internal/web/handlers/githttp/githttp_test.go 161 15
M internal/web/handlers/githttp/handler.go 12 0
M internal/web/server.go 1 1
internal/actions/queries/workflow_jobs.sqlmodified
@@ -157,9 +157,14 @@ SELECT c.id, c.run_id, c.job_index, c.job_key, c.job_name, c.runs_on,
157157
        c.cancel_requested, c.started_at, c.completed_at, c.version,
158158
        c.created_at, c.updated_at,
159159
        r.repo_id, r.run_index, r.workflow_file, r.workflow_name,
160
-       r.head_sha, r.head_ref, r.event, r.event_payload
160
+       r.head_sha, r.head_ref, r.event, r.event_payload,
161
+       COALESCE(owner_user.username, owner_org.slug)::text AS repo_owner,
162
+       repo.name AS repo_name
161163
 FROM claimed c
162
-JOIN workflow_runs r ON r.id = c.run_id;
164
+JOIN workflow_runs r ON r.id = c.run_id
165
+JOIN repos repo ON repo.id = r.repo_id
166
+LEFT JOIN users owner_user ON owner_user.id = repo.owner_user_id
167
+LEFT JOIN orgs owner_org ON owner_org.id = repo.owner_org_id;
163168
 
164169
 -- name: ListJobsForRun :many
165170
 SELECT id, run_id, job_index, job_key, job_name, runs_on, status,
internal/actions/sqlc/workflow_jobs.sql.gomodified
@@ -70,9 +70,14 @@ SELECT c.id, c.run_id, c.job_index, c.job_key, c.job_name, c.runs_on,
7070
        c.cancel_requested, c.started_at, c.completed_at, c.version,
7171
        c.created_at, c.updated_at,
7272
        r.repo_id, r.run_index, r.workflow_file, r.workflow_name,
73
-       r.head_sha, r.head_ref, r.event, r.event_payload
73
+       r.head_sha, r.head_ref, r.event, r.event_payload,
74
+       COALESCE(owner_user.username, owner_org.slug)::text AS repo_owner,
75
+       repo.name AS repo_name
7476
 FROM claimed c
7577
 JOIN workflow_runs r ON r.id = c.run_id
78
+JOIN repos repo ON repo.id = r.repo_id
79
+LEFT JOIN users owner_user ON owner_user.id = repo.owner_user_id
80
+LEFT JOIN orgs owner_org ON owner_org.id = repo.owner_org_id
7681
 `
7782
 
7883
 type ClaimQueuedWorkflowJobParams struct {
@@ -109,6 +114,8 @@ type ClaimQueuedWorkflowJobRow struct {
109114
 	HeadRef         string
110115
 	Event           WorkflowRunEvent
111116
 	EventPayload    []byte
117
+	RepoOwner       string
118
+	RepoName        string
112119
 }
113120
 
114121
 func (q *Queries) ClaimQueuedWorkflowJob(ctx context.Context, db DBTX, arg ClaimQueuedWorkflowJobParams) (ClaimQueuedWorkflowJobRow, error) {
@@ -143,6 +150,8 @@ func (q *Queries) ClaimQueuedWorkflowJob(ctx context.Context, db DBTX, arg Claim
143150
 		&i.HeadRef,
144151
 		&i.Event,
145152
 		&i.EventPayload,
153
+		&i.RepoOwner,
154
+		&i.RepoName,
146155
 	)
147156
 	return i, err
148157
 }
internal/auth/runnerjwt/jwt.gomodified
@@ -28,6 +28,9 @@ const (
2828
 	// DefaultTTL is the runner job-token lifetime from the S41c contract.
2929
 	DefaultTTL = 15 * time.Minute
3030
 
31
+	PurposeAPI      = "api"
32
+	PurposeCheckout = "checkout"
33
+
3134
 	signingKeySize = 32
3235
 	hkdfInfo       = "actions-runner-jwt-v1"
3336
 	jtiBytes       = 32
@@ -45,12 +48,13 @@ var (
4548
 
4649
 // Claims are the JWT payload fields accepted by runner job endpoints.
4750
 type Claims struct {
48
-	Sub    string `json:"sub"`
49
-	JobID  int64  `json:"job_id"`
50
-	RunID  int64  `json:"run_id"`
51
-	RepoID int64  `json:"repo_id"`
52
-	Exp    int64  `json:"exp"`
53
-	JTI    string `json:"jti"`
51
+	Sub     string `json:"sub"`
52
+	JobID   int64  `json:"job_id"`
53
+	RunID   int64  `json:"run_id"`
54
+	RepoID  int64  `json:"repo_id"`
55
+	Exp     int64  `json:"exp"`
56
+	JTI     string `json:"jti"`
57
+	Purpose string `json:"purpose,omitempty"`
5458
 }
5559
 
5660
 // RunnerID extracts the runner id encoded in sub="runner:<id>".
@@ -73,6 +77,7 @@ type MintParams struct {
7377
 	RunID    int64
7478
 	RepoID   int64
7579
 	TTL      time.Duration
80
+	Purpose  string
7681
 }
7782
 
7883
 // Signer signs and verifies HS256 runner JWTs.
@@ -165,13 +170,18 @@ func (s *Signer) Mint(p MintParams) (string, Claims, error) {
165170
 	if err != nil {
166171
 		return "", Claims{}, err
167172
 	}
173
+	purpose := p.Purpose
174
+	if purpose == "" {
175
+		purpose = PurposeAPI
176
+	}
168177
 	claims := Claims{
169
-		Sub:    fmt.Sprintf("runner:%d", p.RunnerID),
170
-		JobID:  p.JobID,
171
-		RunID:  p.RunID,
172
-		RepoID: p.RepoID,
173
-		Exp:    s.now().Add(ttl).Unix(),
174
-		JTI:    jti,
178
+		Sub:     fmt.Sprintf("runner:%d", p.RunnerID),
179
+		JobID:   p.JobID,
180
+		RunID:   p.RunID,
181
+		RepoID:  p.RepoID,
182
+		Exp:     s.now().Add(ttl).Unix(),
183
+		JTI:     jti,
184
+		Purpose: purpose,
175185
 	}
176186
 	if err := validateClaims(claims); err != nil {
177187
 		return "", Claims{}, err
@@ -273,6 +283,11 @@ func validateClaims(c Claims) error {
273283
 	if c.JobID <= 0 || c.RunID <= 0 || c.RepoID <= 0 || c.Exp <= 0 {
274284
 		return ErrInvalidClaims
275285
 	}
286
+	switch c.Purpose {
287
+	case "", PurposeAPI, PurposeCheckout:
288
+	default:
289
+		return ErrInvalidClaims
290
+	}
276291
 	if len(c.JTI) < 16 || len(c.JTI) > 128 {
277292
 		return ErrInvalidClaims
278293
 	}
internal/auth/runnerjwt/jwt_test.gomodified
@@ -45,6 +45,9 @@ func TestMintVerifyRoundTrip(t *testing.T) {
4545
 	if claims.Exp != now.Add(runnerjwt.DefaultTTL).Unix() {
4646
 		t.Fatalf("exp: got %d, want %d", claims.Exp, now.Add(runnerjwt.DefaultTTL).Unix())
4747
 	}
48
+	if claims.Purpose != runnerjwt.PurposeAPI {
49
+		t.Fatalf("purpose: got %q, want %q", claims.Purpose, runnerjwt.PurposeAPI)
50
+	}
4851
 	runnerID, err := claims.RunnerID()
4952
 	if err != nil {
5053
 		t.Fatalf("RunnerID: %v", err)
@@ -62,6 +65,32 @@ func TestMintVerifyRoundTrip(t *testing.T) {
6265
 	}
6366
 }
6467
 
68
+func TestMintVerifyCheckoutPurpose(t *testing.T) {
69
+	now := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)
70
+	signer := newTestSigner(t, now, bytesOf(0x66, 32))
71
+
72
+	token, claims, err := signer.Mint(runnerjwt.MintParams{
73
+		RunnerID: 7,
74
+		JobID:    11,
75
+		RunID:    13,
76
+		RepoID:   17,
77
+		Purpose:  runnerjwt.PurposeCheckout,
78
+	})
79
+	if err != nil {
80
+		t.Fatalf("Mint: %v", err)
81
+	}
82
+	if claims.Purpose != runnerjwt.PurposeCheckout {
83
+		t.Fatalf("purpose: got %q, want checkout", claims.Purpose)
84
+	}
85
+	got, err := signer.Verify(token)
86
+	if err != nil {
87
+		t.Fatalf("Verify: %v", err)
88
+	}
89
+	if got.Purpose != runnerjwt.PurposeCheckout {
90
+		t.Fatalf("verified purpose: got %q, want checkout", got.Purpose)
91
+	}
92
+}
93
+
6594
 func TestVerifyRejectsTamperedPayload(t *testing.T) {
6695
 	signer := newTestSigner(t, time.Unix(100, 0), bytesOf(0x22, 32))
6796
 	token, _, err := signer.Mint(runnerjwt.MintParams{RunnerID: 1, JobID: 2, RunID: 3, RepoID: 4})
internal/runner/api/client.gomodified
@@ -62,6 +62,8 @@ type Job struct {
6262
 	RunIndex       int64             `json:"run_index"`
6363
 	WorkflowFile   string            `json:"workflow_file"`
6464
 	WorkflowName   string            `json:"workflow_name"`
65
+	CheckoutURL    string            `json:"checkout_url"`
66
+	CheckoutToken  string            `json:"checkout_token"`
6567
 	HeadSHA        string            `json:"head_sha"`
6668
 	HeadRef        string            `json:"head_ref"`
6769
 	Event          string            `json:"event"`
internal/runner/engine/docker.gomodified
@@ -3,12 +3,14 @@
33
 package engine
44
 
55
 import (
6
+	"bytes"
67
 	"context"
78
 	"encoding/json"
89
 	"errors"
910
 	"fmt"
1011
 	"io"
1112
 	"log/slog"
13
+	"net/url"
1214
 	"os"
1315
 	"os/exec"
1416
 	"path"
@@ -60,6 +62,7 @@ func (ExecRunner) Run(ctx context.Context, name string, args []string, env []str
6062
 
6163
 type DockerConfig struct {
6264
 	Binary           string
65
+	GitBinary        string
6366
 	DefaultImage     string
6467
 	Network          string
6568
 	Memory           string
@@ -92,6 +95,9 @@ func NewDocker(cfg DockerConfig) *Docker {
9295
 	if cfg.Binary == "" {
9396
 		cfg.Binary = "docker"
9497
 	}
98
+	if cfg.GitBinary == "" {
99
+		cfg.GitBinary = "git"
100
+	}
95101
 	if cfg.LogChunkBytes <= 0 {
96102
 		cfg.LogChunkBytes = 4 * 1024
97103
 	}
@@ -191,8 +197,13 @@ func (d *Docker) Execute(ctx context.Context, job Job) (Outcome, error) {
191197
 }
192198
 
193199
 func (d *Docker) executeStep(ctx context.Context, job Job, step Step) error {
194
-	if strings.TrimSpace(step.Uses) != "" {
195
-		return fmt.Errorf("%w: %s is not executable until checkout/artifact support lands", ErrUnsupportedUses, step.Uses)
200
+	if uses := strings.TrimSpace(step.Uses); uses != "" {
201
+		switch uses {
202
+		case "actions/checkout@v4":
203
+			return d.executeCheckout(ctx, job, step)
204
+		default:
205
+			return fmt.Errorf("%w: %s is not executable until that alias lands", ErrUnsupportedUses, uses)
206
+		}
196207
 	}
197208
 	if strings.TrimSpace(step.Run) == "" {
198209
 		return nil
@@ -230,6 +241,84 @@ func (d *Docker) executeStep(ctx context.Context, job Job, step Step) error {
230241
 	return nil
231242
 }
232243
 
244
+func (d *Docker) executeCheckout(ctx context.Context, job Job, step Step) error {
245
+	checkoutURL, err := validateCheckoutURL(job.CheckoutURL)
246
+	if err != nil {
247
+		return err
248
+	}
249
+	if strings.TrimSpace(job.CheckoutToken) == "" {
250
+		return errors.New("runner engine: checkout token is required")
251
+	}
252
+	headSHA := strings.TrimSpace(job.HeadSHA)
253
+	if !gitObjectIDRE.MatchString(headSHA) {
254
+		return fmt.Errorf("runner engine: invalid checkout sha %q", headSHA)
255
+	}
256
+	if err := os.MkdirAll(job.WorkspaceDir, 0o700); err != nil {
257
+		return fmt.Errorf("runner engine: prepare checkout workspace: %w", err)
258
+	}
259
+	depthArgs, err := checkoutDepthArgs(step.With)
260
+	if err != nil {
261
+		return err
262
+	}
263
+	fetchTarget := headSHA
264
+
265
+	writer := d.newStepLogWriter(ctx, job.ID, step.ID, job.MaskValues)
266
+	out := io.MultiWriter(d.cfg.Stdout, writer)
267
+	errOut := io.MultiWriter(d.cfg.Stderr, writer)
268
+	closeWriter := func(runErr error) error {
269
+		if closeErr := writer.Close(); closeErr != nil {
270
+			if runErr != nil {
271
+				return errors.Join(runErr, closeErr)
272
+			}
273
+			return closeErr
274
+		}
275
+		return runErr
276
+	}
277
+
278
+	fmt.Fprintf(out, "Checking out %s at %s\n", checkoutURL, shortObjectID(headSHA))
279
+	gitEnv := []string{
280
+		"GIT_TERMINAL_PROMPT=0",
281
+		"GIT_ASKPASS=/bin/false",
282
+		"SHITHUB_CHECKOUT_TOKEN=" + job.CheckoutToken,
283
+	}
284
+	//nolint:gosec // G101: this helper reads the token from env; it does not hard-code or argv-expose a secret.
285
+	credentialHelper := `credential.helper=!f() { echo username=shithub-actions; echo password=$SHITHUB_CHECKOUT_TOKEN; }; f`
286
+	runGit := func(args ...string) error {
287
+		if err := d.cfg.Runner.Run(ctx, d.cfg.GitBinary, args, gitEnv, out, errOut); err != nil {
288
+			return fmt.Errorf("git %s: %w", strings.Join(redactCheckoutArgs(args), " "), err)
289
+		}
290
+		return nil
291
+	}
292
+
293
+	if err := runGit("-C", job.WorkspaceDir, "init"); err != nil {
294
+		return closeWriter(fmt.Errorf("runner engine: checkout step %q failed: %w", stepLabel(step), err))
295
+	}
296
+	if err := runGit("-C", job.WorkspaceDir, "remote", "add", "origin", checkoutURL); err != nil {
297
+		return closeWriter(fmt.Errorf("runner engine: checkout step %q failed: %w", stepLabel(step), err))
298
+	}
299
+	fetchArgs := []string{"-C", job.WorkspaceDir, "-c", credentialHelper, "fetch", "--no-tags"}
300
+	fetchArgs = append(fetchArgs, depthArgs...)
301
+	fetchArgs = append(fetchArgs, "origin", fetchTarget)
302
+	if err := runGit(fetchArgs...); err != nil {
303
+		return closeWriter(fmt.Errorf("runner engine: checkout step %q failed: %w", stepLabel(step), err))
304
+	}
305
+	if err := runGit("-C", job.WorkspaceDir, "checkout", "--force", "--detach", headSHA); err != nil {
306
+		return closeWriter(fmt.Errorf("runner engine: checkout step %q failed: %w", stepLabel(step), err))
307
+	}
308
+
309
+	var rev bytes.Buffer
310
+	if err := d.cfg.Runner.Run(ctx, d.cfg.GitBinary,
311
+		[]string{"-C", job.WorkspaceDir, "rev-parse", "HEAD"},
312
+		gitEnv, io.MultiWriter(out, &rev), errOut); err != nil {
313
+		return closeWriter(fmt.Errorf("runner engine: checkout step %q failed: git rev-parse HEAD: %w", stepLabel(step), err))
314
+	}
315
+	if got := strings.TrimSpace(rev.String()); !strings.EqualFold(got, headSHA) {
316
+		return closeWriter(fmt.Errorf("runner engine: checkout step %q failed: HEAD %s != expected %s", stepLabel(step), got, headSHA))
317
+	}
318
+	fmt.Fprintf(out, "Checked out %s\n", shortObjectID(headSHA))
319
+	return closeWriter(nil)
320
+}
321
+
233322
 type dockerInvocation struct {
234323
 	args           []string
235324
 	env            []string
@@ -374,6 +463,65 @@ func permissionsRequestRoot(raw json.RawMessage) bool {
374463
 	return strings.EqualFold(flat[rootPermissionKey], "write")
375464
 }
376465
 
466
+var gitObjectIDRE = regexp.MustCompile(`^[0-9a-fA-F]{40,64}$`)
467
+
468
+func validateCheckoutURL(raw string) (string, error) {
469
+	raw = strings.TrimSpace(raw)
470
+	if raw == "" {
471
+		return "", errors.New("runner engine: checkout url is required")
472
+	}
473
+	u, err := url.Parse(raw)
474
+	if err != nil || u.Scheme == "" || u.Host == "" {
475
+		return "", fmt.Errorf("runner engine: invalid checkout url %q", raw)
476
+	}
477
+	if u.User != nil {
478
+		return "", errors.New("runner engine: checkout url must not contain credentials")
479
+	}
480
+	switch strings.ToLower(u.Scheme) {
481
+	case "http", "https":
482
+	default:
483
+		return "", fmt.Errorf("runner engine: checkout url scheme %q is not allowed", u.Scheme)
484
+	}
485
+	return u.String(), nil
486
+}
487
+
488
+func checkoutDepthArgs(with map[string]string) ([]string, error) {
489
+	for key := range with {
490
+		if key != "fetch-depth" {
491
+			return nil, fmt.Errorf("runner engine: unsupported checkout input %q", key)
492
+		}
493
+	}
494
+	raw := strings.TrimSpace(with["fetch-depth"])
495
+	if raw == "" {
496
+		return []string{"--depth=1"}, nil
497
+	}
498
+	depth, err := strconv.Atoi(raw)
499
+	if err != nil || depth < 0 || depth > 100000 {
500
+		return nil, fmt.Errorf("runner engine: invalid checkout fetch-depth %q", raw)
501
+	}
502
+	if depth == 0 {
503
+		return nil, nil
504
+	}
505
+	return []string{"--depth=" + strconv.Itoa(depth)}, nil
506
+}
507
+
508
+func shortObjectID(oid string) string {
509
+	if len(oid) <= 12 {
510
+		return oid
511
+	}
512
+	return oid[:12]
513
+}
514
+
515
+func redactCheckoutArgs(args []string) []string {
516
+	out := append([]string{}, args...)
517
+	for i, arg := range out {
518
+		if strings.Contains(arg, "SHITHUB_CHECKOUT_TOKEN") {
519
+			out[i] = "credential.helper=<redacted>"
520
+		}
521
+	}
522
+	return out
523
+}
524
+
377525
 func (d *Docker) StreamLogs(_ context.Context, jobID int64) (<-chan LogChunk, error) {
378526
 	return d.ensureStream(jobID), nil
379527
 }
internal/runner/engine/docker_test.gomodified
@@ -570,7 +570,7 @@ func TestDockerExecute_RejectsUnsupportedUses(t *testing.T) {
570570
 	d := NewDocker(DockerConfig{DefaultImage: "runner-image", Network: "bridge", Memory: "2g", CPUs: "2", Runner: &recordingRunner{}})
571571
 	out, err := d.Execute(t.Context(), Job{
572572
 		WorkspaceDir: t.TempDir(),
573
-		Steps:        []Step{{Uses: "actions/checkout@v4"}},
573
+		Steps:        []Step{{Uses: "shithub/upload-artifact@v1"}},
574574
 	})
575575
 	if !errors.Is(err, ErrUnsupportedUses) {
576576
 		t.Fatalf("error: %v", err)
@@ -580,6 +580,96 @@ func TestDockerExecute_RejectsUnsupportedUses(t *testing.T) {
580580
 	}
581581
 }
582582
 
583
+type checkoutRunner struct {
584
+	calls []checkoutCall
585
+}
586
+
587
+type checkoutCall struct {
588
+	name string
589
+	args []string
590
+	env  []string
591
+}
592
+
593
+func (r *checkoutRunner) Run(_ context.Context, name string, args []string, env []string, stdout, _ io.Writer) error {
594
+	r.calls = append(r.calls, checkoutCall{
595
+		name: name,
596
+		args: append([]string{}, args...),
597
+		env:  append([]string{}, env...),
598
+	})
599
+	if len(args) >= 4 && args[len(args)-2] == "rev-parse" && args[len(args)-1] == "HEAD" {
600
+		_, _ = stdout.Write([]byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n"))
601
+	}
602
+	return nil
603
+}
604
+
605
+func TestDockerExecute_CheckoutUsesScopedCredentialAndVerifiesHead(t *testing.T) {
606
+	t.Parallel()
607
+	rec := &checkoutRunner{}
608
+	d := NewDocker(DockerConfig{
609
+		GitBinary:     "git-test",
610
+		DefaultImage:  "runner-image",
611
+		Network:       "bridge",
612
+		Memory:        "2g",
613
+		CPUs:          "2",
614
+		LogChunkBytes: 1024,
615
+		Runner:        rec,
616
+	})
617
+	out, err := d.Execute(t.Context(), Job{
618
+		ID:            1,
619
+		RunID:         2,
620
+		CheckoutURL:   "https://shithub.test/alice/demo.git",
621
+		CheckoutToken: "checkout-token",
622
+		HeadSHA:       "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
623
+		HeadRef:       "refs/heads/trunk",
624
+		WorkspaceDir:  t.TempDir(),
625
+		Steps: []Step{{
626
+			ID:   10,
627
+			Uses: "actions/checkout@v4",
628
+			With: map[string]string{"fetch-depth": "0"},
629
+		}},
630
+	})
631
+	if err != nil {
632
+		t.Fatalf("Execute: %v", err)
633
+	}
634
+	if out.Conclusion != ConclusionSuccess {
635
+		t.Fatalf("Conclusion: %q", out.Conclusion)
636
+	}
637
+	if len(rec.calls) != 5 {
638
+		t.Fatalf("git calls: got %d want 5: %#v", len(rec.calls), rec.calls)
639
+	}
640
+	want := [][]string{
641
+		{"-C", rec.calls[0].args[1], "init"},
642
+		{"-C", rec.calls[1].args[1], "remote", "add", "origin", "https://shithub.test/alice/demo.git"},
643
+		{"-C", rec.calls[2].args[1], "-c", rec.calls[2].args[3], "fetch", "--no-tags", "origin", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
644
+		{"-C", rec.calls[3].args[1], "checkout", "--force", "--detach", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
645
+		{"-C", rec.calls[4].args[1], "rev-parse", "HEAD"},
646
+	}
647
+	for i := range want {
648
+		if rec.calls[i].name != "git-test" {
649
+			t.Fatalf("call %d name = %q", i, rec.calls[i].name)
650
+		}
651
+		if !reflect.DeepEqual(rec.calls[i].args, want[i]) {
652
+			t.Fatalf("call %d args:\ngot  %#v\nwant %#v", i, rec.calls[i].args, want[i])
653
+		}
654
+	}
655
+	if strings.Contains(strings.Join(rec.calls[2].args, " "), "checkout-token") {
656
+		t.Fatalf("checkout token leaked into argv: %#v", rec.calls[2].args)
657
+	}
658
+	if !containsEnv(rec.calls[2].env, "SHITHUB_CHECKOUT_TOKEN=checkout-token") {
659
+		t.Fatalf("checkout token missing from git env: %#v", rec.calls[2].env)
660
+	}
661
+	if rec.calls[2].args[3] != `credential.helper=!f() { echo username=shithub-actions; echo password=$SHITHUB_CHECKOUT_TOKEN; }; f` {
662
+		t.Fatalf("credential helper: %q", rec.calls[2].args[3])
663
+	}
664
+}
665
+
666
+func TestCheckoutDepthArgsRejectsUnsupportedInputs(t *testing.T) {
667
+	t.Parallel()
668
+	if _, err := checkoutDepthArgs(map[string]string{"path": "src"}); err == nil || !strings.Contains(err.Error(), `unsupported checkout input "path"`) {
669
+		t.Fatalf("checkoutDepthArgs error = %v", err)
670
+	}
671
+}
672
+
583673
 func TestContainerWorkdirRejectsEscapes(t *testing.T) {
584674
 	t.Parallel()
585675
 	for _, wd := range []string{"../x", "/tmp"} {
internal/runner/engine/types.gomodified
@@ -40,6 +40,8 @@ type Job struct {
4040
 	RunIndex       int64
4141
 	WorkflowFile   string
4242
 	WorkflowName   string
43
+	CheckoutURL    string
44
+	CheckoutToken  string
4345
 	HeadSHA        string
4446
 	HeadRef        string
4547
 	Event          string
internal/runner/runner.gomodified
@@ -421,6 +421,8 @@ func toEngineJob(job api.Job, workspaceDir, defaultImage string) engine.Job {
421421
 		RunIndex:       job.RunIndex,
422422
 		WorkflowFile:   job.WorkflowFile,
423423
 		WorkflowName:   job.WorkflowName,
424
+		CheckoutURL:    job.CheckoutURL,
425
+		CheckoutToken:  job.CheckoutToken,
424426
 		HeadSHA:        job.HeadSHA,
425427
 		HeadRef:        job.HeadRef,
426428
 		Event:          job.Event,
internal/web/githttp_wiring.gomodified
@@ -10,6 +10,7 @@ import (
1010
 
1111
 	"github.com/jackc/pgx/v5/pgxpool"
1212
 
13
+	"github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
1314
 	"github.com/tenseleyFlow/shithub/internal/infra/config"
1415
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
1516
 	githttph "github.com/tenseleyFlow/shithub/internal/web/handlers/githttp"
@@ -21,6 +22,7 @@ import (
2122
 func buildGitHTTPHandlers(
2223
 	cfg config.Config,
2324
 	pool *pgxpool.Pool,
25
+	runnerJWT *runnerjwt.Signer,
2426
 	logger *slog.Logger,
2527
 ) (*githttph.Handlers, error) {
2628
 	if cfg.Storage.ReposRoot == "" {
@@ -35,8 +37,9 @@ func buildGitHTTPHandlers(
3537
 		return nil, fmt.Errorf("git-http: NewRepoFS: %w", err)
3638
 	}
3739
 	return githttph.New(githttph.Deps{
38
-		Logger: logger,
39
-		Pool:   pool,
40
-		RepoFS: rfs,
40
+		Logger:    logger,
41
+		Pool:      pool,
42
+		RepoFS:    rfs,
43
+		RunnerJWT: runnerJWT,
4144
 	})
4245
 }
internal/web/handlers/api/runners.gomodified
@@ -10,6 +10,7 @@ import (
1010
 	"fmt"
1111
 	"io"
1212
 	"net/http"
13
+	"net/url"
1314
 	"regexp"
1415
 	"sort"
1516
 	"strconv"
@@ -110,15 +111,28 @@ func (h *Handlers) runnerHeartbeat(w http.ResponseWriter, r *http.Request) {
110111
 		JobID:    job.ID,
111112
 		RunID:    job.RunID,
112113
 		RepoID:   job.RepoID,
114
+		Purpose:  runnerjwt.PurposeAPI,
113115
 	})
114116
 	if err != nil {
115117
 		h.d.Logger.ErrorContext(r.Context(), "runner jwt mint failed", "runner_id", runner.ID, "job_id", job.ID, "error", err)
116118
 		writeAPIError(w, http.StatusInternalServerError, "runner token mint failed")
117119
 		return
118120
 	}
121
+	checkoutToken, _, err := h.d.RunnerJWT.Mint(runnerjwt.MintParams{
122
+		RunnerID: runner.ID,
123
+		JobID:    job.ID,
124
+		RunID:    job.RunID,
125
+		RepoID:   job.RepoID,
126
+		Purpose:  runnerjwt.PurposeCheckout,
127
+	})
128
+	if err != nil {
129
+		h.d.Logger.ErrorContext(r.Context(), "runner checkout token mint failed", "runner_id", runner.ID, "job_id", job.ID, "error", err)
130
+		writeAPIError(w, http.StatusInternalServerError, "runner checkout token mint failed")
131
+		return
132
+	}
119133
 	metrics.ActionsRunnerHeartbeatsTotal.WithLabelValues("claimed").Inc()
120
-	metrics.ActionsRunnerJWTTotal.WithLabelValues("issued").Inc()
121
-	writeJSON(w, http.StatusOK, presentRunnerClaim(job, steps, resolvedSecrets, token, time.Unix(claims.Exp, 0)))
134
+	metrics.ActionsRunnerJWTTotal.WithLabelValues("issued").Add(2)
135
+	writeJSON(w, http.StatusOK, h.presentRunnerClaim(job, steps, resolvedSecrets, token, checkoutToken, time.Unix(claims.Exp, 0)))
122136
 }
123137
 
124138
 func (h *Handlers) authenticateRunner(w http.ResponseWriter, r *http.Request) (actionsdb.GetRunnerByTokenHashRow, bool) {
@@ -296,6 +310,11 @@ func (h *Handlers) authenticateRunnerJob(w http.ResponseWriter, r *http.Request)
296310
 		writeAPIError(w, http.StatusUnauthorized, "job token invalid")
297311
 		return runnerJobAuth{}, false
298312
 	}
313
+	if claims.Purpose != "" && claims.Purpose != runnerjwt.PurposeAPI {
314
+		metrics.ActionsRunnerJWTTotal.WithLabelValues("rejected").Inc()
315
+		writeAPIError(w, http.StatusUnauthorized, "job token invalid")
316
+		return runnerJobAuth{}, false
317
+	}
299318
 	if claims.JobID != pathJobID {
300319
 		writeAPIError(w, http.StatusNotFound, "job not found")
301320
 		return runnerJobAuth{}, false
@@ -1264,6 +1283,7 @@ func (h *Handlers) writeNextTokenResponse(
12641283
 		JobID:    auth.Claims.JobID,
12651284
 		RunID:    auth.Claims.RunID,
12661285
 		RepoID:   auth.Claims.RepoID,
1286
+		Purpose:  runnerjwt.PurposeAPI,
12671287
 	})
12681288
 	if err != nil {
12691289
 		h.d.Logger.ErrorContext(r.Context(), "runner next-token mint failed", "job_id", auth.Claims.JobID, "error", err)
@@ -1289,6 +1309,8 @@ type runnerJobPayload struct {
12891309
 	RunIndex       int64             `json:"run_index"`
12901310
 	WorkflowFile   string            `json:"workflow_file"`
12911311
 	WorkflowName   string            `json:"workflow_name"`
1312
+	CheckoutURL    string            `json:"checkout_url"`
1313
+	CheckoutToken  string            `json:"checkout_token"`
12921314
 	HeadSHA        string            `json:"head_sha"`
12931315
 	HeadRef        string            `json:"head_ref"`
12941316
 	Event          string            `json:"event"`
@@ -1320,11 +1342,12 @@ type runnerStep struct {
13201342
 	ContinueOnError  bool            `json:"continue_on_error"`
13211343
 }
13221344
 
1323
-func presentRunnerClaim(
1345
+func (h *Handlers) presentRunnerClaim(
13241346
 	job actionsdb.ClaimQueuedWorkflowJobRow,
13251347
 	steps []actionsdb.ListRunnerStepsForJobRow,
13261348
 	resolvedSecrets map[string]string,
13271349
 	token string,
1350
+	checkoutToken string,
13281351
 	expiresAt time.Time,
13291352
 ) runnerClaimResponse {
13301353
 	outSteps := make([]runnerStep, 0, len(steps))
@@ -1353,6 +1376,8 @@ func presentRunnerClaim(
13531376
 			RunIndex:       job.RunIndex,
13541377
 			WorkflowFile:   job.WorkflowFile,
13551378
 			WorkflowName:   job.WorkflowName,
1379
+			CheckoutURL:    h.checkoutURL(job.RepoOwner, job.RepoName),
1380
+			CheckoutToken:  checkoutToken,
13561381
 			HeadSHA:        job.HeadSha,
13571382
 			HeadRef:        job.HeadRef,
13581383
 			Event:          string(job.Event),
@@ -1372,6 +1397,14 @@ func presentRunnerClaim(
13721397
 	}
13731398
 }
13741399
 
1400
+func (h *Handlers) checkoutURL(owner, repoName string) string {
1401
+	base := strings.TrimRight(strings.TrimSpace(h.d.BaseURL), "/")
1402
+	if base == "" {
1403
+		return ""
1404
+	}
1405
+	return base + "/" + url.PathEscape(owner) + "/" + url.PathEscape(repoName) + ".git"
1406
+}
1407
+
13751408
 func rawJSONOrObject(b []byte) json.RawMessage {
13761409
 	if len(b) == 0 || !json.Valid(b) {
13771410
 		return json.RawMessage(`{}`)
internal/web/handlers/api/runners_test.gomodified
@@ -67,11 +67,13 @@ func TestRunnerHeartbeatClaimsQueuedJob(t *testing.T) {
6767
 	var resp struct {
6868
 		Token string `json:"token"`
6969
 		Job   struct {
70
-			ID           int64          `json:"id"`
71
-			RunID        int64          `json:"run_id"`
72
-			RepoID       int64          `json:"repo_id"`
73
-			EventPayload map[string]any `json:"event_payload"`
74
-			Steps        []struct {
70
+			ID            int64          `json:"id"`
71
+			RunID         int64          `json:"run_id"`
72
+			RepoID        int64          `json:"repo_id"`
73
+			CheckoutURL   string         `json:"checkout_url"`
74
+			CheckoutToken string         `json:"checkout_token"`
75
+			EventPayload  map[string]any `json:"event_payload"`
76
+			Steps         []struct {
7577
 				Run  string `json:"run"`
7678
 				Uses string `json:"uses"`
7779
 			} `json:"steps"`
@@ -86,6 +88,9 @@ func TestRunnerHeartbeatClaimsQueuedJob(t *testing.T) {
8688
 	if resp.Job.RunID != runID || resp.Job.RepoID != repoID || len(resp.Job.Steps) != 2 {
8789
 		t.Fatalf("unexpected job payload: %+v", resp.Job)
8890
 	}
91
+	if resp.Job.CheckoutURL != "https://shithub.test/alice/demo.git" || resp.Job.CheckoutToken == "" {
92
+		t.Fatalf("checkout payload: url=%q token_empty=%t", resp.Job.CheckoutURL, resp.Job.CheckoutToken == "")
93
+	}
8994
 	if resp.Job.EventPayload["ref"] != "refs/heads/trunk" {
9095
 		t.Fatalf("event payload not returned to runner: %#v", resp.Job.EventPayload)
9196
 	}
@@ -103,6 +108,17 @@ func TestRunnerHeartbeatClaimsQueuedJob(t *testing.T) {
103108
 	if claimRunnerID != runnerID {
104109
 		t.Fatalf("claims runner_id: got %d, want %d", claimRunnerID, runnerID)
105110
 	}
111
+	if claims.Purpose != runnerjwt.PurposeAPI {
112
+		t.Fatalf("api token purpose: got %q", claims.Purpose)
113
+	}
114
+	checkoutClaims, err := signer.Verify(resp.Job.CheckoutToken)
115
+	if err != nil {
116
+		t.Fatalf("verify checkout token: %v", err)
117
+	}
118
+	if checkoutClaims.JobID != resp.Job.ID || checkoutClaims.RunID != runID || checkoutClaims.RepoID != repoID ||
119
+		checkoutClaims.Purpose != runnerjwt.PurposeCheckout {
120
+		t.Fatalf("checkout claims/job mismatch: claims=%+v job=%+v", checkoutClaims, resp.Job)
121
+	}
106122
 
107123
 	var logResp struct {
108124
 		Accepted  bool   `json:"accepted"`
@@ -682,6 +698,7 @@ func newRunnerAPIRouter(
682698
 	h, err := apih.New(apih.Deps{
683699
 		Pool:        pool,
684700
 		Logger:      logger,
701
+		BaseURL:     "https://shithub.test",
685702
 		RunnerJWT:   signer,
686703
 		ObjectStore: store,
687704
 	})
@@ -704,6 +721,7 @@ func newRunnerAPIRouterWithRepoFS(
704721
 	h, err := apih.New(apih.Deps{
705722
 		Pool:      pool,
706723
 		Logger:    logger,
724
+		BaseURL:   "https://shithub.test",
707725
 		RunnerJWT: signer,
708726
 		RepoFS:    rfs,
709727
 	})
@@ -726,6 +744,7 @@ func newRunnerAPIRouterWithSecretBox(
726744
 	h, err := apih.New(apih.Deps{
727745
 		Pool:      pool,
728746
 		Logger:    logger,
747
+		BaseURL:   "https://shithub.test",
729748
 		RunnerJWT: signer,
730749
 		SecretBox: box,
731750
 	})
internal/web/handlers/githttp/auth.gomodified
@@ -9,8 +9,10 @@ import (
99
 	"strings"
1010
 	"time"
1111
 
12
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
1213
 	"github.com/tenseleyFlow/shithub/internal/auth/password"
1314
 	"github.com/tenseleyFlow/shithub/internal/auth/pat"
15
+	"github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
1416
 )
1517
 
1618
 // resolvedAuth carries the resolved identity for a git-over-HTTPS
@@ -18,10 +20,12 @@ import (
1820
 // callers decide whether anonymous is allowed (yes for pulling a public
1921
 // repo, no for everything else).
2022
 type resolvedAuth struct {
21
-	Anonymous bool
22
-	UserID    int64
23
-	Username  string
24
-	ViaPAT    bool
23
+	Anonymous          bool
24
+	UserID             int64
25
+	Username           string
26
+	ViaPAT             bool
27
+	ViaRunnerCheckout  bool
28
+	RunnerCheckoutRepo int64
2529
 }
2630
 
2731
 // errBadCredentials is the catch-all for "creds were sent but didn't
@@ -59,6 +63,9 @@ func (h *Handlers) resolveBasicAuth(ctx context.Context, header string) (resolve
5963
 		return resolvedAuth{}, errBadCredentials
6064
 	}
6165
 
66
+	if got, err := h.resolveViaRunnerCheckout(ctx, secret); err == nil {
67
+		return got, nil
68
+	}
6269
 	if strings.HasPrefix(secret, pat.Prefix) {
6370
 		if got, err := h.resolveViaPAT(ctx, secret); err == nil {
6471
 			return got, nil
@@ -69,6 +76,40 @@ func (h *Handlers) resolveBasicAuth(ctx context.Context, header string) (resolve
6976
 	return h.resolveViaPassword(ctx, user, secret)
7077
 }
7178
 
79
+func (h *Handlers) resolveViaRunnerCheckout(ctx context.Context, raw string) (resolvedAuth, error) {
80
+	if h.d.RunnerJWT == nil || raw == "" {
81
+		return resolvedAuth{}, errBadCredentials
82
+	}
83
+	claims, err := h.d.RunnerJWT.Verify(raw)
84
+	if err != nil || claims.Purpose != runnerjwt.PurposeCheckout {
85
+		return resolvedAuth{}, errBadCredentials
86
+	}
87
+	runnerID, err := claims.RunnerID()
88
+	if err != nil {
89
+		return resolvedAuth{}, errBadCredentials
90
+	}
91
+	job, err := h.aq.GetWorkflowJobByID(ctx, h.d.Pool, claims.JobID)
92
+	if err != nil {
93
+		return resolvedAuth{}, errBadCredentials
94
+	}
95
+	run, err := h.aq.GetWorkflowRunByID(ctx, h.d.Pool, job.RunID)
96
+	if err != nil {
97
+		return resolvedAuth{}, errBadCredentials
98
+	}
99
+	if job.RunID != claims.RunID ||
100
+		run.RepoID != claims.RepoID ||
101
+		job.Status != actionsdb.WorkflowJobStatusRunning ||
102
+		!job.RunnerID.Valid ||
103
+		job.RunnerID.Int64 != runnerID {
104
+		return resolvedAuth{}, errBadCredentials
105
+	}
106
+	return resolvedAuth{
107
+		Username:           "shithub-actions",
108
+		ViaRunnerCheckout:  true,
109
+		RunnerCheckoutRepo: claims.RepoID,
110
+	}, nil
111
+}
112
+
72113
 // resolveViaPAT looks up the token by its sha256 hash, checks
73114
 // revoked/expired/suspended, and returns the owning user. Returns
74115
 // errBadCredentials on any failure (no leak about which check failed).
internal/web/handlers/githttp/githttp.gomodified
@@ -14,6 +14,8 @@ import (
1414
 
1515
 	"github.com/jackc/pgx/v5/pgxpool"
1616
 
17
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
18
+	"github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
1719
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
1820
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
1921
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
@@ -22,9 +24,10 @@ import (
2224
 
2325
 // Deps wires the git-HTTP handler set.
2426
 type Deps struct {
25
-	Logger *slog.Logger
26
-	Pool   *pgxpool.Pool
27
-	RepoFS *storage.RepoFS
27
+	Logger    *slog.Logger
28
+	Pool      *pgxpool.Pool
29
+	RepoFS    *storage.RepoFS
30
+	RunnerJWT *runnerjwt.Signer
2831
 	// MaxPushBytes is the hard cap on the git-receive-pack request body.
2932
 	// Defaults to 2 GiB when zero.
3033
 	MaxPushBytes int64
@@ -33,6 +36,7 @@ type Deps struct {
3336
 // Handlers is the registered handler set. Construct via New.
3437
 type Handlers struct {
3538
 	d  Deps
39
+	aq *actionsdb.Queries
3640
 	uq *usersdb.Queries
3741
 	rq *reposdb.Queries
3842
 	oq *orgsdb.Queries
@@ -52,5 +56,5 @@ func New(d Deps) (*Handlers, error) {
5256
 	if d.MaxPushBytes == 0 {
5357
 		d.MaxPushBytes = DefaultMaxPushBytes
5458
 	}
55
-	return &Handlers{d: d, uq: usersdb.New(), rq: reposdb.New(), oq: orgsdb.New()}, nil
59
+	return &Handlers{d: d, aq: actionsdb.New(), uq: usersdb.New(), rq: reposdb.New(), oq: orgsdb.New()}, nil
5660
 }
internal/web/handlers/githttp/githttp_test.gomodified
@@ -19,8 +19,10 @@ import (
1919
 	"github.com/jackc/pgx/v5/pgtype"
2020
 	"github.com/jackc/pgx/v5/pgxpool"
2121
 
22
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
2223
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
2324
 	"github.com/tenseleyFlow/shithub/internal/auth/pat"
25
+	"github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
2426
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
2527
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
2628
 	"github.com/tenseleyFlow/shithub/internal/orgs"
@@ -45,15 +47,18 @@ func gitCmd(args ...string) *exec.Cmd {
4547
 // private repo, mounts the smart-HTTP handlers on a httptest server,
4648
 // and returns everything callers need.
4749
 type env struct {
48
-	srv      *httptest.Server
49
-	pool     *pgxpool.Pool
50
-	userID   int64
51
-	user     string
52
-	pwd      string
53
-	patRaw   string
54
-	pubRepo  string
55
-	privRepo string
56
-	root     string
50
+	srv        *httptest.Server
51
+	pool       *pgxpool.Pool
52
+	userID     int64
53
+	user       string
54
+	pwd        string
55
+	patRaw     string
56
+	pubRepo    string
57
+	pubRepoID  int64
58
+	privRepo   string
59
+	privRepoID int64
60
+	root       string
61
+	runnerJWT  *runnerjwt.Signer
5762
 }
5863
 
5964
 func setupEnv(t *testing.T) *env {
@@ -89,16 +94,18 @@ func setupEnv(t *testing.T) *env {
8994
 		Pool: pool, RepoFS: rfs, Audit: audit.NewRecorder(), Limiter: throttle.NewLimiter(),
9095
 		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
9196
 	}
92
-	if _, err := repos.Create(context.Background(), rdeps, repos.Params{
97
+	publicRes, err := repos.Create(context.Background(), rdeps, repos.Params{
9398
 		OwnerUserID: user.ID, OwnerUsername: user.Username,
9499
 		Name: "public-repo", Visibility: "public", InitReadme: true,
95
-	}); err != nil {
100
+	})
101
+	if err != nil {
96102
 		t.Fatalf("create public: %v", err)
97103
 	}
98
-	if _, err := repos.Create(context.Background(), rdeps, repos.Params{
104
+	privateRes, err := repos.Create(context.Background(), rdeps, repos.Params{
99105
 		OwnerUserID: user.ID, OwnerUsername: user.Username,
100106
 		Name: "private-repo", Visibility: "private", InitReadme: true,
101
-	}); err != nil {
107
+	})
108
+	if err != nil {
102109
 		t.Fatalf("create private: %v", err)
103110
 	}
104111
 
@@ -119,9 +126,10 @@ func setupEnv(t *testing.T) *env {
119126
 		t.Fatalf("create PAT row: %v", err)
120127
 	}
121128
 
129
+	runnerJWT := runnerHTTPSigner(t)
122130
 	h, err := githttph.New(githttph.Deps{
123131
 		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
124
-		Pool:   pool, RepoFS: rfs,
132
+		Pool:   pool, RepoFS: rfs, RunnerJWT: runnerJWT,
125133
 	})
126134
 	if err != nil {
127135
 		t.Fatalf("New: %v", err)
@@ -134,7 +142,9 @@ func setupEnv(t *testing.T) *env {
134142
 	return &env{
135143
 		srv: srv, pool: pool, userID: user.ID,
136144
 		user: "alice", pwd: "wrong-not-real-password", patRaw: raw,
137
-		pubRepo: "public-repo", privRepo: "private-repo", root: root,
145
+		pubRepo: "public-repo", pubRepoID: publicRes.Repo.ID,
146
+		privRepo: "private-repo", privRepoID: privateRes.Repo.ID,
147
+		root: root, runnerJWT: runnerJWT,
138148
 	}
139149
 }
140150
 
@@ -196,6 +206,66 @@ func TestGitHTTP_PATClonePrivate(t *testing.T) {
196206
 	}
197207
 }
198208
 
209
+func TestGitHTTP_RunnerCheckoutTokenClonesPrivateRepoReadOnly(t *testing.T) {
210
+	t.Parallel()
211
+	env := setupEnv(t)
212
+	token := env.runnerCheckoutToken(t, env.privRepoID)
213
+
214
+	dst := filepath.Join(t.TempDir(), "clone")
215
+	cloneURL := authedURL(env.srv.URL, "shithub-actions", token, "/alice/private-repo.git")
216
+	out, err := gitCmd("clone", cloneURL, dst).CombinedOutput()
217
+	if err != nil {
218
+		t.Fatalf("clone with checkout token: %v\n%s", err, out)
219
+	}
220
+	out, err = gitCmd("-C", dst, "rev-list", "--count", "HEAD").CombinedOutput()
221
+	if err != nil {
222
+		t.Fatalf("rev-list: %v\n%s", err, out)
223
+	}
224
+	if got := strings.TrimSpace(string(out)); got != "1" {
225
+		t.Fatalf("rev-list = %q, want 1", got)
226
+	}
227
+
228
+	for _, c := range [][]string{
229
+		{"-C", dst, "config", "user.name", "Runner"},
230
+		{"-C", dst, "config", "user.email", "runner@example.test"},
231
+	} {
232
+		if out, err := gitCmd(c...).CombinedOutput(); err != nil {
233
+			t.Fatalf("config: %v\n%s", err, out)
234
+		}
235
+	}
236
+	if err := writeFile(filepath.Join(dst, "blocked.txt"), "x\n"); err != nil {
237
+		t.Fatalf("write: %v", err)
238
+	}
239
+	for _, c := range [][]string{
240
+		{"-C", dst, "add", "blocked.txt"},
241
+		{"-C", dst, "commit", "-m", "blocked"},
242
+	} {
243
+		if out, err := gitCmd(c...).CombinedOutput(); err != nil {
244
+			t.Fatalf("git %v: %v\n%s", c, err, out)
245
+		}
246
+	}
247
+	out, err = gitCmd("-C", dst, "push", "origin", "trunk").CombinedOutput()
248
+	if err == nil {
249
+		t.Fatalf("checkout token push unexpectedly succeeded: %s", out)
250
+	}
251
+	if !strings.Contains(string(out), "read-only") {
252
+		t.Fatalf("expected read-only checkout-token message, got: %s", out)
253
+	}
254
+}
255
+
256
+func TestGitHTTP_RunnerCheckoutTokenCannotReadOtherRepo(t *testing.T) {
257
+	t.Parallel()
258
+	env := setupEnv(t)
259
+	token := env.runnerCheckoutToken(t, env.pubRepoID)
260
+	dst := filepath.Join(t.TempDir(), "clone")
261
+	cmd := gitCmd("clone", authedURL(env.srv.URL, "shithub-actions", token, "/alice/private-repo.git"), dst)
262
+	cmd.Env = append(cmd.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/false")
263
+	out, err := cmd.CombinedOutput()
264
+	if err == nil {
265
+		t.Fatalf("expected checkout token for public repo to fail against private repo; output: %s", out)
266
+	}
267
+}
268
+
199269
 func TestGitHTTP_PATPushRoundtrip(t *testing.T) {
200270
 	t.Parallel()
201271
 	env := setupEnv(t)
@@ -291,6 +361,82 @@ func writeFile(path, body string) error {
291361
 	return os.WriteFile(path, []byte(body), 0o600)
292362
 }
293363
 
364
+func (e *env) runnerCheckoutToken(t *testing.T, repoID int64) string {
365
+	t.Helper()
366
+	ctx := context.Background()
367
+	q := actionsdb.New()
368
+	runner, err := q.InsertRunner(ctx, e.pool, actionsdb.InsertRunnerParams{
369
+		Name:     "runner-" + e.privRepo,
370
+		Labels:   []string{"ubuntu-latest"},
371
+		Capacity: 1,
372
+	})
373
+	if err != nil {
374
+		t.Fatalf("InsertRunner: %v", err)
375
+	}
376
+	run, err := q.InsertWorkflowRun(ctx, e.pool, actionsdb.InsertWorkflowRunParams{
377
+		RepoID:       repoID,
378
+		RunIndex:     1,
379
+		WorkflowFile: ".shithub/workflows/ci.yml",
380
+		WorkflowName: "CI",
381
+		HeadSha:      strings.Repeat("a", 40),
382
+		HeadRef:      "refs/heads/trunk",
383
+		Event:        actionsdb.WorkflowRunEventPush,
384
+		EventPayload: []byte(`{}`),
385
+		ActorUserID:  pgtype.Int8{Int64: e.userID, Valid: true},
386
+	})
387
+	if err != nil {
388
+		t.Fatalf("InsertWorkflowRun: %v", err)
389
+	}
390
+	job, err := q.InsertWorkflowJob(ctx, e.pool, actionsdb.InsertWorkflowJobParams{
391
+		RunID:          run.ID,
392
+		JobIndex:       0,
393
+		JobKey:         "checkout",
394
+		JobName:        "checkout",
395
+		RunsOn:         "ubuntu-latest",
396
+		NeedsJobs:      []string{},
397
+		TimeoutMinutes: 30,
398
+		Permissions:    []byte(`{}`),
399
+		JobEnv:         []byte(`{}`),
400
+	})
401
+	if err != nil {
402
+		t.Fatalf("InsertWorkflowJob: %v", err)
403
+	}
404
+	if _, err := e.pool.Exec(ctx, `UPDATE workflow_jobs SET runner_id = $1, status = 'running', started_at = now() WHERE id = $2`, runner.ID, job.ID); err != nil {
405
+		t.Fatalf("mark job running: %v", err)
406
+	}
407
+	token, _, err := e.runnerJWT.Mint(runnerjwt.MintParams{
408
+		RunnerID: runner.ID,
409
+		JobID:    job.ID,
410
+		RunID:    run.ID,
411
+		RepoID:   repoID,
412
+		Purpose:  runnerjwt.PurposeCheckout,
413
+	})
414
+	if err != nil {
415
+		t.Fatalf("Mint checkout token: %v", err)
416
+	}
417
+	return token
418
+}
419
+
420
+func runnerHTTPSigner(t *testing.T) *runnerjwt.Signer {
421
+	t.Helper()
422
+	signer, err := runnerjwt.NewFromKey(
423
+		bytesOf(0x88, 32),
424
+		runnerjwt.WithClock(func() time.Time { return time.Now().UTC() }),
425
+	)
426
+	if err != nil {
427
+		t.Fatalf("runnerjwt.NewFromKey: %v", err)
428
+	}
429
+	return signer
430
+}
431
+
432
+func bytesOf(b byte, n int) []byte {
433
+	out := make([]byte, n)
434
+	for i := range out {
435
+		out[i] = b
436
+	}
437
+	return out
438
+}
439
+
294440
 // TestGitHTTP_AnonCloneOrgOwnedPublic is a regression for an outage
295441
 // on 2026-05-09: pushing to https://shithub.sh/tenseleyflow/shithub.git
296442
 // returned 404 because authorizeForService only resolved owner-slugs
internal/web/handlers/githttp/handler.gomodified
@@ -157,6 +157,18 @@ func (h *Handlers) authorizeForService(w http.ResponseWriter, r *http.Request, s
157157
 		writeChallenge(w)
158158
 		return reposdb.Repo{}, false
159159
 	}
160
+	if auth.ViaRunnerCheckout {
161
+		if svc != protocol.UploadPack {
162
+			writeGitErrorMessage(w, http.StatusForbidden,
163
+				"shithub Actions checkout credentials are read-only")
164
+			return reposdb.Repo{}, false
165
+		}
166
+		if auth.RunnerCheckoutRepo != row.ID {
167
+			http.Error(w, "not found", http.StatusNotFound)
168
+			return reposdb.Repo{}, false
169
+		}
170
+		return row, true
171
+	}
160172
 
161173
 	// Build the policy actor and ask Can(). Owner identity, collab role,
162174
 	// archived/deleted gates all live in the policy package now.
internal/web/server.gomodified
@@ -338,7 +338,7 @@ func Run(ctx context.Context, opts Options) error {
338338
 			})
339339
 		}
340340
 
341
-		gitHTTPH, err := buildGitHTTPHandlers(cfg, pool, logger)
341
+		gitHTTPH, err := buildGitHTTPHandlers(cfg, pool, runnerJWT, logger)
342342
 		if err != nil {
343343
 			return fmt.Errorf("git-http handlers: %w", err)
344344
 		}