tenseleyflow/shithub / cc23044

Browse files

web/actions: cover run detail and log views (S41f)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
cc23044e015b2f881935956b951e7fe9d12e9f32
Parents
7aa789f
Tree
09cca42

3 changed files

StatusFile+-
M internal/web/handlers/repo/actions_test.go 397 5
M internal/web/handlers/repo/repo_test.go 23 15
M internal/web/render/render_test.go 31 0
internal/web/handlers/repo/actions_test.gomodified
@@ -3,6 +3,7 @@
33
 package repo
44
 
55
 import (
6
+	"bytes"
67
 	"context"
78
 	"net/http"
89
 	"net/http/httptest"
@@ -15,6 +16,7 @@ import (
1516
 	"github.com/jackc/pgx/v5/pgtype"
1617
 
1718
 	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
19
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
1820
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
1921
 )
2022
 
@@ -130,6 +132,232 @@ func TestRepoTabActionsPaginatesTwentyRuns(t *testing.T) {
130132
 	}
131133
 }
132134
 
135
+func TestRepoActionRunRendersWorkflowRunJobsAndSteps(t *testing.T) {
136
+	t.Parallel()
137
+	f := newRepoFixture(t)
138
+	now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
139
+	runID := f.insertWorkflowRun(t, workflowRunFixture{
140
+		RunIndex:      7,
141
+		WorkflowFile:  ".shithub/workflows/ci.yml",
142
+		WorkflowName:  "CI",
143
+		HeadRef:       "trunk",
144
+		Event:         actionsdb.WorkflowRunEventPush,
145
+		Status:        actionsdb.WorkflowRunStatusCompleted,
146
+		Conclusion:    actionsdb.CheckConclusionFailure,
147
+		ActorUserID:   f.owner.ID,
148
+		CreatedOffset: -20 * time.Minute,
149
+		StartedOffset: -19 * time.Minute,
150
+		DoneOffset:    -10 * time.Minute,
151
+	}, now)
152
+	buildID := f.insertWorkflowJob(t, workflowJobFixture{
153
+		RunID:       runID,
154
+		JobIndex:    0,
155
+		JobKey:      "build",
156
+		JobName:     "Build",
157
+		RunsOn:      "ubuntu-latest",
158
+		Status:      actionsdb.WorkflowJobStatusCompleted,
159
+		Conclusion:  actionsdb.CheckConclusionSuccess,
160
+		StartedAt:   now.Add(-19 * time.Minute),
161
+		CompletedAt: now.Add(-15 * time.Minute),
162
+	})
163
+	testID := f.insertWorkflowJob(t, workflowJobFixture{
164
+		RunID:       runID,
165
+		JobIndex:    1,
166
+		JobKey:      "test",
167
+		JobName:     "Test",
168
+		RunsOn:      "ubuntu-latest",
169
+		Needs:       []string{"build"},
170
+		Status:      actionsdb.WorkflowJobStatusCompleted,
171
+		Conclusion:  actionsdb.CheckConclusionFailure,
172
+		StartedAt:   now.Add(-14 * time.Minute),
173
+		CompletedAt: now.Add(-10 * time.Minute),
174
+	})
175
+	f.insertWorkflowStep(t, workflowStepFixture{
176
+		JobID:       buildID,
177
+		StepIndex:   0,
178
+		StepName:    "Checkout",
179
+		UsesAlias:   "actions/checkout@v4",
180
+		Status:      actionsdb.WorkflowStepStatusCompleted,
181
+		Conclusion:  actionsdb.CheckConclusionSuccess,
182
+		CompletedAt: now.Add(-18 * time.Minute),
183
+	})
184
+	f.insertWorkflowStep(t, workflowStepFixture{
185
+		JobID:       testID,
186
+		StepIndex:   0,
187
+		RunCommand:  "go test ./...",
188
+		Status:      actionsdb.WorkflowStepStatusCompleted,
189
+		Conclusion:  actionsdb.CheckConclusionFailure,
190
+		CompletedAt: now.Add(-10 * time.Minute),
191
+	})
192
+
193
+	resp := httptest.NewRecorder()
194
+	req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/7", nil)
195
+	f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
196
+	if resp.Code != http.StatusOK {
197
+		t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
198
+	}
199
+	body := resp.Body.String()
200
+	for _, want := range []string{
201
+		"RUN=CI:#7:push:alice:failure;",
202
+		"SUMMARY=2:2:1:0;",
203
+		"JOB=Build:success::ubuntu-latest;",
204
+		"STEP=Checkout:success:/alice/public-repo/actions/runs/7/jobs/0/steps/0;",
205
+		"JOB=Test:failure:build:ubuntu-latest;",
206
+		"STEP=go test ./...:failure:/alice/public-repo/actions/runs/7/jobs/1/steps/0;",
207
+	} {
208
+		if !strings.Contains(body, want) {
209
+			t.Fatalf("body missing %q in %s", want, body)
210
+		}
211
+	}
212
+}
213
+
214
+func TestRepoActionRunStatusRendersPollingFragment(t *testing.T) {
215
+	t.Parallel()
216
+	f := newRepoFixture(t)
217
+	now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
218
+	f.insertWorkflowRun(t, workflowRunFixture{
219
+		RunIndex:      8,
220
+		WorkflowFile:  ".shithub/workflows/deploy.yml",
221
+		WorkflowName:  "Deploy",
222
+		HeadRef:       "trunk",
223
+		Event:         actionsdb.WorkflowRunEventWorkflowDispatch,
224
+		Status:        actionsdb.WorkflowRunStatusRunning,
225
+		ActorUserID:   f.owner.ID,
226
+		CreatedOffset: -5 * time.Minute,
227
+		StartedOffset: -4 * time.Minute,
228
+	}, now)
229
+
230
+	resp := httptest.NewRecorder()
231
+	req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/8/status", nil)
232
+	f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
233
+	if resp.Code != http.StatusOK {
234
+		t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
235
+	}
236
+	want := "STATUS=running:false:/alice/public-repo/actions/runs/8/status;"
237
+	if body := resp.Body.String(); !strings.Contains(body, want) {
238
+		t.Fatalf("status fragment missing %q in %s", want, body)
239
+	}
240
+}
241
+
242
+func TestRepoActionStepLogRendersSQLChunks(t *testing.T) {
243
+	t.Parallel()
244
+	f := newRepoFixture(t)
245
+	now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
246
+	runID := f.insertWorkflowRun(t, workflowRunFixture{
247
+		RunIndex:      9,
248
+		WorkflowFile:  ".shithub/workflows/ci.yml",
249
+		WorkflowName:  "CI",
250
+		HeadRef:       "trunk",
251
+		Event:         actionsdb.WorkflowRunEventPush,
252
+		Status:        actionsdb.WorkflowRunStatusRunning,
253
+		ActorUserID:   f.owner.ID,
254
+		CreatedOffset: -5 * time.Minute,
255
+		StartedOffset: -4 * time.Minute,
256
+	}, now)
257
+	jobID := f.insertWorkflowJob(t, workflowJobFixture{
258
+		RunID:     runID,
259
+		JobIndex:  0,
260
+		JobKey:    "build",
261
+		JobName:   "Build",
262
+		RunsOn:    "ubuntu-latest",
263
+		Status:    actionsdb.WorkflowJobStatusRunning,
264
+		StartedAt: now.Add(-4 * time.Minute),
265
+	})
266
+	stepID := f.insertWorkflowStep(t, workflowStepFixture{
267
+		JobID:      jobID,
268
+		StepIndex:  0,
269
+		StepName:   "Run tests",
270
+		RunCommand: "go test ./...",
271
+		Status:     actionsdb.WorkflowStepStatusRunning,
272
+		StartedAt:  now.Add(-3 * time.Minute),
273
+	})
274
+	f.insertStepLogChunk(t, stepID, 0, "hello\n")
275
+	f.insertStepLogChunk(t, stepID, 1, "world\n")
276
+
277
+	resp := httptest.NewRecorder()
278
+	req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/9/jobs/0/steps/0", nil)
279
+	f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
280
+	if resp.Code != http.StatusOK {
281
+		t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
282
+	}
283
+	body := resp.Body.String()
284
+	for _, want := range []string{
285
+		"STEPLOG=Build:Run tests:SQL chunks::false;",
286
+		"LOG=hello\nworld\n;",
287
+	} {
288
+		if !strings.Contains(body, want) {
289
+			t.Fatalf("body missing %q in %s", want, body)
290
+		}
291
+	}
292
+}
293
+
294
+func TestRepoActionStepLogRendersArchivedObject(t *testing.T) {
295
+	t.Parallel()
296
+	f := newRepoFixture(t)
297
+	now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
298
+	runID := f.insertWorkflowRun(t, workflowRunFixture{
299
+		RunIndex:      10,
300
+		WorkflowFile:  ".shithub/workflows/ci.yml",
301
+		WorkflowName:  "CI",
302
+		HeadRef:       "trunk",
303
+		Event:         actionsdb.WorkflowRunEventPush,
304
+		Status:        actionsdb.WorkflowRunStatusCompleted,
305
+		Conclusion:    actionsdb.CheckConclusionSuccess,
306
+		ActorUserID:   f.owner.ID,
307
+		CreatedOffset: -5 * time.Minute,
308
+		StartedOffset: -4 * time.Minute,
309
+		DoneOffset:    -1 * time.Minute,
310
+	}, now)
311
+	jobID := f.insertWorkflowJob(t, workflowJobFixture{
312
+		RunID:       runID,
313
+		JobIndex:    0,
314
+		JobKey:      "build",
315
+		JobName:     "Build",
316
+		RunsOn:      "ubuntu-latest",
317
+		Status:      actionsdb.WorkflowJobStatusCompleted,
318
+		Conclusion:  actionsdb.CheckConclusionSuccess,
319
+		StartedAt:   now.Add(-4 * time.Minute),
320
+		CompletedAt: now.Add(-1 * time.Minute),
321
+	})
322
+	stepID := f.insertWorkflowStep(t, workflowStepFixture{
323
+		JobID:       jobID,
324
+		StepIndex:   0,
325
+		StepName:    "Archive",
326
+		RunCommand:  "printf archived",
327
+		Status:      actionsdb.WorkflowStepStatusCompleted,
328
+		Conclusion:  actionsdb.CheckConclusionSuccess,
329
+		StartedAt:   now.Add(-3 * time.Minute),
330
+		CompletedAt: now.Add(-1 * time.Minute),
331
+	})
332
+	key := "actions/runs/" + strconv.FormatInt(runID, 10) + "/jobs/" + strconv.FormatInt(jobID, 10) + "/steps/" + strconv.FormatInt(stepID, 10) + ".log"
333
+	if _, err := f.objectStore.Put(context.Background(), key, bytes.NewReader([]byte("archived\n")), storage.PutOpts{ContentType: "text/plain; charset=utf-8"}); err != nil {
334
+		t.Fatalf("put log object: %v", err)
335
+	}
336
+	if _, err := actionsdb.New().UpdateWorkflowStepLogObject(context.Background(), f.pool, actionsdb.UpdateWorkflowStepLogObjectParams{
337
+		LogObjectKey: pgtype.Text{String: key, Valid: true},
338
+		LogByteCount: int64(len("archived\n")),
339
+		ID:           stepID,
340
+	}); err != nil {
341
+		t.Fatalf("update log object: %v", err)
342
+	}
343
+
344
+	resp := httptest.NewRecorder()
345
+	req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/10/jobs/0/steps/0", nil)
346
+	f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
347
+	if resp.Code != http.StatusOK {
348
+		t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
349
+	}
350
+	body := resp.Body.String()
351
+	for _, want := range []string{
352
+		"STEPLOG=Build:Archive:object storage:mem://actions/runs/",
353
+		"LOG=archived\n;",
354
+	} {
355
+		if !strings.Contains(body, want) {
356
+			t.Fatalf("body missing %q in %s", want, body)
357
+		}
358
+	}
359
+}
360
+
133361
 func (f *repoFixture) actionsMux(viewer middleware.CurrentUser) http.Handler {
134362
 	mux := chi.NewRouter()
135363
 	mux.Use(func(next http.Handler) http.Handler {
@@ -137,6 +365,9 @@ func (f *repoFixture) actionsMux(viewer middleware.CurrentUser) http.Handler {
137365
 			next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer)))
138366
 		})
139367
 	})
368
+	mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", f.handlers.repoActionStepLog)
369
+	mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", f.handlers.repoActionRunStatus)
370
+	mux.Get("/{owner}/{repo}/actions/runs/{runIndex}", f.handlers.repoActionRun)
140371
 	mux.Get("/{owner}/{repo}/actions", f.handlers.repoTabActions)
141372
 	return mux
142373
 }
@@ -153,10 +384,15 @@ type workflowRunFixture struct {
153384
 	CreatedOffset time.Duration
154385
 	StartedOffset time.Duration
155386
 	DoneOffset    time.Duration
387
+	RepoID        int64
156388
 }
157389
 
158
-func (f *repoFixture) insertWorkflowRun(t *testing.T, fx workflowRunFixture, base time.Time) {
390
+func (f *repoFixture) insertWorkflowRun(t *testing.T, fx workflowRunFixture, base time.Time) int64 {
159391
 	t.Helper()
392
+	repoID := fx.RepoID
393
+	if repoID == 0 {
394
+		repoID = f.publicRepo.ID
395
+	}
160396
 	createdAt := base.Add(fx.CreatedOffset)
161397
 	startedAt := pgtype.Timestamptz{}
162398
 	completedAt := pgtype.Timestamptz{}
@@ -170,7 +406,8 @@ func (f *repoFixture) insertWorkflowRun(t *testing.T, fx workflowRunFixture, bas
170406
 	if fx.Conclusion != "" {
171407
 		conclusion = actionsdb.NullCheckConclusion{CheckConclusion: fx.Conclusion, Valid: true}
172408
 	}
173
-	_, err := f.pool.Exec(context.Background(), `
409
+	var id int64
410
+	err := f.pool.QueryRow(context.Background(), `
174411
 		INSERT INTO workflow_runs (
175412
 			repo_id, run_index, workflow_file, workflow_name,
176413
 			head_sha, head_ref, event, event_payload, actor_user_id,
@@ -179,8 +416,9 @@ func (f *repoFixture) insertWorkflowRun(t *testing.T, fx workflowRunFixture, bas
179416
 			$1, $2, $3, $4,
180417
 			$5, $6, $7, '{}'::jsonb, $8,
181418
 			$9, $10, $11, $12, $13, $14
182
-		)`,
183
-		f.publicRepo.ID,
419
+		)
420
+		RETURNING id`,
421
+		repoID,
184422
 		fx.RunIndex,
185423
 		fx.WorkflowFile,
186424
 		fx.WorkflowName,
@@ -194,12 +432,166 @@ func (f *repoFixture) insertWorkflowRun(t *testing.T, fx workflowRunFixture, bas
194432
 		completedAt,
195433
 		createdAt,
196434
 		createdAt,
197
-	)
435
+	).Scan(&id)
198436
 	if err != nil {
199437
 		t.Fatalf("insert workflow run %d: %v", fx.RunIndex, err)
200438
 	}
439
+	return id
201440
 }
202441
 
203442
 func strconvDigit(n int64) string {
204443
 	return strconv.FormatInt(n%10, 10)
205444
 }
445
+
446
+type workflowJobFixture struct {
447
+	RunID       int64
448
+	JobIndex    int32
449
+	JobKey      string
450
+	JobName     string
451
+	RunsOn      string
452
+	Needs       []string
453
+	Status      actionsdb.WorkflowJobStatus
454
+	Conclusion  actionsdb.CheckConclusion
455
+	StartedAt   time.Time
456
+	CompletedAt time.Time
457
+}
458
+
459
+func (f *repoFixture) insertWorkflowJob(t *testing.T, fx workflowJobFixture) int64 {
460
+	t.Helper()
461
+	status := fx.Status
462
+	if status == "" {
463
+		status = actionsdb.WorkflowJobStatusQueued
464
+	}
465
+	conclusion := actionsdb.NullCheckConclusion{}
466
+	if fx.Conclusion != "" {
467
+		conclusion = actionsdb.NullCheckConclusion{CheckConclusion: fx.Conclusion, Valid: true}
468
+	}
469
+	startedAt := pgtype.Timestamptz{}
470
+	if !fx.StartedAt.IsZero() {
471
+		startedAt = pgtype.Timestamptz{Time: fx.StartedAt, Valid: true}
472
+	}
473
+	completedAt := pgtype.Timestamptz{}
474
+	if !fx.CompletedAt.IsZero() {
475
+		completedAt = pgtype.Timestamptz{Time: fx.CompletedAt, Valid: true}
476
+	}
477
+	needs := fx.Needs
478
+	if needs == nil {
479
+		needs = []string{}
480
+	}
481
+	runnerID := pgtype.Int8{}
482
+	if status == actionsdb.WorkflowJobStatusRunning || status == actionsdb.WorkflowJobStatusCompleted {
483
+		runnerID = pgtype.Int8{Int64: f.insertWorkflowRunner(t), Valid: true}
484
+	}
485
+	var id int64
486
+	err := f.pool.QueryRow(context.Background(), `
487
+		INSERT INTO workflow_jobs (
488
+			run_id, job_index, job_key, job_name, runs_on, needs_jobs,
489
+			runner_id, status, conclusion, started_at, completed_at
490
+		) VALUES (
491
+			$1, $2, $3, $4, $5, $6,
492
+			$7, $8, $9, $10, $11
493
+		)
494
+		RETURNING id`,
495
+		fx.RunID,
496
+		fx.JobIndex,
497
+		fx.JobKey,
498
+		fx.JobName,
499
+		fx.RunsOn,
500
+		needs,
501
+		runnerID,
502
+		status,
503
+		conclusion,
504
+		startedAt,
505
+		completedAt,
506
+	).Scan(&id)
507
+	if err != nil {
508
+		t.Fatalf("insert workflow job %s: %v", fx.JobKey, err)
509
+	}
510
+	return id
511
+}
512
+
513
+func (f *repoFixture) insertWorkflowRunner(t *testing.T) int64 {
514
+	t.Helper()
515
+	var id int64
516
+	err := f.pool.QueryRow(context.Background(), `
517
+		INSERT INTO workflow_runners (name, labels, status)
518
+		VALUES ($1, ARRAY['ubuntu-latest']::text[], 'busy')
519
+		RETURNING id`,
520
+		"runner-"+strconv.FormatInt(time.Now().UnixNano(), 10),
521
+	).Scan(&id)
522
+	if err != nil {
523
+		t.Fatalf("insert workflow runner: %v", err)
524
+	}
525
+	return id
526
+}
527
+
528
+type workflowStepFixture struct {
529
+	JobID       int64
530
+	StepIndex   int32
531
+	StepName    string
532
+	RunCommand  string
533
+	UsesAlias   string
534
+	Status      actionsdb.WorkflowStepStatus
535
+	Conclusion  actionsdb.CheckConclusion
536
+	StartedAt   time.Time
537
+	CompletedAt time.Time
538
+}
539
+
540
+func (f *repoFixture) insertWorkflowStep(t *testing.T, fx workflowStepFixture) int64 {
541
+	t.Helper()
542
+	status := fx.Status
543
+	if status == "" {
544
+		status = actionsdb.WorkflowStepStatusQueued
545
+	}
546
+	runCommand := fx.RunCommand
547
+	if runCommand == "" && fx.UsesAlias == "" {
548
+		runCommand = "true"
549
+	}
550
+	conclusion := actionsdb.NullCheckConclusion{}
551
+	if fx.Conclusion != "" {
552
+		conclusion = actionsdb.NullCheckConclusion{CheckConclusion: fx.Conclusion, Valid: true}
553
+	}
554
+	startedAt := pgtype.Timestamptz{}
555
+	if !fx.StartedAt.IsZero() {
556
+		startedAt = pgtype.Timestamptz{Time: fx.StartedAt, Valid: true}
557
+	}
558
+	completedAt := pgtype.Timestamptz{}
559
+	if !fx.CompletedAt.IsZero() {
560
+		completedAt = pgtype.Timestamptz{Time: fx.CompletedAt, Valid: true}
561
+	}
562
+	var id int64
563
+	err := f.pool.QueryRow(context.Background(), `
564
+		INSERT INTO workflow_steps (
565
+			job_id, step_index, step_name, run_command, uses_alias,
566
+			status, conclusion, started_at, completed_at
567
+		) VALUES (
568
+			$1, $2, $3, $4, $5,
569
+			$6, $7, $8, $9
570
+		)
571
+		RETURNING id`,
572
+		fx.JobID,
573
+		fx.StepIndex,
574
+		fx.StepName,
575
+		runCommand,
576
+		fx.UsesAlias,
577
+		status,
578
+		conclusion,
579
+		startedAt,
580
+		completedAt,
581
+	).Scan(&id)
582
+	if err != nil {
583
+		t.Fatalf("insert workflow step %d: %v", fx.StepIndex, err)
584
+	}
585
+	return id
586
+}
587
+
588
+func (f *repoFixture) insertStepLogChunk(t *testing.T, stepID int64, seq int32, chunk string) {
589
+	t.Helper()
590
+	if _, err := actionsdb.New().AppendStepLogChunk(context.Background(), f.pool, actionsdb.AppendStepLogChunkParams{
591
+		StepID: stepID,
592
+		Seq:    seq,
593
+		Chunk:  []byte(chunk),
594
+	}); err != nil {
595
+		t.Fatalf("insert step log chunk %d: %v", seq, err)
596
+	}
597
+}
internal/web/handlers/repo/repo_test.gomodified
@@ -50,6 +50,7 @@ const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
5050
 type repoFixture struct {
5151
 	pool        *pgxpool.Pool
5252
 	handlers    *Handlers
53
+	objectStore storage.ObjectStore
5354
 	owner       usersdb.User
5455
 	stranger    usersdb.User
5556
 	publicRepo  reposdb.Repo
@@ -70,12 +71,14 @@ func newRepoFixture(t *testing.T) *repoFixture {
7071
 	if err != nil {
7172
 		t.Fatalf("render.New: %v", err)
7273
 	}
74
+	objectStore := storage.NewMemoryStore()
7375
 
7476
 	h, err := New(Deps{
7577
 		Logger:      slog.New(slog.NewTextHandler(io.Discard, nil)),
7678
 		Render:      rr,
7779
 		Pool:        pool,
7880
 		RepoFS:      rfs,
81
+		ObjectStore: objectStore,
7982
 		Audit:       audit.NewRecorder(),
8083
 		Limiter:     throttle.NewLimiter(),
8184
 	})
@@ -124,6 +127,7 @@ func newRepoFixture(t *testing.T) *repoFixture {
124127
 	return &repoFixture{
125128
 		pool:        pool,
126129
 		handlers:    h,
130
+		objectStore: objectStore,
127131
 		owner:       owner,
128132
 		stranger:    stranger,
129133
 		publicRepo:  pubRepo,
@@ -145,6 +149,10 @@ func minimalTemplatesFS() fstest.MapFS {
145149
 		"errors/500.html":              {Data: body},
146150
 		"repo/new.html":                {Data: []byte(`{{ define "page" }}OWNERS={{ range .Owners }}{{ .Token }}:{{ if eq .Token $.Form.Owner }}selected{{ end }}:{{ .Slug }};{{ end }}{{ end }}`)},
147151
 		"repo/actions.html":            {Data: []byte(`{{ define "page" }}COUNT={{ .RunCount }};FILTERED={{ .FilteredRunCount }};PAGE={{ .Pagination.ResultText }};{{ range .Workflows }}WF={{ .Name }}:{{ .Count }}:{{ .Active }};{{ end }}{{ range .Runs }}RUN={{ .Title }}:#{{ .RunIndex }}:{{ .Event }}:{{ .HeadRef }}:{{ .ActorUsername }}:{{ .StateClass }};{{ end }}{{ end }}`)},
152
+		"repo/_action_run_status.html": {Data: []byte(`{{ define "action-run-status" }}STATUS={{ .Run.StateClass }}:{{ .Run.IsTerminal }}:{{ .Run.StatusHref }};{{ end }}`)},
153
+		"repo/action_run.html":         {Data: []byte(`{{ define "page" }}RUN={{ .Run.Title }}:#{{ .Run.RunIndex }}:{{ .Run.Event }}:{{ .Run.ActorUsername }}:{{ .Run.StateClass }};SUMMARY={{ .Run.JobCount }}:{{ .Run.CompletedCount }}:{{ .Run.FailureCount }}:{{ .Run.ArtifactCount }};{{ range .Run.Jobs }}JOB={{ .Name }}:{{ .StateClass }}:{{ .NeedsText }}:{{ .RunsOn }};{{ range .Steps }}STEP={{ .Name }}:{{ .StateClass }}:{{ .LogHref }};{{ end }}{{ end }}{{ end }}`)},
154
+		"repo/action_run_status.html":  {Data: []byte(`{{ define "page" }}{{ template "action-run-status" . }}{{ end }}`)},
155
+		"repo/action_step_log.html":    {Data: []byte(`{{ define "page" }}STEPLOG={{ .Log.Job.Name }}:{{ .Log.Step.Name }}:{{ .Log.LogSource }}:{{ .Log.DownloadURL }}:{{ .Log.LogTruncated }};{{ with .Log.LogError }}ERROR={{ . }};{{ end }}LOG={{ .Log.LogText }};{{ end }}`)},
148156
 		"repo/settings_secrets.html":   {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ range .Secrets }}SECRET={{ .Name }};{{ end }}{{ range .Variables }}VAR={{ .Name }}:{{ .Value }};{{ end }}{{ end }}`)},
149157
 	}
150158
 }
internal/web/render/render_test.gomodified
@@ -75,6 +75,37 @@ func TestRenderFragmentExecutesPageWithoutLayout(t *testing.T) {
7575
 	}
7676
 }
7777
 
78
+func TestFlagHelperSupportsOptionalLayoutToggles(t *testing.T) {
79
+	t.Parallel()
80
+	fsys := fstest.MapFS{
81
+		"_layout.html": &fstest.MapFile{Data: []byte(
82
+			`{{ define "layout" }}<html>{{ if flag . "UseHTMX" }}HTMX{{ end }}{{ template "page" . }}</html>{{ end }}`,
83
+		)},
84
+		"page.html": &fstest.MapFile{Data: []byte(`{{ define "page" }}body{{ end }}`)},
85
+	}
86
+	r, err := New(fsys, Options{})
87
+	if err != nil {
88
+		t.Fatalf("New: %v", err)
89
+	}
90
+	var buf bytes.Buffer
91
+	type typedPageData struct {
92
+		Title string
93
+	}
94
+	if err := r.Render(&buf, "page", typedPageData{Title: "typed"}); err != nil {
95
+		t.Fatalf("render typed data without flag: %v", err)
96
+	}
97
+	if strings.Contains(buf.String(), "HTMX") {
98
+		t.Fatalf("absent typed flag rendered HTMX: %q", buf.String())
99
+	}
100
+	buf.Reset()
101
+	if err := r.Render(&buf, "page", map[string]any{"UseHTMX": true}); err != nil {
102
+		t.Fatalf("render map data with flag: %v", err)
103
+	}
104
+	if !strings.Contains(buf.String(), "HTMX") {
105
+		t.Fatalf("map flag did not render HTMX: %q", buf.String())
106
+	}
107
+}
108
+
78109
 // Regression test for the inbound deferral from S30 dogfood: a partial
79110
 // at `profile/_tabs.html` that defines `{{ define "tabs" }}` was
80111
 // silently registered as an unparsed page. A page that called